Day 8 讲 preload 三层架构:隔离世界里的 IPC 桥,让 webview 安全操作 DOM 并调主进程能力。
开场:webview 能打开 TG 了,然后呢?
Day 6 我们把 Telegram Web 塞进中间栏,Day 7 给每个账号分了独立 partition。老王扫码登录成功,转头问:翻译按钮放哪?消息怎么同步?客户备注怎么挂上去?
这些问题有一个共同答案:得在 webview 里的第三方网页动手脚 。但 Telegram Web 是别人的页面,你没法改它的源码,也不能在页面里直接 require('electron')------Electron 12+ 默认开了 contextIsolation,页面世界和 Node 世界是两套隔离的公寓,钥匙不通用。
所以今天不讲具体平台 DOM 选择器,只讲 preload 是什么、为什么 IM 聚合离不开它、IPC 桥怎么搭。

四层架构:谁住在哪一层
AnchorChat 里和 IM 相关的进程/世界,可以画成四层:
text
主进程(Main)
├── 调 HTTP API、读写本地 DB、管理 session
└── IPC handle / on
宿主渲染进程(WorkspaceView / ChatHost)
├── Vue 三栏 UI、Pinia 状态
└── 监听 webview 的 ipc-message
webview preload(隔离世界)
├── 在 Telegram Web 加载前注入
├── 操作 DOM、挂 Vue 小组件、监听消息
└── invoke 主进程 / sendToHost 回宿主
页面主世界(Telegram Web 自身)
└── 平台 JS、Service Worker、内部 API(preload 读得到 DOM,但碰不到它的闭包变量)
preload 的定位很尴尬也很关键:它住在 webview 里,但不属于页面。像派进别人公司做驻场开发的同事------能改装修(DOM),不能动人家核心系统(页面 JS 闭包),有事还得打电话回总部(IPC)。
为什么必须 contextIsolation
主窗口和 webview 我们都建议:
typescript
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webviewTag: true,
preload: path.join(__dirname, 'preload.mjs'),
}
如果把 nodeIntegration: true 打开,页面里的任意脚本(包括 Telegram 自己加载的第三方 JS)都能碰 Node API------相当于把服务器 root 密码贴在会议室玻璃上。IM 聚合客户端嵌入的是不可控的第三方 Web 版,安全底线必须守住。
contextIsolation: true 之后,页面里的 window 和 preload 里的 window 不是同一个对象。preload 想给页面留接口,得用 contextBridge.exposeInMainWorld;想调主进程能力,走 ipcRenderer.invoke / send。
两种 preload,别搞混
AnchorChat 里其实有两套 preload,新手最容易在这里绕晕:
| 类型 | 挂在哪 | 干什么 |
|---|---|---|
| 主窗口 preload | BrowserWindow 的 webPreferences.preload |
给 Vue 宿主暴露 ipcRenderer、electronStore、窗口控制 |
| 平台 preload | <webview :preload="..."> |
注入 Telegram / WhatsApp Web,操作 DOM、挂翻译条 |
Day 6 解析的 host-script:resolve-preload-path 返回的是平台 preload 路径(public/preload/telegram.js)。主窗口的 preload.mjs 是另一条线,别混在一个文件里。
主窗口 preload 典型写法:
typescript
// electron/preload.mjs --- 宿主侧,简化示例
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args),
send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args),
on: (channel: string, listener: (...args: unknown[]) => void) => {
ipcRenderer.on(channel, (_event, ...args) => listener(...args))
},
})
平台 preload 不走 contextBridge 给页面暴露 API------它自己就是「在隔离世界里干活的脚本」,直接改 DOM、挂组件,必要时用 sendToHost 跟外面的 ChatHost 说话。
平台 preload 最小骨架
下一篇才会讲怎么 build 出第一个 telegram.js,今天先看结构长什么样:
javascript
// platform-scripts/telegram.js --- 简化示例,IIFE 构建后挂到 webview :preload
const { ipcRenderer } = require('electron/renderer')
let hostAppId = ''
// ChatHost 在 did-finish-load 后发来当前账号实例 ID
ipcRenderer.on('host:app-id', (_event, id) => {
hostAppId = id
})
// 通知宿主:用户在 webview 里点了聊天区域(用于收起右侧抽屉等)
function notifyHostClicked() {
ipcRenderer.sendToHost('host:webview-clicked')
}
// 新消息到了,让宿主播提示音 / 托盘闪动
function notifyNewMessage() {
ipcRenderer.sendToHost('host:new-message')
}
// DOM 就绪后启动监听------具体 MutationObserver 逻辑 Day 10 展开
function bootstrap() {
document.addEventListener('click', notifyHostClicked, true)
// observeMessages() ... Day 10
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap)
} else {
bootstrap()
}
宿主 ChatHost 收到 sendToHost 的消息:
typescript
// ChatHost.vue
webviewRef.value?.addEventListener('ipc-message', (event) => {
if (event.channel === 'host:webview-clicked') {
emit('chat-area-clicked')
}
if (event.channel === 'host:new-message') {
window.ipcRenderer.send('tray:notify')
playNotificationSound()
}
})
// 页面加载完成后,把 appId 发给 preload
webviewRef.value?.send('host:app-id', props.account.id)
注意数据流方向:
- preload → 宿主 :
sendToHost(webview 内部事件要外壳配合) - preload → 主进程 :
invoke/send(翻译 API、本地存储等) - 宿主 → preload :
webview.send(channel, payload)(下发 appId、采集指令等)
公共桥接模块:别把 IPC 散落一地
各平台脚本会共用「调翻译」「写输入框」「复制剪贴板」等能力。我们抽了一个公共模块(源码在独立 preload 仓库的 utils/app.js,连载里叫 hostBridge.js):
javascript
// hostBridge.js
import { ipcRenderer } from './electron-shim.js'
export async function requestTranslate(text, channel, targetLang, accountId) {
if (!text || !channel || !targetLang) {
return { success: false, message: '参数不完整' }
}
try {
return await ipcRenderer.invoke('host:translate', {
text,
channel,
targetLang,
accountId,
})
} catch (error) {
return { success: false, message: '翻译调用失败' }
}
}
export async function fillChatInput(element, text) {
element.focus()
// 用 ClipboardEvent 模拟粘贴,比直接改 innerText 更像用户输入
// ... Day 17 发消息翻译时细讲
}
平台脚本只 import 这些封装好的函数,不直接散落 ipcRenderer.invoke('xxx')------否则 Telegram 改一版、WhatsApp 改一版,IPC 通道名能长成一片森林。
主进程注册对应 handle:
typescript
// electron/main.ts --- 简化示例
ipcMain.handle('host:translate', async (_event, payload) => {
const { text, channel, targetLang, accountId } = payload
// 调后端翻译 API、走本地缓存 ... 后面展开
return { success: true, translatedText: '...' }
})
隔离世界的边界:preload 碰不到什么
有一点很多教程不讲清楚:preload 能自由操作 DOM,但不代表能调用页面内部的 JS 对象。
比如 Telegram Web 内部有 rootScope、managers 这类闭包变量,preload 里 window.rootScope 可能是 undefined------它们在页面主世界,不在隔离世界。如果业务确实需要读页面内部状态,常见两条路:
webview.executeJavaScript():由宿主在主世界执行一段代码,把结果通过 IPC 回传 preload(进阶场景,后面个别平台会用到)- 读 DOM / IndexedDB:不碰闭包,只解析页面上可见的数据(更稳,但平台改版要跟着改选择器)
Day 8 记住原则就行:preload 的首要武器是 DOM,不是破解页面 JS。
踩坑与思考
- 两套 preload 别混 :主窗口
preload.mjs给 Vue 用;<webview :preload>给 IM 平台用,构建产物也分仓库 nodeIntegration: false是底线:嵌入第三方 Web 版,不要为图省事打开 Node- IPC 通道集中管理 :用
host:前缀区分宿主通信,主进程 handle 统一注册,方便审计 sendToHostvsinvoke:前者找ChatHost爸爸,后者找主进程爷爷;别用invoke传点击事件,大材小用- 先骨架后平台 :公共
hostBridge.js+ 平台入口telegram.js空壳跑通,再加 MutationObserver和 Vue 气泡组件
明日预告
Day 9:写第一个平台脚本 ------从空文件到 Vite lib 模式 IIFE 构建,把 telegram.js 打进 public/preload/,复制到客户端,刷新 webview 验证注入成功。终于要写「能跑的」代码了