在上一篇中,我們已經基于基礎的 html孵淘、css橡淆、js 完成了一個粗糙版的 to-do list demo屉凯。這一篇我們將從以下幾個方面來介紹我在實戰(zhàn)中的一些經驗,并最終能重構出一個視覺效果良好的 demo单山。
webview 重構
在上一篇中碍现,我們已經了解到,webview 的本質其實就是 HTML饥侵,我們只需要將包含了樣式和腳本的完整 HTML 片段賦值給 WebviewView
實例的 webview.html
屬性即可鸵赫。基于此躏升,那只要我們使用 webpack 或 vite 等構建工具將 react 或 vue 開發(fā)的組件構建出相應的腳本及樣式文件辩棒,然后能掛載進完整的 HTML 片段中,這樣我們便可以使用 react 或 vue 來開發(fā) webview 了膨疏。理論上是可行的一睁,下面我們就以 webpack、react 為例佃却,來看幾個關鍵步驟:
添加 webview.config.js
我們將多個 webview 視為多個頁面者吁,分別構建出各個 webview 的腳本及樣式文件(注:這里之所以要將樣式抽離出來作為獨立的文件,是因為不想松懈上一篇中提到的安全策略饲帅,否則 css in js 的方式動態(tài)插入的樣式將不會生效)复凳。以下是我實踐時用到的配置,大家可重點留意 entry
灶泵、output
育八、devServer
這三個屬性的配置,僅供參考:
// @ts-check
"use strict";
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const resolveApp = (relativePath) => path.resolve(__dirname, relativePath);
const webviewConfig = [
{
name: "webview",
target: "web",
entry: {
addTaskView: resolveApp("webview/views/AddTask"),
toDoListView: resolveApp("webview/views/ToDoList"),
doneView: resolveApp("webview/views/DoneList"),
},
output: {
filename: "[name].js",
path: resolveApp("dist"),
},
devtool: "source-map",
resolve: {
extensions: [".ts", ".js", ".tsx", ".jsx"],
alias: {
webview: resolveApp("webview"),
},
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: "ts-loader",
},
],
},
{
test: /\.css$/,
use: [
"@ecomfe/class-names-loader",
MiniCssExtractPlugin.loader,
"css-loader",
"postcss-loader",
],
},
{
test: /\.less$/,
exclude: /\.module\.less$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
defaultExport: true,
},
},
"css-loader",
"postcss-loader",
{
loader: "less-loader",
options: {
sourceMap: true,
lessOptions: {
javascriptEnabled: true,
},
},
},
],
},
{
test: /\.module\.less$/,
use: [
"@ecomfe/class-names-loader",
{
loader: MiniCssExtractPlugin.loader,
options: {
defaultExport: true,
},
},
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]_[hash:base64:5]", // 定義類名生成規(guī)則
},
},
},
"postcss-loader",
{
loader: "less-loader",
options: {
sourceMap: true,
lessOptions: {
javascriptEnabled: true,
},
},
},
],
},
],
},
performance: {
hints: false,
},
optimization: {
minimizer: [
// 在 webpack@5 中赦邻,你可以使用 `...` 語法來擴展現(xiàn)有的 minimizer(即 `terser-webpack-plugin`)
`...`,
new CssMinimizerPlugin(),
],
},
plugins: [new MiniCssExtractPlugin()],
devServer: {
compress: true,
hot: true,
allowedHosts: "all",
port: 8192,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
},
];
module.exports = [webviewConfig];
優(yōu)化構建腳本命令
有了構建 webview 的 webpack 配置后髓棋,再加上初始化項目后就已經存在的構建插件本身的配置,此時項目中便有了兩份 webpack 配置惶洲。這里我們將并行構建插件代碼和 webview 代碼按声,以下是優(yōu)化后的部分腳本命令:
{
"scripts": {
"watch": "run-p watch:*",
"watch:wv": "webpack serve --mode development --config ./webview.config.js",
"watch:ext": "webpack --mode development --watch --config ./extension.config.js",
"package": "pnpm run clean && run-p package:*",
"package:wv": "webpack --mode production --config ./webview.config.js --devtool hidden-source-map",
"package:ext": "webpack --mode production --config ./extension.config.js --devtool hidden-source-map",
"clean": "rimraf dist"
}
}
相信大家看后便一目了然了,本地開發(fā)時我們執(zhí)行 watch
腳本恬吕,會為 webview 起一個本地 server签则。而生產構建執(zhí)行的 package
腳本,會指定環(huán)境變量為 production
铐料。
有了構建配置和命令怀愧,我們便可以從每個 webview 的入口文件開始侨颈,分別構建出各自的腳本和樣式了,接下來我們就來看看這些入口文件的實現(xiàn)芯义。
添加 webview 入口文件
在上面的 webview.config.js
文件的 entry
中,我們添加了三個入口文件妻柒,下面就以 addTaskView
為例扛拨,來看看它的實現(xiàn):
import * as React from "react";
import { ViewType } from "webview/constants";
import { getClientRoot } from "webview/utils";
import { AddTask } from "./AddTask";
const root = getClientRoot(ViewType.addTaskView);
root.render(<AddTask />);
// Webpack HMR
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
}
其中 getClientRoot
的實現(xiàn)是:
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
let root: Root | null = null;
export function getClientRoot(rootId: string) {
if (!root) {
const container = document.querySelector(`#${rootId}`)!;
root = createRoot(container);
}
return root;
}
這樣我們便可以在 HTML 片段的“根節(jié)點”中渲染 AddTask
組件了。另外举塔,為了提升開發(fā)體驗绑警,這里我們也支持了代碼熱更。
改造 webview html
有了前面三步央渣,便有了各個 webview 的腳本及樣式计盒,那在 webview html 中如何加載它們呢?請看代碼:
import * as vscode from "vscode";
import { ViewType, NODE_ENV_PROD } from "src/constants";
import { getUri, getNonce } from "src/utils";
const webviewInfoMap: Record<
ViewType,
{ id: string; title: string; noStyle?: boolean; noScript?: boolean }
> = {
[ViewType.addTaskView]: {
id: ViewType.addTaskView,
title: "添加待辦項",
},
[ViewType.toDoListView]: {
id: ViewType.toDoListView,
title: "待辦項",
},
[ViewType.doneView]: {
id: ViewType.doneView,
title: "已完成",
},
};
const localServer = "http://localhost:8192";
export function getHtmlForWebview(
webview: vscode.Webview,
extensionUri: vscode.Uri,
viewType: ViewType
) {
let styleUri = null;
let scriptUri = null;
const isProduction = process.env.NODE_ENV === NODE_ENV_PROD;
const { id, title, noStyle, noScript } = webviewInfoMap[viewType];
if (isProduction) {
styleUri = getUri(webview, extensionUri, ["dist", `${viewType}.css`]);
scriptUri = getUri(webview, extensionUri, ["dist", `${viewType}.js`]);
} else {
styleUri = `${localServer}/${viewType}.css`;
scriptUri = `${localServer}/${viewType}.js`;
}
// Use a nonce to only allow a specific script to be run.
const nonce = getNonce();
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Use a content security policy to only allow loading styles from our extension directory, and only allow scripts that have a specific nonce. (See the 'webview-sample' extension sample for img-src content security policy examples) -->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'none';
style-src ${webview.cspSource} ${localServer};
script-src 'nonce-${nonce}' ${localServer};
connect-src ws://0.0.0.0:8192/ws ${localServer};
"
>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${noStyle ? "" : `<link href="${styleUri}" rel="stylesheet">`}
<title>To Do List Demo: ${title}</title>
</head>
<body>
<div id=${id}></div>
${noScript ? "" : `<script nonce="${nonce}" src="${scriptUri}"></script>`}
</body>
</html>
`;
}
在代碼中我們首先根據(jù)環(huán)境變量判斷了下是本地開發(fā)還是生產構建芽丹,本地開發(fā)的話北启,就從本地 server 中加載對應資源,生產構建的話拔第,那就從構建的輸出目錄 dist
中加載資源咕村。其次為了能正常加載這些資源,我們對內容安全策略也進行了按需擴展蚊俺,比如對 style-src
懈涛、script-src
支持了從 http://localhost:8192
加載本地 server 資源,新增了 connect-src
用以支持代碼熱更(更多擴展可參考 內容安全策略)泳猬。
到這里批钠,基于 react 重構 webview 的關鍵步驟就介紹完了。當然如果你想使用 vue 開發(fā) webview 或使用 vite得封、esbuild 等構建工具構建資源埋心,思路都是一樣的,放心嘗試即可呛每。
使用組件庫
有了 webpack 來構建 webview 代碼踩窖,開發(fā)這些 webview 也終于可以像我們開發(fā)普通的 web 項目一樣了,各種組件庫晨横、工具庫都可以拿來用了洋腮。但為了和 vscode 整體風格匹配,這一節(jié)我們會以 vscode-webview-ui-toolkit 為例手形,看看它的接入使用流程啥供。(注:因為一些 客觀因素 該組件庫即將廢棄,正式項目使用需慎重)
安裝
pnpm install @vscode/webview-ui-toolkit -D
使用
由于該組件庫支持了 react 版本库糠,因此可以直接從 @vscode/webview-ui-toolkit/react
引用相關組件并使用:
import React, { useState } from "react";
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react";
import { getVsCodeApi } from "webview/utils";
import style from "./AddTask.module.less";
const vscode = getVsCodeApi();
export const AddTask = () => {
const [taskContent, setTaskContent] = useState("");
function onTaskContentChange(e: any) {
setTaskContent(e.target.value);
}
const toAddTask: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === "Enter") {
vscode.postMessage({ type: "addTask", content: taskContent });
setTaskContent("");
}
};
return (
<div className={style("add-task")}>
<VSCodeTextField
className={style("add-task-input")}
placeholder="請輸入待辦項"
value={taskContent}
onKeyUp={toAddTask}
onChange={onTaskContentChange}
/>
</div>
);
};
好吧伙狐,有了 webpack 等構建工具的加成涮毫,這些感覺真沒啥好說的了。但無論你最終使用的是 vscode-webview-ui-toolkit 還是 ant design 亦或是其它組件庫贷屎,開發(fā)完成后都別忘了在 vscode 的三類顏色主題下看看 webview 的實際視覺效果罢防,下一節(jié)我們就來介紹顏色主題相關的一些內容。現(xiàn)在唉侄,可以看一下優(yōu)化后的 demo 效果了:
顏色主題
vscode 有三大類顏色主題咒吐,分別是:淺色主題、深色主題以及高對比度主題属划。這三類主題在 body
元素上分別有著各自的類名:vscode-light
恬叹、vscode-dark
以及 vscode-high-contrast
。你可以基于這幾個類名定制相應主題下的部分樣式同眯,例如:
body.vscode-light {
color: black;
}
body.vscode-dark {
color: white;
}
body.vscode-high-contrast {
color: red;
}
在開發(fā) webview 的樣式時绽昼,我們更推薦大家盡可能地去使用 vscode 提供好的 顏色變量,這樣可以最大程度地兼容到三類主題须蜗。以 textLink.foreground
為例硅确,我們可以這樣去用:
span {
color: var(--vscode-textLink-foreground)
}
但難免可能存在個別主題下部分樣式的效果還是不夠好,拿我們的 demo 來講唠粥,在高對比度主題下疏魏,鼠標懸浮任務行時,任務名直接看不清了晤愧,如圖示:
這時我們就可以找到 body
元素上的 data-vscode-theme-id
的屬性值大莫,來定制相應主題下的樣式,類似這樣:
body[data-vscode-theme-id="Default High Contrast"] {
.task-item-high-contrast {
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
body[data-vscode-theme-id="Default High Contrast Light"] {
.task-item-high-contrast {
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
}
到這里官份,顏色主題相關的內容基本也就介紹完了只厘。但有必要再提醒一下,那就是開發(fā)完插件后一定要在這三類主題下做一次視覺走查舅巷,很重要但也很容易被遺漏羔味。接下來,我們就來看看開發(fā)的過程中可能會遇到的一些常見問題吧钠右。
常見問題
在 webview 內能否做異步請求赋元?
能,但是不推薦飒房。
要在 webview 內發(fā)起異步請求搁凸,首先內容安全策略中,你得先這樣 connect-src https://xxx;
配置下 connect-src
狠毯,地址即是你要請求的服務端地址护糖。其次就是你調用的接口也得支持跨域。
更推薦您在插件側處理異步請求嚼松,再通過與 webview 通信以同步數(shù)據(jù)嫡良。
如何獲取插件所在的工作區(qū)文件夾路徑锰扶?
獲取工作區(qū)文件夾路徑可使用 vscode.workspace.workspaceFolders
,具體可參考:
import * as vscode from "vscode";
export function getWorkspacePath() {
const workspaceFolders = vscode.workspace.workspaceFolders;
return workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
}
這里需注意寝受,一定要使用 uri
的 fsPath
屬性坷牛,而不能使用其 path
屬性,否則會有系統(tǒng)兼容性問題很澄。
一個會話中 acquireVsCodeApi 能否調用多次漓帅?
不能,一個會話僅能調用一次 acquireVsCodeApi
痴怨。而且處于安全原因,不建議你在調用后器予,將調用結果掛載在全局對象下以共享使用浪藻。你可以參考這樣對調用結果緩存,并通過方法將調用結果暴露出去:
/**
* 由于一個 session 僅能調用一次 acquireVsCodeApi乾翔,故在此緩存處理爱葵。
* 出于安全考慮,這里并沒有將獲取到的 vscode api 對象掛載在 window 下反浓,以防止被其它第三方腳本獲取使用萌丈。
*/
let vsCodeApi: any = null;
export function getVsCodeApi() {
if (!vsCodeApi) {
vsCodeApi = window.acquireVsCodeApi();
}
return vsCodeApi;
}
為什么接入 webpack 等構建工具后,我使用的第三方 UI 組件庫的樣式沒生效雷则?
這大概率是因為你的 css 代碼是以 css in js 的方式存在于構建產物中辆雾,而你在 webview 的 html 的內容安全策略中又限制了 style 只能通過外聯(lián)的方式去加載,因此這種運行時動態(tài)插入的 css 代碼將不會生效月劈。此時你可以將這些 css 從 js 中抽離出去作為獨立的 css 文件引入度迂,你也可以選擇像這樣支持內聯(lián)樣式的加載:style-src: 'unsafe-inline'
。如果你使用的是 antd猜揪,你還可以選擇通過配置 csp 屬性來支持動態(tài)樣式惭墓。
如何獲取輸出面板中當前選中的輸出通道名(OutputChannel Name)?
如果你的插件存在多種類型的日志輸出而姐,并且你可能需要知道當前選中的輸出通道名腊凶,可以嘗試下這種方式:
import * as vscode from "vscode";
import { PUBLISHER, EXTENSION_NAME, OUTPUT_CHANNEL_NAME } from "src/constants";
export function getVisibleOutputChannel() {
const visibleTextEditors = vscode.window.visibleTextEditors;
const visibleOutputChannel = visibleTextEditors.find(({ document }) => {
return (
document.uri.scheme === "output" &&
document.uri.path.startsWith(
`${PUBLISHER}.${EXTENSION_NAME}.${OUTPUT_CHANNEL_NAME}`
)
);
});
return visibleOutputChannel?.document.uri.path;
}
其中,PUBLISHER
和 EXTENSION_NAME
分別對應的是 package.json
中的 publisher
和 name
屬性的值拴念,OUTPUT_CHANNEL_NAME
對應的是你使用 vscode.window.createOutputChannel
方法創(chuàng)建輸出通道時指定的輸出通道名钧萍。
不僅如此,如果你還想監(jiān)聽輸出面板日志的切換丈莺,但同樣由于 vscode 本身并沒有提供直接的 api划煮,我也沒找到更好的實現(xiàn)方式,有需要的話可以暫時參考這樣實現(xiàn):
context.subscriptions.push(
vscode.window.onDidChangeVisibleTextEditors(() => {
// 當存在多個 visibleTextEditor 時缔俄,該回調會被執(zhí)行多次弛秋,每次獲取到的 visibleTextEditors 會按順序遞增
})
);
這里要注意器躏,TextEditors
不僅僅包含所有的輸出通道,你打開的每個文件編輯區(qū)域也都屬于 TextEditor
蟹略,它們的打開登失、關閉、切換都會觸發(fā)該回調挖炬。另外揽浙,當存在多個可見的 TextEditor
時,該回調會被執(zhí)行多次意敛,且每次獲取到的 visibleTextEditors
會按順序遞增馅巷。因此,你可能還需要結合上面示例中的 getVisibleOutputChannel
方法一起去做相關業(yè)務邏輯的處理草姻。
小結
本文的篇幅不長钓猬,所有內容基本也都圍繞著更好更快地開發(fā) webview 插件展開,權當拋磚引玉撩独,不足之處敞曹,還望指正。
到這里综膀,webview 插件開發(fā)的主要內容基本也就介紹完了澳迫。下一篇我們就來看看如何“交付”已經開發(fā)好的 vscode 插件!