VSCode外掛開發全攻略(七)WebView

xal發表於2018-10-18

更多文章請戳VSCode外掛開發全攻略系列目錄導航

什麼是Webview

大家都知道,整個VSCode編輯器就是一張大的網頁,其實,我們還可以在Visual Studio Code中建立完全自定義的、可以間接和nodejs通訊的特殊網頁(通過一個acquireVsCodeApi特殊方法),這個網頁就叫WebView。內建的Markdown的預覽就是使用WebView實現的。使用Webview可以構建複雜的、支援本地檔案操作的使用者介面。

VSCode外掛的WebView類似於iframe的實現,但並不是真正的iframe(我猜底層應該還是基於iframe實現的,只不過上層包裝了一層),通過開發者工具可以看到:

_W1506xH802_

demo

在我們的vscode-plugin-demo中,我寫了一個非常簡單、沒啥實際意義的Webview示例僅供參考,在任意編輯器右鍵可以看到開啟Webview的選單:

_W1424xH842_

什麼時候適合使用WebView

雖然Webview令人很振奮,因為基於它我們可以隨意發揮不受限制,但必須注意還是要慎用,畢竟VSCode是很注重效能的,不能因為你一個外掛拖累了整個IDE,一般僅在原有API和功能以及互動方式無法滿足你時才需要考慮,另外,設計糟糕的Webview也很容易在VS Code中讓人感覺不舒適,不能讓人家一看就覺得你這是一張網頁,好看的UI也很重要。

這是官網給出的建議,在使用webview之前請考慮以下事項:

  • 這個功能真的需要放在VSCode中嗎?作為單獨的應用程式或網站會不會更好呢?
  • webview是實現這個功能的唯一方法嗎?可以使用常規VS Code API嗎?
  • 您的webview是否會帶來足夠的使用者價值以證明其高資源成本?

正式開始WebView之旅

建立WebView

context.subscriptions.push(vscode.commands.registerCommand(`extension.demo.openWebview`, function (uri) {
    // 建立webview
    const panel = vscode.window.createWebviewPanel(
        `testWebview`, // viewType
        "WebView演示", // 檢視標題
        vscode.ViewColumn.One, // 顯示在編輯器的哪個部位
        {
            enableScripts: true, // 啟用JS,預設禁用
            retainContextWhenHidden: true, // webview被隱藏時保持狀態,避免被重置
        }
    );
    panel.webview.html = `<html><body>你好,我是Webview</body></html>`

幾點說明:

  • 預設情況下,在Web檢視中禁用JavaScript,但可以通過傳入enableScripts: true選項輕鬆啟用;
  • 預設情況下當webview被隱藏時資源會被銷燬,通過retainContextWhenHidden: true會一直儲存,但會佔用較大記憶體開銷,僅在需要時開啟;

載入本地資源

出於安全考慮,Webview預設無法直接訪問本地資源,它在一個孤立的上下文中執行,想要載入本地圖片、js、css等必須通過特殊的vscode-resource:協議,網頁裡面所有的靜態資源都要轉換成這種格式,否則無法被正常載入

vscode-resource:協議類似於file:協議,但它只允許訪問特定的本地檔案。和file:一樣,vscode-resource:從磁碟載入絕對路徑的資源。

我簡單封裝了一個轉換方法:

/**
 * 獲取某個擴充套件檔案相對於webview需要的一種特殊路徑格式
 * 形如:vscode-resource:/Users/toonces/projects/vscode-cat-coding/media/cat.gif
 * @param context 上下文
 * @param relativePath 擴充套件中某個檔案相對於根目錄的路徑,如 images/test.jpg
 */
getExtensionFileVscodeResource: function(context, relativePath) {
    const diskPath = vscode.Uri.file(path.join(context.extensionPath, relativePath));
    return diskPath.with({ scheme: `vscode-resource` }).toString();
}

預設情況下,vscode-resource:只能訪問以下位置中的資源:

  • 擴充套件程式安裝目錄中的檔案。
  • 使用者當前活動的工作區內。
  • 當然,你還可以使用dataURI直接在Webview中嵌入資源,這種方式沒有限制;

從檔案載入HTML內容

預設不支援從檔案載入HTML,需要自己封裝程式碼,我簡單封裝了一個供大家參考:

/**
 * 從某個HTML檔案讀取能被Webview載入的HTML內容
 * @param {*} context 上下文
 * @param {*} templatePath 相對於外掛根目錄的html檔案相對路徑
 */
function getWebViewContent(context, templatePath) {
    const resourcePath = path.join(context.extensionPath, templatePath);
    const dirPath = path.dirname(resourcePath);
    let html = fs.readFileSync(resourcePath, `utf-8`);
    // vscode不支援直接載入本地資源,需要替換成其專有路徑格式,這裡只是簡單的將樣式和JS的路徑替換
    html = html.replace(/(<link.+?href="|<script.+?src="|<img.+?src=")(.+?)"/g, (m, $1, $2) => {
        return $1 + vscode.Uri.file(path.resolve(dirPath, $2)).with({ scheme: `vscode-resource` }).toString() + `"`;
    });
    return html;
}

執行這段程式碼之後,會自動將HTML檔案中linkhrefscriptimg的資源相對路徑全部替換成正確的vscode-resource:絕對路徑,例如:

../../lib/vue-2.5.17/vue.js
變成
vscode-resource:/Users/test/workspace/vscode-plugin-demo/lib/vue-2.5.17/vue.js

使用方法如下:

panel.webview.html = getWebViewContent(context, `src/view/test-webview.html`);

訊息通訊

重頭戲來了,Webview和普通網頁非常類似,不能直接呼叫任何VSCodeAPI,但是,它唯一特別之處就在於多了一個名叫acquireVsCodeApi的方法,執行這個方法會返回一個超級閹割版的vscode物件,這個物件裡面有且僅有如下3個可以和外掛通訊的API:

_W624xH430_

外掛和Webview之間如何互相通訊呢?

外掛給Webview傳送訊息(支援傳送任意可以被JSON化的資料):

panel.webview.postMessage({text: `你好,我是小茗同學!`});

Webview端接收:

window.addEventListener(`message`, event => {
    const message = event.data;
    console.log(`Webview接收到的訊息:`, message);
}

Webview主動傳送訊息給外掛:

vscode.postMessage({text: `你好,我是Webview啊!`});

外掛接收:

panel.webview.onDidReceiveMessage(message => {
    console.log(`外掛收到的訊息:`, message);
}, undefined, context.subscriptions);

簡單通訊封裝

為了雙方通訊方便,我把它們簡單封裝了一下,僅供參考,Webview端:

const callbacks = {}; // 存放所有的回撥函式
/**
 * 呼叫vscode原生api
 * @param data 可以是類似 {cmd: `xxx`, param1: `xxx`},也可以直接是 cmd 字串
 * @param cb 可選的回撥函式
 */
function callVscode(data, cb) {
    if (typeof data === `string`) {
        data = { cmd: data };
    }
    if (cb) {
        // 時間戳加上5位隨機數
        const cbid = Date.now() + `` + Math.round(Math.random() * 100000);
        // 將回撥函式分配一個隨機cbid然後存起來,後續需要執行的時候再撈起來
        callbacks[cbid] = cb;
        data.cbid = cbid;
    }
    vscode.postMessage(data);
}
window.addEventListener(`message`, event => {
    const message = event.data;
    switch (message.cmd) {
        // 來自vscode的回撥
        case `vscodeCallback`:
            console.log(message.data);
            (callbacks[message.cbid] || function () { })(message.data);
            delete callbacks[message.cbid]; // 執行完回撥刪除
            break;
        default: break;
    }
});

外掛端:

let global = { projectPath, panel};
panel.webview.onDidReceiveMessage(message => {
    if (messageHandler[message.cmd]) {
        // cmd表示要執行的方法名稱
        messageHandler[message.cmd](global, message);
    } else {
        util.showError(`未找到名為 ${message.cmd} 的方法!`);
    }
}, undefined, context.subscriptions);

/**
 * 存放所有訊息回撥函式,根據 message.cmd 來決定呼叫哪個方法,
 * 想呼叫什麼方法,就在這裡寫一個和cmd同名的方法實現即可
 */
const messageHandler = {
    // 彈出提示
    alert(global, message) {
        util.showInfo(message.info);
    },
    // 顯示錯誤提示
    error(global, message) {
        util.showError(message.info);
    },
    // 回撥示例:獲取工程名
    getProjectName(global, message) {
        invokeCallback(global.panel, message, util.getProjectName(global.projectPath));
    }
}
/**
 * 執行回撥函式
 * @param {*} panel 
 * @param {*} message 
 * @param {*} resp 
 */
function invokeCallback(panel, message, resp) {
    console.log(`回撥訊息:`, resp);
    // 錯誤碼在400-600之間的,預設彈出錯誤提示
    if (typeof resp == `object` && resp.code && resp.code >= 400 && resp.code < 600) {
        util.showError(resp.message || `發生未知錯誤!`);
    }
    panel.webview.postMessage({cmd: `vscodeCallback`, cbid: message.cbid, data: resp});
}

按上述方法封裝之後,例如,Webview端想要執行名為openFileInVscode命令只需要這樣:

callVscode({cmd: `openFileInVscode`, path: `package.json`}, (message) => {
    this.alert(message);
});

然後在外掛端的messageHandler實現openFileInVscode方法即可,其它都不用管:

const messageHandler = {
    // 省略其它方法
    openFileInVscode(global, message) {
        util.openFileInVscode(`${global.projectPath}/${message.path}`);
        invokeCallback(global.panel, message, `開啟檔案成功!`);
    }
};

以上封裝的比較隨便,只是給大家提供一個思路,有時間可以好好封裝一下。

主題適配

Webview可以根據VS Code的當前主題更改其外觀,原理是body上面新增當前主題名稱,主要有以下三種:

  • vscode-light – 淺色主題;
  • vscode-dark -深色主題;
  • vscode-high-contrast – 高對比度主題;

所以我們可以通過自己寫樣式來適配不同主題:

/* 淺色主題 */
.body.vscode-light {
    background: white;
    color: black;
}
/* 深色主題 */
body.vscode-dark {
    background: #252526;
    color: white;
}
/* 高對比度主題 */
body.vscode-high-contrast {
    background: white;
    color: red;
}

深色主題效果:

_W1404xH770_

生命週期

webview由建立它的擴充套件程式所有,返回的panel物件你必須自己儲存,如果你的擴充套件程式丟失了這個引用,那麼將無法再次重新訪問該webview,即使Web檢視繼續顯示在vscode中。

使用者也可以隨時關閉webview皮膚。當使用者關閉webview皮膚時,webview本身將被銷燬,此時不能再使用panel引用,否則將會出現異常,可以通過監聽onDidDispose事件在這裡面做一些銷燬操作。

可以通過panel.dispose()方法主動關閉webview。

狀態保持

當webview移動到後臺又再次顯示時,webview中的任何狀態都將丟失。

解決此問題的最佳方法是使你的webview無狀態,通過訊息傳遞來儲存webview的狀態。

state

在webview的js中我們可以使用vscode.getState()vscode.setState()方法來儲存和恢復JSON可序列化狀態物件。當webview被隱藏時,即使webview內容本身被破壞,這些狀態仍然會儲存。當然了,當webview被銷燬時,狀態將被銷燬。

序列化

通過註冊WebviewPanelSerializer可以實現在VScode重啟後自動恢復你的webview,當然,序列化其實也是建立在getStatesetState之上的。

註冊方法:vscode.window.registerWebviewPanelSerializer

retainContextWhenHidden

對於具有非常複雜的UI或狀態且無法快速儲存和恢復的webview,我們可以直接使用retainContextWhenHidden選項。設定retainContextWhenHidden: true後即使webview被隱藏到後臺其狀態也不會丟失。

儘管retainContextWhenHidden很有吸引力,但它需要很高的記憶體開銷,一般建議在實在沒辦法的時候才啟用。
getStatesetState是持久化的首選方式,因為它們的效能開銷要比retainContextWhenHidden低得多。

除錯

注意,要除錯Webview不能直接把VSCode的開發者工具開啟,直接開啟就會和我們最前面的截圖看到的那樣,你只能看到一個<webview></webview>標籤,看不到程式碼,要看程式碼需要按下Ctrl+Shift+P然後執行開啟Webview開發工具,英文版應該是 Open Webview Developer Tools

_W906xH526_

審查Webview:

_W1152xH1086_

這個時候需要特別注意錯誤日誌出現的位置,如果是Webview的錯誤,一般列印在前面說的這個開發者工具,但如果是外掛端的錯誤只會列印在整個VSCode的開發者工具裡。

糟糕,距離最開始接觸Webview已經有一段時間了,本來有挺多想寫的,但是現在居然沒靈感了,額……坑爹啊

_W240xH227_

參考連結

https://code.visualstudio.com/docs/extensions/webview


相關文章