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 个小时。
闲话少说,方法有两种:
- 在 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});
- 现在(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 在安全方面做了哪些变动。
Electron 1 nodeIntegration 默认是 true Renderer 可以访问全部 node 接口。
Electron 5 nodeIntegration 默认是 false 此时可用 preload 来暴露接口,无论 nodeIntegration 怎么设置,preload 都是能访问 node 接口的。
1//preload.js 2window.api = { 3 deleteFile: f => require('fs').unlink(f) 4}
Electron 5 contextIsolation 默认是 true 这会导致 preload 在一个隔离的环境中运行,这样你就没法 windows.api = xxx 了,你需要 exposeInMainWorld
//preload.js const { contextBridge } = require('electron') contextBridge.exposeInMainWorld('api', { deleteFile: f => require('fs').unlink(f) })
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})
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 里面跑起来,我相信很多人都会。但是奈何我就是找不到一篇能落地的文章。我这里抛砖引玉,希望大家都说说自己怎么实现的,也希望能节省未来某一个少年的时间吧。
将 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
在开发态,你的代码编译应该就不飘红了。打包运行的时候,因为有 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());
根据以上结论,在 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 插件的,也可以留言,我单独写一篇。