# preload、上下文隔离与受限能力桥

## 为什么 preload 里能 require，页面里却不能

你可能已经遇到一个看似矛盾的现象：main.cjs 可以加载文件系统模块，preload.cjs 也写 require，却加载不了同样的模块；renderer.js 则连 require 都不存在。这不是安装坏了，而是三处代码处于不同权限环境。理解差别之后，才能决定某段代码应该放在哪里，而不是通过关闭安全选项让报错暂时消失。

本章前置是前面的窗口与进程关系，目标是准确解释 contextIsolation、sandbox 和 nodeIntegration 分别限制什么，并实现一个边界小、输入可验证的桥。本章桥只维护内存中的展示设置，不涉及磁盘、网络或 IPC；这样可以单独观察跨上下文复制与函数代理，不把下一章的进程通信混进来。

主进程拥有完整的 Node 环境；安全配置下的页面没有 Node 集成；沙箱 preload 获得一组受限的兼容接口，用来完成桥接。这组 require 是受限实现，不能因为名字与主进程一样，就推导它支持任意 Node 包或项目文件。运行环境决定能力，文件扩展名只影响代码解析方式的一部分。

## 三个选项解决三个不同层面的问题

contextIsolation 把 preload 与页面脚本放到不同的 JavaScript 世界。双方操作的是同一页面相关环境，但全局对象、内建对象与脚本变量不是随意共享的。隔离的价值，是让页面不能简单地修改 preload 所依赖的全局对象，再借它执行拥有更多权限的操作。它不是一个新进程，也不是让所有函数自动获得输入检查。

sandbox 限制 renderer 直接访问操作系统资源的能力。需要权限的工作通过明确通信交给更有权限的进程执行。nodeIntegration 决定页面是否获得 Node 集成；开启它会影响沙箱，因此不能把三个布尔值当成互不相关的独立功能开关。安全默认组合应保持页面没有 Node，preload 隔离，renderer 有沙箱。

当前默认值是 contextIsolation 为 true、sandbox 为 true、nodeIntegration 为 false。历史上前两项默认值曾改变，所以旧教程可能要求你显式开启，也可能直接关闭它们以使用老式写法。我们仍在示例中写清这三个值，是为了表达设计意图和便于审查，不是因为当前默认值不安全。不要把某个教程年代的默认值当成所有 Electron 版本的固定事实。

| 配置或边界 | 主要回答的问题 | 不会自动替你做的事 |
| --- | --- | --- |
| contextIsolation | 页面与 preload 是否隔离脚本世界 | 验证业务参数和用户授权 |
| sandbox | renderer 能否直接接触大部分系统资源 | 限制主进程自己暴露的危险能力 |
| nodeIntegration | 页面是否获得 Node 集成 | 让第三方脚本变可信 |
| contextBridge | 哪些值或函数跨隔离边界可见 | 把任意通用接口变成安全接口 |

将按钮暴露为 chooseNote() 与暴露 readFile(path) 有本质区别。前者可以在主进程中打开系统选择框，把权限限定为用户刚刚选中的文件；后者允许页面主动提出任意路径。即便两者都通过 contextBridge、都在沙箱内调用，后者仍然扩大了能力。安全配置提供底座，具体接口仍然由应用负责。

## 沙箱 preload 的 require 到底有哪些边界

当前官方文档列出沙箱 preload 可以访问 Electron 的一组 renderer 模块，以及 events、timers、url 等有限 Node 内建模块，也提供 Buffer、受限 process 和部分计时原语。它没有完整文件系统和子进程能力，也不支持把 preload 用普通 require('./helper.cjs') 随意拆成多个本地 CommonJS 文件。

这解释了一个常见迁移故障：开发者把 preload 里的参数检查提取到 helper.cjs，写法与 main 完全一样，却在窗口中得到加载失败。解决方向是让 preload 保持小而独立，或者在构建阶段把依赖打包成沙箱可执行的单文件。把 sandbox 改成 false 虽可能让旧结构工作，却改变了安全边界，不能作为默认教程答案。

.cjs 只明确 CommonJS 语法，不会把受限 require 升级成完整 Node。把文件改名为 .mjs 也不是通用解决办法，Electron 的 preload 模块支持与沙箱存在额外限制。本组故意使用小型 .cjs 文件，避免一开始引入 ESM 与打包器交互；以后引入构建时，应检查最终产物的运行环境，不只检查源文件能否通过编辑器类型检查。

## 实验一：离线验证模块许可规则

保存 require-policy.cjs，运行 node require-policy.cjs。这里实现的是一份课堂规则模型，不是 Electron 实际的模块加载器，也不是安全沙箱。我们把允许集合和拒绝行为写成显式断言，帮助你区分“函数叫 require”与“拥有完整 Node 模块系统”。

```js require-policy.cjs
'use strict';
const assert = require('node:assert/strict');
const allowed = new Set(['electron', 'events', 'timers', 'url']);
function sandboxRequirePolicy(name) {
  if (typeof name !== 'string') return { ok: false, code: 'BAD_NAME' };
  const normalized = name.startsWith('node:') ? name.slice(5) : name;
  return allowed.has(normalized)
    ? { ok: true, module: normalized }
    : { ok: false, code: 'MODULE_NOT_ALLOWED' };
}
assert.equal(sandboxRequirePolicy('electron').ok, true);
assert.equal(sandboxRequirePolicy('node:events').ok, true);
for (const name of ['node:fs', 'node:child_process', './helper.cjs', 'axios']) {
  assert.equal(sandboxRequirePolicy(name).ok, false);
}
console.log('通过：内建许可集合与本地/第三方模块拒绝规则');
```

模型只验证模块名称集合；真实 Electron 对 electron 导出的模块还有具体范围，不能把本模型的 ok 理解成允许 Electron 包里所有主进程 API。比如 BrowserWindow 属于主进程，不能在 preload 中创建。我们在实际桥中只解构 contextBridge，越小的依赖范围越容易解释和维护。

## 实验二：观察静态快照与函数代理

建立 isolation-lab 目录，放入五个完整文件。页面可以修改一条内存标题，并读取当前快照；它不能获取完整 process、require 或任意通信通道。标题的最大长度在桥内验证，因此即使绕过输入框直接调用函数，也不能绕过合同。

```json package.json
{
  "name": "electron-isolation-lab",
  "version": "1.0.0",
  "private": true,
  "main": "main.cjs",
  "scripts": { "start": "electron ." },
  "devDependencies": { "electron": "44.3.0" }
}
```

```js main.cjs
'use strict';
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
function createWindow() {
  const win = new BrowserWindow({
    width: 780, height: 520,
    webPreferences: {
      preload: path.join(__dirname, 'preload.cjs'),
      contextIsolation: true, sandbox: true, nodeIntegration: false
    }
  });
  win.webContents.on('will-navigate', event => event.preventDefault());
  win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
  void win.loadFile(path.join(__dirname, 'index.html')).catch(error => {
    console.error(error.message);
    app.quit();
  });
}
app.whenReady().then(() => {
  createWindow();
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
}).catch(error => { console.error(error); app.quit(); });
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
```

```js preload.cjs
'use strict';
const { contextBridge } = require('electron');
let title = '初始标题';
let revision = 0;

function snapshot() {
  // 每次构造新的普通数据对象，不返回内部可变容器。
  return { title, revision };
}
function setTitle(value) {
  if (typeof value !== 'string') {
    return { ok: false, error: { code: 'BAD_TYPE', message: '标题必须是字符串' } };
  }
  const normalized = value.trim();
  if (normalized.length < 1 || normalized.length > 40) {
    return { ok: false, error: { code: 'BAD_LENGTH', message: '标题长度需为 1 到 40 个 UTF-16 单元' } };
  }
  title = normalized;
  revision += 1;
  return { ok: true, value: snapshot() };
}
contextBridge.exposeInMainWorld('titleSettings', {
  initial: snapshot(), // 非函数值是公开时的快照，不会自动追踪 title
  environment: {
    sandboxed: process.sandboxed === true,
    isolated: process.contextIsolated === true
  },
  read: snapshot,
  setTitle
});
```

```html index.html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'; connect-src 'none'">
  <title>隔离与桥</title>
  <script src="./renderer.js" defer></script>
</head>
<body>
  <main>
    <h1>标题设置</h1>
    <form id="form">
      <label for="title">新标题</label>
      <input id="title" name="title" value="我的标题" maxlength="40">
      <button type="submit">提交给桥</button>
    </form>
    <p id="status" role="status"></p>
    <pre id="result"></pre>
  </main>
</body>
</html>
```

```js renderer.js
'use strict';
const api = window.titleSettings;
const result = document.querySelector('#result');
const status = document.querySelector('#status');
function render() {
  result.textContent = JSON.stringify({
    initial: api.initial,
    current: api.read(),
    environment: api.environment,
    rendererRequire: typeof require
  }, null, 2);
}
document.querySelector('#form').addEventListener('submit', event => {
  event.preventDefault();
  const reply = api.setTitle(document.querySelector('#title').value);
  status.textContent = reply.ok ? '修改成功' : reply.error.message;
  render();
});
render();
```

```sh
npm install
npm start
```

首次 npm install 下载固定 Electron 44.3.0 的桌面二进制；之后此应用运行无网络请求。预期初始与当前标题都为“初始标题”，提交后 current 更新且 revision 增加，initial 仍保持最初内容；environment 两项为 true，rendererRequire 为 undefined。输入空白时返回长度错误而不修改状态。这些为真实 GUI 的验收步骤，本轮只检查脚本语法和离线规则。

## 为什么修改 current 不会让 initial 跟着变

contextBridge 对非函数值采用复制并冻结的方式，对函数建立可调用的代理。公开 initial 时获得的是当时的数据快照，不是响应式引用。以后 title 改变，页面需要再调用 read 得到新结果。你在 Vue 中习惯的 reactive 依赖追踪，不会跨这个桥自动传播；桥定义的是外部接口，不是共享组件状态。

函数代理也不意味着页面拿到了函数的闭包变量。页面只能调用 setTitle，函数仍在其所属上下文执行，内部 title 没有被直接公开。这个机制让我们可以把允许的变化集中到少数方法里，并在方法入口检查类型和范围。公开一个可任意调用名称的 call(method, args) 则会失去这种可审查性。

示例的 setTitle 是同步操作，因为它只改变极小的本地内存状态。磁盘、系统对话框和网络应通过下一章的异步 IPC 方法完成。把耗时循环放到这个代理函数中仍然可能卡住 renderer，它不自动转移到主进程或后台线程。函数跨世界与工作跨进程是两件独立的事。

这里 revision 表示设置成功修改次数，不是全局唯一版本，也没有持久化和并发冲突处理。页面刷新后 preload 重新执行，状态回到初始值。将它用于真实应用设置时，应由持久化层拥有权威状态，桥只负责访问合同；不要误把 preload 闭包当成跨窗口共享数据库。

## 输入验证的位置与错误语义

输入框的 maxlength 改善交互，却不能成为唯一限制。页面脚本可以直接调用 setTitle，或者未来换成另一个组件，绕过输入框属性。桥中重复验证可以提早提供一致错误；若操作最终进入主进程，主进程还需要自己的验证，因为桥不是唯一可以假定永远正确的安全边界。

例子把字符串长度明确写为 UTF-16 单元，是因为 JavaScript length 对某些表情会计为两个或更多单元。若业务要求“用户看见的字符数”，需要采用相应分段规则，不能把 length 的结果含糊称为所有语言通用的字数。约束越具体，页面提示、校验和测试越容易保持一致。

错误使用普通对象和稳定 code，而不是让页面解析某段异常文字。BAD_TYPE 与 BAD_LENGTH 都不会修改状态，因此调用者可以安全保留当前展示。对于不可预期的程序错误，仍然应该记录诊断信息并修复，不应把所有错误都伪装成用户输错。业务失败与编程缺陷分开，才不会出现“所有按钮都提示重试，但真正原因无从定位”。

## 实验三与练习：做一个只有两项能力的设置合同

练习要求接收 {theme, fontSize}，主题只允许 light 或 dark，字号是十二到三十二之间的整数，拒绝额外字段。返回新的普通对象，不能保留调用者传入的可变引用。提示是先检查对象形状，再检查字段值，不要通过默认合并把未知字段带入内部状态。

<details><summary>参考答案：完整设置校验与回归</summary>

保存 settings-contract.cjs，运行 node settings-contract.cjs，无需依赖。

```js settings-contract.cjs
'use strict';
const assert = require('node:assert/strict');
function validateSettings(input) {
  const fail = code => ({ ok: false, error: { code } });
  if (!input || typeof input !== 'object' || Array.isArray(input)) return fail('BAD_OBJECT');
  const keys = Object.keys(input);
  if (keys.length !== 2 || !keys.includes('theme') || !keys.includes('fontSize')) {
    return fail('BAD_FIELDS');
  }
  if (!['light', 'dark'].includes(input.theme)) return fail('BAD_THEME');
  if (!Number.isInteger(input.fontSize) || input.fontSize < 12 || input.fontSize > 32) {
    return fail('BAD_SIZE');
  }
  return { ok: true, value: { theme: input.theme, fontSize: input.fontSize } };
}
const source = { theme: 'dark', fontSize: 18 };
const reply = validateSettings(source);
source.fontSize = 30;
assert.equal(reply.value.fontSize, 18);
assert.equal(validateSettings({ theme: 'dark', fontSize: 18, path: '/tmp' }).ok, false);
assert.equal(validateSettings({ theme: 'dark', fontSize: 18.5 }).ok, false);
assert.equal(validateSettings(null).ok, false);
assert.equal(validateSettings({ theme: 'system', fontSize: 18 }).ok, false);
console.log('通过：字段、枚举、整数范围和复制边界');
```

</details>

这个合同故意没有 path、channel 或任意执行参数。拒绝额外字段有助于发现页面与主进程版本不一致，也防止将来有人无意把未经检查的字段透传到更深层。真实升级若需要兼容旧字段，应建立明确版本或转换层，而不是永久接受任何形状后祈祷底层忽略。

## 如何判断桥是否过宽

看每个方法能做的最大事情，而不是按钮目前只怎样调用它。如果桥暴露 send(channel, payload)，页面就能选择任何通道；如果暴露 execute(script)，输入就是执行能力；如果暴露 read(path)，任意路径就变成页面可以提出的请求。方法名称写得友好并不会改变参数的权限范围。

同样不要把 ipcRenderer 对象整体跨桥传递。当前 Electron 还明确限制它通过 contextBridge 传输；即便旧版本或其他包装方式能做到，通用消息能力也使每个内部通道成为页面可触达面。正确方向是一项业务动作对应一个受限方法，在 preload 固定通道，在主进程验证发送者与参数。

若需要事件订阅，回调也属于能力边界。Electron 回调中的 event 对象包含与底层通信相关的对象，不能原样转交页面。应只摘出允许的数据，并返回精确的取消订阅函数。下一章会给出完整实现；本章先牢记，函数代理不是参数净化器，你依然决定哪些内容跨界。

## 运行时证据与版本理解

课程的 Windows 项目在实际运行中观察到 Electron 44.3.0 内嵌 Node 24.20.0 与 Chromium 152.0.7977.78，而终端 Node 是 22.22.0。这些值说明开发工具和桌面宿主是两套运行时，不代表任意机器都会安装同样的独立 Node。主进程读取的是应用所带版本，终端命令读取的是开发机版本，两处日志不同属于正常现象。

这项版本观察来自课程项目的运行检查，不等价于本章每个桥行为都已经在 GUI 自动化中验收。离线模型只能证明校验函数的行为，语法检查只能证明代码可被解析，真正的 Electron 宿主还要检查 preload 是否加载、隔离世界如何传值及窗口配置是否生效。明确证据层级，才不会把模拟测试包装成运行事实。

## 隔离世界为何仍然能操作同一份页面

“不同的全局对象”容易让人误以为 preload 与页面完全看不到同一棵 DOM。实际上，隔离主要针对 JavaScript 执行环境中的对象与变量，两边仍可以与所属页面的 DOM 交互。也正因为如此，不应该在 preload 中大量接管界面更新：这样会把权限适配和页面状态耦合，后面换成 Vue 时容易出现两套代码同时修改同一节点。

更清楚的分工是 renderer 拥有 UI，preload 只提供小型能力合同。页面发出请求，拿到普通结果，再由自己的状态管理更新展示。这样即使以后从原生 HTML 换成 Vue，桥接口仍然可以保持稳定；你只需要换掉页面层，而不必把窗口与系统能力代码跟着组件结构重写。

如果页面故意给 window 上设置一个与 preload 内部变量同名的属性，也不应因此改变 preload 的闭包状态。但这不意味着可以在桥里写一个“返回任意变量”的调试方法；一旦你主动把数据返回，隔离就按你的合同允许它通过。安全边界不是猜测开发者意图的过滤器，它只执行你声明的通道和运行约束。

## 普通对象并不等于随便什么都能传

桥支持的值有明确范围，并不是任何 JavaScript 对象都保持原样跨界。自定义原型、特殊对象和含有行为的实例都不适合当通用合同。面向 UI 的设置结果通常只需要字符串、数字、布尔值、数组和普通对象，这种数据传输对象足以表达大部分业务，同时减少对宿主对象身份的依赖。

例如，返回一个包含方法的“文档实例”会让页面误以为它与主进程或 preload 内部实例保持同一身份；返回 {id, title, revision} 则清楚表达当前可见的数据。若需要修改，页面再调用 renameDocument(id, title)。把数据与动作分开，也便于以后记录日志、校验版本和写离线测试。

这并不意味着要在桥两侧无限复制整份大文档。大数据传输也有成本：复制占用时间与内存，频繁全量更新可能拖慢界面。应根据实际需求返回页面当前需要的部分，或者建立有序、可取消的分块协议。基础阶段先使用小 DTO，等确实测到瓶颈再引入更复杂的传输机制，不要为一个标题设置提前建立二进制共享框架。

## 调试时先证明边界，再证明业务

当 window.titleSettings 不存在，先看 preload 是否加载成功，而不是直接检查标题逻辑。一个不被允许的 require 可能在 exposeInMainWorld 之前就抛错，导致整个桥没有建立；此时页面显示的 undefined 是后果，原始原因在 preload 加载日志。完整排查应沿着配置路径、模块加载、桥名称、页面访问名称依次检查。

当桥存在但返回 BAD_LENGTH，说明跨界调用已经成功，问题位于业务输入合同。再去调整沙箱配置不会帮助解决长度错误，反而改变了不相关的运行边界。能把“通道不可用”与“通道拒绝输入”区分开，是阅读错误对象的核心价值。

最后，把调试时临时添加的宿主对象暴露视为需要撤回的代码变更，而不是无害日志。console.log 一个版本字符串与公开完整 process 供页面随时调用，权限结果不同。本章通过明确列出的环境布尔值展示配置，既能观察学习目标，也避免把调试便利扩成长期能力。

## 掌握标准、自测与来源

掌握标准是看到 require 报错时先定位环境，能解释三个选项的不同作用，能写出仅公开两项具体动作的桥，并知道静态公开值不会自动同步。你应能描述错误输入如何被拒绝以及状态为什么保持不变，而不只是让正常按钮工作。

自测一：preload 改成 .cjs 就能加载 fs 吗？不能，沙箱环境仍限制模块范围。自测二：contextBridge 会把普通对象变成跨世界响应式对象吗？不会，普通值复制并冻结。自测三：页面调用桥函数时，这个函数就自动在主进程执行吗？不会，本章函数仍在 preload 所属的 renderer 环境。

官方资料包括 [进程沙箱](https://www.electronjs.org/docs/latest/tutorial/sandbox)、[上下文隔离](https://www.electronjs.org/docs/latest/tutorial/context-isolation)、[contextBridge](https://www.electronjs.org/docs/latest/api/context-bridge)、[WebPreferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences) 与 [process](https://www.electronjs.org/docs/latest/api/process)。按当前文档核对，固定版本为 44.3.0。离线规则与练习实际运行；Electron 多文件实验只做语法检查，未启动 GUI。
