前端做 Word 在线预览这个需求,我第一次接到的时候以为是个小活:不就是把文件渲染出来吗,找个库一套就完事。真正落地之后才发现,这个需求的坑密度远超预期——用户传上来的可能是 2007 年的 .doc,也可能是排版了三百页、每页都有页眉页脚和交叉引用的 .docx;产品想要的"预览"和用户眼里的"还原"根本不是一个东西。这篇文章就把前端实现在线预览 Word 文件这件事从头到尾拆一遍:先讲清楚 Word 文件的技术本质,再对比几条主流技术路线的真实边界,然后给出一套能直接进生产的纯前端落地方案和踩坑清单,最后聊聊什么时候必须把活儿交给服务端。如果你正在做文档中心、合同系统、OA 审批或者在线教育里的作业批改,这篇内容基本能帮你省掉两到三次返工。
1. 先搞清楚 .docx 到底是什么:在线预览 Word 的第一道坎
1.1 docx 是一个 zip 包,而不是一份"文档"
把任意一个 .docx 的文件后缀改成 .zip,解压出来你会看到一组 XML 和一堆资源文件:word/document.xml存正文,word/styles.xml存样式定义,word/media/目录存图片,word/header1.xml、word/footer1.xml是页眉页脚,word/footnotes.xml是脚注,再加上[Content_Types].xml和_rels/*.rels用来描述各部分之间的引用关系。
关键在于,正文里几乎没有"这个字长什么样"的直接信息,只有"这段文字套用了 pStyle 为 Heading1 的段落样式""这个 run 的字体指定为某个东亚字体"这类语义描述。真正的视觉效果,需要渲染器自己去查样式表、算字号行距、算换行位置、算分页边界。
这解释了一个很多人没想明白的问题:前端预览 Word,本质上不是"打开文件",而是"自己写一个简化版的排版引擎"。浏览器只认 HTML 和 CSS,你交给它的东西必须已经算好尺寸和样式。所以你能找到的所有前端预览库,做的基本是同一件事——把 OOXML 语义翻译成 HTML 加内联样式。理解这一点,后面所有"为什么这里渲染不对"的疑问都会变得顺理成章。
1.2 老 .doc 是二进制格式,纯前端这条路基本走不通
.doc 是 OLE 复合文档的二进制结构,不是 zip 包,没有公开完整且可用的解析规范,前端生态里也没有能稳定读取它的库。如果你手上还有这类文件要处理,最务实的做法是把识别和拦截放在上传环节:读文件头魔数(DOC 文件的前若干字节是D0 CF 11 E0 A1 B1 1A E1),识别出来就直接提示用户"请另存为 .docx 后重新上传"。
这里有个我踩过的细节——判断一定要基于文件头,而不是后缀名。用户把 .doc 改个后缀叫成 .docx 的情况太常见了,后端和前端都会被骗。我现在的习惯是在文件选择组件里做一个checkFileSignature的函数,读完前 8 个字节再决定放不放行。这个判断放在选文件的那一刻做,比等到预览弹窗打开再甩个报错出来,体验上好一个档次。
1.3 先定清楚验收标准,再谈选什么库
"能预览"是句废话,得拆成可验收的指标。我在项目里通常会和产品确认这么几件事:分页要不要还原(也就是要不要看到跟 Word 里一样的一页一页)、页眉页脚要不要显示、批注和修订痕迹要不要呈现、只读预览还是要选中复制、是否要支持缩放和全屏、最大允许多大的文件。这几个问题的答案会直接决定技术路线,而不是反过来先选库再去掰需求。
举个例子,如果产品明确说"只要能看清文字和表格就行,分页无所谓",那 mammoth.js 这种把内容转成语义化 HTML 的方案就非常合适,成本最低。但如果需求是"和 Word 里长得一模一样,打印出来能直接当合同用",那纯前端方案基本不用考虑了,直接上服务端转换。最怕的是需求含糊,你按纯前端做了一版,上线后对方说"怎么跟原文件长得不一样",再推倒重来。
2. 四条技术路线的真实边界:纯前端解析、转 PDF、转 HTML、第三方托管
2.1 纯前端解析:docx-preview、mammoth.js、vue-office
这是最省钱也最受欢迎的一类。文件从后端拿到之后不经过任何服务,直接在浏览器里解析渲染。优点很明确:零服务端成本、不需要上传到第三方、响应快、数据不出域,对私有化部署和涉密场景非常友好。
具体到库的选择,我个人的判断是这样:docx-preview是还原度最高的一个,它会渲染分页、页眉页脚、脚注尾注,样式上也尽量贴近 Word 的呈现,代价是体积稍大、解析大文件时主线程压力明显;mammoth.js走的是另一条路,它把文档转换成语义干净的 HTML,输出的是 h1、p、table 这类结构化标签,方便你自己写 CSS 控制排版,但它刻意丢弃了大量视觉样式,不还原分页,适合"读内容"而不是"看排版";vue-office是一套打包好的 Vue 组件封装,底层同样基于 docx 解析,优势是开箱即用、跟 Vue 项目集成成本低,适合快速验证。
这里说句实在话:这三个库都不是"渲染引擎",它们的定位是"解析器加翻译器",遇到复杂排版必然有偏差。你把它当成 90 分的方案去用,心里会舒服很多;当成 100 分去用,最后一定会上火。
2.2 服务端转 PDF:还原度最高,但成本和依赖也要认
链路上很简单——后端收到 docx,用 LibreOffice 的 headless 模式或者商业方案转成 PDF,前端用 pdf.js 渲染。好处是还原度通常是几种方案里最好的,因为 LibreOffice 本身就是完整的办公套件排版引擎;而且 PDF 前端渲染的生态极其成熟,翻页、缩放、搜索、文本选择、打印全都有现成能力。
代价也很清楚:服务端要装几百兆的依赖,首次启动冷、单次转换耗时通常在一秒到数秒之间、并发上来了需要排队、转换进程偶发卡死要有人兜底。如果你的系统是私有化交付给客户的,还得考虑客户服务器允不允许装这些东西。这条路适合文档量可控、对还原度要求高、预算允许的场景,合同、发票、公文这类业务基本都走这条路。
2.3 服务端转 HTML:折中方案,但"自己维护渲染器"的坑很深
把 docx 在服务端转成一份带内联样式的 HTML,前端直接塞进容器渲染。听起来兼顾了还原度和前端轻量,但实际维护成本经常被低估——转换器输出的 HTML 结构非常冗长(一段文字能被拆成十几个 span,每个都带一堆内联样式),DOM 节点数量爆炸,页面一滚动就卡;同时服务端转换器和前端渲染器之间的样式差异会持续给你制造 bug。
我见过几个团队选了这条路,前半年很爽,后面每次改需求都要同时动两边代码,慢慢就变成了技术债。除非你的团队确实有精力长期维护一套渲染逻辑,否则我不太推荐。
2.4 第三方在线预览服务:快,但要过数据合规这一关
想必你也见过那种把文件地址丢给在线预览地址就能看的方案,接入成本极低,复制粘贴几行代码就完事。但这里有两个硬门槛:一是文件必须有一个公网可访问的 URL,内网系统、需要鉴权的私有文件直接用不了;二是文件内容要经过第三方服务器,涉及合同、财务、个人信息的场景基本一票否决。
我的态度是:做内部工具、个人项目、演示 Demo 可以用,正经业务系统尽量别碰。下面这张表是我自己的选型参照,你可以直接拿去和团队对齐。
| 技术路线 | 还原度 | 服务端成本 | 大文件表现 | 适用场景 |
|---|---|---|---|---|
| 纯前端解析(docx-preview) | 中上 | 无 | 一般,需优化 | 内部预览、内容阅读、私有化部署 |
| 纯前端解析(mammoth.js) | 中,只保留语义 | 无 | 好 | 内容提取、搜索高亮、移动端阅读 |
| 服务端转 PDF | 高 | 高 | 好 | 合同、公文、打印归档 |
| 服务端转 HTML | 中上 | 中高 | 一般 | 需要可编辑或深度定制的场景 |
| 第三方在线服务 | 高 | 无(但依赖外部) | 好 | 内部工具、Demo、非敏感内容 |
3. docx-preview 落地实录:一个能进生产的最小可用版本
3.1 依赖安装与最基础的渲染代码
先装依赖,注意 docx-preview 本身带 types,TypeScript 项目不用额外找声明文件。
npm install docx-preview # 或者 pnpm add docx-preview最基础的用法就是把 ArrayBuffer 丢进去渲染。核心 API 是renderAsync,它返回一个 Promise,渲染完成后你能拿到容器元素做后续处理:
import { renderAsync } from 'docx-preview'; async function previewDocx(fileBuffer, container) { await renderAsync(fileBuffer, container, null, { className: 'docx-preview-root', inWrapper: true, breakPages: true, ignoreLastRenderedPageBreak: false, renderHeaders: true, renderFooters: true, renderFootnotes: true, useBase64URL: true, experimental: true, }); }几个参数值得展开说。breakPages控制是否按分页渲染,开了之后视觉上更接近 Word,但解析开销会明显上升,文件页数多的时候差别很大。inWrapper会在外层包一个容器,方便你做整体缩放而不影响其他区域,我基本都会开。useBase64URL建议打开,它会把图片转成 base64 内联进去,这样预览区就不依赖原文件的相对路径,避免图片 404 的问题,代价是内存占用会变高,超大文档要留意。
3.2 容器、样式隔离与分页模式的取舍
docx-preview 输出的样式是以内联为主的,但它仍然会往页面里注入少量样式规则和 CSS 变量。如果你的项目用了微前端或者存在多个预览实例,样式互相污染是迟早的事。我的做法是给预览容器加一个固定的 className,并在全局样式里做一次收敛:
.docx-preview-root { --docx-page-bg: #f5f6f8; background: var(--docx-page-bg); padding: 16px 0; overflow: auto; height: 100%; } .docx-preview-root section.docx { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); margin: 0 auto 16px; }分页模式的选择要看场景。如果只是给用户扫一眼内容,breakPages: false更好,渲染快、DOM 少、滚动手感顺;如果是走审批流、要打印或者需要用户核对排版,那就开分页。我一般会做成一个可切换的开关,默认按文件页数决定——二十页以内开分页,超过就自动关掉,这样体感最稳。
3.3 文件获取这一环,坑比渲染本身还多
渲染之前你得先把文件拿到手。这一步的常见做法是请求后端接口拿二进制流,响应类型必须显式设置:
const res = await fetch(`/api/file/${fileId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('文件获取失败'); const arrayBuffer = await res.arrayBuffer();有两个我踩过的点。第一,如果后端返回的是 JSON 包装的 base64 字符串(有些接口就是这么设计的),你需要先解码成 Uint8Array 再传进去,直接传字符串给 renderAsync 会报类型错误,而且报错信息不太直观。第二,别用responseType: 'blob'之后再交给 FileReader 读半天,arrayBuffer()更直接,少一次内存拷贝。另外提醒一句,鉴权必须走请求头而不是把 token 拼在 URL 上,预览链接经常会被用户复制转发,URL 里带凭证等于把权限公开了。
3.4 卸载与重复渲染:内存泄漏最容易发生的地方
预览弹窗关闭之后,如果你只是把容器 DOM 删了,docx-preview 内部持有的解析结果、图片的 objectURL、事件监听都不会自动释放。用户来回开十几次大文件,标签页内存就能涨到几百兆,最后浏览器直接崩给你看。
我的清理写法是固定的三步:先清空容器内的所有子节点,再把渲染出来的 blob URL 逐个 revoke,最后把保存 buffer 的变量置空:
function disposePreview(container, blobUrls) { if (container) container.innerHTML = ''; (blobUrls || []).forEach((url) => URL.revokeObjectURL(url)); blobUrls = []; }更稳妥的方式是用AbortController把请求也纳入清理范围:用户关弹窗时顺手 abort 掉还在飞的请求,避免"已经关掉的弹窗突然又渲染出来"这种诡异现象。
4. 渲染出来之后才暴露的问题:字体、表格、图片、公式逐项拆解
4.1 字体缺失导致的整体错位
这是纯前端预览最典型的问题,也是新手最容易误判成"库有 bug"的地方。文档里用的是宋体、黑体、仿宋这类中文字体,浏览器如果在渲染环境里找不到对应字体,就会回退到默认字体,字宽一变,整段文字的换行位置全变,本来三行的段落变成四行,后面所有分页位置跟着偏移。
解决办法有几层。最直接的是在预览容器上声明一套字体回退链,保证跨平台至少有一款可用的中文字体:
.docx-preview-root section.docx { font-family: "SimSun", "Songti SC", "STSong", "Noto Serif CJK SC", serif; }如果你的场景对字体一致性要求高(比如要保证打印尺寸),把字体文件放进项目里用@font-face加载是最稳的,代价是字体文件动辄十几兆,要做子集化处理。还有一点要提前跟产品说清楚:不同操作系统上字体本来就不同,Mac 上预览的效果和 Windows 上有差异是正常的,这不是 bug,是环境问题。
4.2 表格列宽与合并单元格的呈现偏差
表格是 OOXML 里逻辑最绕的部分之一。Word 里表格列宽有tblGrid定义的理想宽度,每个单元格又有自己的tcW,还有gridSpan横向合并和vMerge纵向合并,最后还要根据内容做自适应。前端渲染器往往取其一,结果就是列宽跟 Word 里对不上,长文字撑开单元格或者被硬截断。
我遇到过一次印象深刻的情况:一份合同里的费用明细表,某列在 Word 里是固定窄列,预览时被自动撑宽,导致整表超出页面宽度出现横向滚动条,客户一眼就看出不对。最后的处理是给预览区域的表格加一层约束:
.docx-preview-root table { table-layout: fixed; max-width: 100%; word-break: break-word; }table-layout: fixed会让列宽按第一行或 colgroup 计算,不再被内容撑开,但代价是长内容换行更多。这里需要你根据业务取舍——报表类文档建议保持自动布局,公文合同类建议用固定布局,视觉更整齐。
4.3 图片的三种来源与各自的处理方式
文档里的图片有三种常见存法:内嵌在word/media/里通过 rels 引用、以 base64 内联在 XML 中、以及外链到某个 URL。第一种最常见,只要useBase64URL打开,库会帮你转成内联地址;第二种本来就自带地址,正常渲染;第三种最麻烦,外链图片在预览环境里可能因为跨域或者源站失效而裂图。
我的做法是渲染完成后遍历一次预览容器里的 img 标签,统一挂上错误兜底:
container.querySelectorAll('img').forEach((img) => { img.loading = 'lazy'; img.onerror = () => { img.style.display = 'none'; }; });顺便说一句,loading="lazy"对长文档的体验提升非常明显,几十张图片的文档如果全部立即加载,滚动时会明显掉帧。
4.4 公式、图表、批注、页眉页脚这些"看不见"的部分
公式(OMML)在纯前端方案里基本是重灾区,docx-preview 对常见公式有一定支持,但复杂公式、特殊符号、数学字体(Cambria Math)渲染出来常常对不齐或者显示成方框。图表(Chart)本质是引用外部的 chart XML 和嵌入的 excel 数据,前端库通常不处理,会直接空白。批注和修订痕迹无论如何都建议按需开关,不要默认全开,DOM 数量会成倍增长。
页眉页脚和脚注是 docx-preview 的加分项,默认就支持,但要注意renderHeaders、renderFooters、renderFootnotes这三个开关打开后渲染时间会上升,如果文档页数很多,可以做一个"简化模式"给用户切换。我的经验是:给内容阅读场景做一个默认不渲染页眉页脚的轻量模式,给"核对排版"场景提供完整模式,让用户自己选,比你自己纠结要靠谱。
5. 大文件与卡顿:把解析工作从主线程挪走
5.1 什么时候该上 Web Worker
判断标准很简单:文件超过 2MB 或者超过 30 页,主线程解析就会出现肉眼可见的卡顿,弹窗打开时白屏一两秒,页面其他交互也没响应。这时候就该考虑把解析放到 Worker 里。
不过得先说清楚一个限制:docx-preview 的渲染过程涉及大量 DOM 操作,而 Worker 里没有 DOM,所以完整的渲染没法直接搬进去。可行的做法是分层——把 zip 解压、XML 解析、字符串处理这类纯计算放到 Worker 里,把生成的中间结果传回主线程再做 DOM 组装;或者更简单的做法,只把文件读取和文本提取放 Worker,复杂文档仍然走主线程但加上加载态和骨架屏。
我实际项目里用的折中方案是:小于 5MB 直接主线程渲染加 loading,大于 5MB 走 Worker 做预解析并给出进度提示,超过 20MB 直接引导用户下载后本地查看。这个策略比强行支持所有大小要现实得多。
Vite 项目里创建 Worker 很方便,加个?worker后缀就行:
import ParseWorker from './parse.worker?worker'; const worker = new ParseWorker(); worker.postMessage({ buffer }, [buffer]); worker.onmessage = (e) => { renderAsync(e.data.buffer, container, null, { breakPages: true }); };注意postMessage的第二个参数是 transfer 列表,把 ArrayBuffer 转移过去而不是拷贝,能省掉一份大内存,处理几十兆文件时差别很明显。
5.2 分片渲染与虚拟滚动值不值得做
理论上可以把文档按 section 切开,只渲染视口附近的几页,滚动时动态加载。听起来很美,但实现复杂度很高:分页高度需要预先计算、滚动位置要精确映射、复制粘贴跨页会断。除非你的产品就是"在线看几百页文档"这种核心场景,否则我不建议做。
更划算的优化是这几条:渲染前把原始 buffer 缓存下来,切换到简化模式时不用重新请求;用requestIdleCallback把图片的懒加载和样式后处理延后执行;容器加上contain: content告诉浏览器这块区域的布局互不影响,滚动性能会有改善。这几个改动加起来可能只有几十行代码,效果却比虚拟滚动明显。
5.3 缓存策略:同一份文件不要解析两次
用户在一个页面里反复打开同一份文件是很常见的。我的做法是在内存里维护一个 Map,key 用 fileId 加版本号,value 存 ArrayBuffer 和渲染后的 HTML 快照。切换文件再切回来时直接复用快照,几乎瞬间完成。
const previewCache = new Map(); const MAX_CACHE = 3; function getCache(key) { if (!previewCache.has(key)) return null; const val = previewCache.get(key); previewCache.delete(key); previewCache.set(key, val); // 命中后移到队尾,实现 LRU return val; }这里必须限制条数并做 LRU,不然用户连续打开十几个大文件,内存会在你不知情的情况下爆掉。如果希望缓存跨会话保留,可以放到 IndexedDB 里存原始 buffer,但要注意配额和清理策略,别把用户浏览器塞满。
6. 在线预览绕不开的安全与权限问题
6.1 解析出来的 HTML 不要直接往页面里塞
这是个必须强调的点。docx 里的内容是可以被构造的,如果文档内容被外部可控,直接把它生成的 HTML 用innerHTML插入主文档,就可能带进脚本或者危险的属性。docx-preview 内部做了基本的处理,但你不能把安全职责完全交给一个库。
我的原则是:预览容器永远只作为渲染目标,不参与业务逻辑;不要把渲染结果拼进表单、不要用渲染结果做模板;如果确实需要把内容取出来在别处展示,走文本提取而不是 HTML 传递。同时给预览容器加一层隔离样式,禁止外部样式向内穿透:
.docx-preview-root { all: initial; contain: strict; }6.2 宏和外部引用的处理
Word 文档可以携带宏(.docm)、可以引用外部模板、可以嵌入 OLE 对象。单纯的预览不会执行宏,但如果你的系统还提供下载,下载回来的文件在用户本地被打开时是有风险的。合理的做法是在上传环节就做类型和白名单校验,服务端对文件做一次净化或者至少标记出风险文档。
另外,很多企业要求预览时打水印。纯前端打水印只能防君子不防小人——用户可以打开控制台把水印元素删掉。真要防,还是得在服务端渲染出带水印的版本,或者转成 PDF 时叠加水印图层。这一点在需求评审时就要说清楚,别等到验收时才发现"防不了"。
6.3 权限边界要在接口层控制,而不是在预览组件里
我见过一种实现:预览组件的 props 里传一个canDownload,为 false 就隐藏下载按钮。这只能算交互层面的约束。真正的权限必须由后端接口控制——不允许下载的用户,请求文件时后端返回的就是带水印或者降质的版本,甚至直接拒绝返回原始文件。
比较稳妥的设计是把接口拆成两个:预览接口返回渲染所需的 buffer(可以加用户标识水印),下载接口单独鉴权并记录操作日志。这样即便有人绕过前端,也拿不到原始文件。
7. 服务端转换方案:LibreOffice 无头模式的实际成本账
7.1 转换链路和部署时的几个现实问题
链路本身不复杂:文件落到服务器,调用 LibreOffice 的 headless 模式转换,产物存到对象存储,前端用 pdf.js 加载。
soffice --headless --norestore --invisible \ --convert-to pdf --outdir /data/out /data/in/sample.docx部署上有几个经验。第一,必须给每个转换进程独立的用户配置目录,多个请求并发调用同一个 profile 会互相抢占导致失败,通过-env:UserInstallation=file:///tmp/lo_${uuid}指定独立目录就能解决。第二,一定要加超时和进程回收,遇到异常文档 LibreOffice 可能直接挂住不返回,我一般设置 30 秒超时并强制 kill。第三,字体要提前装好,容器镜像里如果没有中文字体,转出来的 PDF 会全是方框,这个坑几乎每个团队都会踩一次。
7.2 缓存、队列与并发控制
转换是重操作,绝对不能让每个预览请求都触发一次转换。标准做法是内容寻址缓存:对文件内容算一个 hash,转换产物以 hash 命名存储,同一个文件第二次预览直接返回缓存地址。
const hash = crypto.createHash('md5').update(buffer).digest('hex'); const cacheKey = `preview:${hash}:${version}`;并发控制用队列加限流,同时转换的任务数控制在 CPU 核数附近,超出的排队。前端这边要能显示"正在生成预览,请稍候"的状态,并支持轮询或长连接获取结果。这一步的设计做得好不好,直接决定高峰期的系统稳定性。
7.3 什么情况下必须上服务端
我的判断依据有三条,满足任意一条就应该走服务端:一是对还原度有硬要求,比如要打印、要归档、要作为凭证;二是文档里包含纯前端无法处理的内容,比如复杂公式、图表、嵌入对象;三是文件体积普遍很大,浏览器端体验无法保障。
反过来,如果是内部管理系统里"用户上传附件,同事扫一眼内容"这种场景,纯前端方案的性价比高得多,省下来的服务器成本足够你优化好几次用户体验了。
8. 回看这几年的选型经验,以及几个印象深刻的坑
8.1 我的选型顺序:先问场景,再问体积,最后问还原度
如果让我给一个简单可执行的决策顺序,是这样的:先确认文件格式,只要有 .doc 就必须准备服务端兜底;再看典型文件体积和页数,超过 10MB 或 100 页的,纯前端方案要谨慎;最后看还原度要求,要求"一模一样"就上服务端转 PDF,要求"能读能搜"就用 mammoth.js,介于两者之间就选 docx-preview。这套顺序我在三个项目里用过,基本不会错得离谱。
还有一个容易被忽略的点:一定要在项目早期拿真实文件测,不要拿自己写的一份简单文档测。让业务方提供十份最有代表性的文件,最好是"最丑最难看的那些",用它们来验证方案的边界。我吃过一次亏——开发阶段用干净文档测试全部正常,上线后用户传的文档页眉里带图片、表格里有嵌套表格,预览直接错位得不成样子,返工花了整整一周。
8.2 三个印象深刻的坑
第一个是弹窗滚动穿透。预览容器打开后,用户滚动到底部继续滚,结果后面的页面跟着滚了。解决办法是在弹窗打开时给 body 加overflow: hidden,关闭时恢复,别用监听 touchmove 那套,移动端兼容性差。
第二个是复制出来的文字带一堆空格。这是渲染器把每个文本片段单独包 span 导致的,用户复制后拿去搜索关键词搜不到。如果业务里有"复制预览内容"的需求,建议提供独立的文本提取接口,而不是让用户从渲染结果里框选。
第三个是打印。用户直接 Ctrl+P 打印预览页面时,往往会带上外面的导航栏和按钮。要正确处理得写专门的打印样式,把非预览区域全部隐藏:
@media print { body > *:not(.docx-preview-root) { display: none !important; } .docx-preview-root { padding: 0; background: #fff; } .docx-preview-root section.docx { box-shadow: none; margin: 0; } }最后分享一个我一直在用的小技巧:在预览组件的加载态里放一句"正在解析文档,大文件可能需要几秒",比转圈的动画有效得多。用户对等待的容忍度取决于他知不知道要等多久,这句话能显著降低投诉量。至于后续演进,我通常会把预览能力抽象成一个独立的组件模块,接口只暴露 fileId 和配置项,内部是纯前端还是服务端转 PDF 对调用方完全透明——这样等业务量上来了想换方案,改一个文件就够了,不用满项目找渲染代码。