1. 程式人生 > 實用技巧 >Webpack 原理淺析(轉載)

Webpack 原理淺析(轉載)

背景

Webpack迭代到4.x版本後,其原始碼已經十分龐大,對各種開發場景進行了高度抽象,閱讀成本也愈發昂貴。但是為了瞭解其內部的工作原理,讓我們嘗試從一個最簡單的 webpack 配置入手,從工具設計者的角度開發一款低配版的Webpack

開發者視角

假設某一天,我們接到了需求,需要開發一個react單頁面應用,頁面中包含一行文字和一個按鈕,需要支援每次點選按鈕的時候讓文字發生變化。於是我們新建了一個專案,並且在[根目錄]/src下新建 JS 檔案。為了模擬Webpack追蹤模組依賴進行打包的過程,我們新建了 3 個 React 元件,並且在他們之間建立起一個簡單的依賴關係。

1 <code >//
index.js 根元件 2 import React from 'react' 3 import ReactDom from 'react-dom' 4 import App from './App' 5 ReactDom.render(<App />, document.querySelector('#container')) 6 </code>
 1 <code >// App.js 頁面元件
 2 import React from 'react'
 3 import Switch from './Switch.js'
 4 export default
class App extends React.Component { 5 constructor(props) { 6 super(props) 7 this.state = { 8 toggle: false 9 } 10 } 11 handleToggle() { 12 this.setState(prev => ({ 13 toggle: !prev.toggle 14 })) 15 } 16 render() { 17 const { toggle } = this.state 18 return ( 19 <div> 20 <h1>Hello, { toggle ? '
NervJS' : 'O2 Team'}</h1> 21 <Switch handleToggle={this.handleToggle.bind(this)} /> 22 </div> 23 ) 24 } 25 } 26 </code>
1 <code >// Switch.js 按鈕元件
2 import React from 'react'
3 export default function Switch({ handleToggle }) {
4 return (
5 <button onClick={handleToggle}>Toggle</button>
6 )
7 }
8 </code>

接著我們需要一個配置檔案讓Webpack知道我們期望它如何工作,於是我們在根目錄下新建一個檔案webpack.config.js並且向其中寫入一些基礎的配置。(如果不太熟悉配置內容可以先學習webpack中文文件)

 1 <code >// webpack.config.js
 2 const resolve = dir => require('path').join(__dirname, dir)
 3 module.exports = {
 4 // 入口檔案地址
 5 entry: './src/index.js',
 6 // 輸出檔案地址
 7 output: {
 8 path: resolve('dist'),
 9 fileName: 'bundle.js'
10 },
11 // loader
12 module: {
13 rules: [
14 {
15 test: /\.(js|jsx)$/,
16 // 編譯匹配include路徑的檔案
17 include: [
18 resolve('src')
19 ],
20 use: 'babel-loader'
21 }
22 ]
23 },
24 plugins: [
25 new HtmlWebpackPlugin()
26 ]
27 }
28 </code>

其中module的作用是在test欄位和檔名匹配成功時就用對應的 loader 對程式碼進行編譯,Webpack本身只認識.js.json這兩種型別的檔案,而通過loader,我們就可以對例如 css 等其他格式的檔案進行處理。

而對於React檔案而言,我們需要將 JSX 語法轉換成純 JS 語法,即React.createElement方法,程式碼才可能被瀏覽器所識別。平常我們是通過babel-loader並且配置好react的解析規則來做這一步。

經過以上處理之後。瀏覽器真正閱讀到的按鈕元件程式碼其實大概是這個樣子的。

1 <code >...
2 function Switch(_ref) {
3 var handleToggle = _ref.handleToggle;
4 return _nervjs["default"].createElement("button", {
5 onClick: handleToggle
6 }, "Toggle");
7 }
8 </code>

而至於plugin則是一些外掛,這些外掛可以將對編譯結果的處理函式註冊在Webpack的生命週期鉤子上,在生成最終檔案之前對編譯的結果做一些處理。比如大多數場景下我們需要將生成的 JS 檔案插入到 Html 檔案中去。就需要使用到html-webpack-plugin這個外掛,我們需要在配置中這樣寫。

 1 <code >const HtmlWebpackPlugin = require('html-webpack-plugin');
 2 const webpackConfig = {
 3 entry: 'index.js',
 4 output: {
 5 path: path.resolve(__dirname, './dist'),
 6 filename: 'index_bundle.js'
 7 },
 8 // 向plugins陣列中傳入一個HtmlWebpackPlugin外掛的例項
 9 plugins: [new HtmlWebpackPlugin()]
10 };
11 </code>

這樣,html-webpack-plugin會被註冊在打包的完成階段,並且會獲取到最終打包完成的入口 JS 檔案路徑,生成一個形如的 script 標籤插入到 Html 中。這樣瀏覽器就可以通過 html 檔案來展示頁面內容了。

ok,寫到這裡,對於一個開發者而言,所有配置項和需要被打包的工程程式碼檔案都已經準備完畢,接下來需要的就是將工作交給打包工具Webpack,通過Webpack將程式碼打包成我們和瀏覽器希望看到的樣子

工具視角

首先,我們需要了解Webpack打包的流程

Webpack的工作流程中可以看出,我們需要實現一個Compiler類,這個類需要收集開發者傳入的所有配置資訊,然後指揮整體的編譯流程。我們可以把Compiler理解為公司老闆,它統領全域性,並且掌握了全域性資訊(客戶需求)。在瞭解了所有資訊後它會呼叫另一個類Compilation生成例項,並且將所有的資訊和工作流程託付給它,Compilation其實就相當於老闆的祕書,需要去調動各個部門按照要求開始工作,而loaderplugin則相當於各個部門,只有在他們專長的工作( js , css , scss , jpg , png...)出現時才會去處理

為了既實現Webpack打包的功能,又只實現核心程式碼。我們對這個流程做一些簡化

首先我們新建了一個webpack函式作為對外暴露的方法,它接受兩個引數,其中一個是配置項物件,另一個則是錯誤回撥。

1 <code >const Compiler = require('./compiler')
2 function webpack(config, callback) {
3 // 此處應有引數校驗
4 const compiler = new Compiler(config)
5 // 開始編譯
6 compiler.run()
7 }
8 module.exports = webpack
9 </code>
複製程式碼

1. 構建配置資訊

我們需要先在Compiler類的構造方法裡面收集使用者傳入的資訊

 1 <code >class Compiler {
 2 constructor(config, _callback) {
 3 const {
 4 entry,
 5 output,
 6 module,
 7 plugins
 8 } = config
 9 // 入口
10 this.entryPath = entry
11 // 輸出檔案路徑
12 this.distPath = output.path
13 // 輸出檔名稱
14 this.distName = output.fileName
15 // 需要使用的loader
16 this.loaders = module.rules
17 // 需要掛載的plugin
18 this.plugins = plugins
19 // 根目錄
20 this.root = process.cwd()
21 // 編譯工具類Compilation
22 this.compilation = {}
23 // 入口檔案在module中的相對路徑,也是這個模組的id
24 this.entryId = getRootPath(this.root, entry, this.root)
25 }
26 }
27 </code>

同時,我們在建構函式中將所有的plugin掛載到例項的hooks屬性中去。Webpack的生命週期管理基於一個叫做tapable的庫,通過這個庫,我們可以非常方便的建立一個釋出訂閱模型的鉤子,然後通過將函式掛載到例項上(鉤子事件的回撥支援同步觸發、非同步觸發甚至進行鏈式回撥),在合適的時機觸發對應事件的處理函式。我們在hooks上宣告一些生命週期鉤子:

 1 <code >const { AsyncSeriesHook } = require('tapable') // 此處我們建立了一些非同步鉤子
 2 constructor(config, _callback) {
 3 ...
 4 this.hooks = {
 5 // 生命週期事件
 6 beforeRun: new AsyncSeriesHook(['compiler']), // compiler代表我們將向回撥事件中傳入一個compiler引數
 7 afterRun: new AsyncSeriesHook(['compiler']),
 8 beforeCompile: new AsyncSeriesHook(['compiler']),
 9 afterCompile: new AsyncSeriesHook(['compiler']),
10 emit: new AsyncSeriesHook(['compiler']),
11 failed: new AsyncSeriesHook(['compiler']),
12 }
13 this.mountPlugin()
14 }
15 // 註冊所有的plugin
16 mountPlugin() {
17 for(let i=0;i<this.plugins.length;i++) {
18 const item = this.plugins[i]
19 if ('apply' in item && typeof item.apply === 'function') {
20 // 註冊各生命週期鉤子的釋出訂閱監聽事件
21 item.apply(this)
22 }
23 }
24 }
25 // 當執行run方法的邏輯之前
26 run() {
27 // 在特定的生命週期釋出訊息,觸發對應的訂閱事件
28 this.hooks.beforeRun.callAsync(this) // this作為引數傳入,對應之前的compiler
29 ...
30 }
31 </code>

冷知識:
每一個plugin Class都必須實現一個apply方法,這個方法接收compiler例項,然後將真正的鉤子函式掛載到compiler.hook的某一個宣告週期上。
如果我們聲明瞭一個hook但是沒有掛載任何方法,在 call 函式觸發的時候是會報錯的。但是實際上Webpack的每一個生命週期鉤子除了掛載使用者配置的plugin,都會掛載至少一個Webpack自己的plugin,所以不會有這樣的問題。更多關於tapable的用法也可以移步Tapable

2. 編譯

接下來我們需要宣告一個Compilation類,這個類主要是執行編譯工作。在Compilation的建構函式中,我們先接收來自老闆Compiler下發的資訊並且掛載在自身屬性中。

 1 <code >class Compilation {
 2 constructor(props) {
 3 const {
 4 entry,
 5 root,
 6 loaders,
 7 hooks
 8 } = props
 9 this.entry = entry
10 this.root = root
11 this.loaders = loaders
12 this.hooks = hooks
13 }
14 // 開始編譯
15 async make() {
16 await this.moduleWalker(this.entry)
17 }
18 // dfs遍歷函式
19 moduleWalker = async () => {}
20 }
21 </code>

因為我們需要將打包過程中引用過的檔案都編譯到最終的程式碼包裡,所以需要宣告一個深度遍歷函式moduleWalker(這個名字是筆者取的,不是webpack官方取的),顧名思義,這個方法將會從入口檔案開始,依次對檔案進行第一步和第二步編譯,並且收集引用到的其他模組,遞迴進行同樣的處理。

編譯步驟分為兩步

  1. 第一步是使用所有滿足條件的loader對其進行編譯並且返回編譯之後的原始碼
  2. 第二步相當於是Webpack自己的編譯步驟,目的是構建各個獨立模組之間的依賴呼叫關係。我們需要做的是將所有的require方法替換成Webpack自己定義的__webpack_require__函式。因為所有被編譯後的模組將被Webpack儲存在一個閉包的物件moduleMap中,而__webpack_require__函式則是唯一一個有許可權訪問moduleMap的方法。

一句話解釋__webpack_require__的作用就是,將模組之間原本檔案地址 -> 檔案內容的關係替換成了物件的key -> 物件的value(檔案內容)這樣的關係。

在完成第二步編譯的同時,會對當前模組內的引用進行收集,並且返回到Compilation中, 這樣moduleWalker才能對這些依賴模組進行遞迴的編譯。當然其中大概率存在迴圈引用和重複引用,我們會根據引用檔案的路徑生成一個獨一無二的 key 值,在 key 值重複時進行跳過。

i.moduleWalker遍歷函式

 1 <code >// 存放處理完畢的模組程式碼Map
 2 moduleMap = {}
 3 // 根據依賴將所有被引用過的檔案都進行編譯
 4 async moduleWalker(sourcePath) {
 5 if (sourcePath in this.moduleMap) return
 6 // 在讀取檔案時,我們需要完整的以.js結尾的檔案路徑
 7 sourcePath = completeFilePath(sourcePath)
 8 const [ sourceCode, md5Hash ] = await this.loaderParse(sourcePath)
 9 const modulePath = getRootPath(this.root, sourcePath, this.root)
10 // 獲取模組編譯後的程式碼和模組內的依賴陣列
11 const [ moduleCode, relyInModule ] = this.parse(sourceCode, path.dirname(modulePath))
12 // 將模組程式碼放入ModuleMap
13 this.moduleMap[modulePath] = moduleCode
14 this.assets[modulePath] = md5Hash
15 // 再依次對模組中的依賴項進行解析
16 for(let i=0;i<relyInModule.length;i++) {
17 await this.moduleWalker(relyInModule[i], path.dirname(relyInModule[i]))
18 }
19 }
20 </code>

如果將dfs的路徑給log出來,我們就可以看到這樣的流程

ii. 第一步編譯loaderParse函式

 1 <code >async loaderParse(entryPath) {
 2 // 用utf8格式讀取檔案內容
 3 let [ content, md5Hash ] = await readFileWithHash(entryPath)
 4 // 獲取使用者注入的loader
 5 const { loaders } = this
 6 // 依次遍歷所有loader
 7 for(let i=0;i<loaders.length;i++) {
 8 const loader = loaders[i]
 9 const { test : reg, use } = loader
10 if (entryPath.match(reg)) {
11 // 判斷是否滿足正則或字串要求
12 // 如果該規則需要應用多個loader,從最後一個開始向前執行
13 if (Array.isArray(use)) {
14 while(use.length) {
15 const cur = use.pop()
16 const loaderHandler =
17 typeof cur.loader === 'string'
18 // loader也可能來源於package包例如babel-loader
19 ? require(cur.loader)
20 : (
21 typeof cur.loader === 'function'
22 ? cur.loader : _ => _
23 )
24 content = loaderHandler(content)
25 }
26 } else if (typeof use.loader === 'string') {
27 const loaderHandler = require(use.loader)
28 content = loaderHandler(content)
29 } else if (typeof use.loader === 'function') {
30 const loaderHandler = use.loader
31 content = loaderHandler(content)
32 }
33 }
34 }
35 return [ content, md5Hash ]
36 }
37 </code>

然而這裡遇到了一個小插曲,就是我們平常使用的babel-loader似乎並不能在Webpack包以外的場景被使用,在babel-loader的文件中看到了這樣一句話

This package allows transpiling JavaScript files using Babel and webpack.

不過好在@babel/corewebpack並無聯絡,所以只能辛苦一下,再手寫一個 loader 方法去解析JSES6的語法。

1 <code >const babel = require('@babel/core')
2 module.exports = function BabelLoader (source) {
3 const res = babel.transform(source, {
4 sourceType: 'module' // 編譯ES6 import和export語法
5 })
6 return res.code
7 }
8 </code>

當然,編譯規則可以作為配置項傳入,但是為了模擬真實的開發場景,我們需要配置一下babel.config.js檔案

 1 <code >module.exports = function (api) {
 2 api.cache(true)
 3 return {
 4 "presets": [
 5 ['@babel/preset-env', {
 6 targets: {
 7 "ie": "8"
 8 },
 9 }],
10 '@babel/preset-react', // 編譯JSX
11 ],
12 "plugins": [
13 ["@babel/plugin-transform-template-literals", {
14 "loose": true
15 }]
16 ],
17 "compact": true
18 }
19 }
20 </code>

於是,在獲得了loader處理過的程式碼之後,理論上任何一個模組都已經可以在瀏覽器或者單元測試中直接使用了。但是我們的程式碼是一個整體,還需要一種合理的方式來組織程式碼之間互相引用的關係。

上面也解釋了我們為什麼要使用__webpack_require__函式。這裡我們得到的程式碼仍然是字串的形式,為了方便我們使用eval函式將字串解析成直接可讀的程式碼。當然這只是求快的方式,對於 JS 這種解釋型語言,如果一個一個模組去解釋編譯的話,速度會非常慢。事實上真正的生產環境會將模組內容封裝成一個IIFE(立即自執行函式表示式)

總而言之,在第二部編譯parse函式中我們需要做的事情其實很簡單,就是將所有模組中的require方法的函式名稱替換成__webpack_require__即可。我們在這一步使用的是babel全家桶。babel作為業內頂尖的JS編譯器,分析程式碼的步驟主要分為兩步,分別是詞法分析和語法分析。簡單來說,就是對程式碼片段進行逐詞分析,根據當前單詞生成一個上下文語境。然後進行再判斷下一個單詞在上下文語境中所起的作用。

注意,在這一步中我們還可以“順便”蒐集模組的依賴項陣列一同返回(用於 dfs 遞迴)

 1 <code >const parser = require('@babel/parser')
 2 const traverse = require('@babel/traverse').default
 3 const types = require('@babel/types')
 4 const generator = require('@babel/generator').default
 5 ...
 6 // 解析原始碼,替換其中的require方法來構建ModuleMap
 7 parse(source, dirpath) {
 8 const inst = this
 9 // 將程式碼解析成ast
10 const ast = parser.parse(source)
11 const relyInModule = [] // 獲取檔案依賴的所有模組
12 traverse(ast, {
13 // 檢索所有的詞法分析節點,當遇到函式呼叫表示式的時候執行,對ast樹進行改寫
14 CallExpression(p) {
15 // 有些require是被_interopRequireDefault包裹的
16 // 所以需要先找到_interopRequireDefault節點
17 if (p.node.callee && p.node.callee.name === '_interopRequireDefault') {
18 const innerNode = p.node.arguments[0]
19 if (innerNode.callee.name === 'require') {
20 inst.convertNode(innerNode, dirpath, relyInModule)
21 }
22 } else if (p.node.callee.name === 'require') {
23 inst.convertNode(p.node, dirpath, relyInModule)
24 }
25 }
26 })
27 // 將改寫後的ast樹重新組裝成一份新的程式碼, 並且和依賴項一同返回
28 const moduleCode = generator(ast).code
29 return [ moduleCode, relyInModule ]
30 }
31 /**
32 * 將某個節點的name和arguments轉換成我們想要的新節點
33 */
34 convertNode = (node, dirpath, relyInModule) => {
35 node.callee.name = '__webpack_require__'
36 // 引數字串名稱,例如'react', './MyName.js'
37 let moduleName = node.arguments[0].value
38 // 生成依賴模組相對【專案根目錄】的路徑
39 let moduleKey = completeFilePath(getRootPath(dirpath, moduleName, this.root))
40 // 收集module陣列
41 relyInModule.push(moduleKey)
42 // 替換__webpack_require__的引數字串,因為這個字串也是對應模組的moduleKey,需要保持統一
43 // 因為ast樹中的每一個元素都是babel節點,所以需要使用'@babel/types'來進行生成
44 node.arguments = [ types.stringLiteral(moduleKey) ]
45 }
46 </code>

3.emit生成bundle檔案

執行到這一步,compilation的使命其實就已經完成了。如果我們平時有去觀察生成的 js 檔案的話,會發現打包出來的樣子是一個立即執行函式,主函式體是一個閉包,閉包中快取了已經載入的模組installedModules,以及定義了一個__webpack_require__函式,最終返回的是函式入口所對應的模組。而函式的引數則是各個模組的key-value所組成的物件。

我們在這裡通過ejs模板去進行拼接,將之前收集到的moduleMap物件進行遍歷,注入到ejs模板字串中去。

模板程式碼

 1 <code >// template.ejs
 2 (function(modules) { // webpackBootstrap
 3 // The module cache
 4 var installedModules = {};
 5 // The require function
 6 function __webpack_require__(moduleId) {
 7 // Check if module is in cache
 8 if(installedModules[moduleId]) {
 9 return installedModules[moduleId].exports;
10 }
11 // Create a new module (and put it into the cache)
12 var module = installedModules[moduleId] = {
13 i: moduleId,
14 l: false,
15 exports: {}
16 };
17 // Execute the module function
18 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
19 // Flag the module as loaded
20 module.l = true;
21 // Return the exports of the module
22 return module.exports;
23 }
24 // Load entry module and return exports
25 return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
26 })({
27 <%for(let key in modules) {%>
28 "<%-key%>":
29 (function(module, exports, __webpack_require__) {
30 eval(
31 `<%-modules[key]%>`
32 );
33 }),
34 <%}%>
35 });
36 </code>

生成bundle.js

 1 <code >/**
 2 * 發射檔案,生成最終的bundle.js
 3 */
 4 emitFile() { // 發射打包後的輸出結果檔案
 5 // 首先對比快取判斷檔案是否變化
 6 const assets = this.compilation.assets
 7 const pastAssets = this.getStorageCache()
 8 if (loadsh.isEqual(assets, pastAssets)) {
 9 // 如果檔案hash值沒有變化,說明無需重寫檔案
10 // 只需要依次判斷每個對應的檔案是否存在即可
11 // 這一步省略!
12 } else {
13 // 快取未能命中
14 // 獲取輸出檔案路徑
15 const outputFile = path.join(this.distPath, this.distName);
16 // 獲取輸出檔案模板
17 // const templateStr = this.generateSourceCode(path.join(__dirname, '..', "bundleTemplate.ejs"));
18 const templateStr = fs.readFileSync(path.join(__dirname, '..', "template.ejs"), 'utf-8');
19 // 渲染輸出檔案模板
20 const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.compilation.moduleMap});
21 this.assets = {};
22 this.assets[outputFile] = code;
23 // 將渲染後的程式碼寫入輸出檔案中
24 fs.writeFile(outputFile, this.assets[outputFile], function(e) {
25 if (e) {
26 console.log('[Error] ' + e)
27 } else {
28 console.log('[Success] 編譯成功')
29 }
30 });
31 // 將快取資訊寫入快取檔案
32 fs.writeFileSync(resolve(this.distPath, 'manifest.json'), JSON.stringify(assets, null, 2))
33 }
34 }
35 </code>

在這一步中我們根據檔案內容生成的Md5Hash去對比之前的快取來加快打包速度,細心的同學會發現Webpack每次打包都會生成一個快取檔案manifest.json,形如

 1 <code >{
 2 "main.js": "./js/main7b6b4.js",
 3 "main.css": "./css/maincc69a7ca7d74e1933b9d.css",
 4 "main.js.map": "./js/main7b6b4.js.map",
 5 "vendors~main.js": "./js/vendors~main3089a.js",
 6 "vendors~main.css": "./css/vendors~maincc69a7ca7d74e1933b9d.css",
 7 "vendors~main.js.map": "./js/vendors~main3089a.js.map",
 8 "js/28505f.js": "./js/28505f.js",
 9 "js/28505f.js.map": "./js/28505f.js.map",
10 "js/34c834.js": "./js/34c834.js",
11 "js/34c834.js.map": "./js/34c834.js.map",
12 "js/4d218c.js": "./js/4d218c.js",
13 "js/4d218c.js.map": "./js/4d218c.js.map",
14 "index.html": "./index.html",
15 "static/initGlobalSize.js": "./static/initGlobalSize.js"
16 }
17 </code>

這也是檔案斷點續傳中常用到的一個判斷,這裡就不做詳細的展開了


檢驗

做完這一步,我們已經基本大功告成了(誤:如果不考慮令人智息的debug過程的話),接下來我們在package.json裡面配置好打包指令碼

1 <code >"scripts": {
2 "build": "node build.js"
3 }
4 </code>

執行yarn build

(@ο@) 哇~激動人心的時刻到了。

然而...

看著打包出來的這一坨奇怪的東西報錯,心裡還是有點想笑的。檢查了一下發現是因為反引號遇到註釋中的反引號於是拼接字串提前結束了。好吧,那麼我在babel traverse時加了幾句程式碼,刪除掉了程式碼中所有的註釋。但是隨之而來的又是一些其他的問題。

好吧,可能在實際react生產打包中還有一些其他的步驟,但是這不在今天討論的話題當中。此時,鬼魅的框架湧上心頭。我腦中想起了京東凹凸實驗室自研的高效能,相容性優秀,緊跟react版本的類react框架NervJS,或許NervJS平易近人(誤)的程式碼能夠支援這款令人抱歉的打包工具

於是我們在babel.config.js中配置alias來替換react依賴項。(React專案轉NervJS就是這麼簡單)

 1 <code >module.exports = function (api) {
 2 api.cache(true)
 3 return {
 4 ...
 5 "plugins": [
 6 ...
 7 [
 8 "module-resolver", {
 9 "root": ["."],
10 "alias": {
11 "react": "nervjs",
12 "react-dom": "nervjs",
13 // Not necessary unless you consume a module using `createClass`
14 "create-react-class": "nerv-create-class"
15 }
16 }
17 ]
18 ],
19 "compact": true
20 }
21 }
22 </code>