VS Code 开发不完全入门

一、背景

将公司埋点管理平台PC端的一些能力集成到 VS Code 插件中,缩短开发人员开发埋点操作路径,提升埋点开发效率,通过开发 VS Code 插件积累了一些在插件开发上的经验,在此分享给大家。

二、 概览

1. 介绍

Visual Studio Code(简称 VS Code)是一款由微软开发且跨平台的免费源代码编辑器,该软件支持语法高亮、代码自动补全、代码重构、查看定义功能,并且内置了命令行工具和Git版本控制系统。用户可以更改主题和键盘快捷方式实现个性化设置,也可以通过内置的扩展程序商店安装扩展以拓展软件功能。

VS Code 的强大之处是一切功能都可以基于插件来实现,IDE 只提供最基本的框架和基本功能。可以通过侧边栏的插件入口来直接安装插件,也可以官方的应用商店来安装插件。

VS Code 插件采用Electron来实现的,其插件开发也是采用html + javascript等前端技术来实现,最后再打包成一个特殊后缀的.vsix的压缩文件,放到 VS Code 的插件目录中。因此对于前端来说开发 VS Code 插件的难度不大,并且很好上手,重点是需要了解 VS Code 所提供的基础能力。

在插件开发之前我们可以先下载 VS Code 所提供的官方 demo,该项目提供了丰富的插件开发示例可以参考,另外在开发中也可参考插件市场上的已有插件的原始代码。

2. 插件能做什么

插件能实现各种丰富的功能,那插件到底能做什么,具有哪些能力呢?

2.1 不受限的磁盘访问

因为 VS Code 是基于Electron开发的,可以使用nodejs随意读写本地文件、跨域请求、甚至创建一个本地server,这都是没有任何限制的。

2.2 自定义颜色或图标主题

比如Material Icon Theme插件,能为打开的文件树增加一个图标,让文件树不那么单调。

2.3 自定义左侧功能面板

Todo+插件在左侧面板中提供的展示项目中的 TODO 项,如埋点管理插件在左侧定义了埋点列表等。

2.4 创建一个 Webview 以显示使用 HTML/CSS/JS 构建的自定义网页

可以在 VS Code 中实现一个简单的浏览器,直接浏览网页。

图 7

2.5 自定义跳转、自动补全、悬浮提示

Tabnine提供的代码自动提示等。

图 8

2.6 自定义命令、快捷键、菜单

VS Code 插件很多功能都是基于一个个命令实现的,我们可以自定义一些命令,这个命令将出现在按下Ctrl+Shift+P后的命令列表里面,同时可以给命令配置快捷键、配置资源管理器菜单、编辑器菜单、标题菜单、下拉菜单、右上角图标、下方状态栏等。

图 9

2.7 其他

VS Code 插件还可以自定义语音支持,如代码高亮,语法解析等,还能增强markdown预览,修改状态栏等。了解更多能力

三、如何开发一个 vscode 插件

1. 开始

在了解 VS Code 插件的能力之后,接下来介绍一下如何快速的开发一款 VS Code 插件。

1.1 创建项目

VS Code 的插件开发可直接利用脚手架创建一个新的项目,安装脚手架。

npm install -g yo generator-code

在安装成功后,打开命令行,开始创建项目,执行yo code


yo code

     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |
   __'.___.'__
 ´   `  |° ´ Y `

? What type of extension do you want to create? (Use arrow keys)
❯ New Extension (TypeScript)
  New Extension (JavaScript)
  New Color Theme
  New Language Support
  New Code Snippets
  New Keymap
  New Extension Pack
  New Language Pack (Localization)
  New Notebook Renderer (TypeScript)

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? hello-word
? What's the identifier of your extension? hello-word
? What's the description of your extension? hello extension
? Initialize a git repository? Yes
? Bundle the source code with webpack? Yes
? Which package manager to use? yarn

通过可交互 ui 来选择项目类型、开发语言等,在此处我们选择创建一个插件项目,在项目创建完成安装完依赖后,使用 VS Code 打开刚刚创建的项目,按下F5,会立即弹窗一个新的窗口,新窗口中运行着刚刚的插件。在命令面板中(快捷键Ctrl+Shift+P)中输入HelloWorld命令。

图 3

我们可以在右下角看到一个Hello World提示弹窗,这就表示我们的插件命令运行成功了。

1.2 项目结构

在我们为插件添加功能之前,我们可以先认识一下 VS Code 插件的项目结构。

插件项目中主要文件有extension.ts(如果 js 项目就是extension.js)与package.jsonextension.ts为插件的入口文件(在package.json中有配置),package.json中主要有 VS Code 的配置字段,插件中的相关功能入口、命令等都需要在package.json中配置。

图 6

2. package.json

接下来简单介绍一下package.json文件,先有一个大致的概念,了解一下,后面再开发到相应的功能时也会再次介绍到。

2.1 package.json 文件

{
    // 插件的名字,应全部小写,不能有空格
    "name": "ks-stark",
    // 插件的友好显示名称,用于显示在应用市场,支持中文
    "displayName": "ks-stark",
    // 描述
    "description": "埋点管理平台插件",
    // 关键字,用于应用市场搜索
    "keywords": ["vscode", "stark"],
    // 版本号
    "version": "1.0.0",
    // 发布者,如果要发布到应用市场的话,这个名字必须与发布者一致
    "publisher": "ks-stark",
    // 表示插件最低支持的vscode版本
    "engines": {
        "vscode": "^1.27.0"
    },
    // 插件应用市场分类,可选值: [Programming Languages, Snippets, Linters, Themes, Debuggers, Formatters, Keymaps, SCM Providers, Other, Extension Packs, Language Packs]
    "categories": ["Other"],
    // 插件图标,至少128x128像素
    "icon": "https://s2-10710.kwimgs.com/kos/nlav10710/ks-stack/images/icon.png",
    // 扩展的激活事件数组,可以被哪些事件激活扩展
    "activationEvents": ["onCommand:extension.sayHello"],
    // 插件的主入口
    "main": "./src/extension",
    // 贡献点,整个插件最重要最多的配置项
    "contributes": {
        // 插件配置项
        "configuration": {
            // 配置项标题,会显示在vscode的设置页
            "title": "ks-track",
            // 配置的选项
            "properties": {
                "ks-track.appName": {
                    "type": "string",
                    "default": "KUAISHOU",
                    "scope": "application",
                    "description": "埋点列表的产品英文名"
                },
                "ks-track.codePlatformList": {
                    "type": "string",
                    "default": "H5",
                    "scope": "application",
                    "description": "样例代码所使用的的平台",
                    "enum": ["PC_WEB", "ANDROID_PHONE", "IPHONE", "H5"] // 枚举
                },
                "ks-track.defaultListType": {
                    "type": "string",
                    "default": "group",
                    "scope": "application",
                    "description": "默认加载埋点方式(group,list)",
                    "enum": ["group", "list"]
                },
                "ks-track.completionKey": {
                    "type": "string",
                    "default": "log",
                    "scope": "application",
                    "description": "触发自动完成的关键词"
                }
            }
        },
        // 命令
        "commands": [
            {
                "command": "extension.sayHello",
                "title": "Hello World"
            }
        ],
        // 快捷键绑定
        "keybindings": [
            {
                "command": "extension.sayHello",
                "key": "ctrl+f10",
                "mac": "cmd+f10",
                "when": "editorTextFocus"
            }
        ],
        // 菜单
        "menus": {
            // 编辑器右键菜单
            "editor/context": [
                {
                    // 表示只有编辑器具有焦点时才会在菜单中出现
                    "when": "editorFocus",
                    "command": "extension.sayHello",
                    // navigation是一个永远置顶的分组,后面的@6是人工进行组内排序
                    "group": "navigation@6"
                },
                {
                    "when": "editorFocus",
                    "command": "extension.demo.getCurrentFilePath",
                    "group": "navigation@5"
                },
                {
                    // 只有编辑器具有焦点,并且打开的是JS文件才会出现
                    "when": "editorFocus && resourceLangId == javascript",
                    "command": "extension.demo.testMenuShow",
                    "group": "z_commands"
                },
                {
                    "command": "extension.demo.openWebview",
                    "group": "navigation"
                }
            ],
            // 编辑器右上角图标,不配置图片就显示文字
            "editor/title": [
                {
                    "when": "editorFocus && resourceLangId == javascript",
                    "command": "extension.demo.testMenuShow",
                    "group": "navigation"
                }
            ],
            // 编辑器标题右键菜单
            "editor/title/context": [
                {
                    "when": "resourceLangId == javascript",
                    "command": "extension.demo.testMenuShow",
                    "group": "navigation"
                }
            ],
            // 资源管理器右键菜单
            "explorer/context": [
                {
                    "command": "extension.demo.getCurrentFilePath",
                    "group": "navigation"
                },
                {
                    "command": "extension.demo.openWebview",
                    "group": "navigation"
                }
            ]
        },
        // 代码片段
        "snippets": [
            {
                "language": "javascript",
                "path": "./snippets/javascript.json"
            },
            {
                "language": "html",
                "path": "./snippets/html.json"
            }
        ],
        // 自定义新的activitybar图标,也就是左侧栏的入口图标
        "viewsContainers": {
            "activitybar": [
                {
                    "id": "ks-track",
                    "title": "埋点管理",
                    "icon": "https://s2-10710.kwimgs.com/kos/nlav10710/ks-stack/images/track-logo.png"
                }
            ]
        },
        // 自定义侧边栏内view的实现
        "views": {
            // 和 viewsContainers 的id对应
            "ks-track": [
                {
                    "id": "track.start",
                    "name": "已开发"
                },
                {
                    "id": "track.noStart",
                    "name": "未开发"
                }
            ]
        },
        // 定义对于view的欢迎页,view 与 views 中的id对应
        "viewsWelcome": [
            {
                "view": "track.start",
                "contents": "当前未登录或登录失败,请点击登录按钮登录。\n 点击[使用教程](https://component.corp.kuaishou.com/docs/weblogger/views/tools/vscode.html)了解更多信息\n[登录](command:track.cmd.login)",
                "when": "store-login-status == nologin"
            },
            {
                "view": "track.start",
                "contents": "数据正在加载中,请等待...",
                "when": "store-login-status == logining"
            }
        ],
        // 图标主题
        "iconThemes": [
            {
                "id": "testIconTheme",
                "label": "测试图标主题",
                "path": "./theme/icon-theme.json"
            }
        ]
    },
    // 同 npm scripts
    "scripts": {},
    // 开发依赖
    "devDependencies": {}
}

下面对package.json中的一些字段做一个简单介绍

2.2 activationEvents

扩展的激活事件,是一个数组,定义可以被哪些事件激活扩展,插件在 VS Code 中默认是没有被激活的,那什么时候才被激活呢?就是通过 activationEvents 来配置,目前支持以下配置:

  • onLanguage:${language}
  • onCommand:${command}
  • onDebug
  • workspaceContains:${toplevelfilename}
  • onFileSystem:${scheme}
  • onView:${viewId}
  • onUri
  • onWebviewPanel
  • onCustomEditor
  • onStartupFinished

比如配置了 onLanguage:javascript,那么只要我打开了JS类型的文件,插件就会被激活,onView:track.start 打开 track.start的侧边栏时插件才激活。如果配置了*,只要一启动 VS Code ,插件就会被激活,为了避免不必要的性能浪费,官方不推荐这么做。

了解更多

2.3 contributes

package.json中最丰富的配置项,在contributes中可以定义很多内容,

2.3.1 configuration

设置提供插件可以配置那些设置项,在setting.json中配置,可以通过 vscode.workspace.getConfiguration() 来获取配置(get({{title}})),或者更新配置(update({{title}}))

// 获得埋点插件的配置
const dConfig = vscode.workspace.getConfiguration().get("ks-track")

图 10

2.3.2 commands:命令

配置命令的图标与 title,

"commands": [
    {
        "command": "track.cmd.listSearch",
        "title": "搜索",
        "icon": {
            "light": "resources/icons/light/search.svg",
            "dark": "resources/icons/dark/search.svg"
        }
    },
]

图 11

2.3.3 menus:菜单

定义视图操作,也就是定义菜单出现的位置,比如右键菜单、侧边栏菜单等。

2.3.4 keybindings

绑定快捷键

"keybindings": [
    {
        "command": "hello-word.helloWorld",
        "key": "ctrl+f10",
        "mac": "cmd+f10",
        "when": "editorTextFocus"
    }
]

2.3.5 viewsContainers

定义右侧侧边栏的图标,图标建议使用24*24的单色 SVG 格式的图标,当然也能接受其他图标类型,尺寸。

"viewsContainers": {
    "activitybar": [
        {
            "id": "ks-track",
            "title": "埋点管理",
            "icon": "resources/icons/dark/track-logo.png"
        }
    ]
},

2.3.6 viewsWelcome

定义视图页面的欢迎页,比如

"viewsWelcome": [
    {
        "view": "track.start",
        "contents": "当前未登录或登录失败,请点击登录按钮登录。\n 点击[使用教程](https://component.corp.kuaishou.com/docs/weblogger/views/tools/vscode.html)了解更多信息\n[登录](command:track.cmd.login)",
    }
],

2.3.7 其他
  • languages:新语言支持
  • debuggers:调试
  • breakpoints:断点
  • grammars
  • themes:主题
  • snippets:代码片段
  • jsonValidation:自定义 JSON 校验
  • views:左侧侧边栏视图
  • viewsContainers:自定义 activitybar
  • problemMatchers
  • problemPatterns
  • taskDefinitions
  • colors

了解更多配置

3. 命令

3.1 注册命令

VS Code 内部含有大量和编辑器交互、控制 UI、后台操作的命令,另外许多插件将它们的核心功能暴露为命令的形式供用户或者其他插件使用,在通过脚手架创建的项目中,默认注册了一个 hellWord 的命令。

// 注册命令
let disposable = vscode.commands.registerCommand("hello-word.helloWorld", () => {
    vscode.window.showInformationMessage("Hello World")
})
context.subscriptions.push(disposable)

vscode.commands.registerCommand是注册命令的API,执行后会返回一个Disposable对象,所有注册类的API执行后都需要将返回结果放到context.subscriptions中。除了需要调用vscode.commands.registerCommand方法注册外,还需要在package.json中定义命令激活的时机,以及命令的title等信息。

{
    // 扩展的激活时间,在打开命令输入时激活
    "activationEvents": ["onCommand:hello-word.helloWorld"],
    "contributes": {
        "commands": [
            {
                // 命令字符串
                "command": "hello-word.helloWorld",
                // 显示命令的title
                "title": "Hello World",
                // 图标
                "icon": {
                    "light": "resources/icons/light/login.svg",
                    "dark": "resources/icons/dark/login.svg"
                }
            }
        ]
    }
}

3.2 registerCommand的回调函数参数

  • 当从资源管理器中右键执行命令时会把当前选中资源路径 uri 作为参数传过;
  • 当从编辑器中右键菜单执行时则会将当前打开文件路径 uri 传过去;
  • 当直接按Ctrl+Shift+P执行命令时,这个参数为空;
context.subscriptions.push(
    vscode.commands.registerCommand("hello-word.getCurrentFilePath", (uri) => {
        vscode.window.showInformationMessage(`当前文件(夹)路径是:${uri ? uri.path : "空"}`)
    })
)

3.3 执行命令

通过vscode.commands.executeCommand API 可以执行一个已注册的命令,包括 VS Code 的内置命令,比如 VS Code 内置的GitMarkdown插件中的命令,命令执行函数都是返回一个类似于PromiseThenable对象,需要异步的处理执行结果。

import * as VS Code from "vscode"
function commentLine() {
    vscode.commands.executeCommand("editor.action.addCommentLine",'params1', 'params2', ...).then(result=>{
        console.log('命令结果', result)
    })
}

3.4 获取所有命令

/**
 *检索所有可用命令的列表。下划线开头的命令为内部命令。
 *@param filterInternal 设置 `true` 以看不到内部命令(以下划线开头)
 *@return Thenable 解析为命令 ID 列表。
 */
vscode.commands.getCommands(filterInternal?: boolean).then((result) => {
    console.log("命令结果", result)
})

3.5 内置命令

在 VS Code 中打开新文件夹。

let uri = Uri.file("/some/path/to/folder")
let success = await commands.executeCommand("vscode.openFolder", uri)

更多内置命令

3.6 控制命令出现在命令面板的时机

默认情况下,注册的命令都会显示在命令面板(⇧⌘P)中,但是有些命令是场景相关的,比如在特定的语言的编辑器中,或者只有用户设置了某些选项时才展示。我们可以通过when字段来控制命令是否显示在命令面板中,通过在commandPalette字段中配置。

{
    "contributes": {
        "menus": {
            "commandPalette": [
                {
                    "command": "myExtension.sayHello",
                    "when": "editorLangId == markdown"
                }
            ]
        }
    }
}

现在myExtension.sayHello命令只会出现在用户的Markdown文件中了。

4. 树视图

树视图是显示在侧边栏的树形列表,如 VS Code 内置的项目目录树,接下来介绍如何在侧边栏实现一个列表。

4.1 配置树视图

要想配置一个视图容器,首先得在 package.json 中的contributes.viewsContainers中配置一个视图入口。

"contributes": {
    "viewsContainers": {
        "activitybar": [
            {
                "id": "ks-track",
                "title": "埋点管理",
                "icon": "resources/icons/dark/track-logo.png"
            }
        ]
    }
}
  • id: 新视图容器的名称
  • title: 展示给用户的视图容器名称,它会显示在视图容器上方
  • icon: 在活动栏中展示的图标

配置后能展示出一个树视图的入口

视图是显示在视图容器中的UI片段。使用contributes.views进行配置,你就可以将新的视图添加到内置或者你配置好的视图容器中了

"contributes": {
    "views": {
        "ks-track": [
            {
                "id": "track.start",
                "name": "开发中"
            }
        ]
    }
}

另外还需要配置插件什么时候被激活,当点击对应的图标后激活,在activationEvents中配置onView:${viewId}

"activationEvents": [
    "onView:track.start"
],

可以在contributes.menus 中定义视图的操作,

  • view/title: 视图标题位置显示的操作。这里可以配置主要的操作,使用”group”: “navigation”进行配置,剩余的二级操作则出现在…菜单中。
  • view/item/context: 每个视图项的操作。这里可以配置主要的操作,使用”group”: “inline”,剩余的二级操作则出现在…菜单中。

图 11

一个菜单项的完整配置如下:

"contributes": {
    "menus": {
        "view/title": [{
            "command": "track.cmd.listSearch",
            "when": "view == track.start && store-login-status == success",
            "group": "navigation@1",
            "alt": "...",
        }]
    }
}
  • view/title 是key值,定义这个菜单出现在的位置;
  • when 控制菜单合适出现;
  • command 定义菜单被点击后要执行什么操作;
  • group 定义菜单分组;
  • alt 定义备用命令,按住 alt 键打开菜单时将执行对应命令;

目前插件可以给以下场景配置自定义菜单:

  • 资源管理器上下文菜单 – explorer/context
  • 编辑器上下文菜单 – editor/context
  • 编辑标题菜单栏 – editor/title
  • 编辑器标题上下文菜单 – editor/title/context
  • 调试 callstack 视图上下文菜单 – debug/callstack/context
  • SCM 标题菜单 – scm/title
  • SCM 资源组菜单 – scm/resourceGroup/context
  • SCM 资源菜单 – scm/resource/context
  • SCM 更改标题菜单 – scm/change/title
  • 左侧视图标题菜单 – view/title
  • 视图项菜单 – view/item/context
  • 控制命令是否显示在命令选项板中 – commandPalette

操作的命令与图标同样在commands中配置, light 和dark 分别对应浅色和深色主题,如果不配置图标则直接显示文字:

"contributes": {
    "commands": [
        {
            "command": "track.cmd.listSearch",
            "title": "搜索",
            "icon": {
                "light": "resources/icons/light/search.svg",
                "dark": "resources/icons/dark/search.svg"
            }
        },
    ]
}

when

通过when 字段可以控制出现的时机,不仅仅用于菜单中,如常见的

  • resourceLangId == javascript:当编辑的文件是 js 文件时;
  • resourceFilename == test.js:当当前打开文件名是test.js时;
  • isLinuxisMacisWindows:判断当前操作系统;
  • editorFocus:编辑器具有焦点时;
  • editorHasSelection:编辑器中有文本被选中时;
  • view == someViewId:当当前视图 ID 等于 someViewId 时;
  • explorer: 显示在资源管理器侧边栏
  • debug: 显示在调试侧边栏
  • scm: 显示在源代码侧边栏
  • test: 测试侧边栏中的资源管理器视图

了解更多

alt

alt表示没有按下alt键时,点击右键菜单执行的是command对应的命令,而按下了alt键后执行的是alt对应的命令。

group

定义菜单项的分组,如在editor/context中有这些默认组:

图 1

  • navigation – 放在这个组的永远排在最前面;
  • 1_modification – 更改组;
  • 9_cutcopypaste – 编辑组
  • z_commands – 最后一个默认组,其中包含用于打开命令选项板的条目。

除了navigation是强制放在最前面之外,其它分组都是按照0-9a-z的顺序排列的,所以如果你想在 1_modification9_cutcopypaste插入一个新的组别的话,你可以定义一个诸如6_test,

editor/title中有

  • navigation – 与导航相关的命令。
  • 1_run – 与运行和调试编辑器相关的命令。
  • 1_diff – 与使用差异编辑器相关的命令。
  • 3_open – 与打开编辑器相关的命令。
  • 5_close – 与关闭编辑器相关的命令。
  • navigation1_run显示在主编辑器标题区域中。其他分组显示在次要区域...菜单下。

可以通过navigation控制命令的排列顺序, 默认同一个组的顺序取决于菜单名称,如果想自定义排序的话可以再组后面通过@的方式来自定义顺序,例如:

"editor/title": [
    {
        "command": "track.cmd.listSearch",
        "when": "view == track.start && store-login-status == success",
        "group": "navigation@2"
    },
    {
        "command": "track.cmd.switchToGroup",
        "when": "store-trackListType == list && store-login-status == success",
        // 强制 switchToGroup 在 listSearch 前面
        "group": "navigation@1"
    },
]

了解更多

4.2 提供数据

在定义菜单之后,我们就可以向菜单的左侧插入数据了,可以通过registerTreeDataProvider直接注册一个树视图,向其中写入数据。TrackList 是实现列表数据的类,也可以动态创建树视图。

vscode.window.registerTreeDataProvider("track.start", new TrackList(context))

也可通过window.createTreeView,来动态的创建一个树视图,treeDataProvider为需要传入的数据。

// 数列表数据
const treeList = new TrackList(context)
// 创建一个树视图
const treeView = vscode.window.createTreeView("track.start", {
    treeDataProvider: treeList,
    showCollapseAll: false,
    canSelectMany: false,
})

TrackList类的关键代码,继承TreeDataProvider

class TrackList implements vscode.TreeDataProvider<TrackItem> {

    private _onDidChangeTreeData: vscode.EventEmitter<TrackItem | undefined | void> = new vscode.EventEmitter<TrackItem | undefined | void>();
    readonly onDidChangeTreeData: vscode.Event<TrackItem | undefined | void> = this._onDidChangeTreeData.event;

    constructor(private context: vscode.ExtensionContext, options:any) {

    }

    refresh() {
        this._onDidChangeTreeData.fire()
    }

    async getChildren(element:TrackItem): Promise<any[]> {
        return Promise.resolve(await this.getList())
    }

    async getList(){
        const list:Array<any> = await DataSource.getList()
        if (list.length){
            return list.map((item:any) => new TrackItem(item, this.context))
        }

        return [new NoData(false)]
    }
}

TrackItem代码,继承 TreeItem

export class Item extends vscode.TreeItem {
    public isGroup:boolean = false
    constructor(public data: any, private context: vscode.ExtensionContext) {
        super('', vscode.TreeItemCollapsibleState.None)
        this.isGroup = !!data.groupId

        // 如果是分组
        if (this.isGroup){
            // 收起状态,三个状态 none,展开,收起
            this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed
            this.label = data.text
        }
        // 如果是列表
        else {
            // 设置列表没有展开收起
            this.collapsibleState = vscode.TreeItemCollapsibleState.None
            // 展示的文字
            this.label = data.treeListLabel
            // 鼠标停留时展示tooltip
            this.tooltip = data.tooltip
            // 注册点击列表时的命令
            this.command = {
                command: 'track.cmd.openDetail',
                title: 'll',
                arguments: [data]
            }
            this.upDataComplete(data.hasComplete)
        }
    }

    private upDataComplete(flag:boolean = false){
        /**
         * contextValue 设置一个值用于在`package.json`中的when中判断
         * ```
         *    "contributes": {
         *        "menus": {
         *            "view/item/context": [
         *                {
         *                    "command": "track.cmd.startComplate",
         *                    "when": "viewItem == list-false",
         *                    "group": "inline"
         *                }
         *            ]
         *        }
         *    }
         * ```
         */
        this.contextValue = this.isGroup ? '' : `list-${flag}`
        // label前的文字,icon有两个主题
        this.iconPath = {
            dark: this.context.asAbsolutePath(path.join('resources', 'icons', 'dark', !flag ? 'checkout-box.svg' : 'gou.svg')),
            light: this.context.asAbsolutePath(path.join('resources', 'icons', 'light', !flag ? 'checkout-box.svg' : 'gou.svg'))
        }
    }
}

了解更多

5. webview

webview 允许扩展在 VS Code 中创建完全可自定义的视图。例如,内置的 Markdown 扩展使用 webview 来呈现 Markdown 预览。 只要敢想, webview 可以直接创建出一个 VS Code 的内置浏览器,再通过 VS Code 所提供的能操作系统的 API,完全可以构建出更丰富的应用。

使用vscode.window.createWebviewPanel,可以创建一个 webview 视图,

5.1 创建 webview

const webviewPanel = vscode.window.createWebviewPanel(
    "埋点详情", // viewType 标识 webview 面板的类型
    viewItem.treeListLabel, // 视图标题
    vscode.ViewColumn.Beside, // 显示在编辑器的哪个部位
    {
        enableScripts: true, // 启用JS,默认禁用
        retainContextWhenHidden: false,
    }
)

// 设置内容
webviewPanel.webview.html = util.getWebViewContent(context, "web/detail/index.html")

function getWebViewContent() {
    // ... 获取内容
    return `<html><body>你好,我是Webview</body></html>`
}
  • 默认情况下,在 Web 视图中禁用 JavaScript,但可以通过传入 enableScripts: true 选项启用;
  • 默认情况下当 webview 被隐藏时资源会被销毁,通过 retainContextWhenHidden: true 会一直保存,但会占用较大内存开销,仅在需要时开启;

5.2 加载文件

VS Code 默认不能直接加载html文件,只能通过加载html字符串的方式加载html文件,另外在 webview 中也不支持直接加载本地资源文件,需要采用vscode-resource协议,如./js/vue,js 需要转换成 vscode-resource:/Users/test/workspace/hellword/js/vue.js

读取可参考下面方法,

/**
 * 获取某个扩展文件绝对路径
 * @param context 上下文
 * @param relativePath 扩展中某个文件相对于根目录的路径,如 https://s2-10710.kwimgs.com/kos/nlav10710/ks-stack/images/test.jpg
 */
function getExtensionFileAbsolutePath(context: vscode.ExtensionContext, relativePath: string): string {
    return path.join(context.extensionPath, relativePath)
}

/**
 * 从某个HTML文件读取能被Webview加载的HTML内容
 * @param {*} context 上下文
 * @param {*} templatePath 相对于插件根目录的html文件相对路径
 */
function getWebViewContent(context: vscode.ExtensionContext, templatePath: string): string {
    const resourcePath = getExtensionFileAbsolutePath(context, templatePath)
    const dirPath = path.dirname(resourcePath)
    let html = fs.readFileSync(resourcePath, "utf-8")
    // vscode不支持直接加载本地资源,需要替换成其专有路径格式,这里只是简单的将样式和JS的路径替换
    html = html.replace(
        /(<link.+?href="|<script.+?src="|<img.+?src=")(.+?)"/g,
        (m: any, $1: any, $2: any) => `${$1 + vscode.Uri.file(path.resolve(dirPath, $2)).with({ scheme: "vscode-resource" }).toString()}"`
    )
    return html
}

5.3 消息通信

webview 和普通网页一样,不能在页面中直接调用 VS Code 的 API,需要通过互相发送消息的方式进行通信,由 VS Code 进程去执行系统 API,在 webview 端:

// 向主进程发送消息
const vscode = acquireVsCodeApi()
// 发送消息
vscode.postMessage({ cmd: "cmd:logger", data: {} })

// 接收主进程发送过来的消息
window.addEventListener("message", (event) => {
    const message = event.data
})

acquireVsCodeApi 方法返回一个超级阉割版的 VS Code 对象,该函数对象只有三个方法。

图 12

在主进程中,webviewPanel 为通过 createWebviewPanel 创建出来的 webview 视图,

// 注册接收数据方法
webviewPanel.webview.onDidReceiveMessage(
    (message) => {
        console.log("========", message)
    },
    undefined,
    context.subscriptions
)

// 发送数据
webviewPanel.webview.postMessage({ cmd: "web:loading", data: {} })

5.4 主题

webview中的css中可以看到绑定在html上的颜色变量,在开发自己的webview页面中,可以采用现有的颜色变量去定制自己的主题。

图 15

另外 VS Code 也会在 body 中添加一个特殊的类来指示当前主题

  • vscode-light – 轻主题。
  • vscode-dark – 黑暗主题。
  • vscode-high-contrast – 高对比度主题。
body.vscode-light {
    color: black;
}
body.vscode-dark {
    color: white;
}
body.vscode-high-contrast {
    color: red;
}

5.5 调试

shift + command + p 打开开发人员,能看到打开开发人员工具选项,点击就能进入熟悉的调试页面了,或者在插件调试时直接按快捷键option + command + i可以看到自己创建的 webview 是整个页面的一个iframe

图 13

图 14

5.6 生命周期

webview 从属于创建他们的插件,插件必须保持住通过createWebviewPanel创建的 webview 实例,如果你的插件失去了这个关联,它就不能再访问 webview 了,不过即使这样, webview 还会继续展示在 VS Code 中,直到关闭 webview。

webview 是一个文本编辑器视图,用户可以随时关闭 webview 。当用户关闭了 webview 面板后,webview 就被销毁了。onDidDispose事件在被销毁时触发,我们在这个事件结束之后更新并释放 webview 资源。

插件也可以通过编程方式关闭 webview 视图,调用它的dispose()方法

查看更多关于 webview 内容

6. 自动补全

通过vscode.languages.registerCompletionItemProvider方法注册自动完成实现,接收 3 个参数:

  • 第一个是要关联的文件类型;
  • 第二个是一个对象,里面必须包含provideCompletionItemsresolveCompletionItem这 2 个方法;
  • 第三个是一个可选的触发提示的字符列表;

比如输入 log. 后弹出推荐列表

// 关联 js,ts,jsx,vue出现提示
vscode.languages.registerCompletionItemProvider(['javascript', 'vue', 'typescript', 'javascriptreact'], {

    async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) {
        const linePrefix = document.lineAt(position).text.substr(0, position.character)

        // config.completionKey 为定义的触发补全的关键词
        if (linePrefix.endsWith(config.completionKey) || linePrefix.endsWith(`${config.completionKey}.`) || linePrefix.endsWith(`${config.completionKey} `)){
            // 获得异步接口返回的数据列表
            let list = await DataSource.getCompletionList()
            list = list.slice(0, maxSlice)

            const res:any = await Promise.all(list.map(async (item:any, i:number) => {
                const cItem: vscode.CompletionItem = new vscode.CompletionItem(item.treeListLabel, vscode.CompletionItemKind.Snippet)
                cItem.insertText = ''
                if (i === 0){
                    cItem.preselect = true
                }
                const image = item.entityImgUrlList[0] || ''
                // 创建markdown
                cItem.documentation = new vscode.MarkdownString(`#### ${item.treeListLabel}\n ${item.tooltip}\n`)

                // 排序用的字符串,都想往前面排,用的String.fromCharCode(0)
                cItem.sortText = `${String.fromCharCode(0)}${String.fromCharCode(i)}`

                // 获得埋点的样例代码
                const { code } = await api.getExampleCode(item) as any
                cItem.documentation.appendCodeblock(code, 'javascript')

                let j:number = 1
                const insertText:string = code
                    .replace(///.*\n/g, '')
                    .replace(//*.**//g, '')
                    .replace(/${.*}/g, () => `$${j++}`)

                cItem.insertText = new vscode.SnippetString(insertText)

                // 为联想列表设置触发命令
                cItem.command = {
                    title: 'insertText',
                    command: 'track.insertExampleCode',
                    arguments: [new vscode.Range(position.line, linePrefix.indexOf(config.completionKey), position.line, position.character)]
                }
                return cItem
            })).then((result) => result)

            res.push(...apiCompletion.map((item:any, i:number) => {
                item.sortText = String.fromCharCode(0) + String.fromCharCode(res.length + i)
                return item
            }))

            return res
        }

        return null
    },

    /**
     * 光标选中当前自动补全item时触发动作,一般情况下无需处理
     * @param {*} item
     * @param {*} token
     */
    resolveCompletionItem(){
        return null
    }
}, ...['.', ' '])

7. 搜索

图 2

搜索功能是在顶部弹出一个输入框,在输入文字后获得搜索结果的列表。

// 定义显示的项
class MessageItem implements vscode.QuickPickItem {
    public label: string ='';
    public description: string= '';
    public detail: string ='';
    public alwaysShow:boolean = true;
    constructor(private data:any) {
        this.label = data.treeListLabel
        this.description = `状态:${data.eventStatusCn};`

        this.detail += data.committerCn ? `创建人:${data.committerCn};` : ''
        this.detail += data.updateOperator ? `更新人:${data.updateOperatorCn};` : ''
        this.detail += data.entityName ? `实体:${data.entityNameCn};` : ''
        this.detail += data.area ? `区域:${data.areaCn}` : ''
    }
}


// 创建一个输入框
const input = vscode.window.createQuickPick()
input.placeholder = "请输入关键词进行搜索"
input.value = ""
input.items = []
input.matchOnDescription = true
input.matchOnDetail = true
input.title = "搜索-支持名称、创建人、更新人及其中文"
// 监听值更改
input.onDidChangeValue(async (keyword) => {
    // 显示进度条
    input.busy = true
    // 异步请求数据
    const data: any = await api.getTrackList({ keyword: key })
    // 关闭进度条
    input.busy = false
    // 将item处理后填充上数据
    input.items = data.map((item: any) => new MessageItem(item))
})

// 选择某一项后执行相关命令
input.onDidChangeSelection((items: any) => {
    // 执行打开埋点详情命令
    vscode.commands.executeCommand("track.cmd.openDetail", data)
})

// 隐藏时
input.onDidHide(() => {
    // ...
})

input.show()

在实际处理中每次关闭搜索框,搜索框也就会对应的销毁,我们可以将搜索的结果保存起来,下次再进入到搜索时还是上次的搜索结果。

8. 打通登录

本插件作为一款需要和服务端交互的插件,用户认证必不可少了,在实现登录中我们可以有这样几套方案。

8.1 方案一 手动输入

创建一个登录的输入框,用户依次输入用户名、密码进行登录,然后将登录信息保存下来。这种方案相对麻烦,需要用户手动输入,并且需要创建一个登录服务,维护用户的登录信息。

8.2 方案二 复制 cookie

本插件的接口交互是直接调用现有 web 应用接口,可以直接把用户的登录信息,cookie保存起来,每次和服务端交互用此cookie就可以了。但是这种方式也有一些缺点:

    1. 操作起来不方便,需要先去浏览器抓接口的cookie,然后再复制到 VS Code 的插件中保存起来;
    1. cookie过期后不能自动更新,需要再次重复抓cookie
    1. cookie存储是直接放到 VS Code 的setting.json中,显示保存cookie也不安全,配置文件很容易被分享出去;

8.3 方案三 利用 SSO 单点登录

登录

借助公司内部的 SSO 系统与现有的 web 平台 ,通过一个简单的服务,将登录信息传递给 VS Code,如下:

图 1

如图所示,在进入到插件中,会先判断是否登录,如果已经登录则直接获取埋点列表,如果未登录则跳转到实现的一个登录页面中,vscode 跳转网页可以使用vscode.open命令,也可以使用第三方node库,如open

vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(url))

登录页面是域名与埋点管理 web 平台一样的独立服务,通过在网关配置不同的路由,将/vscode的请求转到登录服务上,因为域名相同,在请求登录服务时会自动携带上原 web 平台的登录信息。

图 2

然后再登录服务中校验登录信息是否正确,没有问题之后会返回一个重定向到 VS Code 的链接,同时在链接上会携带上登录信息,同时跳转回 VS Code 插件。

注:在链接上直接携带cookie也是不安全的,考虑到是内部系统就不在做更复杂的处理了。

从 chrome 如何到 VS Code 呢?

路由跳转规则 vscode://${publisher}.${name} ,publisher 与 name 为在package.json定义的。

登录服务核心代码,此处服务使用 Koa 创建

const baseUrl = "http://**.com"
const USER_INFO = `${baseUrl}/**`
const SSO_LOGIN_URL = "http://sso.com"

const returnUrl = `${baseUrl}/**?redirect=${encodeURIComponent(`${baseUrl}/vscode/login`)}`
// 重定向到sso平台
const ssoLoginUrl = `${SSO_LOGIN_URL}/?service=${encodeURIComponent(returnUrl)}`

async function redirectVscode(ctx, next) {
    if (ctx.path === "/vscode") {
        // 返回一个html页面,让登录页不那么空
        ctx.response.type = "html"
        ctx.body = fs.createReadStream(path.resolve(__dirname, "index.html"))
    } else if (ctx.path === "/vscode/login") {
        // 获取登录所需的cookie信息
        const userToken = ctx.cookies.get("userToken")
        if (userToken) {
            const cookie = `userToken=${userToken}`
            // 获取用户信息,判断当前cookie信息是否过期
            await got
                .get(USER_INFO, {
                    headers: {
                        cookie,
                    },
                })
                .then((res) => {
                    // 获取用户信息成功后直接跳转到vscode中
                    // 跳转协议: vscode://${publisher}.${name} publisher与name来自package.json中的定义
                    if (res.statusCode === 200) {
                        ctx.redirect(`vscode://ks-track.ks-track/?${cookie}`)
                        return
                    } else {
                        throw new Error()
                    }
                })
                .catch((e) => {
                    ctx.redirect(ssoLoginUrl)
                    return
                })
        } else {
            ctx.redirect(ssoLoginUrl)
            return
        }
    }
}

app.use(redirectVscode)

在 VS Code 插件中接收跳转过来的链接,通过vscode.window.registerUriHandler方法来注册一个回调处理程序,

vscode.window.registerUriHandler({
    handleUri(uri: vscode.Uri): vscode.ProviderResult<void> {
        config.cookie = uri.query
        setCookie(config.cookie)
        //...
    },
})

在 VS Code 中收到登录信息后,将登录信息保存到本地,可以使用 keytar 这一类的密码管理包,存到钥匙串中,或者直接保存到本地,在本插件中直接保存了一个json文件到本地。

了解更多

9. 打包发版、安装与更新

9.1 打包

可以通过官方提供的 VSCE 去发版,首先先安装,

npm install -g vsce

打包,

vsce publish

因为本次插件作为内部系统,不适合发布到应用市场中,要发布应用市场可参考,在本插件中直接打包成了一个 .vsix 的离线文件。

9.2 安装

对于离线的插件包,可以直接拖动到 VS Code 的插件管理列表中就能实现安装,安装后的插件保存到:

系统 安装路径
windows %USERPROFILE%.vscode\extensions
macOS ~/.vscode/extensions
Linux ~/.vscode/extensions

9.3 升级

对于离线包的升级,在每次打包后都会生成一个带版本号的.vsix文件,以及一个描述改版本信息的 json 文件文件,将这两个文件上传到 cdn 中,插件每次在激活时会下载本版本描述文件,读出文件的版本号同时与当前版本做对比,如果有更新则通过消息提示用户是否下载最新版本。

四、总结

在本次插件开发中主要使用到了树视图、搜索、代码自动补全、webview 功能,希望能给开发 VS Code 插件的同学一些帮助。 VS Code 插件还有大量的功能等待我们去探索,除了参考 VS Code 的官网,我们也可以通过 VS Code 的 index.d.ts 文件去了解相关 API 的作用。

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容