iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 18
0
Software Development

自己用的工具自己做! 30天玩轉VS Code Extension之旅系列 第 18

Day18 | WebView API (四)

大家好,我是韋恩,今天是第十八天,我們會繼續了解如何組織Webview,並進一步整合當前流行的SPA前端框架。

設計CustomWebPanel物件


在前面的單元裡,我們已經練習了如何使用Webview的API,並且也了解相關的API與載入本地檔案資源的規範。現在我們要將其運用到實戰中,讓我們進一步組織WebView的API,設計一個模組化的CustomWebViewPanel的class,讓WebView API更好使用與易於管理吧!

  • 使用Singleton模式,讓WebviewPanel物件永遠只會同時有一個實例

對於VSCode的WebPanel而言,我們一次只需要一個panel,重複產生panel物件,會造成資源浪費,因此在這種情境,非常適合使用單例模式來處理。

底下我們提供一個CreateOrShow方法,用以取得WebViewPanel物件。當已經存在創建過的webviewPanel物件時,就會直接呼叫WebPanel的reveal方法將已經創建的webview在當前editor group中的。若是先前未創建WebviePanel,就會直接創建並回傳一個WebviewPanel。

export class WebviewPanel {

 private static _instance: WebviewPanel | null;

 public static createOrShow(context: vscode.ExtensionContext) {
  const column = vscode.window.activeTextEditor ?   vscode.window.activeTextEditor.viewColumn : undefined;
  if (WebviewPanel._instance) {
   WebviewPanel._instance._panel.reveal(column);
  } else {
   WebviewPanel._instance = new WebviewPanel(context);
  }
   return WebviewPanel._instance;
 }

 public readonly viewType = 'SinglePageApplicationPanel';

 private _panel: vscode.WebviewPanel;

 private get webview() {
  return this._panel.webview;
 }

 private constructor(private context: vscode.ExtensionContext) {
  this._panel = this.createPanel(context);
  this.webview.html = this.loadWebviewContent();
  ...
 }

 private createPanel(context: vscode.ExtensionContext) {
  return vscode.window.createWebviewPanel(
   this.viewType,
   'CustomWebview',
   vscode.ViewColumn.One,
   {
    enableScripts: true,
    localResourceRoots: [
      vscode.Uri.joinPath(context.extensionUri, 'out', 'build')
    ]
   }
  );
 }
 ...
}

這裡我們直接使用比較傳統的Singleton模式,而不使用配合ES6模組直接export類別實例的方式。為什麼呢?Webview的物件會佔用耗資源,並且不是像OutputChannl任何時候都會使用到。因此我們不需要再extensiont初始運作時,就直接把WebViewPanel實例化並佔用系統資源,等到需要啟動WebviewPanel再創建物件即可。

另外,因為我們僅使用createOrShow方法來創建與獲取物件,上面我們就直接將constructor設為private,防止有人直接new出另一個類別的實例。

有了createOrShow方法後,我們可以在extension.ts裡這樣使用它

export function activate(context: vscode.ExtensionContext) {

	let disposable = vscode.commands.registerCommand('day18-singleton-webview-spa.createOrShowWebview', () => {
	const webviewPanel = WebviewPanel.createOrShow(context);
    ...
	context.subscriptions.push(disposable);
});};

export function deactivate() {}

好,現在已經有了Webview的創建和呈現方法,讓我們進一步封裝原有的vscode.WebviewPanel裡面的方法,讓外部更好使用。

  • 封裝生命週期方法

export class WebviewPanel {
     ...
     private _panel: vscode.WebviewPanel;

     private get webview() {
      return this._panel.webview;
     }

     private _viewStateEmitter = new vscode.EventEmitter();

     public get onDidChangeViewState() {
      return this._viewStateEmitter.event;
     }
     
     private _messageEmitter = new vscode.EventEmitter();

     public get onDidReceiveMessage() {
      return this._messageEmitter.event;
     }

     private _disposables: vscode.Disposable[] = [];

     private constructor(private context: vscode.ExtensionContext) {
       this._panel = this.createPanel(context);
       this.onLifeCycleChanges();
       this.onWebViewMessage();
       this.webview.html = this.loadWebviewContent();
       this._disposables.push(this._panel);
     }

     ...

     private onLifeCycleChanges() {

      this._panel.onDidChangeViewState(
       e => {
        this._viewStateEmitter.fire(e);
        ...
       },
       null,
       this._disposables
      );

      this._disposables.push(this._viewStateEmitter);

      this._panel.onDidDispose(() => {
        this._disposables.forEach((d: vscode.Disposable) => d.dispose());
        this._disposables = [];
        WebviewPanel._instance = null;
      }, null, this._disposables);

     }

     private onWebViewMessage() {
      this._panel.webview.onDidReceiveMessage(
       message => {
           this._messageEmitter.fire(e);
           ...
       },
       null,
       this._disposables
      );
      this._disposables.push(this._messageEmitter);
     }
     ...
}

這裡我們就用之前實作Treeview的onDidTreeViewData同樣的方法,讓我們的WebviewPanel一樣可以被外部監聽資料改變的事件。同時我們也做好dispose資源的處理,並且監聽WebviewPanel傳來的訊息。

想當然爾,接下來我們就可以提供一個sendMessage方法提供給外部使用

export class WebviewPanel {
    ...
    public sendMessage(action) {
      this.webview.postMessage(action);
     }
    ...
}

整合SPA至Webview


好啦,已經封裝了常用的方法,現在我們要實作最重要的loadWebviewContent方法了

我們先完成一個內部用的webviewUri簡化之後uri轉換的程式碼

 private webviewUri(relativePath: string) {
  const uri = vscode.Uri.parse(
   path.join(this.context.extensionPath,'out','build', relativePath)
  );
  return this.webview.asWebviewUri(uri);
 }

接下來我們就要在loadWebviewContent方法方面設計給spa使用的index.html樣板,底下我們會以react為例。

為什麼是react,而不選用其他流行的框架呢?

這裡選用react原因主要幾點:

  1. 生態系最豐富,其生態系的開源專案(如redux),對前端的各框架影響跟啓發性大

  2. 沒有預設立場,寫法自由,不會預設限制使用者使用何種方式(如物件導向、函數式程式設計)開發。

  3. 時間跟篇幅的關係,僅能選用一個SPA框架在這三十天裡的文章做示範。

另外,就筆者的觀點,如果對方會有「 因為你介紹XXX框架,所以你就是哪一派的人(或哪種人)」的想法,其實也只是對方心理有預設立場,或依此獲取名利、區分派系,才會計較些有的沒的。

不然,搞個技術為什麼要這麼複雜?有什麼跟私心無關的理由隨意幫別人貼標籤呢?

好的,以上是幾點筆者選用react當範例的理由。

鐵人賽的三十天以後,筆者規劃將在附錄介紹angular與vue3.0的整合教學,供選用其餘框架的讀者參考。

  • React專案配置介紹

下面因為我們要載入react網站,會先進行react專案的配置。

這裡我們使用Create-React-APP快速產生React專案,此處創建react的教學亦可參考CRA的官方網站

首先,讓我們在vscode底下再另外開一個termainal,進入src/webview資料夾的路徑下,使用CRA創建一個名為webview的專案。

$ npx create-react-app --typescript <your-react-webview-app-name>

創建react的app以後,進入創建的react資料夾下,並使用react專案底下的npm script啟動react

cd  <your-react-webview-app-name>
npm start

好的,如此我們就可以正常的開發react app,現在,我們需要使用production編譯後的react程式。

npm run build
  • build完在react專案的資料夾可以看到多了一個build資料夾,下面是編譯過的檔案。

現在我們直接serve這index.html,即可啟動react應用程式:

/**
 * 如無安裝servce套件
 */
 yarn global add serve
 /**
 * serve build資料夾下面的專案
 */
 serve -s build

執行serve後,我們可以看到已經listen了localhost為5000的port

現在在瀏覽器下輸入http://localhost:5000 的url,即會顯示react程式。

  • 整合index.html至Webview

現在我們已經知道正常情況下怎麼build完react程式了,但這並不能夠很好的與vscode的extension結合。還記得嗎?在第一天的章節,我們將要讀取的html檔案也複製到extension程式編譯完的資料夾out

現在我們一樣要在build完之後,將production使用的code放置於out資料夾下底下,因此讓我們在react專案下package.json的build script修改為

...
"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build && mv build ../out/build  || move build ../out/build",
    ...
},

讀者可以看到,我們使用了mv來搬運檔案,並於沒有mv指令的windows平台使用了move指令進行檔案搬運。如此一來,在react程式編譯完成後,build資料夾就會被自動搬運到out資料夾下底下。

接下來,讓我們查看一下主程式的進入點index.html吧

<!doctype html>
  <html lang="en">
    <head>
     <meta charset="utf-8"/>
     <link rel="icon" href="/favicon.ico"/>
     <meta name="viewport" content="width=device-width,initial-scale=1"/>
     <meta name="theme-color" content="#000000"/>
     <meta name="description" content="Web site created using create-react-app"/>
     <link rel="apple-touch-icon" href="/logo192.png"/>
     <link rel="manifest" href="/manifest.json"/>
     <title>React App</title>
     <link href="/static/css/main.5f361e03.chunk.css" rel="stylesheet"></head>
     <body>
      <noscript>You need to enable JavaScript to run this app.</noscript>
      <div id="root"></div>
      <script>...</script>
      <script src="/static/js/2.390c405e.chunk.js"></script>
      <script src="/static/js/main.0efac7b8.chunk.js"></script>
     </body>
 </html>

我們可以看到,除了一些logo圖像跟icon以外,script的src下的js與css檔案均加上了webpack隨機產生的hash編號。要動態將正確的路徑轉換為webviewUri,我們需要透過react-script編譯完後產生的一個asset-manifest.json檔案下面的對應表找到對應的javascript/css檔案路徑。

讓我們寫一個小方法讀取這個對應表檔案

function readJSON(path: string) {
	const jsonString = fs.readFileSync(path, 'utf-8');
	return JSON.parse(jsonString);
}

現在我們就可以實作我們提供給Webview的html樣板了

class WebViewPanel {
    ...
    public loadWebviewContent() {
      const mainfest = readJSON(path.join(this.context.extensionPath, 'out/build/asset-manifest.json');
      const entrypointsJs = mainfest['entrypoints'].filter((p: string) => p.includes('static/js'));
      return `<!DOCTYPE html>
       <html lang="en">
       <head>
           <link rel="icon" href="${this.webviewUri('./favicon.ico')}"/>
             <meta name="viewport" content="width=device-width,initial-scale=1"/>
             <meta name="theme-color" content="#000000"/>
             <meta name="description" content="Web site created using create-react-app"/>
             <link rel="apple-touch-icon" href="${this.webviewUri('./logo192.png')}"/>
             <link rel="manifest" href="${this.webviewUri('./manifest.json')}"/>
             <title>React App</title>
             <link rel="stylesheet" type="text/css" href="${this.webviewUri(mainfest.files['main.css'])}">
             <base href="${this.webviewUri('/')}">
       </head>
       <body>
           <noscript>You need to enable JavaScript to run this app.</noscript>
           <div id="root"></div>
           <script src="${this.webviewUri(entrypointsJs[0])}"></script>
           <script src="${this.webviewUri(entrypointsJs[1])}"></script>
           <script src="${this.webviewUri(entrypointsJs[2])}"></script>
       </body>
   </html>`;
     }
	...
}

透過上面的方法,我們將manifest裡面的對應路徑轉換成VSCode的WebviewUri。

同時,為了讓所有我們在react專案底下的相對路徑都能夠生效,我們在index.html中加上base標籤,讓所有react專案下面的相對路徑都能對應到轉換後的webviewUri。

<base href="${this.webviewUri('/')}">

完成以上方法後,再啟動extension並執行創建Webview的命令,我們會看到Webview的react渲染出網站。

啟動Webview後我們發現,react的logo讀取不到圖檔資源。為什麼會這樣呢?讓我們打開webview的devtool檢視下這個image元素

讀者們可以看到,在react程式裡import的圖像的路徑預設會被Create React APP編譯成/static/media/...的方式處理。

這種形式的Url是無法配合我們前面宣告的Base標籤運作的

 <base href="${this.webviewUri('/')}">

為此,我們需要改變Create React APP使用Webpack編譯時的路徑,讓我們在Webview專案的資料夾下面加入一個.env檔案,在檔案裡面設定一個PUBLIC_URL的環境變數

PUBLIC_URL=./

設定完環境變數檔案後,再讓我們再Webview專案下運行npm run build方法執行react-scripts編譯react檔案,並將檔案搬移到out資料夾。讓我們來看一下執行結果:

恭喜讀者,我們成功的整合react與專案裡的圖像資源到擴充套件裡了。

現在我們再來檢視下Webview Devtool下的image路徑,可以看到圖像路徑開頭已經成功地轉為./的相對路徑了。

結語


總算結束啦!

今天我們練習使用物件導向的方式組織WebView API,並進一步整合當前流行的SPA前端框架至WebView。

明天我們將開始extension的專案實戰,我們明天見,謝謝大家。

本日參考文件



上一篇
Day17 | WebView API (三)
下一篇
Day19 | 專案實戰:CodeManager介紹
系列文
自己用的工具自己做! 30天玩轉VS Code Extension之旅36

尚未有邦友留言

立即登入留言