Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VSCode 架构分析:启动和初始化 #86

Open
yinguangyao opened this issue Jan 17, 2025 · 0 comments
Open

VSCode 架构分析:启动和初始化 #86

yinguangyao opened this issue Jan 17, 2025 · 0 comments

Comments

@yinguangyao
Copy link
Owner

安装和启动

Vscode 使用 yarn 进行包管理,gulp 做构建的,我是基于 1.93.0 版本来阅读源码的。
可以按照下面步骤来选择启动 web 或者桌面版:

git clone [email protected]:microsoft/vscode.git
cd vscode
yarn

// 构建启动 web
npm run compile
./scripts/code-web.sh

// 构建启动 electron
npm run compile-client
./scripts/code.sh

// 编译 monaco editor
gulp editor-distro

启动 web 端会自动帮打开 localhost:8080,出现一个 web 版 vscode 界面。web 版虽然是简化了一些功能,但核心编辑能力还是有的,感兴趣的可以基于 web 版来做源码阅读。
启动 client 端会在电脑启动一个 Code - OSS 的程序,打开就是 vscode 编辑器,它包含了我们平时使用 vscode 的功能。
Client 端可以在 Help -> Toggle Developer Tools 打开开发者工具,调试起来也比较方便。

启动流程主要以 Web 端来切入分析,刚接触一个复杂项目的时候,我喜欢从启动命令开始一路探索到页面加载。
启动流程是以 code-web.js 的 main 函数作为入口,启动了一个服务监听 8080 端口,执行了 testWebLocation。

@vscode/test-web 是另一个库,地址在:https://github.com/microsoft/vscode-test-web

可以直接从 vscode 仓库的 node\_modules/@vscode/test-web/out/index.js 看编译后的源码。

Test-web 使用了 playwright 开启无头浏览器,在一切就绪之后,帮用户打开 localhost:3000 页面。

那么无头浏览器访问 localhost:3000 的时候,返回了什么内容呢?

通过 open -> runServer -> app.js 溯源,在 @vscode/test-web/out/app.js 的 createApp 里面看到是基于 Koa 起了一个服务,最终 app.use 调用了 workbench.js,注册了路由。

当用户访问 localhost:3000 的时候,会将 workbench.render 的内容返回,这里的 render 方法渲染了 views/workbench.html 模板,填入一些变量信息。

可以看到访问 localhost:3000 渲染的 HTML 最终会包括上面一些 js 文件,在 dev 模式下,加载了 vs/code/browser/workbench/workbench 文件,这个文件就是前端的加载入口文件。
至此,上述就是 web 端完整的启动流程。

初始化流程

在初始化流程之前,先介绍一下 vscode 的主要目录(找了一张网图):

Web 的初始化加载入口在 BrowserMain 的 open 方法里面。主要做了下面几件事:

  1. 执行 initServices,先将 environmentService、logService 等公共 Service 注册到 serviceCollection

  2. 等待 DOM 加载完成,保证后续流程能直接拿到 DOM

  3. 创建 Workbench 实例, 执行 Workbench 初始化

    // Init services and wait for DOM to be ready in parallel
    const [services] = await Promise.all([this.initServices(), domContentLoaded(getWindow(this.domElement))]);

    // Create Workbench
    const workbench = new Workbench(this.domElement, undefined, services.serviceCollection, services.logService);

    // Listeners
    this.registerListeners(workbench);

    // Startup
    const instantiationService = workbench.startup();

依赖注入

在开始之前,还是先介绍 VSCode 依赖注入的用法。在 VSCode 里面有几个概念,分别是 contribution、service、serviceCollection、instantiationService。

  1. contribution:一个完整的业务模块,可以包含多个 Service 和 Widget,他不会被其他模块依赖,比如查找替换。
  2. service:一个服务类,可以理解为一个偏底层的基础功能模块,比如 logService、configurationService。
  3. serviceCollection:服务集合,内部可以注册很多 Service,等待实例化。
  4. instantiationService 容器服务,用于实例化 Service 的容器。
// 定义一个 service
class LogService implements ILogService {}
// 注册一个 service
const serviceCollection = new ServiceCollection();
serviceCollection.set(ILogService, LogService);
const instantiationService = new InstantiationService(serviceCollection, true);
// 使用一个 service
const logService = instantiationService.invokeFunction(accessor => accessor.get(ILogService));
logService.log('xxx');

// 使用一个 service
class FindContribution {
  constructor(@ILogService private readonly logService: ILogService) {
  }
  
  log() {
    this.logService.log('xxx');
  }
}

至于为什么要使用依赖注入,可以参考这篇文章:https://imwangfu.com/2022/05/vscode-di1.html

Workbench

Workbench 中先创建了容器服务 InstantiationService,注册了上面的基础业务相关的 Service。这里涉及到了异步模块的初始化,需要等待 indexedDB 和 DOM 初始化完成才继续走下去。

对于这种初始化带有异步的处理一般有几种:

  1. 在启动流程中阻塞,比如 indexedDB 打开数据库是个异步操作。

  1. 使用读写锁来控制,初始化本身上写锁,模块其他的接口就上读/写锁,但是这样对应的接口都是async。

  1. 模块自身异步化,必须要await getInstance 才能获取到模块,本质上就是把接口的async调用前置,但该能力更推荐一些按需加载的服务使用,不推荐用于异步初始化。

  2. 不阻塞初始化,提供生命周期钩子,比如 lifecycleService 提供的 when 方法。

接着 Workbench 会从 indexedDB 获取之前缓存过的各面板位置尺寸、展开折叠等信息,等待 layout。

接着 Workbench 会注册一些业务模块 contribution,这些 contribution 都是实现某个功能的模块,更偏上层一些。

最后开始做 Workbench 的渲染和布局,Workbench 会创建提前注册好的 Parts,创建 Grid 布局,将 Part 渲染到对应的节点里面。

Parts 包括 titlebarPart、activitybarPart、statusbarPart、editorPart 等部分,通过调用 registerPart 来提前注册。

以左侧菜单栏 ActivitybarPart 为例,里面的每一项菜单都是配置化注册的。每个菜单项都会以 ViewDescriptor 的形式提前注册,在 actionViewItems 里面触发点击的话,会开始渲染 View。

// 注册查找菜单
const viewDescriptor: IViewDescriptor = {
  id: VIEW_ID,
  containerIcon: searchViewIcon,
  name: nls.localize2('search', "Search"),
  // 渲染菜单面板的 View 类
  ctorDescriptor: new SyncDescriptor(SearchView),
  canToggleVisibility: false,
  canMoveView: true,
  openCommandActionDescriptor: {
    id: viewContainer.id,
    mnemonicTitle: nls.localize({ key: 'miViewSearch', comment: ['&& denotes a mnemonic'] }, "&&Search"),
    keybindings: {
      primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyF,
      // Yes, this is weird. See #116188, #115556, #115511, and now #124146, for examples of what can go wrong here.
      when: ContextKeyExpr.regex('neverMatch', /doesNotMatch/)
    },
    order: 1
  }
};

// Register search default location to sidebar
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([viewDescriptor], viewContainer);

Editor

既然是编辑器项目,那么编辑器的初始化就比较重要,vscode 不仅支持编辑器打开还原,还支持 MultiEditor(多个编辑器)、DiffEditor(Git Diff 编辑器) 等特性。

编辑器入口在 editorPart 文件,在 workbench 渲染的时候,会先从 indexedDB 里面获取上次打开的状态。如果之前有打开过项目,那就走还原流程,否则走新建流程。

如果上次已经打开过某个项目,就会调用 doCreateGridControlWithState 进入还原流程。还原的时候会根据当前 Group 的数量,创建对应的 GroupView(EditorGroupView),每个 GroupView 可以包含很多个 Editor。

那么什么是 Group 呢?可以看下面这张图片,我们打开两个面板,每个面板都是一个 Group:

下面就是存入到 indexedDB 中的信息。每个 GroupView 都是一个 leaf 节点,每个节点内部可以包含很多个编辑器,对应我们在 VSCode 里面打开的文件 Tab。

{
    "root": {
        "type": "branch",
        "data": [
            {
                "type": "leaf",
                "data": {
                    "id": 0,
                    "editors": [
                        {
                            "id": "workbench.editors.files.fileEditorInput",
                            "value": "{\"resourceJSON\":{\"$mid\":1,\"fsPath\":\"/Users/bytedance/Desktop/lvweb/tools/graphic-design/README.md\",\"external\":\"file:///Users/bytedance/Desktop/lvweb/tools/graphic-design/README.md\",\"path\":\"/Users/bytedance/Desktop/lvweb/tools/graphic-design/README.md\",\"scheme\":\"file\"},\"encoding\":\"utf8\"}"
                        },
                        {
                            "id": "workbench.editors.files.fileEditorInput",
                            "value": "{\"resourceJSON\":{\"$mid\":1,\"fsPath\":\"/Users/bytedance/Desktop/lvweb/tools/graphic-design/scm_build.sh\",\"external\":\"file:///Users/bytedance/Desktop/lvweb/tools/graphic-design/scm_build.sh\",\"path\":\"/Users/bytedance/Desktop/lvweb/tools/graphic-design/scm_build.sh\",\"scheme\":\"file\"},\"encoding\":\"utf8\"}"
                        },
                        {
                            "id": "workbench.editors.files.fileEditorInput",
                            "value": "{\"resourceJSON\":{\"$mid\":1,\"fsPath\":\"/Users/bytedance/Desktop/lvweb/packages/workflow-core-sdk/README.md\",\"external\":\"file:///Users/bytedance/Desktop/lvweb/packages/workflow-core-sdk/README.md\",\"path\":\"/Users/bytedance/Desktop/lvweb/packages/workflow-core-sdk/README.md\",\"scheme\":\"file\"},\"encoding\":\"utf8\"}"
                        }
                    ],
                    "mru": [
                        1,
                        0,
                        2
                    ]
                },
                "size": 446
            },
            {
                "type": "leaf",
                "data": {
                    "id": 1,
                    "editors": [
                        {
                            "id": "workbench.editors.files.fileEditorInput",
                            "value": "{\"resourceJSON\":{\"$mid\":1,\"fsPath\":\"/Users/bytedance/Desktop/lvweb/tools/graphic-design/scm_build.sh\",\"external\":\"file:///Users/bytedance/Desktop/lvweb/tools/graphic-design/scm_build.sh\",\"path\":\"/Users/bytedance/Desktop/lvweb/tools/graphic-design/scm_build.sh\",\"scheme\":\"file\"},\"encoding\":\"utf8\"}"
                        }
                    ],
                    "mru": [
                        0
                    ]
                },
                "size": 445
            }
        ],
        "size": 1023
    },
}

在 GroupViewModel 里面会创建每个编辑器对应的 Model,在 VSCode 里面,每个 Tab 都是一个 Editor,Editor 有很多概念,主要是下面几种:

  1. EditorPane:包括 textFileEditor、textDiffEditor 等,本身是一个 editorControl 控制器,持有 Model 信息,作为编辑器的总入口,可以创建一个 EditorWidget 出来。

编辑器面板:

欢迎页面板:

  1. Editor:包括 fileEditorInput、diffEditorInput、untitledTextEditorInput 等,维护了一份 Model 信息,可以通过 fileService 来对文件进行读写。每个 tab 都是一个 Editor。

  2. EditorWidget:编辑器的 UI 模块,包括 CodeEditorWidget、DiffEditorWidget 等,负责编辑器的渲染,包含 findReplace、minimap(右侧缩略图)、viewLine、lineNumber(代码行数) 等业务模块。

在初始化的时候通过 registerEditorPane 来将 EditorPane 和 Editor 一一关联。

Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
  EditorPaneDescriptor.create(
    TextFileEditor,
    TextFileEditor.ID,
    nls.localize('textFileEditor', "Text File Editor")
  ),
  [
    new SyncDescriptor(FileEditorInput)
  ]
);

CodeEditorWidget 有点儿像外层的 Workbench,内部会创建自己的容器服务 instantiationService,将所有的业务模块注册到容器里面。

简单画个图:

EditorWidget

EditorWidget 是编辑器的 UI 模块,每个 tab 都会创建一个 EditorWidget。每个 EditorWidget 都是相互隔离的,彼此不会复用,可以避免一些副作用会互相影响。

EditorWidget 会在初始化的时候会先创建自己的容器服务,然后实例化之前注册的 contribution 和 action。

这里的 instantiationService 也是容器化的关键。Editor 将所有的业务模块都注册到 instantiationService 里面,在关闭 tab 的时候,直接将 instantiationService 销毁就可以避免后续影响。在新开 tab 创建编辑器的时候,也会创建一个新的 instantiationService,这样就能实现完全隔离。

容器化这在编辑器项目中非常重要,比如飞书文档中从文档 A 需要无刷新切换到文档 B。对于日志服务、配置服务这类基础服务是不需要销毁的,可以继续复用。

但是原本在文档 A 里面初始化的模块、快捷键、绑定的事件都需要销毁,在文档 B 中重新创建。

如果代码实现的没有那么安全,很容易就有一些模块的副作用没有被清理干净,就会影响到文档 B 的正常使用。

架构和分层

从 UI 上来看,VSCode 基于编辑器区域进行了划分不同的 Part,每个 Part 只关心自己的实现。

Part 内部还会继续进行划分,比如 Editor 作为其中一个区域,自身内部又划分了不同的编辑器区域。

Activity 也会注册不同的菜单项,每个菜单项有自己的 icon、title 配置,还有自己的对应的二级面板 UI。

从实现上来看,VSCode 划分了不同的 Registry 集合,将这些 Registry 以单例的形式提供出来,类似于 ServiceCollection,这里的 Registry 就是 contribution 的集合,方便将某一类功能模块归类到一起。

每一种 Registry 都会挂载到 RegistryImpl 上面,因此项目中任何地方都可以通过 Registry.as 的形式访问到 Registry 实例。

VSCode 项目中有这么几种 Registry:

  1. WorkbenchContributionsRegistry:注册 Workbench 功能模块

  2. ViewContainersRegistry:注册左侧面板菜单功能模块

  3. EditorPaneRegistry:注册 Editor 区域面板模块(比如欢迎界面、编辑器界面)

  4. EditorContributionRegistry:注册 Editor 内部功能模块(比如 查找替换、minimap 等)

对比 React App

传统的 React App 初始化流程一般比较简单,它是从 ReactDOM.render 开始渲染根组件,可以优先渲染出来页面骨架,一些初始化逻辑会放到 React 组件 useEffect 时机去做。UI 渲染和初始化逻辑是耦合在一起的。

VSCode 则是将这些 useEffect 里面的逻辑提升到上层,执行完初始化流程之后,才会开始渲染 UI。

这篇主要讲解 VSCode 初始化流程,下一篇会详细介绍依赖注入、事件系统和容器化的实现。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant