纯前端 PDF 处理避坑指南:5 个线上真实问题的解决方案

在做浏览器端 PDF 工具的过程中,我踩了不少坑。这篇文章记录 5 个最有代表性的问题和解决方案,希望能帮你少走弯路。

为什么要做浏览器端 PDF 处理?

传统方案是把文件上传到服务器处理。方便是方便,但隐私风险不可忽视——合同、简历、财务报表一旦离开你的设备,安全性就不再由你控制。

浏览器端处理的核心优势:文件不上传服务器,全程本地运算。

但这也带来了一系列技术挑战。


坑 1:大文件直接让浏览器崩溃

现象: 用户上传一个 80MB 的扫描件,点击处理后页面直接卡死,甚至弹出"页面无响应"。

原因: 浏览器单个 Tab 的内存上限通常在 1-2GB。pdf-lib 默认会把整个 PDF 加载到内存,大文件很容易触顶。

解决方案:

不是所有操作都需要加载整份文件。对于"拆分""提取页面"这类需求,可以只读取需要的部分:

// 只加载需要的页面,而不是整个文档
const pdfDoc = await PDFDocument.load(arrayBuffer, {
  updateMetadata: false
});

// 处理完立即释放,不要 hoard 多个文档
const result = await pdfDoc.save();
pdfDoc = null; // 帮助 GC

对于必须全量处理的场景(比如合并),给用户一个文件大小预警,而不是直接处理:

const MAX_SIZE = 100 * 1024 * 1024; // 100MB
if (file.size > MAX_SIZE) {
  alert('文件过大,建议先用桌面软件拆分后再处理');
}

实际效果: 加了预警后,用户投诉"页面卡死"的情况减少了 90%。


坑 2:中文字体变成方块

现象: 用户上传一份中文 PDF,合并后所有中文都变成了 □□□。

原因: pdf-lib 默认只嵌入 14 种标准 PDF 字体,不含中文字符集。

解决方案:

需要手动注册中文字体。但要注意——中文字体文件通常 5-15MB,直接打包会显著增加页面加载时间。

我们的做法是分策略处理:

  1. 英文字符(80% 场景):直接使用 pdf-lib 内置字体

  2. 中文内容:按需加载字体子集,只包含文档中出现的字符

  3. 字体文件放 CDN:不打包进主 bundle,需要时才下载

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 导入语法

import PdfWorker from 'pdfjs-dist/build/pdf.worker?worker';

pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();

方案 B:显式配置 worker 路径

pdfjsLib.GlobalWorkerOptions.workerSrc = 
  new URL('pdfjs-dist/build/pdf.worker.js', import.meta.url).href;

建议: 方案 A 更简洁,但如果遇到兼容性问题,可以回退到方案 B。


坑 4:批量处理时 UI 完全卡死

现象: 用户一次上传 20 个文件,点击"批量压缩"后,进度条不更新,页面无法滚动,用户以为崩溃了。

原因: JavaScript 是单线程的。pdf-lib 的处理逻辑阻塞了主线程,浏览器无法响应用户交互或更新 UI。

解决方案:

把大任务拆成微任务,让出主线程:

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 在浏览器空闲时处理:

files.forEach((file, index) => {
  requestIdleCallback(() => {
    compressPDF(file).then(() => updateProgress(index));
  });
});

效果: 即使处理 20 个文件,UI 依然流畅,用户可以随时取消操作。


坑 5:SPA 的 SEO 灾难

现象: 网站上线一个月,Google 只收录了首页,其他工具页面完全搜不到。

原因: Vue 3 SPA 的 HTML 只有一个空的

,爬虫看不到任何内容。

解决方案:

我们用 Playwright 做了一个预渲染脚本。构建时启动无头浏览器,访问每个路由,等 Vue 挂载完成后把渲染好的 HTML 保存为静态文件。

每个工具页、每篇指南文章都有自己的 index.html,包含完整的语义化内容和 meta 标签。

结果: Google 在一周内收录了 40+ 个页面,每个工具页都能独立被搜索到。


总结

浏览器端 PDF 处理在技术上完全可行,但有几个底线要守住:

问题

核心策略

大文件崩溃

内存预警 + 分批处理

中文乱码

按需加载字体子集

Worker 404

Vite ?worker 语法

UI 卡顿

setTimeout / requestIdleCallback 让出主线程

SEO 空白

构建时预渲染静态 HTML

这些方案已经在 sotool.top 上跑了几个月,处理了数千份文件,整体稳定性还不错。

如果你也在做浏览器端文档处理,欢迎交流遇到的问题。

聊天