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 插件的,也可以留言,我單獨寫一篇。