一个只有十几行的 index.html,打开后却能不断重绘字符画,还把自己的 JavaScript 源码织进画面。代码中充满 eval、with、位运算和嵌在注释里的字符,让人很难第一眼看出它在做什么。

在 Chromium 中运行时,页面显示 16 行、每行 61 个字符的源码背景,中间覆盖一条由空格、* 和 # 组成的横向透镜形图案。图案的轮廓基本固定,内部字符不断变化;每行末尾还有一个可见的反斜杠。

这篇文章以这份文件为例,沿着「数据从哪里来、画面如何生成、源码怎样出现在画面里」的顺序拆解。它是代码压缩和创意编程的练习,其中的一些写法并不适合直接用于普通项目。

一、先看原文件

为了便于对照,下面保留原文件的换行和注释:

.<script>
eval(z='p="<"+"pre>"/* ,.oq#+     ,._, */;for(y in n="zw24l6k\
4e3t4jnt4qj24xh2 x/* =<,m#F^    A W###q. */42kty24wrt413n243n\
9h243pdxt41csb yz/* #K       q##H######Am */43iyb6k43pk7243nm\
r24".split(4)){/* dP      cpq#q##########b, */for(a in t=pars\
eInt(n[y],36)+/*         p##@###YG=[#######y */(e=x=r=[]))for\
(r=!r,i=0;t[a/*         d#qg `*PWo##q#######D */]>i;i+=.05)wi\
th(Math)x-= /*        aem1k.com Q###KWR#### W[ */.05,0<cos(o=\
new Date/1e3/*      .Q#########Md#.###OP  A@ , */-x/PI)&&(e[~\
~(32*sin(o)*/* ,    (W#####Xx######.P^     T % */sin(.5+y/7))\
+60] =-~ r);/* #y    `^TqW####P###BP           */for(x=0;122>\
x;)p+="   *#"/* b.        OQ####x#K           */[e[x++]+e[x++\
]]||(S=("eval"/* l         `X#####D  ,       */+"(z=\'"+z.spl\
it(B = "\\\\")./*           G####B" #       */join(B+B).split\
(Q="\'").join(B+Q/*          VQBP`        */)+Q+")//m1k")[x/2\
+61*y-1]).fontcolor/*         TP         */(/\\w/.test(S)&&"#\
03B");document.body.innerHTML=p+=B+"\n"}setTimeout(z)')//
</script>

开头的 . 是 <script> 标签外的普通文本;脚本首次替换 document.body.innerHTML 后,它也会被替换。源码中的反斜杠加换行位于 JavaScript 字符串内,用于续行;不要简单删除反斜杠,或把它们当作最终生成代码里的普通换行。

二、先梳理整体流程

最外层结构可以概括为:

eval(z = '一整段 JavaScript 源码');

变量 z 保存要执行的源码,eval 首次运行它。这段源码每次生成一帧后,又调用 setTimeout(z),把同一个字符串作为代码安排到后续任务中执行,于是反复刷新画面。

每一帧的大致流程是:

  1. 创建以 <pre> 开头的输出字符串,保留空格与换行的布局。
  2. 将一串以 4 分隔的数据拆开,依次计算各行上的图案位置。
  3. 把图案字符和源码字符拼进输出字符串。
  4. 用 document.body.innerHTML 更新页面,再安排下一次执行。

setTimeout(z) 没有显式指定延迟。它不是固定帧率的动画循环:执行频率受浏览器事件循环、定时器限速和页面状态影响。

三、压缩数据:split(4) 与 36 进制

原代码的入口是:

for (y in n = "zw24l6k4e3t4jnt4qj24xh2 x...".split(4)) {
    for (a in t = parseInt(n[y], 36) + (e = x = r = [])) {
        // ...
    }
}

这里截断了字符串,只展示结构。split(4) 会先把数字 4 转为字符串 "4",然后按它拆分。每个片段交给 parseInt(..., 36):36 进制使用数字 0–9 和字母 a–z,可以用较短的字符串表示较大的整数。

接下来有两个容易漏看的类型转换:

  • e = x = r = [] 将三个变量都设为同一个空数组;后续代码会分别重新赋值,e 用来记录当前行的位置。
  • 数字与空数组做 + 时,空数组转成空字符串,因此结果成为十进制字符串。for...in 随后遍历这个字符串的索引,代码通过 t[a] 读取其中的数字字符。

例如,parseInt('zw2', 36) 得到一个整数;再加上 [],得到的是这个整数的十进制文本。数据字符串中有些片段包含空格及看似注释的字符;它们位于字符串内部,parseInt 读到空格等无效字符时会停止,只解析前面的有效部分。数据本身不是一段可直接阅读的文字,而是转换为后续循环使用的数字。

数据末尾也有一个 4,所以拆分结果的最后一段是空字符串。parseInt('', 36) 得到 NaN,加上空数组后变为字符串 "NaN";其字符与数字比较时都不满足采样循环条件。因此最后一行没有图案覆盖,显示的是源码背景。

四、三角函数如何驱动动画

核心计算压缩在一段循环中:

for (r = !r, i = 0; t[a] > i; i += .05)
    with (Math)
        x -= .05,
        0 < cos(o = new Date / 1e3 - x / PI) &&
            (e[~~(32 * sin(o) * sin(.5 + y / 7)) + 60] = -~r);

可以把它理解成在当前行上多次采样,再把满足条件的结果写入数组 e:

  • new Date / 1e3 把当前时间转换成以秒为单位的数值;时间变化会改变后面的三角函数结果。
  • x 随采样递减,影响相位 o,让不同采样点分布在不同位置。
  • cos(o) > 0 决定是否记录当前采样点。
  • 32 * sin(o) * sin(.5 + y / 7) 让位置同时受时间、采样点和行号影响;加上 60 后,把坐标移到行的中间附近。
  • ~~ 对结果做 32 位整数转换,在这里通常表现为向零截断;-~r 是利用按位取反和负号计算 r + 1 的简写。

r = !r 在处理十进制字符串的每个数字字符时切换布尔值,使写入数组的值随之切换。with (Math) 让后面的 sin、cos 和 PI 不必写 Math. 前缀;它禁止用于严格模式,也会降低代码可读性。

五、图案和源码如何拼在一起

每一行的输出由下面这段代码完成:

for (x = 0; 122 > x;)
    p += "   *#"[e[x++] + e[x++]] || /* 对应位置的源码字符 */;

每次循环读取 e 中相邻的两个位置,x 因两次 x++ 增加 2,因此一行处理 61 个字符位置。两处数组值相加后被用于索引 " *#";如果取不到对应字符,就退回使用源码字符串中相应位置的字符。图案因此会覆盖部分源码,其他位置则显示源码字符。

源码文本由 z 自身重建,关键片段是:

"eval" + "(z='" + z.split(B = "\\").join(B + B)
    .split(Q = "'").join(B + Q) + Q + ")//m1k"

它先把反斜杠替换为两个反斜杠,再给单引号加上反斜杠,最后拼接 eval(z='...') 形式的文本。这样生成的文本能够表达包含引号和反斜杠的源码字符串。这是一种自引用的构造方式:程序把自身内容作为数据,用于生成画面。

原代码还通过 fontcolor 给匹配 \w 的字符设置颜色。String.prototype.fontcolor() 会生成旧式 HTML 标签,属于已废弃的兼容性接口;现代页面应使用 CSS。

每完成一行,代码都会追加反斜杠和换行,再赋给 document.body.innerHTML;这就是行尾可见反斜杠的来源。因此它每帧会多次更新 DOM,而不是等所有行都拼好才更新。

六、读懂这些简写

写法作用阅读时注意
eval(z = '...')保存字符串并执行会把字符串当代码运行
split(4)按 "4" 拆分数字参数会转换成字符串
parseInt(s, 36)按 36 进制解析遇到无效字符时可能提前停止解析
number + []拼成字符串空数组会被转换为空字符串
r = !r切换真假值首次空数组也是真值
~~value转成 32 位有符号整数大数、NaN 等情况与 Math.trunc 不完全相同
-~r按 32 位整数规则计算 r + 1依赖位运算的数值转换
a && ba 为真值时计算 b这里借短路完成条件赋值
`ab`

这些技巧可以减少字符数量,但会增加理解和维护成本。实际项目通常更适合使用显式变量、函数和条件语句。

七、如果要用于实际页面

这份文件本身很适合用来研究代码压缩,但改造为可维护的动画时,可以从以下方向入手:

  1. 去掉字符串执行:用普通函数保存绘制逻辑,不使用 eval 或字符串形式的 setTimeout。这也有助于遵守禁止动态代码执行的内容安全策略(CSP)。
  2. 使用 requestAnimationFrame:让浏览器在合适的时机安排绘制,并使用传入的时间戳计算动画;页面隐藏时,浏览器通常会暂停或降低调用频率。
  3. 减少 DOM 更新:先拼完整帧,再一次更新已有的 <pre> 元素。若不需要生成 HTML 标签,可用 textContent 避免把文本解释为 HTML。
  4. 拆分数据与渲染:把解码、采样、字符选择和显示拆成独立函数,为变量取有意义的名字。
  5. 照顾可访问性:提供暂停动画的方式,并考虑 prefers-reduced-motion;频繁变化的字符画不应成为获取信息的唯一方式。

八、总结

这段 index.html 把多个技巧叠在了一起:用 36 进制压缩数据,用三角函数和时间生成动态位置,用数组保存采样结果,再把图案与转义后的自身源码拼成画面。

阅读这类代码时,不必急着逐字符翻译。先找到输入数据、循环边界、状态更新和输出位置,再展开类型转换与运算符,通常就能从一团压缩代码中还原出清晰的执行流程。