深刻webpack打包原理,loader和plugin的實現

本文討論的核心內容以下:javascript

  1. webpack進行打包的基本原理
  2. 如何本身實現一個loaderplugin

注: 本文使用的webpack版本是v4.43.0, webpack-cli版本是v3.3.11node版本是v12.14.1npm版本v6.13.4(若是你喜歡yarn也是能夠的),演示用的chrome瀏覽器版本81.0.4044.129(正式版本) (64 位)html

1. webpack打包基本原理

webpack的一個核心功能就是把咱們寫的模塊化的代碼,打包以後,生成能夠在瀏覽器中運行的代碼,咱們這裏也是從簡單開始,一步步探索webpack的打包原理前端

1.1 一個簡單的需求

咱們首先創建一個空的項目,使用npm init -y快速初始化一個package.json,而後安裝webpack webpack-clijava

接下來,在根目錄下建立src目錄,src目錄下建立index.jsadd.jsminus.js,根目錄下建立index.html,其中index.html引入index.js,在index.js引入add.jsminus.jsnode

目錄結構以下:webpack

文件內容以下:es6

// 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)
複製代碼
<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>demo</title>
</head>
<body>
    <script src="./src/index.js"></script>
</body>
</html>
複製代碼

這樣直接在index.html引入index.js的代碼,在瀏覽器中顯然是不能運行的,你會看到這樣的錯誤web

Uncaught SyntaxError: Cannot use import statement outside a module
複製代碼

是的,咱們不能在script引入的js文件裏,使用es6模塊化語法正則表達式

1.2 實現webpack打包核心功能

咱們首先在項目根目錄下再創建一個bundle.js,這個文件用來對咱們剛剛寫的模塊化js代碼文件進行打包chrome

咱們首先來看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官網說明進行分析,明確咱們進行打包工做的基本流程以下:

  1. 首先,咱們須要讀到入口文件裏的內容(也就是index.js的內容)
  2. 其次,分析入口文件,遞歸的去讀取模塊所依賴的文件內容,生成依賴圖
  3. 最後,根據依賴圖,生成瀏覽器可以運行的最終代碼

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的內容以及相應的路徑文件名,來對入口文件內容進行分析,獲取有用的信息。可是若是 importexport的內容很是多,這會是一個很麻煩的過程,這裏咱們藉助 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/parserparse方法把入口文件轉化稱爲了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. 手寫loaderplugin

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('./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打包時候,會調用pluginaplly方法來執行plugin的邏輯,這個方法接受一個compiler做爲參數,這個compilerwebpack實例

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文檔

到此爲止,本文介紹了webpack打包的基本原理,以及本身實現loader和plugin的方法。但願本文內容能對你們對webpack的學習,使用帶來幫助,謝謝你們。

相關文章
相關標籤/搜索