Day 8:Preload 是什么?——在别人的网页里打黑工还要合规

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 BrowserWindowwebPreferences.preload 给 Vue 宿主暴露 ipcRendererelectronStore、窗口控制
平台 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、本地存储等)
  • 宿主 → preloadwebview.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 内部有 rootScopemanagers 这类闭包变量,preload 里 window.rootScope 可能是 undefined------它们在页面主世界,不在隔离世界。如果业务确实需要读页面内部状态,常见两条路:

  1. webview.executeJavaScript():由宿主在主世界执行一段代码,把结果通过 IPC 回传 preload(进阶场景,后面个别平台会用到)
  2. 读 DOM / IndexedDB:不碰闭包,只解析页面上可见的数据(更稳,但平台改版要跟着改选择器)

Day 8 记住原则就行:preload 的首要武器是 DOM,不是破解页面 JS

踩坑与思考

  • 两套 preload 别混 :主窗口 preload.mjs 给 Vue 用;<webview :preload> 给 IM 平台用,构建产物也分仓库
  • nodeIntegration: false 是底线:嵌入第三方 Web 版,不要为图省事打开 Node
  • IPC 通道集中管理 :用 host: 前缀区分宿主通信,主进程 handle 统一注册,方便审计
  • sendToHost vs invoke :前者找 ChatHost 爸爸,后者找主进程爷爷;别用 invoke 传点击事件,大材小用
  • 先骨架后平台 :公共 hostBridge.js + 平台入口 telegram.js 空壳跑通,再加 MutationObserver和 Vue 气泡组件

明日预告

Day 9:写第一个平台脚本 ------从空文件到 Vite lib 模式 IIFE 构建,把 telegram.js 打进 public/preload/,复制到客户端,刷新 webview 验证注入成功。终于要写「能跑的」代码了

相关推荐
柒和远方1 小时前
Worker Readiness:为什么 `/health` 不能证明后台任务能跑
前端·后端·架构
吃糖的小孩1 小时前
Web Owner Console 还没写前端,我先给它设计 read model
前端
析数塔1 小时前
2026前端框架深度解析:React Compiler、Angular Zoneless、Vue 3.5重写游戏规则
前端·vue.js·react.js
嘟嘟07171 小时前
Node.js spawn 实现迷你 Cursor 自动化终端|子进程完整解析
前端·后端·面试
程序员Agions1 小时前
ES2026,早该这样了
前端·javascript
ONE_SIX_MIX1 小时前
lightweight-charts-onesixth v2.8.1 → v3.1.1 更新总览
java·前端·python
恋猫de小郭1 小时前
Flutter material/cupertino 解耦最新进展,已经在做新样式了
android·前端·flutter
weedsfly1 小时前
纯函数与副作用——前端开发中最容易被忽视的质量保证
前端·javascript·面试
杖雍皓1 小时前
没有服务器如何构建现代 Web 应用:平台选型与组合策略
运维·服务器·前端·react.js·github·netlify·vercel