最近我在开发一个在线工具站。项目叫 ToolExo,目前公开页面中包含计算器、开发者工具、图片处理和 PDF 工具等多个类别。
工具站看起来简单:输入内容,点击按钮,返回结果。但工具数量增加以后,问题很快就不只是“再写一个表单”了。
我需要处理几个相互关联的问题:
- 用户输入应该在哪里计算?
- 图片、PDF和文本是否必须上传服务器?
- 如何防止某个复杂任务卡住整个页面?
- 一百多个工具如何共用页面结构,同时保持各自逻辑独立?
- Next.js应用部署到Cloudflare Workers后,动态内容和缓存如何配合?
这篇文章记录目前采用的方案,以及其中几处比较实际的取舍。
一、先划定数据边界:能在浏览器完成,就不上传
这个项目最早确定的一条规则是:适合在浏览器完成的任务,默认在浏览器处理。
比如:
- JSON格式化
- 正则表达式测试
- Base64编解码
- UUID和密码生成
- 图片压缩与尺寸调整
- PDF合页面拆分、合并和旋转
- 普通数学计算
这类操作没有必要先把数据发给服务器,再把结果传回来。
以JSON格式化为例,完整流程可以留在当前页面中:
export function processJson(
input: string,
options: {
mode: "format" | "minify" | "validate";
indent?: "2" | "4" | "tab";
sortKeys?: boolean;
},
) {
if (input.trim().length === 0) {
return {
ok: false,
message: "请先输入 JSON",
};
}
if (input.length > 1_000_000) {
return {
ok: false,
message: "输入内容超出当前工具限制",
};
}
try {
const value = JSON.parse(input);
return {
ok: true,
output:
options.mode === "minify"
? JSON.stringify(value)
: JSON.stringify(value, null, 2),
};
} catch (error) {
return {
ok: false,
message:
error instanceof Error
? error.message
: "JSON 解析失败",
};
}
}
这里没有API请求,也没有数据库写入。输入、解析和输出都发生在浏览器中。
下载结果时同样可以使用浏览器提供的Blob URL,不需要先生成服务器文件:
function downloadJson(value: string) {
const blob = new Blob([value], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "formatted.json";
link.click();
URL.revokeObjectURL(url);
}
实际版本还限制了输入长度、JSON嵌套深度和用于展示的节点数量。限制不是为了制造功能缺口,而是为了防止一次异常输入耗尽浏览器内存。
可以在JSON Formatter中看到这套交互。
二、本地处理不等于把所有任务塞进主线程
文件处理或文本分析放在浏览器里,还有一个常见问题:JavaScript执行时间太长时,页面会失去响应。
例如,一个设计不当的正则表达式可能产生大量回溯。如果直接在React事件处理中运行,输入框、按钮和页面滚动都会受到影响。
因此,正则测试器没有直接在主线程里执行表达式,而是为每次任务创建一个Web Worker:
function createRegexJob(request: RegexRequest) {
const worker = new Worker(
new URL("./regex-worker.ts", import.meta.url),
{
type: "module" },
);
const timer = setTimeout(() => {
worker.terminate();
}, 500);
worker.postMessage({
id: crypto.randomUUID(),
request,
});
worker.addEventListener("message", (event) => {
clearTimeout(timer);
worker.terminate();
console.log(event.data.result);
});
}
上面的代码经过了简化。项目中的实际实现还处理了:
- Worker启动失败
- 用户连续修改表达式时取消旧任务
- 零长度匹配
- Unicode模式
- 匹配数量上限
- 替换结果长度限制
u和v标志互斥- 潜在高回溯结构提示
Web Worker可以在与主线程分离的执行环境中运行脚本,适合处理可能占用较长时间的任务。不过Worker不能直接操作DOM,主线程和Worker之间需要通过消息传递数据。MDN Web Worker文档
)
这里有一个容易忽视的细节:Web Worker只能隔离任务,不能自动解决正则表达式拒绝服务问题。
如果Worker里的表达式一直运行,浏览器仍然会消耗CPU。项目因此设置了500毫秒的执行上限,到时直接终止Worker。这个限制比较保守,但在线测试工具首先要保证页面不会被一个表达式拖死。
对应实现可以在Regex Tester中体验。
三、服务器负责交付页面,不参与不必要的计算
前端本地计算并不代表整个网站只能做成纯静态站点。
项目当前采用的主要技术包括:
Next.js
React
TypeScript
Cloudflare Workers
OpenNext Cloudflare适配层
Cloudflare R2
Durable Objects
Neon PostgreSQL
职责大致分为三层。
浏览器
浏览器负责适合本地运行的工具逻辑:
输入
↓
边界校验
↓
本地计算或Web Worker
↓
结果展示
↓
用户主动复制或下载
工具输入默认不会因为计算而进入数据库。
Next.js应用
Next.js负责:
- 页面路由
- 服务端和静态页面生成
- Metadata
- canonical
- 结构化数据
- 分类和相关推荐
- 文章页面
- 管理端服务逻辑
工具的计算引擎一般写成独立TypeScript模块。React组件处理界面状态,计算引擎处理输入规则和业务逻辑。
这种分离带来的直接好处是:计算引擎不依赖DOM,单元测试也不需要启动浏览器。
// engine.ts
export function calculate(input: ToolInput): ToolResult {
// 输入检查和计算
}
// component.tsx
const result = calculate(formValue);
Cloudflare与数据库
Cloudflare Workers负责运行Next.js应用和处理请求。项目当前使用OpenNext的Cloudflare适配方案,并保留R2增量缓存和Durable Object重验证队列。
简化后的配置如下:
import {
defineCloudflareConfig }
from "@opennextjs/cloudflare";
import r2IncrementalCache
from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue
from "@opennextjs/cloudflare/overrides/queue/do-queue";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
queue: doQueue,
enableCacheInterception: true,
});
Neon PostgreSQL主要保存工具目录、文章、发布状态和管理数据,而不是保存用户粘贴到工具里的JSON、文本或文件。
公开页面优先读取缓存内容,避免每次访问都查询数据库。定时发布等后台任务则通过Cloudflare Cron Trigger进入受控流程。
需要说明的是,Cloudflare当前针对新Next.js项目的文档已经把vinext列为默认建议;已有OpenNext项目则可以继续查看其他部署路径。技术选型需要结合项目的现有兼容性和迁移成本,不能只看最新示例就立即替换生产链路。Cloudflare Next.js部署文档 OpenNext Cloudflare文档
四、一百多个工具不能等于复制一百多个组件
批量建设工具页时,最容易出现两种极端。
第一种是所有工具都独立开发。这样自由度高,但页面结构、错误状态、可访问性和SEO设置会逐渐失控。
第二种是所有工具强行套用同一个动态表单。开发速度快,但复杂工具很快会被模板限制,代码里也会出现大量条件判断。
项目最后采用了中间方案:
共享页面外壳
├── 标题和简介
├── 分类导航
├── 隐私提示
├── 使用步骤
├── 方法或公式说明
├── FAQ
├── 相关文章
└── 结构化数据
独立工具模块
├── 输入类型
├── 校验规则
├── 计算引擎
├── 特定交互
├── 结果展示
└── 导出方式
普通计算器可以复用统一页面外壳。JSON、正则、PDF、图片等交互差异较大的工具,则保留独立组件。
共享的是稳定规则,不是业务逻辑本身。
五、本地工具仍然需要安全限制
“没有上传服务器”只能说明一部分隐私边界,不能直接等同于“绝对安全”。
浏览器工具仍然需要处理:
- 超大输入造成的内存占用
- 恶意PDF或异常图片
- 正则表达式高回溯
- CSV公式注入
- 文件名和MIME类型不一致
- 用户输入进入HTML时造成XSS
- Blob URL未及时释放
- 旧任务结果覆盖新任务
- 浏览器能力不完整
项目使用固定输入上限、显式格式检查和任务取消机制。用户输入通过React普通文本节点显示,不把任意输入拼进HTML。
页面还设置了CSP、X-Content-Type-Options、Referrer-Policy和frame-ancestors等响应头。不过这些响应头只能降低部分风险,不能替代输入校验和安全的业务实现。
对于无法稳定在浏览器完成的任务,正确做法也不是悄悄切换到服务器。界面应该先说明:
- 文件是否上传
- 上传到哪里
- 保存多久
- 谁可以访问
- 任务完成后什么时候删除
目前ToolExo仍以浏览器优先处理为主,可以从项目首页查看公开工具分类。
六、实际开发中最容易踩的几个坑
把“本地运行”写成一句宣传语
如果页面宣称文件不会上传,代码里就不能存在隐藏的上传请求、远程分析SDK或自动持久化行为。隐私说明必须能从网络请求和源代码中验证。
只限制文件大小,不限制计算复杂度
一个体积很小的输入也可能造成高CPU消耗,例如嵌套正则、深层JSON或结构异常的压缩文件。长度、深度、节点数、执行时间和输出规模需要分别限制。
为了共用模板而牺牲工具边界
JSON格式化和正则测试都属于开发者工具,但它们的风险模型和交互方式完全不同。共享颜色、按钮和页面结构没有问题,强行共享同一套业务状态通常会让代码更难维护。
只测试正确输入
工具最重要的测试经常不是“1+1是否等于2”,而是:
- 空输入如何处理
- 超限输入是否提前停止
- 浏览器缺少某项能力时是否有明确提示
- 任务被取消后是否仍更新旧结果
- 导出文件是否与页面结果一致
这些测试比增加一段营销文案更能提高工具的可信度。
结语
做在线工具站以后,我对“简单工具”有了新的理解。
公式本身往往不难。真正花时间的是数据边界、异常输入、浏览器兼容、任务取消、结果解释和测试。工具数量增加后,架构的价值也不在于少写几行代码,而在于让新工具继承已有的隐私、安全和质量约束。
项目还在继续完善。后面我准备单独整理浏览器端PDF处理、图片元数据清理,以及正则表达式超时隔离的实现细节。