webpack 之手写 loader 和 plugin

419 阅读5分钟

webpack 是前端非常常用的一种项目打包工具,它可以在很大程度上方便项目构建、调试和打包。而其中比较重要的是它的 loader 和 plugin 的使用。

webpack 工作流程

工作流程:

  1. 组装 option 参数,添加默认插件
  • validation(configOption) 校验 option 参数
  • compiler.hooks.environment.call() 应用 Nodejs 风格的文件系统到 compiler 对象
  • applyWebpackOptionDefaults(options) 添加默认参数
  • compiler = new Compiler(options, context) 实例化 Compiler
  • WebpackOptionsApply().process(options, compiler) 添加插件,依次调用 apply 方法,同时传入 compiler 实例
  1. 编译
  • compiler.hooks.beforeRun 清除缓存
  • compiler.hooks.run 启动一次新的编译 run
  • compiler.hooks.thisCompilation 备份一次 thisCompilation
  • compiler.hooks.compilation 构建 compilation 对象,包含当前模块资源、编译生成资源、变化的文件
  • compiler.hooks.beforeCompile 执行之前定义的 beforeCompile
  • compiler.hooks.compile 插件一次新的编译将要启动
  • compilation = this.newCompilation(params) 创建 compilation 对象
  • compiler.hooks.make 根据 entry,通过 acorn 将 JS 转成 AST,递归 AST 节点,resolve 依赖路径,处理loader,把所有文件组装到 modules 数组
  1. 处理编译后的文件,合并 module、chunk
  • build 完成,组装编译后的内容,把 module 组合成一个个的 chunk
  • 通过 Template 生成代码,保存到 compilation、assets
  • emitAssets 生成文件到 dist 目录

其中,compiler对象包含了 webpack 环境所有的配置信息,而compilation 包含了其中的模块资源、编译生成资源、变化的文件等,他们的主要区别是:

  • compiler 代表了整个 webpack 从启动都关闭的生命周期,compilation 只代表某一次的编译过程
  • compiler 和 compilation 都暴露出很多钩子,可以自定义处理

loader

首先 loader 是指加载器,这是 webpack 用来加载一些文件的时候使用的。它在 module.rules 中配置,是作为解析模块规则而存在的。

loader 的类型是数组,其中每一项就是一个 Object,描述了文件的类型、loader、options 等。

同时,loader 是链式传递的,对文件资源从上一个 loader 传递到下一个,而 loader 的处理遵循着从上到下的顺序(webpack 中是这样的),它有一定的开发规则:

  • 单一原则。每个 loader 负责一件事,方便维护和使用
  • 链式调用。Webpack 会按照顺序链式调用每个 loader
  • 统一原则。遵循 webpack 规范,输入输出都应是字符串,并且各个 loader 相互独立
  • 无状态原则。在转换不同模块时,不应该在 loader 中保存状态

尝试手写 less-loader

我们在处理 source 时,可能会用到异步操作,此时可以用两种方法:

  1. async/await,阻塞操作执行
  2. 用 loader 本身的回调函数 callback()

我们可以尝试手写一下自己的 less-loader:

const less = require("less")
fucntion loader(source){
    const callback = this.async()
    less.render(source,{sourceMap:{}}, function(err,res){
        let {css,map} = res
        callback(null, css,map)
    })
}
module.exports = loader

其中 callback 函数接收四个参数:

callback({
    error: Error | null, //无法翻译时返回 Error
    content: string | buffer, //转换后的内容
    sourceMap?: SourceMap, //转换后依然得到原内容的 SourceMap
    abstractSyntaxTree?: AST //原内容生成 AST 语法树
})

然后我们需要将 loader 文件加载到 webpack 配置中去:

module.exports = {
    module:{
        rules:[{
            test: /\.less/,
            use:[
                {
                    loader: './loader/style-loader.js',
                },
                {
                    loader: path.resolve(__dirname,"loader","less-loader"),
                },
            ],
        }]
    }
}

当然,写到这里还没算完,我们还需要对 loader 的传递参数进行一定的配置,这一般都是通过 options 属性来传递的。Webpack 也提供了相应的 query 属性来传参数,但是较不稳定,不是很推荐。这里我们使用官方的 laoder-utils 来处理,用 getOptions 返回处理后的参数:

const {
    getOptions,
    parseQuery,
    stringifyRequest
} = require("loader-utils")

module.exports = function(source, map){
    const options = getOptions(this)
    parseQuery("?param1=foo")
    stringifyRequest(this, "test/lib/index.js")
}


//如果是字符串的话就调用如下方法:
const parseQuery = require('./parseQuery'); 
    function getOptions(loaderContext) { 
        const query = loaderContext.query; 
        if (typeof query === 'string' && query !== '') { 
            return parseQuery(loaderContext.query); 
        } 
        if (!query || typeof query !== 'object') { 
            return {}; 
        } 
        return query;
    }
module.exports = getOptions

而在获取到参数之后,还需要对参数的完整性进行检验:

const { getOptions } = require("loader-utils");
const { validate } = require("schema-utils");
const schema = require("./schema.json");
module.exports = function (source, map) {
  const options = getOptions(this);
  const configuration = { name: "Loader Name"};
  validate(schema, options, configuration);
  ...
}

//其中 validate 并没有返回值,需要引入另一个 schema.json:
{
    "type":"object",
    "properties":{
        "source":{
            "type":"boolean"
        },
        "name":{
            "type":"string"
        }
    },
    "additionalProperties": false //代表是否允许有额外属性
}

plugin

至于 plugin 则可以理解为插件,它在 plugins 中配置,类型也是数组,并且每一项都是一个 plugin 实例,参数通过构造函数直接传入。常见的有 html-webpack-plugin、define-plugin 等。

尝试手写 plugin

其实 plugin 的本质也可以理解为一个类,可以先手写一个简单的 plugin:

class MyPlugin {
    constructor(){
        console.log('Plugin has been constructed')
    }
    apply(compiler){}
}
module.exports = Mylugin

而在 webpack 中的配置如下:

const MyPlugin = require('./plugins/Myplugin')
module.exports = {
    plugins:[
        new Myplugin()
    ]
}

当然,我们可以做一些改动,让它支持插件传参:

class MyPlugin {
    constructor(options){
        console.log('Plugin has been constructed')
        console.log(options)
        this.options = options
    }
    apply(compiler){}
}
module.exports = {
    plugins:[
        new Myplugins({title: 'Myplugin'})
    ]
}

其中, apply 函数会在 webpack 运行时被调用,并赋予 compiler 对象:

  1. webpack 启动,执行 new Myplugin() 来初始化插件并获取实例
  2. 初始化 compiler 对象,抵用 MyPlugin.apply()给插件传入 compiler
  3. 插件实例获取 compiler,通过 compiler 监听 webpack 时间,通过 compiler 操作 webpack

接下来可以尝试手写一个 FileListPlugin:

class FileListPlugin {
    apply(compiler){
        compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback)=>{
            var filelist = 'In this build:\n\n';
            for (var filename in compilation.assets) {
                filelist += '- ' + filename + '\n';
            }
            compilation.assets['filelist.md'] = {
                source: function() {
                    return filelist;
                },
                size: function() {
                    return filelist.length;
                }
            };
            callback();
        })
    }
}
module.exports = FileListPlugin

这样就可以依次读出文件夹中的文件了

参考

本篇总结参考了谢小飞的《Webpack 手写 loader 和 plugin》,对更多细节有兴趣的朋友可以访问原博客。

©本总结教程版权归作者所有,转载需注明出处