如何在 VSCode 扩展中使用 WebView

开发 VSCode 扩展,有时候 VSCode 本身的交互无法满足需要,这时候就可以使用 WebView 来实现自定义的交互。本文将介绍如何在 VSCode 扩展中使用 WebView。VSCode 目前最新版本已经可以在 Editor,SideBar,Panel 中使用 WebView。基本覆盖了 VSCode 的主要 UI 部分。

1. 创建 WebView

在 Editor 和 SideBar / Panel 中创建 WebView 的方法是不同的。在 Editor 中,可以使用 vscode.window.createWebviewPanel 方法,而在 SideBar / Panel 中,需要使用 vscode.window.registerWebviewViewProvider 方法。

1.1 Editor 中创建 WebView

vscode.window.createWebviewPanel 方法中,有四个参数:

  • viewType:WebView 的类型,用于区分不同的 WebView。
  • title:WebView 的标题。
  • viewColumn:WebView 的位置,可以是 vscode.ViewColumn 枚举值。
  • options:WebView 的配置,可以设置 WebView 的 enableScriptsretainContextWhenHidden 等属性。
const panel = vscode.window.createWebviewPanel(
    'webviewSample',
    'WebView 示例',
    vscode.ViewColumn.Beside,
    {
        enableScripts: true, // 是否启用脚本
        localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath))] // 本地资源根目录
    }
);

创建后,可以通过 panel.webview 获取 WebView 的实例,然后可以通过 panel.webview.html 设置 WebView 的内容。

panel.webview.html = `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebView 示例</title>
</head>
<body>
    <h1>WebView 示例</h1>
</body>
</html>
`;

这样,就可以在 Editor 中创建一个 WebView 了。可以把这部分代码放在一个 Command 中(Command 需要注册与定义),然后通过 Command Palette 执行。

vscode.commands.registerCommand('webviewSample.show', () => {
    const panel = vscode.window.createWebviewPanel(
        'webviewSample',
        'WebView 示例',
        vscode.ViewColumn.Beside,
        {
            enableScripts: true,
            localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath))]
        }
    );

    panel.webview.html = `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>WebView Sample</title>
    </head>
    <body>
        <h1>Hello WebView in Editor</h1>
    </body>
    </html>
    `;
});

这样,按下 Ctrl + Shift + P 在 Command Palette 中执行 webviewSample.show 命令,打开一个 WebView。

1.2 SideBar / Panel 中创建 WebView

在 SideBar / Panel 中创建 WebView,则是使用 vscode.window.registerWebviewViewProvider 方法。这个方法有两个参数:

  • viewType:WebView 的 Id,用于区分不同的 WebView。
  • provider:一个 vscode.WebviewViewProvider 对象,主要是实现了 resolveWebviewView 方法。

因此,你需要先实现一个 WebviewViewProvider

class WebviewSampleProvider implements vscode.WebviewViewProvider {
  resolveWebviewView(webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken) {
      webviewView.webview.html = `
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>WebView 示例</title>
      </head>
      <body>
          <h1>Hello WebView Provider</h1>
      </body>
      </html>
      `;
  }
}

然后,注册这个 Provider:

vscode.window.registerWebviewViewProvider('webviewSample', new WebviewSampleProvider());

接着,还需要去 Package.json 中配置 contributes.viewsContainers,添加一个视图容器:

"contributes": {
  "viewsContainers": {
    "activitybar": [
      {
        "id": "webviewSidebar",
        "title": "WebView Sidebar",
        "icon": "webviewSample.svg" // 建议使用无色 svg 图标
      }
    ]
  }
}

activitybar 是 SideBar 的视图容器,这里创建了一个叫 webviewSidebar 的视图容器。接着,我们再去 contributes.views 中添加一个视图:

"contributes": {
  "views": {
    "webviewSidebar": [
      {
        "id": "webviewSample", // 此处的 id 和 Webview 注册时填写的 viewType 一致
        "name": "WebView Sample",
        "type": "webview" // 视图类型为 webview
      }
    ]
  }
}

这样启动扩展,就可以在 SideBar 中看到一个 webviewSample.svg 的图标了,点击即可加载 webviewSample 的 Webview。如果是要在 Panel 中创建 WebView,只需要把 activitybar 改成 panel 即可。值得注意的是,视图容器的的类型是数组,也就是一个容器可以包含多个视图。在 Sidebar 中,多个视图会使用纵向排列,而在 Panel 中,多个视图会使用横向排列。多个视图的话,视图是可以折叠的,默认为展开,如果需要初始化时为折叠,则可以通过配置 visibilitycollapsed 来折叠。

2. WebView 通信

WebView 和 VSCode 之间的通信,可以通过 postMessageonMessage 方法来实现。WebView 可以通过 postMessage 向 VSCode 发送消息,VSCode 可以通过 onMessage 监听 WebView 发送的消息。

每个 WebView 都有一个 acquireVsCodeApi 方法,可以获取一个 vscode 对象,这个对象有一个 postMessage 方法,可以向 VSCode 发送消息。

const vscode = acquireVsCodeApi();

vscode.postMessage({ command: 'alert', text: 'Hello VSCode!' });

在 VSCode 中,可以通过 webview 对象的 onDidReceiveMessage 方法监听 WebView 发送的消息。webview 对象分别在 WebviewPanelWebviewView 中取得。

webview.onDidReceiveMessage(message => {
    switch (message.command) {
        case 'alert':
            vscode.window.showInformationMessage(message.text);
            break;
    }
});

而在 vscode 的 extension Host 中,则可以通过 webview 对象的 postMessage 方法向 WebView 发送消息。

webview.postMessage({ command: 'alert', text: 'Hello WebView!' });

在 WebView 中,可以通过 window.addEventListener 方法监听 message 事件,来接收 VSCode 发送的消息。

window.addEventListener('message', event => {
    const message = event.data;
    switch (message.command) {
        case 'alert':
            alert(message.text);
            break;
    }
});

这样,就可以实现 WebView 和 VSCode 之间的通信了。

此外,vscode 对象不仅有 postMessage 方法,还有 getStatesetState 方法,可以用来保存 WebView 的状态。这样,就可以在 WebView 的生命周期中保存一些状态了。

3. WebView 生命周期

WebView 的生命周期主要有两个事件:onDidDisposeonDidChangeViewStateonDidDispose 会在 WebView 被销毁时触发,onDidChangeViewState 会在 WebView 的可见性发生变化时触发。

panel.onDidDispose(() => {
    // WebView 被销毁时触发
});

panel.onDidChangeViewState(() => {
    // WebView 的可见性发生变化时触发
});

WebviewViewProvider 中,也有类似的生命周期事件:

class WebviewSampleProvider implements vscode.WebviewViewProvider {
  resolveWebviewView(webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken) {
      webviewView.onDidDispose(() => {
          // WebView 被销毁时触发
      });

      webviewView.onDidChangeVisibility(() => {
          // WebView 的可见性发生变化时触发
      });
  }
}

这样,就可以在 WebView 的生命周期中做一些操作了。

如果希望 WebView 在隐藏时不被销毁,可以在创建 WebView 时设置 retainContextWhenHiddentrue。SideBar / Panel 中的 WebView 默认是保持上下文的,因此无需设置。

4. WebView 中的资源

WebView 中可以加载本地资源,但是需要通过 localResourceRoots 属性来指定本地资源的根目录。这样,WebView 才能加载本地资源。

const panel = vscode.window.createWebviewPanel(
    'webviewSample',
    'WebView 示例',
    vscode.ViewColumn.Beside,
    {
        enableScripts: true,
        localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath))]
    }
);

然后,可以通过 webview.asWebviewUri 方法来获取本地资源的 Uri。

const scriptUri = panel.webview.asWebviewUri(vscode.Uri.file(path.join(context.extensionPath, 'script.js')));

这样,就可以获得一个本地资源的 Uri 了。然后,可以在 WebView 中通过这个 Uri 来加载本地资源。

webviewView.webview.html = `
  <!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>WebView 示例</title>
      <script src="${scriptUri}"></script>
  </head>
  <body>
      <h1>Hello WebView Provider</h1>
  </body>
  </html>
  `;

要注意的是,WebView 中加载的资源,需要遵循 CSP(Content Security Policy)规则。因此,需要在 WebView 的 contentSecurityPolicy 属性中设置 CSP 规则。

const panel = vscode.window.createWebviewPanel(
    'webviewSample',
    'WebView 示例',
    vscode.ViewColumn.Beside,
    {
        enableScripts: true,
        localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath)],
        contentSecurityPolicy: 'default-src \'none\'; script-src vscode-resource: \'unsafe-inline\'; style-src vscode-resource: \'unsafe-inline\';'
    }
);

这样,就可以在 WebView 中加载本地资源了。

5. Web 前端框架支持与调试

前面说了那么多,都只是原生的 Web 技术。如果想要在 WebView 中使用 React,Vue 等前端框架,需要注意一些问题。

使用前端框架,我们并没有办法等做好页面,打包好,然后再与 VSCode 的 Host 代码联调,因此就需要通过某种方法来让 WebView 加载本地服务。

因此,我们可以引入一个中间层,这个中间层是一个静态网页,然后通过 iframe 的方式来加载本地服务。

<!DOCTYPE html>
<html>
  <head>
    <base href="">
    <style>
      iframe {
          width: 100%;
          border: none;
          height: 99.5vh;
      }  
      body {
          overflow: hidden;
      }        
    </style>
  </head>
  <body style="padding: 0;">
    <iframe id="main" src="http://localhost:5173" 
      frameborder="0" 
      sandbox="allow-same-origin allow-pointer-lock allow-scripts allow-downloads allow-forms allow-popups" 
      allow="clipboard-read; clipboard-write;"></iframe>
    <script src="js/main.js"></script>
  <body>
</html>
// main.js
// 获取 iframe 的 Vite 本地服务页面的 window 对象
let main = document.getElementById('main').contentWindow;
const vscode = acquireVsCodeApi();

document.getElementById('main').onload = () => {
  // 待页面加载完成后,将 vscode 的默认样式转发给 iframe 内页面
  main.postMessage({ command: 'style', data: document.querySelector('html').getAttribute('style')}, '*');
};

// 消息通信转发
window.addEventListener('message', event => {
  const message = event.data;
  switch (message.command) {
  case 'forward':
    {
      // 转发 iframe 的消息给扩展后端
      message.command = message.real;
      vscode.postMessage(message);
      break;
    }
  default:
    {
      // 转发扩展后端的消息给 iframe
      main.postMessage(message, '*');
      break;
    }
  }
});

这个中间层做了三件事:

  1. 通过 iframe 加载本地服务。
  2. 将 vscode 的默认样式转发给 iframe 内页面。
  3. 将消息通信转发给 iframe 内页面,以及将 iframe 内页面的消息转发给扩展 Host。

而框架网页,则需要再全局加上一句:window.vscode = window.acquireVsCodeApi ? window.acquireVsCodeApi() : window.parent;,这样就可以在框架中使用 vscode 对象了。

此外,还需要接收 style 消息,将 vscode 的默认样式应用到框架网页中。

window.addEventListener('message', (event) => {
  const message = event.data;
  switch (message.command) {
    case 'style': {
      // 接受 iframe 父窗体转发的 VSCode html 注入的 style
      document.querySelector('html')!.setAttribute('style', message.data);
      break;
    }
  }
});

这样,调试时,我们可以先启动本地服务,然后再启动 VSCode 扩展,这样就可以在 WebView 中调试 React,Vue 等前端框架了。而打包时,只需要将打包输出目录配置为扩展的打包目录,然后在 .vscodeignore 中配置中间层的目录忽略打包即可。

因此,在返回 Webview 的 html 时,可以这样写:

// 通过 dev 文件夹是否存在来判断现在是打包模式还是开发模式
let exists = fs.existsSync(path.resolve(__dirname, '..', '..', 'dev'));

// 获取 index.html 文件路径
let mainHtml = exists ? 
  path.resolve(__dirname, '..', '..', 'dev', 'index.html') : 
  path.resolve(__dirname, '..', 'webview', 'index.html');

const root = this.context.extensionPath;
// 获取 base 路径的 VSCode uri,这样才能载入本地资源
let baseUrl = vscode.Uri.file(exists ?
  path.join(root, 'dev', '/') :
  path.join(root, 'out', 'webview', '/')
);

// 读取到的文件做一些处理,替换 base 路径,添加 CSP (才能正常执行外部的 js)等。
return fs.readFileSync(mainHtml).toString().replace(/<base href="[^"]*">/, 
    `<base href="${this.webview!.asWebviewUri(baseUrl)}">`)
  .replace(/<(script|link) /g, '<$1 nonce="vuescript" ')
  .replace(/<head>/, `<head>
    <meta http-equiv="Content-Security-Policy" content="default-src 'none'; 
    img-src *; font-src http://* https://*; style-src http://* https://* 'unsafe-inline'; frame-src *;script-src 'nonce-vuescript';">`);

这里用了一个 nonce 来解决 CSP 的脚本加载问题,具体可以看这篇文章

这样,就可以在 WebView 中使用调试 React,Vue 等前端框架的网页了。

6. 最后

可能看完文章你还是云里雾里,不过没关系,这里准备了一个 Vue3 + Element Plus 样例工程,你可以参考这个工程来快速编写一个使用 WebView 的 VSCode 扩展。

文章首发于:https://my.hancel.org/2024/04/04/how-to-use-webview-in-vscode-extension.html