Webpack手写loader和plugin

9,765 阅读9分钟

  我们在Webpack基础篇介绍了多种loader和plugin以及每种的用途;那么他们两者在webpack内部是如何进行工作的呢?让我们手写一个loader和plugin来看看它内部的原理,以便加深对webpack的理解。

手写loader

  我们在在Webpack配置基础篇介绍过,loader是链式传递的,对文件资源从上一个loader传递到下一个,而loader的处理也遵循着从下到上的顺序,我们简单了解一下loader的开发原则:

  1. 单一原则: 每个Loader只做一件事,简单易用,便于维护;
  2. 链式调用: Webpack 会按顺序链式调用每个Loader;
  3. 统一原则: 遵循Webpack制定的设计规则和结构,输入与输出均为字符串,各个Loader完全独立,即插即用;
  4. 无状态原则:在转换不同模块时,不应该在loader中保留状态;

  因此我们就来尝试写一个less-loaderstyle-loader,将less文件处理后通过style标签的方式渲染到页面上去。

同步loader

  loader默认导出一个函数,接受匹配到的文件资源字符串和SourceMap,我们可以修改文件内容字符串后再返回给下一个loader进行处理,因此最简单的一个loader如下:

module.exports = function(source, map){
    return source
}

导出的loader函数不能使用箭头函数,很多loader内部的属性和方法都需要通过this进行调用,比如this.cacheable()来进行缓存、this.sourceMap判断是否需要生成sourceMap等。

  我们在项目中创建一个loader文件夹,用来存放我们自己写的loader,然后新建我们自己的style-loader:

//loader/style-loader.js
function loader(source, map) {
  let style = `
    let style = document.createElement('style');
    style.innerHTML = ${JSON.stringify(source)};
    document.head.appendChild(style)
  `;
  return style;
}
module.exports = loader;

  这里的source就可以看做是处理后的css文件字符串,我们把它通过style标签的形式插入到head中;同时我们也发现最后返回的是一个JS代码的字符串,webpack最后会将返回的字符串打包进模块中。

异步loader

  上面的style-loader都是同步操作,我们在处理source时,有时候会进行异步操作,一种方法是通过async/await,阻塞操作执行;另一种方法可以通过loader本身提供的回调函数callback

//loader/less-loader
const less = require("less");
function loader(source) {
  const callback = this.async();
  less.render(source, function (err, res) {
    let { css } = res;
    callback(null, css);
  });
}
module.exports = loader;

  callback的详细传参方法如下:

callback({
    //当无法转换原内容时,给 Webpack 返回一个 Error
    error: Error | Null,
    //转换后的内容
    content: String | Buffer,
    //转换后的内容得出原内容的Source Map(可选)
    sourceMap?: SourceMap,
    //原内容生成 AST语法树(可选)
    abstractSyntaxTree?: AST 
})

  有些时候,除了将原内容转换返回之外,还需要返回原内容对应的Source Map,比如我们转换less和scss代码,以及babel-loader转换ES6代码,为了方便调试,需要将Source Map也一起随着内容返回。

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

  这样我们在下一个loader就能接收到less-loader返回的sourceMap了,但是需要注意的是:

Source Map生成很耗时,通常在开发环境下才会生成Source Map,其它环境下不用生成。Webpack为loader提供了this.sourceMap这个属性来告诉loader当前构建环境用户是否需要生成Source Map。

加载本地loader

  loader文件准备好了之后,我们需要将它们加载到webpack配置中去;在基础篇中,我们加载第三方的loader只需要安装后在loader属性中写loader名称即可,现在加载本地loader需要把loader的路径配置上。

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

  我们可以在loader中配置本地loader的相对路径或者绝对路径,但是这样写起来比较繁琐,我们可以利用webpack提供的resolveLoader属性,来告诉webpack应该去哪里解析本地loader。

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

  这样webpack会先去loader文件夹下找loader,没有找到才去node_modules;因此我们写的loader尽量不要和第三方loader重名,否则会导致第三方loader被覆盖加载。

处理参数

  我们在配置loader时,经常会给loader传递参数进行配置,一般是通过options属性来传递的,也有像url-loader通过字符串来传参:

{
  test: /\.(jpg|png|gif|bmp|jpeg)$/,
  use: 'url-loader?limt=1024&name=[hash:8].[ext]'
}

  webpack也提供了query属性来获取传参;但是query属性很不稳定,如果像上面的通过字符串来传参,query就返回字符串格式,通过options方式就会返回对象格式,这样不利于我们处理。因此我们借助一个官方的包loader-utils帮助处理,它还提供了很多有用的工具。

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

module.exports = function (source, map) {
  //获取options参数
  const options = getOptions(this);
  //解析字符串为对象
  parseQuery("?param1=foo")
  //将绝对路由转换成相对路径
  //以便能在require或者import中使用以避免绝对路径
  stringifyRequest(this, "test/lib/index.js")
}

  常用的就是getOptions将处理后的参数返回出来,它内部的实现逻辑也非常的简单,也是根据query属性进行处理,如果是字符串的话调用parseQuery方法进行解析,源码如下:

//loader-utils/lib/getOptions.js
'use strict';
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;

  获取到参数后,我们还需要对获取到的options参数进行完整性校验,避免有些参数漏传,如果一个个判断校验比较繁琐,这就用到另一个官方包schema-utils

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函数并没有返回值,打印返回值发现是`undefined,因为如果参数不通过的话直接会抛出ValidationError异常,直接进程中断;这里引入了一个schema.json,就是我们对options``中参数进行校验的一个json格式的对应表:

{
    "type": "object",
    "properties": {
        "source": {
            "type": "boolean"
        },
        "name": {
            "type": "string"
        },
    },
    "additionalProperties": false
}

  properties中的健名就是我们需要检验的options中的字段名称,additionalProperties代表了是否允许options中还有其他额外的属性。

less-loader源码分析

  写完我们自己简单的less-loader,让我们来看一下官方的less-loader源码到底是怎么样的,这里贴上部分源码:

import less from 'less';
import { getOptions } from 'loader-utils';
import { validate } from 'schema-utils';
import schema from './options.json';
async function lessLoader(source) {
  const options = getOptions(this);
  //校验参数
  validate(schema, options, {
    name: 'Less Loader',
    baseDataPath: 'options',
  });
  const callback = this.async();
  //对options进一步处理,生成less渲染的参数
  const lessOptions = getLessOptions(this, options);
  //是否使用sourceMap,默认取options中的参数
  const useSourceMap =
    typeof options.sourceMap === 'boolean' 
    ? options.sourceMap : this.sourceMap;
  //如果使用sourceMap,就在渲染参数加入
  if (useSourceMap) {
    lessOptions.sourceMap = {
      outputSourceFiles: true,
    };
  }
  let data = source;
  let result;
  try {
    result = await less.render(data, lessOptions);
  } catch (error) {
  }
  const { css, imports } = result;
  //有sourceMap就进行处理
  let map =
    typeof result.map === 'string' 
    ? JSON.parse(result.map) : result.map;
  
  callback(null, css, map);
}
export default lessLoader;

  可以看到官方的less-loader和我们写的简单的loader本质上都是调用less.render函数,对文件资源字符串进行处理,然后将处理好后的字符串和sourceMap通过callback返回。

loader依赖

  在loader中,我们有时候也会使用到外部的资源文件,我们需要在loader对这些资源文件进行声明;这些声明信息主要用于使得缓存loader失效,以及在观察模式(watch mode)下重新编译。

  我们尝试写一个banner-loader,在每个js文件资源后面加上我们自定义的注释内容;如果传了filename,就从文件中获取预设好的banner内容,首先我们预设两个banner的txt:

//loader/banner1.txt
/* build from banner1 */

//loader/banner2.txt
/* build from banner2 */

  然后在我们的banner-loader中根据参数来进行判断:

//loader/banner-loader
const fs = require("fs");
const path = require("path");
const { getOptions } = require("loader-utils");

module.exports = function (source) {
  const options = getOptions(this);
  if (options.filename) {
    let txt = "";
    if (options.filename == "banner1") {
      this.addDependency(path.resolve(__dirname, "./banner1.txt"));
      txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
    } else if (options.filename == "banner2") {
      this.addDependency(path.resolve(__dirname, "./banner1.txt"));
      txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
    }
    return source + txt;
  } else if (options.text) {
    return source + `/* ${options.text} */`;
  } else {
    return source;
  }
};

  这里使用了this.addDependency的API将当前处理的文件添加到文件依赖中(并不是项目的package.json)。如果在观察模式下,依赖的text文件发生了变化,那么打包生成的文件内容也随之变化。

如果不添加this.addDependency的话项目并不会报错,只是在观察模式下,如果依赖的文件发生了变化生成的bundle文件并不能及时更新。

缓存加速

  在有些情况下,loader处理需要大量的计算非常耗性能(比如babel-loader),如果每次构建都重新执行相同的转换操作每次构建都会非常慢。

  因此webpack默认会将loader的处理结果标记为可缓存,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时,它的输出结果必然是相同的;如果不想让webpack缓存该loader,可以禁用缓存:

module.exports = function(source) {
  // 强制不缓存
  this.cacheable(false);
  return source;
};

手写loader所有代码均在webpackdemo19

手写plugin

  在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的API改变输出结果。和手写loader一样,我们先来写一个简单的plugin:

//plugins/MyPlugin.js
class MyPlugin {
  constructor() {
    console.log("Plugin被创建了");
  }
  apply (compiler) {}
}
module.exports = MyPlugin;

  plugin的本质是类;我们在定义plugin时,其实是在定义一个类;定义好plugin后就可以在webpack配置中使用这个插件:

//webpack.config.js
const MyPlugin = require('./plugins/MyPlugin')
module.exports = {
  plugins: [
    new MyPlugin()
  ],
}

plugin.png

  这样我们的插件就在webpack中生效了;这时有些童鞋可能会想起来,我们在使用HtmlWebpackPlugin或者CleanWebpackPlugin等一些官方插件时,可以通过实例化插件传入参数;那么这里我们是否也能通过这种方式给我们的插件传参呢?

//plugins/MyPlugin.js
class MyPlugin {
  constructor(options) {
    console.log("Plugin被创建了");
    console.log(options);
    this.options = options;
  }
  apply (compiler) {}
}
//webpack.config.js
module.exports = {
  plugins: [
    new MyPlugin({ title: 'MyPlugin' })
  ],
}

plugin1.png

  我们在构建插件时就能通过options获取配置信息,对插件做一些初始化的工作。在构造函数中我们发现多了一个apply函数,它会在webpack运行时被调用,并且注入compiler对象;其工作流程如下:

  1. webpack启动,执行new myPlugin(options),初始化插件并获取实例
  2. 初始化complier对象,调用myPlugin.apply(complier)给插件传入complier对象
  3. 插件实例获取complier,通过complier监听webpack广播的事件,通过complier对象操作webpack

  我们可以通过apply函数中注入的compiler对象进行注册事件:

class MyPlugin {
  apply(compiler) {
    //不推荐使用,plugin函数被废弃了
    // compiler.plugin("compile", (compilation) => {
    //   console.log("compile");
    // });
    //注册完成的钩子
    compiler.hooks.done.tap("MyPlugin", (compilation) => {
      console.log("compilation done");
    });
  }
}

  compiler不仅有同步的钩子,通过tap函数来注册,还有异步的钩子,通过tapAsynctapPromise来注册:

class MyPlugin {
  apply(compiler) {
    compiler.hooks.run.tapAsync("MyPlugin", (compilation, callback) => {
      setTimeout(()=>{
        console.log("compilation run");
        callback()
      }, 1000)
    });
    compiler.hooks.emit.tapPromise("MyPlugin", (compilation) => {
      return new Promise((resolve, reject) => {
        setTimeout(()=>{
          console.log("compilation emit");
          resolve();
        }, 1000)
      });
    });
  }
}

  这里又有一个compilation对象,它和上面提到的compiler对象都是Plugin和webpack之间的桥梁:

  • compiler对象包含了 Webpack 环境所有的的配置信息。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
  • compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

  compiler和compilation的区别在于:

  • compiler代表了整个webpack从启动到关闭的生命周期,而compilation只是代表了一次新的编译过程
  • compiler和compilation暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理

手写FileListPlugin

  了解了compiler和compilation的区别,我们就来尝试一个简单的示例插件,在打包目录生成一个filelist.md文件,文件的内容是将所有构建生成文件展示在一个列表中:

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';
            }
            // 将这个列表作为一个新的文件资源,插入到 webpack 构建中:
            compilation.assets['filelist.md'] = {
                source: function() {
                    return filelist;
                },
                size: function() {
                    return filelist.length;
                }
            };
            callback();
        })
    }
}
module.exports = FileListPlugin

  我们这里用到了assets对象,它是所有构建文件的一个输出对象,打印出来大概长这样:

{
  'main.bundle.js': { source: [Function: source], size: [Function: size] },
  'index.html': { source: [Function: source], size: [Function: size] }
}

  我们手动加入一个filelist.md文件的输出;打包后我们在dist文件夹中会发现多了这个文件:

In this build:

- main.bundle.js
- index.html

  这个插件就完成了我们的预期任务了。

参考

webpack loader从入门到精通全解析

作者好文推荐:

Webpack配置全解析(优化篇)

Webpack配置全解析(基础篇)

更多前端资料请关注公众号【前端壹读】

如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客