反壓縮 js ,我的萬花筒寫輪眼開了,CV 能力大幅提升

前言

因為比較菜,所以經常需要讀一些別人的代碼學習學習。

有源碼的代碼當然好,但是很多網站不開源。這些網站的 js 又都是打包壓縮過的,學習起來很難受。

所以我做了一個小工具,通過修改抽象語法樹,來處理這些打包壓縮過的 js,增強代碼可讀性,讓我們學習起來更容易。

如果再藉助重定向線上 js 到本地 js,或者使用 chrome 自帶的 override 源碼能力,甚至可以輕鬆調試別人的線上代碼。

有了這個工具,我 CV 界大師兄的名號可謂實至名歸。

下面是這個工具的代碼倉庫:boompack

需求

在此之前,其實面對這些壓縮過的 js 我是不太想做這個工具的。

通常使用 prettier 美化一下,然後慢慢磨就好了。

但是這次我面對的是一個 canvas 相關的 js,壓縮後的核心代碼使用 prettier 格式化之後有 2 萬多行,看到這份代碼之後人都麻了。

這裡隨便寫個壓縮代碼示例來舉例:

function f() {
  var a = (c = 33, d = 12), b = 1, g = (e == 2 ? a === 1 && b == 1 || c == 1 && d == 1 && c == 4 : c = 2);
  for (var i; i < 10; i++)if (s < 1) s++
  return a = 2, d == 2, e = !1, e = !0
}

這份代碼其實並不算特別複雜,因為沒有十幾個邏輯表達式和三元運算交雜在一起。

但是這份代碼很典型,因為基本上比較影響閱讀的點都有。

我們簡單列一下:

  • 大量無意義的單字符變量,修改變量名也無法批量替換
  • 序列表達式很多,即大量語句以逗號分隔,調試時只算做一行代碼
  • 多個邏輯表達式混雜不清,需要改為 if 表達式
  • if 或者 for 循環不加花括號
  • 大量三元運算,需要使用 if 和 else
  • !0 和!1 的表達反人類,需要使用 true 和 false
  • return 語句結合序列表達式,實際上只返回最後一個

這些就是主要的困難,特別是當它們各種互相嵌套,又和十幾個邏輯運算和三元運算交雜在一起,光是拆解出來就得花個十幾分鐘。

如果人力拆解這些代碼,也不是沒有好處,至少你可以化身人肉低端編譯器,反覆鞏固 js 基礎,要是碰到一些奇葩公司讓你手寫代碼你就是王者。

但是因為我需要留出時間打遊戲的原因,所以還是寫了這麼個工具簡化流程。

使用方法

  • 克隆倉庫到本地後
  • yarn install 安裝依賴包
  • 將需要轉換地壓縮代碼,複製粘貼到test/from/index.js這個文件中
  • 終端運行腳本 yarn start
  • 最終會在test/to/這個文件夾下生成 index.js,也就是我們最後修改後的文件。

效果

使用工具轉換後的 js 代碼如下:

function func_f() {
  let var_a, var_b, var_g;
  c = 33;
  var_a = d = 12;
  var_b = 1;

  if (e == 2) {
    if (var_a === 1 && var_b == 1) {
      if (var_a === 1) {
        var_g = var_b == 1;
      } else {
        var_g = var_a === 1;
      }
    } else {
      if (c == 1 && d == 1) {
        var_g = c == 4;
      } else {
        if (c == 1) {
          var_g = d == 1;
        } else {
          var_g = c == 1;
        }
      }
    }
  } else {
    var_g = c = 2;
  }

  for (var var_i; var_i < 10; var_i++) {
    if (s < 1) {
      s++
    }
  }
  let result;
  var_a = 2;
  d == 2;
  e = false;
  result = e = true;
  return result;
}

可以看到相對於壓縮後的代碼,我們轉換後的代碼變長了很多。

這份代碼相較於上一份,可讀性大大增強了。

另外我已經使用 jQuery 壓縮後的文件測試過了,轉換沒有任何問題。

然而,依然不保證轉換後的代碼一定正確,js 的 hack 玩法太多,只能說用這個轉換肯定可控。

核心玩法:抽象語法樹

想要解析修改這種壓縮 js,需要用到我們的抽象語法樹。

所謂抽象語法樹,實際上就是一種樹形結構來表示編程語句。

具體可以百度,這裡不解釋太多,總之你可以理解為可以將一串代碼解析成一個樹形結構,這個樹形結構上面每個節點代表一種語法結構。

這裡列一個必備網站://astexplorer.net/,用來查看 js 被轉換為抽象語法樹後的樣子。

現在前端的基礎庫 babel 系列,就是通過抽象語法樹將 es6 轉換為 es5 的,當然也包括轉換 reacttypescript

因為抽象語法樹和代碼之間是可以相互轉換的。

所以我們的核心思路是將代碼轉換為抽象語法樹,然後在這個樹上做修改,修改完後再轉換為代碼。

應用 recast 去轉換代碼

js 代碼和抽象語法樹的轉換有很多 js 庫可以實現。

比如@babel/parserrecast,還有不少其他的庫,這裡我們使用 recast

我對這個研究也不深入,沒怎麼了解他們的優缺點,不過當時看到 recast滿足需求就直接用了。

可以在 npmjs 上找到 recast,裏面有簡單的介紹文檔:地址,也有倉庫地址。

但是 recast 的文檔不太夠,有的關鍵點還得自己看下具體的示例和源碼才能弄明白,不過也不難。

這裡就不展開了,先上一段我自己寫的簡單代碼:

import { parse, print } from "recast";
import { readFile, writeFile } from "fs";
import path from "path";
import modifyAst from "./utils/modifyAst.js";

const fromPath = path.join("./test/from/index.js");
const toPath = path.join("./test/to/index.js");

readFile(fromPath, { encoding: "utf8" }, (err, sourceCode) => {
    // 通過recast的parse函數轉換為ast語法樹
    const ast = parse(sourceCode);
    modifyAst(ast);
    writeFile(toPath, print(ast).code, () => {
        console.info("搞完");
    });
});

這段代碼的用處是從 from 文件夾下的文件獲取 js 代碼後,通過 recastparse 函數轉換為 ast語法樹 ,再通過我自定義的函數 modifyAst 來修改語法樹後,最後使用 recastprint 函數將 ast語法樹 轉換為 js 代碼。

這段內容比較簡單,主要就是藉助 recast 將代碼轉成抽象語法樹,再轉回代碼。

具體修改抽象語法樹在 modifyAst 裏面:

import addBlock from "./addBlock.js";
import modifyReturn from "./modifyReturn.js";
import modifyUnaryExpression from "./modifyUnaryExpression.js";
// 修改聲明中的表達式
import replaceVarName from "./modifyVariableDeclaration/replaceVarName.js";
import modifyDeclarationInit from "./modifyVariableDeclaration/modifyDeclarationInit.js";

// 修改表達式
import modifyExpressionStatement from "./modifyExpressionStatement/index.js";

/**
* 修改抽象語法樹
*/
const modifyAst = (ast) => {
  modifyUnaryExpression(ast);
  replaceVarName(ast);
  addBlock(ast);
  modifyReturn(ast);
  modifyDeclarationInit(ast);
  modifyExpressionStatement(ast);
};

export default modifyAst;

modifyAst 中,我將不同的語句修改按照功能進行了劃分到,寫在了不同的文件中。

本篇博客也不宜展開過多,我只挑一部分代碼展示:

import { types, visit } from "recast";

const { blockStatement } = types.builders;

/**
* 找到所有的if和for語句,給他們增加花括號
* @param {抽象語法樹} ast
*/
const addBlock = (ast) => {
  visit(ast, {
    // 找到所有的if語句給他們增加花括號
    visitIfStatement: function (path) {
      if (
        path.node.consequent != null &&
        path.node.consequent.type != "BlockStatement"
      ) {
        path.node.consequent = blockStatement([path.node.consequent]);
      }
      if (
        path.node.alternate != null &&
        path.node.alternate.type != "BlockStatement"
      ) {
        path.node.alternate = blockStatement([path.node.alternate]);
      }
      this.traverse(path);
    },
  });
};

export default addBlock;

上面這部分代碼的作用是遍歷抽象樹中所有的 if 語句,給那些沒加花括號的 if 語句加上花括號。

實際上就是使用 recastvisit 方法遍歷抽象語法樹。visitIfStatement 這個回調函數,就是在遍歷到 if 語句後執行的函數。

在函數中有兩個 if 語句,那就是判斷以及修改的代碼,這個不多講。

需要注意的是,recast 遍歷抽象語法樹時,如果識別到 if 語句後,不會繼續遍歷這個 if 語句里包裹的 if 語句,所以這裡使用

this.traverse(path);

這行代碼是用來繼續遍歷當前節點的子節點的,繼續往下找 if 語句。

如果你自己判斷出不需要向下遍歷,不能簡單地刪掉這段代碼,需要用這行代碼替換:

return false

返回 false 表示不再向下遍歷。

另外如果此時想直接使用新語句替換當前語句,可以直接返回一個新語句,例如:

return literal(true);

總結

總的來說,做完這個小工具算是解放了我大把的時間。

但是它只是我遇到典型壓縮代碼後,針對性進行更改的結果。可能遇到一些其他壓縮後的語法,效果不大好,您也可以針對相應語法自行修改。

當然,如果您有更好的方法和建議,也希望能不吝賜教。