我的浏览器书签栏里有个文件夹,名字很随意,叫「乱七八糟」,里面塞了四十多个网址。同事看到过一次,问我是不是在收集什么资源站,我说不是,这些全是一些奇怪的在线工具——有的能把一段文字里看不见的字符揪出来,有的能把一张图压到指定体积,有的专门生成假数据喂给测试环境。它们大多长得丑,没有登录入口,没有会员体系,甚至连个像样的首页都没有,打开就是两个文本框加一个按钮。但就是这些东西,在过去几年里帮我省下的时间,比很多正经软件加起来还多。这篇就聊聊这类在线工具到底是怎么分类的、背后靠什么技术在跑、哪些坑我踩过,以及如果你想自己做一个,从零到能用大概需要哪些东西。不管你是刚入行的新手,还是已经能看懂源码的老手,应该都能从里面拿走点东西。
1. 我为什么会攒一堆「没什么用」的在线工具
1.1 起因是一份导不进去的表格
印象很深,早几年做数据整理,从一份文档里复制了几千行数据贴进表格软件,导入一直报错,提示列数不匹配。我盯着屏幕看了半小时,每一行肉眼看起来都一模一样,行列对齐得好好的。后来把单行贴进一个显示不可见字符的小页面,才发现每行末尾都挂着一个零宽字符,那是原文排版时留下的。问题两分钟解决,但前面浪费了两个小时。
那次之后我就形成了一个习惯:遇到「肉眼没问题但机器不认」的情况,先怀疑不可见字符和编码,再怀疑业务逻辑。也是从那时候开始,我会有意识地收集这类只解决单一问题的小工具。它们的共同点是——你一年可能只用三次,但用的时候如果没有,就只能手搓脚本,而手搓脚本的时间往往是使用工具时间的几十倍。
这类工具解决的其实不是「功能」问题,而是「摩擦」问题。它们把一件小事从「打开编辑器写二十行代码」压缩成「粘贴、点击、复制」,中间的认知负担被彻底抹掉了。
1.2 我筛工具的四条硬标准
收藏得多了,自然会有筛选。我现在看到一个在线工具,先看四件事,不符合的直接关掉。
第一条,是否需要注册。需要注册的一律放弃,哪怕功能再强。一个只做一件事的工具,让我填邮箱、收验证码、设密码,这个交互成本已经超过了我手写脚本的收益。
第二条,数据是否离开本机。这条最重要。判断方法很简单:打开浏览器开发者工具,切到网络面板,勾上「保留日志」,然后完整操作一遍。如果除了页面本身的静态资源,没有任何请求带着你的输入内容出去,那基本可以认为它是纯前端处理的。如果看到有请求把整段文本塞进请求体,那就要掂量了。在企业内网环境下,这条几乎是生死线。
第三条,能不能离线用。很多这类工具本质就是一个 HTML 文件加一段 JavaScript,完全可以保存到本地双击打开。我会优先选那种把逻辑全写在页面里的,保存下来之后断网也能跑,等于给自己留了个永久备份。
第四条,输入输出是否明确。好的小工具一定有一个清晰的「进」和「出」,中间不需要配置一堆选项。如果一个工具打开之后有十几个折叠面板和三四十个开关,那它已经不是「奇怪的小工具」了,而是个正经软件,那我不如用正经软件。
注意:判断「纯前端」不能只看它宣传页怎么写。有些页面静态文本写得很漂亮,实际交互时照样把内容发出去。只信网络面板,不信文案。
2. 这些奇怪工具背后的共性技术拆解
2.1 浏览器到底能扛多少活
很多人对浏览器的认知还停留在「渲染页面」,实际上现代浏览器已经是一个相当完整的运行时。我梳理了一下最常见的一批能力,以及它们对应能做出什么类型的工具,这张表基本就是我收集夹的分类依据。
| 浏览器能力 | 具体接口 | 能撑起的工具类型 |
|---|---|---|
| 文件读写 | File API、Blob、FileReader | 格式转换、压缩、合并拆分 |
| 图形绘制 | Canvas 2D、OffscreenCanvas | 取色、缩放、水印、格式互转 |
| 密码学 | Web Crypto API | 哈希计算、随机数生成、加解密演示 |
| 文本编解码 | TextEncoder / TextDecoder | 编码转换、乱码修复、字符集识别 |
| 后台计算 | Web Worker | 大文件处理、批量任务不卡界面 |
| 本地存储 | IndexedDB、Cache API | 历史记录、离线可用 |
| 剪贴板 | Clipboard API | 一键复制、截图粘贴 |
| 流式处理 | Streams API | 超大文件分块读取 |
看懂这张表,你就能明白为什么这类工具大多活得很好——它们的计算量本来就小,浏览器完全够用,把数据传到服务器再传回来反而是多余的一步。一个 200KB 的文本做字符替换,在前端跑是毫秒级的事,走一趟网络反而要几百毫秒。
2.2 三个最容易被低估的接口
第一个是Blob 和 URL.createObjectURL。生成下载链接的标准做法是URL.createObjectURL(blob)拿到一个临时地址塞给<a>的 href,点击触发下载。这比FileReader.readAsDataURL好得多,因为后者要把整个文件编码成 Base64 字符串塞进内存,一个 50MB 的文件编码完会变成 67MB 左右的字符串,加上原始数据,内存直接翻倍。而 createObjectURL 只是个引用,几乎不占额外内存。
但这里有个必须记住的坑:createObjectURL 生成的地址不会自动释放,必须手动调URL.revokeObjectURL(url)。我在一个批量图片压缩的页面上忘了这一步,连续处理两百张图之后页面直接卡死,排查了半天才发现是内存泄漏。正确写法是下载触发之后立刻释放,或者用setTimeout延迟一小会儿再释放。
第二个是TextDecoder。很多人不知道浏览器原生支持多种字符集解码,new TextDecoder('gbk')是有效的。这意味着你可以在前端直接读取一份老式编码的文本文件并转成 UTF-8,不需要任何后端。这个能力撑起了一整类「乱码修复」工具。
第三个是crypto.subtle。它是异步的,返回 Promise,很多人第一次用会写成同步调用然后拿到 undefined。好处是它在安全上下文里可用,算哈希的速度也够快,用来做文件校验、去重完全没问题。
// 计算文件哈希,用于去重或校验 async function sha256(buffer) { const digest = await crypto.subtle.digest('SHA-256', buffer); return Array.from(new Uint8Array(digest)) .map(b => b.toString(16).padStart(2, '0')) .join(''); }这三个接口组合起来,已经能覆盖我收集夹里一大半工具的核心逻辑了。
3. 五类高频怪工具的实操拆解
3.1 文本类:把看不见的东西揪出来
这类工具我用得最多,核心就三件事:清理不可见字符、做批量替换、做格式转换。
清理不可见字符的正则我一般这么写:
const INVISIBLE = /[\u200B-\u200D\uFEFF\u00A0\u3000]/g; const cleaned = text.replace(INVISIBLE, '');这里面每一个都有实际来历。\u200B到\u200D是零宽空格、零宽非连接符、零宽连接符,从网页复制文本时特别喜欢带出来;\uFEFF是 BOM,在文件开头出现会让某些解析器把第一列字段名读成乱码;\u00A0是不换行空格,肉眼和普通空格完全一样,但字符串比较时永远不相等,这个坑我在做数据比对时踩过不止一次;\u3000是全角空格,中文排版里常见,做英文标识符清洗时要一并处理。
除了清理,还有一类是格式转换。比如把一堆变量名在驼峰、下划线、短横线、大写常量之间来回切换,或者是给一段文字加上行号、去掉重复行、按长度排序。这类操作写脚本也就几行,但在浏览器里粘贴即用,体验完全不同。
我个人的经验是:别在这些工具里直接改原文。永远保持「左边原文、右边结果」的双栏布局,改完复制走,原文不动。因为有一次我在一个单栏工具里直接编辑,手滑按了全选删除,那段文字又没保存,只能重新找来源。
3.2 图像类:浏览器里的取色、压缩与格式互转
图像类工具是另一大类。核心能力全是 Canvas 给的:把图片画到画布上,用getImageData拿到像素数组,剩下的就是纯计算。
取色器的原理简单到有点无聊——监听鼠标在画布上的位置,读出对应像素的 RGBA 值,转成十六进制。真正需要动脑筋的是缩放适配:一张 4000 像素宽的图,画布只有 600 像素宽,鼠标坐标要按比例换算回原始图像坐标。我一般这么算:
const scale = naturalWidth / canvas.clientWidth; const realX = Math.floor(event.offsetX * scale); const realY = Math.floor(event.offsetY * scale);压缩就更有意思了。Canvas 的toBlob第二个参数是质量,范围 0 到 1,只对 JPEG 和 WebP 有效,PNG 会忽略这个参数。这里有个实用技巧:如果你要实现「压到指定体积以下」,不要靠猜,用二分法迭代。先试 0.8,看结果大小,再根据偏大偏小调整区间,通常迭代五六次就能逼近目标值。
async function compressToTarget(canvas, targetBytes) { let lo = 0.05, hi = 0.95, best = null; for (let i = 0; i < 8; i++) { const q = (lo + hi) / 2; const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', q)); if (blob.size <= targetBytes) { best = blob; lo = q; } else { hi = q; } } return best; }这段代码我用了很多次,实测比按固定比例压缩靠谱得多,尤其面对不同内容复杂度的图片,同样的质量参数压缩率能差三四倍。
注意:用 Canvas 处理来自其他域名的图片时,画布会被标记为「污染」,之后调用 getImageData 或 toBlob 都会抛安全错误。解决办法是让图片以跨域方式加载并带上 CORS 响应头,或者干脆让用户本地选图——后者更省事,也更符合「不上传」的原则。
还有个细节值得说:EXIF 方向信息。手机拍的照片,像素数据经常是横着的,靠 EXIF 里的方向标记告诉查看器旋转 90 度。直接在 Canvas 里画会丢掉这个标记,结果压缩完图是躺着的。处理办法是读取 EXIF 的 Orientation 字段,在绘制前用ctx.rotate手动转正。这个坑几乎所有自己写压缩工具的人都踩过。
3.3 编码转换类:Base64、URL 编码和哈希
这类工具的使用频率不高,但每次用都是救命的。
Base64 最经典的坑是中文。btoa()只接受 Latin-1 范围内的字符,传入中文直接抛InvalidCharacterError。正确姿势是先编码成字节再转 Base64:
function toBase64(str) { const bytes = new TextEncoder().encode(str); let binary = ''; bytes.forEach(b => binary += String.fromCharCode(b)); return btoa(binary); }反向解码同理,先用atob拿到二进制字符串,再塞进 Uint8Array 用 TextDecoder 解回文本。这一段我建议直接背下来,因为几乎每个涉及 Base64 的项目都会撞上。
URL 编码的坑在于编码范围。encodeURI不编码&、=、?、/这些保留字符,适合处理整个 URL;encodeURIComponent会把它们全编码掉,适合处理参数值。用错了,参数里带个&就会把后面的内容截断成新参数,这种 bug 排查起来特别费劲,因为看起来「有时候对有时候错」,其实是参数内容决定的。
哈希类的工具我主要用来做文件比对。两个文件大小一样、名字不一样,内容到底一不一样?算个 SHA-256 对比一下,五秒钟出结果。前端算哈希的好处是不用上传文件,几个 G 的文件也能边读边算——用Blob.slice分块读进crypto.subtle.digest,虽然 subtle 本身不支持流式增量,但分块之后逐块累积也能做,只是要注意不能直接拼哈希结果,得换成支持增量的实现。
3.4 生成器类:假数据、占位图和二维码
这一类严格说不算「处理」,而是「凭空造」。
假数据生成器在联调阶段特别有用。测试环境里一列表格里全是「测试1、测试2、测试3」,翻页翻到第十页还在想「这到底是第几页的数据」。如果生成的是像模像样的姓名、地址、时间戳,一眼就能看出分页对不对。这类工具的核心就是一个词库加一个随机函数,实现成本极低,但收益很直接。
占位图生成器更有意思,最轻量的实现根本不需要图片文件,直接拼一个 SVG 的 data URI:
function placeholder(w, h, text) { const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"> <rect width="100%" height="100%" fill="#e8e8e8"/> <text x="50%" y="50%" fill="#888" font-size="20" text-anchor="middle" dominant-baseline="middle">${text}</text> </svg>`; return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }注意这里用的是encodeURIComponent而不是 Base64,好处是可读、体积小,缺点是如果文本里有特殊字符必须编码干净,否则 SVG 会解析失败。
二维码生成也是同理,本质是把一串数据按规范编码成黑白点阵。这块如果想自己写,工作量不小,涉及纠错码的数学。我的建议是别硬啃,用成熟实现,把精力放在「批量生成」「扫码后自动跳转」这类周边体验上。
3.5 时间与坐标类:跨时区、跨坐标系的换算
时间戳转换器看起来最不起眼,但我几乎每周都用。原因很简单:日志里全是毫秒时间戳,肉眼完全无法判断先后和间隔。粘进去转成可读时间,一眼就能看出某两个事件是不是同一秒发生的。
这类工具最容易出错的地方是时区。浏览器默认用本机时区,同一个时间戳在不同机器上显示的结果不一样。做工具时要明确标注当前用的是哪个时区,最好提供一个切换。我见过一个内部工具因为没有标注时区,导致两个团队对同一份日志的理解差了八小时,讨论了一下午才发现是环境不同。
坐标转换是另一个小众但刚需的场景。不同地图服务用的坐标系不一样,同一个点在不同底图上的位置能差出几百米。这类换算有公开的数学公式,前端实现完全可行,属于典型的「公式不难但没人愿意自己写一遍」的工具。
4. 手搓一个:把「隐形字符清理器」做出来
4.1 需求收敛与页面结构
聊完分类,动手做一个最有代表性的。我选择隐形字符清理器,因为它足够小、足够典型,能覆盖前面提到的绝大部分技术点。
先把需求收死:左边输入,右边输出,实时显示清理了多少个字符,一键复制,一键下载为 txt 文件。不做登录、不做历史记录、不做云同步、不引入任何第三方库。整个东西就是一个 HTML 文件,双击能开,断网能用。
页面结构就三块:一个双栏布局的容器,两个 textarea,底部一行操作按钮和统计信息。样式用原生 CSS 写几十行就够了,不需要框架。这里我要强调一点:小工具不要引入构建工具。一旦上了打包,你就得维护依赖、跑构建、处理版本,本来十分钟能写完的东西变成半天。原生写,一个文件扔那儿,两年后回来还能跑。
4.2 核心逻辑实现
清理逻辑本身很简单,关键在于「实时」这两个字。如果给 textarea 绑 input 事件然后每次都全量替换,文本一长(比如十万行)就会明显卡顿,因为每次输入都触发一次完整的正则替换加 DOM 更新。
我的做法是加一层防抖,两百毫秒内没有新输入才执行:
function debounce(fn, wait) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; } const DECORATIVE = /[\u200B-\u200D\uFEFF]/g; const SPACES = /[\u00A0\u3000]/g; const process = debounce((raw) => { const out = raw.replace(DECORATIVE, '').replace(SPACES, ' '); const removed = raw.length - out.length; stats.textContent = `清理 ${removed} 个字符,剩余 ${out.length} 个`; output.value = out; }, 200);这里我把两类字符分开替换,是有意为之。零宽字符是直接删掉,因为它本来就不可见;而全角空格和不换行空格替换成普通空格,因为它们在语义上就是一个空格,直接删掉会让「张三 李四」变成「张三李四」。这个区别很小,但决定了工具是「能用」还是「好用」。
下载功能用 Blob 实现:
function download(text, filename) { const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }文件名我建议带时间戳,比如cleaned-20240115-1432.txt,避免多次导出互相覆盖。另外type里显式写上charset=utf-8,能减少某些编辑器打开时的乱码概率。
4.3 大文本的处理与体积控制
上面这套代码处理几千字毫无压力,但如果粘贴进来的是几十万行,textarea 本身就会成为瓶颈——DOM 节点存那么长的字符串,渲染和光标定位都会拖慢。
我的处理策略是设一个阈值:超过五十万字符时,关闭实时预览,改成手动点「开始处理」。这样至少用户知道慢是预期的,而不是页面卡死了。同时把处理逻辑放进 Web Worker,主线程只负责接收结果,界面不会假死。
Worker 那边的写法要注意:不能直接传 DOM 引用,只能传字符串。用postMessage传原始文本进去,Worker 处理完再postMessage传回来。文本特别长的时候,postMessage的序列化成本也不低,可以考虑用Transferable Objects传 ArrayBuffer,不过对纯文本来说,先做防抖加阈值控制,收益比已经足够。
体积方面,我这个工具最终的 HTML 文件不到 15KB,不压缩。这个体积意味着它可以塞进邮件、塞进文档附件、塞进任何地方,随时可用。这就是「单文件工具」的价值所在——它不依赖任何环境,本身就是一个完整的环境。
5. 常见问题与排查实录
下面这张表是我这些年自己写和用这类工具时,反复撞上的问题清单。每一条都对应真实排查过的场景。
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 中文传给 btoa 直接报错 | btoa 只支持 Latin-1 | 先 TextEncoder 转字节再逐字节转字符 |
| 压缩完的图躺着 | Canvas 绘制丢失 EXIF 方向 | 读取 Orientation 后手动旋转 |
| 处理几十张图后页面卡死 | createObjectURL 未释放 | 用完立刻 revokeObjectURL |
| 读取图片像素报安全错误 | 画布被跨域图片污染 | 改用本地选图,或配置 CORS 头 |
| 正则判断总是「隔一次错一次」 | 带 g 标志的 test 会推进 lastIndex | 判断场景不要用 g,或每次重置 lastIndex |
| 读取老文件中文全是乱码 | 文件实际是 GBK 编码 | TextDecoder 指定 gbk 解码后再转 UTF-8 |
| 下载的文件名变成乱码 | 文件名未做 URI 编码 | 用 encodeURIComponent 处理文件名 |
| 复制的按钮点了没反应 | 非安全上下文或缺少用户手势 | 必须在点击事件内同步调用剪贴板接口 |
| 输入框粘贴后界面假死 | 全量替换 + 长文本渲染 | 加防抖,超阈值改为手动触发 |
另外有两条表中没写、但我觉得值得单独拎出来的经验。
第一条关于正则的 lastIndex。带g标志的正则在调test()时会记住上次匹配的位置,下一次从那里继续找。所以在循环或者重复判断里用同一个正则对象,结果会诡异地在 true 和 false 之间跳。这个坑非常隐蔽,因为单次调用永远是对的。要么在判断场景用不带 g 的版本,要么每次判断前手动把regex.lastIndex = 0。
第二条关于剪贴板权限。navigator.clipboard.writeText()必须在用户手势的同步调用栈里执行,如果放在await之后、或者setTimeout里,浏览器会认为这不是用户主动触发的,直接拒绝。有些浏览器还会要求页面处于安全上下文。所以复制按钮的写法是:点击事件里直接调,不要前面的异步操作挡着。
提示:如果只需要复制纯文本,其实可以用更古老的
document.execCommand('copy')配合临时 textarea 作为兜底方案,兼容范围更广,代价是多十几行代码。在主方案失败时降级,是很务实的做法。
6. 判断一个在线工具值不值得信
聊到最后,说点偏判断层面的东西。这些工具的特点是「用完就走」,正因为如此,你很难对它建立信任。我现在的判断流程大概分三步,走完基本能有个结论。
第一步,看它是不是一个文件。如果能找到只包含一个 HTML 的版本,或者能把它保存下来断网打开还能用,那它的可信度立刻上一个档次——因为代码全在你手里,你甚至可以自己读一遍。反过来,如果一个工具打开之后加载了几十个脚本文件,还引用了一堆外部统计,那它做的事很可能比你看到的多。
第二步,看网络面板。这是最硬的证据。清空日志,粘贴一段无关紧要的测试文本,点执行,看有没有请求带着这段文本出去。只要输入内容出现在了请求体里,不管它宣传什么,都算上传。这一步花不了一分钟,但能避免很多麻烦。
第三步,看它有没有留下痕迹。有些工具会在本地存储里塞一大堆东西,或者在地址栏里带上你输入过的内容。地址栏带内容这一点尤其要注意,很多人习惯性地把带参数的链接分享到群里,等于把数据公开了。
反过来,我自己做工具的时候也会遵守对应的原则:输入不上传、结果不落在 URL 里、本地不留存、关掉页面就当没发生过。这几条听起来像是自我约束,其实也是这类工具最核心的竞争力——它让人觉得安全,是因为它真的什么都没做。
有个小技巧我一直在用:把常用的这类单文件工具统一存到一个目录里,加一个最简单的索引页,纯静态的 HTML 列表,点进去就是对应工具。这个目录可以放在本地,也可以扔在任何静态托管上。这样既保留了「在线打开」的便利,又保证了断网、内网、临时离线状态下随时可用。我那个索引页现在已经列了三十多个条目,每个都只有一行说明,写清楚「输入什么、输出什么」,时间久了也不会忘。
最后分享一个习惯:每做完一个这种小工具,我会在文件顶部留三行注释——这工具解决什么问题、输入输出是什么、哪一步最容易出错。这三十多个文件里,有几行注释我已经回头看过很多次了,每次都比重新读一遍代码快得多。工具会过时,但那三行字通常不会。