窺探原理:實現一個簡單的前端程式碼打包器 Roid

215566435發表於2019-02-16

roid

roid 是一個極其簡單的打包軟體,使用 node.js 開發而成,看完本文,你可以實現一個非常簡單的,但是又有實際用途的前端程式碼打包工具。

如果不想看教程,直接看程式碼的(全部註釋):點選地址

為什麼要寫 roid ?

我們每天都面對前端的這幾款編譯工具,但是在大量交談中我得知,並不是很多人知道這些打包軟體背後的工作原理,因此有了這個 project 出現。誠然,你並不需要了解太多編譯原理之類的事情,如果你在此之前對 node.js 極為熟悉,那麼你對前端打包工具一定能非常好的理解。

弄清楚打包工具的背後原理,有利於我們實現各種神奇的自動化、工程化東西,比如表單的雙向繫結,自創 JavaScript 語法,又如螞蟻金服 ant 中大名鼎鼎的 import 外掛,甚至是前端檔案自動掃描載入等,能夠極大的提升我們工作效率。

不廢話,我們直接開始。

從一個自增 id 開始

const { readFileSync, writeFileSync } = require(`fs`)
const path = require(`path`)
const traverse = require(`babel-traverse`).default
const { transformFromAst, transform } = require(`babel-core`)

let ID = 0

// 當前使用者的操作的目錄
const currentPath = process.cwd()

id:全域性的自增 id ,記錄每一個載入的模組的 id ,我們將所有的模組都用唯一識別符號進行標示,因此自增 id 是最有效也是最直觀的,有多少個模組,一統計就出來了。

解析單個檔案模組

function parseDependecies(filename) {
  const rawCode = readFileSync(filename, `utf-8`)

  const ast = transform(rawCode).ast

  const dependencies = []

  traverse(ast, {
    ImportDeclaration(path) {
      const sourcePath = path.node.source.value
      dependencies.push(sourcePath)
    }
  })

  // 當我們完成依賴的收集以後,我們就可以把我們的程式碼從 AST 轉換成 CommenJS 的程式碼
  // 這樣子相容性更高,更好
  const es5Code = transformFromAst(ast, null, {
    presets: [`env`]
  }).code

  // 還記得我們的 webpack-loader 系統嗎?
  // 具體實現就是在這裡可以實現
  // 通過將檔名和程式碼都傳入 loader 中,進行判斷,甚至使用者定義行為再進行轉換
  // 就可以實現 loader 的機制,當然,我們在這裡,就做一個弱智版的 loader 就可以了
  // parcel 在這裡的優化技巧是很有意思的,在 webpack 中,我們每一個 loader 之間傳遞的是轉換好的程式碼
  // 而不是 AST,那麼我們必須要在每一個 loader 進行 code -> AST 的轉換,這樣時非常耗時的
  // parcel 的做法其實就是將 AST 直接傳遞,而不是轉換好的程式碼,這樣,速度就快起來了
  const customCode = loader(filename, es5Code)

  // 最後模組匯出
  return {
    id: ID++,
    code: customCode,
    dependencies,
    filename
  }
}

首先,我們對每一個檔案進行處理。因為這只是一個簡單版本的 bundler ,因此,我們並不考慮如何去解析 cssmdtxt 等等之類的格式,我們專心處理好 js 檔案的打包,因為對於其他檔案而言,處理起來過程不太一樣,用檔案字尾很容易將他們區分進行不同的處理,在這個版本,我們還是專注 js

const rawCode = readFileSync(filename, `utf-8`) 函式注入一個 filename 顧名思義,就是檔名,讀取其的檔案文字內容,然後對其進行 AST 的解析。我們使用 babeltransform 方法去轉換我們的原始程式碼,通過轉換以後,我們的程式碼變成了抽象語法樹( AST ),你可以通過 https://astexplorer.net/, 這個視覺化的網站,看看 AST 生成的是什麼。

當我們解析完以後,我們就可以提取當前檔案中的 dependenciesdependencies 翻譯為依賴,也就是我們檔案中所有的 import xxxx from xxxx,我們將這些依賴都放在 dependencies 的陣列裡面,之後統一進行匯出。

然後通過 traverse 遍歷我們的程式碼。traverse 函式是一個遍歷 AST 的方法,由 babel-traverse 提供,他的遍歷模式是經典的 visitor 模式
visitor 模式就是定義一系列的 visitor ,當碰到 ASTtype === visitor 名字時,就會進入這個 visitor 的函式。型別為 ImportDeclaration 的 AST 節點,其實就是我們的 import xxx from xxxx,最後將地址 push 到 dependencies 中.

最後匯出的時候,不要忘記了,每匯出一個檔案模組,我們都往全域性自增 id+ 1,以保證每一個檔案模組的唯一性。

解析所有檔案,生成依賴圖

function parseGraph(entry) {
  // 從 entry 出發,首先收集 entry 檔案的依賴
  const entryAsset = parseDependecies(path.resolve(currentPath, entry))

  // graph 其實是一個陣列,我們將最開始的入口模組放在最開頭
  const graph = [entryAsset]

  for (const asset of graph) {
    if (!asset.idMapping) asset.idMapping = {}

    // 獲取 asset 中檔案對應的資料夾
    const dir = path.dirname(asset.filename)

    // 每個檔案都會被 parse 出一個 dependencise,他是一個陣列,在之前的函式中已經講到
    // 因此,我們要遍歷這個陣列,將有用的資訊全部取出來
    // 值得關注的是 asset.idMapping[dependencyPath] = denpendencyAsset.id 操作
    // 我們往下看
    asset.dependencies.forEach(dependencyPath => {
      // 獲取檔案中模組的絕對路徑,比如 import ABC from `./world`
      // 會轉換成 /User/xxxx/desktop/xproject/world 這樣的形式
      const absolutePath = path.resolve(dir, dependencyPath)

      // 解析這些依賴
      const denpendencyAsset = parseDependecies(absolutePath)

      // 獲取唯一 id
      const id = denpendencyAsset.id

      // 這裡是重要的點了,我們解析每解析一個模組,我們就將他記錄在這個檔案模組 asset 下的 idMapping 中
      // 之後我們 require 的時候,能夠通過這個 id 值,找到這個模組對應的程式碼,並進行執行
      asset.idMapping[dependencyPath] = denpendencyAsset.id

      // 將解析的模組推入 graph 中去
      graph.push(denpendencyAsset)
    })
  }

  // 返回這個 graph
  return graph
}

接下來,我們對模組進行更高階的處理。我們之前已經寫了一個 parseDependecies 函式,那麼現在我們要來寫一個 parseGraph 函式,我們將所有檔案模組組成的集合叫做 graph(依賴圖),用於描述我們這個專案的所有的依賴關係,parseGraphentry (入口) 出發,一直手機完所有的以來檔案為止.

在這裡我們使用 for of 迴圈而不是 forEach ,原因是因為我們在迴圈之中會不斷的向 graph 中,push 進東西,graph 會不斷增加,用 for of 會一直持續這個迴圈直到 graph 不會再被推進去東西,這就意味著,所有的依賴已經解析完畢,graph 陣列數量不會繼續增加,但是用 forEach 是不行的,只會遍歷一次。

for of 迴圈中,asset 代表解析好的模組,裡面有 filename , code , dependencies 等東西 asset.idMapping 是一個不太好理解的概念,我們每一個檔案都會進行 import 操作,import 操作在之後會被轉換成 require 每一個檔案中的 requirepath 其實會對應一個數字自增 id,這個自增 id 其實就是我們一開始的時候設定的 id,我們通過將 path-id 利用鍵值對,對應起來,之後我們在檔案中 require 就能夠輕鬆的找到檔案的程式碼,解釋這麼囉嗦的原因是往往模組之間的引用是錯中複雜的,這恰巧是這個概念難以解釋的原因。

最後,生成 bundle

function build(graph) {
  // 我們的 modules 就是一個字串
  let modules = ``

  graph.forEach(asset => {
    modules += `${asset.id}:[
            function(require,module,exports){${asset.code}},
            ${JSON.stringify(asset.idMapping)},
        ],`
  })

  const wrap = `
  (function(modules) {
    function require(id) {
      const [fn, idMapping] = modules[id];
      function childRequire(filename) {
        return require(idMapping[filename]);
      }
      const newModule = {exports: {}};
      fn(childRequire, newModule, newModule.exports);
      return newModule.exports
    }
    require(0);
  })({${modules}});` // 注意這裡需要給 modules 加上一個 {}
  return wrap
}

// 這是一個 loader 的最簡單實現
function loader(filename, code) {
  if (/index/.test(filename)) {
    console.log(`this is loader `)
  }
  return code
}

// 最後我們匯出我們的 bundler
module.exports = entry => {
  const graph = parseGraph(entry)
  const bundle = build(graph)
  return bundle
}

我們完成了 graph 的收集,那麼就到我們真正的程式碼打包了,這個函式使用了大量的字串處理,你們不要覺得奇怪,為什麼程式碼和字串可以混起來寫,如果你跳出寫程式碼的範疇,看我們的程式碼,實際上,程式碼就是字串,只不過他通過特殊的語言形式組織起來而已,對於指令碼語言 JS 來說,字串拼接成程式碼,然後跑起來,這種操作在前端非常的常見,我認為,這種思維的轉換,是擁有自動化、工程化的第一步。

我們將 graph 中所有的 asset 取出來,然後使用 node.js 製造模組的方法來將一份程式碼包起來,我之前做過一個《庖丁解牛:教你如何實現》node.js 模組的文章,不懂的可以去看看,https://zhuanlan.zhihu.com/p/…

在這裡簡單講述,我們將轉換好的原始碼,放進一個 function(require,module,exports){} 函式中,這個函式的引數就是我們隨處可用的 requiremodule,以及 exports,這就是為什麼我們可以隨處使用這三個玩意的原因,因為我們每一個檔案的程式碼終將被這樣一個函式包裹起來,不過這段程式碼中比較奇怪的是,我們將程式碼封裝成了 1:[...],2:[...]的形式,我們在最後匯入模組的時候,會為這個字串加上一個 {},變成 {1:[...],2:[...]},你沒看錯,這是一個物件,這個物件裡用數字作為 key,一個二維元組作為值:

  • [0] 第一個就是我們被包裹的程式碼
  • [1] 第二個就是我們的 mapping

馬上要見到曙光了,這一段程式碼實際上才是模組引入的核心邏輯,我們製造一個頂層的 require 函式,這個函式接收一個 id 作為值,並且返回一個全新的 module 物件,我們倒入我們剛剛製作好的模組,給他加上 {},使其成為 {1:[...],2:[...]} 這樣一個完整的形式。

然後塞入我們的立即執行函式中(function(modules) {...})(),在 (function(modules) {...})() 中,我們先呼叫 require(0),理由很簡單,因為我們的主模組永遠是排在第一位的,緊接著,在我們的 require 函式中,我們拿到外部傳進來的 modules,利用我們一直在說的全域性數字 id 獲取我們的模組,每個模組獲取出來的就是一個二維元組。

然後,我們要製造一個 子require,這麼做的原因是我們在檔案中使用 require 時,我們一般 require 的是地址,而頂層的 require 函式引數時 id
不要擔心,我們之前的 idMapping 在這裡就用上了,通過使用者 require 進來的地址,在 idMapping 中找到 id

然後遞迴呼叫 require(id),就能夠實現模組的自動倒入了,接下來製造一個 const newModule = {exports: {}};,執行我們的函式 fn(childRequire, newModule, newModule.exports);,將應該丟進去的丟進去,最後 return newModule.exports 這個模組的 exports 物件。

這裡的邏輯其實跟 node.js 差別不太大。

最後寫一點測試

測試的程式碼,我已經放在了倉庫裡,想測試一下的同學可以去倉庫中自行提取。

打滿註釋的程式碼也放在倉庫了,點選地址

git clone https://github.com/Foveluy/roid.git
npm i
node ./src/_test.js ./example/index.js

輸出

this is loader

hello zheng Fang!
welcome to roid, I`m zheng Fang

if you love roid and learnt any thing, please give me a star
https://github.com/Foveluy/roid

參考

  1. https://github.com/blackLearn…
  2. https://github.com/ronami/min…

相關文章