如何在Electron中正确使用preload.js

101

我尝试在renderer进程中使用Node模块(例如fs),像这样:

// main_window.js
const fs = require('fs')

function action() {
    console.log(fs)
}

注意: 当我在main_window中按下一个按钮时,action函数会被调用。

但这会导致一个错误:

Uncaught ReferenceError: require is not defined
    at main_window.js:1

我可以解决这个问题,如此被接受的答案所建议的那样,在初始化main_window时添加以下这些代码到我的main.js中:

// main.js
main_window = new BrowserWindow({
    width: 650,
    height: 550,
    webPreferences: {
        nodeIntegration: true
    }
})

但是,根据文档,这不是最好的做法,我应该创建一个preload.js文件,并在其中加载这些Node模块,然后在所有renderer进程中使用它。像这样:

main.js:

main_window = new BrowserWindow({
    width: 650,
    height: 550,
    webPreferences: {
        preload: path.join(app.getAppPath(), 'preload.js')
    }
})

preload.js:

const fs = require('fs')

window.test = function() {
    console.log(fs)
}

main_window.js:

function action() {
    window.test()
}

好的!


现在我的问题是,我是否应该在 preload.js 中编写大部分 renderer 进程的代码(因为只有在 preload.js 中我才能访问 Node 模块),然后仅在每个 renderer.js 文件中调用函数(例如在这里,main_window.js)?我还有什么不理解的地方吗?

5个回答

141

编辑2022


我已经发布了一篇更大的文章,介绍了Electron的历史(安全性如何随着Electron版本的变化而改变),以及Electron开发人员可以考虑的其他安全注意事项,以确保在新应用程序中正确使用预加载文件。

2020年编辑


作为另一个用户提问,让我在下面解释一下我的答案。
在Electron中使用preload.js的正确方法是,在任何应用程序可能需要require的模块周围公开白名单包装器。
从安全角度来看,将require或通过require调用检索到的任何内容暴露在您的preload.js中都是危险的(有关更多说明,请参见我在此处的评论)。如果您的应用程序加载远程内容,则尤其如此。
为了做正确的事情,您需要在BrowserWindow上启用许多选项,如下所述。设置这些选项会强制您的电子应用程序通过IPC(进程间通信)进行通信,并将两个环境彼此隔离。像这样设置您的应用程序允许您验证后端中可能是require模块的任何内容,该内容不受客户端篡改。
下面,您将找到一个简短的示例,说明我的意思以及它如何在您的应用程序中显示。如果您刚开始,我建议使用secure-electron-template(我是作者),其中包含从构建electron应用程序时就内置的所有安全最佳实践。

这个页面还提供了关于使用preload.js制作安全应用程序时所需的体系结构的良好信息。


main.js

const {
  app,
  BrowserWindow,
  ipcMain
} = require("electron");
const path = require("path");
const fs = require("fs");

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;

async function createWindow() {

  // Create the browser window.
  win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false, // is default value after Electron v5
      contextIsolation: true, // protect against prototype pollution
      enableRemoteModule: false, // turn off remote
      preload: path.join(__dirname, "preload.js") // use a preload script
    }
  });

  // Load app
  win.loadFile(path.join(__dirname, "dist/index.html"));

  // rest of code..
}

app.on("ready", createWindow);

ipcMain.on("toMain", (event, args) => {
  fs.readFile("path/to/file", (error, data) => {
    // Do something with file contents

    // Send result back to renderer process
    win.webContents.send("fromMain", responseObj);
  });
});

preload.js

const {
    contextBridge,
    ipcRenderer
} = require("electron");

// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld(
    "api", {
        send: (channel, data) => {
            // whitelist channels
            let validChannels = ["toMain"];
            if (validChannels.includes(channel)) {
                ipcRenderer.send(channel, data);
            }
        },
        receive: (channel, func) => {
            let validChannels = ["fromMain"];
            if (validChannels.includes(channel)) {
                // Deliberately strip event as it includes `sender` 
                ipcRenderer.on(channel, (event, ...args) => func(...args));
            }
        }
    }
);

index.html

<!doctype html>
<html lang="en-US">
<head>
    <meta charset="utf-8"/>
    <title>Title</title>
</head>
<body>
    <script>
        window.api.receive("fromMain", (data) => {
            console.log(`Received ${data} from main process`);
        });
        window.api.send("toMain", "some data");
    </script>
</body>
</html>

3
如果你有时间的话,你应该将你在链接评论中的基本想法搬到这个回答中,因为现在这个答案看起来有点像一个模板回复,没有真正回答问题。特别是如果链接失效了。 - Assimilater
1
谢谢@Assimilater,我已经添加了更多细节到我的回答中。 - reZach
4
首先,这个答案很棒。如果你也在使用typescript,你需要将preload更改为preload.ts,并且像这样进行处理:https://dev59.com/kVMI5IYBdhLWcg3wVqCm - David Rasch
1
在你的示例中,你在主进程中使用了 fs,你知道直接在 preload 中使用它是否会存在任何安全风险吗? - Arkellys
1
如果 path.join(__dirname, 'preload.js') 不起作用,那么请使用 __static。我的问题和解决方案可以在 https://dev59.com/XVIH5IYBdhLWcg3wKqI7#68795261 找到。 - Андрей Сорока
显示剩余15条评论

33

考虑这个示例

Electron Main Preload and Renderer

官方文档中并非所有内容都可以直接实施于您的代码中。 您必须对环境和进程有简明的了解。

环境/进程 描述
Main 更接近操作系统(低级别)的API,包括文件系统、基于操作系统的通知弹出窗口、任务栏等。 这些是通过Electron核心API和Node.js的结合实现的。
Preload 为了防止主环境中可用的强大API泄漏而新增的一项功能。 有关更多详细信息,请参见Electron v12更改日志问题#23506
Renderer 现代Web浏览器的API,如DOM和前端JavaScript(高级别)。 这是通过Chromium实现的。

上下文隔离和Node集成

场景 contextIsolation nodeIntegration 备注
A false false 不需要Preload。 Node.js在Main中可用,但在Renderer中不可用。
B false true 不需要Preload。 Node.js在Main和Renderer中均可用。
C true false 需要Preload。 Node.js在Main和Preload中可用,但在Renderer中不可用。默认推荐
D true true 需要Preload。 Node.js在Main、Preload和Renderer中均可用。

如何正确使用preload?

您必须使用Electron的进程间通信(IPC)以使Main和Renderer进程进行通信。

  1. Main进程中,使用:
  2. Preload进程中,公开用户定义的端点以供Renderer进程使用。
  3. Renderer进程中,使用公开的用户定义的端点来:
    • 向Main发送消息
    • 从Main接收消息
    • /**
       * Sending messages to Renderer
       * `window` is an object which is an instance of `BrowserWindow`
       * `data` can be a boolean, number, string, object, or array
       */
      window.webContents.send( 'custom-endpoint', data );
      
      /**
       * Receiving messages from Renderer
       */
      ipcMain.handle( 'custom-endpoint', async ( event, data ) => {
          console.log( data )
      } )
      

      预加载

      const { contextBridge, ipcRenderer } = require('electron')
      
      contextBridge.exposeInMainWorld( 'api', {
          send: ( channel, data ) => ipcRenderer.invoke( channel, data ),
          handle: ( channel, callable, event, data ) => ipcRenderer.on( channel, callable( event, data ) )
      } )
      

      渲染器

      /**
       * Sending messages to Main
       * `data` can be a boolean, number, string, object, or array
       */
      api.send( 'custom-endpoint', data )
      
      /**
       * Receiving messages from Main
       */
      api.handle( 'custom-endpoint', ( event, data ) => function( event, data ) {
          console.log( data )
      }, event);
      

      如何使用Promises?

      尽可能让承诺保持在相同的进程/环境中。您在主进程上的承诺应该留在主进程。您在渲染器进程上的承诺也应该留在渲染器进程。不要让承诺从主进程到预加载进程再到渲染器进程跳转。

      文件系统

      大部分业务逻辑仍然应该在主进程或渲染器进程中,但永远不应该在预加载进程中。这是因为预加载进程基本上只是一个媒介。预加载进程应该非常简洁。

      在OP的情况下fs 应该在主进程中实现。


我会接受针对 Electron 版本 v12 及以前的情况 A 和 B 特定的 contextIsolation 配置。自 v12 版本及更高版本,contextIsolation : true (默认值)。如果需要设置 contextIsolation 为 false,则可以通过 contextBridge 安全地实现;至于 nodeItegration 属性:如果存在 preload 脚本,则无论将其设置为什么布尔值,这些脚本都会忽略 nodeItegration - projektorius96
当我的应用程序启动时,我想使用preload.js来填充次要或非索引HTML页面中的表格,但是显然preload.js似乎无法访问它。您知道我该如何处理这个问题,或者这是否是适当的方法?换句话说,index.html有指向其他.html文件的链接;正是这些其他.html文件之一拥有一个table,我希望在从index.hmtl导航到该.html页面之前就填充它。 - oldboy
@oldboy 在你的情况下,你可以这样做:(1)在 Renderer 中向主进程发送消息。(2)在主进程中处理消息,然后向 Renderer 发送消息。(3)在 Renderer 中处理消息,填充表格。Preload 没有访问它的原因是因为 Preload 只运行一次,并且在 Renderer 执行之前。此时它无法处理实时/传入的更改。 - Abel Callejo
@AbelCallejo 啊,我已经很久没有使用Node.js / Electron编码了,所以我想避免那个 xD。与此同时,我已经决定将其制作成单页应用程序,但这仍然存在问题,因为通过preload填充表格的文档片段中的元素在DOM中直到相当长的延迟之后才被renderer检测到:(感谢您的信息<3实际上,还有一个问题:只有当单击“index.html”的链接并访问/查看“BrowserWindow”中的“secondary.html”时,“secondary.html”或“secondary.js”才会执行吗? - oldboy

12

在 Electron 中进展迅速,引起了一些混淆。最新的惯用示例(在我大费周折后尽可能确定)是:

main.js

app.whenReady().then(() => {`
    let mainWindow = new BrowserWindow({`
        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
            contextIsolation: true
        },
        width:640,
        height: 480,
        resizable: false
    })
 ... rest of code

预加载.js

const { contextBridge, ipcRenderer} = require('electron')

contextBridge.exposeInMainWorld(
    'electron',
    {
        sendMessage: () => ipcRenderer.send('countdown-start')
    }
)

renderer.js

document.getElementById('start').addEventListener('click', _ => {
    window.electron.sendMessage()
})

1
我不明白为什么在根配置和webPreferences中都放置nodeIntegration等...竟然拯救了我的生命...但是,你拯救了我的生命。谢谢! - Benoît Lahoz

2
我本周重新开始学习Electron,其中一个棘手的概念是如何限制代码执行。在当前这个时代,安全性非常重要,公司经常被勒索,数据也会被盗取,到处都有坏人。因此,你不希望任何人仅仅因为他们发现了你的应用程序中的漏洞就能够在你的电脑上执行代码。
因此,Electron通过封锁来促进良好行为。现在,你不能再从渲染进程中访问系统API,至少不能完全访问。只有那些通过preload文件向渲染进程公开的部分才能访问。
所以,将UI代码编写在浏览器端,并在preload.js文件中暴露函数。使用ContextBridge将你的渲染端代码连接到主进程。
使用context bridge的exposeInMainWorld函数。
然后,在你的渲染文件中,你可以直接引用该函数。虽然这种方法并不算很干净,但它确实可行。

1

我看到你得到了一个有点离题的答案,所以...

是的,你需要将代码分成两部分:

  • 事件处理和数据显示(render.js
  • 数据准备/处理:(preload.js

Zac给出了一个超级安全的示例:通过发送消息。但electron接受你的解决方案

// preload.js

const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('nodeCrypto', require('./api/nodeCrypto'))
)


// api/nodeCrypto.js

const crypto = require('crypto')
const nodeCrypto = {
  sha256sum (data) {
    const hash = crypto.createHash('sha256')
    hash.update(data)
    return hash.digest('hex')
  }
}
module.exports = nodeCrypto 


请注意,这两种方法都请求返回数据或执行操作。直接托管“本地”Node库是错误的。这里有一个例子,展示了“无害”的共享记录器。只需使用代理对象公开选定的方法即可。
在同一篇文章中,有一个使用通信ipc的例子,并不能使我们免于思考...因此,请记得过滤您的输入。
最后,我将引用官方文档

仅启用contextIsolation并使用contextBridge并不意味着您所做的一切都是安全的。例如,此代码是不安全的

// ❌ Bad code
contextBridge.exposeInMainWorld('myAPI', {
  send: ipcRenderer.send
})

// ✅ Good code
contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接