翻譯 | 一行 JavaScript 程式碼的逆向工程

iKcamp發表於2017-08-07

幾個月前,我看到一個郵件問:有沒有人可以解析這一行 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);複製程式碼

下一步,我宣告瞭變數 ipj,然後把他們放在函式的頂部。

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/DELAYlet magic = ((i % 2 * j - j + n / DELAY) ^ j) 當中移除掉,我們最終將會看到一個靜態的佈局,如下圖

現在,讓我們來看看移除了 + n/DELAYmagic。如何能得到上面漂亮的圖片。

(i % 2 * j - j) ^ j

注意到每次迴圈裡,我們都會執行:

j = DELAY / i;
i -= 1 / DELAY;複製程式碼

換句話說,我們可以將上述表示式中的 ji 表示,變成 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 的數量已經增長了一定的數量。例如第一行裡面就有一半的值是偶數,從現在起,一大段的ps 將移動他們的位置。

為了說明這一點,我們可以看到當 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前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

相關文章