在做浏览器端 PDF 工具的过程中,我踩了不少坑。这篇文章记录 5 个最有代表性的问题和解决方案,希望能帮你少走弯路。
为什么要做浏览器端 PDF 处理?
传统方案是把文件上传到服务器处理。方便是方便,但隐私风险不可忽视------合同、简历、财务报表一旦离开你的设备,安全性就不再由你控制。
浏览器端处理的核心优势:文件不上传服务器,全程本地运算。
但这也带来了一系列技术挑战。
坑 1:大文件直接让浏览器崩溃
现象: 用户上传一个 80MB 的扫描件,点击处理后页面直接卡死,甚至弹出"页面无响应"。
原因: 浏览器单个 Tab 的内存上限通常在 1-2GB。pdf-lib 默认会把整个 PDF 加载到内存,大文件很容易触顶。
解决方案:
不是所有操作都需要加载整份文件。对于"拆分""提取页面"这类需求,可以只读取需要的部分:
csharp
// 只加载需要的页面,而不是整个文档
const pdfDoc = await PDFDocument.load(arrayBuffer, {
updateMetadata: false
});
// 处理完立即释放,不要 hoard 多个文档
const result = await pdfDoc.save();
pdfDoc = null; // 帮助 GC
对于必须全量处理的场景(比如合并),给用户一个文件大小预警,而不是直接处理:
arduino
const MAX_SIZE = 100 * 1024 * 1024; // 100MB
if (file.size > MAX_SIZE) {
alert('文件过大,建议先用桌面软件拆分后再处理');
}
实际效果: 加了预警后,用户投诉"页面卡死"的情况减少了 90%。
坑 2:中文字体变成方块
现象: 用户上传一份中文 PDF,合并后所有中文都变成了 □□□。
原因: pdf-lib 默认只嵌入 14 种标准 PDF 字体,不含中文字符集。
解决方案:
需要手动注册中文字体。但要注意------中文字体文件通常 5-15MB,直接打包会显著增加页面加载时间。
我们的做法是分策略处理:
- 英文字符(80% 场景):直接使用 pdf-lib 内置字体
- 中文内容:按需加载字体子集,只包含文档中出现的字符
- 字体文件放 CDN:不打包进主 bundle,需要时才下载
ini
import fontkit from '@pdf-lib/fontkit';
pdfDoc.registerFontkit(fontkit);
// 按需加载字体
const fontBytes = await fetch('/fonts/NotoSansSC-subset.ttf')
.then(r => r.arrayBuffer());
const font = await pdfDoc.embedFont(fontBytes);
关键优化: 字体只嵌入一次,所有页面复用同一个 font 对象。每页都 embedFont 会导致文件体积暴增。
坑 3:Web Worker 在生产环境 404
现象: 本地开发时 PDF 渲染正常,部署到生产环境后,控制台报错 pdf.worker.js 404。
原因: pdfjs-dist 使用 Web Worker 解析 PDF,开发时路径正常,但 Vite 打包后 worker 文件被重命名或移动了位置。
解决方案(Vite 环境):
方案 A:使用 Vite 的 ?worker 导入语法
ini
import PdfWorker from 'pdfjs-dist/build/pdf.worker?worker';
pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();
方案 B:显式配置 worker 路径
arduino
pdfjsLib.GlobalWorkerOptions.workerSrc =
new URL('pdfjs-dist/build/pdf.worker.js', import.meta.url).href;
建议: 方案 A 更简洁,但如果遇到兼容性问题,可以回退到方案 B。
坑 4:批量处理时 UI 完全卡死
现象: 用户一次上传 20 个文件,点击"批量压缩"后,进度条不更新,页面无法滚动,用户以为崩溃了。
原因: JavaScript 是单线程的。pdf-lib 的处理逻辑阻塞了主线程,浏览器无法响应用户交互或更新 UI。
解决方案:
把大任务拆成微任务,让出主线程:
javascript
async function processBatch(files) {
for (let i = 0; i < files.length; i++) {
await compressPDF(files[i]);
updateProgressBar(i + 1, files.length);
// 关键:让出主线程,让浏览器喘口气
await new Promise(r => setTimeout(r, 0));
}
}
更进一步,可以用 requestIdleCallback 在浏览器空闲时处理:
scss
files.forEach((file, index) => {
requestIdleCallback(() => {
compressPDF(file).then(() => updateProgress(index));
});
});
效果: 即使处理 20 个文件,UI 依然流畅,用户可以随时取消操作。
坑 5:SPA 的 SEO 灾难
现象: 网站上线一个月,Google 只收录了首页,其他工具页面完全搜不到。
原因: Vue 3 SPA 的 HTML 只有一个空的 <div id="app"></div>,爬虫看不到任何内容。
解决方案:
我们用 Playwright 做了一个预渲染脚本。构建时启动无头浏览器,访问每个路由,等 Vue 挂载完成后把渲染好的 HTML 保存为静态文件。
每个工具页、每篇指南文章都有自己的 index.html,包含完整的语义化内容和 meta 标签。
结果: Google 在一周内收录了 40+ 个页面,每个工具页都能独立被搜索到。
总结
浏览器端 PDF 处理在技术上完全可行,但有几个底线要守住:
| 问题 | 核心策略 |
|---|---|
| 大文件崩溃 | 内存预警 + 分批处理 |
| 中文乱码 | 按需加载字体子集 |
| Worker 404 | Vite ?worker 语法 |
| UI 卡顿 | setTimeout / requestIdleCallback 让出主线程 |
| SEO 空白 | 构建时预渲染静态 HTML |
这些方案已经在 sotool.top 上跑了几个月,处理了数千份文件,整体稳定性还不错。
如果你也在做浏览器端文档处理,欢迎交流遇到的问题。