Webpack 原理淺析

凹凸實驗室發表於2020-07-29

作者: 凹凸曼 - 風魔小次郎

背景

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

開發者視角

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

// index.js 根元件
import React from 'react'
import ReactDom from 'react-dom'
import App from './App'
ReactDom.render(<App />, document.querySelector('#container'))
// App.js 頁面元件
import React from 'react'
import Switch from './Switch.js'
export default class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      toggle: false
    }
  }
  handleToggle() {
    this.setState(prev => ({
      toggle: !prev.toggle
    }))
  }
  render() {
    const { toggle } = this.state
    return (
      <div>
        <h1>Hello, { toggle ? 'NervJS' : 'O2 Team'}</h1>
        <Switch handleToggle={this.handleToggle.bind(this)} />
      </div>
    )
  }
}
// Switch.js 按鈕元件
import React from 'react'

export default function Switch({ handleToggle }) {
  return (
    <button onClick={handleToggle}>Toggle</button>
  )
}

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

// webpack.config.js
const resolve = dir => require('path').join(__dirname, dir)

module.exports = {
  // 入口檔案地址
  entry: './src/index.js',
  // 輸出檔案地址
  output: {
		path: resolve('dist'),
    fileName: 'bundle.js'
  },
  // loader
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        // 編譯匹配include路徑的檔案
        include: [
          resolve('src')
        ],
        use: 'babel-loader'
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin()
  ]
}

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

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

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

...
function Switch(_ref) {
  var handleToggle = _ref.handleToggle;
  return _nervjs["default"].createElement("button", {
    onClick: handleToggle
  }, "Toggle");
}

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

const HtmlWebpackPlugin = require('html-webpack-plugin');

const webpackConfig = {
  entry: 'index.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'index_bundle.js'
  },
  // 向plugins陣列中傳入一個HtmlWebpackPlugin外掛的例項
  plugins: [new HtmlWebpackPlugin()]
};

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

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

工具視角

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

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

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

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

const Compiler = require('./compiler')

function webpack(config, callback) {
  // 此處應有引數校驗
  const compiler = new Compiler(config)
  // 開始編譯
  compiler.run()
}

module.exports = webpack

1. 構建配置資訊

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

class Compiler {
  constructor(config, _callback) {
    const {
      entry,
      output,
      module,
      plugins
    } = config
    // 入口
    this.entryPath = entry
    // 輸出檔案路徑
    this.distPath = output.path
    // 輸出檔名稱
    this.distName = output.fileName
    // 需要使用的loader
    this.loaders = module.rules
    // 需要掛載的plugin
    this.plugins = plugins
     // 根目錄
    this.root = process.cwd()
     // 編譯工具類Compilation
    this.compilation = {}
    // 入口檔案在module中的相對路徑,也是這個模組的id
    this.entryId = getRootPath(this.root, entry, this.root)
  }
}

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

const { AsyncSeriesHook } = require('tapable') // 此處我們建立了一些非同步鉤子
constructor(config, _callback) {
  ...
  this.hooks = {
    // 生命週期事件
    beforeRun: new AsyncSeriesHook(['compiler']), // compiler代表我們將向回撥事件中傳入一個compiler引數
    afterRun: new AsyncSeriesHook(['compiler']),
    beforeCompile: new AsyncSeriesHook(['compiler']),
    afterCompile: new AsyncSeriesHook(['compiler']),
    emit: new AsyncSeriesHook(['compiler']),
    failed: new AsyncSeriesHook(['compiler']),
  }
  this.mountPlugin()
}
// 註冊所有的plugin
mountPlugin() {
  for(let i=0;i<this.plugins.length;i++) {
    const item = this.plugins[i]
    if ('apply' in item && typeof item.apply === 'function') {
      // 註冊各生命週期鉤子的釋出訂閱監聽事件
      item.apply(this)
    }
  }
}
// 當執行run方法的邏輯之前
run() {
  // 在特定的生命週期釋出訊息,觸發對應的訂閱事件
  this.hooks.beforeRun.callAsync(this) // this作為引數傳入,對應之前的compiler
  ...
}

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

2. 編譯

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

class Compilation {
  constructor(props) {
    const {
      entry,
      root,
      loaders,
      hooks
    } = props
    this.entry = entry
    this.root = root
    this.loaders = loaders
    this.hooks = hooks
  }
  // 開始編譯
  async make() {
    await this.moduleWalker(this.entry)
  }
  // dfs遍歷函式
  moduleWalker = async () => {}
}

因為我們需要將打包過程中引用過的檔案都編譯到最終的程式碼包裡,所以需要宣告一個深度遍歷函式 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 遍歷函式

// 存放處理完畢的模組程式碼Map
moduleMap = {}

// 根據依賴將所有被引用過的檔案都進行編譯
async moduleWalker(sourcePath) {
  if (sourcePath in this.moduleMap) return
  // 在讀取檔案時,我們需要完整的以.js結尾的檔案路徑
  sourcePath = completeFilePath(sourcePath)
  const [ sourceCode, md5Hash ] = await this.loaderParse(sourcePath)
  const modulePath = getRootPath(this.root, sourcePath, this.root)
  // 獲取模組編譯後的程式碼和模組內的依賴陣列
  const [ moduleCode, relyInModule ] = this.parse(sourceCode, path.dirname(modulePath))
  // 將模組程式碼放入ModuleMap
  this.moduleMap[modulePath] = moduleCode
  this.assets[modulePath] = md5Hash
  // 再依次對模組中的依賴項進行解析
  for(let i=0;i<relyInModule.length;i++) {
    await this.moduleWalker(relyInModule[i], path.dirname(relyInModule[i]))
  }
}

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

ii. 第一步編譯 loaderParse函式

async loaderParse(entryPath) {
  // 用utf8格式讀取檔案內容
  let [ content, md5Hash ] = await readFileWithHash(entryPath)
  // 獲取使用者注入的loader
  const { loaders } = this
  // 依次遍歷所有loader
  for(let i=0;i<loaders.length;i++) {
    const loader = loaders[i]
    const { test : reg, use } = loader
    if (entryPath.match(reg)) {
      // 判斷是否滿足正則或字串要求
      // 如果該規則需要應用多個loader,從最後一個開始向前執行
      if (Array.isArray(use)) {
        while(use.length) {
          const cur = use.pop()
          const loaderHandler = 
            typeof cur.loader === 'string' 
            // loader也可能來源於package包例如babel-loader
              ? require(cur.loader)
              : (
                typeof cur.loader === 'function'
                ? cur.loader : _ => _
              )
          content = loaderHandler(content)
        }
      } else if (typeof use.loader === 'string') {
        const loaderHandler = require(use.loader)
        content = loaderHandler(content)
      } else if (typeof use.loader === 'function') {
        const loaderHandler = use.loader
        content = loaderHandler(content)
      }
    }
  }
  return [ content, md5Hash ]
}

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

This package allows transpiling JavaScript files using Babel and webpack.

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

const babel = require('@babel/core')

module.exports = function BabelLoader (source) {
  const res = babel.transform(source, {
    sourceType: 'module' // 編譯ES6 import和export語法
  })
  return res.code
}

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

module.exports = function (api) {
  api.cache(true)
  return {
    "presets": [
      ['@babel/preset-env', {
        targets: {
          "ie": "8"
        },
      }],
      '@babel/preset-react', // 編譯JSX
    ],
    "plugins": [
      ["@babel/plugin-transform-template-literals", {
        "loose": true
      }]
    ],
    "compact": true
  }
}

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

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

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

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

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const types = require('@babel/types')
const generator = require('@babel/generator').default
...
// 解析原始碼,替換其中的require方法來構建ModuleMap
parse(source, dirpath) {
  const inst = this
  // 將程式碼解析成ast
  const ast = parser.parse(source)
  const relyInModule = [] // 獲取檔案依賴的所有模組
  traverse(ast, {
    // 檢索所有的詞法分析節點,當遇到函式呼叫表示式的時候執行,對ast樹進行改寫
    CallExpression(p) {
      // 有些require是被_interopRequireDefault包裹的
      // 所以需要先找到_interopRequireDefault節點
      if (p.node.callee && p.node.callee.name === '_interopRequireDefault') {
        const innerNode = p.node.arguments[0]
        if (innerNode.callee.name === 'require') {
          inst.convertNode(innerNode, dirpath, relyInModule)
        }
      } else if (p.node.callee.name === 'require') {
        inst.convertNode(p.node, dirpath, relyInModule)
      }
    }
  })
  // 將改寫後的ast樹重新組裝成一份新的程式碼, 並且和依賴項一同返回
  const moduleCode = generator(ast).code
  return [ moduleCode, relyInModule ]
}
/**
 * 將某個節點的name和arguments轉換成我們想要的新節點
 */
convertNode = (node, dirpath, relyInModule) => {
  node.callee.name = '__webpack_require__'
  // 引數字串名稱,例如'react', './MyName.js'
  let moduleName = node.arguments[0].value
  // 生成依賴模組相對【專案根目錄】的路徑
  let moduleKey = completeFilePath(getRootPath(dirpath, moduleName, this.root))
  // 收集module陣列
  relyInModule.push(moduleKey)
  // 替換__webpack_require__的引數字串,因為這個字串也是對應模組的moduleKey,需要保持統一
  // 因為ast樹中的每一個元素都是babel節點,所以需要使用'@babel/types'來進行生成
  node.arguments = [ types.stringLiteral(moduleKey) ]
}

3. emit 生成bundle檔案

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

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

模板程式碼

// template.ejs
(function(modules) { // webpackBootstrap
  // The module cache
  var installedModules = {};
  // The require function
  function __webpack_require__(moduleId) {
      // Check if module is in cache
      if(installedModules[moduleId]) {
          return installedModules[moduleId].exports;
      }
      // Create a new module (and put it into the cache)
      var module = installedModules[moduleId] = {
          i: moduleId,
          l: false,
          exports: {}
      };
      // Execute the module function
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
      // Flag the module as loaded
      module.l = true;
      // Return the exports of the module
      return module.exports;
  }
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
})({
 <%for(let key in modules) {%>
     "<%-key%>":
         (function(module, exports, __webpack_require__) {
             eval(
                 `<%-modules[key]%>`
             );
         }),
     <%}%>
});

生成bundle.js

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

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

{
  "main.js": "./js/main7b6b4.js",
  "main.css": "./css/maincc69a7ca7d74e1933b9d.css",
  "main.js.map": "./js/main7b6b4.js.map",
  "vendors~main.js": "./js/vendors~main3089a.js",
  "vendors~main.css": "./css/vendors~maincc69a7ca7d74e1933b9d.css",
  "vendors~main.js.map": "./js/vendors~main3089a.js.map",
  "js/28505f.js": "./js/28505f.js",
  "js/28505f.js.map": "./js/28505f.js.map",
  "js/34c834.js": "./js/34c834.js",
  "js/34c834.js.map": "./js/34c834.js.map",
  "js/4d218c.js": "./js/4d218c.js",
  "js/4d218c.js.map": "./js/4d218c.js.map",
  "index.html": "./index.html",
  "static/initGlobalSize.js": "./static/initGlobalSize.js"
}

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


檢驗

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

"scripts": {
  "build": "node build.js"
}

執行 yarn build

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

然而...

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

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

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

module.exports = function (api) {
  api.cache(true)
  return {
		...
    "plugins": [
			...
      [
        "module-resolver", {
          "root": ["."],
          "alias": {
            "react": "nervjs",
            "react-dom": "nervjs",
            // Not necessary unless you consume a module using `createClass`
            "create-react-class": "nerv-create-class"
          }
        }
      ]
    ],
    "compact": true
  }
}

執行 yarn build


(@ο@) 哇~程式碼終於成功執行了起來,雖然存在著許多的問題,但是至少這個 webpack 在設計如此簡單的情況下已經有能力支援大部分JS框架了。感興趣的同學也可以自己嘗試寫一寫,或者直接從這裡clone下來看

毫無疑問,Webpack 是一個非常優秀的程式碼模組打包工具(雖然它的官網非常低調的沒有任何slogen)。一款非常優秀的工具,必然是在保持了自己本身的特性的同時,同時能夠賦予其他開發者在其基礎上擴充設想之外作品的能力。如果有能力深入學習這些工具,對於我們在程式碼工程領域的認知也會有很大的提升。

歡迎關注凹凸實驗室部落格:aotu.io

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章:

歡迎關注凹凸實驗室公眾號

相關文章