typescript,node 插件,electron 和 webpack 那些事

1,065 阅读5分钟

首先要明确在哪里引入 node 的插件, main,preload还是 renderer?

我们开发了一个 node 的插件,需要在 electron 中引入。我们一开始当然是希望在 renderer 中引入,毕竟最接近业务逻辑,省事。

不过会遇到报错 'require is not defined',也就是没有 require 函数。

这个时候网上可能有些回答会让你在 main.ts 中打开 nodeIntegration:

mainWindow = new BrowserWindow({
  height: 800,
  width: 1280,
  maxHeight: 2160,
  webPreferences: {
    nodeIntegration: true,
    devTools: nodeEnv.dev,
    preload: path.join(__dirname, './preload.bundle.js'),
  },
});

实际上这是不推荐的,为什么要在 renderer 中允许执行本地的命令,如 fs 等等?如果是一个恶意的网站,他就能访问你本机所有的文件。当然如果你确保自己的应用不访问外部网站,也可以。

我们可以了解下比较安全的做法。

闲话少说,方法有两种:

  1. 在 main.ts 加载插件然后和 renderer 使用 ipc 通信(麻烦) 这样的话需要写大量这样的 ipc 接口,这当然不是我们想要的。
    // preload
    import { ipcRenderer } from 'electron';
    
    function showFolderPicker() {
        return ipcRenderer.invoke('dialog:openDirectory');
    }
    
    export default { showFolderPicker };
    // main
    ipcMain.handle('dialog:openDirectory', async () => {
        const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow!, {
            properties: ['openDirectory'],
        });
        if (canceled) {
            return "";
        }
        return filePaths[0];
    });
    
  2. 现在(electron 30.0) preload 仍然保留有访问 node api 的权限,将插件在 preload 暴露给 renderer。
    // preload
    import { contextBridge } from 'electron';
    import ipcAPI from '_preload/ipc-api';
    import myplugin from 'myplugin'; // node 插件
    
    contextBridge.exposeInMainWorld('ipcAPI', ipcAPI);
    contextBridge.exposeInMainWorld('myplugin', myplugin);
    
    // global.d.ts 别忘了定义一个全局的类型文件
    declare global {
        interface Window {
            /** APIs for Electron IPC */
            ipcAPI?: typeof import('_preload/ipc-api').default;
            myplugin?: typeof import('myplugin');
        }
    }
    // Makes TS sees this as an external modules so we can extend the global scope.
    export { };
    
    // renderer 中就可以调用了
    windows.myplugin.hello()
    
    在生产环境的配置方法见下文。

为什么会有这个问题

这要看 electron 在安全方面做了哪些变动。

  1. Electron 1 nodeIntegration 默认是 true Renderer 可以访问全部 node 接口。

  2. Electron 5 nodeIntegration 默认是 false 此时可用 preload 来暴露接口,无论 nodeIntegration 怎么设置,preload 都是能访问 node 接口的。

    //preload.js
    window.api = {
        deleteFile: f => require('fs').unlink(f)
    }
    
  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,

    mainWindow = new BrowserWindow({
        height: 800,
        width: 1280,
        maxHeight: 2160,
        webPreferences: {
            devTools: nodeEnv.dev,
            preload: path.join(__dirname, './preload.bundle.js'),
            sandbox: true, // 就是这
    

    那你的 preload 得这么写:

    //preload.js
    const { contextBridge, remote } = require('electron')
    
    contextBridge.exposeInMainWorld('api', {
       deleteFile: f => remote.require('fs').unlink(f)
    })
    
  5. Electron 10 enableRemoteModule 默认是 false (remote module 在 Electron 12 中就废弃了)

    remote 模块大家都很熟悉了,如果你需要访问 main 进程的 api,你就得用它。没有它你就要写大量的 ipc,就像上面说的方法1.

推荐做法

设置

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

如果觉得不够安全,就开 sandbox,这样你就可以愉快的写大量的 ipc 代码了。

sandbox 关闭的时候,preload 可以直接访问 node api,比如 require('fs').readFile,只要你别这么玩,你就是安全的:

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

具体代码

具体怎么在 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 文件。

    // node-gyp's linked version in the "build" dir
    ['module_root', 'build', 'bindings'],
    // node-waf and gyp_addon (a.k.a node-gyp)
    ['module_root', 'build', 'Debug', 'bindings'],
    ['module_root', 'build', 'Release', 'bindings'],
    // Debug files, for development (legacy behavior, remove for node v0.9)
    ['module_root', 'out', 'Debug', 'bindings'],
    ['module_root', 'Debug', 'bindings'],
    // Release files, but manually compiled (legacy behavior, remove for node v0.9)
    ['module_root', 'out', 'Release', 'bindings'],
    ['module_root', 'Release', 'bindings'],
    // Legacy from node-waf, node <= 0.4.x
    ['module_root', 'build', 'default', 'bindings'],
    // Production "Release" buildtype binary (meh...)
    ['module_root', 'compiled', 'version', 'platform', 'arch', 'bindings'],
    // node-qbs builds
    ['module_root', 'addon-build', 'release', 'install-root', 'bindings'],
    ['module_root', 'addon-build', 'debug', 'install-root', 'bindings'],
    ['module_root', 'addon-build', 'default', 'install-root', 'bindings'],
    // node-pre-gyp path ./lib/binding/{node_abi}-{platform}-{arch}
    ['module_root', 'lib', 'binding', 'nodePreGyp', 'bindings']
    ...
    function bindings(opts) {
       // Argument surgery
       if (typeof opts == 'string') {
         opts = { bindings: opts };
       } else if (!opts) {
         opts = {};
       }
       if (!opts.module_root) {
          opts.module_root = exports.getRoot(exports.getFileName());
       } 
        ...
         opts.try[i].map(function(p) {
              return opts[p] || p; // 如果 bindings 传入了一个对象 {},且对象中有 module_root,就用对象中的 module_root 对应的值。否则直接用 try 里面的字符串
            })
    

    bindings.js 详解: 你可以自己在浏览器中测试一下 bindings.js 的逻辑,本文不再贴代码,直接说结论。

    下面是一段 preload.ts 的代码,我写了两种加载插件的方法,请看注释:

    function getPlugin() {
      // 这是第一种加载方法,一切都是默认,只传一个 myplugin 名称
      const nodeAddon = bindings("myplugin"); // 这里在 bindings.js 中的 getRoot 和 getFileName 中会做一些运算,根据谁引入的 bindings.js 来计算 module_root,也就是去哪个文件夹中去找。这一段逻辑很繁琐和无趣,可以自行了解一下
      logger.log("preload.ts1" + JSON.stringify(nodeAddon));
    
      // 这是第二种加载方法
      let tries = [["module_root", "bindings"]]; // 含义:生产环境去加载 process.cwd()/myplugin.node(module_root被替换成了process.cwd(), bindings 被替换成了 myplugin.node)
      if (dev) { // dev = process.env.NODE_ENV === 'development'
        tries = [["module_root", "build", "bindings"]]; // 含义:开发环境去加载 process.cwd()/build/myplugin.node(build没有被替换,看下面 bindings 函数的参数,没有传 build)
      }
      const nodeAddon2 = bindings({
        bindings: "myplugin",
        module_root: process.cwd(), // 含义:binding.js 中将 module_root 替换成 process.cwd()
        try: tries,
      });
    
      logger.log("preload.ts2" + JSON.stringify(nodeAddon2));
      return nodeAddon2;
    }
    
    contextBridge.exposeInMainWorld('myplugin', getPlugin());
    
  3. 根据以上结论,在 webpack 里面这么设置,将 node 文件和 dll 文件放到工程的根目录下的 build 目录。

    const mainConfig = merge(commonConfig, {
       entry: './src/main/main.ts',
       target: 'electron-main',
       output: { filename: 'main.bundle.js' },
       plugins: [
          new CopyPlugin({
             patterns: [
                {
                 // 省略
                },
                {
                   from: 'node_modules/myplugin/build/Release/myplugin.node',
                   to: '../build/',
                },
                {
                   from: 'node_modules/myplugin/myplugin.dll',
                   to: '../build/',
                },
             ],
          }),
       ],
    });
    

    而在打包后,比如用 Electron Builder,可以这么配置,直接去安装目录找:

    "build": {
     "appId": "",
     "productName": "",
     ...
     "extraFiles": [
       {
         "from": "build/",
         "to": ""
       },
     ],
    

我尝试过 webpack 设置 externals 或者 node-loader,都没跑通。你有什么好的方法,欢迎分享,另外说句感想,最近一个月接触的前端,但是感觉前端真的乱。有想知道怎么用 go 写 node 插件的,也可以留言,我单独写一篇。