Webpack 模組打包原理
在使用webpack的過程中,你是否好奇webpack打包的程式碼為什麼可以直接在瀏覽器中跑?為什麼webpack可以支援各種ES6最新語法?為什麼在webpack中可以書寫import ES6模組,也支援require CommonJS模組?
模組規範
關於模組,我們先來認識下目前主流的模組規範(自從有了ES6 Module及Webpack等工具,AMD/CMD規範生存空間已經很小了):
- CommonJS
- UMD
- ES6 Module
CommonJS
ES6前,js沒有屬於自己的模組規範,所以社群制定了 CommonJS規範。而NodeJS所使用的模組系統就是基於CommonJS規範實現的。
// CommonJS 匯出 module.exports = { age: 1, a: 'hello', foo:function(){} } // CommonJS 匯入 const foo = require('./foo.js') 複製程式碼
UMD
根據當前執行環境的判斷,如果是 Node 環境 就是使用 CommonJS 規範, 如果不是就判斷是否為 AMD 環境, 最後匯出全域性變數。這樣程式碼可以同時執行在Node和瀏覽器環境中。目前大部分庫都是打包成UMD規範,Webpack也支援UMD打包,配置API是output.libraryTarget。詳細案例可以看筆者封裝的npm工具包:cache-manage-js
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.libName = factory()); }(this, (function () { 'use strict';}))); 複製程式碼
ES6 Module
ES6 模組的設計思想是儘量的靜態化,使得編譯時就能確定模組的依賴關係,以及輸入和輸出的變數。具體思想和語法可以看筆者的另外一篇文章:ES6-模組詳解
// es6模組 匯出 export default { age: 1, a: 'hello', foo:function(){} } // es6模組 匯入 import foo from './foo' 複製程式碼
Webpack模組打包
既然模組規範有這麼多,那webpack是如何去解析不同的模組呢?
webpack根據webpack.config.js中的入口檔案,在入口檔案裡識別模組依賴,不管這裡的模組依賴是用CommonJS寫的,還是ES6 Module規範寫的,webpack會自動進行分析,並通過轉換、編譯程式碼,打包成最終的檔案。最終檔案中的模組實現是基於webpack自己實現的webpack_require(es5程式碼)
,所以打包後的檔案可以跑在瀏覽器上。
同時以上意味著在webapck環境下,你可以只使用ES6 模組語法書寫程式碼(通常我們都是這麼做的),也可以使用CommonJS模組語法,甚至可以兩者混合使用。因為從webpack2開始,內建了對ES6、CommonJS、AMD 模組化語句的支援,webpack會對各種模組進行語法分析,並做轉換編譯
。
我們舉個例子來分析下打包後的原始碼檔案,例子原始碼在webpack-module-example
// webpack.config.js const path = require('path'); module.exports = { mode: 'development', // JavaScript 執行入口檔案 entry: './src/main.js', output: { // 把所有依賴的模組合併輸出到一個 bundle.js 檔案 filename: 'bundle.js', // 輸出檔案都放到 dist 目錄下 path: path.resolve(__dirname, './dist'), } }; 複製程式碼
// src/add export default function(a, b) { let { name } = { name: 'hello world,'} // 這裡特意使用了ES6語法 return name + a + b } // src/main.js import Add from './add' console.log(Add, Add(1, 2)) 複製程式碼
打包後精簡的bundle.js檔案如下:
// modules是存放所有模組的陣列,陣列中每個元素儲存{ 模組路徑: 模組匯出程式碼函式 } (function(modules) { // 模組快取作用,已載入的模組可以不用再重新讀取,提升效能 var installedModules = {}; // 關鍵函式,載入模組程式碼 // 形式有點像Node的CommonJS模組,但這裡是可跑在瀏覽器上的es5程式碼 function __webpack_require__(moduleId) { // 快取檢查,有則直接從快取中取得 if(installedModules[moduleId]) { return installedModules[moduleId].exports; } // 先建立一個空模組,塞入快取中 var module = installedModules[moduleId] = { i: moduleId, l: false, // 標記是否已經載入 exports: {} // 初始模組為空 }; // 把要載入的模組內容,掛載到module.exports上 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l = true; // 標記為已載入 // 返回載入的模組,呼叫方直接呼叫即可 return module.exports; } // __webpack_require__物件下的r函式 // 在module.exports上定義__esModule為true,表明是一個模組物件 __webpack_require__.r = function(exports) { Object.defineProperty(exports, '__esModule', { value: true }); }; // 啟動入口模組main.js return __webpack_require__(__webpack_require__.s = "./src/main.js"); }) ({ // add模組 "./src/add.js": (function(module, __webpack_exports__, __webpack_require__) { // 在module.exports上定義__esModule為true __webpack_require__.r(__webpack_exports__); // 直接把add模組內容,賦給module.exports.default物件上 __webpack_exports__["default"] = (function(a, b) { let { name } = { name: 'hello world,'} return name + a + b }); }), // 入口模組 "./src/main.js": (function(module, __webpack_exports__, __webpack_require__) { __webpack_require__.r(__webpack_exports__) // 拿到add模組的定義 // _add__WEBPACK_IMPORTED_MODULE_0__ = module.exports,有點類似require var _add__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/add.js"); // add模組內容: _add__WEBPACK_IMPORTED_MODULE_0__["default"] console.log(_add__WEBPACK_IMPORTED_MODULE_0__["default"], Object(_add__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2)) }) }); 複製程式碼
以上核心程式碼中,能讓打包後的程式碼直接跑在瀏覽器中,是因為webpack通過__webpack_require__ 函式模擬了模組的載入(類似於node中的require語法),把定義的模組內容掛載到module.exports上。同時__webpack_require__函式中也對模組快取做了優化,防止模組二次重新載入,優化效能。
再讓我們看下webpack的原始碼:
// webpack/lib/MainTemplate.js // 主檔案模板 // webpack生成的最終檔案叫chunk,chunk包含若干的邏輯模組,即為module this.hooks.render.tap( "MainTemplate", (bootstrapSource, chunk, hash, moduleTemplate, dependencyTemplates) => { const source = new ConcatSource(); source.add("/******/ (function(modules) { // webpackBootstrap\n"); // 入口內容,__webpack_require__就在bootstrapSource中 source.add(new PrefixSource("/******/", bootstrapSource)); source.add("/******/ })\n"); source.add( "/************************************************************************/\n" ); source.add("/******/ ("); source.add( // 依賴的module都會寫入對應陣列 this.hooks.modules.call( new RawSource(""), chunk, hash, moduleTemplate, dependencyTemplates ) ); source.add(")"); return source; } 複製程式碼
Webpack ES6語法支援
可能細心的讀者看到,以上打包後的add模組程式碼中依然還是ES6語法,在低端的瀏覽器中不支援。這是因為沒有對應的loader去解析js程式碼,webpack把所有的資源都視作模組,不同的資源使用不同的loader進行轉換。
這裡需要使用babel-loader及其外掛@babel/preset-env進行處理,把ES6程式碼轉換成可在瀏覽器中跑的es5程式碼。
// webpack.config.js module.exports = { ..., module: { rules: [ { // 對以js字尾的檔案資源,用babel進行處理 test: /\.m?js$/, exclude: /(node_modules|bower_components)/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } } ] } }; 複製程式碼
// 經過babel處理es6語法後的程式碼 __webpack_exports__["default"] = (function (a, b) { var _name = {name: 'hello world,'}, name = _name.name; return name + a + b; }); 複製程式碼
總結
- webpack對於ES模組/CommonJS模組的實現,是基於自己實現的webpack_require,所以程式碼能跑在瀏覽器中。
- 從 webpack2 開始,已經內建了對 ES6、CommonJS、AMD 模組化語句的支援。但不包括新的ES6語法轉為ES5程式碼,這部分工作還是留給了babel及其外掛。
- 在webpack中可以同時使用ES6模組和CommonJS模組。因為 module.exports很像export default,所以ES6模組可以很方便相容 CommonJS:import XXX from 'commonjs-module'。反過來CommonJS相容ES6模組,需要額外加上default:require('es-module').default。