花两周用 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...

在线体验:

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

相关推荐
风骏时光牛马1 小时前
TypeScript 泛型与工具类型实战:企业级通用数据请求封装完整案例
前端
卤蛋fg61 小时前
使用 vxe-table 树表格实现产品列表与明细关联展示
vue.js
阿猫的故乡1 小时前
Vue自定义指令从入门到实用:自动聚焦、权限控制、防抖、懒加载……全案例教学
前端·javascript·vue.js
嘟嘟07171 小时前
吃透 JS 八大数据类型与内存原理,从代码到底层一站式复习
前端
问心无愧05131 小时前
ctf show web入门157 158
前端·笔记
该用户已成仙1 小时前
vue3 使用 vuedraggable 报错 TypeError: isFunction2 is not a function
前端·javascript·vue.js
aidou13141 小时前
Kotlin中实现星级评价选择功能(仅支持整数)
前端·kotlin·自定义view·imageview·ontouchevent·customratingbar
良逍Ai出海1 小时前
我用 Codex 搭了一个 WordPress 独立站
前端
TPBoreas1 小时前
前端面试问题打把-场景题
开发语言·前端·javascript