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

roid

roid 是一個極其簡單的打包軟件,使用 node.js 開發而成,看完本文,你能夠實現一個很是簡單的,可是又有實際用途的前端代碼打包工具。css

若是不想看教程,直接看代碼的(所有註釋):點擊地址前端

爲何要寫 roid ?

咱們天天都面對前端的這幾款編譯工具,可是在大量交談中我得知,並非不少人知道這些打包軟件背後的工做原理,所以有了這個 project 出現。誠然,你並不須要瞭解太多編譯原理之類的事情,若是你在此以前對 node.js 極爲熟悉,那麼你對前端打包工具必定能很是好的理解。node

弄清楚打包工具的背後原理,有利於咱們實現各類神奇的自動化、工程化東西,好比表單的雙向綁定,自創 JavaScript 語法,又如螞蟻金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自動掃描載入等,可以極大的提高咱們工做效率。webpack

不廢話,咱們直接開始。git

從一個自增 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 是最有效也是最直觀的,有多少個模塊,一統計就出來了。github

解析單個文件模塊

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 文件的打包,由於對於其餘文件而言,處理起來過程不太同樣,用文件後綴很容易將他們區分進行不一樣的處理,在這個版本,咱們仍是專一 jsweb

const rawCode = readFileSync(filename, 'utf-8') 函數注入一個 filename 顧名思義,就是文件名,讀取其的文件文本內容,而後對其進行 AST 的解析。咱們使用 babeltransform 方法去轉換咱們的原始代碼,經過轉換之後,咱們的代碼變成了抽象語法樹( AST ),你能夠經過 https://astexplorer.net/, 這個可視化的網站,看看 AST 生成的是什麼。npm

當咱們解析完之後,咱們就能夠提取當前文件中的 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 中.bash

最後導出的時候,不要忘記了,每導出一個文件模塊,咱們都往全局自增 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...
相關文章
相關標籤/搜索