VSCode WebView插件(扩展)开发实战

8,666 阅读10分钟

VSCode是微软出的一款轻量级代码编辑器,免费而且功能强大,以功能强大、提示友好、不错的性能和颜值俘获了大量开发者的青睐,对JavaScript和NodeJS的支持非常好,自带很多功能,例如代码格式化,代码智能提示补全、Emmet插件等。

它是通过 Electron 实现跨平台的,而 Electron 则是基于 Chromium 和 Node.js,比如 VS Code 的界面,就是通过 Chromium 进行渲染的。同时, VS Code 是多进程架构,当 VS Code 第一次被启动时会创建一个主进程(main process),然后每个窗口,都会创建一个渲染进程( Renderer Process)。与此同时,VS Code 会为每个窗口创建一个进程专门来执行插件,也就是 Extension Host。除了这三个主要的进程以外,还有两种特殊的进程。第一种是调试进程,VS Code 为调试器专门创建了Debug Adapter 进程,渲染进程会通过 VS Code Debug Protocol 跟 Debug Adapter 进程通讯。

架构图如下:

不过这次分享我们不过多的探讨它的架构,主要看下插件(或者称为扩展,下同)怎么写。

VSCode插件分为哪些类型

vscode 插件开发的脚手架(执行yo code)我们可以看到有如下选项:

  • New Extension (TypeScript)
  • New Extension (JavaScript)
  • New Color Theme
  • New Language Support
  • New Code Snippets
  • New Keymap
  • New Extension Pack

通过cli我们可以直接创建扩展、主题、语言支持、代码片段、快捷键等插件项目,这些插件项目创建后开箱直用,按F5运行即可。

VSCode插件能做些什么?

  • 不受限的本地磁盘访问
  • 自定义命令、快捷键、菜单
    • 资源管理器右键菜单
    • 编辑器右键菜单
    • 标题菜单
    • 下拉菜单
    • 右上角图标
  • 自定义跳转
  • 自动补全
  • 悬浮提示
  • 自定义设置
  • 自定义欢迎页
  • 自定义webview(比如markdown preview
  • 自定义左侧功能面板(比如git
  • 自定义颜色主题、图标主题
  • 新增语言支持(Java.NetPythonDartGo……)

……等等等等

VSCode极其优秀的扩展架构给我们提供了非常大的施展拳脚的空间。

比如,你在项目中对反复执行某项繁杂操作很不爽,那么你是时候做一个插件解放你的双手了!!!

可以参考下面这个博客,博主对主流插件功能(包括自定义跳转、自动补全、悬浮提示)做了非常全面的介绍

VSCode插件开发全套攻略

如何实现一个webview插件

我今天主要讲一下,自己是如何实践webview插件的。对于前端而言,做一些能看得到的漂亮东西,总是更具有吸引力,所以我主要关注了webview这块。先贴个成品图:

首先,安装vscode cli,

npm install -g yo generator-code

再用cli创建一个New Extension (TypeScript)项目

yo code

它会帮我们初始化好如下几块内容 :

  • tsconfig.json
  • package.json
  • extension.ts
  • .vscode目录下的包括一键调试在内的配置项

我们暂时不太需要关心tsconfig.json文件,因为是开箱即用的,除非我们需要用到一些typescript的独特特性。

先来看看package.json里都有什么:

{
    // 插件的激活事件
    "activationEvents": [
        "onCommand:extension.sayHello"
    ],
    // 入口文件
    "main": "./src/extension",
    "engines": {
        "vscode": "^1.27.0"
    },
    // 贡献点,vscode插件大部分功能配置都在这里
    "contributes": {
        "commands": [
            {
                "command": "extension.sayHello",
                "title": "Hello World"
            }
        ]
    }
}
  • activationEvents扩展激活事件,属性值是个数组,包含一系列事件(除了onCommand之外还有onViewonUrionLanguage等等)。因为VSCode为了性能考虑,并不会一打开就加载所有的插件。只有当用户行为触发了该数组中包含的事件(比如执行命令或所打开文件的语言是json)时,才会激活插件(也可以配成"*",就会马上加载,但是不建议这样做);
  • main定义了整个插件的入口点;
  • engines插件最低支持的VSCode版本
  • contributes定义了插件所有的贡献点,比如commands(命令)、menus(菜单)、configuration(配置项)、keybindings(快捷键绑定)、snippets(代码片段)、views(侧边栏内view的实现)、iconThemes(图标主题)等等。

我们要配一个右上角的菜单,直接贴配置:

"contributes": {
  "commands": [{
    "command": "extension.colaMovie",
    "title": "Cola Movie",
    "icon": {
      "light": "./images/film-light.svg",
      "dark": "./images/film-dark.svg"
    }
  }],
  "menus": {
    "editor/title": [{
      "when": "isWindows || isMac",
      "command": "extension.colaMovie",
      "group": "navigation"
    }]
  }
}

解释:定义一个extension.colaMovie命令,顺便配置titleicon

为了一处命令配置多处使用,titleicon项放置在commands中了。此外,icon支持lightdark明暗两类主题。如果不配置icon,则会显示文字标题。

定义一个menus菜单,类型为editor/title,代表右上角图标。

  • when 配置了该菜单出现的场景(条件),除了isWindowsisMac还有非常多条件可以使用
  • command 指定点击该菜单会触发什么命令(commands中的命令)
  • group 指定菜单分组,主要用于编辑器右键菜单

然后我们再回过头来看一下main入口extension.ts文件:

const vscode = require('vscode');

/**
 * 插件被激活时触发,所有代码总入口
 * @param {*} context 插件上下文
 */
exports.activate = function(context: vscode.ExtensionContext) {
    console.log('恭喜,您的扩展“vscode-plugin-demo”已被激活!');
    // 注册命令
    context.subscriptions.push(vscode.commands.registerCommand('extension.colaMovie', function () {
        vscode.window.showInformationMessage('Hello World!');
    }));
};

/**
 * 插件被释放时触发
 */
exports.deactivate = function() {
    console.log('您的扩展“vscode-plugin-demo”已被释放!')
};

该入口文件导出了两个生命周期方法activatedeactivate

我们回忆一下之前的activationEvents属性,当里面相应的事件触发了插件时,activate方法会被唤起,当插件被销毁时,deactivate会被调用。

然后,我们必须在activate注册一个命令 extension.colaMovie

context.subscriptions.push(vscode.commands.registerCommand('extension.colaMovie', async () => { 
  vscode.window.showInformationMessage('Hello World!');  
}));

注意,所有注册的对象(不论是命令还是语言vscode.languages.registerDefinitionProvider或是其它)都必须要将结果放入context.subscriptions中去,这是为了方便deactivateVSCode帮你自动注销它们。

此时,我们按F5调试之后,已经可以看到右上角出现Cola Movie的小图标了,当我们点击它的时候会在右下角弹出Hello World!的提示信息。

让我们来完善一下点击事件,试着创建一个webview看看:

panel = vscode.window.createWebviewPanel(
  "movie",
  "Cola Movie",
  vscode.ViewColumn.One,
  {
    enableScripts: true,
    retainContextWhenHidden: true,
  }
);
  • enableScripts代表允许js脚本执行
  • retainContextWhenHidden代表当页签切换离开时保持插件上下文不销毁

VSCode为了性能考虑,非当前页签都会销毁上下文,直到切换回来再重建上下文。所以提供了setStategetState两个方法供webview使用以即时保存与恢复上下文。

此时,webview已经创建并打开,但是却一片空白。

这时我们需要给panel.webview.html设置html内容,但是:

出于安全考虑,Webview默认无法直接访问本地资源,它在一个孤立的上下文中运行,想要加载本地图片、jscss等必须通过特殊的vscode-resource:协议,网页里面所有的静态资源都要转换成这种格式,否则无法被正常加载。
vscode-resource:协议类似于file:协议,但它只允许访问特定的本地文件。和file:一样,vscode-resource:从磁盘加载绝对路径的资源。

找了一段替换html引用资源协议的函数,如下所示:

function getWebViewContent(context: vscode.ExtensionContext, templatePath: string) {
  const resourcePath = path.join(context.extensionPath, 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, $1, $2) => {
    return $1 + vscode.Uri.file(path.resolve(dirPath, $2)).with({ scheme: 'vscode-resource' }).toString() + '"';
  });
  return html;
}

我之前写过一个electron版本的Cola Movie,此时,我想将它移植进来试下水,看下webview插件能做到什么程度。

我先把那边的dist目录拷贝过来加载index.html

const html = getWebViewContent(context, 'dist/index.html');
panel.webview.html = html;

一经调试就发现,这里面有一个巨大的坑:

webview内部不允许发送ajax请求,所有ajax请求都是跨域的,因为webview本身是没有host

我之前那边做electron开发时碰到过跨域问题,通过简单的electron配置webSecurity: false就可以开放跨域权限:

let winProps = {
  title: '******',
  width: 1200,
  height: 800,
  backgroundColor: '#0D4966',
  autoHideMenuBar: true,
  webPreferences: {
    webSecurity: false,
    nodeIntegration: true
  }
};

可是VSCode并不会让我们接触electron配置,所以我想这条路是堵死了。

那怎么发送ajax请求把数据取到手呢?

我在extension.ts里试了下axios是可以发送请求并取到数据的,这里就引出我们接下来要讲的一个重头戏了:

消息通信

webview和普通网页一样,并不能直接调用任何VSCode API。但是,它唯一特别之处就在于多了一个名叫acquireVsCodeApi的方法,执行这个方法会返回一个简易版的vscode对象,具有如下三个方法:

  • getState()
  • postMessage(msg)
  • setState(newState)

这样的话,我们可以发消息让extension去帮我们发送http请求!

消息通信方式如下:

// 插件发送消息给webview
panel.webview.postMessage(message);

// webview接收消息
window.addEventListener('message', event => {
  const message = event.data;
  console.log('Webview接收到的消息:', message);
};

// webview发送消息给插件
const vscode = acquireVsCodeApi();
vscode.postMessage(message);

// 插件端接收消息
panel.webview.onDidReceiveMessage(message => {
    console.log('插件收到的消息:', message);
}, undefined, context.subscriptions);

写过electron程序的同学肯定知道,这同electronipcMain/ipcRenderer还有websocketsend/onmessage一样,两端互调接口是独立的,写出来略有些不是很好看……

cs-channel 跨端通信库

于是我又封装了一个跨端通信库cs-channel,并开源出去了,大家可以看一下使用方式。

extension端代码

const channel = new Channel({
  receiver: callback => {
    panel.webview.onDidReceiveMessage((message: IMessage) => {
      message.api && callback(message);
    }, undefined, context.subscriptions);
  },
  sender: message => void panel.webview.postMessage(message)
});
channel.on('http-get', async param => {
  return await Q(http.get(param.url, { params: param.params }));
});

上面,插件端就完成了一个http-get的接口定义

webview端代码

const vscode = acquireVsCodeApi();
const channel = new Channel({
  sender: message => void vscode.postMessage(message),
  receiver: callback => {
    window.addEventListener('message', (event: { data: any }) => {
      event && event.data && callback(event.data);
  });
  }
});
const result = await channel.call('http-get', { url, ...data });

上面,webview端就完成了一次http-get接口的调用,并直接拿到了插件端的http调用结果!

Channel对象,一个项目实例化两个(webview + extension)就足够了,不用经常实例化。
若是一个项目有多个通信方式,比如websocket + web worker + iframe父子通信,就实例化各自的Channel对象即可。

DLNA 投屏功能迁移

之前electron版本的Cola Movie具备DLNA投屏功能,我觉着在VSCode的插件里既然能全量使用nodejs api,应该也能投屏才对?

我写了段测试代码

import * as Browser from 'nodecast-js';
// 是的,你没看错,借助于nodecast-js库nodejs使用dlna就是这么简单
const browser = new Browser();
browser.onDevice(function () {
  console.log(browser.getList());
});
browser.start();

确实打印了局域网内所有的可投屏设备~

那事情就简单多了,利用刚刚和ajax同样的原理让extension帮忙拿设备列表,并帮忙推送投屏请求即可。
直接贴代码:

extension端代码

const DLNA = {
  browser: null,
  start: (): Promise<any[]> => {
    if (DLNA.browser !== null) {
      DLNA.stop();
    }
    return new Promise(resolve => {
      DLNA.browser = new Browser();
      DLNA.browser.onDevice(function () {
        resolve(DLNA.browser.getList());
      });
      setTimeout(() => {
        resolve([]);
      }, 8000);
      DLNA.browser.start();
    });
  },
  stop: () => {
    DLNA.browser && DLNA.browser.destroy();
    DLNA.browser = null;
  }
};

channel.on('dlna-request', async param => {
  const devices = await DLNA.start();
  localDevices = devices;
  return devices;
});

channel.on('dlna-destroy', async param => {
  DLNA.stop();
});

channel.on('dlna-play', async param => {
  localDevices.find(device => device.host === param.host).play(param.url, 60);
});

定义三个接口:

  • dlna-request获取设备列表
  • dlna-play投屏视频播放地址url到某设备
  • dlna-destroy销毁browser对象

webview端代码

const DLNA = {
  start: async () => await chanel.call<IDevice[]>('dlna-request'),
  play: (device: IDevice, url: string) => channel.call('dlna-play', { host: device.host, url }),
  stop: () => channel.call('dlna-destroy');
}

F5,调试,投屏成功!

PS:其实还有一个遗憾,就是VSCode本身在打包electron的时候移除了ffmpeg,导致webview里根本无法使用audiovideo标签,所以播放功能是做不了了。而且cookielocalStorage等接口一律无法访问。所以播放功能我就直接做成打开浏览器播放了。只不过chrome要实现m3u8源的播放需要安装一个插件:Play HLS M3u8