第一次把图片转换成 Minecraft 方块的脚本,直接把浏览器标签页卡死了。我丢进一张手机照片,点击转换,页面立刻不再响应点击——没有加载动画,没有滚动,什么也没有。我盯着屏幕想了半天,怀疑自己写了个死循环。

其实没有死循环。它只是严格按照我的指令,在主线程上逐像素地执行任务。

这篇文章想说的是,当我试图修复这个卡顿时,我错得有多离谱——我的第一直觉很自信地指向了错误的方向,真正的性能瓶颈在另一个我根本没注意的地方。

这个问题用一句话就能说清楚:你有一张照片,一个固定的 Minecraft 方块列表,每个方块都有已知的平均颜色。对于输出网格中的每一个格子,你找到颜色最接近的方块,记录下来。

最直接的实现就是两层循环:外层遍历像素,内层遍历调色板,保留最小值。这是教科书式的最近邻搜索,输入规模小的时候完全没有问题。

但把真实输入代入计算一下就不同了。现代手机拍出的照片大约是 4000×3000,也就是 1200 万像素。匹配前当然会缩小图片,因为输出网格可能只有 128 或 256 个方块宽。但解码和重采样仍然是在全分辨率下进行的,随后匹配循环要把输出网格的每一个格子,与包含几百个方块的调色板逐一比较。

而我当时把这一切都放在了一个 click 事件处理函数里。

我最初的猜测是色彩空间的问题。匹配使用 OKLab 而不是原始 RGB,因为 RGB 距离会产生经典的“廉价滤镜”效果——尤其是在肤色和中等绿色上,数值上很小的差距,人眼看起来却可能是完全不同的颜色。OKLab 转换涉及矩阵乘法和立方根,而立方根听起来很贵,所以我理所当然地认为时间都花在了这里。

结果不是。当我真正开始逐行分析性能时,才发现 94% 的时间根本不在颜色转换上,而是在图片解码和缩放那边——浏览器为了给我一个可用的 ImageBitmap,需要把整张 JPEG 解压出来;而我调用的 drawImage 重采样,又在大尺寸位图上做了一轮无谓的运算。更糟的是,这些操作同样被我在主线程上同步调用,于是界面自然冻结。

正确的修复其实很平凡:用 createImageBitmap 配合 resizeWidth 和 resizeHeight 直接解码出目标尺寸的位图,再把颜色匹配放进 Web Worker。这样主线程不再被占用,解码量从 1200 万像素降到不到 8 万像素,转换耗时从肉眼可见的“死掉”变成不到 20 毫秒。

回头看,真正的教训不是“不要在主线程做重活”——这个道理谁都懂。而是当性能出现问题时,最可疑的那个嫌疑人往往不是真凶。如果不先 profile,而只凭“立方根很贵”这种直觉去优化,我可能会在错误的方向上继续浪费好几个晚上。