8.2 KiB
客户端懒加载分块(lazy chunks)设计
日期:2026-08-12 状态:已实现 范围:
dsh-better-sidebar插件,客户端 bundle 拆分
1. 背景与问题
改造前客户端是单文件 bundle(lib/client.js,约 24.6MB),页面启动时全部下载 + 解析 + 执行。实测构成(按 sourcemap 源字节):
| 依赖 | 体积 | 用途 |
|---|---|---|
@univerjs/* 全家桶 + echarts/zrender 等 |
~18MB | .xlsx 预览 |
@codemirror/* + @lezer/* |
~1.9MB | 文本编辑器高亮 |
xlsx(SheetJS) |
~0.6MB | .xlsx 解析 |
@aiden0z/pptx-renderer |
~0.5MB | .pptx 预览 |
xterm |
~0.3MB | 终端 |
docx-preview + jszip |
~0.2MB | .docx 预览 |
| 插件自身代码 | ~0.35MB | — |
虽然 office-view.tsx 用了动态 import(),但 codeSplitting: false 只保证延迟执行(Promise.resolve().then(() => (init_docx_preview(), ...))),下载与解析成本仍在启动时;且 xlsx-to-univer.ts 对 @univerjs/presets 的静态 value-import 让 Univer 核心实际在启动时执行。
目标:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
2. 机制
chunk 脚本不经过 window.__ModuleLoader__ / ClientModuleSystem.import() 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
- 构建期:每个 chunk 是独立 tsdown 浏览器 bundle(
lib/client-<name>.js),脚本首行为:与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;globalThis.__dshChunks__ = globalThis.__dshChunks__ || {}; globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };codeSplitting: false保持单文件。 - 运行期
loadChunk(name):- 注入
<script src="/sidebar/bundle/<name>.js">(经典同源脚本;官方/plugins/<id>/client.js路由的路径白名单无法服务任意文件名,故走插件自有路由); - 从
globalThis.__dshChunks__[name]读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类); - 用自定义 require 物化:externals(CLIENT_EXTERNALS 清单)经
__DSH_MODULES__.import(spec)的 seed 分支逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
- 注入
3. 分块清单
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|---|---|---|---|---|
xlsx |
src/client/chunks/xlsx.tsx |
XlsxView + Univer 全家桶 + xlsx + Univer CSS |
lib/client-xlsx.js(~20MB) | 打开 .xlsx |
docx |
src/client/chunks/docx.tsx |
DocxView + docx-preview + jszip |
lib/client-docx.js(~330KB) | 打开 .docx |
pptx |
src/client/chunks/pptx.tsx |
PptxView + pptx-renderer |
lib/client-pptx.js(~2.4MB) | 打开 .pptx |
terminal |
src/client/chunks/terminal.tsx |
TerminalView + xterm + addon-fit + xterm.css |
lib/client-terminal.js(~450KB) | 打开终端 tab |
editor |
src/client/chunks/editor.tsx |
TextEditor + @codemirror/* + lang.ts + cm-themes |
lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
| 核心 | src/client/index.tsx |
其余全部 | lib/client.js(~325KB) | 启动 |
关键拆分:office-view.tsx 拆为 docx-view.tsx + xlsx-view.tsx + office-shared.tsx(共享 LoadState/BinaryFallback)。若不拆,docx chunk 会经共享文件静态拖入 xlsx-to-univer → Univer 14.5MB。原路径 office-view.tsx 保留为 re-export 垫片,兼容 ./src/* 深导入。
4. 缓存契约(三层,各带失败路径)
| 层 | 机制 | 失败路径 |
|---|---|---|
| 内存 | loadChunk 每 chunk 一个 in-flight promise,并发去重,常驻到 resetChunks |
失败删除缓存项 → 下次调用重试 |
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
| HTTP | /sidebar/bundle 路由 cache-control: no-cache + ETag(内容哈希,按 mtime/size 记忆化),If-None-Match 命中返回 304 |
页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
HMR:apply() 每次激活调用 resetChunks()(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
5. 对外 API 兼容性(硬约束)
ctx.betterSidebar服务方法签名与行为零变化;懒加载只作用于内置 descriptor,外部插件注册的原样存储/渲染。component契约(props) => ReactNode保持:懒包装是纯渲染函数(函数体无 hooks,hooks 在内部LazyChunkView),兼容 Sidebar 的函数式调用与 EditorHost 的createElement渲染两种风格。- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 →
matchFileViewer语义、外部插件同扩展名高优先级覆盖、设置页清单不变。 - 公开类型面(
./client/service、./client/api)不变;无新增 peerDeps。 - 契约测试:
tests/lazy-chunk.spec.tsx钉住"component 可直接函数调用不抛错"。
6. 边界与失败模式
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(
css.editorError+css.terminalRetry),面板其余部分不受影响; - host 旧版本无
/sidebar/bundle路由(404) → 同样的错误 + 重试,升级 host 后重试即可; window.__DSH_MODULES__缺失(异常环境) → 明确报错,不触网;- chunk 注册 id 恒定
dsh-better-sidebar/<name>,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合); - 跨 chunk 复用:
t()/api.ts/SandboxStatusBar/clsx/sidebar.module.css等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合; - CSS:
sidebar.module.css的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
7. 实施偏差记录
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
- 原设想 chunk 注册 id 按渠道区分(
__DSH_PLUGIN_ID__define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。 resetChunks同时清空测试注册表(测试隔离需要)。lazy-chunk.tsx内部渲染用ComponentType<any>状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。- v2(运行时修复):最初实现经
window.__ModuleLoader__.load({id})注册 chunk factory、再__DSH_MODULES__.import(id)物化——真实环境报client-modules: cannot resolve "dsh-better-sidebar/terminal"(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(globalThis.__dshChunks__) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;resetChunks不再需要 invalidate。 - v3(运行时修复):terminal 懒包装把整个
TabComponentProps原样透传,但TerminalView的 props 是{ scope, tabId, store }而TabComponentProps只有tab对象、没有tabId→tabId为 undefined →isAgentTabId里undefined.startsWith崩溃(dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith'))。修复:descriptor 显式映射tabId={tab.id},懒包装的 props 泛型 = TerminalView 真实签名({ scope, tabId, store });回归测试钉住映射(lazy-chunk.spec.tsx)。教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集。 - v2 附加:bundled 产物经真实
ClientModuleSystem(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由tests/chunk-artifact.spec.ts长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。