两周前我觉得在线 PDF 工具都要上传文件到服务器,太不安全了,于是想做一个纯浏览器端处理的工具站。
现在站点上线了,20+ 个功能,Google 刚开始收录,日活不多但每天在涨。回头看,这两周踩的坑比我过去半年都多。
下面这 8 个坑,每个都是真刀真枪在生产环境遇到的。如果你也在做浏览器端文件处理工具,希望能帮你少走点弯路。
坑 1:pdf-lib 加载大文件直接崩
症状: 用户上传一个 80MB 的扫描件 PDF,页面白屏,控制台报 RangeError: Array buffer allocation failed。
原因: pdf-lib 会把整个 PDF 读进内存,用 ArrayBuffer 存。浏览器对单个 ArrayBuffer 有大小限制,而且加载过程中内存会翻倍(原始文件一份 + pdf-lib 内部结构一份)。
我的解法:
不是不用 pdf-lib,而是别让大文件一次性进内存。
对于超大文件,我做了两层保护:
- 前端文件大小限制:超过 100MB 的文件直接提示"建议用桌面软件处理"
- 分片加载 :对于 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 的 Uint8Array 在 postMessage 时会被拷贝(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,然后处理重定向时出了问题。
我的解法:
- sitemap.xml 全部改成带斜杠 :
/split/而不是/split - 所有内部链接统一带斜杠 :导航栏的
<a href="/split/"> - 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 成功免费收录
我的解法:
放弃了"广撒网"策略,专注做内容营销:
- SEO 长文:Blogger/掘金/Dev.to 持续发文章,带自然外链
- 技术社区:掘金、知乎(被禁言中...)、Dev.to
- 社交媒体:X/LinkedIn 发 Build in Public 内容
内容营销的好处是复利效应:一篇文章发了永远在,搜索引擎会源源不断地带来流量。而工具目录提交是一次性的,过了就过了。
数据复盘(截至 6 月 10 日)
| 渠道 | 动作 | 效果 |
|---|---|---|
| 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:
在线体验:
- 中文版:sotool.top
- 英文版:en.sotool.top
如果你也在做浏览器端文件处理工具,欢迎在评论区交流踩坑记录。坑踩得越多,上线后睡得越香。