Files
apes-sidebar/docs/plans/2026-08-12-lazy-chunks-design.md
T
Atlas Zeng 5e416eccef feat(mermaid): Markdown 预览安全渲染 Mermaid 图表(strict 清洗 + 懒加载 chunk + 点击放大) (#164)
* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖)

tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName /
package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与
lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。

* feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗

- splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化;
- mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用
  bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗:
  删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部
  href/xlink:href,仅接受 SVG 根,解析失败返回空串;
- 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互,
  基于已清洗 SVG 克隆重写,不新增攻击面);
- 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。

* test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md

- mermaid-blocks.spec 10 例 fence 切块语义;
- mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/
  script/外来元素/畸形 XML/非 SVG 根);
- bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid
  (chunk-artifact 补 addEventListener stub);
- 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、
  点击弹窗 Esc 关闭、preview 模式编辑器隐藏。

* docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新

设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型
(strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。

* fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1)

XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML),
oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先
toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。

* fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1)

XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到
<sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG
script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按
localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写
script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器
命中 0,新逻辑命中 2)。

* fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2)

P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次
MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram
(React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子,
协调安全;块不再 mermaid 时还原原 children)。新增
tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。

P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且
长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复
带缩进开 fence 的 info 提取。blocks 单测 +3 例。
2026-08-18 01:22:28 +08:00

8.4 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 注册表:

  1. 构建期:每个 chunk 是独立 tsdown 浏览器 bundle(lib/client-<name>.js),脚本首行为:
    globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
    globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
    
    与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;codeSplitting: false 保持单文件。
  2. 运行期 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 文件
mermaid src/client/chunks/mermaid.tsx MermaidBlocks + mermaid + d3/dagre/cytoscape lib/client-mermaid.js(~7MB) 预览含 mermaid fence 的 md
核心 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 物化)。