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

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

它是经过 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 进程通信。html

架构图以下:前端

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

VSCode插件分为哪些类型

vscode 插件开发的脚手架(执行yo code)咱们能够看到有以下选项:ios

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

经过cli咱们能够直接建立扩展、主题、语言支持、代码片断、快捷键等插件项目,这些插件项目建立后开箱直用,按F5运行便可。git

VSCode插件能作些什么?

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

……等等等等web

VSCode极其优秀的扩展架构给咱们提供了很是大的施展拳脚的空间。ajax

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

能够参考下面这个博客,博主对主流插件功能(包括自定义跳转、自动补全、悬浮提示)作了很是全面的介绍typescript

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

相关文章
相关标签/搜索