VSCode WebView外掛(擴充套件)開發實戰

可樂爸發表於2019-10-15

VSCode是微軟出的一款輕量級程式碼編輯器,免費而且功能強大,以功能強大、提示友好、不錯的效能和顏值俘獲了大量開發者的青睞,對JavaScript和NodeJS的支援非常好,自帶很多功能,例如程式碼格式化,程式碼智慧提示補全、Emmet外掛等。

它是通過 Electron 實現跨平臺的,而 Electron 則是基於 Chromium 和 Node.js,比如 VS Code 的介面,就是通過 Chromium 進行渲染的。同時, VS Code 是多程式架構,當 VS Code 第一次被啟動時會建立一個主程式(main process),然後每個視窗,都會建立一個渲染程式( Renderer Process)。與此同時,VS Code 會為每個視窗建立一個程式專門來執行外掛,也就是 Extension Host。除了這三個主要的程式以外,還有兩種特殊的程式。第一種是除錯程式,VS Code 為偵錯程式專門建立了Debug Adapter 程式,渲染程式會通過 VS Code Debug Protocol 跟 Debug Adapter 程式通訊。

架構圖如下:

VSCode WebView外掛(擴充套件)開發實戰

不過這次分享我們不過多的探討它的架構,主要看下外掛(或者稱為擴充套件,下同)怎麼寫。

VSCode外掛分為哪些型別

vscode 外掛開發的腳手架(執行yo code)我們可以看到有如下選項:

  • New Extension (TypeScript)
  • New Extension (JavaScript)
  • New Color Theme
  • New Language Support
  • New Code Snippets
  • New Keymap
  • New Extension Pack

通過cli我們可以直接建立擴充套件、主題、語言支援、程式碼片段、快捷鍵等外掛專案,這些外掛專案建立後開箱直用,按F5執行即可。

VSCode外掛能做些什麼?

  • 不受限的本地磁碟訪問
  • 自定義命令、快捷鍵、選單
    • 資源管理器右鍵選單
    • 編輯器右鍵選單
    • 標題選單
    • 下拉選單
    • 右上角圖示
  • 自定義跳轉
  • 自動補全
  • 懸浮提示
  • 自定義設定
  • 自定義歡迎頁
  • 自定義webview(比如markdown preview
  • 自定義左側功能皮膚(比如git
  • 自定義顏色主題、圖示主題
  • 新增語言支援(Java.NetPythonDartGo……)

……等等等等

VSCode極其優秀的擴充套件架構給我們提供了非常大的施展拳腳的空間。

比如,你在專案中對反覆執行某項繁雜操作很不爽,那麼你是時候做一個外掛解放你的雙手了!!!

可以參考下面這個部落格,博主對主流外掛功能(包括自定義跳轉、自動補全、懸浮提示)做了非常全面的介紹

VSCode外掛開發全套攻略

如何實現一個webview外掛

我今天主要講一下,自己是如何實踐webview外掛的。對於前端而言,做一些能看得到的漂亮東西,總是更具有吸引力,所以我主要關注了webview這塊。先貼個成品圖:

VSCode WebView外掛(擴充套件)開發實戰

VSCode WebView外掛(擴充套件)開發實戰

首先,安裝vscode cli,

npm install -g yo generator-code
複製程式碼

再用cli建立一個New Extension (TypeScript)專案

yo code
複製程式碼

它會幫我們初始化好如下幾塊內容 :

  • tsconfig.json
  • package.json
  • extension.ts
  • .vscode目錄下的包括一鍵除錯在內的配置項

我們暫時不太需要關心tsconfig.json檔案,因為是開箱即用的,除非我們需要用到一些typescript的獨特特性。

先來看看package.json裡都有什麼:

{
    // 外掛的啟用事件
    "activationEvents": [
        "onCommand:extension.sayHello"
    ],
    // 入口檔案
    "main": "./src/extension",
    "engines": {
        "vscode": "^1.27.0"
    },
    // 貢獻點,vscode外掛大部分功能配置都在這裡
    "contributes": {
        "commands": [
            {
                "command": "extension.sayHello",
                "title": "Hello World"
            }
        ]
    }
}
複製程式碼
  • activationEvents擴充套件啟用事件,屬性值是個陣列,包含一系列事件(除了onCommand之外還有onViewonUrionLanguage等等)。因為VSCode為了效能考慮,並不會一開啟就載入所有的外掛。只有當使用者行為觸發了該陣列中包含的事件(比如執行命令或所開啟檔案的語言是json)時,才會啟用外掛(也可以配成"*",就會馬上載入,但是不建議這樣做);
  • main定義了整個外掛的入口點;
  • engines外掛最低支援的VSCode版本
  • contributes定義了外掛所有的貢獻點,比如commands(命令)、menus(選單)、configuration(配置項)、keybindings(快捷鍵繫結)、snippets(程式碼片段)、views(側邊欄內view的實現)、iconThemes(圖示主題)等等。

我們要配一個右上角的選單,直接貼配置:

"contributes": {
  "commands": [{
    "command": "extension.colaMovie",
    "title": "Cola Movie",
    "icon": {
      "light": "./images/film-light.svg",
      "dark": "./images/film-dark.svg"
    }
  }],
  "menus": {
    "editor/title": [{
      "when": "isWindows || isMac",
      "command": "extension.colaMovie",
      "group": "navigation"
    }]
  }
}
複製程式碼

解釋:定義一個extension.colaMovie命令,順便配置titleicon

為了一處命令配置多處使用,titleicon項放置在commands中了。此外,icon支援lightdark明暗兩類主題。如果不配置icon,則會顯示文字標題。

定義一個menus選單,型別為editor/title,代表右上角圖示。

  • when 配置了該選單出現的場景(條件),除了isWindowsisMac還有非常多條件可以使用
  • command 指定點選該選單會觸發什麼命令(commands中的命令)
  • group 指定選單分組,主要用於編輯器右鍵選單

然後我們再回過頭來看一下main入口extension.ts檔案:

const vscode = require('vscode');

/**
 * 外掛被啟用時觸發,所有程式碼總入口
 * @param {*} context 外掛上下文
 */
exports.activate = function(context: vscode.ExtensionContext) {
    console.log('恭喜,您的擴充套件“vscode-plugin-demo”已被啟用!');
    // 註冊命令
    context.subscriptions.push(vscode.commands.registerCommand('extension.colaMovie', function () {
        vscode.window.showInformationMessage('Hello World!');
    }));
};

/**
 * 外掛被釋放時觸發
 */
exports.deactivate = function() {
    console.log('您的擴充套件“vscode-plugin-demo”已被釋放!')
};
複製程式碼

該入口檔案匯出了兩個生命週期方法activatedeactivate

我們回憶一下之前的activationEvents屬性,當裡面相應的事件觸發了外掛時,activate方法會被喚起,當外掛被銷燬時,deactivate會被呼叫。

然後,我們必須在activate註冊一個命令 extension.colaMovie

context.subscriptions.push(vscode.commands.registerCommand('extension.colaMovie', async () => { 
  vscode.window.showInformationMessage('Hello World!');  
}));
複製程式碼

注意,所有註冊的物件(不論是命令還是語言vscode.languages.registerDefinitionProvider或是其它)都必須要將結果放入context.subscriptions中去,這是為了方便deactivateVSCode幫你自動登出它們。

此時,我們按F5除錯之後,已經可以看到右上角出現Cola Movie的小圖示了,當我們點選它的時候會在右下角彈出Hello World!的提示資訊。

讓我們來完善一下點選事件,試著建立一個webview看看:

panel = vscode.window.createWebviewPanel(
  "movie",
  "Cola Movie",
  vscode.ViewColumn.One,
  {
    enableScripts: true,
    retainContextWhenHidden: true,
  }
);
複製程式碼
  • enableScripts代表允許js指令碼執行
  • retainContextWhenHidden代表當頁籤切換離開時保持外掛上下文不銷燬

VSCode為了效能考慮,非當前頁籤都會銷燬上下文,直到切換回來再重建上下文。所以提供了setStategetState兩個方法供webview使用以即時儲存與恢復上下文。

此時,webview已經建立並開啟,但是卻一片空白。

這時我們需要給panel.webview.html設定html內容,但是:

出於安全考慮,Webview預設無法直接訪問本地資源,它在一個孤立的上下文中執行,想要載入本地圖片、jscss等必須通過特殊的vscode-resource:協議,網頁裡面所有的靜態資源都要轉換成這種格式,否則無法被正常載入。
vscode-resource:協議類似於file:協議,但它只允許訪問特定的本地檔案。和file:一樣,vscode-resource:從磁碟載入絕對路徑的資源。

找了一段替換html引用資源協議的函式,如下所示:

function getWebViewContent(context: vscode.ExtensionContext, templatePath: string) {
  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;
}
複製程式碼

我之前寫過一個electron版本的Cola Movie,此時,我想將它移植進來試下水,看下webview外掛能做到什麼程度。

我先把那邊的dist目錄拷貝過來載入index.html

const html = getWebViewContent(context, 'dist/index.html');
panel.webview.html = html;
複製程式碼

一經除錯就發現,這裡面有一個巨大的坑:

webview內部不允許傳送ajax請求,所有ajax請求都是跨域的,因為webview本身是沒有host

我之前那邊做electron開發時碰到過跨域問題,通過簡單的electron配置webSecurity: false就可以開放跨域許可權:

let winProps = {
  title: '******',
  width: 1200,
  height: 800,
  backgroundColor: '#0D4966',
  autoHideMenuBar: true,
  webPreferences: {
    webSecurity: false,
    nodeIntegration: true
  }
};
複製程式碼

可是VSCode並不會讓我們接觸electron配置,所以我想這條路是堵死了。

那怎麼傳送ajax請求把資料取到手呢?

我在extension.ts裡試了下axios是可以傳送請求並取到資料的,這裡就引出我們接下來要講的一個重頭戲了:

訊息通訊

webview和普通網頁一樣,並不能直接呼叫任何VSCode API。但是,它唯一特別之處就在於多了一個名叫acquireVsCodeApi的方法,執行這個方法會返回一個簡易版的vscode物件,具有如下三個方法:

  • getState()
  • postMessage(msg)
  • setState(newState)

這樣的話,我們可以發訊息讓extension去幫我們傳送http請求!

VSCode WebView外掛(擴充套件)開發實戰

訊息通訊方式如下:

// 外掛傳送訊息給webview
panel.webview.postMessage(message);

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

// webview傳送訊息給外掛
const vscode = acquireVsCodeApi();
vscode.postMessage(message);

// 外掛端接收訊息
panel.webview.onDidReceiveMessage(message => {
    console.log('外掛收到的訊息:', message);
}, undefined, context.subscriptions);
複製程式碼

寫過electron程式的同學肯定知道,這同electronipcMain/ipcRenderer還有websocketsend/onmessage一樣,兩端互調介面是獨立的,寫出來略有些不是很好看……

cs-channel 跨端通訊庫

於是我又封裝了一個跨端通訊庫cs-channel,並開源出去了,大家可以看一下使用方式。

extension端程式碼

const channel = new Channel({
  receiver: callback => {
    panel.webview.onDidReceiveMessage((message: IMessage) => {
      message.api && callback(message);
    }, undefined, context.subscriptions);
  },
  sender: message => void panel.webview.postMessage(message)
});
channel.on('http-get', async param => {
  return await Q(http.get(param.url, { params: param.params }));
});
複製程式碼

上面,外掛端就完成了一個http-get的介面定義

webview端程式碼

const vscode = acquireVsCodeApi();
const channel = new Channel({
  sender: message => void vscode.postMessage(message),
  receiver: callback => {
    window.addEventListener('message', (event: { data: any }) => {
      event && event.data && callback(event.data);
  });
  }
});
const result = await channel.call('http-get', { url, ...data });
複製程式碼

上面,webview端就完成了一次http-get介面的呼叫,並直接拿到了外掛端的http呼叫結果!

Channel物件,一個專案例項化兩個(webview + extension)就足夠了,不用經常例項化。
若是一個專案有多個通訊方式,比如websocket + web worker + iframe父子通訊,就例項化各自的Channel物件即可。

DLNA 投屏功能遷移

之前electron版本的Cola Movie具備DLNA投屏功能,我覺著在VSCode的外掛裡既然能全量使用nodejs api,應該也能投屏才對?

我寫了段測試程式碼

import * as Browser from 'nodecast-js';
// 是的,你沒看錯,藉助於nodecast-js庫nodejs使用dlna就是這麼簡單
const browser = new Browser();
browser.onDevice(function () {
  console.log(browser.getList());
});
browser.start();
複製程式碼

VSCode WebView外掛(擴充套件)開發實戰

確實列印了區域網內所有的可投屏裝置~

那事情就簡單多了,利用剛剛和ajax同樣的原理讓extension幫忙拿裝置列表,並幫忙推送投屏請求即可。
直接貼程式碼:

extension端程式碼

const DLNA = {
  browser: null,
  start: (): Promise<any[]> => {
    if (DLNA.browser !== null) {
      DLNA.stop();
    }
    return new Promise(resolve => {
      DLNA.browser = new Browser();
      DLNA.browser.onDevice(function () {
        resolve(DLNA.browser.getList());
      });
      setTimeout(() => {
        resolve([]);
      }, 8000);
      DLNA.browser.start();
    });
  },
  stop: () => {
    DLNA.browser && DLNA.browser.destroy();
    DLNA.browser = null;
  }
};

channel.on('dlna-request', async param => {
  const devices = await DLNA.start();
  localDevices = devices;
  return devices;
});

channel.on('dlna-destroy', async param => {
  DLNA.stop();
});

channel.on('dlna-play', async param => {
  localDevices.find(device => device.host === param.host).play(param.url, 60);
});
複製程式碼

定義三個介面:

  • dlna-request獲取裝置列表
  • dlna-play投屏視訊播放地址url到某裝置
  • dlna-destroy銷燬browser物件

webview端程式碼

const DLNA = {
  start: async () => await chanel.call<IDevice[]>('dlna-request'),
  play: (device: IDevice, url: string) => channel.call('dlna-play', { host: device.host, url }),
  stop: () => channel.call('dlna-destroy');
}
複製程式碼

F5,除錯,投屏成功!

PS:其實還有一個遺憾,就是VSCode本身在打包electron的時候移除了ffmpeg,導致webview里根本無法使用audiovideo標籤,所以播放功能是做不了了。而且cookielocalStorage等介面一律無法訪問。所以播放功能我就直接做成開啟瀏覽器播放了。只不過chrome要實現m3u8源的播放需要安裝一個外掛:Play HLS M3u8

相關文章