← 返回博客

纯前端本地编辑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 出,零上传。

目录

一、问题描述:官方集成到底痛在哪

原文作者张海潮列的痛点接口很精准,翻译成人话就是三宗罪:

interface OfficialIntegrationPainPoints {
  cloudDependency: boolean;    // 必须起 DocumentServer(含 ConvertService)
  dataTransmission: boolean;   // 文档 URL 或二进制必须给服务端拉取
  formatLimitation: string[];  // 官方 Conversion API 仅 30+ 种,且老格式先转 OOXML
}

典型现场:

  1. 医疗电子病历:三甲医院要求患者数据禁止出内网,但官方 DocsAPI.DocEditor 的 document.url 必须是一个 DocumentServer 能下载到的公网 URL,内网文件根本喂不进去。
  2. 律所离线签署:保密协议要在断网会议室打开编辑,官方方案一断网转换服务就 404。
  3. 军工审计:涉密文档要过本地化安全审计,任何上传到第三方转换服务的行为直接一票否决。

原文 Demo onlyoffice-web-local 证明了另一条路:本地选文件 -> 浏览器内转 bin -> 本地编辑器打开 -> 点保存落回本地磁盘,全程可断网(首次缓存后)。

二、原因分析:OnlyOffice 架构拆解

要理解 WASM 替代了什么,先看官方 DocumentServer 的三块:

浏览器 (web-apps + sdkjs)
   │ document.url + callbackUrl
   ▼
DocService (Node.js,协同、锁、存盘回调)
   │ 需要转换时投递任务
   ▼
ConvertService -> FileConverter/bin/x2t (C++,格式互转核心)

所以“纯前端”不是把 DocumentServer 编译到 WASM,而是掐头(转换)去尾(协同存盘),只留中间编辑器:转换用 x2t-wasm,协同用单机模式,存盘用前端 DownloadAs 劫持。

三、路线全景:四类纯前端方案对比

路线代表原理保真度数据出站适用
SaaS iframe 嵌入Office Online / WPS / 石墨云端转换+云端编辑最高是通用协作
预览-onlyvue-office / docx-preview / mammothJS 重排渲染为 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"

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.tsCELL/SLIDE/PDF 扩展集合 + buildDocsConfig 伪造 config无
createEditor.ts同页直挂 DocsAPI.DocEditor,无 onlyoffice.html 外壳无
saveStream.tshook AscCommon.DownloadFileFromBytes,postMessage 回传文件流无(原文只说保存到本地)
types.tsOfficeDocumentInput/CreateEditorOptions/SaveResult 类型无
EditorPage.tsxPendingDoc -> 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,直追踪 Gitclone 慢、Pages 部署大,Release 用 html.zip 分发
首屏缓存命中前数秒到十秒x2t.wasm + 编辑器双重预热,必须加 loading + Worker 预加载
转换5MB docx 约 1-3 秒(桌面 Chrome)移动端翻倍,大文件建议分片提示
导出docx/xlsx/pptx/pdf 均可,PDF 中文易丢字字体包问题,见下节
协同无单机架构性缺失

结论:单机保真度 ≈ 官方,工程代价全在体积和字体。

八、常见坑点:5 个必踩的坑

  1. COOP/COEP 与 SharedArrayBuffer:多线程版 WASM 需要跨域隔离头,GitHub Pages 默认没有,onlyoffice-web-local 用单线程 + nginx.conf 示例绕过,迁移到自有 Nginx 务必照抄。
  2. PDF 字体丢失:WASM 内无系统字体,params.xml 的 FontDir 指向空目录即乱码。自带 Noto Sans CJK 子集并双端(转入 bin / 导出 PDF)保持一致。
  3. 700MB vendor 进 Git:git clone 极慢,CI 爆内存。正确姿势是 Git LFS 或 Release 附件 + .dockerignore 瘦身,仓库 Dockerfile 已示范。
  4. 加密文档/宏/VBA:x2t-wasm 直接报错或丢宏。立项前用客户真实文档跑 tests/ 批量转换,不要用 lorem demo 自欺。
  5. 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 验证。

参考资料

如果这篇帮到你,别忘了订阅本站 RSS,并在 GitHub 上给相关开源项目点个 Star。

原创技术博客 · 开源项目分享 · AI全栈创作社区 idao.fun