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
,因此,我們並不考慮如何去解析 css
、md
、txt
等等之類的格式,我們專心處理好 js
檔案的打包,因為對於其他檔案而言,處理起來過程不太一樣,用檔案字尾很容易將他們區分進行不同的處理,在這個版本,我們還是專注 js
。
const rawCode = readFileSync(filename, `utf-8`)
函式注入一個 filename 顧名思義,就是檔名,讀取其的檔案文字內容,然後對其進行 AST 的解析。我們使用 babel
的 transform
方法去轉換我們的原始程式碼,通過轉換以後,我們的程式碼變成了抽象語法樹( AST
),你可以通過 https://astexplorer.net/, 這個視覺化的網站,看看 AST
生成的是什麼。
當我們解析完以後,我們就可以提取當前檔案中的 dependencies
,dependencies
翻譯為依賴,也就是我們檔案中所有的 import xxxx from xxxx
,我們將這些依賴都放在 dependencies
的陣列裡面,之後統一進行匯出。
然後通過 traverse
遍歷我們的程式碼。traverse
函式是一個遍歷 AST
的方法,由 babel-traverse
提供,他的遍歷模式是經典的 visitor
模式
,visitor
模式就是定義一系列的 visitor
,當碰到 AST
的 type === 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
(依賴圖),用於描述我們這個專案的所有的依賴關係,parseGraph
從 entry
(入口) 出發,一直手機完所有的以來檔案為止.
在這裡我們使用 for of
迴圈而不是 forEach
,原因是因為我們在迴圈之中會不斷的向 graph
中,push
進東西,graph
會不斷增加,用 for of
會一直持續這個迴圈直到 graph
不會再被推進去東西,這就意味著,所有的依賴已經解析完畢,graph
陣列數量不會繼續增加,但是用 forEach
是不行的,只會遍歷一次。
在 for of
迴圈中,asset
代表解析好的模組,裡面有 filename
, code
, dependencies
等東西 asset.idMapping
是一個不太好理解的概念,我們每一個檔案都會進行 import
操作,import
操作在之後會被轉換成 require
每一個檔案中的 require
的 path
其實會對應一個數字自增 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){}
函式中,這個函式的引數就是我們隨處可用的 require
,module
,以及 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