在 webapck+electron+typescript中使用go開發的node插件

Webpack Electron Node 插件配置

node 插件,electron 和 webpack 那些事

首先要明確在哪裏引入 node 的插件, main,preload還是 renderer?

我們開發了一個 node 的插件,需要在 electron 中引入。我們一開始當然是希望在 renderer 中引入,畢竟最接近業務邏輯,省事。

不過會遇到報錯 ‘require is not defined’,也就是沒有 require 函數。

這個時候網上可能有些回答會讓你在 main.ts 中打開 nodeIntegration:

 1mainWindow = new BrowserWindow({
 2  height: 800,
 3  width: 1280,
 4  maxHeight: 2160,
 5  webPreferences: {
 6    nodeIntegration: true,
 7    devTools: nodeEnv.dev,
 8    preload: path.join(__dirname, './preload.bundle.js'),
 9  },
10});

實際上這是不推薦的,爲什麼要在 renderer 中允許執行本地的命令,如 fs 等等?如果是一個惡意的網站,他就能訪問你本機所有的文件。當然如果你確保自己的應用不訪問外部網站,也可以。

我們可以瞭解下比較安全的做法。

爲了解決這個問題,我花了整整一天的時間,我這個項目的技術棧是 TypeScript, Webpack 5, 並且需要引入一個 go 寫的 node 插件,現代 javascript 的buff 疊滿了屬於是,我這個後端開發感受到了前端滿滿的惡意了。 開發插件並在 node 中跑通不到兩小時,可是把這個插件放到 webpack + electron 中花了我整整 7 個小時。

閒話少說,方法有兩種:

  1. 在 main.ts 加載插件然後和 renderer 使用 ipc 通信(麻煩) 這樣的話需要寫大量這樣的 ipc 接口,這當然不是我們想要的。
     1// preload
     2import { ipcRenderer } from 'electron';
     3
     4function showFolderPicker() {
     5    return ipcRenderer.invoke('dialog:openDirectory');
     6}
     7
     8export default { showFolderPicker };
     9// main
    10ipcMain.handle('dialog:openDirectory', async () => {
    11    const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow!, {
    12        properties: ['openDirectory'],
    13    });
    14    if (canceled) {
    15        return "";
    16    }
    17    return filePaths[0];
    18});
    
  2. 現在(electron 30.0) preload 仍然保留有訪問 node api 的權限,將插件在 preload 暴露給 renderer。
     1// preload
     2import { contextBridge } from 'electron';
     3import ipcAPI from '_preload/ipc-api';
     4import myplugin from 'myplugin'; // node 插件
     5
     6contextBridge.exposeInMainWorld('ipcAPI', ipcAPI);
     7contextBridge.exposeInMainWorld('myplugin', myplugin);
     8
     9// global.d.ts 別忘了定義一個全局的類型文件
    10declare global {
    11    interface Window {
    12        /** APIs for Electron IPC */
    13        ipcAPI?: typeof import('_preload/ipc-api').default;
    14        myplugin?: typeof import('myplugin');
    15    }
    16}
    17// Makes TS sees this as an external modules so we can extend the global scope.
    18export { };
    19
    20// renderer 中就可以調用了
    21windows.myplugin.hello()
    
    在生產環境的配置方法見下文。

爲什麼會有這個問題

這要看 electron 在安全方面做了哪些變動。

  1. Electron 1 nodeIntegration 默認是 true Renderer 可以訪問全部 node 接口。

  2. Electron 5 nodeIntegration 默認是 false 此時可用 preload 來暴露接口,無論 nodeIntegration 怎麼設置,preload 都是能訪問 node 接口的。

    1//preload.js
    2window.api = {
    3    deleteFile: f => require('fs').unlink(f)
    4}
    
  3. Electron 5 contextIsolation 默認是 true 這會導致 preload 在一個隔離的環境中運行,這樣你就沒法 windows.api = xxx 了,你需要 exposeInMainWorld

    //preload.js
    const { contextBridge } = require('electron')
    contextBridge.exposeInMainWorld('api', {
        deleteFile: f => require('fs').unlink(f)
    })
    
  4. Electron 6 如果你在 mainWindow 設置了 sandbox: true,

    1mainWindow = new BrowserWindow({
    2    height: 800,
    3    width: 1280,
    4    maxHeight: 2160,
    5    webPreferences: {
    6        devTools: nodeEnv.dev,
    7        preload: path.join(__dirname, './preload.bundle.js'),
    8        sandbox: true, // 就是這
    

    那你的 preload 得這麼寫:

    1//preload.js
    2const { contextBridge, remote } = require('electron')
    3
    4contextBridge.exposeInMainWorld('api', {
    5   deleteFile: f => remote.require('fs').unlink(f)
    6})
    
  5. Electron 10 enableRemoteModule 默認是 false (remote module 在 Electron 12 中就廢棄了)

    remote 模塊大家都很熟悉了,如果你需要訪問 main 進程的 api,你就得用它。沒有它你就要寫大量的 ipc,就像上面說的方法1.

推薦做法

設置

1{nodeIntegration: false, contextIsolation: true, enableRemoteModule: false}

如果覺得不夠安全,就開 sandbox,這樣你就可以愉快的寫大量的 ipc 代碼了。

sandbox 關閉的時候,preload 可以直接訪問 node api,比如 require('fs').readFile,只要你別這麼玩,你就是安全的:

1//bad
2contextBridge.exposeInMainWorld('api', {
3    readFile: require('fs').readFile
4})

具體代碼

具體怎麼在 webpack 和 electron 裏面跑起來,我相信很多人都會。但是奈何我就是找不到一篇能落地的文章。我這裏拋磚引玉,希望大家都說說自己怎麼實現的,也希望能節省未來某一個少年的時間吧。

  1. 將 node-gyp 生成的包,直接本地 npm install 假設你的工程目錄如下,插件在 src/plugin/build 下面。

    publid
    package.json
    src
      |-plugin
        |- build // 這一層有 package.json 的就是你的插件的包描述文件
           |- build
             |- Release
               |- myplugin.node
           |- package.json
           |- index.js
           |- index.d.ts
           |- myplugin.dll
    

    執行 npm i src/plugin/build

  2. 在開發態,你的代碼編譯應該就不飄紅了。打包運行的時候,因爲有 bindings.js,它會去以下位置找你的 myplugin.node 文件。

     1// node-gyp's linked version in the "build" dir
     2['module_root', 'build', 'bindings'],
     3// node-waf and gyp_addon (a.k.a node-gyp)
     4['module_root', 'build', 'Debug', 'bindings'],
     5['module_root', 'build', 'Release', 'bindings'],
     6// Debug files, for development (legacy behavior, remove for node v0.9)
     7['module_root', 'out', 'Debug', 'bindings'],
     8['module_root', 'Debug', 'bindings'],
     9// Release files, but manually compiled (legacy behavior, remove for node v0.9)
    10['module_root', 'out', 'Release', 'bindings'],
    11['module_root', 'Release', 'bindings'],
    12// Legacy from node-waf, node <= 0.4.x
    13['module_root', 'build', 'default', 'bindings'],
    14// Production "Release" buildtype binary (meh...)
    15['module_root', 'compiled', 'version', 'platform', 'arch', 'bindings'],
    16// node-qbs builds
    17['module_root', 'addon-build', 'release', 'install-root', 'bindings'],
    18['module_root', 'addon-build', 'debug', 'install-root', 'bindings'],
    19['module_root', 'addon-build', 'default', 'install-root', 'bindings'],
    20// node-pre-gyp path ./lib/binding/{node_abi}-{platform}-{arch}
    21['module_root', 'lib', 'binding', 'nodePreGyp', 'bindings']
    22...
    23function bindings(opts) {
    24   // Argument surgery
    25   if (typeof opts == 'string') {
    26     opts = { bindings: opts };
    27   } else if (!opts) {
    28     opts = {};
    29   }
    30   if (!opts.module_root) {
    31      opts.module_root = exports.getRoot(exports.getFileName());
    32   } 
    33    ...
    34     opts.try[i].map(function(p) {
    35          return opts[p] || p; // 如果 bindings 傳入了一個對象 {},且對象中有 module_root,就用對象中的 module_root 對應的值。否則直接用 try 裏面的字符串
    36        })
    

    bindings.js 詳解: 你可以自己在瀏覽器中測試一下 bindings.js 的邏輯,本文不再貼代碼,直接說結論。

    下面是一段 preload.ts 的代碼,我寫了兩種加載插件的方法,請看註釋:

     1function getPlugin() {
     2  // 這是第一種加載方法,一切都是默認,只傳一個 myplugin 名稱
     3  const nodeAddon = bindings("myplugin"); // 這裏在 bindings.js 中的 getRoot 和 getFileName 中會做一些運算,根據誰引入的 bindings.js 來計算 module_root,也就是去哪個文件夾中去找。這一段邏輯很繁瑣和無趣,可以自行了解一下
     4  logger.log("preload.ts1" + JSON.stringify(nodeAddon));
     5
     6  // 這是第二種加載方法
     7  let tries = [["module_root", "bindings"]]; // 含義:生產環境去加載 process.cwd()/myplugin.node(module_root被替換成了process.cwd(), bindings 被替換成了 myplugin.node)
     8  if (dev) { // dev = process.env.NODE_ENV === 'development'
     9    tries = [["module_root", "build", "bindings"]]; // 含義:開發環境去加載 process.cwd()/build/myplugin.node(build沒有被替換,看下面 bindings 函數的參數,沒有傳 build)
    10  }
    11  const nodeAddon2 = bindings({
    12    bindings: "myplugin",
    13    module_root: process.cwd(), // 含義:binding.js 中將 module_root 替換成 process.cwd()
    14    try: tries,
    15  });
    16
    17  logger.log("preload.ts2" + JSON.stringify(nodeAddon2));
    18  return nodeAddon2;
    19}
    20
    21contextBridge.exposeInMainWorld('myplugin', getPlugin());
    
  3. 根據以上結論,在 webpack 裏面這麼設置,將 node 文件和 dll 文件放到工程的根目錄下的 build 目錄。

     1const mainConfig = merge(commonConfig, {
     2   entry: './src/main/main.ts',
     3   target: 'electron-main',
     4   output: { filename: 'main.bundle.js' },
     5   plugins: [
     6      new CopyPlugin({
     7         patterns: [
     8            {
     9             // 省略
    10            },
    11            {
    12               from: 'node_modules/myplugin/build/Release/myplugin.node',
    13               to: '../build/',
    14            },
    15            {
    16               from: 'node_modules/myplugin/myplugin.dll',
    17               to: '../build/',
    18            },
    19         ],
    20      }),
    21   ],
    22});
    

    而在打包後,比如用 Electron Builder,可以這麼配置,直接去安裝目錄找:

     1"build": {
     2 "appId": "",
     3 "productName": "",
     4 ...
     5 "extraFiles": [
     6   {
     7     "from": "build/",
     8     "to": ""
     9   },
    10 ],
    

我嘗試過 webpack 設置 externals 或者 node-loader,都沒跑通。你有什麼好的方法,歡迎分享,另外說句感想,最近一個月接觸的前端,但是感覺前端真的亂。有想知道怎麼用 go 寫 node 插件的,也可以留言,我單獨寫一篇。