在线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。
// 错误做法: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,而不是拷贝:
// 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%了。
解决方案:
进度条必须分阶段展示:
阶段 1:读取文件(20%)
阶段 2:逐个处理(20% ~ 80%)
阶段 3:生成 ZIP(80% ~ 95%)
阶段 4:触发下载(95% ~ 100%)
代码里使用 requestAnimationFrame 更新进度,而不是用 setState 频繁刷新:
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只有一个 ,Googlebot抓取时看不到任何实际内容。
解决方案:
采用了 Playwright Prerender。构建时使用 Playwright 把每个路由都渲染成静态HTML:
// prerender.mjs
for (const route of routes) {
const page = await context.newPage();
await page.goto(`:${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:
rel="canonical" href="https://en.sotool.top/" />
Prerender时虽然渲染了页面内容,但没有更新canonical。结果Google认为 /split/、/compress/、/merge/ 等页面都是首页的重复内容,只保留了首页的索引。
解决方案:
在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);
});
这样每个页面的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,然后处理重定向时出现问题。
解决方案:
三个地方统一后,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
最后
踩坑越多,上线后睡得越香。
