花两周用 Vue 3 做了个 PDF 工具站,我在生产环境踩了 8 个坑

两周前我觉得在线 PDF 工具都要上传文件到服务器,太不安全了,于是想做一个纯浏览器端处理的工具站。

现在站点上线了,20+ 个功能,Google 刚开始收录,日活不多但每天在涨。回头看,这两周踩的坑比我过去半年都多。

下面这 8 个坑,每个都是真刀真枪在生产环境遇到的。如果你也在做浏览器端文件处理工具,希望能帮你少走点弯路。


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

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

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

我的解法:

不是不用 pdf-lib,而是别让大文件一次性进内存

对于超大文件,我做了两层保护:

  1. 前端文件大小限制:超过 100MB 的文件直接提示"建议用桌面软件处理"
  2. 分片加载 :对于 Image to PDF 这种场景,图片是逐个嵌入的,每处理完一张就 imageBytes = null 手动释放
csharp 复制代码
// 错误做法:20MB 的 ArrayBuffer 一直占着内存
const imageBytes = await file.arrayBuffer(); // 20MB
const image = await pdfDoc.embedJpg(imageBytes); // 又拷贝一份
// 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 秒。

原因: Web Worker 和主线程之间传数据需要序列化/反序列化。PDF 的 Uint8ArraypostMessage 时会被拷贝(structured clone)。一个 50MB 的 PDF,光是拷贝就要几百毫秒。

我的解法:

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

ini 复制代码
// 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%,但实际上浏览器还在疯狂转圈,用户以为卡死了疯狂点下载。

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

我的解法:

进度条要分阶段:

erlang 复制代码
阶段 1:读取文件(20%)
阶段 2:逐个处理(20% ~ 80%)
阶段 3:生成 ZIP(80% ~ 95%)
阶段 4:触发下载(95% ~ 100%)

代码里用 requestAnimationFrame 更新进度,不要用 setState 狂刷:

ini 复制代码
function updateProgress(percent) {
  requestAnimationFrame(() => {
    progressBar.style.width = percent + '%';
  });
}

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


坑 4:Google 只收录了首页,其他页面全"消失"了

症状: 上线一周,Google Search Console 显示只收录了首页。我 site:en.sotool.top 搜了一下,确实只有首页。

原因: Vue 3 SPA 的 HTML 只有一个 <div id="app"></div>,Googlebot 抓取时看不到任何内容。

我的解法:

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

javascript 复制代码
// 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);
}

部署后 Google 终于能抓取到内容了。但紧接着我跳进了更大的坑...


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

症状: 修复了 SPA 空 div 问题后,Google 仍然只收录首页。用 GSC 的 URL Inspection 一看,/split/ 页面的 canonical 竟然是 https://en.sotool.top/

原因: index.html 模板里硬编码了 canonical:

ini 复制代码
<link rel="canonical" href="https://en.sotool.top/" />

Prerender 时虽然渲染了页面内容,但没有更新 canonical。结果 Google 认为 /split//compress//merge/ 等所有页面都是首页的重复内容,只保留了首页的索引。

我的解法:

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

ini 复制代码
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);
});

这样每个页面的 canonical 都指向自己,Google 才知道"这些是不同的页面,都值得收录"。


坑 6:Cloudflare Pages 的 308 重定向把我搞懵了

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

原因: Cloudflare Pages 对目录型页面默认开启 trailing slash(/split → 308 → /split/)。这本身是对的,但我之前的 sitemap.xml 写的是 /split(不带斜杠),Googlebot 抓 sitemap 里的 URL 收到 308,然后处理重定向时出了问题。

我的解法:

  1. sitemap.xml 全部改成带斜杠/split/ 而不是 /split
  2. 所有内部链接统一带斜杠 :导航栏的 <a href="/split/">
  3. canonical 也带斜杠:和实际 URL 完全一致

三个地方统一后,GSC 的 redirect error 消失了。

这个坑的教训是:不要和托管平台对着干。Cloudflare Pages 要 trailing slash,那就全站统一 trailing slash,不要有的地方有、有的地方没有。


坑 7:花了 3 天写的技术文章,阅读量不到 30

症状:Dev.to 和 Blogger 发了好几篇技术文章,结果每篇阅读不到 30。我感觉自己在写"技术日记",不是在推广。

原因: 我早期写的都是"How to build X"的纯技术教程,这类内容在搜索引擎上竞争不过大厂文档,在社交媒体上又太硬核没人转。

我的解法:

调整了内容策略,从"教别人怎么做"改成"分享我踩的坑":

之前(没人看) 之后(有流量)
How to merge PDFs with pdf-lib 5个线上真实问题的解决方案
Vue 3 SPA SEO guide 我的预渲染实战记录(带踩坑)

数据证明, "避坑记录"比"教程"更有传播性。因为开发者更愿意转发"这个坑我遇到过",而不是"这个教程我看完了"。

另一个发现:Blogger 上"Compress PDF"和"Image to PDF"类文章的阅读量最高,因为搜索这些词的人直接有需求。


坑 8:工具目录全是付费的,免费推广渠道比想象中少

症状: 我列了 10 个工具目录(Futurepedia、AlternativeTo、Product Hunt 等),结果:

  • Futurepedia:$247/年
  • Toolify.ai:付费
  • AlternativeTo:账号太新被秒拒
  • 只有 Product Hunt 和 SaaSHub 成功免费收录

我的解法:

放弃了"广撒网"策略,专注做内容营销

  1. SEO 长文:Blogger/掘金/Dev.to 持续发文章,带自然外链
  2. 技术社区:掘金、知乎(被禁言中...)、Dev.to
  3. 社交媒体:X/LinkedIn 发 Build in Public 内容

内容营销的好处是复利效应:一篇文章发了永远在,搜索引擎会源源不断地带来流量。而工具目录提交是一次性的,过了就过了。


数据复盘(截至 6 月 10 日)

渠道 动作 效果
Google SEO + Prerender 刚修复 canonical,等待重新收录
掘金 5 篇技术文章 2 篇爆款(2900+ 展现),3 篇一般
Blogger 9 篇文章 Compress 类文章阅读最高
Dev.to 7 篇技术教程 流量一般,但外链质量好
Product Hunt 已发布 带来一些初期流量
CJ Affiliate 申请 Wondershare 待审批

技术栈总结

  • 前端: Vue 3 + TypeScript + Tailwind CSS
  • PDF 处理: pdf-lib + PDF.js
  • 构建: Vite + Playwright Prerender
  • 部署: Cloudflare Pages
  • 分析: Google Analytics 4 + Search Console

最后

这个项目开源在 GitHub,欢迎 star 和提 issue:

👉 github.com/sunshey/pdf...

在线体验:

如果你也在做浏览器端文件处理工具,欢迎在评论区交流踩坑记录。坑踩得越多,上线后睡得越香。

相关推荐
小徐_23336 小时前
Wot UI 2.2.0 发布:Button 新增 subtle,VideoPreview 预览体验继续增强
前端·微信小程序·uni-app
天蓝色的鱼鱼9 小时前
关于 CSS 你可能不知道的属性,但关键时刻很有用
前端·css
泯泷9 小时前
第 2 篇:设计第一套字节码:Opcode、Instruction 与 Constant Pool
前端·javascript·安全
妙码生花9 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十五):优化细节、网络请求封装
前端·后端·ai编程
泯泷9 小时前
第 1 篇:从 1 + 2 开始:亲手写出第一台 JSVM
前端·javascript·安全
团团崽_七分甜9 小时前
Spring Boot 核心知识点总结
前端
lichenyang45310 小时前
从一个按钮开始,理解 ASCF 框架到底在做什么
前端
古夕10 小时前
第三方 SSO 接入实践:redirect_uri 编码、回调一致性与跨项目联调
前端·vue.js
朦胧之10 小时前
页面白屏卡住排查方法
前端·javascript