VSCode插件开发小记

VSCode介绍

VSCode是微软开源的一款基于Electron开发的代码编辑器。Electron 是一个基于 Chromium 的项目,可用于开发基于 Node.js 的本地应用程序。VSCode 使用 Blink 排版引擎渲染用户界面。虽然基于 Electron 框架,但并不是Atom的复刻。Code是由“Monaco”的编辑器核心制作,与 Visual Studio Team Services 相同。

在2019年的Stack Overflow组织的开发者调研中,VS Code被认为是最受开发者欢迎的开发环境,据调查87317名受访者中有50.7%的受访者声称正在使用VS Code1

Feature Overview

Visual Studio Code feature overview

Context View

Context view diagram

Module Organization

Module organization

VSCode Architecture

VSCode architecture

Process structure of VSCode

从上图可以看出来渲染进程与调试/插件进程都是隔离的,通过RPC去调用,这保证了VSCode在安装很多插件时都能以极快的速度启动,这点对于用户体验很重要,因为启动太慢会让人焦虑(想想IDEA和Emacs在插件多的时候的感人启动速度)。

更多关于 VSCode 架构设计见 VSCode 团队负责人 Erich Gamma 的这个分享:Building an App Using JS/TypeScript, Node, Electron & 100 OSS Components • Erich Gamma • GOTO 2016

Emacs Architecture

EMACS Conceptual Architecture

分析完VSCode的架构后,我们还可以学习下上古神器Emacs的架构,Emacs拥有极其强大的扩展能力,号称伪装成操作系统的编辑器(只差一个内核了)。

在Emacs的世界里,用户的输入通过终端输入给Command Dispatcher组件,前者与Lisp解释器共同处理用户的输入,然后调用底层的Display Processer(处理显示)和Primitives(提供基本功能,例如刷新屏幕,插入一个字符并加载文件),这两个组件又调用最底层的Buffer(缓存区)和OS(操作系统原生命令)。

这种架构很类似我们Web开发中的MVC架构,通过Lisp解释器提供强大的定制能力,用户可以编写elisp代码给Emacs添加各种功能。

同样的VSCode也提供了各种插件API,虽不及Emacs这么强大,但是对于我们的很多需求都足够了,加上VSCode的生态的确要远比Emacs的好,入门门槛极低,我在不了解VSCode插件开发到发布第一款插件总共花了不到两天的时间(还是断断续续的开发)。

VSCode插件开发

背景

如果只是做一个很简单的demo插件,可能不到半小时就搞定了,不过真实的场景一般是比较复杂的,在《我的时间管理工具》这篇文章中提到我开发了VSCode插件TODO++,这是一款基于别人插件的修改版本,我需要的很多功能vscode-todo-plus都已经提供了,但是我需要查看我当前正在做的事情以及当前标记为很重要但是未开始做的事情,我需要在VSCode左侧的窗口中再提供两个窗口(DOING/CRITICAL)。

step1/开发

因为要添加TreeView的两个窗口,首先要了解TreeView的基本知识,可以查看VSCode官方的Tree View Guideyour-first-extension

全部的代码在这里 add doing task tree view

需要注意的是需要先在package.json的加入自己的command,因为我们要在TreeView中添加刷新按钮,当点击刷新按钮时,需要调用我们添加的这个刷新命令。

{
    "command": "todo.refreshDoingEntry",
    "title": "Refresh",
    "icon": {
        "light": "resources/icons/refresh_light.svg",
        "dark": "resources/dark/refresh_dark.svg"
    }
}

这个json会注册 todo.refreshDoingEntry 到VSCode的命令表中,那这个命令真正在commands.ts中:

function refreshDoingEntry () {
  DoingFiles.refresh ( true );
}
class Doing extends View {

  id = 'todo.views.0doing';
  clear = false;
  filePathRe = /^(?!~).*(?:\\|\/)/;

  getTreeItem ( item: Item ): vscode.TreeItem {
    return item
  }

  async getChildren ( item?: Item ): Promise<Item[]> {

    if ( this.clear ) {

      setTimeout ( this.refresh.bind ( this ), 0 );

      return [];

    }

    let obj = item ? item.obj : await Utils.files.get ();

    while ( obj && '' in obj ) obj = obj['']; // Collapsing unnecessary groups

    if ( _.isEmpty ( obj ) ) return [new Placeholder ( 'No todo files found' )];

    if ( obj.textEditor ) {

      const items = [],
            lineNr = obj.hasOwnProperty ( 'lineNr' ) ? obj.lineNr : -1;

      Utils.ast.walkChildren ( obj.textEditor, lineNr, data => {

        data.textEditor = obj.textEditor;
        data.filePath = obj.filePath;
        data.lineNr = data.line.lineNumber;

        let isDoing = data.line.text.includes("@doing") || (data.line.text.includes("@started") && !data.line.text.includes("@done"));

        let isGroup = false;

        Utils.ast.walkChildren2 ( obj.textEditor, data.line.lineNumber, data => {
          if ((data.line.text.includes("@doing") || data.line.text.includes("@started")) && !data.line.text.includes("@done")) {
            isGroup = true;
            return false;
          }
          return true;
      });

        if (isDoing || isGroup) {
          const label = _.trimStart ( data.line.text ),
              item = isGroup ? new Group ( data, label ) : new Todo ( data, label );
          items.push ( item );
        }
      });

      if ( !items.length ) { return []; }

      return items;

    } else {

      const keys = Object.keys ( obj ).sort ();

      return keys.map ( key => {

        const val = obj[key];

        if ( this.filePathRe.test ( key ) ) {

          const uri = Utils.view.getURI ( val );

          return new File ( val, uri );

        } else {

          return new Group ( val, key, this.config.embedded.view.icons );

        }

      });

    }

  }

  refresh ( clear? ) {

    this.clear = !!clear;

    super.refresh ();

  }

}

Doing extends View implements vscode.TreeDataProvider<Item>,这就是VSCode的TreeView,以id = 'todo.views.0doing'注册,在package.json中:

"views": {
      "todo": [
        {
          "id": "todo.views.0doing",
          "name": "Doing"
        },
        {
          "id": "todo.views.3critical",
          "name": "Critical"
        },
        {
          "id": "todo.views.1files",
          "name": "Files"
        },
        {
          "id": "todo.views.2embedded",
          "name": "Embedded"
        }
      ]
    }

注册刷新按钮到TreeView中:

"view/title": [
        {
          "command": "todo.refreshDoingEntry",
          "when": "view == todo.views.0doing",
          "group": "navigation@0"
        }
    ]

最后别忘了在index.ts中export:

export default [Doing, Critical, Files, Embedded];

step2/发布

开发完毕后,我们可以利用Github Actions提供的自动化发布插件Vscode release plugin编写自己的插件发布pipeline:

on: push
name: "Release Vscode Plugin "
jobs:
  npmInstall:
    name: npm install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: npm install
        run: npm install
      - name: Vscode release plugin
        uses: JCofman/vscodeaction@master
        env:
          PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }}
        with:
          args: publish -p $PUBLISHER_TOKEN

这样每次push都会触发自动发布至VSCode Marketplace。当然要了解详细的发布知识,可以查看官方的Publishing Extensions

进一步阅读

可研究下VSCode官方提供了一些插件的Demo: vscode-extension-samples

插件开发中不可避免要Debug,VSCode也提供了强大的Debug能力,可参考Debugging extensions

References


  1. Developer Survey Results 2019 - Most Popular Development Environments ↩︎