roid 是一個極其簡單的打包軟件,使用 node.js 開發而成,看完本文,你能夠實現一個很是簡單的,可是又有實際用途的前端代碼打包工具。css
若是不想看教程,直接看代碼的(所有註釋):點擊地址前端
咱們天天都面對前端的這幾款編譯工具,可是在大量交談中我得知,並非不少人知道這些打包軟件背後的工做原理,所以有了這個 project 出現。誠然,你並不須要瞭解太多編譯原理之類的事情,若是你在此以前對 node.js 極爲熟悉,那麼你對前端打包工具必定能很是好的理解。node
弄清楚打包工具的背後原理,有利於咱們實現各類神奇的自動化、工程化東西,好比表單的雙向綁定,自創 JavaScript 語法,又如螞蟻金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自動掃描載入等,可以極大的提高咱們工做效率。webpack
不廢話,咱們直接開始。git
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
,所以,咱們並不考慮如何去解析 css
、md
、txt
等等之類的格式,咱們專心處理好 js
文件的打包,由於對於其餘文件而言,處理起來過程不太同樣,用文件後綴很容易將他們區分進行不一樣的處理,在這個版本,咱們仍是專一 js
。web
const rawCode = readFileSync(filename, 'utf-8')
函數注入一個 filename 顧名思義,就是文件名,讀取其的文件文本內容,而後對其進行 AST 的解析。咱們使用 babel
的 transform
方法去轉換咱們的原始代碼,經過轉換之後,咱們的代碼變成了抽象語法樹( AST
),你能夠經過 https://astexplorer.net/, 這個可視化的網站,看看 AST
生成的是什麼。npm
當咱們解析完之後,咱們就能夠提取當前文件中的 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 中.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
(依賴圖),用於描述咱們這個項目的全部的依賴關係,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
就可以輕鬆的找到文件的代碼,解釋這麼囉嗦的緣由是每每模塊之間的引用是錯中複雜的,這恰巧是這個概念難以解釋的緣由。
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
,一個二維元組做爲值:
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