- 原文地址:Reverse Engineering One Line of JavaScript
- 原文作者:Alex Kras
- 譯者:李波
- 校對者:冬青、小蘿蔔
幾個月前,我看到一個郵件問:有沒有人可以解析這一行 JavaScript 程式碼
<pre id=p><script>n=setInterval("for(n+=7,i=k,P='p.\\n';i-=1/k;P+=P[i%2?(i%2*j-j+n/k^j)&1:2])j=k/i;p.innerHTML=P",k=64)</script>複製程式碼
這一行程式碼會被渲染成下圖的效果。你可以在這裡用瀏覽器開啟來觀看。這是 Mathieu ‘p01’ Henri 寫的,你還可以在作者的網站www.p01.org裡看到更多很酷的例子。
好的!我決定接受挑戰
第一步:讓程式碼變得可讀
第一件事,讓 HTML 檔案裡只有 HTML 程式碼,然後把 JavaScript 程式碼放到 code.js
檔案裡。我還用 id="p"
來包裝 pre 標籤。
index.html
<script src="code.js"></script>
<pre id="p"></pre>複製程式碼
我注意到變數 k
只是一個常量,所以把它移出來,然後重新命名為 delay
。
code.js
var delay = 64;
var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var n = setInterval(draw, delay);複製程式碼
接下來,因為 setInterval
可以接收一個函式或者字串來執行,字串 var draw
會被 setInterval 用 eval
來解析並執行。所以我把它移到一個新建的函式體內。 然後保留舊的那行程式碼,以供參考。
我注意到的另一個點,變數 p
指向了存在於 HTML 的 DOM 結構裡 id 為 p
的標籤,就是那個之前我包裝過的 pre 標籤。事實上,元素標籤可以通過他們的 id 用 JavaScript 來獲取,只要 id 僅由字母數字組成。這裡,我通過 document.getElementById("p")
來讓它更加直觀。
var delay = 64;
var p = document.getElementById("p"); // < --------------
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var draw = function() {
for (n += 7, i = delay, P = 'p.\n'; i -= 1 / delay; P += P[i % 2 ? (i % 2 * j - j + n / delay ^ j) & 1 : 2]) {
j = delay / i; p.innerHTML = P;
}
};
var n = setInterval(draw, delay);複製程式碼
下一步,我宣告瞭變數 i
、p
和 j
,然後把他們放在函式的頂部。
var delay = 64;
var p = document.getElementById("p");
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var draw = function() {
var i = delay; // < ---------------
var P ='p.\n';
var j;
for (n += 7; i > 0 ;P += P[i % 2 ? (i % 2 * j - j + n / delay ^ j) & 1 : 2]) {
j = delay / i; p.innerHTML = P;
i -= 1 / delay;
}
};
var n = setInterval(draw, delay);複製程式碼
我把 for
迴圈分解成 while
迴圈。只保留了 for
的CHECK_EVERY_LOOP部分(for的三個部分分別是RUNS_ONCE_ON_INIT; CHECK_EVERY_LOOP; DO_EVERY_LOOP),然後分別把其他的程式碼移到迴圈的內外部。
var delay = 64;
var p = document.getElementById("p");
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var draw = function() {
var i = delay;
var P ='p.\n';
var j;
n += 7;
while (i > 0) { // <----------------------
//Update HTML
p.innerHTML = P;
j = delay / i;
i -= 1 / delay;
P += P[i % 2 ? (i % 2 * j - j + n / delay ^ j) & 1 : 2];
}
};
var n = setInterval(draw, delay);複製程式碼
接著我將會展開 P += P[i % 2 ? (i % 2 * j - j + n / delay ^ j) & 1 : 2]
中的三元操作(判斷條件 ? true時執行 :false時執行
)
i % 2
是用來檢測 i
是奇數還是偶數,如果 i 是偶數,則返回 2。如果是奇數,則返回 (i % 2 * j - j + n / delay ^ j) & 1
的計算結果(更多的是這種情況)。
最終,這個返回值被當作索引,被用於獲取字串P的某個字元,因此它可以寫成 P += P[index]
。
var delay = 64;
var p = document.getElementById("p");
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var draw = function() {
var i = delay;
var P ='p.\n';
var j;
n += 7;
while (i > 0) {
//Update HTML
p.innerHTML = P;
j = delay / i;
i -= 1 / delay;
let index;
let iIsOdd = (i % 2 != 0); // <---------------
if (iIsOdd) { // <---------------
index = (i % 2 * j - j + n / delay ^ j) & 1;
} else {
index = 2;
}
P += P[index];
}
};
var n = setInterval(draw, delay);複製程式碼
下一步,我會把 index = (i % 2 * j - j + n / delay ^ j) & 1
裡的 & 1
分解到另外的 if 表示式裡。
這是一種聰明的方法來檢測括號內的值是奇數還是偶數,如果是偶數則返回 0,反之返回 1.&
是與的位運算子。與的邏輯如下:
- 1 & 1 = 1
- 0 & 1 = 0
因此 something & 1
則可以看成把“something”轉化成二進位制,接著在 1 的前面填充對應數量的 0,從而保持和 something 的長度一致,然後僅僅返回與運算的最後一位。例如,5的二進位制是 101
。如果我們和 1
進行與運算,將會得到如下結果:
101
AND 001
001複製程式碼
或者說,5是一個奇數,5 & 1
的結果是 1。用 JavaScript 的控制檯很容易可以證明下面這個邏輯。
0 & 1 // 0 - even return 0
1 & 1 // 1 - odd return 1
2 & 1 // 0 - even return 0
3 & 1 // 1 - odd return 1
4 & 1 // 0 - even return 0
5 & 1 // 1 - odd return 1複製程式碼
注意,我將上述 index
的剩餘部分重新命名為 magic
。因此這些程式碼加上展開 & 1
後的程式碼看起來是下面這樣的。
var delay = 64;
var p = document.getElementById("p");
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var draw = function() {
var i = delay;
var P ='p.\n';
var j;
n += 7;
while (i > 0) {
//Update HTML
p.innerHTML = P;
j = delay / i;
i -= 1 / delay;
let index;
let iIsOdd = (i % 2 != 0);
if (iIsOdd) {
let magic = (i % 2 * j - j + n / delay ^ j);
let magicIsOdd = (magic % 2 != 0); // &1 < --------------------------
if (magicIsOdd) { // &1 <--------------------------
index = 1;
} else {
index = 0;
}
} else {
index = 2;
}
P += P[index];
}
};
var n = setInterval(draw, delay);複製程式碼
接下來,我將會分解 P += P[index]
到一個 switch 表示式裡。現在我們可以很清晰的知道 index的值只可能為 0、1 和 2 中的一個。也可以知道 P 的初始化總是 var P ='p.\n'
, index 為 0 時指向 p
,為 1 時指向 .
,為 2 時指向 \n
—— 新的一行字串。
var delay = 64;
var p = document.getElementById("p");
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
var draw = function() {
var i = delay;
var P ='p.\n';
var j;
n += 7;
while (i > 0) {
//Update HTML
p.innerHTML = P;
j = delay / i;
i -= 1 / delay;
let index;
let iIsOdd = (i % 2 != 0);
if (iIsOdd) {
let magic = (i % 2 * j - j + n / delay ^ j);
let magicIsOdd = (magic % 2 != 0); // &1
if (magicIsOdd) { // &1
index = 1;
} else {
index = 0;
}
} else {
index = 2;
}
switch (index) { // P += P[index]; <-----------------------
case 0:
P += "p"; // aka P[0]
break;
case 1:
P += "."; // aka P[1]
break;
case 2:
P += "\n"; // aka P[2]
}
}
};
var n = setInterval(draw, delay);複製程式碼
我將簡化 var n = setInterval(draw, delay)
。setInterval
會返回一個從 1 開始的整數,並且每次執行完 setInterval
之後返回值都會遞增。這個整數可以在 clearInterval
方法裡面用到(用來取消定時器)。在我們的程式碼裡, setInterval
僅僅只會執行一次,所以 n 可以簡單的設定為 1.
我還把 delay
重新命名為 DELAY
讓它看起來是一個常量。
最後但並非不重要的一點,我用括號把 i % 2 * j - j + n / DELAY
包起來,指明 ^
異或運算的執行優先度低於 %
,*
,-
,+
和/
操作。或者說,所有的運算操作都會比 ^
先執行。包裝後的程式碼應該是這樣的 ((i % 2 * j - j + n / DELAY) ^ j)
。
// 之前我把 `p.innerHTML = P;` 放錯地方了,更新後,把它移出了while迴圈
const DELAY = 64; // approximately 15 frames per second 15 frames per second * 64 seconds = 960 frames
var n = 1;
var p = document.getElementById("p");
// var draw = "for(n+=7,i=delay,P='p.\\n';i-=1/delay;P+=P[i%2?(i%2*j-j+n/delay^j)&1:2])j=delay/i;p.innerHTML=P";
/**
* Draws a picture
* 128 chars by 32 chars = total 4096 chars
*/
var draw = function() {
var i = DELAY; // 64
var P ='p.\n'; // First line, reference for chars to use
var j;
n += 7;
while (i > 0) {
j = DELAY / i;
i -= 1 / DELAY;
let index;
let iIsOdd = (i % 2 != 0);
if (iIsOdd) {
let magic = ((i % 2 * j - j + n / DELAY) ^ j); // < ------------------
let magicIsOdd = (magic % 2 != 0); // &1
if (magicIsOdd) { // &1
index = 1;
} else {
index = 0;
}
} else {
index = 2;
}
switch (index) { // P += P[index];
case 0:
P += "p"; // aka P[0]
break;
case 1:
P += "."; // aka P[1]
break;
case 2:
P += "\n"; // aka P[2]
}
}
//Update HTML
p.innerHTML = P;
};
setInterval(draw, 64);複製程式碼
你可以在這裡看到最後的結果。
第二步:理解程式碼
這部分將會介紹什麼內容呢?不要心急,讓我們一步一步來解析。
i
通過 var i = DELAY
,被初始化為 64,然後每次迴圈遞減 1/64,等於0.015625(i -= 1 / DELAY)。迴圈持續到 i
小於 0 時(while (i > 0) {
)。每次執行迴圈,i
將會減少 1/64,所以每執行 64 次迴圈,i
就會減 1 (64 / 64 = 1),總得來說, i
需要執行 64 x 64 = 4096 次,之後小於 0.
之前的圖片中,一共有 32 行,每行包含了 128 個字元。恰巧的是 64 x 64 = 32 x 128 = 4096。我們觸發 32 次 i
為嚴謹的偶數的情況,i
是絕對的偶數時,i
才為偶數(非奇數 let iIsOdd = (i % 2 != 0)
; 譯者提示:偶數是整數,所以2.2是奇數),例如 i
為 64,62,60等。在這 32 次裡,index 通過 index = 2
賦值為 2,意味著字串將新增 P += "\n"; // aka P[2]
從而換行,開始一行新的字串。剩餘的 127 個字元則都是 p
和 .
。
那麼我們根據什麼來判斷何時用 p
或者 .
?
當然,之前我們就已經知道了,當 let magic = ((i % 2 * j - j + n / DELAY) ^ j)
中的 magic 是奇數的時候用 .
,如果是偶數則用 p
。
var P ='p.\n';
...
if (magicIsOdd) { // &1
index = 1; // second char in P - .
} else {
index = 0; // first char in P - p
}複製程式碼
但我們很難知道 magic 是奇數還是偶數,這是一個很有分量的問題。在此之前,讓我們證實一些事情。
如果我們把 + n/DELAY
從 let magic = ((i % 2 * j - j + n / DELAY) ^ j)
當中移除掉,我們最終將會看到一個靜態的佈局,如下圖
現在,讓我們來看看移除了 + n/DELAY
的 magic
。如何能得到上面漂亮的圖片。
(i % 2 * j - j) ^ j
注意到每次迴圈裡,我們都會執行:
j = DELAY / i;
i -= 1 / DELAY;複製程式碼
換句話說,我們可以將上述表示式中的 j
用 i
表示,變成 j = DELAY/ (i + 1/DELAY)
,但因為 1/DELAY 是一個非常小的數值,所以我們暫時去掉 + 1/DELAY
並簡化成 j = DELAY/i = 64/i
// 譯者注
為何這裡不是 j = DELAY/ (i - 1/DELAY)呢?
原因:
i -= 1 / DELAY
轉化成i = i - 1 / DELAY
這裡有 2 個
i
可以代入消元,但是因為j
的表示式在i
前面,所以j
取得i
應
該是自減前的i
,故i = i + 1/ DELAY
因此我們可以重寫 (i % 2 * j - j) ^ j
為 (i % 2 * 64/i - 64/i) ^ 64/i
讓我們用線上的圖形計算器來繪製那些函式
首先,我們來繪製下 i%2
的圖
從下面的圖形可以看出,y 的值區間在 0 到 2 之間。
如果我們繪製 64 / i
則會得到如下圖形
如果我們繪製 (i % 2 * 64/i - 64/i)
表示式,我們將得到一個混合了上面兩張圖的一個圖形,如下
最後,如果我們把2個函式同時繪製出來,將會是如下的圖(紅線為 j
的關係圖)
我們能從圖形裡知道些什麼?
讓我們回憶下我們要去解答的問題:如何得到如下靜止影象:
好的,我們知道如果 (i % 2 * j - j) ^ j
的值是一個偶數,那麼我們將新增 p
,如果是一個奇數則新增 .
。
讓我們專注在圖形的前面 16 行,i
的值在 64 到 32 之間。
異或運算在 JavaScript 裡會把小數點右邊的值忽略掉,所以它看起來和執行 Math.floor
的效果一樣。
其實當2個對比位都是 1 或者 0 的時候, 異或操作會返回0。
這裡我們的 j
初始值為 1,且慢慢的遞增趨向於 2,但始終小於 2,所以我們可以把它當成 1 來處理(Math.floor(1.9999) === 1
),為了得到結果為 0 (意味著是偶數),我們還需要異或表示式的左邊也是 1,使得返回一個 p
給我們。
換句話說,每條藏青色的傾斜線都相當於我們影象中的一行,因為前面16行的 j
值總是介於 1 和 2 之間,而唯一能得到奇數值的方法是讓 (i % 2 * j - j) ^ j
(也可以說i % 2 * i/64 - i/64
或者藏青色的傾斜線)大於 1 或小於 -1。
為了將這個地方講清楚,這裡有一些Javascript控制檯的輸出,0 或者 -2 意味著結果是偶數,1 則是奇數。
1 ^ 1 // 0 - even p
1.1 ^ 1.1 // 0 - even p
0.9 ^ 1 // 1 - odd .
0 ^ 1 // 1 - odd .
-1 ^ 1 // -2 - even p
-1.1 ^ 1.1 // -2 - even p複製程式碼
如果我們觀察下我們的圖形,可以看出原點右邊的斜線大部分都是大於 1 或者小於 -1(幾乎沒有偶數,或者說幾乎沒有 p),且越靠後(靠近原點)越如此。第 16 行幾乎介於 2 和 -2 之間。第 16 行之後,我們可以看到圖形是另外一種模式。
16 行之後 j
超過了 2,使得結果發生了變化。現在當藏青色的斜線大於 2 ,小於 -2 ,或者在1和-1之間且不等於的時候,我們將會得到一個偶數。這也是為什麼在 17 行之後我們會在一行內看到兩組和兩組以上的 p
。
如果你仔細看動圖的最底部幾行,你會發現這幾行不符合上面的規則,圖表曲線看起來起伏非常大。
現在讓我們把 + n/DELAY
加回來。在程式碼裡我們可以看到 n
的初始值是 8 (初始是 1 ,但是每次定時器被呼叫時就加 7),它會在每次執行定時器時增加 7。
當 n
變成 64,圖形會變成如下樣子。
可以注意到,j
總是 ~1(這裡的 ~ 是近似的意思),但是現在紅斜線的左半邊位於 62-63 區間的值無限趨近於 0,紅斜線的右半邊位於 63-64 則無限趨近與 1。因為我們的字元按64到62的順序排列,那麼我們可以猜測斜線的 63-64 部分(1^1=0 是偶數)新增的是一段 p
,左邊 62-63 部分(1^0=1 是奇數)新增的是一段 .
。就像普通的英語單詞一樣,從左到右的新增上。
用 HTML 渲染出來的話,將會看到下圖(你可以自己在 codepen 改變 n
來觀看效果)。這和我們的預期一致。
這一時刻 p
的數量已經增長了一定的數量。例如第一行裡面就有一半的值是偶數,從現在起,一大段的p
和 s
將移動他們的位置。
為了說明這一點,我們可以看到當 n
在下一個定時器裡增加了 7 時,圖形就會有稍微的變化
注意,第一行的斜線(在 64 附近)已經稍微移動了 1 小格,假設 4 個方格代表 128 個字元,1 個方格 相當於 32 個字元,那麼 1 個小格則相當於 32/5=6.4 個字元(大約)。正如下圖所示,我們可以看到第一行實際上向右移動了 7 個字元。
最後一個例子。就是當定時器被呼叫超過 7 次時(n 等於 64+9x7)會發生什麼。
對於第一行,j
還等於 1。現在紅斜線的上部分在 64 左右的值趨向於 2,下部分趨向於 1。這個圖片將會翻轉,因為現在 1^2 = 3 是奇數-輸出.
而 1^1 = 0 是偶數- 輸出p
。所以我們預期在一大段 p
之後會是一大段 .
。
他會這麼渲染。
自此,圖形將會以這種形式無限迴圈下去。
我希望我解釋清楚了。我不認為自己有能力寫出這樣的程式碼,但是我很享受理解它的過程。
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。