dsh-spreadjs-editor 的代码量不大。它没有重写任何表格功能,全部工作集中在四件事上:注册入口、分流格式、写回文件、配置运行环境。
如果要把在线电子表格嵌进一个已经存在的 Web 系统,这四件事基本躲不掉。下面按源码顺序走一遍,重点说清每一处的取舍。

注册入口:两个注册,一个身份
右侧的文件树由 DSH 自己提供。插件要做的第一件事,是声明自己能打开哪些文件:
javascript
export const inject = ['documentPreviews', 'slots']export const SPREADSHEET_EXTENSIONS = ['xlsx', 'xlsm', 'csv', 'sjs', 'ssjson'] as constexport const SPREADSHEET_DOCUMENT_ID = '@grapecity-software/dsh-spreadjs-editor/spreadsheet'
inject 列的两个服务都是 Web 外壳的一部分,所以是硬依赖,等待它们出现没有意义。注册分两处,共用同一个 id:
lua
ctx.effect(() => ctx.documentPreviews.register({ id: SPREADSHEET_DOCUMENT_ID, extensions: SPREADSHEET_EXTENSIONS, title: () => 'SpreadJS', // 编辑器要的是整本工作簿,不是一个分页窗口:这是 DSH 自己的 // 图片和 PDF 实现用的同一种模式。 loading: 'bytes-complete', wrap: false,}), 'dsh-spreadjs-editor: document metadata')ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ name: 'sidebar.right.tab.document', key: SPREADSHEET_DOCUMENT_ID,}, SpreadsheetDocument)), 'dsh-spreadjs-editor: document body')
一处声明元数据,一处提供界面主体,中间没有第三条注册。
这里同样值得看没有做的事。DSH 的 text 标签类型已经认领了所有会话文件地址,并把它路由到最匹配的实现,所以插件不注册标签类型、不注册面板、不注册 store,也不碰布局。它能被调用,前提只是扩展名在清单里。
loading: 'bytes-complete' 这个取值决定插件拿到什么。默认的分页模式只给文件的一部分,套在表格编辑器上会得到一个看起来打开了、实际不完整的工作簿。要求整份字节,是这类实现的必要条件。
这里还有一处机制值得知道:同一批扩展名可能已经有内置实现。DSH 的注册表按档位排序,默认档(extension)优先于 builtin,其次比后缀长度,最后按注册顺序。插件停在默认档,所以在工作簿扩展名上排在 DSH 内置的表格预览之前;插件没有认领的 .xls、.tsv,继续由内置预览处理。两套实现并存,谁渲染由扩展名和档位决定,不需要插件去关掉别人。
早期版本把查看器注册在一个第三方侧边栏服务上,用的是「等服务出现」的软依赖写法。那个能力改由 DSH 自己提供之后,软依赖就没有存在必要了:依赖项从「可能缺席的第三方能力」变成了「一定在场的 DSH 注册表」。
读文件交给 DSH,写文件必须自己做
这是整条链路里最值得看的一处不对称。
读的部分,插件不做任何文件读取。DSH 把文件按 dsh-resource://file/session/<sessionId>/<path> 寻址并读完,然后把完整字节交给文档实现。插件只负责把这段地址拆成 sessionId 和 path:
lua
export function parseSessionFileAddress(address: string): SessionFileAddress | undefined { if (!address.startsWith('dsh-resource://file/')) return undefined // ... return { sessionId, path, name: path.split('/').pop() ?? path }}
它没有复用 DSH 的解析器,注释给了理由:那个解析器在一个内部模块路径后面,而这段语法只有四行,重写一遍比依赖一个私有入口更划算。
写的部分,DSH 帮不上忙。它的文件系统接缝能读字节,但只能改文本:writeText、editText,以及只接受字符串的原子写入。工作簿是字节,Agent 也写不出字节,所以这个能力只能落在文档自己的插件上。
一句话概括这个边界:读是 DSH 的职责,写是插件的职责。 它不是设计偏好,而是 DSH 当前接口形状的直接结果。这也意味着插件必须自己承担写路径上的全部安全判断,下一节会看到那些判断长什么样。
按扩展名分流
SpreadJS 能处理多种格式,但不同格式的导入路径并不一样,插件用一个函数集中分发:
lua
async function loadIntoWorkbook(spread: any, file: File, path: string): Promise<void> { const ext = extname(path) if (ext === '.xlsx' || ext === '.xlsm') { // ExcelIO 是 xlsx/xlsm 的成熟路径:先解析成工作簿 JSON,再 fromJSON spread.fromJSON(await excelJson(file)) return } if (ext === '.ssjson') { spread.fromJSON(JSON.parse(await file.text()) as object) return } await new Promise<void>((resolve, reject) => { const fail = (error: unknown) => { reject(error instanceof Error ? error : new Error(String(error))) } if (ext === '.sjs') { spread.open(file, resolve, fail) return } spread.import(file, resolve, fail, { fileType: workbookFileType(path), includeBindingSource: true, }) })}
| 扩展名 | 导入方式 | 说明 |
|---|---|---|
| .xlsx /.xlsm | ExcelIO 解析为工作簿 JSON,再 fromJSON | Excel 文件走 ExcelIO 这条成熟路径 |
| .ssjson | JSON.parse 后 fromJSON | SpreadJS 自有 JSON 格式 |
| .sjs | spread.open() | SpreadJS 自有压缩格式 |
| .csv | spread.import(),fileType 为 csv | 按文本表格导入 |
.xlsx 这里没有走 spread.import()。后者在 API 上看起来更统一,四种格式可以共用一条调用,但 Excel 格式的解析是独立于表格引擎的一块能力,ExcelIO 在这条路上跑得更久,遇到过的真实文件也更多。插件选的是已经验证过的那条路,代价是多一个依赖和一次 JSON 中转。
.xlsm 和 .xlsx 共用同一条路径,因为两者本来就是同一个容器格式。这解决的是「能作为工作簿导入」,至于宏能不能保留或执行,是另一件事。
接管 Designer 的「保存」
Designer 自带的「保存」会弹一个下载对话框。在这个面板里那是错的语义:打开的文件本来就在会话工作区里,保存应该写回它。副本的需求归 DSH 的文件下载管。
替换分两层,第二层是被实测逼出来的。
第一层,换掉 Save 命令。 Designer.CommandNames.Save 是字符串 "save",功能区按钮、文件菜单项和 Ctrl+S 都从同一个命令分发。包装同时装在两个位置:
css
command.execute = redirectingconfig.commandMap = { ...(config.commandMap ?? {}), [name]: { ...command, execute: redirecting } }
一处是 Designer.getCommand() 返回的对象,一处是 config.commandMap;Designer 在 _applyConfig 里按实例注册后者。两条路都装,就不必猜哪一条最终拿到分发权。
第二层,重写文件菜单。 只换命令是不够的。文件菜单每一行走的是 FileMenuHandler.processPropertyChanged,不是命令表,所以菜单里的「保存」照旧弹下载框。源码注释把这件事记录得很直白:Measured in a browser, it kept downloading.
菜单模板的行身份也不在直觉位置。导航列里每一项的身份是它的 value(New、OpenSJS、SaveSJS、Import、Export、Print、Info),不是 bindingPath。按 bindingPath 去匹配 CommandNames.Save,结果是什么都没匹配上。所以重写是:
- 导航列只留下
SaveSJS那一项,其余六项和用于分组的分隔线一起去掉; - 内容列只留下标记为
data-activeCategory_main=SaveSJS的面板; - 面板内部换成插件自己的一个按钮,形状照抄被替换的那个(
type: "Button"加bindingPath),让引擎照原样渲染和分发。
整个重写是 fail-soft 的:模板认不出来,就把实际形状记进 summary 并原样放过;连导航列都找不到时,补一行自己的,而不是让菜单干脆没有「保存」。注释解释了这份谨慎:这不是一份公开契约,猜错的代价大于不做。
最后一条不变量更关键:替换是有条件的。没有任何面板能写回时,redirecting 会把调用交还给 Designer 的原命令。一个没有工作区文件的 Designer,不会因此得到一个按不动的「保存」按钮。
一次写回要过六道关
写路径自己做,安全判断也就都得自己做。插件的写回接口在 /spreadjs/api/save,按顺序过这些检查:
- 请求来源可信。
Host头必须指向回环地址,或者是部署在trustedHosts里显式列出的地址。这条沿用了 DSH 的浏览器信任姿态。 - 参数齐全。
sessionId、path、body一个不能少。 - 工作区根可解析。 按 DSH 自己的解析顺序找:活跃会话头里的
cwd,其次是持久化的会话头,最后是部署的沙箱根。三处都拿不到就拒绝写入,没有兜底路径。 - 目标在工作区内。 用
ctx.fs.contains判断,..逃逸同样被挡住。 - 上传的字节和声明的哈希一致。 客户端先在浏览器里算 SHA-256 随请求带上,服务端收到字节后重算比对,不一致直接 400。
- 原文件没有被别人改过。 打开文件时插件记下内容哈希,写入前重新读取磁盘上的完整字节比对;不一致返回 409 并说明原因,而不是覆盖掉别人的改动。
超限也在这一层处理:maxSaveBytes 默认 64MB,超过直接拒绝,不做截断。
真正落盘的动作很小:
python
await writeFile(temporary, bytes, { flag: 'wx' })await rename(temporary, targetPath)
先在目标同目录排他创建一个临时文件(wx 保证不覆盖已有文件),再 rename 覆盖目标。任何时刻读这个文件的程序,看到的要么是旧版完整文件,要么是新版完整文件,不会读到半个工作簿。失败时临时文件会被清掉。
DSH 自己的原子写入只接受字符串,所以这里用同样的形状补了二进制版本。整段代码里只有最后这次字节替换是本地的,其余判断全部委托给 DSH 服务。注释里把这称为「平台长出字节写入能力时应该保持的形状」。
DSH 配置要在引擎构造之前就位
服务端这半边只做三件事:下发 DSH 配置、提供健康检查、接收写回。它不做文件读取。
容易踩的是时序。Designer 一旦构造完成,部分配置就没有机会再生效,所以客户端必须等配置到手之后再创建实例。代码里那个 ready 标志就是为这件事存在的:它卡住 Designer 的构造,直到配置接口返回。
响应头带了 Cache-Control: no-store。少了它,改完配置重启服务,浏览器里拿到的还是缓存的旧值,这类「改了却没生效」的问题排查起来很费时间。
主题跟随系统
Designer 的界面配色可以整体替换。插件监听系统的深浅色偏好,映射成一份设计令牌:
scss
const DARK_THEME: Record<string, string | undefined> = { colorForeground: '#e8eaed', colorBackground: '#1e2227', colorBackgroundHover: '#2a2f36', colorBrandBackground: '#3b6ef6', colorStroke: '#3a414a', borderRadiusM: '4px', borderRadiusL: '6px', // ...}function applyDesignerTheme(): void { const ns = designerNamespace() if (ns?.setTheme === undefined) return const dark = window.matchMedia('(prefers-color-scheme: dark)').matches === true try { ns.setTheme(dark ? DARK_THEME : null) } catch { // 主题属于外观,不能因为配色失败挡住编辑器。 }}
浅色模式传 null,表示恢复默认主题,所以不需要维护两套完整配色表。主题在 Designer 构造前应用一次,另有单独一个 effect 监听 matchMedia 的 change 事件,用户切换系统主题时编辑器跟着变,不用刷新页面。
这里的坑不在 API,而在换色的范围。setTheme() 只重新着色 Designer 自己的控件,工作表的单元格区域是另一套绘制路径。早期版本只做了前半段,结果是深色工具栏配一张雪白的表格。补齐工作表那一半,才算真的跟随系统。
那个空 catch 也不是偷懒。配色令牌可能因为引擎版本差异失效,正常做法是继续显示默认配色,而不是让整个编辑器白屏。
把正在编辑的工作簿交给别的插件
面板挂载后,会把当前工作簿交给同一页面里另一个插件,前提是它装了。这段适配不需要任何一方硬依赖对方:
less
useEffect(() => publishWorkbook({ id: PROVIDER_ID, getWorkbook: () => hostRef.current?.getWorkbook(), getActivePath: () => resourceAddress, save: async () => { await hostRef.current?.save() },}), [resourceAddress])
没有接收方时这是一次空操作,编辑器行为不变。有接收方时,对方拿到的是 Designer 正在渲染的那份对象,不是文件副本。所以它的改动立刻出现在屏幕上,也不会覆盖用户尚未保存的编辑。工作簿是编辑器主动交出去的,不存在「查找别人的工作表」这类入口。
几个容易忽略的地方
源码里还有几处处理,直接决定了用起来像不像一个成熟产品。
加载期间挂起重绘。 导入工作簿时用 spread.suspendPaint() 包住,导入完再 resumePaint()。少了这一步,打开一个多 Sheet 文件能看到中间态的闪烁和逐帧刷新。
丢弃过期的异步结果。 文件树点击很快,用户可能连着点两个文件。插件用自增的 loadSeqRef 标记每次加载,回调时比对序号,过期结果直接丢掉。没有这个机制,先发的慢请求后到,会把后点的文件覆盖掉。文件查看器里这是最典型的一类竞态。
提示只在一处出现,而且会消失。 加载和保存的结果不再挂在面板底部的常驻状态栏上,改成一个右下角浮层,用 idle / busy / error 三种色调区分语气。进行中和失败会一直留着,等下一句替换它;「已加载」「已保存」这类结论 4 秒后隐藏。错误信息直接取引擎返回的 errorMessage,不是统一显示「操作失败」,用户自助排查时能拿到有用信息。省掉的标题栏和状态栏,把纵向空间还给了表格。
打开工作簿标签栏。 Designer 构造后设了 tabStripVisible = true 和 newTabVisible = true,让用户直接看到并切换多个工作表。多 Sheet 是 Excel 文件的核心结构,默认藏起来会让人以为文件只有一个表。
这套做法能搬到哪些地方
把这段链路抽象出来,它处理的是一个通用问题:在一个已有系统里嵌在线电子表格,同时不接管 DSH 的文件与权限体系。有几条做法可以直接搬:
- 存在一个「DSH 读、插件写」的分工时,写路径上的每一道安全判断都要由插件补齐,不能假设 DSH 已经做过。
- 文件格式分流的依据是验证过的成熟路径,不是 API 的统一程度。多一条依赖换更低的解析风险,通常划算。
- 替换产品的内置命令前,先确认它真正的分发入口。命令表看起来是显然的答案,实际可能不是;这里就多花了一层文件菜单的重写。
- 依赖产品未公开的模板结构时,把失败做成「原样放过 + 记录形状」,而不是猜。
- DSH 配置在引擎构造前就位,避免「构造完成后再改已经无效」这类时序陷阱。
如果你的系统同样需要「打开用户已有的工作簿、允许编辑、再写回原位置」,这条链路的形状基本是一样的。格式分流那一段需要按实际文件类型调整,配置和主题的处理可以直接沿用。接入前建议先用真实文件走一遍验收清单,方法见《在线 Excel 怎么验收》。
小结
把 SpreadJS Designer 嵌进业务系统,难点不在调 API。入口注册、格式分流、文件写回、运行环境配置,这四件事各自都有踩空的地方,它们决定了用户最终看到的是一个能用的编辑器,还是一个打开就报错的页面。
写回这一段最能说明问题:DSH 能读字节、只能写文本,于是插件的价值就不再只是「把引擎放到正确的位置」,还包括在接口没有覆盖的那一段里,把安全判断一条一条补齐。
表格能力本身来自 SpreadJS,这些判断来自集成层。