90 行代碼的 webpack,你確定不學(xué)嗎?
在前端社區(qū)里,webpack 可以說是一個(gè)經(jīng)久不衰的話題。其強(qiáng)大、靈活的功能曾極大地促進(jìn)了前端工程化進(jìn)程的發(fā)展,伴隨了無數(shù)前端項(xiàng)目的起與落。其紛繁復(fù)雜的配置也曾讓無數(shù)前端人望而卻步,笑稱需要一個(gè)新工種"webpack 配置工程師"。作為一個(gè)歷史悠久,最常見、最經(jīng)典的打包工具,webpack 極具討論價(jià)值。理解 webpack,掌握 webpack,無論是在面試環(huán)節(jié),還是在日常項(xiàng)目搭建、開發(fā)、優(yōu)化環(huán)節(jié),都能帶來不少的收益。那么本文將從核心理念出發(fā),帶各位讀者撥開 webpack 的外衣,看透其本質(zhì)。

究竟是啥
其實(shí)這個(gè)問題在 webpack 官網(wǎng)的第一段就給出了明確的定義:
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 的核心是用于現(xiàn)代 JavaScript 應(yīng)用程序的靜態(tài)模塊打包器。當(dāng) webpack 處理您的應(yīng)用程序時(shí),它會(huì)在內(nèi)部構(gòu)建一個(gè)依賴關(guān)系圖,該圖映射您項(xiàng)目所需的每個(gè)模塊并生成一個(gè)或多個(gè)包。
要素察覺:靜態(tài)模塊打包器、依賴關(guān)系圖、生成一個(gè)或多個(gè)包。雖然如今的前端項(xiàng)目中,webpack 扮演著重要的角色,囊括了諸多功能,但從其本質(zhì)上來講,其仍然是一個(gè)“模塊打包器”,將開發(fā)者的 JavaScript 模塊打包成一個(gè)或多個(gè) JavaScript 文件。
要干什么
那么,為什么需要一個(gè)模塊打包器呢?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
webpackis to bundle CommonJs modules into javascript files which can be loaded by<script>-tags.
可以看到,node.js 生態(tài)中積累了大量的 JavaScript 寫的代碼,卻因?yàn)?node.js 端遵循的 CommonJS 模塊化規(guī)范與瀏覽器端格格不入,導(dǎo)致代碼無法得到復(fù)用,這是一個(gè)巨大的損失。于是 webpack 要做的就是將這些模塊打包成可以在瀏覽器端使用 <script> 標(biāo)簽加載并運(yùn)行的JavaScript 文件。
或許這并不是唯一解釋 webpack 存在的原因,但足以給我們很大的啟發(fā)——把 CommonJS 規(guī)范的代碼轉(zhuǎn)換成可在瀏覽器運(yùn)行的 JavaScript 代碼
怎么干的
既然瀏覽器端沒有 CommonJS 規(guī)范,那就實(shí)現(xiàn)一個(gè)好了。從 webpack 打包出的產(chǎn)物,我們能看出思路。
新建三個(gè)文件觀察其打包產(chǎn)物:
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!')
}
執(zhí)行 npx webpack --mode development 打包產(chǎn)出 dist/main.js 文件

上圖中,使用了 webpack 打包 3 個(gè)簡(jiǎn)單的 js 文件 index.js/a.js/b.js, 其中 index.js 中依賴了 a.js, 而 a.js 中又依賴了 b.js, 形成一個(gè)完整依賴關(guān)系。
那么,webpack 又是如何知道文件之間的依賴關(guān)系的呢,如何收集被依賴的文件保證不遺漏呢?我們依然能從官方文檔找到答案:
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 會(huì)從配置的入口開始,遞歸的構(gòu)建一個(gè)應(yīng)用程序所需要的模塊的依賴樹。我們知道,CommonJS 規(guī)范里,依賴某一個(gè)文件時(shí),只需要使用 require 關(guān)鍵字將其引入即可,那么只要我們遇到require關(guān)鍵字,就去解析這個(gè)依賴,而這個(gè)依賴中可能又使用了 require 關(guān)鍵字繼續(xù)引用另一個(gè)依賴,于是,就可以遞歸的根據(jù) require 關(guān)鍵字找到所有的被依賴的文件,從而完成依賴樹的構(gòu)建了。
可以看到上圖最終輸出里,三個(gè)文件被以鍵值對(duì)的形式保存到 __webpack_modules__ 對(duì)象上, 對(duì)象的 key 為模塊路徑名,value 為一個(gè)被包裝過的模塊函數(shù)。函數(shù)擁有 module, module.exports, __webpack_require__ 三個(gè)參數(shù)。這使得每個(gè)模塊都擁有使用 module.exports 導(dǎo)出本模塊和使用 __webpack_require__ 引入其他模塊的能力,同時(shí)保證了每個(gè)模塊都處于一個(gè)隔離的函數(shù)作用域范圍。
為什么 webpack要修改require關(guān)鍵字和require的路徑?我們知道require是node環(huán)境自帶的環(huán)境變量,可以直接使用,而在其他環(huán)境則沒有這樣一個(gè)變量,于是需要webpack提供這樣的能力。只要提供了相似的能力,變量名叫 require還是 __webpack_require__其實(shí)無所謂。至于重寫路徑,當(dāng)然是因?yàn)樵?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(244, 138, 0);">node端系統(tǒng)會(huì)根據(jù)文件的路徑加載,而在 webpack打包的文件中,使用原路徑行不通,于是需要將路徑重寫為 __webpack_modules__ 的鍵,從而找到相應(yīng)模塊。
而下面的 __webpack_require__函數(shù)與 __webpack_module_cache__ 對(duì)象則完成了模塊加載的職責(zé)。使用 __webpack_require__ 函數(shù)加載完成的模塊被緩存到 __webpack_module_cache__ 對(duì)象上,以便下次如果有其他模塊依賴此模塊時(shí),不需要重新運(yùn)行模塊的包裝函數(shù),減少執(zhí)行效率的消耗。同時(shí),如果多個(gè)文件之間存在循環(huán)依賴,比如 a.js 依賴了 b.js 文件, b.js 又依賴了 a.js,那么在 b.js 使用 __webpack_require__加載 a.js 時(shí),會(huì)直接走進(jìn) if(cachedModule !== undefined) 分支然后 return已緩存過的 a.js 的引用,不會(huì)進(jìn)一步執(zhí)行 a.js 文件加載,從而避免了循環(huán)依賴無限遞歸的出現(xiàn)。
不能說這個(gè)由 webpack 實(shí)現(xiàn)的模塊加載器與 CommonJS 規(guī)范一毛一樣,只能說八九不離十吧。這樣一來,打包后的 JavaScript 文件可以被 <script> 標(biāo)簽加載且運(yùn)行在瀏覽器端了。
簡(jiǎn)易實(shí)現(xiàn)
了解了 webpack 處理后的 JavaScript 長(zhǎng)成什么樣子,我們梳理一下思路,依葫蘆畫瓢手動(dòng)實(shí)現(xiàn)一個(gè)簡(jiǎn)易的打包器,幫助理解。
要做的事情有這么些:
讀取入口文件,并收集依賴信息 遞歸地讀取所有依賴模塊,產(chǎn)出完整的依賴列表 將各模塊內(nèi)容打包成一塊完整的可運(yùn)行代碼
話不多說,創(chuàng)建一個(gè)項(xiàng)目,并安裝所需依賴
npm init -y
npm i @babel/core @babel/parser @babel/traverse webpack webpack-cli -D
其中:
@babel/parser用于解析源代碼,產(chǎn)出 AST@babel/traverse用于遍歷 AST,找到require語句并修改成_require_,將引入路徑改造為相對(duì)根的路徑@babel/core用于將修改后的 AST 轉(zhuǎn)換成新的代碼輸出
創(chuàng)建一個(gè)入口文件 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')
緊接著,我們需要對(duì)某一個(gè)模塊進(jìn)行解析,并產(chǎn)出其模塊信息,包括:模塊路徑、模塊依賴、模塊轉(zhuǎn)換后代碼
// 保存根路徑,所有模塊根據(jù)根路徑產(chǎn)出相對(duì)路徑
let root = process.cwd()
function readModuleInfo(filePath) {
// 準(zhǔn)備好相對(duì)路徑作為 module 的 key
filePath =
'./' + path.relative(root, path.resolve(filePath)).replace(/\\+/g, '/')
// 讀取源碼
const content = fs.readFileSync(filePath, 'utf-8')
// 轉(zhuǎn)換出 AST
const ast = parser.parse(content)
// 遍歷模塊 AST,將依賴收集到 deps 數(shù)組中
const deps = []
traverse(ast, {
CallExpression: ({ node }) => {
// 如果是 require 語句,則收集依賴
if (node.callee.name === 'require') {
// 改造 require 關(guān)鍵字
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,
}
}
接下來,我們從入口出發(fā)遞歸地找到所有被依賴的模塊,并構(gòu)建成依賴樹
function buildDependencyGraph(entry) {
// 獲取入口模塊信息
const entryInfo = readModuleInfo(entry)
// 項(xiàng)目依賴樹
const graphArr = []
graphArr.push(entryInfo)
// 從入口模塊觸發(fā),遞歸地找每個(gè)模塊的依賴,并將每個(gè)模塊信息保存到 graphArr
for (const module of graphArr) {
module.deps.forEach((depPath) => {
const moduleInfo = readModuleInfo(path.resolve(depPath))
graphArr.push(moduleInfo)
})
}
return graphArr
}
經(jīng)過上面一步,我們已經(jīng)得到依賴樹能夠描述整個(gè)應(yīng)用的依賴情況,最后我們只需要按照目標(biāo)格式進(jìn)行打包輸出即可
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 規(guī)范的模板,自動(dòng)加載入口模塊,并使用 IIFE 將代碼包裝,保證代碼模塊不會(huì)影響到全局作用域。
最后,編寫一個(gè)入口函數(shù) main 用以啟動(dòng)打包過程
function main(entry = './src/index.js', output = './dist.js') {
fs.writeFileSync(output, pack(buildDependencyGraph(entry), entry))
}
main()
執(zhí)行并驗(yàn)證結(jié)果
node myPack.js
至此,我們使用了總共不到 90 行代碼(包含注釋),完成了一個(gè)極簡(jiǎn)的模塊打包工具。雖然沒有涉及任何 Webpack 源碼, 但我們從打包器的設(shè)計(jì)原理入手,走過了打包工具的核心步驟,簡(jiǎn)易卻不失完整。
總結(jié)
本文從 webpack 的設(shè)計(jì)理念和最終實(shí)現(xiàn)出發(fā),梳理了其作為一個(gè)打包工具的核心能力,并使用一個(gè)簡(jiǎn)易版本實(shí)現(xiàn)幫助更直觀的理解其本質(zhì)??偟膩碚f,webpack 作為打包工具無非是從應(yīng)用入口出發(fā),遞歸的找到所有依賴模塊,并將他們解析輸出成一個(gè)具備類 CommonJS 模塊化規(guī)范的模塊加載能力的 JavaScript 文件。
因其優(yōu)秀的設(shè)計(jì),在實(shí)際生產(chǎn)環(huán)節(jié)中,webapck 還能擴(kuò)展出諸多強(qiáng)大的功能。然而其本質(zhì)仍是模塊打包器。不論是什么樣的新特性或新能力,只要我們把握住打包工具的核心思想,任何問題終將迎刃而解。
參考
Concepts (https://webpack.js.org/concepts/)
modules-webpack (https://github.com/webpack/webpack/blob/2e1460036c5349951da86c582006c7787c56c543/README.md)
Dependency Graph (https://webpack.js.org/concepts/dependency-graph/)
Build Your Own Webpack (https://www.youtube.com/watch?v=Gc9-7PBqOC8)
往期推薦

Vite 太快了,煩死了,是時(shí)候該小睡一會(huì)了。

如何實(shí)現(xiàn)比 setTimeout 快 80 倍的定時(shí)器?

CSS 是如何發(fā)起攻擊的?

萬字長(zhǎng)文!總結(jié)Vue 性能優(yōu)化方式及原理
最后
如果你覺得這篇內(nèi)容對(duì)你挺有啟發(fā),我想邀請(qǐng)你幫我三個(gè)小忙:
點(diǎn)個(gè)「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點(diǎn)在看,都是耍流氓 -_-)
歡迎加我微信「huab119」拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
關(guān)注公眾號(hào)「前端勸退師」,持續(xù)為你推送精選好文,也可以加我為好友,隨時(shí)聊騷。

