90 行代碼的webpack,你肯定不學嗎?

掘金引流終版.gif

構建專欄系列目錄入口javascript

陳恆,微醫前端技術部平臺支撐組,最帥的架構師~前端

在前端社區裏,webpack 能夠說是一個經久不衰的話題。其強大、靈活的功能曾極大地促進了前端工程化進程的發展,伴隨了無數前端項目的起與落。其紛繁複雜的配置也曾讓無數前端人望而卻步,笑稱須要一個新工種"webpack 配置工程師"。做爲一個歷史悠久,最多見、最經典的打包工具,webpack 極具討論價值。理解 webpack,掌握 webpack,不管是在面試環節,仍是在平常項目搭建、開發、優化環節,都能帶來很多的收益。那麼本文將從核心理念出發,帶各位讀者撥開 webpack 的外衣,看透其本質。 image.pngjava

到底是啥

其實這個問題在 webpack 官網的第一段就給出了明確的定義:node

At its core, webpack is a static module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph which maps every module your project needs and generates one or more bundles.webpack

其意爲:git

webpack 的核心是用於現代 JavaScript 應用程序的靜態模塊打包器。 當 webpack 處理您的應用程序時,它會在內部構建一個依賴關係圖,該圖映射您項目所需的每一個模塊並生成一個或多個包github

要素察覺:靜態模塊打包器依賴關係圖生成一個或多個包。雖然現在的前端項目中,webpack 扮演着重要的角色,囊括了諸多功能,但從其本質上來說,其仍然是一個「模塊打包器」,將開發者的 JavaScript 模塊打包成一個或多個 JavaScript 文件。web

要幹什麼

那麼,爲何須要一個模塊打包器呢?webpack 倉庫早年的 README 也給出了答案:面試

As developer you want to reuse existing code. As with node.js and web all file are already in the same language, but it is extra work to use your code with the node.js module system and the browser. The goal of webpack is to bundle CommonJs modules into javascript files which can be loaded by <script>-tags.npm

能夠看到,node.js 生態中積累了大量的 JavaScript 寫的代碼,卻由於 node.js 端遵循的 CommonJS 模塊化規範與瀏覽器端格格不入,致使代碼沒法獲得複用,這是一個巨大的損失。因而 webpack 要作的就是將這些模塊打包成能夠在瀏覽器端使用 <script> 標籤加載並運行的JavaScript 文件。

或許這並非惟一解釋 webpack 存在的緣由,但足以給咱們很大的啓發——把 CommonJS 規範的代碼轉換成可在瀏覽器運行的 JavaScript 代碼

怎麼幹的

既然瀏覽器端沒有 CommonJS 規範,那就實現一個好了。從 webpack 打包出的產物,咱們能看出思路。 ​

新建三個文件觀察其打包產物:

src/index.js

const printA = require('./a')
printA()
複製代碼

src/a.js

const printB = require('./b')

module.exports = function printA() {
  console.log('module a!')
  printB()
}
複製代碼

src/b.js

module.exports = function printB() {
  console.log('module b!')
}
複製代碼

執行 npx webpack --mode development 打包產出 dist/main.js 文件

dist.png

上圖中,使用了 webpack 打包 3 個簡單的 js 文件 index.js/a.js/b.js, 其中 index.js 中依賴了 a.js, 而 a.js 中又依賴了 b.js, 造成一個完整依賴關係。

那麼,webpack 又是如何知道文件之間的依賴關係的呢,如何收集被依賴的文件保證不遺漏呢?咱們依然能從官方文檔找到答案:

When webpack processes your application, it starts from a list of modules defined on the command line or in its configuration file. Starting from these entry points, webpack recursively builds a dependency graph that includes every module your application needs, then bundles all of those modules into a small number of bundles - often, just one - to be loaded by the browser.

也就是說,webpack 會從配置的入口開始,遞歸的構建一個應用程序所須要的模塊的依賴樹。咱們知道,CommonJS 規範裏,依賴某一個文件時,只須要使用 require 關鍵字將其引入便可,那麼只要咱們遇到require關鍵字,就去解析這個依賴,而這個依賴中可能又使用了 require 關鍵字繼續引用另外一個依賴,因而,就能夠遞歸的根據 require 關鍵字找到全部的被依賴的文件,從而完成依賴樹的構建了。

能夠看到上圖最終輸出裏,三個文件被以鍵值對的形式保存到 __webpack_modules__ 對象上, 對象的 key 爲模塊路徑名,value 爲一個被包裝過的模塊函數。函數擁有 module, module.exports, __webpack_require__ 三個參數。這使得每一個模塊都擁有使用 module.exports 導出本模塊和使用 __webpack_require__ 引入其餘模塊的能力,同時保證了每一個模塊都處於一個隔離的函數做用域範圍。

爲何 webpack要修改require關鍵字和require的路徑?咱們知道requirenode環境自帶的環境變量,能夠直接使用,而在其餘環境則沒有這樣一個變量,因而須要webpack提供這樣的能力。只要提供了類似的能力,變量名叫 require仍是 __webpack_require__其實無所謂。至於重寫路徑,固然是由於在node端系統會根據文件的路徑加載,而在 webpack打包的文件中,使用原路徑行不通,因而須要將路徑重寫爲 __webpack_modules__ 的鍵,從而找到相應模塊。 ​

而下面的 __webpack_require__函數與 __webpack_module_cache__ 對象則完成了模塊加載的職責。使用 __webpack_require__ 函數加載完成的模塊被緩存到 __webpack_module_cache__ 對象上,以便下次若是有其餘模塊依賴此模塊時,不須要從新運行模塊的包裝函數,減小執行效率的消耗。同時,若是多個文件之間存在循環依賴,好比 a.js 依賴了 b.js 文件, b.js 又依賴了 a.js,那麼在 b.js 使用 __webpack_require__加載 a.js 時,會直接走進 if(cachedModule !== undefined) 分支而後 return已緩存過的 a.js 的引用,不會進一步執行 a.js 文件加載,從而避免了循環依賴無限遞歸的出現

不能說這個由 webpack 實現的模塊加載器與 CommonJS 規範一毛同樣,只能說八九不離十吧。這樣一來,打包後的 JavaScript 文件能夠被 <script> 標籤加載且運行在瀏覽器端了。

簡易實現

瞭解了 webpack 處理後的 JavaScript 長成什麼樣子,咱們梳理一下思路,依葫蘆畫瓢手動實現一個簡易的打包器,幫助理解。

要作的事情有這麼些:

  1. 讀取入口文件,並收集依賴信息
  2. 遞歸地讀取全部依賴模塊,產出完整的依賴列表
  3. 將各模塊內容打包成一塊完整的可運行代碼

話很少說,建立一個項目,並安裝所需依賴

npm init -y
npm i @babel/core @babel/parser @babel/traverse webpack webpack-cli -D
複製代碼

其中:

  • @babel/parser 用於解析源代碼,產出 AST
  • @babel/traverse 用於遍歷 AST,找到 require 語句並修改爲 _require_,將引入路徑改造爲相對根的路徑
  • @babel/core 用於將修改後的 AST 轉換成新的代碼輸出

建立一個入口文件 myPack.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')
複製代碼

緊接着,咱們須要對某一個模塊進行解析,併產出其模塊信息,包括:模塊路徑、模塊依賴、模塊轉換後代碼

// 保存根路徑,全部模塊根據根路徑產出相對路徑
let root = process.cwd()

function readModuleInfo(filePath) {
  // 準備好相對路徑做爲 module 的 key
  filePath =
    './' + path.relative(root, path.resolve(filePath)).replace(/\\+/g, '/')
  // 讀取源碼
  const content = fs.readFileSync(filePath, 'utf-8')
  // 轉換出 AST
  const ast = parser.parse(content)
  // 遍歷模塊 AST,將依賴收集到 deps 數組中
  const deps = []
  traverse(ast, {
    CallExpression: ({ node }) => {
      // 若是是 require 語句,則收集依賴
      if (node.callee.name === 'require') {
        // 改造 require 關鍵字
        node.callee.name = '_require_'
        let moduleName = node.arguments[0].value
        moduleName += path.extname(moduleName) ? '' : '.js'
        moduleName = path.join(path.dirname(filePath), moduleName)
        moduleName = './' + path.relative(root, moduleName).replace(/\\+/g, '/')
        deps.push(moduleName)
        // 改造依賴的路徑
        node.arguments[0].value = moduleName
      }
    },
  })
  // 編譯回代碼
  const { code } = babel.transformFromAstSync(ast)
  return {
    filePath,
    deps,
    code,
  }
}
複製代碼

接下來,咱們從入口出發遞歸地找到全部被依賴的模塊,並構建成依賴樹

function buildDependencyGraph(entry) {
  // 獲取入口模塊信息
  const entryInfo = readModuleInfo(entry)
  // 項目依賴樹
  const graphArr = []
  graphArr.push(entryInfo)
  // 從入口模塊觸發,遞歸地找每一個模塊的依賴,並將每一個模塊信息保存到 graphArr
  for (const module of graphArr) {
    module.deps.forEach((depPath) => {
      const moduleInfo = readModuleInfo(path.resolve(depPath))
      graphArr.push(moduleInfo)
    })
  }
  return graphArr
}
複製代碼

通過上面一步,咱們已經獲得依賴樹可以描述整個應用的依賴狀況,最後咱們只須要按照目標格式進行打包輸出便可

function pack(graph, entry) {
  const moduleArr = graph.map((module) => {
    return (
      `"${module.filePath}": function(module, exports, _require_) { eval(\`` +
      module.code +
      `\`) }`
    )
  })
  const output = `;(() => { var modules = { ${moduleArr.join(',\n')} } var modules_cache = {} var _require_ = function(moduleId) { if (modules_cache[moduleId]) return modules_cache[moduleId].exports var module = modules_cache[moduleId] = { exports: {} } modules[moduleId](module, module.exports, _require_) return module.exports } _require_('${entry}') })()`
  return output
}
複製代碼

直接使用字符串模板拼接成類 CommonJS 規範的模板,自動加載入口模塊,並使用 IIFE 將代碼包裝,保證代碼模塊不會影響到全局做用域。

最後,編寫一個入口函數 main 用以啓動打包過程

function main(entry = './src/index.js', output = './dist.js') {
  fs.writeFileSync(output, pack(buildDependencyGraph(entry), entry))
}

main()
複製代碼

執行並驗證結果

node myPack.js
複製代碼

至此,咱們使用了總共不到 90 行代碼(包含註釋),完成了一個極簡的模塊打包工具。雖然沒有涉及任何 Webpack 源碼, 但咱們從打包器的設計原理入手,走過了打包工具的核心步驟,簡易卻不失完整。

總結

本文從 webpack 的設計理念和最終實現出發,梳理了其做爲一個打包工具的核心能力,並使用一個簡易版本實現幫助更直觀的理解其本質。總的來講,webpack 做爲打包工具無非是從應用入口出發,遞歸的找到全部依賴模塊,並將他們解析輸出成一個具有類 CommonJS 模塊化規範的模塊加載能力的 JavaScript 文件

因其優秀的設計,在實際生產環節中,webapck 還能擴展出諸多強大的功能。然而其本質還是模塊打包器。不管是什麼樣的新特性或新能力,只要咱們把握住打包工具的核心思想,任何問題終將迎刃而解。

打嗝.gif

參考

Concepts

modules-webpack

Dependency Graph

Build Your Own Webpack

構建專欄系列目錄入口

相關文章
相關標籤/搜索