案例 Vue 3 纯前端 PDF 工具站上线:8 个坑,从 80MB 白屏到只收录首页

2026-10-29 21:31:13

Vue 3 纯前端 PDF 工具站上线:8 个坑,从白屏、进度条撒谎到只收录首页

两周前开始做一个纯浏览器端处理的在线 PDF 工具站,起因是当时能找到的在线工具都要把文件上传到服务器。现在站点上线,20+ 个功能,Google 刚开始收录。回头看,这两周踩的坑比过去半年都多。

坑 1:pdf-lib 加载大文件直接崩

症状:用户上传一个 80MB 的扫描件 PDF,页面白屏,控制台报 RangeError: Array buffer allocation failed。

原因:pdf-lib 会把整个 PDF 读进内存,用 ArrayBuffer 存。浏览器对单个 ArrayBuffer 有大小限制,而且加载过程中内存会翻倍——原始文件一份,pdf-lib 内部结构一份。

解法:不是不用 pdf-lib,而是别让大文件一次性进内存。两层保护:前端文件大小限制,超过 100MB 直接提示“建议用桌面软件处理”;分片加载,Image to PDF 场景图片逐个嵌入,每处理完一张就手动释放。

// 错误做法:20MB 的 ArrayBuffer 一直占着内存
const imageBytes = await file.arrayBuffer(); // 20MB
const image = await pdfDoc.embedJpg(imageBytes); // 又拷贝一份
// 正确做法:嵌入后立即释放
let imageBytes = await file.arrayBuffer();
const image = await pdfDoc.embedJpg(imageBytes);
imageBytes = null; // 允许 GC 回收

真正的教训是:浏览器不是服务端,不要承诺处理无限大的文件。在 UI 上给用户明确的文件大小预期,比后面优化代码更有价值。

坑 2:Web Worker 通信开销被我忽略了

症状:把 PDF 处理逻辑放进 Web Worker 后,50 页的文件处理时间从 3 秒变成了 5 秒。

原因:Worker 和主线程之间传数据需要结构化克隆(structured clone)。一个 50MB 的 PDF,光是拷贝就要几百毫秒。

解法:用 Transferable Objects,把 ArrayBuffer 的所有权转移给 Worker,而不是拷贝:

// worker.js
self.onmessage = async (e) => {
const { pdfBytes } = e.data; // 拿到所有权,没有拷贝
const pdfDoc = await PDFDocument.load(pdfBytes);
const result = await pdfDoc.save();
self.postMessage({ result }, [result.buffer]); // 转移所有权回去
};

// main.js
const worker = new Worker('worker.js', { type: 'module' });
worker.postMessage({ pdfBytes }, [pdfBytes.buffer]); // 转移,不是拷贝
// 注意:转移后 main 线程不能再访问 pdfBytes

不是每个功能都需要 Worker。5MB 以下的文件主线程处理反而更快,少了通信开销。最终方案是小于 10MB 走主线程,大于 10MB 走 Worker。

坑 3:批量处理的进度条在“撒谎”

症状:批量压缩 20 个 PDF,进度条显示 100%,但浏览器还在转圈,用户以为卡死,疯狂点下载。

原因:进度条只统计了“处理完的文件数”,但最后一个文件处理完后还要用 JSZip 生成 ZIP 包,这个操作很耗时,进度条却已经到 100%。

解法:进度条分阶段——阶段 1 读取文件(20%);阶段 2 逐个处理(20%~80%);阶段 3 生成 ZIP(80%~95%);阶段 4 触发下载(95%~100%)。

用 requestAnimationFrame 更新进度:

function updateProgress(percent) {
requestAnimationFrame(() => {
progressBar.style.width = percent + '%';
});
}

批量处理时,每处理完一个文件用 await new Promise(r => setTimeout(r, 0)) 让出主线程,不然进度条根本不会动。

坑 4:Google 只收录了首页

症状:上线一周,Google Search Console 显示只收录了首页。

原因:Vue 3 SPA 的 HTML 只有一个 ,Googlebot 抓取时看不到内容。

解法:上 Playwright Prerender,构建时用 Playwright 把每个路由都渲染成静态 HTML:

// prerender.mjs
for (const route of routes) {
const page = await context.newPage();
await page.goto(`http://localhost:${PORT}${route}`, { waitUntil: 'networkidle' });
// 关键:等 Vue 真正把内容渲染出来
await page.waitForFunction(() => {
const app = document.getElementById('app');
return app && app.innerHTML.length > 100;
}, { timeout: 15000 });
const html = await page.content();
fs.writeFileSync(outputPath, html);
}

坑 5:Prerender 抓到的页面 canonical 全指向首页

症状:修复 SPA 空 div 后,Google 仍只收录首页。GSC 的 URL Inspection 显示 /split/ 页面的 canonical 是 https://en.sotool.top/。

原因:index.html 模板里硬编码了 ``,prerender 时没更新。Google 认为所有页面都是首页的重复内容。

解法:在 Vue Router 的 afterEach 里动态更新 canonical:

router.afterEach((to) => {
const canonicalUrl = to.path === '/' ? 'https://en.sotool.top/' : `https://en.sotool.top${to.path}/`;
let el = document.querySelector('link[rel="canonical"]');
if (!el) {
el = document.createElement('link');
el.setAttribute('rel', 'canonical');
document.head.appendChild(el);
}
el.setAttribute('href', canonicalUrl);
});

坑 6:Cloudflare Pages 的 308 重定向

症状:GSC 报告 /split 页面有 “redirect error”。浏览器访问 /split 会自动跳到 /split/,看起来正常,但 Googlebot 报错。

原因:Cloudflare Pages 对目录型页面默认开启 trailing slash(/split → 308 → /split/),而 sitemap.xml 里写的是不带斜杠的 /split。

解法:sitemap.xml 全部改成带斜杠;所有内部链接统一带斜杠;canonical 也带斜杠。三处统一后 GSC 的 redirect error 消失。教训:不要和托管平台对着干。

坑 7、坑 8:内容和推广

早期写 “How to build X” 的纯技术教程,阅读量不到 30,改成“我踩的坑”之后有流量。

工具目录大多付费:Futurepedia $247/年、Toolify.ai 付费、AlternativeTo 账号太新被拒,只有 Product Hunt 和 SaaSHub 免费收录成功。最后改走内容营销:SEO 长文、技术社区、Build in Public。

技术栈与项目信息

Vue 3 + TypeScript + Tailwind CSS;pdf-lib + PDF.js;Vite + Playwright Prerender;Cloudflare Pages;GA4 + Search Console。

项目开源在 GitHub,在线体验:中文版 sotool.top,英文版 en.sotool.top。

复制全文 生成海报 Vue 3 pdf-lib Web Worker 预渲染 SEO

推荐文章

程序员茄子在线接单