VS Code插件开发教程(8) Webview

5,517 阅读6分钟

webview API允许插件在 Visual Studio Code中创建高度定制化的视图,例如内置的Markdown插件就是利用webview来渲染Markdown预览页的。webview还可以创建出比VS Code原生API所直接支持的更加复杂的用户界面。

可以将webview视为VS Code下插件所控制的iframewebview可以渲染出任意的HTML内容并通过信息(message)机制和插件通信,这种高度自由使得webview的能力十分强大,令其开启了插件可能性的新领域

webview在以下一些场景中被使用:

  • createWebviewPanel创建一个webview面板,在VS Code中地位如同一个单独的编辑界面,这在展示自定义UI和自定义的可视化方面非常有用
  • 作为 custom editor 的一个视图存在, custom editor 允许插件提供一个自定义UI去编辑工作区中的任何的文件,custom editor API提供了一系列编辑事件,如撤销、保存等
  • sidebarpanel中渲染,查看 webview view sample 获取更多信息

API

使用场景

webview功能十分强大,不过我们不应滥用,只有当VS Code原生API无法支持时才建议使用webviewwebview涉及到的资源较多,和普通插件相比其运行在一个单独的环境里,这就使得一个设计不好的webview会感到和VS Code有些不相称。在使用webview前,请仔细考虑下面这些问题:

  • 该功能真的适合放在VS Code中而不是一个单独的app或网站么?
  • webview是唯一实现的途径么?VS Code API能否直接支持?
  • 是否评估过webview中的资源成本?

使用方法

基础示例

为了说明Webviews API的用法,我们会创建一个简单的插件,这个插件将会利用一个webview来展示一幅图片。

下面是这个插件第一个版本的package.json,可以看到其定义了一个命令catCoding.start,当用户触发了这个命令,我们将会展示有猫咪图片的webview

{
    "name": "webview-test",
    "displayName": "webviewTest",
    "description": "",
    "version": "0.0.1",
    "engines": {
        "vscode": "^1.56.0"
    },
    "categories": [
        "Other"
    ],
    "activationEvents": ["onCommand:catCoding.start"],
    "main": "./dist/extension.js",
    "contributes": {
        "commands": [{
            "command": "catCoding.start",
            "title": "Start new cat coding session",
            "category": "Cat Coding"
        }]
    },
    "scripts": {
        "vscode:prepublish": "yarn run package",
        "compile": "webpack",
        "watch": "webpack --watch",
        "package": "webpack --mode production --devtool hidden-source-map",
        "test-compile": "tsc -p ./",
        "test-watch": "tsc -watch -p ./",
        "pretest": "yarn run test-compile && yarn run lint",
        "lint": "eslint src --ext ts",
        "test": "node ./out/test/runTest.js"
    },
    "devDependencies": {
        "@types/vscode": "^1.56.0",
        "@types/glob": "^7.1.3",
        "@types/mocha": "^8.0.4",
        "@types/node": "14.x",
        "eslint": "^7.19.0",
        "@typescript-eslint/eslint-plugin": "^4.14.1",
        "@typescript-eslint/parser": "^4.14.1",
        "glob": "^7.1.6",
        "mocha": "^8.2.1",
        "typescript": "^4.1.3",
        "vscode-test": "^1.5.0",
        "ts-loader": "^8.0.14",
        "webpack": "^5.19.0",
        "webpack-cli": "^4.4.0"
    }
}

接下来让我们注册catCoding.start命令去打开webview

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(
        vscode.commands.registerCommand('catCoding.start', () => {
            // Create and show a new webview
            const panel = vscode.window.createWebviewPanel(
                'catCoding', // Identifies the type of the webview. Used internally
                'Cat Coding', // Title of the panel displayed to the user
                vscode.ViewColumn.One, // Editor column to show the new webview panel in.
                {} // Webview options. More on these later.
            );
        })
    );
}

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', () => {
            // Create and show a new webview
            const panel = vscode.window.createWebviewPanel(
                'catCoding', // Identifies the type of the webview. Used internally
                'Cat Coding', // Title of the panel displayed to the user
                vscode.ViewColumn.One, // Editor column to show the new webview panel in.
                {} // Webview options. More on these later.
            );
            // And set its HTML content
+            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>`;
+ }

webview.html必须是完整的html文档,如果是html片段可能会引发意料之外的问题

刷新内容

webview.html可以在创建完毕后进行更新,接下来让我们改进一下代码,增加动态切换图片的能力:

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);
+      };

      // Set initial content
      updateWebview();

+      // And schedule updates to the content every second
+      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>`;
}

设置webview.html会替换掉整个webview的内容,类似重新加载iframe,如果你在webview中使用了js脚本这点就很重要,需要注意设置了webview.html会导致脚本的状态被重置。需要留意上文的示例还使用了webview.title来改变标题,重置标题不会导致webview的重新加载

生命周期

webview面板属于创建它的插件,通过获取createWebviewPanel的返回对象来操控webview。对于用户来说,可以随时关闭webview面板,一旦被关闭,则webview将会被销毁,尝试去引用一个被销毁的webview会报错,所以对于上述代码来说,setInterval的使用存在bug。当用户关闭了webview面板后setInterval依然会被不断地触发,下面让我们来看下如何处理这个问题。

webview面板被销毁时会触发onDidDispose事件,我们可以在此来取消setInterval

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', () => {
            // Create and show a new webview
            const panel = vscode.window.createWebviewPanel(
                'catCoding', // Identifies the type of the webview. Used internally
                'Cat Coding', // Title of the panel displayed to the user
                vscode.ViewColumn.One, // Editor column to show the new webview panel in.
                {} // Webview options. More on these later.
            );
            let iteration = 0;
            const updateWebview = () => {
                const cat = iteration++ % 2 ? 'Compiling Cat' : 'Coding Cat';
                panel.title = cat;
                panel.webview.html = getWebviewContent(cat);
            };

            // Set initial content
            updateWebview();

            // And schedule updates to the content every second
+            const interval = setInterval(updateWebview, 1000);

+            panel.onDidDispose(
+                () => {
+                    // When the panel is closed, cancel any future updates to the webview content
+                    clearInterval(interval);
+                },
+                null,
+                context.subscriptions
+            );
        })
    );
}

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>`;
}

对于插件来说,也可以通过调用dispose关闭webview面板,例如我们可以控制在五秒之后关闭webview面板:

export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(
        vscode.commands.registerCommand('catCoding.start', () => {
            // Create and show a new webview
            const panel = vscode.window.createWebviewPanel(
                'catCoding', // Identifies the type of the webview. Used internally
                'Cat Coding', // Title of the panel displayed to the user
                vscode.ViewColumn.One, // Editor column to show the new webview panel in.
                {} // Webview options. More on these later.
            );
            let iteration = 0;
            const updateWebview = () => {
                const cat = iteration++ % 2 ? 'Compiling Cat' : 'Coding Cat';
                panel.title = cat;
                panel.webview.html = getWebviewContent(cat);
            };

            // Set initial content
            updateWebview();

            // And schedule updates to the content every second
            const interval = setInterval(updateWebview, 1000);
            // After 5sec, programmatically close the webview panel
+           const timeout = setTimeout(() => {
+               clearInterval(interval);
+               panel.dispose();
+           }, 5000);


            panel.onDidDispose(
                () => {
                    // When the panel is closed, cancel any future updates to the webview content
                    clearInterval(interval);
+                   clearTimeout(timeout);
                },
                null,
                context.subscriptions
            );
        })
    );
}

可以看到依次发生了:执行panel.dispose->触发panel.onDidDispose->webview面板关闭

可见性与移动

当我们在编辑区切换tab,将webview面板置于不可见的状态时,webview面板实际上没有被销毁VS Code会将webview内容存储起来,当webview面板切换回来时继续使用。可以通过visible属性获取到当前webview面板可见性的状态。插件可以通过reveal方法将被切换到隐藏的webview重新切到可见状态,方法接受一个表明视图列的参数。

下面来优化下代码,如果webview已经被创建,则将其切换到可见状态(在前面的版本里,执行命令会不断生成新的webview):

export function activate(context: vscode.ExtensionContext) {
    // Track currently webview panel
    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) {
                // If we already have a panel, show it in the target column
                currentPanel.reveal(columnToShowIn);
            } else {
                // Otherwise, create a new panel
                currentPanel = vscode.window.createWebviewPanel(
                    'catCoding',
                    'Cat Coding',
                    columnToShowIn,
                    {}
                );
                currentPanel.webview.html = getWebviewContent('Coding Cat');

                // Reset when the current panel is closed
                currentPanel.onDidDispose(
                    () => {
                        currentPanel = undefined;
                        console.log('onDidDispose');
                    },
                    null,
                    context.subscriptions
                );
            }
        })
    );
}

webview的可见性或所处的列发生变动时,会触发onDidChangeViewState事件,我们可以改造下插件,让它在所处不同视图列时展示不同的内容:

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',
    '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('Coding Cat');

            // Update contents based on view state changes
            panel.onDidChangeViewState(e => {
                const curPanel = e.webviewPanel;
                console.log(panel.viewColumn, curPanel.viewColumn);
                switch (curPanel.viewColumn) {
                    case vscode.ViewColumn.One:
                        updateWebviewForCat(curPanel, 'Coding Cat');
                        return;
                    case vscode.ViewColumn.Two:
                        updateWebviewForCat(curPanel, 'Compiling Cat');
                        return;
                    case vscode.ViewColumn.Three:
                        updateWebviewForCat(curPanel, 'Testing Cat');
                        return;
                }
            }, null, context.subscriptions);
        })
    );
}

function updateWebviewForCat(panel: vscode.WebviewPanel, catName: keyof typeof cats) {
    panel.title = catName;
    panel.webview.html = getWebviewContent(catName);
}

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>`;
}

检查和调试

运行Developer: Toggle Developer Tools命令可以调试webview

如果你的VS Code版本低于1.56或设置了enableFindWidget,则需要执行Developer: Open Webview Developer Tools命令,这个命令给每个webview开启了一个单独的Developer Tools而不是所有的webview共用同一个。

命令Developer: Reload Webview会重载所有已经开启的webview,当希望重置webview的内部状态或重新加载资源时可以用到。

加载本地资源

webview通过localResourceRoots选项来控制哪些资源可以从用户本地加载,localResourceRoots定义了一系列允许本地加载的根URI。例如我们可以通过localResourceRoots来限制插件只能从本地的media文件夹加载资源:

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,
                {
                    // Only allow the webview to access resources in our extension's media directory
                    localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))]
                }
            );
            const onDiskPath = vscode.Uri.file(
                path.join(context.extensionPath, 'media', 'cat.gif')
            );
            const catGifSrc = panel.webview.asWebviewUri(onDiskPath);
            panel.webview.html = getWebviewContent(catGifSrc);
        })
    );
}

如果希望不允许加载本地任何资源,将localResourceRoots设置成空数组即可。总的来说在webview中我们还是应该尽可能的限制对本地资源的加载,localResourceRoots并不提供全面的安全保护,有关安全性方面我们后面会有所介绍。

适配主题

我们可以用css来给webview附加匹配当前主题对的样式,VS Code的主题有三大类:vscode-lightvscode-darkvscode-high-contrastVS Code会将当前主题的类别附加在webviewbody元素的class

然后我们可以编写类似如下的代码适配不同的主题:

body.vscode-light {
  color: black;
}

body.vscode-dark {
  color: white;
}

body.vscode-high-contrast {
  color: red;
}

VS Code官方要求当开发一款webview应用时,需要确保其支持三种主题,并且一定要在high-contrast做好测试以便对于存在视力缺陷的用户也是可以使用的。

在编写webview样式文件时,我们可以使用 css 变量 来访问VS Code内置的主题色,这些变量是主题色变量附加--vscode-前缀并将.替换成-,例如editor.foregroundcss变量是var(--vscode-editor-foreground)

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

你可以在 theme-color 找到VS Code所支持的主题色,你可以通过安装 VS Code CSS Theme Completions 来得到变量名称的自动补全。

另外你要还需要知道一些和字体有关的变量:

  • --vscode-editor-font-family:数值同editor.fontFamily
  • --vscode-editor-font-weight:数值同editor.fontWeight
  • --vscode-editor-font-size:数值同editor.fontSize

如果你希望给某个具体的主题适配css样式,你可以利用body上的vscode-theme-name属性,该属性值是当前使用的主题名称

body[data-vscode-theme-name="Darktooth"] {
    background: hotpink;
}

脚本和消息传递

脚本

webviewiframe很相似,你可以运行自己的脚本,默认情况下脚本是不可用状态,需要通过设置enableScripts: true来开启该功能,让我们编写一段简单的计数脚本,不过这仅是个示例,实际编写的时候不要使用内联的脚本(内联代码被视为是有害的

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,
                {
                    // Enable scripts in the 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>`;
}

webview中的脚本使用起来和普通网页里的脚本一样,能干的事情也一样,没有区别。需要注意的是,webview中的脚本不能直接调用VS Code API,如有此类需要,需借助消息机制来实现

消息传递

从插件向webview传递消息

对于插件来说,可以通过webview.postMessage()函数向webview传递JSON数据,在webview中通过监听message事件(window.addEventListener('message', event => { ... }))来接收数据。为了演示数据传递的功能,我们增加一个新的命令catCoding.doRefactor,当执行这个新命令时传递给webview一条信息,让计数器计数减半:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    // Only allow a single Cat Coder
    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);
            }
        })
    );

    // Our new command
    context.subscriptions.push(
        vscode.commands.registerCommand('catCoding.doRefactor', () => {
            if (!currentPanel) {
                return;
            }
            // Send a message to our webview.
            // You can send any JSON serializable data.
            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>`;
}

从webview向插件传递消息

webview可以给插件传递信息,利用webview内嵌的VS Code API对象来调用postMessage函数,为了获取到VS Code API对象需要调用acquireVsCodeApi函数。acquireVsCodeApi函数只能调用一次,所以你需要将其返回的内容保存好,以便其它脚本可以调用到:

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
                }
            );

            panel.webview.html = getWebviewContent();

            // Handle messages from the 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() {
            // 获取VS Code API 对象
            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 (count % 100 === 0) {
                    vscode.postMessage({
                        command: 'alert',
                        text: '🐛  on line ' + count
                    })
                }
            }, 100);
        }())
    </script>
</body>
</html>`;
}

按照VS Code官方的建议,出于安全性考虑,VS Code API对象(上文代码里的vscode变量)应该是私有的,不要设置为全局变量。

安全性

webview中编写代码有一些安全性的最佳实践,需要开发者知晓

限制权限

webview应该仅收紧对各项能力权限的限制,比如说你的webview程序不需要运行js脚本,那么就不要设置enableScripts: true;如果你的程序不需要加载本地资源,那么就不要设置localResourceRoots开启对本地的访问权限

内容安全政策

内容安全政策 更进一步的限制哪些内容可以在webview中被加载和执行,例如一个内容安全政策可以限制只有某个白名单内的脚本才能可以在webview中运行,或者告知webview只可以加载https协议的图片。

增加内容安全政策的方法是,在webviewhead标签顶端增加<meta http-equiv="Content-Security-Policy">指令:

<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>

政策default-src 'none';不允许任何内容,在此基础上逐步构建更完善的政策,下面这段表示仅允许加载本地脚本和样式文件,且图片加载协议应该是https的政策:

<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'none'; img-src ${webview.cspSource} https:; script-src ${webview.cspSource}; style-src ${webview.cspSource};"
/>

${webview.cspSource}是一个占位符,关于这个变量的用法,可以参见 extension.ts#L196

从安全策略上讲,不允许内嵌的脚本或样式,将所有的脚本和样式改为外部文件的方式是一种很好的实践,这样我们就可以用csp来控制其安全性

仅通过https加载资源

如果你的webview允许加载资源,那么强烈推荐通过https加载而非http

防止内容注入

一定要控制好用户的输入防止内容注入,例如以下的情况就需要额外注意:

  • 文件内容
  • 文件以及文件夹路径
  • 用户设置

进阶

在通常的webview生命周期里,webview是被createWebviewPanel函数所创建并在用户关闭或调用了dispose方法时销毁,不过对于webview的内容来说不是这样,tab的切换会导致webview的可见性来回切换,当webview不可见时其内容会被销毁,当可见时内容又会被重新创建。例如上面示例中的计数器,当可见性在切换时其数据会因销毁而丢失

解决这个的最佳实践是确保你的webview本身不存储状态,用消息机制来做webview状态的保存和恢复

getState、setState

webview中可以使用getStatesetState方法来解决数据的保存和恢复,保存的数据是JSON格式。保存的数据在webview切换为不可见状态、页面内容被销毁后依然可以保存,只有当webview本身被销毁(关闭或执行dispose)时才会销毁。

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
                }
            );
            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>
        (function() {
            // 获取VS Code API 对象
            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++;
                // Update the saved state
                vscode.setState({ count });
            }, 100);
        }())
    </script>
</body>
</html>`;
}

getStatesetState是保存状态的最佳的实践方式,性能开销要远低于 retainContextWhenHidden

序列化

通过WebviewPanelSerializer,当VS Code重启的时候你的webview可以自动实现数据的恢复,其底层依赖getStatesetState,并且只有当你为webview注册WebviewPanelSerializer的时候才会发挥作用。

如果要确保你的代码在webview恢复可见后依然能够保持住之前的状态,首先你需要在package.jsonactivation event中增加一个onWebviewPanel

"activationEvents": [
    ...,
    "onWebviewPanel:catCoding"
]

这段代码确保了VS Code在任何时候想要恢复webview,插件都会被激活

然后在插件的activate方法中,通过registerWebviewPanelSerializer方法注册一个新的WebviewPanelSerializer,这个WebviewPanelSerializer就是负责存储webview内容里的状态,这个状态是一个在webview中调用setState存储的JSON格式的数据。

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
                }
            );
            panel.webview.html = getWebviewContent();
        })
    );
    vscode.window.registerWebviewPanelSerializer('catCoding', new CatCodingSerializer());
}

class CatCodingSerializer implements vscode.WebviewPanelSerializer {
    async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: any) {
        // `state` is the state persisted using `setState` inside the webview
        console.log(`Got state: ${state}`);
        // Restore the content of our webview.
        //
        // Make sure we hold on to the `webviewPanel` passed in here and
        // also restore any event listeners we need on it.
        webviewPanel.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>
        (function() {
            // 获取VS Code API 对象
            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++;
                // Update the saved state
                vscode.setState({ count });
            }, 100);
        }())
    </script>
</body>
</html>`;
}

现在如果重启打开一个webviewVS Code,我们会发现webview中的state没有丢失

retainContextWhenHidden

对于UI极为复杂不能在恢复可见性后快恢复的webview,我们可以retainContextWhenHidden选项来代替getStatesetState

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>`;
}

这个方式对内存性能损耗较大,尽量不要使用

按照VS Code官方文档的说法,当webview页面不可见时脚本将会本挂起,当页面恢复可见性时脚本叫被唤醒,但在1.57.0版本的VS Code下,似乎页面不可见的时候计数器也没有停止,这点需要注意。

相关文章