Architectural Styles and Principles#
VS Code adopts a layered and modular approach to software architecture for its core package. The core package is designed to be extended using the Extension API, which provides almost all the features available in VS Code. This architecture style helps to separate different responsibilities of the code editor for implementation and testing, making it clear for developers where they should implement specific parts of their features.
Each layer communicates with the layer directly below it in the architecture. The base layer communicates with the platform layer through the platform-specific dependencies defined in the base layer, and the platform layer communicates with the extension layer through the Extension API. In the following sections, we will mainly focus on the analysis of base, platform and extension layer of VS Code.
Base Layer#
The base layer is built on top of the ElectronJS framework and provides general utilities and user interface building blocks that can be used in any other layer. These components are built using web technologies such as HTML, CSS, and JavaScript. The base layer also provides file system utilities that allow VS Code to interact with the file system, such as reading and writing files, navigating directories, and creating new files and directories. More importantly, this layer includes the extension management system that allows users to install, manage, and update extensions. It is built using web technologies and the ElectronJS APIs, and provides an intuitive interface for managing extensions.
The base package includes several sub-packages, such as common, electron-browser, and node. Each of these sub-packages contains modules that are specific to that package. The common package includes modules that are used across different platforms and environments. This includes modules for handling text buffers, working with files and directories, and providing basic UI components like buttons and menus. The electron-browser package includes modules that are specific to the ElectronJS platform, which is used to run VS Code on desktops. This includes modules for handling UI elements, managing web views, and interacting with the ElectronJS APIs. The node package includes modules that are specific to the NodeJS runtime, which is used to run VS Code on servers and in other environments. This includes modules for working with processes, files, and directories, as well as providing low-level networking capabilities.
Overall, the base layer interacts with other layers through the extension API, the user interface to provide the core functionality that the other layers build upon to create the full-featured code editor.
Platform Layer#
The platform layer of VS Code is the layer responsible for providing all services through dependency injection, which is a type of connectors. The platform layer provides the basic infrastructure and common services for the different modules and components of the software. This approach allows modules to be more loosely coupled and promotes modularity and extensibility. Also, the platform layer provides an extension service that allows extensions to register themselves with the platform and interact with other services and modules.
The platform package includes several sub-packages, such as services, configuration, and workspace. The services package includes modules that provide services to the rest of the application, such as the text model service, which handles the text editing functionality, and the file service, which provides access to the file system. The configuration package includes modules that handle the configuration of the editor, such as the language configuration service, which provides information about the syntax of a file, and the settings service, which handles the user's settings. The workspace package includes modules that handle the workspace-related functionality, such as the workspace service, which provides information about the files in the workspace, and the search service, which provides a way to search for files. The code also uses an event system to allow different parts of the application to communicate with each other. This makes it easy to add new functionality to the application and to respond to user input.
Extension Layer#
The extension layer of VS Code provides the ability to extend the functionality of the software through third-party extensions. This layer is built on top of the platform layer and allows developers to create their own modules and services that can interact with the rest of the software. The extension API provides a rich set of features and methods for creating and managing extensions, while the language support, editor customization, and debugging functionality provide additional capabilities for creating a more personalized development environment. With the extension layer, developers can create new features and services that can integrate seamlessly with the rest of the software.
The extension package includes several sub-packages, such as commands, languages, and views. The commands package includes modules that provide commands that can be executed in the editor. The languages package includes modules that provide language support for different programming languages. The views package includes modules that provide views and editors for different file types or functionalities. It is also notable that the code uses an API as a connector to allow extensions to interact with the base and platform layer and access the functionalities they provide. When an extension is installed, it is loaded into a separate process from the main editor process. This allows the extension to run in a separate context and not interfere with the core editor functionality. The extension process communicates with the main editor process through a communication channel provided by the Extension API.
Basic Principles#
The architectural principles of VS Code are centered around the concepts of modularity, extensibility, and flexibility.
-
Modularity: The architecture of VS Code is designed to be modular, with each layer consisting of multiple independent modules. This allows for greater flexibility and ease of maintenance, as modules can be added, removed, or replaced without affecting the overall system.
-
Extensibility: VS Code is designed to be highly extensible, with the use of web technologies and the VS Code Extension API. This allows developers to easily add new features and functionality to the editor through the use of extensions.
-
Flexibility: The design of VS Code is also flexible, with the ability to support multiple programming languages, development environments, and platforms. It is built on a combination of web technologies and native code, allowing it to run on multiple operating systems and be easily customized to fit the needs of different developers.
Other architectural principles that are employed in the design of VS Code include separation of concerns, modularity, and layered architecture. These principles help to ensure that the system is easy to understand, maintain, and extend.
Electron#
- Using web technology to write UI and running it using the Chrome browser engine
- Using NodeJS to operate the file system and initiate network requests
- Using NodeJS C++ Addon to call the native API of the operating system
In Electron, there are two types of processes: the main process and renderer processes:
- Main Process: The main process is responsible for creating browser windows and managing various aspects of the application such as system-level events, menus, and dialog boxes. It runs in a Node.js environment and has access to native operating system APIs. The main process also provides the IPC mechanisms for communication between the different parts of the application.
- Renderer Process: Each window in an Electron.js application runs its own renderer process, which is responsible for rendering the application's user interface using Chromium. The renderer processes run in a sandboxed environment and have limited access to the native operating system APIs. They communicate with the main process using the IPC mechanism provided by Electron.js.
Therefore, when not in the same process, cross-process communication is involved. In Electron, the following methods can be used to achieve communication between the main process and renderer processes.
- Communication can be achieved between the backend of the application (ipcMain) and frontend application windows (ipcRenderer) through the IPC method using the ipcMain and ipcRenderer modules, which are event triggers for inter-process communication.
- Remote procedure call (RPC) communication can be achieved using the remote module.
Each object (including functions) returned by the remote module represents an object (called a remote object or remote function) in the main process. When you call a method of a remote object, call a remote function, or create a new object using a remote constructor function, it actually sends synchronous inter-process messages.
Any state sharing between the backend and frontend of an Electron application (and vice versa) is done through the ipcMain and ipcRenderer modules. This way, the JavaScript context of the main process and renderer processes remain independent, but data can be explicitly transferred between them.
Process Structure of VSCode#
VSCode has a multi-process architecture and is mainly composed of the following processes upon startup:
Background Process#
The background process serves as the entry point for VSCode and is responsible for managing the editor's lifecycle, inter-process communication, automatic updates, and menu management.
When we start VSCode, the background process is the first to start. It reads various configuration information and historical records, integrates this information with the HTML main file path of the main window UI into a URL, and starts a browser window to display the editor's UI. The background process always monitors the status of UI processes, and when all UI processes are closed, the entire editor exits.
In addition, the background process also opens a local socket. When a new VSCode process starts, it tries to connect to this socket and passes the startup parameter information to it so that the existing VSCode can execute relevant actions. This ensures the uniqueness of VSCode and avoids problems caused by opening multiple folders.
Editor Window#
The editor window process is responsible for displaying the entire UI, which is what we see on the screen. The UI is entirely written in HTML, and there is not much to introduce in this regard.
Node.js Asynchronous IO#
Reading and saving project files is completed by the NodeJS API in the main process. Because all operations are asynchronous, even with relatively large files, they will not block the UI. The IO and UI are in the same process and use asynchronous operations, which ensures the performance of IO and the responsiveness of the UI.
Extension Process#
Each UI window will start a NodeJS sub-process as the host process for extensions. All extensions will run together in this process. The main purpose of this design is to avoid complex extension systems from blocking the UI response. In most operating systems, the refresh rate of the display is 60 frames per second, which means that the application needs to complete all calculations and UI refresh within 16.7 milliseconds. The speed of HTML DOM has always been criticized, leaving less time for JavaScript. Therefore, to ensure the responsiveness of the UI, all instructions must be completed within such a short time frame. However, in reality, it is difficult to complete tasks such as coloring ten thousand lines of code within such a short time. Therefore, these time-consuming tasks need to be moved to other threads or processes. After the task is completed, the results can be returned to the UI process. However, putting extensions in a separate process also has obvious drawbacks. Because it is a separate process, not a UI process, there is no direct access to the DOM tree. Changing the UI efficiently in real-time becomes difficult, and there are almost no APIs for extending the UI in VSCode's extension system.
Debugging Process#
The debugger extension differs slightly from ordinary extensions. It does not run in the extension process but is opened separately by the UI in a new process every time it is debugged.
Searching Process#
Search is a very time-consuming task, and VSCode also uses a separate process to implement this feature, ensuring the efficiency of the main window. Distributing time-consuming tasks to multiple processes effectively guarantees the responsiveness of the main process.
The multiple processes in VSCode can be categorized into the following three types:
- Main Process: The main process is responsible for managing the overall lifecycle of the application, including UI management, extension management, and communication with renderer processes. It provides an abstraction layer that shields the underlying platform-specific details from the rest of the application.
- Renderer Processes: There are two types of renderer processes in VSCode: the window renderer and the webview renderer. The window renderer is responsible for rendering the main user interface of the application, including the editor area, sidebar, and toolbar. The webview renderer, on the other hand, is used to render embedded web content like custom views and panels.
- Extension Processes: Extensions in VSCode run in separate processes from the main application to prevent them from blocking the UI thread or interfering with other extensions. Each extension runs in its own isolated process, which communicates with the main process through IPC mechanisms.
Overall, the multi-process architecture of VSCode allows for better performance, improved security, and more reliable operation. By separating different tasks into different processes, VSCode can handle complex operations without impacting the overall responsiveness of the application. Additionally, the use of extension processes ensures that extensions can run safely without negatively affecting the user's experience.
Inter-Process Communication (IPC)#
Protocol#
In IPC communication, protocols are the most fundamental. Just like how communication between people requires an agreed-upon way of communicating (language, sign language), in IPC, protocols can be seen as agreements.
As a communication capability, the most basic protocol scope includes sending and receiving messages.
export interface IMessagePassingProtocol {
send(buffer: VSBuffer): void; // Sending messages in Uint8Array format over a lower-level communication channel
onMessage: Event<VSBuffer>; // Triggering the upper-level callback function when a message is received on the lower-level communication channel.
}
As for the specific protocol content, it may include connection, disconnection, events, and so on.
export class Protocol implements IMessagePassingProtocol {
constructor(private sender: Sender, readonly onMessage: Event<VSBuffer>) { }
// send message
send(message: VSBuffer): void {
try {
this.sender.send('vscode:message', message.buffer);
} catch (e) {
// systems are going down
}
}
// close connection
dispose(): void {
this.sender.send('vscode:disconnect', null);
}
}
IPC is essentially the ability to send and receive information, and in order to communicate accurately, the client and server need to be on the same channel.
Channel#
As for a channel, it has two functions: one is to make a call, and the other is to listen.
/**
* IChannel is an abstraction of a set of commands
* A call always returns a Promise with at most a single return value
*/
export interface IChannel {
call<T>(command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(event: string, arg?: any): Event<T>;
}
/**
* An `IServerChannel` is the counter part to `IChannel`,
* on the server-side. You should implement this interface
* if you'd like to handle remote promises or events.
*/
export interface IServerChannel<TContext = string> {
call<T>(ctx: TContext, command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}
Client and Server#
Generally speaking, the distinction between the client and server mainly lies in the fact that the initiating end of the connection is the client, while the endpoint being connected to is the server. In VSCode, the main process is the server, providing various channels and services for subscription; the renderer process is the client, listening to various channels/services provided by the server and can also send messages to the server (such as connecting, subscribing/listening, leaving, etc.)
Both the client and server need the ability to send and receive messages to communicate properly.
In VSCode, the client includes ChannelClient
and IPCClient
. ChannelClient
only handles the most basic channel-related functions, including:
- Obtaining a channel with
getChannel
. - Sending channel requests with
sendRequest
. - Receiving request results and processing them with
onResponse/onBuffer
.
// client
export class ChannelClient implements IChannelClient, IDisposable {
getChannel<T extends IChannel>(channelName: string): T {
const that = this;
return {
call(command: string, arg?: any, cancellationToken?: CancellationToken) {
return that.requestPromise(channelName, command, arg, cancellationToken);
},
listen(event: string, arg: any) {
return that.requestEvent(channelName, event, arg);
}
} as T;
}
private requestPromise(channelName: string, name: string, arg?: any, cancellationToken = CancellationToken.None): Promise<any> {}
private requestEvent(channelName: string, name: string, arg?: any): Event<any> {}
private sendRequest(request: IRawRequest): void {}
private send(header: any, body: any = undefined): void {}
private sendBuffer(message: VSBuffer): void {}
private onBuffer(message: VSBuffer): void {}
private onResponse(response: IRawResponse): void {}
private whenInitialized(): Promise<void> {}
dispose(): void {}
}
Similarly, the server includes ChannelServer
and IPCServer
, where ChannelServer
also only handles functions directly related to channels, including:
- Registering a channel with
registerChannel
. - Listening to client messages with
onRawMessage/onPromise/onEventListen
. - Processing client messages and returning request results with
sendResponse
.
// server
export class ChannelServer<TContext = string> implements IChannelServer<TContext>, IDisposable {
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
}
private sendResponse(response: IRawResponse): void {}
private send(header: any, body: any = undefined): void {}
private sendBuffer(message: VSBuffer): void {}
private onRawMessage(message: VSBuffer): void {}
private onPromise(request: IRawPromiseRequest): void {}
private onEventListen(request: IRawEventListenRequest): void {}
private disposeActiveRequest(request: IRawRequest): void {}
private collectPendingRequest(request: IRawPromiseRequest | IRawEventListenRequest): void {}
public dispose(): void {}
}
The main responsibilities of IChannelServer
include:
- Receiving messages from
protocol
- Processing messages based on their type
- Invoking the appropriate
IServerChannel
to handle a request - Sending responses back to the client
- Registering
IServerChannel
IChannelServer
directly listens to messages from protocol
, and then calls its own onRawMessage
method to handle the request. onRawMessage
will call other methods based on the type of the request. Taking Promise-based calls as an example, we can see that its core logic is to call the call
method of IServerChannel
.
private onRawMessage(message: VSBuffer): void {
const reader = new BufferReader(message);
const header = deserialize(reader);
const body = deserialize(reader);
const type = header[0] as RequestType;
switch (type) {
case RequestType.Promise:
if (this.logger) {
this.logger.logIncoming(message.byteLength, header[1], RequestInitiator.OtherSide, `${requestTypeToStr(type)}: ${header[2]}.${header[3]}`, body);
}
return this.onPromise({ type, id: header[1], channelName: header[2], name: header[3], arg: body });
// ...
}
}
private onPromise(request: IRawPromiseRequest): void {
const channel = this.channels.get(request.channelName);
let promise: Promise<any>;
try {
promise = channel.call(this.ctx, request.name, request.arg, cancellationTokenSource.token);
} catch (err) {
// ...
}
const id = request.id;
promise.then(data => {
this.sendResponse(<IRawResponse>{ id, data, type: ResponseType.PromiseSuccess });
this.activeRequests.delete(request.id);
}, err => {
// ...
});
}
Connection#
Now we have the channel-related client part ChannelClient
and the server part ChannelServer
, but they need to be connected in order to communicate. A connection (Connection
) is composed of ChannelClient
and ChannelServer
.
interface Connection<TContext> extends Client<TContext> {
readonly channelServer: ChannelServer<TContext>;
readonly channelClient: ChannelClient;
}
The establishment of a connection is handled by IPCServer
and IPCClient
. Specifically:
IPCClient
is based onChannelClient
and is responsible for simple one-to-one connections from the client to the server.IPCServer
is based onChannelServer
and is responsible for connections from the server to the client. Since a server can provide multiple services, there may be multiple connections.
export class IPCClient<TContext = string> implements IChannelClient, IChannelServer<TContext>, IDisposable {
private channelClient: ChannelClient;
private channelServer: ChannelServer<TContext>;
getChannel<T extends IChannel>(channelName: string): T {
return this.channelClient.getChannel(channelName) as T;
}
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channelServer.registerChannel(channelName, channel);
}
}
export class IPCServer<TContext = string> implements IChannelServer<TContext>, IRoutingChannelClient<TContext>, IConnectionHub<TContext>, IDisposable {
private channels = new Map<string, IServerChannel<TContext>>();
private _connections = new Set<Connection<TContext>>();
get connections(): Connection<TContext>[] {}
/**
* Retrieve a channel from a remote client.
* After going through the router, it can be specified which client to call and listen to/from.
* Otherwise, when making a call without a router, a random client will be chosen, and when listening without a router, every client will be listened to.
*/
getChannel<T extends IChannel>(channelName: string, router: IClientRouter<TContext>): T;
getChannel<T extends IChannel>(channelName: string, clientFilter: (client: Client<TContext>) => boolean): T;
getChannel<T extends IChannel>(channelName: string, routerOrClientFilter: IClientRouter<TContext> | ((client: Client<TContext>) => boolean)): T {}
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
this._connections.forEach(connection => {
connection.channelServer.registerChannel(channelName, channel);
});
}
}
Server#
- A service is the actual location where various business logic occurs.
IServerChannel
corresponds to a service and providescall
andlisten
methods forChannelServer
to call, which then invoke the corresponding service to execute various business logic. It is actually a wrapper for the service.IChannelServer
is responsible for listening to requests passed throughIMessagePassingProtocol
, then locating the correspondingIServerChannel
based on thechannelName
specified in the request and invoking it. It is also capable of returning the execution result to the client.- IPCServer provides a set of methods for registering and retrieving IServerChannel, and is capable of selecting the client to communicate with through a routing mechanism.
IMessagePassingProtocol
is responsible for transmitting binary information in the form of Uint8Array and notifying the upper layer through events when a message is received.
Client#
- Business code refers to the code that implements the business logic and may call the methods provided by
IChannel
to initiate an IPC. IChannel
providescall
andlisten
methods for business code to initiate IPC.IPCClient
provides a set of methods for registering and retrievingIChannel
.IMessagePassingProtocol
has the same functionality as its counterpart on the server side.
RPC Protocol#
The second mechanism of VSCode IPC is based on RpcProtocol
and is used for communication between the renderer process and extension host process. If VSCode is running in a browser environment, it is used for communication between the main thread and extension host web worker.
For example, if an error occurs during the initialization of the host process, it will inform the renderer process. The code is as follows:
const mainThreadExtensions = rpcProtocol.getProxy(MainContext.MainThreadExtensionService);
const mainThreadErrors = rpcProtocol.getProxy(MainContext.MainThreadErrors);
errors.setUnexpectedErrorHandler(err => {
const data = errors.transformErrorForSerialization(err);
const extension = extensionErrors.get(err);
if (extension) {
mainThreadExtensions.$onExtensionRuntimeError(extension.identifier, data);
} else {
mainThreadErrors.$onUnexpectedError(data);
}
});
IPC occurs when calling the methods of mainThreadExtensions
or mainThreadError
.
The mechanism is shown in the following diagram:
Event System#
In Visual Studio Code, events are used extensively to facilitate communication and data exchange between different parts of the application. The event mechanism is based on the Observer pattern and allows objects in the application to subscribe to and receive notifications about changes or updates to a particular aspect of the application.
Here are some key features of the event mechanism in VSCode:
- Custom Events: VSCode provides a simple and flexible way to define custom events that can be emitted by objects in the application. An event is typically defined as a class that extends the
Event
base class, and contains any necessary data or arguments that will be passed to subscribers. - Event Emitters: Objects that produce events are known as "event emitters." These objects contain a list of registered subscribers, and emit events when certain conditions are met. For example, a text editor component might emit a "document saved" event when the user saves a file.
- Event Subscribers: Objects that want to receive notifications about events are known as "event subscribers." These objects register with an event emitter using the
on
method, which takes the name of the event and a callback function to handle the event. When an event is emitted, all registered subscribers will receive the event and its associated data. - Built-in Events: VSCode also includes a number of built-in events that provide notifications about various aspects of the application, such as workspace changes, window focus, and editor content changes. These events can be subscribed to using the
on
method of the relevant service or object.
// Here are some aspects of the lifecycle and configuration of an event emitter
export interface EmitterOptions {
onFirstListenerAdd?: Function;
onFirstListenerDidAdd?: Function;
onListenerDidAdd?: Function;
onLastListenerRemove?: Function;
leakWarningThreshold?: number;
}
export class Emitter<T> {
// some lifecycle methods and settings that can be passed in
constructor(options?: EmitterOptions) {}
// Allowing others to subscribe to events emitted by this emitter.
get event(): Event<T> {
// In this case, the relevant lifecycle methods will be called in the corresponding scenarios based on the passed-in settings.
}
// Triggering events to subscribers.
fire(event: T): void {}
// Cleaning up related listeners and queues.
dispose() {}
}
In VS Code, the usage of events mainly includes:
- Registering an event emitter.
- Providing defined events to the outside world.
- Triggering events to subscribers at specific times.
class WindowManager {
public static readonly INSTANCE = new WindowManager();
// Registering an event emitter.
private readonly _onDidChangeZoomLevel = new Emitter<number>();
// Retrieving the events that this emitter allows others to subscribe to.
public readonly onDidChangeZoomLevel: Event<number> = this._onDidChangeZoomLevel.event;
public setZoomLevel(zoomLevel: number, isTrusted: boolean): void {
if (this._zoomLevel === zoomLevel) {
return;
}
this._zoomLevel = zoomLevel;
// Triggering this event when zoomLevel changes.
this._onDidChangeZoomLevel.fire(this._zoomLevel);
}
}
// Providing a method for accessing the global instance externally.
export function onDidChangeZoomLevel(callback: (zoomLevel: number) => void): IDisposable {
return WindowManager.INSTANCE.onDidChangeZoomLevel(callback);
}
import { onDidChangeZoomLevel } from 'vs/base/browser/browser';
let zoomListener = onDidChangeZoomLevel(() => {});
const instance = new WindowManager(opts);
instance.onDidChangeZoomLevel(() => {});
export abstract class Disposable implements IDisposable {
// Using a Set to store registered event emitters.
private readonly _store = new DisposableStore();
constructor() {
trackDisposable(this);
}
// Handling event emitters.
public dispose(): void {
markTracked(this);
this._store.dispose();
}
// Registering an event emitter.
protected _register<T extends IDisposable>(t: T): T {
if ((t as unknown as Disposable) === this) {
throw new Error('Cannot register a disposable on itself!');
}
return this._store.add(t);
}
}
The Dispose pattern is primarily used for resource management. This includes releasing resources, such as memory, that are being held by an object through a method call.
export interface IDisposable {
dispose(): void;
}
export class DisposableStore implements IDisposable {
private _toDispose = new Set<IDisposable>();
private _isDisposed = false;
// Dispose all registered Disposables and mark them as disposed.
// All Disposables added in the future to this object will be disposed in the 'add' method.
public dispose(): void {
if (this._isDisposed) {
return;
}
markTracked(this);
this._isDisposed = true;
this.clear();
}
// Dispose all registered Disposables without marking them as disposed.
public clear(): void {
this._toDispose.forEach(item => item.dispose());
this._toDispose.clear();
}
// Disposable Add a Disposable.
public add<T extends IDisposable>(t: T): T {
markTracked(t);
if (this._isDisposed) {
// raise an error
} else {
this._toDispose.add(t);
}
return t;
}
}
export class Scrollable extends Disposable {
private _onScroll = this._register(new Emitter<ScrollEvent>());
public readonly onScroll: Event<ScrollEvent> = this._onScroll.event;
private _setState(newState: ScrollState): void {
const oldState = this._state;
if (oldState.equals(newState)) {
return;
}
this._state = newState;
// Trigger an event when the state changes.
this._onScroll.fire(this._state.createScrollEvent(oldState));
}
}
Dependency Injection#
In VS Code, there are many services that provide APIs for different modules to be called. Dependencies are declared in the constructor of a class with Decorator-annotated parameters. The caller does not need to explicitly instantiate this service, as these dependency services will be automatically created and passed to the caller when it is created. Different services can also depend on each other. This greatly reduces program coupling while improving maintainability.
This decoupling approach is called Dependency Injection and is one way to implement Inversion of Control. That is, the dependencies of an object (or entity), which may be other objects, are injected externally, avoiding the instantiation process of self-implemented dependencies within the object.
First, a class needs to be defined and its dependencies declared in its constructor.
class MyClass {
constructor(
@IAuthService private readonly authService: IAuthService,
@IStorageService private readonly storageService: IStorageService,
) {
}
}
The @IAuthService
and @IStorageService
in the constructor are two decorators, which belong to a proposal in JavaScript and are an experimental feature in TypeScript. They can be attached to class declarations, methods, accessors, and parameters. In this code, they are attached to the constructor parameters authService
and storageService
, which are parameter decorators. Parameter decorators will be called at runtime and passed three arguments:
- For static members, the class constructor is passed as the first argument, whereas for instance members, the prototype of the class is passed as the first argument.
- The name of the member.
- The index of the parameter in the function's parameter list.
The decorator and interface definition for a service usually look like this:
export const IAuthService = createDecorator<IAuthService>('AuthService');
export interface IAuthService {
readonly id: string;
readonly nickName: string;
readonly firstName: string;
readonly lastName: string;
requestService: IRequestService;
}
A service interface needs to have a concrete implementation, which can also depend on other services.
class AuthServiceImpl implements IAuthService {
constructor(
@IRequestService public readonly requestService IRequestService,
){
}
public async getUserInfo() {
const { id, nickName, firstName } = await getUserInfo();
this.id = id;
this.nickName = nickName;
this.firstName = firstName;
//...
}
}
You also need a service collection, which is used to hold a group of services and create a container from them.
export class ServiceCollection {
private _entries = new Map<ServiceIdentifier<any>, any>();
constructor(...entries: [ServiceIdentifier<any>, any][]) {
for (let [id, service] of entries) {
this.set(id, service);
}
}
set<T>(id: ServiceIdentifier<T>, instanceOrDescriptor: T | SyncDescriptor<T>): T | SyncDescriptor<T> {
const result = this._entries.get(id);
this._entries.set(id, instanceOrDescriptor);
return result;
}
forEach(callback: (id: ServiceIdentifier<any>, instanceOrDescriptor: any) => any): void {
this._entries.forEach((value, key) => callback(key, value));
}
has(id: ServiceIdentifier<any>): boolean {
return this._entries.has(id);
}
get<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> {
return this._entries.get(id);
}
}
As mentioned earlier, objects are automatically instantiated by the container. However, in VSCode, some services do not have any other dependencies (such as the logging service) and are only dependent on by other services, so they can be manually instantiated and registered with the container. In this example, AuthServiceImpl
depends on IRequestService
, which needs to be wrapped in a SyncDescriptor
and saved in the service collection.
const services = new ServiceCollection();
const logService = new LogService();
services.set(ILogService, logService);
services.set(IAuthService, new SyncDescriptor(AuthServiceImpl));
SyncDescriptor
is an object used to wrap a descriptor that needs to be instantiated by the container. It holds the constructor function of the object and its static parameters (which need to be directly passed to the constructor).
export class SyncDescriptor<T> {
readonly ctor: any;
readonly staticArguments: any[];
readonly supportsDelayedInstantiation: boolean;
constructor(ctor: new (...args: any[]) => T, staticArguments: any[] = [], supportsDelayedInstantiation: boolean = false) {
this.ctor = ctor; // constructor of service
this.staticArguments = staticArguments;
this.supportsDelayedInstantiation = supportsDelayedInstantiation;
}
}
Now we can create a container and register services with it. In VSCode, the container is InstantiationService
.
const instantiationService = new InstantiationService(services, true);
InstantiationService
is the core of Dependency Injection. After registering services with the container, we need to manually instantiate the program entry point. In VSCode, this is CodeApplication
. The container (instantiationService
) holds the dependency relationships between these objects, so CodeApplication
also needs to use the container to be instantiated.
// Here, the second and third parameters are static parameters of the CodeApplication constructor, which need to be manually passed in.
instantiationService.createInstance(CodeApplication, mainIpcServer, instanceEnvironment).startup();
You can also manually obtain a service instance by calling the instantiationService.invokeFunction
method and passing in a callback function. The callback function takes an accessor as its parameter, and when the specified service is obtained through the accessor, the container automatically analyzes its dependent services and instantiates them before returning the service instance.
instantiationService.invokeFunction(accessor => {
const logService = accessor.get(ILogService);
const authService = accessor.get(IAuthService);
});
The instantiationService
contains a member method called createChild
, which can create a child container. In order to better define dependency relationships, the child container can access service instances in the parent container, but the parent container cannot access instances in the child container. When a required service instance does not exist in the child container, it calls instantiationService._parent
to obtain a reference to the parent container and recursively searches for dependencies upwards.
Reference#
Electron (stephanosterburg.com)
VSCode 技术解析 | VSCode 技术解析与实践 (codeteenager.github.io)
Visual Studio Code / Egret Wing 技术架构:基础・Chen's Blog (imzc.me)
VSCode 源码解读:事件系统设计 | 被删的前端游乐场 (godbasin.github.io)
https://zhuanlan.zhihu.com/p/60228431
https://zhuanlan.zhihu.com/p/96041706
vscode 源码解析 - 依赖注入 - 知乎 (zhihu.com)