Webpack 原理淺析

做者: 凹凸曼 - 風魔小次郎javascript

背景

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

開發者視角

假設某一天,咱們接到了需求,須要開發一個 react 單頁面應用,頁面中包含一行文字和一個按鈕,須要支持每次點擊按鈕的時候讓文字發生變化。因而咱們新建了一個項目,而且在 [根目錄]/src 下新建 JS 文件。爲了模擬 Webpack 追蹤模塊依賴進行打包的過程,咱們新建了 3 個 React 組件,而且在他們之間創建起一個簡單的依賴關係。html

// 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中文文檔java

// 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 等其餘格式的文件進行處理。node

而對於 React 文件而言,咱們須要將 JSX 語法轉換成純 JS 語法,即 React.createElement 方法,代碼纔可能被瀏覽器所識別。日常咱們是經過 babel-loader 而且配置好 react 的解析規則來作這一步。react

通過以上處理以後。瀏覽器真正閱讀到的按鈕組件代碼其實大概是這個樣子的。webpack

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

而至於 plugin 則是一些插件,這些插件能夠將對編譯結果的處理函數註冊在 Webpack 的生命週期鉤子上,在生成最終文件以前對編譯的結果作一些處理。好比大多數場景下咱們須要將生成的 JS 文件插入到 Html 文件中去。就須要使用到 html-webpack-plugin 這個插件,咱們須要在配置中這樣寫。git

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 文件來展現頁面內容了。github

ok,寫到這裏,對於一個開發者而言,全部配置項和須要被打包的工程代碼文件都已經準備完畢,接下來須要的就是將工做交給打包工具 Webpack,經過 Webpack 將代碼打包成咱們和瀏覽器但願看到的樣子web

工具視角

首先,咱們須要瞭解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),不定時推送文章:

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