dsh-spreadjs-editor 把原生 SpreadJS Designer 接进了 DSH 的右侧栏,让工作区里的工作簿能直接打开、编辑、保存回原文件。
在 DSH 的插件里接一套重型引擎,功能本身往往不是最难的部分。真正要回答的是四件事:引擎放不放进发布包、入口从哪来、写入由谁负责、界面该画多少。这四件事上每个选择都有代价,而且通常要等到装到用户机器上才显现。

下面把这四次取舍摊开,每次都说清判断和代价,也看看换一种选法会怎样。
一、引擎放进发布包,还是让用户安装时自己构建
先看一个数字:这个插件现在的发布包约 53MB,绝大部分是内置的 SpreadJS 与配套 Designer。
这里有一条硬限制,它决定了这一节的取舍:DSH 的插件通过 pnpm 安装,而 SpreadJS 走的是 npm 这条分发路径,插件安装的环节没法把引擎一起装进来。引擎因此只能在发布侧打进包里。代价随之固定下来:内置的 SpreadJS 什么时候升、升到哪一版,从依赖解析变成了仓库里的一件事,要由我们自己跟。
如果这条限制不存在,最优解不是自己打包:引擎跟着依赖走,版本升级就不用我们操心。所以这一节真正的取舍不在「打不打包」,而在打包之后,构建放在谁的机器上。
我们最早把构建放在使用者那边。0.1.0 的包只有 56KB,引擎留作依赖、构建产物不进 tarball,用户安装时用 postinstall 现场构建。包在 npm 页面上很好看,代价全在使用者那边:每个装插件的人都要跑一遍构建,还要多带一套构建工具链。构建失败的环境各有各的失败方式,而失败发生在别人的机器上,我们只能等对方把日志发过来。
0.1.2 换成了现在这一种:引擎和相关产物全部进包,dependencies 清空,装完即用,包体涨到约 48MB。
两个方向都能用,区别在代价落在谁身上。我们最后选的判断只有一条:包体是发布方的问题,构建失败是使用者的问题。前者我们能自己解决,后者只能等别人配合。
这条判断反过来也成立。内网仓库、离线交付这类环境对体积敏感,48MB 会直接变成使用方的负担,那种场景下安装时构建更合适。要避免的是第三种情况:分发环境不可控,又拿不到对方的构建日志。这时候一次构建失败就足以让安装卡住,而失败原因我们根本看不到。
二、DSH 没提供的位置,要不要自己造
DSH 的早期版本没有右侧栏。它能给插件的位置是零散的:侧边栏底部的一个动作位(sidebar.footer.action),加上覆盖整个界面的浮层(shell.overlay)。第一版就挂在这两个席位上:底部一个入口,点开在浮层里显示 SpreadJS 查看器。
这条路能跑通,但这两个位置不是为文档设计的。工作簿只能「弹出来看」,不能像文件那样被打开、切换、关闭;插件还得自己管查看器状态、自己去读文件字节,当时仓库里对应的就是 store.ts 和 fs-bridge.ts 两个模块。
后来 DSH 提供了更贴近文档的接口,插件重构成 view-first 的形状。中间有一段时间,插件同时维护两套适配器:一套对接新接口,一套对接第三方侧边栏。两套注册路径、两套测试,这是那段时期真实的成本。再往后砍掉一套,只留第三方侧边栏。
直到 0.2.0,DSH 自己长出了右侧栏和文档注册表,插件顺着迁过去,写法收敛成两个注册:
ts
// 登记这个实现认领哪些后缀ctx.documentPreviews.register({ id: SPREADSHEET_DOCUMENT_ID, extensions: ['xlsx', 'xlsm', 'csv', 'sjs', 'ssjson'], title: () => 'SpreadJS', loading: 'bytes-complete', wrap: false,})// 同一个 id 下提供正文ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ name: 'sidebar.right.tab.document', key: SPREADSHEET_DOCUMENT_ID,}, SpreadsheetDocument))
第三方依赖随之整个删掉。更省事的是文件读取也不用自己做了:字节由 DSH 的文档实现读好后传进来,插件里原来那个 /sidebar/file 路由连同它的桥接模块一起删掉了。客户端里省下来的东西同样能说明问题:DSH 的 text 标签类型已经认领了所有会话文件地址,插件不需要注册标签类型、面板、store 或任何布局。
所以这类位置怎么选,关键不在当下哪条省事,而在这个能力将来会不会由 DSH 提供。DSH 正在快速补齐的能力,自己造一遍大概率只在维护成本上留下记录。
三、DSH 能读字节,但写不了工作簿
先看 DSH 给了什么。文件相关的接口都在 ctx.fs 上:
| 要做什么 | 调什么 |
|---|---|
| 寻址 | ctx.fs.resolve(path, { cwd }) |
| 拿到可打开的绝对路径 | ctx.fs.processPath(target) |
| 判断是否在工作区内 | ctx.fs.contains(rootTarget, target) |
| 取元信息 | ctx.fs.stat(target) |
| 读字节 | ctx.fs.readBytes(target, signal, maxBytes) |
解析会话的工作区根也全是 DSH 的服务:ctx.sessions.get(sessionId).header.cwd 优先,会话不在线上时问 ctx.sessionPersistence.stat(id),最后回落到 ctx.sandboxPolicy.workspaceRoot。
没有给的是写字节。 ctx.fs 改文件只有 writeText 和 editText,连原子写入 writeFileAtomic 也只接受字符串。工作簿是二进制,DSH 写不了,Agent 也写不了。
于是插件要自己补的动作只剩一个:在同一目录下用随机后缀建临时文件(writeFile(..., { flag: 'wx' })),再 rename 覆盖目标。形状和 DSH 自己的原子写入一致,只是收的是字节。
保存一个工作簿要按顺序过七道判断:
- 请求的
Host头指向回环地址,或者是部署里显式列出的地址 - 参数齐全:
sessionId、path、body哈希 - 能解析出这个会话的工作区根
ctx.fs在场;服务缺席就拒绝,没有兜底路径fs.contains(rootTarget, target),目标不能越出工作区- 上传字节的 sha256 与请求声明的
body哈希一致 - 文件已存在时,
fs.stat加fs.readBytes读出当前内容,它的 sha256 必须等于打开时的base哈希
源码注释把这段的分工说得很直接:所有判断都已委托给 DSH 的服务,本地只剩最后一次字节替换。
这也是写回这一段仍然算「临时」的原因。等 DSH 提供二进制写入,它就能整体交还,插件回到只做格式适配和界面集成的位置。
顺序上这里有个教训:先读 DSH 的写入接口,再选引擎。等集成完才发现形状不对就晚了。只要 DSH 只提供文本写入,任何二进制格式都要自己实现,工作簿、图片、压缩包、PDF 都一样。写入一旦由插件负责,来源校验、路径包含、冲突检测、原子替换这些安全判断也一并转移过来,DSH 不会再兜底。
四、界面元素由谁来画
面板上的东西,哪些由插件自己画,哪些交给 DSH?两条路都能把功能做出来。插件完全可以自己画一条状态栏显示文件名和版本,DSH 的标签页同样能显示文件名。区别还是在代价。
插件画,样式、位置、行为都由自己控制。代价是这些都要自己维护,DSH 改一次外观就得跟一次;而且信息源不在插件手里,显示的未必和 DSH 的实际状态一致。
DSH 画,自动跟着 DSH 的外观和交互走,插件不用管。代价是位置和样式由 DSH 的接口决定,接口没提供的信息,我们想显示也没有地方放。
判断标准可以归结到一点:这份信息属于谁。文件名、文件身份、文件状态是 DSH 的事实,插件再画一遍等于复制一份会漂移的数据。0.2.0 因此删掉了面板顶部的文件名标题栏,文件名交回 DSH 的标签页。反过来,引擎的工具栏、菜单、主题在 DSH 那边根本不存在,只能由插件提供;插件自己的版本信息没有常驻的必要,搬进了「关于」弹窗,保存与冲突提示改成右下角一个会消失的浮层,处理完就不再占位置。
主题的坑跟 DSH 无关,它是引擎内部的问题。Designer 有两套绘制路径:工具栏和面板是 DOM 控件,工作表的单元格区域画在 canvas 上。setTheme() 只重新着色前者。我们早期只接了控件那一半,结果就是深色工具栏配一张雪白的表格,看起来像两个产品拼在一起。
引擎自带的外观系统要整块接。 判断接全了没有,不能看哪一层更显眼,要看引擎对外暴露出几条绘制路径。
小结
这四件事里,包体和界面是自己能算清的账,入口和写入取决于 DSH 给什么。真正容易吃亏的是后两件,因为它们需要我们在动手之前就去读接口、读 DSH 的实现。
这几轮改动始终停在集成层。SpreadJS 的电子表格功能没有被插件动过,插件补的一直是 DSH 里的安装、打开、保存、主题和配置体验。
如果只能留一条建议:先查 DSH 的接口形状,再选引擎。 功能可以慢慢补,接口形状不匹配,补的代价会成倍增加。