vscode webview 插件開發(fā)(精裝篇)

上一篇中,我們已經基于基礎的 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;
}

這里需注意寝受,一定要使用 urifsPath 屬性坷牛,而不能使用其 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;
}

其中,PUBLISHEREXTENSION_NAME 分別對應的是 package.json 中的 publishername 屬性的值拴念,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 插件!

Demo 源碼

to-do-list-demo-v0.2.0

相關閱讀

vscode webview 插件開發(fā)(毛坯篇)

vscode webview 插件開發(fā)(交付篇)

參考資料

vscode-webview-ui-toolkit--getting-started

theming-webview-content

vscode-theme-color

vscode-output-channel

內容安全策略

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末剧劝,一起剝皮案震驚了整個濱河市橄登,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌担平,老刑警劉巖示绊,帶你破解...
    沈念sama閱讀 218,284評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異暂论,居然都是意外死亡通危,警方通過查閱死者的電腦和手機攻柠,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,115評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人孤紧,你說我怎么就攤上這事誊册∠沈龋” “怎么了材彪?”我有些...
    開封第一講書人閱讀 164,614評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長觉痛。 經常有香客問我役衡,道長,這世上最難降的妖魔是什么薪棒? 我笑而不...
    開封第一講書人閱讀 58,671評論 1 293
  • 正文 為了忘掉前任手蝎,我火速辦了婚禮榕莺,結果婚禮上,老公的妹妹穿的比我還像新娘棵介。我一直安慰自己钉鸯,他們只是感情好,可當我...
    茶點故事閱讀 67,699評論 6 392
  • 文/花漫 我一把揭開白布邮辽。 她就那樣靜靜地躺著唠雕,像睡著了一般。 火紅的嫁衣襯著肌膚如雪吨述。 梳的紋絲不亂的頭發(fā)上岩睁,一...
    開封第一講書人閱讀 51,562評論 1 305
  • 那天,我揣著相機與錄音揣云,去河邊找鬼笙僚。 笑死,一個胖子當著我的面吹牛灵再,可吹牛的內容都是我干的。 我是一名探鬼主播亿笤,決...
    沈念sama閱讀 40,309評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼翎迁,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了净薛?” 一聲冷哼從身側響起汪榔,我...
    開封第一講書人閱讀 39,223評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎肃拜,沒想到半個月后痴腌,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 45,668評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡燃领,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,859評論 3 336
  • 正文 我和宋清朗相戀三年士聪,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片猛蔽。...
    茶點故事閱讀 39,981評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡剥悟,死狀恐怖,靈堂內的尸體忽然破棺而出曼库,到底是詐尸還是另有隱情区岗,我是刑警寧澤,帶...
    沈念sama閱讀 35,705評論 5 347
  • 正文 年R本政府宣布毁枯,位于F島的核電站慈缔,受9級特大地震影響,放射性物質發(fā)生泄漏种玛。R本人自食惡果不足惜藐鹤,卻給世界環(huán)境...
    茶點故事閱讀 41,310評論 3 330
  • 文/蒙蒙 一瓤檐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧教藻,春花似錦距帅、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,904評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至悄窃,卻和暖如春讥电,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背轧抗。 一陣腳步聲響...
    開封第一講書人閱讀 33,023評論 1 270
  • 我被黑心中介騙來泰國打工恩敌, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人横媚。 一個月前我還...
    沈念sama閱讀 48,146評論 3 370
  • 正文 我出身青樓纠炮,卻偏偏與公主長得像,于是被迫代替她去往敵國和親灯蝴。 傳聞我的和親對象是個殘疾皇子恢口,可洞房花燭夜當晚...
    茶點故事閱讀 44,933評論 2 355

推薦閱讀更多精彩內容