ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

共享浏览器与克隆Session:登录态快照迁移实战

共享浏览器与克隆Session:登录态快照迁移实战

简介:围绕“共享浏览器”与“克隆Session”构建的实战型工程包,面向具备Web开发、HTTP协议与浏览器扩展基础的开发者,也可供远程协作、多设备登录状态延续等场景做技术研究。资源围绕会话捕获与解析、Session ID加密传输、请求头注入、会话模拟及实时同步等关键环节组织,结合CefSharp/CEF相关工程文件,便于理解跨设备复用登录态的完整链路与安全边界。压缩包共129个文件、约77.46MB,以58个pak资源包、20个dll动态库、10个pdb调试符号及xml、bin、exe、ini等配置与可执行文件为主,兼顾运行依赖、资源加载与调试排查。已有362人浏览学习,适合需要对照工程结构梳理Session克隆实现思路、研究Cookie管理与加密通信细节的读者参考,对团队协作与跨地域工作场景的会话共享机制也有参考意义。

1. 异地接手一个已登录的浏览器:共享浏览器省掉的是那串验证码

做后台核验、运营巡检的同学大概都遇到过一个场景:账号登录在办公室那台常开的机器上,人已经到了另一个城市,临时要看一眼后台的订单详情,结果登录要过短信、要过人机校验,还不一定过得了。共享浏览器就是为这种场景准备的:浏览器实际跑在一台常开的机器上,你用网页连过去操作它;而「克隆 Session」是它的另一半——把某个会话的 cookies、localStorage、IndexedDB 原样搬到另一台机器的浏览器里,让对方打开就是已登录状态。这两个能力合起来,才叫「克隆 Session 共享浏览器」。它不适合做高帧率的视频类操作,但对付中后台系统、多标签页来回切、需要保留登录态的巡检类工作,比远程桌面省带宽,也比单纯导出一份 cookie 文件靠谱得多。

2. 架构拆解:控制面、浏览器池、快照通道三段式

整个项目我按「谁管会话、谁跑浏览器、谁搬状态」拆成三段,中间用一条 WebSocket 连起来。控制面只管签发、鉴权和租约,不碰浏览器进程;跑浏览器的那一侧叫执行器,一台机器上可以起很多个实例;快照通道是独立的一条链路,可以走对象存储,也可以点对点直推。这样拆的好处是:你哪天想把快照换成自己的加密方案或者自己的中转服务,只动第三个模块,前两个不用碰。

2.1 三个进程角色与目录结构

目录我按 monorepo 组织,apps放可部署的东西,packages放可以单独发版的库。快照那部分一定要独立成包,因为它是最容易被改的一块,混在业务里以后没法复用。

shared-browser/ ├── apps/ │ ├── control/ # 控制面:HTTP 签发会话 + WebSocket 网关 + 租约 │ ├── web/ # 前端:canvas 渲染画面、采集键鼠事件 │ └── runner/ # 执行器:拉起内核实例、桥接调试协议、跑快照 ├── packages/ │ ├── snapshot/ # 快照抓取与回灌,本篇重头戏 │ ├── protocol/ # 二进制帧 + JSON 控制帧的编解码 │ └── crypto/ # AES-256-GCM 打包与校验 ├── scripts/ │ └── e2e-clone.js # 端到端验证:A 机抓、B 机灌、探针校验 └── docker/ └── runner.Dockerfile # 执行器镜像,内置内核与字体

control里最关键的是会话表:一个 sessionId 对应哪台执行器、哪个调试端口、哪个 user-data-dir、当前租约归谁。web只做两件事——把二进制帧画到 canvas 上、把鼠标键盘事件按比例换算后发回去,它不持有任何登录凭据。runner是唯一能碰到浏览器进程的地方,抓快照、灌快照都在这里发生。snapshot包对外只暴露四个函数:dump、seal、open、apply,剩下全是内部实现,方便你后面替换存储和加密。

2.2 一次「克隆到异地」的完整时序

流程我按消息走一遍,控制帧用 JSON,画面帧走二进制,两者共用一条连接,靠第一字节的魔数区分。

{ "type": "snapshot.request", "sessionId": "s-7f3a91", "targets": ["cookies", "localStorage", "indexedDB"], "includeOrigins": ["https://console.internal.example"], "ttl": 600, "receiver": "runner-b" }

这条消息发出去之后,执行器 A 侧按下面的顺序干活:

  1. 先做「静默」处理:等Page.lifecycleEvent到networkIdle,再用Page.setDocumentContent之外的任何导航都拦掉,保证抓取期间页面不再写状态。
  2. 遍历所有标签页 target,逐个抓 cookies、Web Storage、IndexedDB,合并成一份带 origin 索引的快照对象。
  3. 计算 sha256,用一次性密钥 AES-256-GCM 加密,产出{v, digest, iv, tag, payload}五段式信封。
  4. 通过控制面把信封交给执行器 B,或者写进对象存储再发一个短链过去。
  5. 执行器 B 起一个全新实例、全新 user-data-dir,先做指纹对齐,再按 cookies → localStorage → IndexedDB 的顺序回灌,最后跑探针校验。
  6. 校验通过,控制面把租约从 A 转到 B,A 侧实例按策略销毁或降级为只读。

ttl这个字段别省。快照信封里装的等同于登录凭据,我一般给 600 秒,过期直接作废,接收端拿到过期的包必须拒绝,不要「先试试能不能用」。

2.3 技术选型:为什么不用远程桌面

很多人第一反应是装个远程桌面软件。能跑,但在这种场景下不合适:远程桌面传的是像素,一个 1080p 桌面在带宽紧张的时候会糊成一片;更要命的是它拿不到 DOM,你没法在服务端判断「这个页面到底登录上没有」,也没法做多会话隔离——一个桌面只能一个人用。

维度远程桌面调试协议截帧 + 输入注入纯快照迁移
带宽占用高,跟分辨率绑定中,JPEG 帧可压到 60 质量极低,一次性传完
能否读 DOM不能能,可直接跑探针不能,但能读快照
多会话隔离差,一桌面一用户好,一实例一目录好,一包一实例
实现复杂度低中中高

实际做法是把中间那列和右边那列合起来:日常操作走截帧通道,需要搬状态的时候走快照通道。代价是画面有延迟,滚动列表会有点拖影,所以前端要自己做一层帧丢弃——队列里堆了三帧以上就丢掉旧的,只画最新的,不然延迟会越积越大。

3. 实例池与实时画面:一个会话一个 user-data-dir

这一章讲执行器怎么把浏览器拉起来、怎么保证两个人不会抢同一个会话、以及画面是怎么从服务端流到浏览器的。三件事看着不相关,其实是同一条链路上的三个卡点:拉不起来就没有画面,抢同一个会话状态就会互相覆盖,画面不通就没法操作。

3.1 启动参数:把指纹、UA、时区一起定死

快照能不能灌成功,一半取决于启动参数。因为 cookies 里的__Host-前缀、Secure、SameSite都跟 origin 和协议绑定,如果目标实例的视口、UA、时区跟源端差太远,某些风控严格的系统会直接判为异地异常登录,你灌进去的 cookie 下一秒就被服务端作废。

// apps/runner/src/launch.js —— 每个会话独占一个 user-data-dir const { spawn } = require('node:child_process'); const path = require('node:path'); function launchSession({ sessionId, port, profileRoot, fingerprint }) { const userDataDir = path.join(profileRoot, sessionId); const args = [ '--headless=new', // 需要人眼看画面时改成 false,性能更好 `--remote-debugging-port=${port}`, // 调试端口,执行器通过它接 CDP `--remote-debugging-address=127.0.0.1`,// 只监听本机,对外一律走控制面 `--user-data-dir=${userDataDir}`, // 状态落盘目录,快照的物理基础 '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', // 后台标签页的定时器照常跑 '--disable-renderer-backgrounding', `--window-size=${fingerprint.width},${fingerprint.height}`, `--lang=${fingerprint.acceptLanguage.split(',')[0]}`, ]; const child = spawn(process.env.BROWSER_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.on('data', (buf) => { // 启动失败最常见的两行:端口被占、user-data-dir 权限不足 if (/DevTools listening on/.test(buf)) return; process.stderr.write(`[${sessionId}] ${buf}`); }); return { sessionId, port, userDataDir, child }; }

参数里--user-data-dir是整套方案的根。每个会话一个目录,好处有两个:一是会话之间不会互相影响 cookie,二是快照抓取时你知道该读哪个目录——真要兜底,直接把这个目录打包也能搬走大半状态。--disable-background-timer-throttling容易被忽略,但不加的话,用户切到别的标签页,后台页面的轮询就停了,抓快照时拿到的是几十分钟前的旧状态。

命令行参数只能定一部分指纹,时区、语言、UA 这些交给调试协议更可靠:

// 通过协议覆盖,比命令行参数生效更准 async function applyFingerprint(cdp, fp) { await cdp.Emulation.setTimezoneOverride({ timezoneId: fp.timezoneId }); await cdp.Emulation.setUserAgentOverride({ userAgent: fp.ua, acceptLanguage: fp.acceptLanguage, // 影响 navigator.language platform: fp.platform, // 影响 navigator.platform }); await cdp.Emulation.setDeviceMetricsOverride({ width: fp.width, height: fp.height, deviceScaleFactor: 1, mobile: false, }); // navigator.language 改了,请求头不会自动跟着变,得单独补一次 await cdp.Network.setExtraHTTPHeaders({ headers: { 'Accept-Language': fp.acceptLanguage }, }); }

这段我踩过坑:只调setUserAgentOverride的acceptLanguage,页面上navigator.language确实变了,但服务端收到的Accept-Language头还是默认值,两边对不上,有些系统会直接返回一个降级页面。补上setExtraHTTPHeaders之后才一致。

3.2 租约、心跳与实例回收

共享浏览器最怕的不是性能,是两个人在同一个实例上操作,你点了提交,对方同时点了返回,状态就乱了。所以我给每个会话加了租约,写操作必须持租约。

// apps/control/src/lease.js const leases = new Map(); // sessionId -> { owner, expireAt, version } function acquire(sessionId, owner, ttl = 60_000) { const now = Date.now(); const cur = leases.get(sessionId); if (cur && cur.expireAt > now && cur.owner !== owner) { const err = new Error('SESSION_BUSY'); err.holder = cur.owner; err.retryAfter = cur.expireAt - now; throw err; } const version = (cur?.version || 0) + 1; leases.set(sessionId, { owner, expireAt: now + ttl, version }); return { version, ttl }; } function renew(sessionId, owner, ttl = 60_000) { const cur = leases.get(sessionId); if (!cur || cur.owner !== owner) throw new Error('LEASE_LOST'); cur.expireAt = Date.now() + ttl; // 心跳只续期,不改 version return cur.version; } function release(sessionId, owner) { const cur = leases.get(sessionId); if (cur && cur.owner === owner) leases.delete(sessionId); }

version是我加的一个小保险:客户端每次发操作指令都带上它拿到的 version,控制面比对一次,不一致就拒绝。这样即使租约刚好在两次心跳之间过期被抢走,旧客户端也发不出命令。ttl给 60 秒,前端每 20 秒续一次,三次续不上就当断线处理。

回收走「先关页面、再发信号、最后删目录」三步,中间留两秒缓冲。删目录之前一定要先检查有没有没取走的快照文件,我图省事写过一个rm -rf一把梭,把一份刚抓完还没传出去的信封删了,结果对方等了半天没收到,白折腾一上午。

3.3 画面通道:截帧、ack 与输入坐标换算

画面这块不用自己写编码器,调试协议自带截帧能力,质量参数压到 60 左右,一帧大约 30~60 KB,中后台页面完全够用。

// 开始截帧。everyNthFrame 控制抽帧,动态页面可以给 2 await cdp.Page.startScreencast({ format: 'jpeg', quality: 60, maxWidth: 1280, maxHeight: 800, everyNthFrame: 1, }); cdp.Page.screencastFrame(async ({ data, sessionId, metadata }) => { // 关键:不回 ack,服务端只推一帧就停,这是最常见的「画面卡死」原因 await cdp.Page.screencastFrameAck({ sessionId }); broadcastBinary({ type: 'frame', w: metadata.deviceWidth, h: metadata.deviceHeight, data }); });

前端把帧画到 canvas 上,用户点击时把 canvas 坐标换回页面坐标,再通过Input.dispatchMouseEvent注入回去。换算必须用帧里带回来的deviceWidth/deviceHeight,不能用 canvas 的 CSS 尺寸,否则页面缩放一改,点击位置就整体偏移。

function toPageCoord(evt, canvas, frameMeta) { const rect = canvas.getBoundingClientRect(); return { x: Math.round((evt.clientX - rect.left) * (frameMeta.w / rect.width)), y: Math.round((evt.clientY - rect.top) * (frameMeta.h / rect.height)), }; } async function click(cdp, point) { await cdp.Input.dispatchMouseEvent({ type: 'mouseMoved', x: point.x, y: point.y }); await cdp.Input.dispatchMouseEvent({ type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 }); await cdp.Input.dispatchMouseEvent({ type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 }); }

键盘事件记得同时处理keyDown、char、keyUp三个,只发keyDown的话输入框里一个字符都出不来,中文输入法还得额外挂Input.imeSetComposition。这块调试起来很枯燥,但改一次就一劳永逸。

4. Session 快照:cookies、localStorage、IndexedDB 的抓取与回灌

这是整个项目最有价值也最容易出问题的模块。绝大多数「克隆失败」都不是加密或传输的问题,而是状态没抓全、或者抓全了但注入顺序错了。我把它拆成抓取、打包、回灌、校验四步,每一步都能单独写测试。

4.1 抓取:分四层把状态捞干净

登录态散落在至少四个地方:HTTP cookies、Web Storage、IndexedDB、Cache Storage。前三个必须抓,第四个默认跳过——里面通常是几 MB 到几百 MB 的静态资源缓存,搬过去收益极低,体积却会翻十倍。

// packages/snapshot/src/dump.js async function dumpSession(cdp, { origins, includeIndexedDB = true }) { const snap = { version: 1, createdAt: Date.now(), cookies: [], localStorage: {}, sessionStorage: {}, indexedDB: {} }; // 1) cookies:Storage.getCookies 能拿到带 partitionKey 的分区 cookie,Network 版拿不到 const { cookies } = await cdp.Storage.getCookies({}); snap.cookies = cookies; for (const origin of origins) { // 2) Web Storage:DOMStorage 按 origin 读,不用注入脚本 for (const [key, isLocalStorage] of [['localStorage', true], ['sessionStorage', false]]) { const { entries } = await cdp.DOMStorage.getDOMStorageItems({ storageId: { securityOrigin: origin, isLocalStorage }, }); snap[key][origin] = Object.fromEntries(entries); } // 3) IndexedDB:没有专门的协议命令,只能在页面上下文里枚举 if (includeIndexedDB) snap.indexedDB[origin] = await dumpIndexedDB(cdp, origin); } return snap; }

DOMStorage.getDOMStorageItems返回的是[key, value]数组,直接Object.fromEntries就行。注意sessionStorage只在当前标签页有效,多标签场景要按 target 分别抓,合并的时候加一层 targetId 前缀区分。

IndexedDB 只能靠页面里跑脚本:

async function dumpIndexedDB(cdp, origin) { const { result } = await cdp.Runtime.evaluate({ awaitPromise: true, // 表达式是 async 函数,必须等 Promise 落地 returnByValue: true, // 把结果序列化成 JSON 回传,大对象慎用 expression: `(async () => { const out = {}; const dbs = await indexedDB.databases(); // 拿到所有库名和版本号 for (const info of dbs) { const db = await new Promise((res, rej) => { const req = indexedDB.open(info.name, info.version); // 带版本号打开,避免触发升级 req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); out[info.name] = { version: info.version, stores: {} }; for (const storeName of db.objectStoreNames) { const store = db.transaction(storeName, 'readonly').objectStore(storeName); const records = await new Promise((res, rej) => { const r = store.getAll(); // 大表要改成游标分页 r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); out[info.name].stores[storeName] = records; } db.close(); } return out; })()`, }); return result.value || {}; }

两个参数必须给对:awaitPromise少了会拿到一个空对象,returnByValue少了拿到的是远程对象句柄,序列化时就报循环引用。getAll()在记录数超过几千条时会把渲染进程内存打高,稳妥做法是用openCursor分批读,每批 500 条,读一批await一次。

4.2 打包:清单、校验和与加密

快照信封里装的东西等价于一把钥匙,传输过程必须当成凭据对待。我的做法是:明文 JSON → sha256 摘要 → AES-256-GCM 加密 → 五段式信封。

// packages/crypto/src/seal.js const crypto = require('node:crypto'); function seal(snapshot, key /* 32 字节,一次性 */) { const body = Buffer.from(JSON.stringify(snapshot), 'utf8'); const digest = crypto.createHash('sha256').update(body).digest('hex'); const iv = crypto.randomBytes(12); // GCM 推荐 12 字节 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const enc = Buffer.concat([cipher.update(body), cipher.final()]); return { v: 1, digest, // 解包后重新算一次做完整性比对 iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), // 少了这个,解密时会直接抛错 payload: enc.toString('base64'), expiresAt: Date.now() + 600_000, }; }

密钥由控制面生成,走另一条通道下发给接收方,绝不跟信封放一起。接收端解包后第一件事是重算 sha256 跟digest比对,第二件事是检查expiresAt。这两步都过了,才允许进入回灌环节。别小看digest,我曾经遇到过传输层截断导致 JSON 尾部缺了几个字节、JSON.parse居然还能成功的情况,靠摘要比对才拦下来。

4.3 回灌:顺序错了就白干

回灌的顺序比抓取更讲究。正确顺序是:起实例 → 对齐指纹 → 写 cookies → 按 origin 注入 localStorage → 写 IndexedDB → 最后才导航到业务页。

// packages/snapshot/src/apply.js async function applySession(cdp, snapshot, fingerprint) { await applyFingerprint(cdp, fingerprint); // 必须先做,否则 cookie 的 Secure 判定会变 // 1) cookies 批量写。expires 单位是秒,-1 表示会话 cookie const cookies = snapshot.cookies.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path || '/', secure: c.secure ?? false, httpOnly: c.httpOnly ?? false, sameSite: c.sameSite || 'Lax', expires: typeof c.expires === 'number' && c.expires > 0 ? c.expires : -1, })); await cdp.Network.setCookies({ cookies }); // 2) Web Storage:用 addScriptToEvaluateOnNewDocument 抢在页面脚本之前写 for (const [origin, data] of Object.entries(snapshot.localStorage)) { const { identifier } = await cdp.Page.addScriptToEvaluateOnNewDocument({ source: `(() => { const data = ${JSON.stringify(data)}; for (const k of Object.keys(data)) { try { localStorage.setItem(k, data[k]); } catch (e) {} } })();`, }); await cdp.Page.navigate({ url: origin }); // 导航到该 origin,脚本随之执行 await cdp.Page.removeScriptToEvaluateOnNewDocument({ identifier }); // 用完立刻摘掉 } }

addScriptToEvaluateOnNewDocument这段是整个回灌里最关键的一手。很多单页应用在首屏脚本里就会读 localStorage 取 token,读不到就跳登录页;如果等页面加载完再localStorage.setItem,人家早跳走了。用这个接口把注入脚本挂在文档解析之前,页面第一次读就是你要的值。

removeScriptToEvaluateOnNewDocument那行千万别忘。忘了的话,后续每个新文档都会被旧值覆盖一遍,用户在页面上改了设置、刷新一下又变回去了,现象特别诡异,我第一次遇到时排查了整整一下午。

4.4 校验:怎么确认「真的登录上了」

抓完就返回成功是最常见的自欺欺人。校验要做三件事:cookie 数量比对、关键 cookie 的摘要比对、以及发一个带凭据的探针请求。

async function verify(cdp, { probeUrl, probeExpr }) { await cdp.Page.navigate({ url: probeUrl }); await new Promise((r) => setTimeout(r, 800)); // 等首屏脚本跑完,别用固定 3 秒 const { result } = await cdp.Runtime.evaluate({ awaitPromise: true, returnByValue: true, expression: probeExpr, }); return result.value === true; } // probeExpr 推荐写带凭据的 fetch,而不是读 document.cookie const probeExpr = `fetch('/api/me', { credentials: 'include' }) .then(r => r.ok).catch(() => false)`;

为什么不用document.cookie?因为 HttpOnly 的会话 cookie 在 JS 里根本看不到,你能读到的往往只是一些埋点用的标识,读到了也不代表登录成功。带credentials: 'include'的 fetch 走的是浏览器真实凭据链路,r.ok为真才说明服务端认这个身份。校验失败不要重试超过两次,直接把信封标成失效,让用户重新抓一份,比反复灌一个半残的状态强。

5. 避坑排查:五个「看着克隆成功其实没登录」的坑

下面这几条都是我在真实联调里撞过的,按「现象 → 原因 → 解决」记,遇到同类问题可以直接对着查。

5.1 状态层:抓到了但没生效

坑一:cookie 全写进去了,页面还是跳登录页。现象是Storage.getCookies返回的条数和目标端Network.getCookies对得上,但一导航就被踢回登录。原因是导入时做了「归一化」,把domain的前导点去掉了,还把__Host-前缀的 cookie 补了个domain字段。__Host-前缀的硬性要求是:必须Secure、必须Path=/、且不能带Domain,改任何一条浏览器都会静默丢弃。解决办法是原样搬运,domain、path、secure三个字段一个字节都别动;对__Host-开头的项做一次断言,不满足就报错而不是悄悄改。

坑二:localStorage 灌进去了,用户一刷新就没了。现象是刚灌完页面正常,F5 一下回到未登录。两个原因:一是注入时机放在了load之后,页面脚本已经把默认值写回去了;二是 origin 没对齐,源端是https://a.example.com:443,目标端写成了https://a.example.com,浏览器认为是两个不同的存储区。解决是把注入改到addScriptToEvaluateOnNewDocument,并且在抓取时就用new URL(origin).origin做一次规范化,端口是默认值就剥掉。

坑三:IndexedDB 里的令牌没搬。现象是 cookies 和 localStorage 都对齐了,接口还是 401。原因是不少单页应用把 refresh token 存在 IndexedDB 里,而抓取脚本只覆盖了前两层。更隐蔽的是版本号:回灌时如果不带version打开库,会触发一次upgradeneeded,页面自己的迁移逻辑可能把旧数据清掉。解决是抓取时记下version,回灌时用同一个版本号打开,写完记录再close(),全程不要多 open 一次。

5.2 通道层:画面卡住与并发覆盖

坑四:画面只推第一帧就不动了。现象是连上去能看到一帧静态图,之后再也不更新,鼠标点了也没反应。原因是screencastFrame回调里没有回screencastFrameAck,协议层的流控认为上一帧还没消费完,就不再推。这个坑的迷惑之处在于日志里一点报错都没有。解决是在回调第一行无条件 ack,用帧自带的sessionId,不是你自己的 sessionId。顺带提一句,ack 也要做异常捕获,回调里抛错会把整条流断掉。

坑五:两个人同时操作,抓出来的快照是半截状态。现象是快照能灌进去,但过一会儿就掉线,或者偶发地缺了某个标签页的数据。根因有两个叠加:没做租约,两个客户端同时发指令;抓取过程中页面还在写,你抓到的是写入中间态。解决分两层——写操作走租约,version不匹配直接拒;抓取前先做静默,等networkIdle之后再拦掉新导航,抓完再放开。静默窗口我一般给 3 秒上限,超时就中断抓取并报「会话繁忙」,宁可让用户重点一次,也别传一个可疑的状态出去。

6. 二次开发:把快照通道改成你自己的会话中继

真正要落到自己业务里,改得最多的是快照通道,因为它跟你的存储、你的权限体系、你的审计要求绑得最紧。我建议从三个地方下手,按改动成本从低到高排:

扩展点位置改动量典型需求
新增 exporterpackages/snapshot/src/registry.js半天多抓一层 Cache Storage、多抓一个自定义存储 key
换传输后端apps/runner/src/transport/*一天从点对点改成走对象存储、加断点续传
换密钥体系packages/crypto两三天接入你自己的密钥服务、做一次一密

exporter 这层是我特意留的注册表模式,加一层存储不用动主流程:

// packages/snapshot/src/registry.js const exporters = new Map(); function register(name, fn) { exporters.set(name, fn); } async function run(cdp, names) { const out = {}; for (const name of names) { const fn = exporters.get(name); if (!fn) throw new Error(`UNKNOWN_EXPORTER: ${name}`); out[name] = await fn(cdp); // 每个 exporter 自己负责异常兜底 } return out; } register('cookies', async (cdp) => (await cdp.Storage.getCookies({})).cookies); register('localStorage', async (cdp) => collectWebStorage(cdp, true)); register('indexedDB', dumpIndexedDB); // 复用 4.1 里的实现

注册表的好处是抓取清单可以按会话配置。比如某些会话你只想搬 cookies,那就把targets传成["cookies"],其余两层跳过,包体积能小一个数量级。加新 exporter 的时候注意返回值必须是可 JSON 序列化的,Map、Set、ArrayBuffer都要自己转换,否则JSON.stringify出来是空的,这种错误在日志里看不出来,只有在回灌端才发现少了一层。

验证方法我固定用两条:一条是单测,给每个 exporter 造一份假 CDP 客户端,断言输出结构;另一条是端到端,用 compose 起两台执行器,A 机用一个测试账号登录,dump 出信封交给 B 机,B 机 apply 之后跑verify探针。两台机器要故意配置不一样——不同的时区、不同的视口、不同的 UA——这样「异地」的差异才会在测试里暴露出来,而不是等上线后由用户帮你发现。

改造时我最容易翻车的地方不是代码,是忘了同步清理。新的 exporter 加进去之后,apply那边也得有对应的清理逻辑,否则上一次注入的脚本还挂着,下一次灌的时候两套值打架。所以我给每个 exporter 配一个cleanup,注册表里成对注册,少了直接启动报错。

从那以后我每次动快照模块,哪怕只改一行,都强制先跑一遍e2e-clone.js里的探针校验,确认 A 机登录、B 机能读到/api/me,才敢提交。这个习惯帮我挡掉了至少三次「本地看着好好的、一到异地就掉登录」的回归。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表