webpack5进阶

484 阅读11分钟

前面一篇文章,我们整理了webpack的基础配置和概念,接下来我们就开始进阶版的学习~

1.vue-cli的配置文件查看

当前有vue项目的可以尝试使用以下命令输出配置文件

vue inspect --mode=development > webpack.dev.js
//
vue inspect --mode=production > webpack.prod.js

通过以上两个命令,我们可以在将不同mode环境中的webpack配置文件单独打包到我们指定的文件名中webpack.dev.jswebpack.prod.js

观察发现里面很多的配置基本上都是我们之前基础入门中讲到的东西,不过多了一些配置和插件,这里就不细细讲解了,有不明白的网上查看吧~

2.loader

package以及结构准备工作如下

1.手写loader及使用

loader1.js

loader的本质是一个函数

module.exports = function(content, map, meta){
    console.log(content,1)
    return content
}

content上下文,map对应的文件source-map映射关系,meta文件的源信息

以上写法等同于以下

module.exports = function(content, map, meta){
    console.log(content,1)
    this.callback(null, content)
}

使用方式

index.js

console.log("测试 loader")

config文件

const path = require('path')

module.exports = {
    mode: 'production',
    module: {
        rules:[
            {
                test: /\.js$/,
                use:[
                    'loader1'
                ]
            }
        ]
    },
    //配置loader解析规则
    resolveLoader: {
        modules: [
            'node_modules',
            path.resolve(__dirname, 'loaders')
        ]
    }
}

npx webpack执行,结果如下,说明loader生效了

2.loader的执行顺序

为了验证我们的loader执行顺序,新建一个loader2.js

module.exports = function(content, map, meta){
    console.log(content,2)
    this.callback(null, content)
}

配置文件:

    module: {
        rules:[
            {
                test: /\.js$/,
                use:[
                    'loader1',
                    'loader2'
                ]
            }
        ]
    }

输出结果如下:

所以我们验证出loader的顺序是从下往上

如果写法为:use:['loader1','loader2'] 也可以成为从右往左

Pitching Loader

普通的loader函数执行顺序为从下往上,但是定义loader函数的时候导入一个ptich方法,将会先与loader函数本身执行,并且多个loader将会从上往下执行,并不会在乎上一个loader的返回结果

  • demo1

修改loader1为以下内容:

module.exports = function(content, map, meta){    
    console.log(content,1)    
    return content
}
module.exports.pitch =  function (){    
    console.log('pitch 1')
}

loader2同以上

执行打包观察输出顺序:

3.loader的同步和异步

以上的loader写法就是同步方式

修改loader2测试异步方式: this.async()

module.exports = function(content,map,meta){
    console.log(content,2)

    const callback = this.async()
    setTimeout(() => {
        callback(null,content)
    },1000)
}

module.exports.pitch = function(){
    console.log('pitch 2')
}

打包:

4.options的获取及校验

之前的文章中我们知道使用loader的时候我们通常还会更改一些loader的属性

新建loader3获取options中传入的值: this.getOptions()

module.exports = function(content, map, meta){
    const options = this.getOptions()
    console.log(content,options,3)
    return content
}

修改配置文件为以下:

    module: {
        rules:[
            {
                test: /\.js$/,
                use:[
                    'loader1',
                    'loader2',
                    {
                        loader: 'loader3',
                        options: {
                            name: 'lxy'
                        }
                    }
                ]
            }
        ]
    }

打包:

验证options值

this.getOptions(schema):提取给定的 loader 选项,接受一个可选的 JSON schema 作为参数

//引入验证的规则文件
const schema = require('./schema.json)

module.exports = function(content, map, meta){
    const options = this.getOptions(schema)
    console.log(content,options,3)
    return content
}

新建schema.json文件:

{
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description":"名称~"
        }
    },
    "additionalProperties": true
}

additionalProperties这个属性表示是否可以追加没有定义的属性

  • 测试将配置文件的name修改为number

  • 修改additionalProperties属性为false,options中追加age属性

5.实战手写简单babel loader

配置文件修改为:

{
    test: /\.js$/,
    loader: 'babelLoader',
    options: {
        presets:[
            '@babel/preset-env'
        ]
    }
}

新建babelSchema.json

{
    "type": "object",
    "properties": {
        "presets": {
            "type": "array"
        }
    },
    "additionalProperties": true
}

新建babelLoader.js

const babelSchema = require('./babelSchema.json')
const babel = require('@babel/core')
const util = require('util')

//babel.transform用来编译代码,是一个普通的异步方法

module.exports = funcion(content,map,meta){
    const option = this.getOptions('babelSchema')
    const callback = this.async()
    //将content按照options标准编译
    babel.transformAsync(content,options).then(res => {
        console.log(res)
        callback(null,res.code)
    }).catch(err => {
        callback(err)
    })

index.js中按照es6的写法,然后测试查看打包文件的变化吧

3.plugins

相信到这里之后,大家应该会对插件有了一定的了解。现在就开始更深入的了解插件,以及如何自定义一个功能性插件吧~

准备工作

1.了解Tapable

webpack的本质上是事件流机制,而其核心的模块就是Tapable它暴露了一些方法让插件可以向webpack注入一些自定义构建步骤。

主要暴露的方法是:taptapAsynctapPromise

学习以下tapable的具体使用吧

npm i tapable -D

tapable公开了许多的钩子类

const {
	SyncHook, //这是基本hook
	SyncBailHook,
	SyncWaterfallHook, 
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
 } = require("tapable");

以上钩子可以拼接以下字段意思理解

sync同步

  • Bai :  任意函数有返回值,就退出
  • Waterfall : 将每个函数的返回值传递给下一个
  • Loop : 当循环钩子中的插件返回一个未定义的值时,钩子将从第一个插件重启。它会循环,直到所有的插件返回undefined。

async异步

  • Parallel : 并行执行相关函数

  • Series : 串行

尝试两个同步tapable的钩子使用,编辑tapable.test.js

const {
    SyncHook,
    SyncBailHook
} = require('tapable')

class Lesson{
    constructor(){
        //初始化hooks
        this.hooks = {
            course: new SyncHook(['type']),
            course1: new SyncBailHook(['type'])
        }
    }
    tap(){
        //在hooks容器注册相应事件/添加回调函数
        this.hooks.course.tap('course1',type => {
            console.log('course1:'+type)
        })
        this.hooks.course.tap('course2',type => {
            console.log('course2:'+type)
        })
        //SyncBailHook
        this.hooks.course1.tap('course3',type => {
            console.log('course3:'+type)
            return type
        })
        this.hooks.course1.tap('course4',type => {
            console.log('course4:'+type)
        })
    }
    start(){
        //触发hooks
        this.hooks.course.call('english')
        this.hooks.course1.call('math')
    }
}

const lesson = new Lesson()
lesson.tap()
lesson.start()

node运行该文件

尝试两个异步tapable的钩子使用,修改tapable.test.js

注意一下tapAsync和tapPromise的具体使用方式:

const {
    AsyncParallelHook,
    AsyncSeriesHook
} = require('tapable')

class Lesson{
    constructor(){
        //初始化hooks
        this.hooks = {
            course2: new AsyncParallelHook(['type']), //异步并行
            course3: new AsyncSeriesHook(['type']), //异步串行
        }
    }
    tap(){
        //AsyncParallelHook
        this.hooks.course2.tapAsync('course2',(type, cb) => {
            setTimeout(() => {
                console.log('course21:'+type)
                cb()
            }, 2000)
        })
        this.hooks.course2.tapAsync('course2',(type, cb) => {
            setTimeout(() => {
                console.log('course22:'+type)
                cb()
            }, 1000)
        })
        //AsyncSeriesHook
        this.hooks.course3.tapPromise('course3', (type) => {
            //return promise
            return new Promise(resolve => {
                setTimeout(() => {
                    console.log('course31:' + type)
                    resolve()
                }, 3000)
            })

        })
        this.hooks.course3.tapPromise('course3', (type) => {
           return new Promise(resolve => {
               setTimeout(() => {
                   console.log('course32:' + type)
                   resolve()
               }, 1000)
           })
        })
    }
    start(){
        this.hooks.course2.callAsync('chinese', () => {
            console.log('course2 end~~~')
        })
        this.hooks.course3.promise('chinese').then(() => {
            console.log('course3 end~~~')
        })
    }
}

node运行结果:注意查看输出顺序

总结:同步hook注册事件是通过tap,触发事件是通过call

           异步hook注册事件是通过tap,tapAsync或者tapPromise,触发则是call,callAsync或promise

2.compiler和compilation

webpack中的两个核心类就是compilercompilation,且它两都是继承自Tapable。

compiler是整个webpack环境的生命周期对象,compilation是webpack每次构建过程中的生命周期对象。且compilation是compiler创建的实例,分别都有自己对应的生命周期和hook。

  • compiler

列举两个生命周期,具体可查看webpack官网

webpack 插件是一个具有 apply 方法的 JavaScript 对象。apply 方法会被 webpack compiler 调用,并且在__整个__编译生命周期都可以访问 compiler 对象。

plugins目录下新建plugin1.js,尝试基本的插件写法

//plugin 是一个类
//生命周期钩子函数,是由 compiler 暴露

class Plugin1 {
    apply(complier){
        complier.hooks.emit.tap('Plugin1',(compilation) => {
            console.log('emit.tap 1111')
        })
        complier.hooks.emit.tapAsync('Plugin1', (compilation, cb) => {
            setTimeout(()=>{
                console.log('emit.tapAsync 1111')
                cb()
            },1000)
        })
        complier.hooks.emit.tapPromise('Plugin1', (compilation) => {
            return new Promise(resolve => {
                setTimeout(() => {
                    console.log('emit.tapPromise 1111')
                    resolve()
                }, 1000)
            })
        })
        complier.hooks.afterEmit.tap('Plugin1', (compilation) => {
            console.log('afterEmit.tap 2222')
        })
    }
}

module.exports = Plugin1

配置文件

const Plugin1 = require("./plugins/plugin1")

module.exports = {
    mode: 'production',
    plugins: [
        new Plugin1(),
    ]
}

打包查看结果即可

  • compilation

这里我们尝试一个demo1,功能主要是在打包的目录里面增加一个文件

涉及的有compiler中的thisCompilationcompilation中的additionalAssets

class Plugin1 {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap('Plugin1',(compilation) => {
            compilation.hooks.additionalAssets.tapAsync('Plugin1',cb => {
                const content = "柳暗花明又一村"
                compilation.assets['assets.txt'] = {
                    size(){
                        return content.length
                    },
                    source(){
                        return content
                    }
                }
                cb()
            })
        })
    }
}

module.exports = Plugin1

打包发现dist下增加了assets.txt

接下来我们尝试一个demo2,主要功能是读取一个txt文件,并且将其中的内容输出到dist下的另一个文件内

const fs = require('fs')
const util = require('util')
const { RawSource } = require('webpack').sources
const path = require('path')

class Plugin1 {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap('Plugin1',(compilation) => {
            compilation.hooks.additionalAssets.tapAsync('Plugin1', async cb => {
                //将readFile方法转换成promise风格的
                const readFile = util.promisify(fs.readFile)
                // 读取test.txt的数据
                const data = await readFile(path.resolve(__dirname,'../src/test.txt'))
                //将data转换成webpack格式的资源,输出到b.txt文件中
                compilation.assets['b.txt'] = new RawSource(data)
                cb()
            })
        })
    }
}

module.exports = Plugin1

打包观察dist变化以及内容即可

3.自定义copy-webpack-plugin

有了以上插件使用基础之后,我们实现一个copy-webpack-plugin相似的功能,类似于vue等项目中public目录,里面的内容不需要打包,直接复制打dist目录下一致

准备一下目录

目的:将public中的内容除了index以外的文件全部拷贝到dist打包目录中

了解插件globby:排除目录下需要忽略的文件,输出一个数组

修改配置文件如下:

const CopyWebpackPlugin = require('./plugins/CopyWebpackPlugin')

module.exports = {
    mode: 'production',
    plugins: [
        new CopyWebpackPlugin({
            from: 'public',
            //to:'.', //默认值可忽略,主要看from中的目录复制到dist中是否还是需要文件夹包裹
            ignore:['**/index.html']
        }),
    ]
}

在CopyWebpackPlugin.js:

/**
 * 1.验证传入的值
 * 2.忽略文件夹下的不要目录
 * 3.读取剩下文件中的所以数据
 * 4.将这个数据全部转换成webpack资源
 * 5.输出
 */
const { validate } = require('schema-utils')
const Schema = require('./CopyWebpackPlugin.json')

const globby = require('globby')
const path = require('path')

const fs = require('fs')
const util = require('util')
const readFile = util.promisify(fs.readFile)

const { RawSource } = require('webpack').sources

class CopyWebpackPlugin{
    constructor(options = {}){
        validate(Schema, options, {
            name:'CopyWebpackPlugin'
        })
        //通过验证
        this.options = options
    }
    apply(compiler){
        compiler.hooks.thisCompilation.tap('CopyWebpackPlugin',(compilation)=>{
            // console.log(compiler)
            compilation.hooks.additionalAssets.tapAsync('CopyWebpackPlugin',async (cb) => {
                //2
                const { from, to='.' ,ignore } = this.options
                //获取项目的根目录
                const context = compiler.options.context
                //也可以根据node进程参考
                // const context = process.cwd() 
                const isAbsolutePAth = path.isAbsolute(from) ? path.isAbsolute(from) : path.resolve(context,from)
                console.log(isAbsolutePAth)
                //globby(要处理的文件夹(要求是绝对路径),ignore)
                const paths = await globby(isAbsolutePAth,{ignore})
                console.log(paths)

                //步骤3
                const files = await Promise.all(
                    paths.map(async item => {
                        const filename = path.join(to,path.basename(item))
                        const data = await readFile(item)
                        return {                       
                            data,
                            filename
                        }
                    })
                )
                console.log(files)

                //步骤4
                const assets = files.map(item => {
                    const file = new RawSource(item.data)
                    return{
                        file,
                        filename: item.filename
                    }
                })

                //步骤5
                //emitAsset(资源名称,资源来源,附加资源信息)
                assets.forEach(item => {
                    compilation.emitAsset(item.filename,item.file)
                })

                cb()
            })
        })
    }
}

module.exports = CopyWebpackPlugin

打包查看文件变化~

4.手写简单webpack

1.了解webpack工作流程

  • 1.初始化Compiler: new Webpack(config) 得到Compiler 对象

  • 2.开始编译:调用Compiler 对象run 方法开始执行编译

  • 3.确定入口:根据配置中的entry找出所有的入口文件。

  • 4.编译模块:从入口文件出发,调用所有配置的Loader对模块进行编译,再找出该模块依赖的模块,递归直到所有模块被加载进来

  • 5.完成模块编译:在经过第 4步使用Loader 编译完所有模块后,得到了每个模块被编译后的最终内容以及它们之间的依赖关系

  • 6.输出资源:根据入口和模块之间的依赖关系,组装成一 个个包含多个模块的Chunk, 再把每个Chunk转换成个单独的文件加入到输出列表。(注意: 这步是可以修改输出内容的最后机会)

  • 7.输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

2.准备工作目录

05.myWebpack
   ├─ README.md
   ├─ config
   │  └─ webpack.config.js  //webpack配置文件
   ├─ dist                  //打包后的文件夹
   │  ├─ index.html
   │  └─ main.js        
   ├─ lib                  
   │  └─ myWebpack          // 处理代码的文件
   │     ├─ Compiler.js     // 处理webpack的启动,构建和资源的输出
   │     ├─ demo.js
   │     ├─ index.js        // webpack的入口文件
   │     └─ parse.js        // 处理具体的构建方法
   ├─ package-lock.json
   ├─ package.json
   ├─ script
   │  └─ build.js           //webpack的打包命令入口文件
   └─ src                   //前端代码
      ├─ add.js
      ├─ count.js
      ├─ index.js
      └─ test.js

build.js

const myWebpack = require('../lib/myWebpack/index.js')
const config = require('../config/webpack.config.js')

const compiler = myWebpack(config)
compiler.run()

webpack.config.js

const path = require('path')

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname,'../dist'),
        filename: 'main.js'
    }
}

../lib/myWebpack2/index.js

const Compiler = require('./Compiler')

function myWebpack(config){
    return new Compiler(config)
}

module.exports = myWebpack

3.将代码转化成抽象语法树ast

需要babel工具:@babel/parser

npm i @babel/parser -D

const fs = require('fs')
const babelParse = require('@/babel/parse')

getAst(filePath){
    const file = fs.readFileSync(filePath,'utf-8')
    //将读到的文件file解析成ast抽象语法树
    const ast = babelParse.parse(file,{
        sourceType: "module"
    })
    //debugger
    return ast
}

如需debugger,执行npm run debug

可以任意网页中调试

4.通过babel编译将ast解析成code代码

需要babel工具:@babel/core

npm i @babel/core -D

const { transformFromAst } =require('@babel/core')

//将ast解析成code
getCode(ast){
    //编译代码
    const { code } = transformFromAst(ast,null,{
        presets: ["@babel/preset-env"]
    })
    return code
}

5.通过ast获取代码之前的依赖关系表

需要babel工具:@babel/traverse

npm i @babel/traverse -D

const path = require("path")
const traverse = require('@babel/traverse').default

/*
//设计依赖deps对象
{
  './test.js': '/Users/username/Documents/study/webpack2/05.myWebpack/src/test.js'
}
*/
//获取依赖
getDeps(filepath,ast){
    //获取文件夹路径
    const dirname = path.dirname(filepath)
    const deps = {}
    //收集依赖
    traverse(ast,{
        //遍历内部ast.program.body,如果每一个数组的type是ImportDeclaration执行
        ImportDeclaration({ node }){
            //依赖的名称:./add.js等
            const relativePath = node.source.value
            //生成绝对路径
            const absolutePath = path.resolve(dirname,relativePath)
            deps[relativePath] = absolutePath
        }
    })
    console.log(deps)
    return deps
},

在开始webpack开始构建的过程中,将每一个文件生成一个对象{文件地址,文件依赖对象,文件的代码},因此封装build方法

build(filePath){
    const ast = getAst(filepath)
    const deps = getDeps(filepath,ast)
    const code = getCode(ast)

    return{
        filepath,
        deps,
        code
    }
}

Compiler.js的run方法里遍历每一个文件,生成依赖关系列表

class Compiler {
    constructor(options = {}){
        this.options = options
        //所以依赖容器
        this.modules = []
    }
    //启动webpck打包
    run(){
        //读取入口文件
        const filepath = this.options.entry
        //第一次构建,得到入口文件信息
        const fileInfo = this.build(filepath)
        this.modules.push(fileInfo)
        //入口文件依赖开始,遍历每一个文件
        const getDepsMethod = (deps) => {
            for (const key in deps) {
                const fileInfo = this.build(deps[key])
                this.modules.push(fileInfo)
                if(JSON.stringify(fileInfo.deps) != '{}'){
                    //表示文件下还有别的依赖文件
                    getDepsMethod(fileInfo.deps)
                }
            }
        }
        //递归依赖
        getDepsMethod(fileInfo.deps)

        console.log(this.modules)

        //将依赖整理成更好的依赖表
        const depsChart =  this.modules.reduce((data,value) => {
            return {
                ...data,
                [value.filepath]:{
                    code:value.code,
                    deps: value.deps
                }
            }
        },{})
    }
}

以上不明白的地方,引入完整代码,多花点心思,敲一敲,console一下看看结构就懂了

加油~

6.通过依赖表生成输出资源到dist

//生成输出资源
generate(depsChart){
    const bundle=`(function(depsChart){
        function require(module){
            function localRequire(relativePath){
                return require(depsChart[module].deps[relativePath])//递归解析
            }
            var exports={};
            (function(require,exports,code){
                //执行文件的字符串代码eval(),过程中遇上require,或者export从参数里面获取
                eval(code)
            })(localRequire,exports,depsChart[module].code)
            return exports
        }

        //初始化引入入口文件
        require('${this.options.entry}')
    })(${JSON.stringify(depsChart)})`
    
    //输出资源
    const filePath = path.resolve(this.options.output.path,this.options.output.filename)
    fs.writeFileSync(filePath,bundle,'utf-8')
}

7.总结

以上附上重要文件完整代码:其余可看:github.com/yuyuanlove/…
Compiler.js

const {getAst,getDeps,getCode} = require('./parse')
const fs = require('fs')
const path = require('path')
class Compiler {
    constructor(options = {}){
        this.options = options
        //所以依赖容器
        this.modules = []
    }
    //启动webpck打包
    run(){
        //读取入口文件
        const filepath = this.options.entry
        //第一次构建,得到入口文件信息
        const fileInfo = this.build(filepath)
        this.modules.push(fileInfo)
        const getDepsMethod = (deps) => {
            for (const key in deps) {
                const fileInfo = this.build(deps[key])
                this.modules.push(fileInfo)
                if(JSON.stringify(fileInfo.deps) != '{}'){
                    getDepsMethod(fileInfo.deps)
                }
            }
        }
        //递归依赖
        getDepsMethod(fileInfo.deps)

        console.log(this.modules)
        //将依赖整理成更好的依赖表
        const depsChart =  this.modules.reduce((data,value) => {
            return {
                ...data,
                [value.filepath]:{
                    code:value.code,
                    deps: value.deps
                }
            }
        },{})
        this.generate(depsChart)

    }
    //开始构建
    build(filepath){
        const ast = getAst(filepath)
        const deps = getDeps(filepath,ast)
        const code = getCode(ast)

        return{
            filepath,
            deps,
            code
        }
    }

    //生成输出资源
    generate(depsChart){
        const bundle=`(function(depsChart){
            function require(module){
                function localRequire(relativePath){
                   return require(depsChart[module].deps[relativePath])//递归解析
                }
                var exports={};
                (function(require,exports,code){
                 eval(code)
                })(localRequire,exports,depsChart[module].code)
                return exports
            }
            require('${this.options.entry}')
        })(${JSON.stringify(depsChart)})`

        const filePath = path.resolve(this.options.output.path,this.options.output.filename)
        fs.writeFileSync(filePath,bundle,'utf-8')
    }
}

module.exports = Compiler

parse.js

const fs = require('fs')
const babelParser = require('@babel/parser')
const path = require('path')
//插件收集依赖
const traverse = require('@babel/traverse').default
//编译插件
const { transformFromAst } = require('@babel/core')


const parser = {
    //获取抽象语法树
    getAst(filepath){
        const file = fs.readFileSync(filepath,'utf-8')
        //将其解析成ast抽象语法树
        const ast = babelParser.parse(file,{
            sourceType: "module"
        })    
        return ast
    },
    //获取依赖
    getDeps(filepath,ast){
        //获取文件夹路径
        const dirname = path.dirname(filepath)
        const deps = {}
        //收集依赖
        traverse(ast,{
            //遍历内部ast.program.body,如果每一个数组的type是ImportDeclaration执行
            ImportDeclaration({ node }){
                const relativePath = node.source.value
                //生成绝对路径
                const absolutePath = path.resolve(dirname,relativePath)
                deps[relativePath] = absolutePath
            }
        })
        console.log(deps)
        return deps
    },
    //将ast解析成code
    getCode(ast){
        //编译代码
        const { code } = transformFromAst(ast,null,{
            presets: ['@babel/preset-env']
        })
        return code
    }
}

module.exports = parser