分析一段精妙的地球 JavaScript 代码

15 min

之前网上冲浪时偶然看到了一个网站:https://aem1k.com/。里面有很多 JavaScript 的极简代码,比如下面这个。

.<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> 标签和内层的 eval() 函数。eval() 函数就是把括号内的内容当作 JS 代码执行。

而外面的 <script> 标签前面加一个点,其实是为了定位。纯 JS 的网页没有 <body> 标签,而这个点就充当了 <body> 标签的角色,让动画有位置展示。

至于 eval() 函数内的代码,这段代码的混淆性特别强,先把它展开来看看。

p = "<" + "pre>";
for (y in (n =
  "zw24l6k4e3t4jnt4qj24xh2 x42kty24wrt413n243n9h243pdxt41csb yz43iyb6k43pk7243nmr24".split(
    4,
  ))) {
  for (a in (t = parseInt(n[y], 36) + (e = x = r = [])))
    for (r = !r, i = 0; t[a] > i; i += 0.05)
      with (Math)
        ((x -= 0.05),
          0 < cos((o = new Date() / 1e3 - x / PI)) &&
            (e[~~(32 * sin(o) * sin(0.5 + y / 7)) + 60] = -~r));
  for (x = 0; 122 > x; )
    p +=
      "   *#"[e[x++] + e[x++]] ||
      (S = ("eval" +
        "(z=\'" +
        z
          .split((B = "\\\\"))
          .join(B + B)
          .split((Q = "\'"))
          .join(B + Q) +
        Q +
        ")//m1k")[x / 2 + 61 * y - 1]).fontcolor(/\\w/.test(S) && "#03B");
  document.body.innerHTML = p += B + "\\n";
}
setTimeout(z);

压缩地图

原来的图中存了一整幅地图,但是代码量只有 1023 个字符,为了存图画,就必须压缩。就比如一张原始的地图长这样:

. . . . # # # # # # . . . . . # . . . .
. . # # # # # # # . . . . # # # # # . .
. # # # # # # # # . . # # # # # # # # .
. . # # # # # . . . . # # # # # # # # .
. . . # # # # . . . # # # # # # # # . .
. . . . # # # . . . # # # # # # # # . .
. . . . # # # . . . # . # # # # # # . .
. . . . # # . . . . # # # # # # # . . .
. . . . . # . . . # # # # # # # . . . .
. . . . . . # . . # # # # . . # . . . .
. . . . . . # # . . # # # . . . # . . .
. . . . . . # # # . . # # . . . . . . .
. . # . . . # # . . . # # . . . . # . .
. . . . . . # # . . . # . . . . # # . .
. . . . . . # . . . . # . . . . # # . .

上面的 . 代表海水,# 代表陆地1

但在这里,整张图就 300 个字符了,占源代码大小的 30%,因此必须进行压缩。观察这个图,容易发现图中只存在两种字符,而且每一行都由 . 开始,那也就意味着可以用字符连续出现的次数表达每一行。比如,第一行 . . . . # # # # # # . . . . . # . . . . 就等价于 46514。这样就可以按照纬度把地图进行压缩。

46514 27452 18281 25481 34382 43382 4331162 42473 51374 6124214 6223313 63227 213232412 6231422 6141422

压缩率不错,已经达到了三分之一,但是仍然占源代码大小的 10%。为了继续压缩,接着试试提高进制。JavaScript 中最高可转换的进制是 36 进制,那么继续压缩。

zw2 l6k e3t jnt qj2 xh2 2kty2 wrt 13n2 3n9h2 3pdxt 1csb 3iyb6k 3pk72 3nmr2

这样就是压缩的极限了。但是不能用数组存,如果用数组存就会让每个字符串前面多出两个引号,字符串之间又多出个逗号,只能弃掉,换成字符串存储。就比如用一个 36 进制里面不会出现的字符来分割,比如 !,那就能够用函数 split() 来分割。但是实际上不会用 !,因为 ! 是字符,还要搭进去两个引号,选一个没出现过的数字 4 更好。2

"zw24l6k4e3t4jnt4qj24xh2 x42kty24wrt413n243n9h243pdxt41csb yz43iyb6k43pk7243nmr24"

画出地图

这里是整段代码最神奇的部分。

解压缩 & 初始化变量

上面压缩完之后,自然是要用函数解压缩。因此首先调用循环和函数 parseInt() 来解压,具体就像是这样。

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

变量 t 实际上就是 n 隐式转换成数组之后用 36 进制解码数组 n 的每一位,也就是说 t 实际上就是地图压缩成的一系列数字。紧接着再把一个数字加上空数组变成字符串。就像是:

n[0] = zw2;
parseInt(n[0]) = 46514;
46514 + [] = "46514";

三个数组变量各有各的用处。

  • e = x = r = []
    • e: 用来存储当前这一行要显示的像素点(相当于一行显存)。
    • x: 用作经度坐标。
    • r: 用作地形类型的开关(陆地/海洋)。

控制画图密度

地图是一个矩形,但实际展示却是像地球仪一样上下窄中间宽,这就必须控制画图的密度(体现在图上就是地图的经度),这需要用到球面投影(也就是三角函数)。

通过 with (Math)3 引入 JavaScript 的数学库就可以使用 sin()cos() 等数学函数了。

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

r=!r,实际上就是反转 r 的布尔值;而 t[a] > i 中,t[a] 越大就代表要画的部分越长,这恰好对应了上文用空白表示海洋,# 表示陆地的压缩方式。

i += 0.05 是一个精细的步长,实际上是把地图的精度放大了 20 倍。

x 代表地球的经度,每次递减 0.05 就能让地球旋转起来,而后面的 && 相当于一个 if (cond) ... 语句。这样,其实就相当于:

while (还在绘制) {
    (经度减小), 
    if (o < cos()) {
        e[计算列的序号] = -~r
    }
}

那么继续分析 cos() 中的 (o = new Date() / 1e3 - x / PI),其实就是取一个变量 o,它等于现在的时间(取时间戳)除以 10004,用它减去经度除以 Math.PI,就得到了当前地球自转的相位。

得到相位的目的是什么呢?cos(x) 的值,是在 [-1, 1] 之间振荡,那么得到了当前的相位就知道了地球上的某一点是否在图中展示出来。当相位小于 0,就表示这一点转到了朝向观察者的一面。

更新显存

上面说了,e 是负责渲染地图的显存。所以根据上面得到的经度 x 和纬度 y,就能够计算得到当前一点的水平投影位置和数据。

源代码中的计算公式是这样的:

e[~~(32 * sin(o) * sin(0.5 + y / 7)) + 60] = -~r)

32 * sin(o) * sin(0.5 + y / 7) 计算位置。sin(0.5 + y / 7) 根据纬度 y 调整球体的宽度(赤道宽,两极窄)。32 是放大倍数,决定了当前地球的显示宽度。60 是圆心的偏移。

最后按照位运算技巧(-~r 等价于 r+=1)给 e 存入要么是 1 要么是 2 的值(后面有用)。

下面这张图展示了在数组 e 中,对于每一个纬度 y ,有数据的索引范围。容易看到,当纬度在 7 时索引范围最宽,0 和 14 时索引范围最窄。也就是对应实际展示中两极窄赤道宽的样式。

distribution_of_indices_in_array_e_across_row
distribution_of_indices_in_array_e_across_row

渲染合成

这段代码开始展示艺术,它将地球放到当前的浏览器中。

for (x = 0; 122 > x; )
    p +=
      "   *#"[e[x++] + e[x++]] ||
      (S = ("eval" +
        "(z=\'" +
        z
          .split((B = "\\\\"))
          .join(B + B)
          .split((Q = "\'"))
          .join(B + Q) +
        Q +
        ")//m1k")[x / 2 + 61 * y - 1]).fontcolor(/\\w/.test(S) && "#03B");
  document.body.innerHTML = p += B + "\\n";

简单地说,它会判断当前的这一个字符是不是地球。如果是,就画出地球的像素点;如果不是,就画出源代码。

这段代码利用了 JavaScript 的逻辑或运算符 || 的“短路”特性来实现图层叠加。将它拆解为 A || B 两部分来看。

A 部分

长这样:" *#"[e[x++] + e[x++]]

这个字符串是个调色板。索引 0-2(空格)表示亮度很低或者是海洋;索引 3(*)表示陆地边缘或中等亮度;索引 4 (#)表示高亮的陆地中心。

有了调色板,就可以用数组 e 中的数据提取了。

因为通常情况下字符的高是宽的大约两倍,为了不让地球看起来像个橄榄,需要压缩分辨率。

具体方法也很简单,只需要 x++ 两次,这样输出一个字符实际上消耗了 e 的两个数据点,就达成了压缩分辨率的目的。5

B 部分

如果当前位置没有地球(即 e数组里没值),代码就会执行 || 右边的部分。这部分非常硬核,它在通过计算生成程序自身的源代码字符串

(S = ("eval" + "(z=\'" + ... + ")//m1k")[索引]).fontcolor(...)

先重组源码。"eval" + "(z=\'" + ... 这一长串是在动态拼凑当前正在运行的这段代码的字符串形式。它处理了转义字符(splitjoin 里的 \\\'),确保拼出来的代码字符串是合法的。

然后计算背景文字的位置。

  • [x / 2 + 61 * y - 1]

    1. 61:因为外层循环 122 > x 且每次步进 2(x++ 两次),所以一行宽 61 个字符。

    2. y:当前是第几行。

这个公式确保了背景里的源代码是整整齐齐地按顺序排布的,就像一张印着代码的壁纸铺在地球后面。

最后染色。上面已经说过 && 运算符的作用。.fontcolor(/\\w/.test(S) && "#03B"); 就是在用正则表达式匹配每一个字母、数字和下划线,并给它们染上蓝色。其余部分保持默认。

document.body.innerHTML = p += B + "\\n"; 将计算得到的结果拼接到 p(最后展示出的 HTML)中。

总结起来,这部分代码的伪代码表示如下。

for (字符位 in 屏幕) {
    if (地球数据 in e) {
        draw = "   *#"选一个画进去;
    } else {
        draw = 如果这里是显示源代码,显示的第几个字母;
        给源代码染色;
    }
    最终展示 += draw;
}

这就是为什么在最终效果图中看到:前面是一个旋转的地球,而地球的空隙和背景里,整整齐齐地写着这段代码本身。

循环往复

在字符串 z(还记得有这个字符串吗)的最后出现了一个 setTimeout(z),它的作用是在极短的时间后,把字符串 z 当作代码再执行一次。(接近 0 毫秒)

这样做有两个原因。

  1. 为了复制自己。变量 z 的使命是存储完整的源代码。 如果 setTimeout(z) 这句话不在 z 里面,那么当程序执行 eval(z) 时,它会运行一遍绘图逻辑,然后就结束了。因为它不知道“运行完之后该干什么”。 只有把“运行完之后,再运行一遍我自己”这条指令(即 setTimeout(z))写进源代码字符串 z 里,程序才能在执行完那一刻,知道要触发下一次执行。
  2. 为了显示背景代码。记得上一段提到的背景代码显示逻辑吗? (S = ("eval" + "(z=\'" + z ...) 这段代码会把 z 的内容打印在屏幕背景上。 如果 setTimeout(z) 不在 z 里,那么屏幕背景上显示的源代码就会少这一句。为了让显示的源代码和实际运行的源代码完全一致(完美的 Quine),它必须包含自身所有的逻辑,包括循环指令。

讲真的。这段 JavaScript 代码,设计精巧,在不到 1 KiB(1023 Bytes)的大小里面融入了 Quine(自产生程序)、位运算、压缩算法还有代码高亮,实在是很牛逼。

最后在这里贴出来原作者 Martin Kleppe 的讲解。(在 YouTube 上观看)

On YouTube - Martin Kleppe: 1024+ Seconds of JS Wizardry — JSConf EU 2013

(B 站也有)

On Bilibili - Martin Kleppe: 1024+ Seconds of JS Wizardry — JSConf EU 2013

Footnotes

  1. 实在是很抽象,这是从源代码中提取出来的,后面会说怎么转换。

  2. 为了源代码展示效果是个方形,源代码中进行了补位。

  3. 这种写法很不规范就是了,而且很容易爆 undefined 和安全问题。

  4. 时间的变化相比起经度的变化微乎其微,可忽略不计。

  5. 将相邻两个点求和然后计算起到了简单的抗锯齿作用。