作者: 凹凸曼 - 風魔小次郎 背景 Webpack 迭代到4.x版本後,其源碼已經十分龐大,對各種開發場景進行了高度抽象,閱讀成本也愈發昂貴。但是為了瞭解其內部的工作原理,讓我們嘗試從一個最簡單的 webpack 配置入手,從工具設計者的角度開發一款低配版的 Webpack。 開發者視角 假設某一 ...
作者: 凹凸曼 - 風魔小次郎
背景
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
其實就相當於老闆的秘書,需要去調動各個部門按照要求開始工作,而 loader
和 plugin
則相當於各個部門,只有在他們專長的工作( 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官方取的),顧名思義,這個方法將會從入口文件開始,依次對文件進行第一步和第二步編譯,並且收集引用到的其他模塊,遞歸進行同樣的處理。
編譯步驟分為兩步
- 第一步是使用所有滿足條件的
loader
對其進行編譯並且返回編譯之後的源代碼 - 第二步相當於是
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/core
和 webpack
並無聯繫,所以只能辛苦一下,再手寫一個 loader 方法去解析 JS
和 ES6
的語法。
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),不定時推送文章: