在 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 插件的,也可以留言,我单独写一篇。