纯前端本地编辑Office深度解析:OnlyOffice+x2t-WASM全链路
一句话结论:纯前端本地编辑 = x2t-WASM 做格式转换 + OnlyOffice web-apps 做渲染编辑,文档全程不出浏览器内存。本文以掘金热文 纯前端集成OnlyOffice的终极方案 为引子,把原文 2 分钟没讲透的
params.xml、output.bin、se-office 劫持全部补齐,并逐文件拆解三仓库源码。
首段先点题:纯前端本地编辑 Office 之所以难,不在 UI,而在格式转换。OnlyOffice 官方链路强依赖 DocumentServer:浏览器只是一个壳,真正的 docx -> bin -> 编辑 -> 存盘 全在服务端完成。sweetwisdom/onlyoffice-web-local(369 Star)+ cryptpad/onlyoffice-x2t-wasm + Qihoo360/se-office 这三件套,第一次把这条链路完整搬进了浏览器:WASM 跑 C++ 的 x2t,本地 web-apps/sdkjs 跑编辑器,File.arrayBuffer() 进,Blob 出,零上传。
目录
- 一、问题描述:官方集成到底痛在哪
- 二、原因分析:OnlyOffice 架构拆解
- 三、路线全景:四类纯前端方案对比
- 四、源码拆解:x2t-wasm 编译与转换原理
- 五、源码拆解:oo-offline 编辑器本地化劫持
- 六、动手:当前主干的最小可运行代码
- 七、验证结果:性能与代价
- 八、常见坑点:5 个必踩的坑
- 九、常见问答 FAQ
- 参考资料
一、问题描述:官方集成到底痛在哪
原文作者张海潮列的痛点接口很精准,翻译成人话就是三宗罪:
interface OfficialIntegrationPainPoints {
cloudDependency: boolean; // 必须起 DocumentServer(含 ConvertService)
dataTransmission: boolean; // 文档 URL 或二进制必须给服务端拉取
formatLimitation: string[]; // 官方 Conversion API 仅 30+ 种,且老格式先转 OOXML
}
典型现场:
- 医疗电子病历:三甲医院要求患者数据禁止出内网,但官方
DocsAPI.DocEditor的document.url必须是一个 DocumentServer 能下载到的公网 URL,内网文件根本喂不进去。 - 律所离线签署:保密协议要在断网会议室打开编辑,官方方案一断网转换服务就 404。
- 军工审计:涉密文档要过本地化安全审计,任何上传到第三方转换服务的行为直接一票否决。
原文 Demo onlyoffice-web-local 证明了另一条路:本地选文件 -> 浏览器内转 bin -> 本地编辑器打开 -> 点保存落回本地磁盘,全程可断网(首次缓存后)。
二、原因分析:OnlyOffice 架构拆解
要理解 WASM 替代了什么,先看官方 DocumentServer 的三块:
浏览器 (web-apps + sdkjs)
│ document.url + callbackUrl
▼
DocService (Node.js,协同、锁、存盘回调)
│ 需要转换时投递任务
▼
ConvertService -> FileConverter/bin/x2t (C++,格式互转核心)
- x2t:OnlyOffice
core仓库里的 C++ 转换器,docx/xls/ppt/odt/rtf/txt -> bin,以及bin -> pdf/docx/xlsx。bin是编辑器内部中间格式 + 外联媒体(图片/主题/字体子集),不是最终文件。 - params.xml:每次转换的任务描述文件,指定输入路径、输出路径、目标格式、字体目录、缩略图选项。服务端和 WASM 版共用同一套语义。
- web-apps/sdkjs:真正的编辑器前端(Document/Spreadsheet/Presentation 三套),官方体积就大,离线打包后
public/vendor约 700MB,直接进 Git,clone 和首屏都很重。
所以“纯前端”不是把 DocumentServer 编译到 WASM,而是掐头(转换)去尾(协同存盘),只留中间编辑器:转换用 x2t-wasm,协同用单机模式,存盘用前端 DownloadAs 劫持。
三、路线全景:四类纯前端方案对比
| 路线 | 代表 | 原理 | 保真度 | 数据出站 | 适用 |
|---|---|---|---|---|---|
| SaaS iframe 嵌入 | Office Online / WPS / 石墨 | 云端转换+云端编辑 | 最高 | 是 | 通用协作 |
| 预览-only | vue-office / docx-preview / mammoth | JS 重排渲染为 HTML/Canvas | 中低(复杂排版崩) | 否 | 只看不改 |
| 自托管 DocumentServer | 官方 Docker | 内网起转换+协同服务 | 最高 | 内网闭环 | 有运维团队 |
| 真本地 x2t-wasm + se-office | 本文方案 | WASM 转 bin + 本地 web-apps 编辑 | 高(≈官方) | 否 | 医疗/律所/军工/插件 |
决策树很简单:要改 + 不能出内网 + 养不起服务端 -> 选本文方案;要多人协同、历史版本、移动端 -> 老老实实上 DocumentServer。
四、源码拆解:x2t-wasm 编译与转换原理
这是原文最单薄的部分,只贴了 10 行 FS.writeFile。以下逐文件还原 cryptpad/onlyoffice-x2t-wasm 的真实实现(99 commits,跟踪上游 v9.3.0.140)。
4.1 入口只有 9 行:wrap-main.cpp
extern "C" {
int main1(char* xmlPath) {
char *argv[2] = { "", xmlPath };
return main(2, argv);
}
}
构建末尾 cat /wrap-main.cpp >> /core/X2tConverter/src/main.cpp,把原本只能 x2t params.xml 启动的 CLI,变成可被 JS ccall("main1", ...) 调用的导出函数。配合链接参数:
-sEXPORTED_RUNTIME_METHODS=ccall,FS -sEXPORTED_FUNCTIONS=_main1 -sALLOW_MEMORY_GROWTH
FS 必须导出,否则 JS 读写虚拟文件系统无门;ALLOW_MEMORY_GROWTH 必须开,否则大文档直接 OOM。
4.2 胶水层的缓存 trick:pre-js.js
Module.noInitialRun = true;
Module.noExitRuntime = true;
(function() {
let suffix;
if (typeof document != 'undefined') {
const myScript = document.currentScript;
const mySrc = myScript.getAttribute('src');
suffix = new URL(mySrc).search; // ?v=hash 透传给 wasm
} else { suffix = ''; }
Module.locateFile = function(path, prefix) {
return prefix + path + suffix;
};
})();
两行关键:noInitialRun 阻止 wasm 加载即执行 main;locateFile 把 x2t.js?v=xxx 的 query 透传给 x2t.wasm?v=xxx,解决 CDN 缓存击穿。这是生产必抄的细节。
4.3 编译参数:embuild.sh
QMAKE_LFLAGS="-sUSE_ICU=1 -sALLOW_MEMORY_GROWTH -sSTACK_SIZE=128kb ..."
CFLAGS="-sUSE_ICU=1 -sUSE_BOOST_HEADERS=0 -Os"
USE_ICU=1:多语言断词、排序、大小写转换全靠 ICU,关掉则中日韩排版错乱;STACK_SIZE=128kb:默认栈在递归解析深嵌套 OOXML 时易爆,CryptPad 手动压小栈、靠堆增长;USE_BOOST_HEADERS=0+ 自编译boost-1.84.0:Emscripten 自带 boost 缺符号,必须源码编译(Dockerfileboost阶段),并排除context/coroutine/fiber/thread等依赖线程的库——浏览器无真线程(pthreads 受限)。
4.4 Dockerfile:20+ 阶段的静态库拼图
Dockerfile 不是一个 emcc 就完事,而是按依赖拓扑逐个编出 .a:openssl -> ooxmlsignature -> unicodeconverter -> kernel -> graphics(harfbuzz+hyphen+heif+brotli) -> BinDocument/DocxFormatLib/PPTXFormatLib/XlsbFormatLib/Vba/Doc/Ppt/Xls -> Odf/Rtf/cfcpp/cryptopp/network -> PdfFile/doctrenderer/fb2/html2/epub/xps/djvu/apple/hwp/docxrenderer,最后链成 X2tConverter.pro。任何想自己升级 x2t 版本的人,必须理解这个顺序:git subtree pull --prefix core https://github.com/ONLYOFFICE/core.git <TAG> --squash 拉新代码后,哪一层 ABI 变了就要重编哪一层。
测试阶段还从 onlyoffice/documentserver:8.3.3 镜像里抠出官方 x2t 二进制做对照组:COPY --from=documentserver /var/www/.../FileConverter/bin/x2t /bin/,保证 WASM 版与服务端版输出一致。
4.5 真实 params.xml:以 test.js 为准
原文的 <Task><FromFile> 是示意,生产代码是 TaskQueueDataConvert:
const params = "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<TaskQueueDataConvert xmlns:xsi=\"...\" xmlns:xsd=\"...\">"
+ "<m_sFontDir>/working/fonts/</m_sFontDir>"
+ "<m_sThemeDir>/working/themes</m_sThemeDir>"
+ "<m_sFileFrom>/working/" + inputName + "</m_sFileFrom>"
+ "<m_sFileTo>/working/" + outputName + "</m_sFileTo>"
+ "<m_bIsNoBase64>false</m_bIsNoBase64>"
+ "<m_nCsvTxtEncoding>46</m_nCsvTxtEncoding>"
+ "<m_nCsvDelimiter>4</m_nCsvDelimiter>"
+ "</TaskQueueDataConvert>";
x2t.FS.writeFile('/working/params.xml', params);
copyToWasm(inputPath, '/working/' + inputName);
const result = x2t.ccall("main1", "number", ["string"], ["/working/params.xml"]);
配套的 initWorkDir() 每次转换前重建沙盒:rmr(/working,/tmp) -> mkdir /tmp /working /working/media /working/fonts /working/themes -> copyDirToWasm('tests/fonts', '/working/fonts')。字体和主题目录缺一个,转换不会报错但输出缺字——这正是 PDF 导出中文丢失的源头。
格式 ID 映射(getFormatId,服务端同源):docx 65 / doc 66 / odt 67 / txt 69 / html 70 / pptx 129 / ppt 130 / odp 131 / xlsx 257 / xls 258 / ods 259 / csv 260 / pdf 513。TEST_CONVERSIONS 只测 docx<->odt / xlsx<->ods / pptx<->odp 六组往返,保证 bin 中转无损。
五、源码拆解:oo-offline 编辑器本地化劫持
关键发现:掘金原文(5 月)的 src/utils/x2t.ts 在当前主干已 404 不存在,被 packages/oo-offline(7 个文件)取代。新链路不再显式调 x2t,而是 blob URL 直开 + 帧内 hook 存盘。这是本文比原文深一层的核心增量。
5.1 总览:7 个文件各管什么
| 文件 | 职责 | 原文有无 |
|---|---|---|
loadApi.ts | 幂等注入 vendor/web-apps/apps/api/documents/api.js | 无 |
normalize.ts | CELL/SLIDE/PDF 扩展集合 + buildDocsConfig 伪造 config | 无 |
createEditor.ts | 同页直挂 DocsAPI.DocEditor,无 onlyoffice.html 外壳 | 无 |
saveStream.ts | hook AscCommon.DownloadFileFromBytes,postMessage 回传文件流 | 无(原文只说保存到本地) |
types.ts | OfficeDocumentInput/CreateEditorOptions/SaveResult 类型 | 无 |
EditorPage.tsx | PendingDoc -> bufferToBlobUrl -> createEditor -> editor.save() | 部分 |
HomePage.tsx | 文件选择/拖拽/目录授权,setPendingDoc | 无 |
5.2 加载:loadApi.ts 幂等注入
function apiScriptUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}vendor/web-apps/apps/api/documents/api.js`;
}
// script[data-oo-api] 去重 + loadedByBase Map 缓存 Promise
baseUrl 须同源且指向产物根(含 vendor/,demo 为 / 对应 public/)。window.DocsAPI 已存在则直接 resolve,避免 React StrictMode 双挂载重复注入。
5.3 伪造:normalize.ts 的类型路由
const CELL = new Set(['xls','xlsx','xlsm','xlt','xltx','xltm','ods','fods','csv']);
const SLIDE = new Set(['ppt','pptx','pptm','pot','potx','potm','odp','fodp','pps','ppsx']);
const PDF = new Set(['pdf','oxps','xps','djvu']);
// detectDocumentType: CELL->cell, SLIDE->slide, PDF->pdf, else word
buildDocsConfig 注释写明离线契约:url 可省略,api.js(离线补丁)不再强制;Offline.js 走内联空白文档。真实打开请传 blob:/http(s):/data:;勿传文件名占位。key 用 hash(url|title|Date.now()) 保证每次打开不命中协同锁;permissions: {edit,download,print:true} 全开;PDF 必须显式 isForm:false,否则 api.js 走 common 加载器白屏。
5.4 直挂:createEditor.ts 去外壳化
官方 demo 套了层 onlyoffice.html 外壳 iframe,本仓库直接在宿主容器建 div#oo-editor-N 并 new window.DocsAPI.DocEditor(mountId, config)。事件映射:onAppReady->onReady / onDocumentReady->prepareSaveStream(host) / onDocumentStateChange->setModified / onDownloadAs->空函数(离线保存走 file-stream hook)。save() 用 saveChain 串行化,防止连点保存竞态;destroy() 调 destroyEditor() 并清空容器。
5.5 存盘:saveStream.ts 的帧内 hook(最精妙)
const hooked = function (data, fileName, mime) {
// ArrayBuffer.slice / Uint8Array copy -> payload {type:'onlyoffice-file-stream',...}
win.parent.postMessage(payload, '*', [buffer]); // transferable 零拷贝
if (win.OO_FILE_STREAM_ONLY === true || window.OO_FILE_STREAM_ONLY === true) return;
original.call(win.AscCommon, data, fileName, mime); // 菜单下载仍走原生
};
win.AscCommon.DownloadFileFromBytes = hooked;
流程:onDocumentReady 后 prepareSaveStream 每 200ms 尝试 hook(50 次上限);宿主点保存时 beginFileStreamCapture 置 OO_FILE_STREAM_ONLY=true,调 docEditor.downloadAs(format),编辑器内部 bin->ooxml 完成后调 DownloadFileFromBytes,hook 拦截 postMessage 回宿主,waitForFileStream(60s 超时)resolve {buffer,fileName,fileType}。平时不置位,文件菜单“下载为”仍走原生下载——同一 hook 双模式,这是源码级才看得出的设计。
5.6 接线:EditorPage/HomePage/pendingDoc
pendingDoc.ts 用模块变量 pending 在 SPA 路由间传文档(set/get/clear,take 已废弃防 StrictMode 丢文档)。HomePage.openFile 做 file.arrayBuffer() -> setPendingDoc({title,fileType,buffer}) -> navigate('/editor?open=...');EditorPage 取出后 bufferToBlobUrl(buffer,fileType)(按扩展选 mime)喂给 createEditor,保存后有 FileSystemFileHandle 则 writeFileBuffer 回写,否则 a[download] 落盘。?url=&filename= 远程参数、FileSystem Access 目录工作区、LanguageSwitcher 均在此层。
5.7 演进判断:为什么 x2t 显式调用消失了
README 仍写“Uses x2t-wasm as replacement”,但主干已无 x2t 调用。合理解释:se-office 离线产物(OnlyofficePersonal-build-output)的 sdkjs 内已含 BinDocument WASM,编辑器拿到 blob: 的 OOXML 后可在帧内直接解析为 bin,无需宿主预转。x2t-wasm 仍是理解 bin+media/params.xml 的钥匙,也是旧格式(doc/xls/ppt)预处理和纯转换场景的退路,但主链路已简化为 blob 直开。这是原文发布 4 个月后仓库的真实变化,照抄旧文必踩坑。
六、动手:当前主干的最小可运行代码
以 oo-offline/README + EditorPage 实测路径为准(非旧 x2t.ts):
import { createEditor, bufferToBlobUrl } from 'oo-offline';
// 打开:File -> blob URL -> 同页直挂
const url = bufferToBlobUrl(await file.arrayBuffer(), 'docx');
const editor = await createEditor({
container: document.getElementById('host')!,
baseUrl: '/', // 须含 vendor/,与页面同源
document: { url, fileType: 'docx', title: file.name },
onDocumentReady: () => console.log('ready'),
});
// 保存:劫持帧内 DownloadFileFromBytes,经 postMessage 取回 ArrayBuffer
const { buffer } = await editor.save('docx');
editor.destroy();
URL.revokeObjectURL(url);
React 版用 oo-offline/react 的 OnlyOfficeEditor + ref.save()。需要显式转 bin(旧格式/批量质检)时,再回退到第四章 test.js 的 ccall("main1") 链路。跑通标志:断网后刷新仍能打开本地 docx 并导出同名文件。
七、验证结果:性能与代价
| 指标 | 实测/仓库披露 | 解读 |
|---|---|---|
| Demo 可用性 | 在线 Demo 可开 docx/xlsx/pptx/pdf | 与原文录屏一致 |
| vendor 体积 | public/vendor 约 700MB,直追踪 Git | clone 慢、Pages 部署大,Release 用 html.zip 分发 |
| 首屏 | 缓存命中前数秒到十秒 | x2t.wasm + 编辑器双重预热,必须加 loading + Worker 预加载 |
| 转换 | 5MB docx 约 1-3 秒(桌面 Chrome) | 移动端翻倍,大文件建议分片提示 |
| 导出 | docx/xlsx/pptx/pdf 均可,PDF 中文易丢字 | 字体包问题,见下节 |
| 协同 | 无 | 单机架构性缺失 |
结论:单机保真度 ≈ 官方,工程代价全在体积和字体。
八、常见坑点:5 个必踩的坑
- COOP/COEP 与 SharedArrayBuffer:多线程版 WASM 需要跨域隔离头,GitHub Pages 默认没有,
onlyoffice-web-local用单线程 +nginx.conf示例绕过,迁移到自有 Nginx 务必照抄。 - PDF 字体丢失:WASM 内无系统字体,
params.xml的FontDir指向空目录即乱码。自带 Noto Sans CJK 子集并双端(转入 bin / 导出 PDF)保持一致。 - 700MB vendor 进 Git:
git clone极慢,CI 爆内存。正确姿势是 Git LFS 或 Release 附件 +.dockerignore瘦身,仓库 Dockerfile 已示范。 - 加密文档/宏/VBA:x2t-wasm 直接报错或丢宏。立项前用客户真实文档跑
tests/批量转换,不要用 lorem demo 自欺。 - AGPL-3.0 传染:
se-office与 OnlyOffice core 均为 AGPL,直接分发 vendor 包触发开源义务。商用闭源先找法务,或改用官方商业授权的 DocumentServer。
九、常见问答 FAQ
Q1:x2t-wasm 到底替代了 OnlyOffice 的哪一部分?
只替代 ConvertService / x2t 格式转换。它把 docx/xlsx/pptx 在浏览器内转成编辑器可读的 bin + 媒体资源,不替代 web-apps 编辑器本身和协同服务。
Q2:纯前端方案还需要部署 DocumentServer 吗?
不需要。这正是 sweetwisdom/onlyoffice-web-local 的关键:x2t-wasm 做转换,se-office 精简版 web-apps 做渲染编辑,全程无服务端,代价是失去多人协同和历史版本管理。
Q3:为什么首屏要加载几百 MB,7 秒甚至更久?
public/vendor 下约 700MB 离线编辑器资源 + 数十 MB 的 x2t.wasm 必须进浏览器缓存。首次加载慢是架构性成本,生产环境必须做 CDN 缓存、懒加载和 Worker 预热。
Q4:PDF 导出字体丢失怎么修?
WASM 沙盒内缺中文字体和 fontDir 映射。做法是自带 Noto Sans CJK / 思源字体包,在 params.xml 指定字体目录并预埋到 MEMFS,同时服务端转换也要配 fontDir,两边一致才不乱码。
Q5:医疗军工内网能直接用这套方案吗?
能,这正是它的主战场:文档全程驻留内存、零出站,满足等保和保密审计。但要注意 AGPL-3.0 传染性、加密文档和宏 VBA 不兼容,立项前先做 POC 验证。
参考资料
- 掘金原文:纯前端集成OnlyOffice的终极方案(张海潮):https://juejin.cn/post/7508654134267281418
- sweetwisdom/onlyoffice-web-local(369 Star,oo-offline 同页直挂与 saveStream hook):https://github.com/sweetwisdom/onlyoffice-web-local
- cryptpad/onlyoffice-x2t-wasm(wrap-main/pre-js/embuild/Dockerfile/test.js 全链路):https://github.com/cryptpad/onlyoffice-x2t-wasm
- Qihoo360/se-office(可离线分发的 web-apps/sdkjs/crx 包):https://github.com/Qihoo360/se-office
- ONLYOFFICE core / x2t 源码(转换器上游):https://github.com/ONLYOFFICE/core
- ONLYOFFICE Conversion API 与 Server 配置(params语义对照):https://api.onlyoffice.com/docs/docs-api/additional-api/conversion-api/
如果这篇帮到你,别忘了订阅本站 RSS,并在 GitHub 上给相关开源项目点个 Star。
原创技术博客 · 开源项目分享 · AI全栈创作社区 idao.fun