Webpack4打包機制原理簡析

簡介

webpack是一個打包模塊化 JavaScript 的工具,在 webpack裏一切文件皆模塊,經過 Loader 轉換文件,經過 Plugin 注入鉤子,最後輸出由多個模塊組合成的文件。 webpack專一於構建模塊化項目。前端

簡單版打包模型

咱們先從簡單的入手看,當 webpack 的配置只有一個出口時,不考慮分包的狀況,其實咱們只獲得了一個bundle.js的文件,這個文件裏包含了咱們全部用到的js模塊,能夠直接被加載執行。那麼,我能夠分析一下它的打包思路,大概有如下4步:node

  1. 利用babel完成代碼轉換及解析,並生成單個文件的依賴模塊Map
  2. 從入口開始遞歸分析,並生成整個項目的依賴圖譜
  3. 將各個引用模塊打包爲一個當即執行函數
  4. 將最終的bundle文件寫入bundle.js中

單個文件的依賴模塊Map

咱們會可使用這幾個包:webpack

  • @babel/parser:負責將代碼解析爲抽象語法樹
  • @babel/traverse:遍歷抽象語法樹的工具,咱們能夠在語法樹中解析特定的節點,而後作一些操做,如ImportDeclaration獲取經過import引入的模塊,FunctionDeclaration獲取函數
  • @babel/core:代碼轉換,如ES6的代碼轉爲ES5的模式

由這幾個模塊的做用,其實已經能夠推斷出應該怎樣獲取單個文件的依賴模塊了,轉爲Ast->遍歷Ast->調用ImportDeclaration。代碼以下:git

// exportDependencies.js
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')

 const exportDependencies = (filename)=>{
    const content = fs.readFileSync(filename,'utf-8')
    // 轉爲Ast
    const ast = parser.parse(content, {
        sourceType : 'module' //babel官方規定必須加這個參數,否則沒法識別ES Module
    })

    const dependencies = {}
    //遍歷AST抽象語法樹
    traverse(ast, {
        //調用ImportDeclaration獲取經過import引入的模塊
        ImportDeclaration({node}){
            const dirname = path.dirname(filename)
            const newFile = './' + path.join(dirname, node.source.value)
            //保存所依賴的模塊
            dependencies[node.source.value] = newFile
        }
    })
    //經過@babel/core和@babel/preset-env進行代碼的轉換
    const {code} = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
    })
    return{
        filename,//該文件名
        dependencies,//該文件所依賴的模塊集合(鍵值對存儲)
        code//轉換後的代碼
    }
}
module.exports = exportDependencies
複製代碼

能夠跑一個例子:github

//info.js
const a = 1
export a
// index.js
import info from './info.js'
console.log(info)

//testExport.js
const exportDependencies = require('./exportDependencies')
console.log(exportDependencies('./src/index.js'))
複製代碼

控制檯輸出以下圖: web

GitHub
看圖就能夠理解,輸出的依賴是什麼啦~

單個文件的依賴模塊Map

有了獲取單個文件依賴的基礎,咱們就能夠在這基礎上,進一步得出整個項目的模塊依賴圖譜了。首先,從入口開始計算,獲得entryMap,而後遍歷entryMap.dependencies,取出其value(即依賴的模塊的路徑),而後再獲取這個依賴模塊的依賴圖譜,以此類推遞歸下去便可,代碼以下:數組

const exportDependencies = require('./exportDependencies')

//entry爲入口文件路徑
const exportGraph = (entry)=>{
    const entryModule = exportDependencies(entry)
    const graphArray = [entryModule]
    for(let i = 0; i < graphArray.length; i++){
        const item = graphArray[i];
        //拿到文件所依賴的模塊集合,dependencies的值參考exportDependencies
        const { dependencies } = item;
        for(let j in dependencies){
            graphArray.push(
                exportDependencies(dependencies[j])
            )//關鍵代碼,目的是將入口模塊及其全部相關的模塊放入數組
        }
    }
    //接下來生成圖譜
    const graph = {}
    graphArray.forEach(item => {
        graph[item.filename] = {
            dependencies: item.dependencies,
            code: item.code
        }
    })
    //能夠看出,graph實際上是 文件路徑名:文件內容 的集合
    return graph
}
module.exports = exportGraph
複製代碼

這裏就不貼測試例子圖了,有興趣的能夠本身跑如下~~緩存

輸出當即執行函數

首先,咱們的代碼被加載到頁面中的時候,是須要當即執行的。因此輸出的bundle.js實質上要是一個當即執行函數。咱們主要注意如下幾點:微信

  • 咱們寫模塊的時候,用的是 import/export.經轉換後,變成了require/exports
  • 咱們要讓require/exports能正常運行,那麼咱們得定義這兩個東西,並加到bundle.js裏
  • 在依賴圖譜裏,代碼都成了字符串。要執行,可使用eval

所以,咱們要作這些工做:babel

  1. 定義一個require函數,require函數的本質是執行一個模塊的代碼,而後將相應變量掛載到exports對象上
  2. 獲取整個項目的依賴圖譜,從入口開始,調用require方法。 完整代碼以下:
const exportGraph = require('./exportGraph')
// 寫入文件,能夠用fs.writeFileSync等方法,寫入到output.path中
const exportBundle = require('./exportBundle')

const exportCode = (entry)=>{
    //要先把對象轉換爲字符串,否則在下面的模板字符串中會默認調取對象的toString方法,參數變成[Object object]
    const graph = JSON.stringify(exportGraph(entry))
    exportBundle(` (function(graph) { //require函數的本質是執行一個模塊的代碼,而後將相應變量掛載到exports對象上 function require(module) { //InnerRequire的本質是拿到依賴包的exports變量 function InnerRequire(relativePath) { return require(graph[module].dependencies[relativePath]); } var exports = {}; (function(require, exports, code) { eval(code); })(InnerRequire, exports, graph[module].code); return exports; //函數返回指向局部變量,造成閉包,exports變量在函數執行後不會被摧毀 } require('${entry}') })(${graph})`)
}
module.exports = exportCode
複製代碼

將最終的bundle文件寫入bundle.js中

這裏,直接使用node的內置模塊,fs來寫入文件。根據webpack的output.path,來輸出到對應的目錄便可。我這裏,爲了簡便,直接固定了輸出路徑,代碼以下:

const fs = require('fs')
const path = require('path')

const exportBundle = (data)=>{
    const directoryPath = path.resolve(__dirname,'dist')
    if (!fs.existsSync(directoryPath)) {
      fs.mkdirSync(directoryPath)
    }
    const filePath =  path.resolve(__dirname, 'dist/bundle.js')
    fs.writeFileSync(filePath, `${data}\n`)
}
const access = async filePath => new Promise((resolve, reject) => {
    fs.access(filePath, (err) => {
        if (err) {
        if (err.code === 'EXIST') {
            resolve(true)
        }
        resolve(false)
        }
        resolve(true)
    })
})
module.exports = exportBundle
複製代碼

至此,簡單打包完成。我貼一下我跑的demo的結果。bundle.js的文件內容爲:

(function(graph) {
            //require函數的本質是執行一個模塊的代碼,而後將相應變量掛載到exports對象上
            function require(module) {
                //InnerRequire的本質是拿到依賴包的exports變量
                function InnerRequire(relativePath) {
                    return require(graph[module].dependencies[relativePath]);
                }
                var exports = {};
                (function(require, exports, code) {
                    eval(code);
                })(InnerRequire, exports, graph[module].code);
                return exports;//函數返回指向局部變量,造成閉包,exports變量在函數執行後不會被摧毀
            }
            require('./src/index.js')
        })({"./src/index.js":{"dependencies":{"./info.js":"./src/info.js"},"code":"\"use strict\";\n\nvar _info = _interopRequireDefault(require(\"./info.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_info[\"default\"]);"},"./src/info.js":{"dependencies":{"./name.js":"./src/name.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _name = require(\"./name.js\");\n\nvar info = \"\".concat(_name.name, \" is beautiful\");\nvar _default = info;\nexports[\"default\"] = _default;"},"./src/name.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.name = void 0;\nvar name = 'winty';\nexports.name = name;"}})
複製代碼

至此,簡單打包模型完成。須要看例子的移步至:github.com/LuckyWinty/…

webpack打包流程歸納

webpack的運行流程是一個串行的過程,從啓動到結束會依次執行如下流程:

  1. 初始化參
  2. 開始編譯 用上一步獲得的參數初始Compiler對象,加載全部配置的插件,通 過執行對象的run方法開始執行編譯
  3. 肯定入口 根據配置中的 Entry 找出全部入口文件
  4. 編譯模塊 從入口文件出發,調用全部配置的 Loader 對模塊進行編譯,再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理
  5. 完成模塊編譯 在通過第4步使用 Loader 翻譯完全部模塊後, 獲得了每一個模塊被編譯後的最終內容及它們之間的依賴關係
  6. 輸出資源:根據入口和模塊之間的依賴關係,組裝成一個個包含多個模塊的 Chunk,再將每一個 Chunk 轉換成一個單獨的文件加入輸出列表中,這是能夠修改輸出內容的最後機會
  7. 輸出完成:在肯定好輸出內容後,根據配置肯定輸出的路徑和文件名,將文件的內容寫入文件系統中。

在以上過程當中, Webpack 會在特定的時間點廣播特定的事件,插件在監聽到感興趣的事件後會執行特定的邏輯,井且插件能夠調用 Webpack 提供的 API 改變 Webpack 的運行結果。其實以上7個步驟,能夠簡單概括爲初始化、編譯、輸出,三個過程,而這個過程其實就是前面說的基本模型的擴展。

webpack打包結果簡化對比

下面,咱們直接看看,webpack4 打包後的代碼長什麼樣,跟咱們上文的簡化模型有何區別(爲了格式好看點,我精簡了一下,用圖片表示):

GitHub
能夠看到,webpack中,有個 __webpack_require____webpack_exports__是否是很眼熟?而後再認真觀察,有個Module對象,key是模塊名,value是代碼塊。輸出的也是當即執行函數,從入口開始執行...

__webpack_require__實現

這裏也是放一個簡化的圖,由於源碼,太多啦!以下:

GitHub
能夠看到,這裏的核心邏輯,和簡化版實際上是同樣的。這裏的moduleId就是模塊路徑,如./src/commonjs/index.js。

要注意的是,webpack4中只有optimization.namedModules爲true,此時moduleId纔會爲模塊路徑,不然是數字id。爲了方便開發者調試,在development模式下optimization.namedModules參數默認爲true。

總結

其實簡單模型仍是很好理解的。咱們理解了以後,就能夠更方便地深刻去了解webpack的多入口打包(應該一樣的機制跑2次就能夠了吧),公共包抽離(由於模塊加載時有緩存,只有加上一個次數記錄就能夠知道這個包被加載了多少次,就能夠抽離出來作公共包)了。固然仍是不少細節的地方,須要耐心細緻地去理解的。持續學習吧!

參考資料

最後

  • 歡迎加我微信(winty230),拉你進技術羣,長期交流學習...
  • 歡迎關注「前端Q」,認真學前端,作個有專業的技術人...

GitHub
相關文章
相關標籤/搜索