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 配置中和样式相关的常见配置:

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

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

自定义 loader

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

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

js 复制代码
// 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 中进行配置,内容如下:
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 要查找的路径,具体如下:
js 复制代码
// 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.

js 复制代码
// 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,改造内容和运行结果如下:

js 复制代码
// 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 中的内容进行改写,如下:

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 中进行合法配置:

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

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

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

实现自定义 loader ------ vueLoader

功能描述

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

webapck.config.js

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

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 状态来指明异步处理的结果。

js 复制代码
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

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

CopyWebpackPlugin.js

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);

      // 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 = join(to, 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;
相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端
爱敲代码的小鱼14 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax