一个只有十几行的 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),把同一个字符串作为代码安排到后续任务中执行,于是反复刷新画面。
每一帧的大致流程是:
- 创建以
<pre>开头的输出字符串,保留空格与换行的布局。 - 将一串以
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 && b | a 为真值时计算 b | 这里借短路完成条件赋值 |
| `a | b` |
这些技巧可以减少字符数量,但会增加理解和维护成本。实际项目通常更适合使用显式变量、函数和条件语句。
七、如果要用于实际页面
这份文件本身很适合用来研究代码压缩,但改造为可维护的动画时,可以从以下方向入手:
- 去掉字符串执行:用普通函数保存绘制逻辑,不使用
eval或字符串形式的setTimeout。这也有助于遵守禁止动态代码执行的内容安全策略(CSP)。 - 使用
requestAnimationFrame:让浏览器在合适的时机安排绘制,并使用传入的时间戳计算动画;页面隐藏时,浏览器通常会暂停或降低调用频率。 - 减少 DOM 更新:先拼完整帧,再一次更新已有的
<pre>元素。若不需要生成 HTML 标签,可用textContent避免把文本解释为 HTML。 - 拆分数据与渲染:把解码、采样、字符选择和显示拆成独立函数,为变量取有意义的名字。
- 照顾可访问性:提供暂停动画的方式,并考虑
prefers-reduced-motion;频繁变化的字符画不应成为获取信息的唯一方式。
八、总结
这段 index.html 把多个技巧叠在了一起:用 36 进制压缩数据,用三角函数和时间生成动态位置,用数组保存采样结果,再把图案与转义后的自身源码拼成画面。
阅读这类代码时,不必急着逐字符翻译。先找到输入数据、循环边界、状态更新和输出位置,再展开类型转换与运算符,通常就能从一团压缩代码中还原出清晰的执行流程。