# Webview API

webview API为开发者提供了完全自定义视图的能力,例如内置的Markdown插件使用了webview渲染Markdown预览文件。Webview也能用于构建比VS Code原生API支持构建的更加复杂的用户交互界面。

可以把webview看成是VS Code中的iframe,它可以渲染几乎全部的HTML内容,它通过消息机制和插件通信。这样的自由度令我们的webview非常强劲并将插件的潜力提升到了新的高度。

# 相关链接


# 使用的VS Code API

# 我应该用webview吗?


webview虽然很赞,但是我们应该节制地使用这个功能——比如当VS Code原生API不够用时。Webview重度依赖资源,所以它脱离插件的进程而单独运行在其他环境中。在VS Code中使用设计不良的webview会让用户抓狂。

在使用webview之前,请作以下考虑:

  • 这个功能真的需要VS Code来提供吗?分离成一个应用或者网站会不会更好?
  • webview是实现这个特性的最后方案吗?VS Code原生API是否能达到同样的目的呢?
  • 你的webview所牺牲的高资源占用是否能换得同样的用户价值?

请记住:不要因为能使用webview而滥用webview。相反,如果你有充足的理由和自信,那么本篇教程对你来说会非常有用,现在就让我们开始吧。

# Webviews API 基础


为了解释webviewAPI,我们先构建一个简单的Cat Coding插件。这个插件会用一个webview显示猫写代码的gif。随着我们不断了解API,我们会不断地给插件添加功能,包括我们的猫写了多少行代码的计数跟踪器,如果猫猫写出了bug还会有一个提示弹出框。

这是Cat Coding插件的第一版package.json,你可以在这里找到完整的代码。我们的第一版插件提供了一个命令,叫做catCoding.start。当用户从命令面板调用Cat Coding: Start new cat coding session,或者一个创建好的键绑定命令,我们的猫猫会出现在webview窗口内。

{
  "name": "cat-coding",
  "description": "Cat Coding",
  "version": "0.0.1",
  "publisher": "bierner",
  "engines": {
    "vscode": "^1.23.0"
  },
  "activationEvents": ["onCommand:catCoding.start"],
  "main": "./out/src/extension",
  "contributes": {
    "commands": [
      {
        "command": "catCoding.start",
        "title": "Start new cat coding session",
        "category": "Cat Coding"
      }
    ]
  },
  "scripts": {
    "vscode:prepublish": "tsc -p ./",
    "compile": "tsc -watch -p ./",
    "postinstall": "node ./node_modules/vscode/bin/install"
  },
  "dependencies": {
    "vscode": "*"
  },
  "devDependencies": {
    "@types/node": "^9.4.6",
    "typescript": "^2.8.3"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

现在让我们实现catCoding.start命令,在我们的主文件中,像下面这样注册一个基础的webview:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      // 创建并显示新的webview
      const panel = vscode.window.createWebviewPanel(
        'catCoding', // 只供内部使用,这个webview的标识
        'Cat Coding', // 给用户显示的面板标题
        vscode.ViewColumn.One, // 给新的webview面板一个编辑器视图
        {} // Webview选项。我们稍后会用上
      );
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

vscode.window.createWebviewPanel函数创建并在编辑区展示了一个webview,下图显示了如果你试着运行catCoding.start命令会显示的东西:

一个空的webview

我们的命令以正确的标题打开了一个新的webview面板,但是没有任何内容!要想把我们的猫加到这个面板里面,我们需要webview.html设置HTML内容。

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      // 创建和显示webview
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {}
      );

      // 设置HTML内容
      panel.webview.html = getWebviewContent();
    })
  );
}

function getWebviewContent() {
  return
    `
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Cat Coding</title>
        </head>
        <body>
            <img src="https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" width="300" />
        </body>
        </html>
    `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

如果你再次运行命令,应该能看到下图:

含有html内容的webview

大功告成!

webview.html应该是一个完整的HTML文档。使用HTML片段或者格式错乱的HTML会造成异常。

# 更新webview内容

webview.html也能在webview创建后更新内容,让我们用猫猫轮播图使Cat Coding具有动态性:

import * as vscode from 'vscode';

const cats = {
  'Coding Cat': 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif',
  'Compiling Cat': 'https://media.giphy.com/media/mlvseq9yvZhba/giphy.gif'
};

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {}
      );

      let iteration = 0;
      const updateWebview = () => {
        const cat = iteration++ % 2 ? 'Compiling Cat' : 'Coding Cat';
        panel.title = cat;
        panel.webview.html = getWebviewContent(cat);
      };

      // 设置初始化内容
      updateWebview();

      // 每秒更新内容
      setInterval(updateWebview, 1000);
    })
  );
}

function getWebviewContent(cat: keyof typeof cats) {
  return
    `
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Cat Coding</title>
        </head>
        <body>
            <img src="${cats[cat]}" width="300" />
        </body>
        </html>
    `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

更新webview内容

因为webview.html方法替换了整个webview内容,页面看起来像重新加载了一个iframe。记住:如果你在webview中使用了脚本,那就意味着webview.html的重置会使脚本状态重置。

上述示例也使用了webview.title改变编辑器中的展示的文件名称,设置标题不会使webview重载。

# 生命周期

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

因为webview是一个文本编辑器视图,所以用户可以随时关闭webview。当用户关闭了webview面板后,webview就被销毁了。在我们的例子中,销毁webview时抛出了一个异常,说明我们上面的示例中使用的seInterval实际上产生了非常严重的Bug:如果用户关闭了面板,setInterval会继续触发,而且还会尝试更新panel.webview.html,这当然会抛出异常。喵星人可不喜欢异常,我们现在就来解决这个问题吧。

onDidDispose事件在webview被销毁时触发,我们在这个事件结束之后更新并释放webview资源。

import * as vscode from 'vscode';

const cats = {
  'Coding Cat': 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif',
  'Compiling Cat': 'https://media.giphy.com/media/mlvseq9yvZhba/giphy.gif'
};

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {}
      );

      let iteration = 0;
      const updateWebview = () => {
        const cat = iteration++ % 2 ? 'Compiling Cat' : 'Coding Cat';
        panel.title = cat;
        panel.webview.html = getWebviewContent(cat);
      };

      updateWebview();
      const interval = setInterval(updateWebview, 1000);

      panel.onDidDispose(
        () => {
          // 当面板关闭时,取消webview内容之后的更新
          clearInterval(interval);
        },
        null,
        context.subscriptions
      );
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

插件也可以通过编程方式关闭webview视图——调用它们的dispose()方法。我们假设,现在限制我们的猫猫每天工作5秒钟:

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {}
      );

      panel.webview.html = getWebviewContent(cats['Coding Cat']);

      // 5秒后,程序性地关闭webview面板
      const timeout = setTimeout(() => panel.dispose(), 5000);

      panel.onDidDispose(
        () => {
          // 在第五秒结束之前处理用户手动的关闭动作
          clearTimeout(timeout);
        },
        null,
        context.subscriptions
      );
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 移动和可见性

当webview面板被移动到了非激活标签上,它就隐藏起来了。但这时并不是销毁,当重新激活标签后,VS Code会从webview.html自动恢复webview的内容。

webview自动恢复内容

.visible属性告诉你当前webview面板是否是可见的。

插件也可以通过调用reveal()方法,程序性地将webview面板激活。这个方法可以接受一个用于放置面板的目标视图布局。一个面板一次只能显示在一个编辑布局中。调用reveal()或者拖动webview面板到新的编辑布局中去。

在标签页中移动webview视图

现在更新我们的插件,一次只允许存在一个webview视图。如果面板处于非激活状态,那catCoding.start命令就把这个面板激活。

export function activate(context: vscode.ExtensionContext) {
  // 追踪当前webview面板
  let currentPanel: vscode.WebviewPanel | undefined = undefined;

  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const columnToShowIn = vscode.window.activeTextEditor
        ? vscode.window.activeTextEditor.viewColumn
        : undefined;

      if (currentPanel) {
        // 如果我们已经有了一个面板,那就把它显示到目标列布局中
        currentPanel.reveal(columnToShowIn);
      } else {
        // 不然,创建一个新面板
        currentPanel = vscode.window.createWebviewPanel(
          'catCoding',
          'Cat Coding',
          columnToShowIn,
          {}
        );
        currentPanel.webview.html = getWebviewContent(cats['Coding Cat']);

        // 当前面板被关闭后重置
        currentPanel.onDidDispose(
          () => {
            currentPanel = undefined;
          },
          null,
          context.subscriptions
        );
      }
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

下面是一个新插件的行为:

在单个面板中展示

不论何时,如果webview的可见性改变了,或者当webview移动到了新的视图布局中,就会触发onDidChangeViewState。我们的插件可以利用这个时间改变布局中的webview显示的猫:

const cats = {
  'Coding Cat': 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif',
  'Compiling Cat': 'https://media.giphy.com/media/mlvseq9yvZhba/giphy.gif',
  'Testing Cat': 'https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif'
};

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {}
      );
      panel.webview.html = getWebviewContent(cats['Coding Cat']);

      // 根据视图状态变动更新内容
      panel.onDidChangeViewState(
        e => {
          const panel = e.webviewPanel;
          switch (panel.viewColumn) {
            case vscode.ViewColumn.One:
              updateWebviewForCat(panel, 'Coding Cat');
              return;

            case vscode.ViewColumn.Two:
              updateWebviewForCat(panel, 'Compiling Cat');
              return;

            case vscode.ViewColumn.Three:
              updateWebviewForCat(panel, 'Testing Cat');
              return;
          }
        },
        null,
        context.subscriptions
      );
    })
  );
}

function updateWebviewForCat(panel: vscode.WebviewPanel, catName: keyof typeof cats) {
  panel.title = catName;
  panel.webview.html = getWebviewContent(cats[catName]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

响应onDidChangeViewState事件

# 检查和调试webviews

在命令面板中输入Developer: Toggle Developer Tools能帮助你调试webview。运行命令之后会为当前可见的webview加载一个devtool:

Webview开发者工具

webview的内容是在webview文档中的一个iframe中的,用开发者工具检查和修改webview的DOM,在webview内调试脚本。

如果你用了webview开发者工具的console,确保你在Console面板左上角的下拉框里选中了当前激活窗体环境:

选择激活窗体

激活窗体环境是webview脚本执行的地方。

另外,Developer: Reload Webview命令会刷新所有已激活的webview。如果你需要重置一个webview的状态,这个命令会非常有用,或者你想要读取硬盘内容的webview更新一下,也可以使用这个方法。

# 加载本地内容


webview运行在独立的环境中,因此不能直接访问本地资源,这是出于安全性考虑的做法。这也意味着要想从你的插件中加载图片、样式等其他资源,或是从用户当前的工作区加载任何内容的话,你必须使用webview中的vscode-resource:协议。

vscode-resource:协议就像file:协议一样,不过它只允许访问本地文件。和file:一样的是,vscode-resource:只能从绝对路径中加载资源。

想想一下,我们想要从本地把喵喵们的gif打包进来,而不是从Giphy(国外出名的gif收集站)里加载进来。要想做到这点,我们首先给本地文件新建一个URI,然后用vscode-resource:协议更新这些URI:

import * as vscode from 'vscode';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {}
      );

      // 获取磁盘上的资源路径
      const onDiskPath = vscode.Uri.file(
        path.join(context.extensionPath, 'media', 'cat.gif')
      );

      // 获取在webview中使用的特殊URI
      const catGifSrc = onDiskPath.with({ scheme: 'vscode-resource' });

      panel.webview.html = getWebviewContent(catGifSrc);
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

catGifSrc的值最后会像这样:

vscode-resource:/Users/toonces/projects/vscode-cat-coding/media/cat.gif
1

默认情况下,scode-resource:只能访问下列地址的资源:

  • 你的插件安装的目录
  • 用户当前激活的工作区

你也可以用data URI将资源直接嵌套到webview中去。

# 控制本地资源访问

使用localResourceRoots选项,webview可以控制vscode-resource:加载的的资源。 localResourceRoots定义了可能被加载的本地内容的根URI。

我们用localResourceRoots去约束Cat Codingwebview只加载我们插件的media目录下的内容:

import * as vscode from 'vscode';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {
          // 只允许webview加载我们插件的`media`目录下的资源
          localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))]
        }
      );

      const onDiskPath = vscode.Uri.file(
        path.join(context.extensionPath, 'media', 'cat.gif')
      );
      const catGifSrc = onDiskPath.with({ scheme: 'vscode-resource' });

      panel.webview.html = getWebviewContent(catGifSrc);
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

为了禁止所有的本地资源,只要把localResourceRoots设为[]就好了。

通常来说,webview应该和加载本地资源一样严格,然而,vscode-resourcelocalResourceRoots并不保证百分百的安全性。请确保你的webview遵循安全性最佳实践,强烈建议考虑添加一个内容安全政策以便约束之后加载的内容。

# 给webview内容加上主题

webview可以基于当前的VS Code主题和CSS改变自身的样式。VS Code将主题分成3中类别,而且在body元素上加上了特殊类名以表明当前主题:

  • vscode-light——亮色主题
  • vscode-dark——暗色主题
  • vscode-high-contrast——高反差主题

下列CSS改变了基于用户当前主题的webview字体颜色:

body.vscode-light {
  color: black;
}

body.vscode-dark {
  color: white;
}

body.vscode-high-contrast {
  color: red;
}
1
2
3
4
5
6
7
8
9
10
11

当开发一个webview应用的时候,请保证应用能在三种主题下都可以运作,务必在高反差模式下测试你的webview,以便有视觉障碍的用户也能正常使用。

webview可以通过CSS variables访问VS Code主题,这些变量以vscode为前缀,并且用-替代了.,例如editor.foreground变成了var(--vscode-editor-foreground)

code {
  color: var(--vscode-editor-foreground);
}
1
2
3

更多可用的主题变量,参阅主题色彩

下面也定义了一些与字体有关的变量:

  • -vscode-editor-font-family - 编辑器的文字类型(设置中的editor.fontFamily配置项)
  • -vscode-editor-font-weight - 编辑器的文字粗细(设置中的editor.fontWeight配置项)
  • -vscode-editor-font-size - 编辑器文字大小(设置中的editor.fontSize配置项)

# 脚本和信息传递


既然webview就像iframe一样,也就是说它们也可以运行脚本,webview中的Javascript默认是禁用的,不过我们能用enableScripts: true打开它。

让我们写一段脚本,追踪我们家喵星人写代码的行数。运行一个基础脚本非常的容易,但是注意这个示例只作演示用途,在实践中,你的webview应该遵循内容安全政策,禁止行内脚本。

import * as path from 'path';
import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(vscode.commands.registerCommand('catCoding.start', () => {
        const panel = vscode.window.createWebviewPanel('catCoding', "Cat Coding", vscode.ViewColumn.One, {
            // 在webview中启用脚本
            enableScripts: true
        });

        panel.webview.html = getWebviewContent();
    }));
}

function getWebviewContent() {
    return
        `
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Cat Coding</title>
            </head>
            <body>
                <img src="https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" width="300" />
                <h1 id="lines-of-code-counter">0</h1>

                <script>
                    const counter = document.getElementById('lines-of-code-counter');

                    let count = 0;
                    setInterval(() => {
                        counter.textContent = count++;
                    }, 100);
                </script>
            </body>
            </html>
        `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

在webview中运行脚本

哇!真是位高产的喵主子!

注意

webveiw的脚本能做到任何普通网页脚本能做到的事情,但是webview运行在自己的上下文中,脚本不能访问VS Code API。

# 将插件的信息传递到webview

插件可以用webview.postMessage()将数据发送到它的webview中。这个方法能发送任何序列化的JSON数据到webview中,在webview中则通过message事件接受信息。

我们现在就来看看这个实现,在Cat Coding中新增一个命令来表示我们家的喵在重构他的代码(所以会减少代码总行数)。新增catCoding.doRefactor命令,利用postMessage把指示发送到webview中,webview中的window.addEventListener('message' event => { ... })则会处理这些信息:

export function activate(context: vscode.ExtensionContext) {

    // 现在只有一只喵喵程序员了
    let currentPanel: vscode.WebviewPanel | undefined = undefined;

    context.subscriptions.push(vscode.commands.registerCommand('catCoding.start', () => {
        if (currentPanel) {
            currentPanel.reveal(vscode.ViewColumn.One);
        } else {
            currentPanel = vscode.window.createWebviewPanel('catCoding', "Cat Coding", vscode.ViewColumn.One, {
                enableScripts: true
            });
            currentPanel.webview.html = getWebviewContent();
            currentPanel.onDidDispose(() => { currentPanel = undefined; }, undefined, context.subscriptions);
        }
    }));

    // 我们新的命令
    context.subscriptions.push(vscode.commands.registerCommand('catCoding.doRefactor', () => {
        if (!currentPanel) {
            return;
        }

        // 把信息发送到webview
        // 你可以发送任何序列化的JSON数据
        currentPanel.webview.postMessage({ command: 'refactor' });
    }));
}

function getWebviewContent() {
    return
        `
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Cat Coding</title>
            </head>
            <body>
                <img src="https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" width="300" />
                <h1 id="lines-of-code-counter">0</h1>

                <script>
                    const counter = document.getElementById('lines-of-code-counter');

                    let count = 0;
                    setInterval(() => {
                        counter.textContent = count++;
                    }, 100);

                    // Handle the message inside the webview
                    window.addEventListener('message', event => {

                        const message = event.data; // The JSON data our extension sent

                        switch (message.command) {
                            case 'refactor':
                                count = Math.ceil(count * 0.5);
                                counter.textContent = count;
                                break;
                        }
                    });
                </script>
            </body>
            </html>
        `;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67

向webview传递信息

# 将webview的信息传递到插件中

webview也可以把信息传递回对应的插件中,用VS Code API 为webview提供的postMessage函数我们就可以完成这个目标。调用webview中的acquireVsCodeApi获取VS Code API对象。这个函数在一个会话中只能调用一次,你必须保持住这个方法返回的VS Code API实例,然后再转交到需要调用这个实例的地方。

现在我们在Cat Coding添加一个弹出bug的警示框:

export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(vscode.commands.registerCommand('catCoding.start', () => {
        const panel = vscode.window.createWebviewPanel('catCoding', "Cat Coding", vscode.ViewColumn.One, {
            enableScripts: true
        });

        panel.webview.html = getWebviewContent();

        // 处理webview中的信息
        panel.webview.onDidReceiveMessage(message => {
            switch (message.command) {
                case 'alert':
                    vscode.window.showErrorMessage(message.text);
                    return;
            }
        }, undefined, context.subscriptions);
    }));
}

function getWebviewContent() {
    return
        `
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Cat Coding</title>
            </head>
            <body>
                <img src="https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" width="300" />
                <h1 id="lines-of-code-counter">0</h1>

                <script>
                    (function() {
                        const vscode = acquireVsCodeApi();
                        const counter = document.getElementById('lines-of-code-counter');

                        let count = 0;
                        setInterval(() => {
                            counter.textContent = count++;

                            // Alert the extension when our cat introduces a bug
                            if (Math.random() < 0.001 * count) {
                                vscode.postMessage({
                                    command: 'alert',
                                    text: '🐛  on line ' + count
                                })
                            }
                        }, 100);
                    }())
                </script>
            </body>
            </html>
        `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

从webview向插件传递信息

出于安全性考虑,你必须保证VS Code API的私有性,也不会泄露到全局状态中去。

# 安全性


每一个你创建的webview都必须遵循这些基础的安全性最佳实践。

# 限制能力

webview应该留有它所需的最小功能集合即可。例如:如果你的webview不需要运行脚本,就不要设置enableScripts: true。如果你的webview不需要加载用户工作区的资源,就把localResourceRoots设置为[vscode.Uri.file(extensionContext.extensionPath)]或者[]以便禁止访问任何本地资源。

# 内容安全策略

内容安全策略可以进一步限制webview可以加载和执行的内容。例如:内容安全策略强制可以运行在webview中的脚本白名单,或者告诉webview只加载带https协议的图片。

要想加上内容安全策略,将<meta http-equiv="Content-Security-Policy">指令放到webview的<head>

function getWebviewContent() {
    return
        `
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">

                <meta http-equiv="Content-Security-Policy" content="default-src 'none';">

                <meta name="viewport" content="width=device-width, initial-scale=1.0">

                <title>Cat Coding</title>
            </head>
            <body>
                ...
            </body>
            </html>
        `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

default-src 'none';策略直接禁止了所有内容。我们可以按插件需要的最少内容修改这个指令,如只允许通过https加载本地脚本、样式和图片:

<meta
    http-equiv="Content-Security-Policy"
    content="default-src 'none'; img-src vscode-resource: https:; script-src vscode-resource:; style-src vscode-resource:;"
>
1
2
3
4

上述策略也隐式地禁用了内联脚本和样式。把内联样式和脚本提取到外部文件中是一个非常好的实践,也不会与内容安全策略冲突。

# 只通过https加载内容

如果你的webview允许加载外部资源,我们强烈建议你只允许通过https加载而不要使用http,上面的例子已经用内容安全策略展示了使用https的方式。

# 审查用户输入

就像构建普通HTML页面一样,你也同样需要在webview中审查用户输入的内容。 没有审查输入内容可能会导致内容注入,也就意味着将用户置于了危险之中。

可能需要审查的值:

  • 文件内容
  • 文件和文件夹路径
  • 用户工作区设置

可以考虑用一个辅助库去构建HTML模板,或者确保所有来自用户工作区的内容都通过了审查

只依赖审查内容的安全性是不够的,你也要遵循其他安全性的最佳实践,尽可能减少潜在的内容注入。

# 持久性


在webview的标准生命周期中,createWebviewPanel负责创建和销毁(用户关闭或者调用.dispose()方法)webview。而webview的内容再是在webview可见时创建的,在webview处于非激活状态时销毁。webview处于非激活标签中时,任何webview中的保留的状态都会丢失。

所以最好减少webview中的状态,取而代之用消息传递储存状态。

# getState和setState

运行在webview中的脚本可以使用getStatesetState方法保存和恢复JSON序列化的状态对象。这个状态可以一直保留,即使webview面板已经被隐藏,只有当它销毁时,状态则会一起销毁。

// webview中的脚本
const vscode = acquireVsCodeApi();

const counter = document.getElementById('lines-of-code-counter');

// 检查是否需要恢复状态
const previousState = vscode.getState();
let count = previousState ? previousState.count : 0;
counter.textContent = count;

setInterval(() => {
    counter.textContent = count++;
    // 更新已经保存的状态
    vscode.setState({ count })
}, 100);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

getStatesetState是用来保存状态的比较好的办法,因为他们的性能消耗要远低于retainContextWhenHidden

# 序列化

使用WebviewPanelSerializer之后,你的webview可以在VS Code关闭后自动恢复。序列化构建于getStatesetState之上,只有你的插件注册了WebviewPanelSerializer,这个功能才会生效。

给插件的package.json添加一个onWebviewPanel激活事件,然后我们的代码喵就能在VS Code重启后继续工作了:

"activationEvents": [
    ...,
    "onWebviewPanel:catCoding"
]
1
2
3
4

这个激活事件确保我们的插件不论VS Code何时恢复catCodingwebview时都会启动。

然后在我们插件的activate方法中调用registerWebviewPanelSerializer注册一个新的WebviewPanelSerializer,这个函数负责恢复webview之前保存的内容。其中的state就是webview用setState设置的JSON格式的状态。

export function activate(context: vscode.ExtensionContext) {
    // 常见设置...

    // 确保我们注册了一个序列化器
    vscode.window.registerWebviewPanelSerializer('catCoding', new CatCodingSerializer());
}

class CatCodingSerializer implements vscode.WebviewPanelSerializer {
    async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: any) {
        // `state`是webview内调用`setState`保留住的
        console.log(`Got state: ${state}`);

        // 恢复我们的webview内容
        //
        // 确保我们将`webviewPanel`传递到了这里
        // 然后用事件侦听器恢复我们的内容
        webviewPanel.webview.html = getWebviewContent();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在VS Code中打开一个喵喵打代码的面板,关闭后重启就能看到这个面板恢复到了之前的状态和位置。

# 隐藏时保留上下文

如果webview的视图非常复杂,或者状态不能很快地保存和恢复,你则可以用retainContextWhenHidden选项,这个选项在不可见的状态中保存了webview的内容,即使webview本身不处于激活状态。

虽然Cat Coding说不上有很复杂的状态,不过我们可以打开retainContextWhenHidden看看webview的行为会发生什么变化:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('catCoding.start', () => {
      const panel = vscode.window.createWebviewPanel(
        'catCoding',
        'Cat Coding',
        vscode.ViewColumn.One,
        {
          enableScripts: true,
          retainContextWhenHidden: true
        }
      );
      panel.webview.html = getWebviewContent();
    })
  );
}

function getWebviewContent() {
  return
    `
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Cat Coding</title>
        </head>
        <body>
            <img src="https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" width="300" />
            <h1 id="lines-of-code-counter">0</h1>

            <script>
                const counter = document.getElementById('lines-of-code-counter');

                let count = 0;
                setInterval(() => {
                    counter.textContent = count++;
                }, 100);
            </script>
        </body>
        </html>
    `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

持久化保留

我们可以注意到计数器没有重置,webview隐藏之后就恢复了。而且不需要多余的代码!retainContextWhenHidden的行为就像浏览器一样,脚本和其他内容被暂时挂起,但是一旦webview可见之后就会立即恢复。但是在webview隐藏状态下,你还是不能给它发送消息的。

虽然retainContextWhenHidden很吸引人,但是记住这个功能的内容占用很高,只有其他的持久化技术无能为力之时再选择这种方式。

# 下一步

如果你想了解学习更多VS Code扩展性的内容,请查看下列主题: