歡迎您光臨本站 註冊首頁

深入webpack打包原理及loader和plugin的實現

←手機掃碼閱讀     techdo @ 2020-05-09 , reply:0

本文討論的核心內容如下:
webpack 進行打包的基本原理
進行打包的基本原理 如何自己實現一個 loader 和 plugin
註: 本文使用的 webpack 版本是 v4.43.0 , webpack-cli 版本是 v3.3.11 , node 版本是 v12.14.1 , npm 版本 v6.13.4 (如果你喜歡 yarn 也是可以的),演示用的 chrome 瀏覽器版本 81.0.4044.129(正式版本) (64 位)
1. webpack打包基本原理
webpack的一個核心功能就是把我們寫的模組化的程式碼,打包之後,生成可以在瀏覽器中執行的程式碼,我們這裡也是從簡單開始,一步步探索webpack的打包原理
1.1 一個簡單的需求
我們首先建立一個空的專案,使用 npm init -y 快速初始化一個 package.json ,然後安裝 webpack webpack-cli
接下來,在根目錄下建立 src 目錄, src 目錄下建立 index.js , add.js , minus.js ,根目錄下建立 index.html ,其中 index.html 引入 index.js ,在 index.js 引入 add.js , minus.js ,
目錄結構如下:
檔案內容如下:
// add.js export default (a, b) => { return a + b } // minus.js export const minus = (a, b) => { return a - b } // index.js import add from './add.js' import { minus } from './minus.js' const sum = add(1, 2) const division = minus(2, 1) console.log('sum>>>>>', sum) console.log('division>>>>>', division)


demo


這樣直接在 index.html 引入 index.js 的程式碼,在瀏覽器中顯然是不能執行的,你會看到這樣的錯誤
Uncaught SyntaxError: Cannot use import statement outside a module
是的,我們不能在 script 引入的 js 檔案裡,使用 es6 模組化語法
1.2 實現webpack打包核心功能
我們首先在專案根目錄下再建立一個bundle.js,這個檔案用來對我們剛剛寫的模組化 js 程式碼檔案進行打包
我們首先來看webpack官網對於其打包流程的描述:
it internally builds a dependency graph which maps every module your project needs and generates one or more bundles(webpack會在內部構建一個 依賴圖(dependency graph),此依賴圖會對映專案所需的每個模組,並生成一個或多個 bundle)
在正式開始之前,結合上面 webpack 官網說明進行分析,明確我們進行打包工作的基本流程如下:
首先,我們需要讀到入口檔案裡的內容(也就是index.js的內容) 其次,分析入口檔案,遞迴的去讀取模組所依賴的檔案內容,生成依賴圖 最後,根據依賴圖,生成瀏覽器能夠執行的最終程式碼 1. 處理單個模組(以入口為例) 1.1 獲取模組內容
既然要讀取檔案內容,我們需要用到 node.js 的核心模組 fs ,我們首先來看讀到的內容是什麼:
// bundle.js const fs = require('fs') const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') console.log(body) } getModuleInfo('./src/index.js')
我們定義了一個方法 getModuleInfo ,這個方法裡我們讀出文件內容,列印出來,輸出的結果如下圖:
我們可以看到,入口檔案 index.js 的所有內容都以字串形式輸出了,我們接下來可以用正規表示式或者其它一些方法,從中提取到 import 以及 export 的內容以及相應的路徑檔名,來對入口檔案內容進行分析,獲取有用的資訊。但是如果 import 和 export 的內容非常多,這會是一個很麻煩的過程,這裡我們藉助 babel
提供的功能,來完成入口檔案的分析
1.2 分析模組內容
我們安裝 @babel/parser ,演示時安裝的版本號為 ^7.9.6
這個babel模組的作用,就是把我們js檔案的程式碼內容,轉換成js物件的形式,這種形式的js物件,稱做 抽象語法樹(Abstract Syntax Tree, 以下簡稱AST)
// bundle.js const fs = require('fs') const parser = require('@babel/parser') const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') const ast = parser.parse(body, { // 表示我們要解析的是es6模組 sourceType: 'module' }) console.log(ast) console.log(ast.program.body) } getModuleInfo('./src/index.js')
使用 @babel/parser 的 parse 方法把入口檔案轉化稱為了 AST ,我們列印出了 ast ,注意檔案內容是在 ast.program.body 中,如下圖所示:
入口檔案內容被放到一個陣列中,總共有六個 Node 節點,我們可以看到,每個節點有一個 type 屬性,其中前兩個的 type 屬性是 ImportDeclaration ,這對應了我們入口檔案的兩條 import 語句,並且,每一個 type 屬性是 ImportDeclaration 的節點,其 source.value
屬性是引入這個模組的相對路徑,這樣我們就得到了入口檔案中對打包有用的重要資訊了。
接下來要對得到的ast做處理,返回一份結構化的資料,方便後續使用。
1.3 對模組內容做處理
對 ast.program.body 部分資料的獲取和處理,本質上就是對這個陣列的遍歷,在迴圈中做資料處理,這裡同樣引入一個babel的模組 @babel/traverse 來完成這項工作。
安裝 @babel/traverse ,演示時安裝的版本號為 ^7.9.6
const fs = require('fs') const path = require('path') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') const ast = parser.parse(body, { sourceType: 'module' }) const deps = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const absPath = './' + path.join(dirname, node.source.value) deps[node.source.value] = absPath } }) console.log(deps) } getModuleInfo('./src/index.js')
建立一個物件 deps ,用來收集模組自身引入的依賴,使用 traverse 遍歷 ast ,我們只需要對 ImportDeclaration 的節點做處理,注意我們做的處理實際上就是把相對路徑轉化為絕對路徑,這裡我使用的是 Mac 系統,如果是 windows 系統,注意斜槓的區別
獲取依賴之後,我們需要對 ast 做語法轉換,把 es6 的語法轉化為 es5 的語法,使用 babel 核心模組 @babel/core 以及 @babel/preset-env 完成
安裝 @babel/core @babel/preset-env ,演示時安裝的版本號均為 ^7.9.6
const fs = require('fs') const path = require('path') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const babel = require('@babel/core') const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') const ast = parser.parse(body, { sourceType: 'module' }) const deps = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const absPath = './' + path.join(dirname, node.source.value) deps[node.source.value] = absPath } }) const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"] }) const moduleInfo = { file, deps, code } console.log(moduleInfo) return moduleInfo } getModuleInfo('./src/index.js')
如下圖所示,我們最終把一個模組的程式碼,轉化為一個物件形式的資訊,這個物件包含檔案的絕對路徑,檔案所依賴模組的資訊,以及模組內部經過 babel 轉化後的程式碼
2. 遞迴的獲取所有模組的資訊
這個過程,也就是獲取 依賴圖(dependency graph) 的過程,這個過程就是從入口模組開始,對每個模組以及模組的依賴模組都呼叫 getModuleInfo 方法就行分析,最終返回一個包含所有模組資訊的物件
const parseModules = file => { // 定義依賴圖 const depsGraph = {} // 首先獲取入口的資訊 const entry = getModuleInfo(file) const temp = [entry] for (let i = 0; i < temp.length; i++) { const item = temp[i] const deps = item.deps if (deps) { // 遍歷模組的依賴,遞迴獲取模組資訊 for (const key in deps) { if (deps.hasOwnProperty(key)) { temp.push(getModuleInfo(deps[key])) } } } } temp.forEach(moduleInfo => { depsGraph[moduleInfo.file] = { deps: moduleInfo.deps, code: moduleInfo.code } }) console.log(depsGraph) return depsGraph } parseModules('./src/index.js')
獲得的depsGraph物件如下圖:
我們最終得到的模組分析資料如上圖所示,接下來,我們就要根據這裡獲得的模組分析資料,來生產最終瀏覽器執行的程式碼。
3. 生成最終程式碼
在我們實現之前,觀察上一節最終得到的依賴圖,可以看到,最終的code裡包含exports以及require這樣的語法,所以,我們在生成最終程式碼時,要對exports和require做一定的實現和處理
我們首先呼叫之前說的parseModules方法,獲得整個應用的依賴圖物件:
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) }
接下來我們應該把依賴圖物件中的內容,轉換成能夠執行的程式碼,以字串形式輸出。 我們把整個程式碼放在自執行函式中,引數是依賴圖物件
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return `(function(graph){ function require(file) { var exports = {}; return exports } require('${file}') })(${depsGraph})` }
接下來內容其實很簡單,就是我們取得入口檔案的code資訊,去執行它就好了,使用eval函式執行,初步寫出程式碼如下:
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return `(function(graph){ function require(file) { var exports = {}; (function(code){ eval(code) })(graph[file].code) return exports } require('${file}') })(${depsGraph})` }
上面的寫法是有問題的,我們需要對file做絕對路徑轉化,否則 graph[file].code 是獲取不到的,定義adsRequire方法做相對路徑轉化為絕對路徑
const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return `(function(graph){ function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function(require, exports, code){ eval(code) })(absRequire, exports, graph[file].code) return exports } require('${file}') })(${depsGraph})` }
接下來,我們只需要執行bundle方法,然後把生成的內容寫入一個JavaScript檔案即可
const content = bundle('./src/index.js') // 寫入到dist/bundle.js fs.mkdirSync('./dist') fs.writeFileSync('./dist/bundle.js', content)
最後,我們在index.html引入這個 ./dist/bundle.js 檔案,我們可以看到控制檯正確輸出了我們想要的結果
4. bundle.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') const getModuleInfo = file => { const body = fs.readFileSync(file, 'utf-8') console.log(body) const ast = parser.parse(body, { sourceType: 'module' }) // console.log(ast.program.body) const deps = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const absPath = './' + path.join(dirname, node.source.value) deps[node.source.value] = absPath } }) const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"] }) const moduleInfo = { file, deps, code } return moduleInfo } const parseModules = file => { // 定義依賴圖 const depsGraph = {} // 首先獲取入口的資訊 const entry = getModuleInfo(file) const temp = [entry] for (let i = 0; i < temp.length; i++) { const item = temp[i] const deps = item.deps if (deps) { // 遍歷模組的依賴,遞迴獲取模組資訊 for (const key in deps) { if (deps.hasOwnProperty(key)) { temp.push(getModuleInfo(deps[key])) } } } } temp.forEach(moduleInfo => { depsGraph[moduleInfo.file] = { deps: moduleInfo.deps, code: moduleInfo.code } }) // console.log(depsGraph) return depsGraph } // 生成最終可以在瀏覽器執行的程式碼 const bundle = file => { const depsGraph = JSON.stringify(parseModules(file)) return `(function(graph){ function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function(require, exports, code){ eval(code) })(absRequire, exports, graph[file].code) return exports } require('${file}') })(${depsGraph})` } const build = file => { const content = bundle(file) // 寫入到dist/bundle.js fs.mkdirSync('./dist') fs.writeFileSync('./dist/bundle.js', content) } build('./src/index.js')
2. 手寫 loader 和 plugin
2.1 如何自己實現一個 loader
loader本質上就是一個函式,這個函式會在我們在我們載入一些檔案時執行
2.1.1 如何實現一個同步 loader
首先我們初始化一個專案,專案結構如圖所示:
其中index.js和webpack.config.js的檔案內容如下:
// index.js console.log('我要學好前端,因為學好前端可以: ') // webpack.config.js const path = require('path') module.exports = { mode: 'development', entry: { main: './src/index.js' }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' } }
我們在根目錄下建立 syncLoader.js ,用來實現一個同步的loader,注意這個函式必須返回一個 buffer 或者 string
// syncloader.ja module.exports = function (source) { console.log('source>>>>', source) return source }
同時,我們在 webpack.config.js 中使用這個 loader ,我們這裡使用 resolveLoader 配置項,指定 loader 查詢檔案路徑,這樣我們使用 loader 時候可以直接指定 loader 的名字
const path = require('path') module.exports = { mode: 'development', entry: { main: './src/index.js' }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' }, resolveLoader: { // loader路徑查詢順序從左往右 modules: ['node_modules', './'] }, module: { rules: [ { test: /.js$/, use: 'syncLoader' } ] } }
接下來我們執行打包命令,可以看到命令列輸出了source內容,也就是loader作用檔案的內容。
接著我們改造我們的loader:
module.exports = function (source) { source += '升值加薪' return source }
我們再次執行打包命令,去觀察打包後的程式碼:
這樣,我們就實現了一個簡單的loader,為我們的檔案增加一條資訊。 我們可以嘗試在 loader 的函式裡列印 this ,發現輸出結果是非常長的一串內容, this 上有很多我們可以在 loader 中使用的有用資訊,所以,對於 loader 的編寫,一定不要使用箭頭函式,那樣會改變 this
的指向。
一般來說,我們會去使用官方推薦的 loader-utils 包去完成更加複雜的 loader 的編寫
我們繼續安裝 loader-utils ,版本是 ^2.0.0
我們首先改造 webpack.config.js :
const path = require('path') module.exports = { mode: 'development', entry: { main: './src/index.js' }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' }, resolveLoader: { // loader路徑查詢順序從左往右 modules: ['node_modules', './'] }, module: { rules: [ { test: /.js$/, use: { loader: 'syncLoader', options: { message: '升值加薪' } } } ] } }
注意到,我們為我們的 loader 增加了 options 配置項,接下來在loader函式裡使用loader-utils獲取配置項內容,拼接內容,我們依然可以得到與之前一樣的打包結果
// syncLoader.js const loaderUtils = require('loader-utils') module.exports = function (source) { const options = loaderUtils.getOptions(this) console.log(options) source += options.message // 可以傳遞更詳細的資訊 this.callback(null, source) }
這樣,我們就完成了一個簡單的同步 loader 的編寫
2.1.2 如何實現一個非同步 loader
和同步loader的編寫方式非常相似,我們在根目錄下建立一個asyncLoader.js的檔案,內容如下:
const loaderUtils = require('loader-utils') module.exports = function (source) { const options = loaderUtils.getOptions(this) const asyncfunc = this.async() setTimeout(() => { source += '走上人生顛覆' asyncfunc(null, res) }, 200) }
注意這裡的 this.async() ,用官方的話來說就是 Tells the loader-runner that the loader intends to call back asynchronously. Returns this.callback. 也就是讓webpack知道這個loader是非同步執行,返回的是和同步使用時一致的 this.callback
接下來我們修改webpack.config.js
const path = require('path') module.exports = { mode: 'development', entry: { main: './src/index.js' }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' }, resolveLoader: { // loader路徑查詢順序從左往右 modules: ['node_modules', './'] }, module: { rules: [ { test: /.js$/, use: [ { loader: 'syncLoader', options: { message: '走上人生巔峰' } }, { loader: 'asyncLoader' } ] } ] } }
注意loader執行順序是從下網上的,所以首先為文字寫入『升值加薪',然後寫入『走上人生巔峰'
到此,我們簡單介紹瞭如何手寫一個 loader ,在實際專案中,可以考慮一部分公共的簡單邏輯,可以透過編寫一個 loader 來完成(比如國際化文字替換)
2.2 如何自己實現一個 plugin
plugin 通常是在 webpack 在打包的某個時間節點做一些操作,我們使用 plugin 的時候,一般都是 new Plugin() 這種形式使用,所以,首先應該明確的是, plugin 應該是一個類。
我們初始化一個與上一接實現loader時候一樣的專案,根目錄下建立一個 demo-webpack-plugin.js 的檔案,我們首先在 webpack.config.js 中使用它
const path = require('path') const DemoWebpackPlugin = require('./plugins/demo-webpack-plugin') module.exports = { mode: 'development', entry: { main: './src/index.js' }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' }, plugins: [ new DemoWebpackPlugin() ] }
再來看 demo-webpack-plugin.js 的實現
class DemoWebpackPlugin { constructor () { console.log('plugin init') } apply (compiler) { } } module.exports = DemoWebpackPlugin
我們在 DemoWebpackPlugin 的建構函式列印一條資訊,當我們執行打包命令時,這條資訊就會輸出, plugin 類裡面需要實現一個 apply 方法, webpack 打包時候,會呼叫 plugin 的 aplly 方法來執行 plugin 的邏輯,這個方法接受一個 compiler 作為引數,這個 compiler 是 webpack 例項
plugin的核心在於,apply方法執行時,可以操作webpack本次打包的各個時間節點(hooks,也就是生命週期勾子),在不同的時間節點做一些操作
關於webpack編譯過程的各個生命週期勾子,可以參考 Compiler Hooks
同樣,這些hooks也有同步和非同步之分,下面演示 compiler hooks 的寫法,一些重點內容可以參考註釋:
class DemoWebpackPlugin { constructor () { console.log('plugin init') } // compiler是webpack例項 apply (compiler) { // 一個新的編譯(compilation)建立之後(同步) // compilation代表每一次執行打包,獨立的編譯 compiler.hooks.compile.tap('DemoWebpackPlugin', compilation => { console.log(compilation) }) // 生成資源到 output 目錄之前(非同步) compiler.hooks.emit.tapAsync('DemoWebpackPlugin', (compilation, fn) => { console.log(compilation) compilation.assets['index.md'] = { // 檔案內容 source: function () { return 'this is a demo for plugin' }, // 檔案尺寸 size: function () { return 25 } } fn() }) } } module.exports = DemoWebpackPlugin
我們的這個 plugin 的作用就是,打包時候自動生成一個 md 文件,文件內容是很簡單的一句話
上述非同步hooks的寫法也可以是以下兩種:
// 第二種寫法(promise) compiler.hooks.emit.tapPromise('DemoWebpackPlugin', (compilation) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve() }, 1000) }).then(() => { console.log(compilation.assets) compilation.assets['index.md'] = { // 檔案內容 source: function () { return 'this is a demo for plugin' }, // 檔案尺寸 size: function () { return 25 } } }) }) // 第三種寫法(async await) compiler.hooks.emit.tapPromise('DemoWebpackPlugin', async (compilation) => { await new Promise((resolve, reject) => { setTimeout(() => { resolve() }, 1000) }) console.log(compilation.assets) compilation.assets['index.md'] = { // 檔案內容 source: function () { return 'this is a demo for plugin' }, // 檔案尺寸 size: function () { return 25 } } })
最終的輸出結果都是一樣的,在每次打包時候生成一個md文件


[techdo ] 深入webpack打包原理及loader和plugin的實現已經有309次圍觀

http://coctec.com/docs/program/show-post-233418.html