前言
webapck 相關的內容:
-
手寫一個 JavaScript 打包器
-
所有配置項
-
優化 90% 打包速度
-
優化包體積
-
優化首屏加載時間與頁面流暢度
-
構建包分析
-
詳細配置
-
手寫一個 webapck 插件(模擬 HtmlWebpackPlugin 的實現)
-
webapck4 核心源碼解讀
-
webapck5 展望
在這章你將會深入理解 JavaScript 打包器是什麼,它的打包機制是什麼?解決了什麼問題?如果你理解了這些,接下來的 webpack 優化就會很簡單。
一、什麼是模塊
一個模塊可以有很多定義,但我認爲:模塊是一組與特定功能相關的代碼。它封裝了實現細節,公開了一個公共API,並與其他模塊結合以構建更大的應用程序。
例如在一個項目中:
<html>
<script src="/src/man.js"></script>
<script src="/src/person.js"></script>
</html>
其中 person.js
中依賴 man.js
,在引用時如果你把它們的引用順序顛倒就會報錯。在大型項目中,這種依賴關係就顯得尤其重要,而且極難維護,除此之外,它還有以下問題:
-
一切都加載到全局上下文中,導致名稱衝突和覆蓋
-
涉及開發人員的大量手動工作,以找出依賴關係和包含順序
模塊就尤其重要。
由於前後端 JavaScript 分別擱置在 HTTP 的兩端,它們扮演的角色不同,側重點也不一樣。 瀏覽器端的 JavaScript 需要經歷從一個服務器端分發到多個客戶端執行,而服務器端 JS 則是相同的代碼需要多次執行。前者的瓶頸在於寬帶,後者的瓶頸則在於 CPU 等內存資源。前者需要通過網絡加載代碼,後者則需要從磁盤中加載, 兩者的加載速度也不是在一個數量級上的。所以前後端的模塊定義不是一致的,其中服務器端的模塊定義爲:
-
CJS(CommonJS):旨在用於服務器端 JavaScript 的同步定義,Node 的模塊系統實際上基於 CJS;
但 CommonJS 是以同步方式導入,因爲用於服務端,文件都在本地,同步導入即使卡住主線程影響也不大,但在瀏覽器端,如果在 UI 加載的過程中需要花費很多時間來等待腳本加載完成,這會造成用戶體驗的很大問題。鑑於網絡的原因, CommonJS 爲後端 JavaScript 制定的規範並不完全適合與前端的應用場景,下面來介紹 JavaScript 前端的規範。
-
AMD(異步模塊定義):被定義爲用於瀏覽器中模塊的異步模型,RequireJS 是 AMD 最受歡迎的實現;
-
UMD(通用模塊定義):它本質上一段 JavaScript 代碼,放置在庫的頂部,可讓任何加載程序、任何環境加載它們;
-
ES2015(ES6):定義了異步導入和導出模塊的語義,會編譯成
require/exports
來執行的,這也是我們現今最常用的模塊定義;
二、什麼是打包器
所謂打包器,就是前端開發人員用來將 JavaScript 模塊打包到一個可以在瀏覽器中運行的優化的 JavaScript 文件的工具,例如 webapck、rollup、gulp 等。
舉個例子,你在一個 html 文件中引入多個 JavaScript 文件:
<html>
<script src="/src/entry.js"></script>
<script src="/src/message.js"></script>
<script src="/src/hello.js"></script>
<script src="/src/name.js"></script>
</html>
當瀏覽器打開該網頁時,每個 js 文件都需要一個單獨的 http 請求,即 4 個往返請求,才能正確的啓動你的項目。
我們知道瀏覽器加載模塊很慢,即使是 HTTP/2 支持有效的加載許多小文件,但其性能都不如加載一個更加有效(即使不做任何優化)。
因此,最好將所有 4 個文件合併爲1個:
<html>
<script src="/dist/bundle.js"></script>
</html>
這樣只需要一次 http 請求即可。
如何打包到一個文件呢?它通常有一個入口文件,從入口文件開始,獲取所有的依賴項,並打包到一個文件 bundle.js
中。例如上例,我們可以以 /src/entry.js
作爲入口文件,進行合併其餘的 3 個 JavaScript 文件。
當然合併不能是簡單的將 4 個文件所有內容放入一個 bundle.js
中。我們先思考一下,它具體該怎麼實現喃?
1. 解析入口文件,獲取所有的依賴項
首先我們唯一確定的是入口文件的地址,通過入口文件的地址可以
-
獲取其文件內容
-
獲取其依賴模塊的相對地址
由於依賴模塊的引入是通過相對路徑(import './message.js'
),所以,我們需要保存入口文件的路徑,結合依賴模塊的相對地址,就可以確定依賴模塊絕對地址,讀取它的內容。
如何在依賴關係中去表示一個模塊,以方便在依賴圖中引用
所以我們可以模塊表示爲:
-
code: 文件解析內容,注意解析後代碼能夠在當前以及舊瀏覽器或環境中運行;
-
dependencies: 依賴數組,爲所有依賴模塊路徑(相對)路徑;
-
filename: 文件絕對路徑,當
import
依賴模塊爲相對路徑,結合當前絕對路徑,獲取依賴模塊路徑;
其中 filename(絕對路徑) 可以作爲每個模塊的唯一標識符,通過 key: value 形式,直接獲取文件的內容依賴模塊:
// 模塊
'src/entry': {
code: '', // 文件解析後內容
dependencies: ["./message.js"], // 依賴項
}
2. 遞歸解析所有的依賴項,生成一個依賴關係圖
我們已經確定了模塊的表示,那怎麼才能將這所有的模塊關聯起來,生成一個依賴關係圖,通過這個依賴關係可以直接獲取所有模塊的依賴模塊、依賴模塊的代碼、依賴模塊的來源、依賴模塊的依賴模塊。
如何去維護依賴文件間的關係
現在對於每一個模塊,可以唯一表示的就是 filename
,而我們在由入口文件遞歸解析時,我們可以獲取到每個文件的依賴數組 dependencies
,也就是每個依賴項的相對路徑,所以我們需要定義一個:
// 關聯關係
let mapping = {}
用來在運行代碼時,由 import
相對路徑映射到 import
絕對路徑。
所以我們模塊可以定義爲[filename: {}]:
// 模塊
'src/entry': {
code: '', // 文件解析後內容
dependencies: ["./message.js"], // 依賴項
mapping:{
"./message.js": "src/message.js"
}
}
則依賴關係圖爲:
// graph 依賴關係圖
let graph = {
// entry 模塊
"src/entry.js": {
code: '',
dependencies: ["./src/message.js"],
mapping:{
"./message.js": "src/message.js"
}
},
// message 模塊
"src/message.js": {
code: '',
dependencies: [],
mapping:{},
}
}
當項目運行時,通過入口文件成功獲取入口文件代碼內容,運行其代碼,當遇到 import
依賴模塊時,通過 mapping
映射其爲絕對路徑,就可以成功讀取模塊內容。
並且每個模塊的絕對路徑 filename 是唯一的,當我們將模塊接入到依賴圖 graph
時,僅僅需要判斷 graph[filename]
是否存在,如果存在就不需要二次加入,剔除掉了模塊的重複打包。
3. 使用依賴圖,返回一個可以在瀏覽器運行的 JavaScript 文件
現今,可立即執行的代碼形式,最流行的就是 IIFE(立即執行函數),它同時能夠解決全局變量污染的問題。
IIFE
所謂 IIFE,就是在聲明被直接調用的匿名函數,由於 JavaScript 變量的作用域僅限於函數內部,所以你不必考慮它會污染全局變量。
(function(man){
function log(name) {
console.log(`hello ${name}`);
}
log(man.name)
})({name: 'bottle'});
// hello bottle
4. 輸出到 dist/bundle.js
fs.writeFile
寫入 dist/bundle.js
即可。
至此,打包流程與實現方案已確定,接下來就實踐一遍吧!
三、創建一個minipack項目
新建一個 minipack 文件夾,並 npm init
,創建以下文件:
- src
- - entry.js // 入口 js
- - message.js // 依賴項
- - hello.js // 依賴項
- - name.js // 依賴項
- index.js // 打包 js
- minipack.config.js // minipack 打包配置文件
- package.json
- .gitignore
其中 entry.js
:
import message from './message.js'
import {name} from './name.js'
message()
console.log('----name-----: ', name)
message.js
:
import {hello} from './hello.js'
import {name} from './name.js'
export default function message() {
console.log(`${hello} ${name}!`)
}
hello.js
:
export const hello = 'hello'
name.js
:
export const name = 'bottle'
minipack.config.js
:
const path = require('path')
module.exports = {
entry: 'src/entry.js',
output: {
filename: "bundle.js",
path: path.resolve(__dirname, './dist'),
}
}
並安裝文件
npm install @babel/core @babel/parser @babel/preset-env @babel/traverse --save-dev
至此,整個項目創建完成。接下來就是打包了:
-
解析入口文件,遍歷所有依賴項
-
遞歸解析所有的依賴項,生成一個依賴關係圖
-
使用依賴圖,返回一個可以在瀏覽器運行的 JavaScript 文件
-
輸出到
/dist/bundle.js
四、解析入口文件,遍歷所有依賴項
1. @babel/parser 解析入口文件,獲取 AST
在 ./index.js 文件中,我們創建一個打包器,首先解析入口文件,我們使用 @babel/parser
解析器進行解析:
步驟一:讀取入口文件內容
// 獲取配置文件
const config = require('./minipack.config');
// 入口
const entry = config.entry;
const content = fs.readFileSync(entry, 'utf-8');
步驟二:使用 @babel/parser
(JavaScript解析器)解析代碼,生成 ast(抽象語法樹)
const babelParser = require('@babel/parser')
const ast = babelParser.parse(content, {
sourceType: "module"
})
其中,sourceType
指示代碼應解析的模式。可以是"script"
, "module"
或 "unambiguous"
之一,其中 "unambiguous"
是讓 @babel/parser
去猜測,如果使用 ES6 import
或 export
的話就是 "module"
,否則爲 "script"
。這裏使用 ES6 import
或 export
,所以就是 "module"
。
由於 ast 樹較複雜,所以這裏我們可以通過 https://astexplorer.net/ 查看:
我們已經獲取了入口文件所有的 ast,接下來我們要做什麼喃?
-
解析 ast,解析入口文件內容(可在當前和舊瀏覽器或環境中向後兼容的 JavaScript 版本)
-
獲取它所有的依賴模塊
dependencies
2. 獲取入口文件內容
我們已經知道了入口文件的 ast,可以通過 @babel/core
的 transformFromAst
方法,來解析入口文件內容:
const {transformFromAst} = require('@babel/core');
const {code} = transformFromAst(ast, null, {
presets: ['@babel/preset-env'],
})
3. 獲取它所有的依賴模塊
就需要通過 ast 獲取所有的依賴模塊,也就是我們需要獲取 ast 中所有的 node.source.value
,也就是 import
模塊的相對路徑,通過這個相對路徑可以尋找到依賴模塊。
步驟一:定義一個依賴數組,用來存放 ast 中解析出的所有依賴
const dependencies = []
步驟二:使用 @babel/traverse
,它和 babel 解析器配合使用,可以用來遍歷及更新每一個子節點
traverse
函數是一個遍歷 AST
的方法,由 babel-traverse
提供,他的遍歷模式是經典的 visitor
模式 ,visitor
模式就是定義一系列的 visitor
,當碰到 AST
的 type === visitor
名字時,就會進入這個 visitor
的函數。類型爲 ImportDeclaration
的 AST 節點,其實就是我們的 import xxx from xxxx
,最後將地址 push
到 dependencies
中.
const traverse = require('@babel/traverse').default
traverse(ast, {
// 遍歷所有的 import 模塊,並將相對路徑放入 dependencies
ImportDeclaration: ({node}) => {
dependencies.push(node.source.value)
}
})
3. 有效返回
{
dependencies,
code,
}
完整代碼:
/**
* 解析文件內容及其依賴,
* 期望返回:
* dependencies: 文件依賴模塊
* code: 文件解析內容
* @param {string} filename 文件路徑
*/
function createAsset(filename) {
// 讀取文件內容
const content = fs.readFileSync(filename, 'utf-8')
// 使用 @babel/parser(JavaScript解析器)解析代碼,生成 ast(抽象語法樹)
const ast = babelParser.parse(content, {
sourceType: "module"
})
// 從 ast 中獲取所有依賴模塊(import),並放入 dependencies 中
const dependencies = []
traverse(ast, {
// 遍歷所有的 import 模塊,並將相對路徑放入 dependencies
ImportDeclaration: ({
node
}) => {
dependencies.push(node.source.value)
}
})
// 獲取文件內容
const {
code
} = transformFromAst(ast, null, {
presets: ['@babel/preset-env'],
})
// 返回結果
return {
dependencies,
code,
}
}
五、遞歸解析所有的依賴項,生成一個依賴關係圖
步驟一:獲取入口文件:
const mainAssert = createAsset(entry)
步驟二:創建依賴關係圖:
由於每個模塊都是 key: value 形式,所以定義依賴圖爲:
// entry: 入口文件絕對地址
const graph = {
[entry]: mainAssert
}
步驟三:遞歸搜索所有的依賴模塊,加入到依賴關係圖中:
定義一個遞歸搜索函數:
/**
* 遞歸遍歷,獲取所有的依賴
* @param {*} assert 入口文件
*/
function recursionDep(filename, assert) {
// 跟蹤所有依賴文件(模塊唯一標識符)
assert.mapping = {}
// 由於所有依賴模塊的 import 路徑爲相對路徑,所以獲取當前絕對路徑
const dirname = path.dirname(filename)
assert.dependencies.forEach(relativePath => {
// 獲取絕對路徑,以便於 createAsset 讀取文件
const absolutePath = path.join(dirname, relativePath)
// 與當前 assert 關聯
assert.mapping[relativePath] = absolutePath
// 依賴文件沒有加入到依賴圖中,才讓其加入,避免模塊重複打包
if (!queue[absolutePath]) {
// 獲取依賴模塊內容
const child = createAsset(absolutePath)
// 將依賴放入 queue,以便於繼續調用 recursionDep 解析依賴資源的依賴,
// 直到所有依賴解析完成,這就構成了一個從入口文件開始的依賴圖
queue[absolutePath] = child
if(child.dependencies.length > 0) {
// 繼續遞歸
recursionDep(absolutePath, child)
}
}
})
}
從入口文件開始遞歸:
// 遍歷 queue,獲取每一個 asset 及其所以依賴模塊並將其加入到隊列中,直至所有依賴模塊遍歷完成
for (let filename in queue) {
let assert = queue[filename]
recursionDep(filename, assert)
}
六、使用依賴圖,返回一個可以在瀏覽器運行的 JavaScript 文件
步驟一:創建一個了立即執行函數,用於在瀏覽器上直接運行
const result = `
(function() {
})()
`
步驟二:將依賴關係圖作爲參數傳遞給立即執行函數
定義傳遞參數 modules:
let modules = ''
遍歷 graph
,將每個 mod
以 key: value,
的方式加入到 modules
,
注意:由於依賴關係圖要傳入以上立即執行函數中,然後寫入到 dist/bundle.js
運行,所以,code
需要放在 function(require, module, exports){${mod.code}}
中,避免污染全局變量或其它模塊
for (let filename in graph) {
let mod = graph[filename]
modules += `'${filename}': [
function(require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`
}
步驟三:將參數傳入立即執行函數,並立即執行入口文件:
首先實現一個 require 函數,require('${entry}')
執行入口文件,entry
爲入口文件絕對路徑,也爲模塊唯一標識符
const result = `
(function(modules) {
require('${entry}')
})({${modules}})
`
注意:modules
是一組 key: value,
,所以我們將它放入 {}
中
步驟四:重寫瀏覽器 require
方法,當代碼運行 require('./message.js')
轉換成 require(src/message.js)
const result = `
(function(modules) {
function require(moduleId) {
const [fn, mapping] = modules[moduleId]
function localRequire(name) {
return require(mapping[name])
}
const module = {exports: {}}
fn(localRequire, module, module.exports)
return module.exports
}
require('${entry}')
})({${modules}})
`
注意:
-
moduleId
爲傳入的filename
,爲模塊的唯一標識符 -
通過解構
const [fn, mapping] = modules[id]
來獲得我們的函數包裝(function(require, module, exports) {${mod.code}}
)和mappings
對象 -
由於一般情況下
require
都是require
相對路徑,而不是絕對路徑,所以重寫fn
的require
方法,將require
相對路徑轉換成require
絕對路徑,即localRequire
函數 -
將
module.exports
傳入到fn
中,將依賴模塊內容需要輸出給其它模塊使用時,當require
某一依賴模塊時,就可以直接通過module.exports
將結果返回
七、輸出到 dist/bundle.js
// 打包
const result = bundle(graph)
// 寫入 ./dist/bundle.js
fs.writeFile(`${output.path}/${output.filename}`, result, (err) => {
if (err) throw err;
console.log('文件已被保存');
})
源碼地址:https://github.com/sisterAn/minipack