webpack 核心模块 —— loader & plugins

前言

本文主要针对 webpack 中的 laoder 和 plugins 进行学习,不涉及如何使用和配置 webpack,因为这些基础在官方文档中已经很明确了,重点在于如何去实现属于自定义的 laoder 和 plugins。那么在开始前,先简单的介绍下什么叫构建工具。

构建工具

在 web 应用程序中,除了 HTML 文件之外,往往还需要用的很多的其他静态资源加以修饰,比如在 HTML 中使用的 图片、css 样式、js文件等等,但是浏览器并不能识别所有的文件资源,并正确的加载。

因此,开发者需要对不同的文件资源进行对应的处理操作,目的是为了能够正确的加载和使用对应的文件资源。比如:

  • 图片除了常用的一些格式能被正常加载和显示之外,一些特殊的格式就无法直接使用;
  • css 样式我们可能会使用 less / scss / css in js 等方式去使用;
  • js 文件中可能使用了比较新的语法,如 ES6 、ES7 以至于更新的特性,需要对应的编译工具去做转换等等。

由于需要针对不同的文件资源做不同的处理,并且还要考这些用于处理文件资源工具的维护问题,因此就诞生了构建工具。

构建工具就是包含了处理大多数以上提及到问题的解决方案,意味着原本我们需要不同的小工具去处理不同的文件内容,但是现在只需要关注构建工具本身如何使用即可。

webpack

webpack 是什么?

webpack 是众多构建工具中的一种,它也是一个用于现代 JavaScript 应用程序的 静态模块打包工具

webpack 处理应用程序时,它会在内部从 一个多个 入口点去构建一个 依赖图,然后将项目中所需的每一个模块组合成一个或多个 bundles,它们均为 静态资源,用于展示你的内容。

其中涉及到的 chunkbundles 的概念,可以根据下图来辅助理解:

  • 根据引入的各种文件资源,形成对应的 依赖图,其中包含了要处理的 代码块 chunk
  • 代码块 chunk 进行对应的处理,也称之为打包,输出之后就得到了需要的 bundles

五大核心

mode

  • 可选值:development, productionnone
  • 设置 mode 参数,可以启用 webpack 内置在相应环境下的默认优化
  • mode 参数默认值为 production

entry

入口起点(entry point) 指示 webpack 应该使用哪个文件作为入口模块,用来作为构建其内部依赖图,可以拥有多个入口文件。

output

output 负责告诉 webpack 需要在哪里输出它所创建的 bundle,以及怎么去命名这些文件.

  • 默认输出目录: ./dist
  • 默认主要输出文件名: ./dist/main.js
  • 其他生成文件默认放在 ./dist

loader

webpack 只能理解 JavaScriptJSON 文件,开箱的 webapck 没办法识别其他文件类型。loader 就能够把这些文件类型转换成 weback 能识别的资源,并将它们转换为有效 模块,以便于在应用程序中去使用,同时也会被添加到依赖图中。

plugin

loader 用于转换某些类型的模块,而 plugin 则可以用于执行包括 loader 在内的、范围更广的任务。比如:打包优化,资源管理,注入环境变量等。

  • 可以通过 require 引入对应plugin 插件,并在选项配置 plugins 的数组中 实例化调用 new PluginName(opt)
  • 可以自定义 webpack 插件实现具体场景的需求

loader

在 webpack 中 loader 是什么?

loader 本质就是一个函数,这个函数会接收三个参数:

  • content:对应模块的内容
  • map:对应模块的 sourcemap
  • meta:对应模块的元数据

loader 执行顺序

通常 loader 的书写结构决定了对执行顺序的描述:

  • 左右结构 ——> 执行顺序为 从右往左
  • 上下结构 ——> 执行顺序为 从下往上

为了更清晰和直观,下面列出了一个在 webpack 配置中和样式相关的常见配置:

module: {
    rules: [
      {
        test: /\.css$/,
        // 左右结构
        use: ['style-loader', 'css-loader'],
        
        //   或
        
        // 上下结构
        use: [
          'style-loader',
          'css-loader'
        ]
        
      }
    ]
  }
复制代码

无论是 左右结构 还是 上下结构,都可以统一理解为 从后往前 的顺序去执行.

自定义 loader

  • 新建 loader1.jsloader2.js 作为自定义 loader,注意除了向外暴露的函数方法以外,还给这个函数对象上添加了一个 pitch 方法,内容具体如下:

pitch 方法执行顺序和 loader 是相反的,也就是说 pitch从前往后 的顺序去执行.

// loader1.js
module.exports = function(content, map, meta) {
  console.log('loader1 ...');
  return content;
}
module.exports.pitch = function (){
  console.log('loader1 pitch...');
}

// loader2.js
module.exports = function(content, map, meta) {
  console.log('loader2 ...');
  return content;
}
module.exports.pitch = function (){
  console.log('loader2 pitch...');
}
复制代码
  • 并在 webpack.config.js 中进行配置,内容如下:
// webpack.config.js
const { resolve } = require('path');
module.exports = {
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          resolve(__dirname, 'loaders/loader1.js'),
          resolve(__dirname, 'loaders/loader2.js'),
        ]
      },
    ]
  }
}
复制代码
  • 为了简化每次引入自定义 loader 时,都要写完整路径,如:resolve(__dirname, 'loaders/xxx.js),因此可以通过配置 resolveLoader 选项统一指定 loader 要查找的路径,具体如下:
// webpack.config.js
const { resolve } = require('path');
module.exports = {
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          'loader1',
          'loader2',
        ]
      },
    ]
  },
  resolveLoader: {
    modules: [
      resolve(__dirname, 'loaders'),
      'node_modules'
    ],
  }
}
复制代码
  • 当在编辑器终端输入 webpack 指令进行打包时,控制台输出结果如下:

loader 的同步和异步

同步 loader

自定义 loader 中书写 loader 的方式就属于同步 loader,当然还有另一种写法,那就是通过调用 this.callback() 方法,可以将上述 自定义 loader 中的写法进行改写,具体如下:

this.callback(error, content, map, meta),其中 error 表示错误内容,当没有错误时,可将其执行为 null. 使用这样的方式,就不需要在显式的进行 return.

// loader1.js
module.exports = function(content, map, meta) {
  console.log('loader1 ...');
  this.callback(null, content, map, meta);
}
module.exports.pitch = function (){
  console.log('loader1 pitch...');
}

// loader2.js
module.exports = function(content, map, meta) {
  console.log('loader1 ...');
  this.callback(null, content, map, meta);
}
module.exports.pitch = function (){
  console.log('loader1 pitch...');
}
复制代码

异步 loader

异步 loader 需要通过 const callBack = this.async(); 方法进行指定,然后通过调用 callBack() 方法表明异步执行完成.

可以将 loader2.js 变为异步 loader,改造内容和运行结果如下:

// loader2.js
module.exports = function(content, map, meta) {
  console.log('loader2 ...');
  const callback = this.async();
  setTimeout(()=>{
    callback(null,content, map, meta);
  },1000);
}
module.exports.pitch = function (){
  console.log('loader2 pitch...');
}
复制代码

PS: 当执行到 loader2 时,会先等待 1s 左右,然后在执行 loader1 . 同时 compiled successfully 的时间明显比之前更多.

对 loader 中的 options 进行合法校验

为什么需要校验合法性?

向外提供了 options 配置是为了让自定义 loader 具有更高的灵活性和可配置性,但是这样的灵活性如果没有得到约束,那么 options 配置可能就变得没有意义。试想一下,外部使用时传递了一堆 loader 中根本用不到的配置,除了让配置看起来更复杂之外,也会让 loader 内部的各种判断逻辑进行无用的执行。基于以上种种原因,对 options 的合法校验显得尤为重要,只有在校验通过之后再去执行 loader 中的其他处理程序。

获取 loader 中的 options 配置

要对 options 进行合法校验,首先就得获取 options,获取方式有 2 种:

  • 通过 const options = this.getOptions()的方式获取
  • 通过调用 loader-utils 库中的 getOptions(this) 方法获取

校验合法性

可以通过 schema-utils 库中的 validate() 方法进行校验.

通过一个例子进行直观的理解,首先在 webpack.config.js 中修改配置,也就是给 loader1 传入 options 配置,然后对 loader1.js 中的内容进行改写,如下:

// webpack.config.js
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'loader1',
            options: {
              name: 'this is a name!'
            }
          },
          'loader2',
        ]
      },
    ]
  }
  
// loader1.js
const { validate } = require('schema-utils');

// schema 意为模式,定义校验规则
const loader1_schema = {
  type: "object",
  properties: {
    name: {
      type: 'string',
    },
  },
  // additionalProperties 代表是否可以追加属性
  additionalProperties: true
};

module.exports = function (content, map, meta) {
  console.log('loader1 ...');

  // 获取 options
  const options = this.getOptions();
  console.log('loader1 options = ',options);

  // 校验 options 是否合法
  validate(loader1_schema, options,{
    name: 'loader1',
    baseDataPath: 'options',
  });

  this.callback(null, content, map, meta);
}

module.exports.pitch = function () {
  console.log('loader1 pitch...');
}
复制代码

在 webpack.config.js 中进行合法配置:

         {
            loader: 'loader1',
            options: {
              name: 'this is a name!'
            }
          }
复制代码

在 webpack.config.js 中进行非法配置:

         {
            loader: 'loader1',
            options: {
              name: false
            }
          }
复制代码

实现自定义 loader —— vueLoader

功能描述

针对 .vue 文件中的 <template> 、<script>、<style> 三部分进行拆分,并且重组到一个 .html 文件中.

webapck.config.js

const { resolve } = require('path');

module.exports = {
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: {
          loader: 'vueLoader',
          options: {
            template: {
              path: resolve(__dirname, 'src/index.html'),
              fileName: 'app',
            },
            name: 'app',
            title: 'Home Page',
            reset: true
          }
        }
      },
    ]
  },
  resolveLoader: {
    modules: [
      resolve(__dirname, 'loaders'),
      'node_modules'
    ],
  }
}
复制代码

vueLoader.js

const { validate } = require('schema-utils');
const fs = require('fs');
const { resolve } = require('path');

const vueLoader_schema = {
  type: "object",
  properties: {
    template: {
      type: 'object',
      properties: {
        path: { type: 'string' },
        fileName: { type: 'string' }
      },
      additionalProperties: false
    },
    name: {
      type: 'string',
    },
    title: {
      type: 'string',
    },
    reset: {
      type: 'boolean',
    }
  },
  additionalProperties: false
};

module.exports = function (content, map, meta) {
  const options = this.getOptions();

  const regExp = {
    template: /<template>([\s\S]+)<\/template>/,
    script: /<script>([\s\S]+)<\/script>/,
    style: /<style.+>([\s\S]+)<\/style>/,
  };

  validate(vueLoader_schema, options, {
    name: 'vueLoader',
    baseDataPath: 'options',
  });

  let template = '';
  let script = '';
  let style = '';

  if (content.match(regExp.template)) {
    template = RegExp.$1;
  }
  if (content.match(regExp.script)) {
    let match = RegExp.$1;
    let name = match.match(/name:(.+),?/)[1].replace(/("|')+/g,'');
    script = match.replace(/export default/, `const ${name} = `);
  }
  if (content.match(regExp.style)) {
    style = RegExp.$1;
  }

  let { path, fileName } = options.template;
  fileName = fileName || path.substring(path.lastIndexOf('\\') + 1, path.lastIndexOf('.html'));
  
  fs.readFile(path, 'utf8', function (error, data) {
    if (error) {
      console.log(error);
      return false;
    }

    const innerRegExp = {
      headEnd: /<\/head>/,
      bodyEnd: /<\/body>/,
    };

    content = data
      .replace(innerRegExp.headEnd, (match, p1, index, origin) => {
        let resetCss = "";
        if (options.reset) {
          resetCss = fs.readFileSync(resolve(__dirname, 'css/reset.css'), 'utf-8')
        }
        let rs = `<style>${resetCss} ${style}</style></head>`;
        return rs;
      })
      .replace(innerRegExp.bodyEnd, (match, p1, index, origin) => {
        let rs = `${template}<script>${script}</script></body>`;
        return rs;
      });

    if (options.title) {
      content = content.replace(/<title>([\s\S]+)<\/title>/, () => {
        return `<title>${options.title}</title>`
      });
    }

    fs.writeFile(`dist/${fileName}.html`, content, 'utf8', function (error) {
      if (error) {
        console.log(error);
        return false;
      }

      console.log('Write successfully!!!');
    });
  });

  return "";
}
复制代码

plugins

在 webpack 中 plugin 是什么?

webpack 中的 plugin 由以下组成:

  • 一个 JavaScript 命名函数JavaScript 类
  • 在插件函数的 prototype 上定义一个 apply() 方法
  • 指定一个绑定到 命名函数 自身的 事件钩子
  • 处理 webpack 内部实例的特定数据
  • 功能完成后调用 webpack 提供的回调

下面是一个 plugin 的基本结构:

apply 中的 tap() 方法来绑定同步操作,但有些 plugin 需要进行是异步操作,这时候可以使用 tapAsync() 或 tapPromise() 这两个异步方法来绑定。当使用 tapAsync 方式时,回调参数会多一个 callback 用于指明异步处理是否结束;当时用 tapPromise 方式时,要在其内部返回一个 Promise 对象,通过改变 Promise 状态来指明异步处理的结果。

class TestWebpackPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('TestWebpackPlugin', (compilation) => {
      console.log('tap callBack ...');
      
      // 返回 true 以输出 output 结果,否则返回 false
      return true;
    });

    compiler.hooks.emit.tapAsync('TestWebpackPlugin', (compilation, callback) => {
      setTimeout(() => {
        console.log('tapAsync callBack ...');
        callback();
      }, 2000);
    });

    compiler.hooks.emit.tapPromise('TestWebpackPlugin', (compilation) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log('tapPromise callBack ...');
          resolve();
        }, 1000);
      });
    });

  }
}

module.exports = TestWebpackPlugin;
// 输出顺序:
//         1. tap callBack ...  
//         2. tapAsync callBack ...(等待前面的 tap 执行完毕,2s 后输出)  
//         3. tapPromise callBack ...(等待前面的 tapAsync 执行完毕,1s 后输出)
复制代码

plugin 中的执行顺序

从上面的例子中,可以看出其执行顺序为:

  • 不同 hooks 的执行时机可以参考 生命周期钩子函数,执行时机决定了执行顺序
  • 同一个 plugin 中的同一个 hooks 中注册的回调,会按串行顺序执行,即便其中包含了 异步操作

对 plugin 中的 options 进行合法校验

这一点和 loader 中的校验一样,都需要使用 schema-utils 中的 validate() 方法进行校验。和 loader 中不一样的就是,plugin 中的 options 不需要通过 this.getOptions() 的方式获取,因为 plugin 是一个 class 或者是 构造函数,因此可以直接在 constructor 中直接进行获取。

实现自定义 plugin —— CopyWebpackPlugin

功能描述

指定目录 下的所有文件复制到 目标目录,支持忽略某些文件.

webpack.config.js

const CopyWebpackPlugin = require('./plugins/CopyWebpackPlugin');
module.exports = {
  mode:'none',
  plugins: [
    new CopyWebpackPlugin({
      from: './public',
      to: 'dist',
      ignores: ['notCopy.txt']
    })
  ]
};
复制代码

CopyWebpackPlugin.js

const { validate } = require('schema-utils');
const { join, resolve, isAbsolute, basename } = require('path');
const { promisify } = require('util');
const fs = require('fs');
const webapck = require('webpack');

const { RawSource } = webapck.sources;
const readdir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);

const schema = {
  type: 'object',
  properties: {
    from: {
      type: 'string',
    },
    to: {
      type: 'string',
    },
    ignores: {
      type: 'array',
    },
  },
  additionalProperties: false,
}

class CopyWebpackPlugin {
  constructor(options = {}) {
    this.options = options;
    // 校验 options 合法性
    validate(schema, options);
  }

  apply(compiler) {
    compiler.hooks.emit.tapAsync('CopyWebpackPlugin', async (compilation, callback) => {
      let { from, to = '.', ignores = [] } = this.options;
      // 运行指令的目录
      let dir = process.cwd() || compilation.options.context;
      // 判断传入的路径是否为绝对路径
      from = isAbsolute(from) ? from : resolve(dir, from);
      to = isAbsolute(to) ? to : resolve(dir, to);

      // 1. 获取 form 目录下所以文件或文件夹名称
      let dirFiles = await readdir(from, 'utf-8');

      // 2. 通过 ignores 进行过滤文件或文件夹名称
      dirFiles = dirFiles.filter(name => !ignores.includes(name));

      // 3. 读取 form 目录下所有文件
      const files = await Promise.all(dirFiles.map(async (name) => {
        const fullPath = join(from, name);
        const data = await readFile(fullPath);
        const filename = basename(fullPath);

        return {
          data,// 文件内容数据
          filename,// 文件名
        };
      }));

      // 4. 生成 webpack 格式的资源
      const assets = files.map(file => {
        const source = new RawSource(file.data);
        
        return {
          source,
          filename: file.filename,
        };
      });

      // 5. 添加到 compilation 中,向外输出
      assets.forEach((asset) => {
        compilation.emitAsset(asset.filename, asset.source);
      });

      // 6. 通过 callback 指明当前处理完成
      callback();
    });
  }
}

module.exports = CopyWebpackPlugin;
复制代码

猜你喜欢

转载自juejin.im/post/7033409615098281997