玩转 webpack5(下)原创
前言
这是接玩转 webpack(上) 的续集。
本篇长文是学习程柳峰老师开设的《玩转 webpack》专栏的实践笔记,和专栏不一样的是,我的实战源码是基于 webpack5,它的配置和源码实现上与 webpack4 有许多不同的地方,感兴趣的同学可以结合我在上面放出的源码仓库进行学习,相信会有不小的收获。
看完本篇长文,你将收获:
- 能够根据项目需求灵活的进行 webpack 的配置。
 - 理解 Tree Shaking、Scope Hoisting 等原理。
 - 能够实现打包多页应用、组件库和基础库、SSR 等。
 - 编写可维护的 webpack 配置,结合单元测试、冒烟测试等控制代码质量,使用 Travis 实现持续集成。
 - 知道如何优化构建速度和构建资源体积。
 - 通过源代码掌握 webpack 打包原理
 - 编写 loader 和 plugin。
 
我将源码放在我的仓库中,可以对照着文档阅读,另外由于掘金字数限制,只能分成两篇文章,想要更好的阅读体验可以移步到我的博客。源码地址:玩转 webpack
webpack 构建速度和体积优化策略
初级分析:使用 stats
在 webpack5 中可以得到构建各个阶段的处理过程、耗费时间以及缓存使用的情况。
module.exports = {
  stats: 'verbose', // 输出所有信息 normal: 标准信息; errors-only: 只有错误的时候才输出信息
};
复制代码
在根目录下生成 stats.json,包含了构建的信息。
{
  "scripts": {
    "****yze:stats": "webpack --config config/webpack.prod.js --json stats.json"
  }
}
复制代码
速度分析:使用 speed-measure-webpack-plugin
这个插件在 webpack5 中已经用不到了,可以使用内置的 stats 替代。
作用:
- 分析整个打包总耗时
 - 每个插件和 loader 的耗时情况
 
yarn add speed-measure-webpack-plugin -D
复制代码
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const **p = new SpeedMeasurePlugin();
const webpackConfig = **p.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
});
复制代码
体积分析:webpack-bundle-****yzer
分析:
- 依赖的大小
 - 业务组件代码的大小
 
yarn add webpack-bundle-****yzer
复制代码
const { BundleAnalyzerPlugin } = require('webpack-bundle-****yzer');
module.exports = {
  plugins: [new BundleAnalyzerPlugin()],
};
复制代码
执行完成后会自动打开 http://127.0.0.1:8888/,如下图所示:
使用更高版本的 webpack 和 nodejs
webpack4 和 nodejs 高版本较之前所做的优化:
- V8 带来的优化:for of 替代 forEach; Map/Set 替代 Object; includes 替代 indexOf。
 - md5 → md4 算法。
 - 使用字符串方法替代正则表达式。
 
webpack5 的主要优化及特性:
- 持久化缓存。可以设置基于内存的临时缓存和基于文件系统的持久化缓存。
- 一旦开启,会忽略其它插件的缓存设置。
 
 - Tree Shaking
- 增加了对嵌套模块的导出跟踪功能,能够找到那些嵌套在最内层而未被使用的模块属性。
 - 增加了对 cjs 模块代码的静态分析功能。
 
 - Webpack 5 构建输出的日志要丰富完整得多,通过这些日志能够很好地反映构建各阶段的处理过程、耗费时间,以及缓存使用的情况。
 - 新增了改变微前端构建运行流程的 Module Federation。
 - 对产物代码进行优化处理 Runtime Modules。
 - 优化了处理模块的工作队列。
 - 在生命周期中增加了 stage 选项。
 
多进程/多实例构建
可选方案:
- thread-loader
 - parallel-webpack
 - 一些插件内置的 parallel 参数(如 TerserWebpackPlugin, CssMinimizerWebpackPlugin, HtmlMinimizerWebpackPlugin)
 - HappyPack(作者已经不维护)
 
thread-loader
原理:每次 webpack 解析一个模块,thread-loader 会将它及它的依赖分配给 worker 线程中。
module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workder: 3,
            },
          },
          'babel-loader',
          'eslint-loader',
        ],
      },
    ],
  },
};
复制代码
并行压缩
可以配置并行压缩的插件:
- terser-webpack-plugin
 - css-minimizer-webpack-plugin
 - html-minimizer-webpack-plugin
 
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new CssMinimizerPlugin(),
      new TerserPlugin({ parallel: 2 }),
      '...',
    ],
  },
};
复制代码
进一步分包:预编译资源模块(DLL)
回顾之前分包的思路:
使用 SplitChunkPlugin 将 react, react-dom 等基础库分离成单独的 chunk。
缺点是每次打包时仍然会对基础包进行解析编译,更好的方式是进行预编译资源模块,通过 DLLPlugin, DllReferencePlugin 实现。
预编译资源模块
思路:将 react, react-dom, redux, react-redux 基础包和业务基础包打包成一个文件,可以提供给其它项目使用。
方法:使用 DLLPlugin 进行分包,DllReferencePlugin 对 manifest.json 引用。
首先定义一个 config/webpack.dll.js, 用于将基础库进行分离:
const path = require('path');
const webpack = require('webpack');
module.exports = {
  mode: 'production',
  entry: {
    library: ['react', 'react-dom'],
  },
  output: {
    filename: '[name]_[chunkhash].dll.js',
    path: path.resolve(__dirname, '../build/library'),
    library: '[name]',
  },
  plugins: [
    new webpack.DllPlugin({
      context: __dirname,
      name: '[name]_[hash]',
      path: path.join(__dirname, '../build/library/[name].json'),
    }),
  ],
};
复制代码
然后在 webpack.common.js 中将预编译资源模块引入:
module.exports = {
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('../build/library/library.json'),
      scope: 'xyz',
      sourceType: 'commonjs2',
    }),
  ],
};
复制代码
充分利用缓存提升二次构建速度
目的:提升二次构建速度。
缓存思路:
- webpack5 内置的基于内存的临时缓存和基于文件系统的持久化缓存。
 - cache-loader。
 - terser-webpack-plugin 开启缓存。
 
基于文件系统的持久化缓存,在 node_modules 下会生成 .cache 目录:
module.exports = {
  cache: {
    type: 'filesystem', // memory 基于内存的临时缓存
    // cacheDirectory: path.resolve(__dirname, '.temp_cache'),
  },
};
复制代码
缩小构建目标
目的:减少需要解析的模块。
- babel-loader 不解析 node_modules
 
减少文件搜索范围:
- resolve.modules 减少模块搜索层级,指定当前 node_modules。
 - resovle.mainFields 指定入口文件。
 - resolve.extension 对于没有指定后缀的引用,指定解析的文件后缀算法。
 - 合理使用 alias,引用三方依赖的生成版本。
 
module.exports = {
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
    },
    modules: [path.resolve(__dirname, './node_modules')],
    extensions: ['.js', '.jsx', '.json'],
    mainFields: ['main'],
  },
};
复制代码
Tree Shaking 擦除无用的 css
前面已经介绍了使用 Tree Shaking 擦除无用的 js,这在 webpack5 中已经内置了,这一小节介绍如何擦除无用的 css。
- PurifyCSS: 遍历代码,识别已经用到的 css class。
 - uncss: html 需要通过 jsdom 加载,所有的样式通过 PostCSS 解析,通过 document.querySelector 识别 html 文件中不存在的选择器。
 
在 webpack 中使用 PurifyCSS:
- 使用 purgecss-webpack-plugin
 - 和 mini-css-extract-plugin 配合使用
 
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const glob = require('glob');
const PATHS = { src: path.resolve('../src') };
module.exports = {
  plugins: [
    new PurgeCSSPlugin({
      paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
    }),
  ],
};
复制代码
图片压缩
yarn add image-minimizer-webpack-plugin
复制代码
无损压缩推荐使用下面依赖:
yarn add imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo
复制代码
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
module.exports = {
  plugins: [
    new ImageMinimizerPlugin({
      minimizerOptions: {
        plugins: [['jpegtran', { progressive: true }]],
      },
    }),
  ],
};
复制代码
使用动态 Polyfill 服务
用于体积优化。
polyfill-service: 只给用户返回需要的 polyfill,国内部分浏览器可能无法识别 User Agent,可以采用优雅降级的方案。
polyfill-service 原理:识别 User Agent,下发不同的 polyfill,做到按需加载需要的 polyfill。
体积优化策略总结
- Scope Hoisting
 - Tree Shaking
 - 公共资源分离
- SplitChunks
 - 预编译资源模块
 
 - 图片压缩
 - 动态 Polyfill
 
通过源代码掌握 webpack 打包原理
webpack 启动过程分析
开始:从 webpack 命令行说起。
- 通过 npm scripts 运行 webpack
- 开发环境: npm run dev
 - 生产环境: npm run build
 
 - 通过 webpack 直接运行
- webpack entry.js bundle.js
 
 
这个过程发生了什么?
执行上述命令之后,npm 会让命令行进入 node_modules/.bin 目录下查找 webpack.js,如果存在就执行,不存在就抛出错误。
.bin 目录下的文件实际上是软链接,webpack.js 真正指向的文件是 node_modules/webpack/bin/webpack.js。
分析 webpack 的入口文件:webpack.js
认识几个关键函数:
- runCommand -> 运行命令
 - isInstalled -> 判断某个包是否安装
 - runCli -> 执行 webpack-cli
 
执行流程:
- 判断 webpack-cli 是否存在。
 - 不存在则抛出异常,存在则直接到第 6 步。
 - 判断当前使用的包管理器是 yarn/npm/pnpm 中的哪一种。
 - 使用包管理器自动安装 webpack-cil(runCommand -> 
yarn webpack-cli -D) - 安装成功后执行 runCli
 - 执行 runCli(执行 
webpack-cli/bin/cli.js) 
总结:webpack 最终会找到 webpack-cli 这个包,并且执行 webpack-cli。
webpack-cli 源码阅读
webpack-cli 做的事情:
- 引入 
Commander.js,对命令行进行定制。 - 分析命令行参数,对各个参数进行转换,组成编译配置项。
 - 引用 webpack,根据生成的配置项进行编译和构建。
 
命令行工具包 Commander.js 介绍
完成的
nodejs命令行解决方案。
- 提供命令和分组参数。
 - 动态生成 help 帮助信息。
 
具体的执行流程:
- 接着上面一小节的执行 webpack-cli,也就是执行 
node_modules/webpack-cli/bin/cli.js。 - 检查是否有 
webpack,有的话执行runCli,它主要做的是实例化node_modules/webpack-cli/webpack-cli.js,然后调用它的run方法。 - 使用 
Commander.js定制命令行参数。 - 解析命令行的参数,如果是内置参数,则调用 
createCompiler,主要做的事是想将得到的参数传递给webpack,生成实例化对象compiler。 
总结:webpack-cli 对命令行参数进行转换,最终生成配置项参数 options,将 options 传递给 webpack 对象,执行构建流程(最后会判断是否有监听参数,如果有,就执行监听的动作)。
Tapable 插件架构与 Hooks 设计
webpack 的本质:webpack 可以将其理解成是一种基于事件流(发布订阅模式)的编程范例,一系列的插件运行。
Compiler 和 Compilation 都是继承 Tapable,那么 Tapable 是什么呢?
Tapable 是一个类似于 nodejs 的 EventEmitter 的库,主要是控制钩子函数的发布与订阅,控制着 webpack 的插件系统。
- Tapable 暴露了很多 Hook 类,为插件提供挂载的钩子。
 
Tapable hooks 类型:
Tapable 提供了同步和异步绑定钩子的方法,并且都有绑定事件和执行事件对应的方法。
实现一个 Car 类,其中有个 hooks 对象,包含了加速、刹车、计算路径等 hook,对其分别注册事件和触发事件:
console.time('cost');
class Car {
  constructor() {
    this.hooks = {
      acclerate: new SyncHook(['newspped']), // 加速
      brake: new SyncHook(), // 刹车
      calculateRoutes: new AsyncSeriesHook(['source', 'target', 'routes']), // 计算路径
    };
  }
}
const myCar = new Car();
// 绑定同步钩子
myCar.hooks.brake.tap('WarningLmapPlugin', () => {
  console.log('WarningLmapPlugin');
});
// 绑定同步钩子并传参
myCar.hooks.acclerate.tap('LoggerPlugin', (newSpeed) => {
  console.log(`accelerating spped to ${newSpeed}`);
});
// 绑定一个异步的 promise
myCar.hooks.calculateRoutes.tapPromise(
  'calculateRoutes tabPromise',
  (params) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log(`tapPromise to ${params}`);
        resolve();
      }, 1000);
    });
  }
);
// 触发同步钩子
myCar.hooks.brake.call();
// 触发同步钩子并传入参数
myCar.hooks.acclerate.call(120);
// 触发异步钩子
myCar.hooks.calculateRoutes
  .promise(['Async', 'hook', 'demo'])
  .then(() => {
    console.timeEnd('cost');
  })
  .catch((err) => {
    console.error(err);
    console.timeEnd('cost');
  });
复制代码
Tapable 是如何和 webpack 进行关联起来的
上面说到 webpack-cli.js 中执行 createCompiler 的时候,将转换后得到的 options 传递给 webpack 方法然后生成 compiler 对象,接下来说说 webpack.js 中做的事情,我将 createCompiler 的源码贴出,便于理解:
const createCompiler = (rawOptions) => {
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);
  const compiler = new Compiler(options.context); // 实例化 compiler
  compiler.options = options;
  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging,
  }).apply(compiler);
  if (Array.isArray(options.plugins)) {
    // 遍历并调用插件
    for (const plugin of options.plugins) {
      if (typeof plugin === 'function') {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }
  applyWebpackOptionsDefaults(options);
  // 触发监听的 hooks
  compiler.hooks.environment.call();
  compiler.hooks.afterEnvironment.call();
  new WebpackOptionsApply().process(options, compiler); // 注入内部插件
  compiler.hooks.initialize.call();
  return compiler; // 将实例返回
};
复制代码
通过上述代码可以得到两个关于插件的结论:
- 插件就是监听 compiler 对象上的 hooks。
 - 执行插件需要调用插件的 apply 方法,并将 compiler 对象作为参数传入。
 
webpack.js:
- webpack 中也有 createCompiler 方法,它会先实例化 
Compiler对象,生成 compiler 实例。 - Compiler 中的核心在于挂载了许多继承自 Tapable 的 hooks,其它地方可以使用 compiler 实例注册和触发事件,在 webpack 构建的不同阶段,会触发不同的 hook。
 options.plugins即配置的一系列插件,在 createCompiler 中,生成 compiler 实例后,如果options.plugins是数组类型,则会遍历调用它,并传入 compiler,形如plugin.apply(compiler),内部绑定 compiler 上的一些 hooks 事件。
简易模拟 Compiler 和插件的实现:
// Compiler 对象,挂载了一些 hook
const { SyncHook, AsyncSeriesHook } = require('tapable');
module.exports = class Compiler {
  constructor() {
    this.hooks = {
      acclerate: new SyncHook(['newspped']),
      brake: new SyncHook(),
      calculateRoutes: new AsyncSeriesHook(['source', 'target', 'routesList']),
    };
  }
  run() {
    this.acclerate(100);
    this.brake();
    this.calculateRoutes('Async', 'hook', 'demo');
  }
  acclerate(speed) {
    this.hooks.acclerate.call(speed);
  }
  brake() {
    this.hooks.brake.call();
  }
  calculateRoutes(...params) {
    this.hooks.calculateRoutes.promise(...params).then(
      () => {},
      (err) => {
        console.log(err);
      }
    );
  }
};
复制代码
webpack 插件,根据传入的 compiler 对象,选择性监听了一些 hook:
const Compiler = require('./compiler');
class MyPlugin {
  apply(compiler) {
    // 绑定事件
    compiler.hooks.acclerate.tap('打印速度', (newSpeed) =>
      console.log(`speed acclerating to ${newSpeed}`)
    );
    compiler.hooks.brake.tap('刹车警告', () => console.log('正在刹车'));
    compiler.hooks.calculateRoutes.tapPromise(
      '计算路径',
      (source, target, routesList) =>
        new Promise((resolve, reject) => {
          setTimeout(() => {
            console.log(`计算路径: ${source} ${target} ${routesList}`);
            resolve();
          }, 1000);
        })
    );
  }
}
// 模拟插件执行
const compiler = new Compiler();
const myPlugin = new MyPlugin();
// 模拟 webpack.config.js 的 plugins 配置
const options = { plugins: [myPlugin] };
for (const plugin of options.plugins) {
  if (typeof plugin === 'function') {
    plugin.call(compiler, compiler);
  } else {
    plugin.apply(compiler); // 绑定事件
  }
}
compiler.run(); // 触发事件
复制代码
webpack 流程篇:准备阶段
webpack 的打包流程可以分为三个阶段:
- 准备:初始化参数,为对应的参数注入插件
 - 模块编译和打包
 - 模块优化、代码生成和输出到磁盘。
 
webpack 的编译按照下面钩子的调用顺序进行:
- entry-option: 初始化 option。
 - run: 开始编译。
 - make: 从 entry 开始递归地分析依赖,对每个依赖模块进行 build。
 - before-resolve: 对模块位置进行解析。
 - build-module: 开始构建某个模块。
 - normal-module-loader: 将 loader 加载完成的 module 进行编译,生成 AST 树。
 - program: 遍历 AST,当遇到 require 等一些调用表达式,收集依赖。
 - seal: 所有依赖 build 完成,开始优化。
 - emit: 输出到 dist 目录。
 
entry-option
首先第一步,在目录下查询 entryOption 字符串的位置:
grep "\.entryOption\." -rn ./node_modules/webpack
复制代码
得到如下结果:
可以看到,在 EntryOptionPlugin 和 DllPlugin 中有绑定该 hook,在 WebpackOptionsApply 中触发该 hook。
WebpackOptionsApply
- 将所有的配置 options 参数转换成 webpack 内部插件。
 
如:
- options.externals 对应 ExternalsPlugin。
 - options.output.clean 对应 CleanPlugin。
 - options.experiments.syncWebAssembly 对应 WebAssemblyModulesPlugin。
 
- 绑定 entryOption hook 并触发它。
 
最后准备阶段以一张较为完整的流程图结束:
webpack 流程篇:模块构建和 chunk 生成阶段
相关的 hook
流程相关:
- (before-)run
 - (before-/after-)compile
 - make
 - (after-)emit
 - done
 
监听相关:
- watch-run
 - watch-close
 
Compilation
Compiler 调用 Compilation 生命周期方法:
- addEntry -> addModuleChain
 - finish(上报模块错误)
 - seal
 
ModuleFactory
Compiler 会创建两个工厂函数,分别是 NormalModuleFactory 和 ContextModuleFactory,均继承 ModuleFactory。
- NormalModuleFactory: 普通模块名导入。
 - ContextModuleFactory: 以路径形式导入的模块。
 
NormalModule
Build-构建阶段:
- 使用 loader-runner 运行 loaders 解析模块生成 js 代码。
 - 通过 Parser 解析(内部使用 acron) 解析依赖,
 - ParserPugins 添加依赖 所有依赖解析完成后,make 阶段就结束了。
 
具体构建流程
- compiler.compile: hooks.compile -> hooks.make(开始构建) -> compilation.addEntry(添加入口文件)。
 
查看绑定和触发 hooks.make 的地方:
- 
模块构建完成后,触发 hook.finishMake -> compilation.finish -> compilation.seal -> hooks.afterCompile,最终得到经过 loaders(loader-runner) 解析生成的代码。
 - 
以 NormalModule 的构建为例,说说它的过程:
 
- 构建,通过 loader 解析:build -> doBuild -> runLoaders。
 - 分析及添加依赖:parser.parse(将 loader 编译过得代码使用 acron 解析并添加依赖)。
 - 将最终得到的结果存储到 compilation.modules。
 - hook.finishMake 完成构建。
 
chunk 生成算法
- webpack 先将 entry 中对应的 module 都生成一个新的 chunk。
 - 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中。
 - 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖。
 - 重复上面的过程,知道生成所有的 chunk。
 
webpack 流程篇:文件生成
完成构建后,执行 hooks.seal、hooks.optimize 对构建结果进行优化,优化完成后出触发 hooks.emit,将构建结果输出到磁盘上。
动手编写一个简易的 webpack
模块化:增强代码可读性和维护性
闭包 + 立即执行函数 → angularjs 的依赖注入 → nodejs 的 commonjs -> AMD -> es2015 的 e**odule。
- 传统的网页开发转变成 Web App 开发。
 - 代码复杂度在逐步增高。
 - 分离的 js 文件/模块,便于后续代码的维护。
 - 部署时希望把代码优化成多个 HTTP 请求。
 
常见的几种模块化方式:
// e**odule
// 静态分析,不能动态,只能在文件最顶层导入。
import * as largeNumber from 'large-number';
largeNumber('99');
复制代码
// commonjs
// nodejs 默认遵循的规范,支持动态导入
const largeNumber = require('large-number');
largeNumber('99');
复制代码
// AMD
// 借鉴 commonjs,浏览器中经常使用
require(['large-number'], function (largeNumber) {
  largeNumber.add('99');
});
复制代码
AST 基础知识
AST 即抽象语法树,是源代码的抽象语法结构的树状表现形式。
AST 的使用场景:
- 模板引擎实现的两种方式
- 正则匹配
 - AST
 
 - es6 → es5 或 ts → js
 
webpack 的模块机制
- 打包出来的是一个 IIFE(匿名闭包函数)。
 - modules 是一个数组,每一项是一个模块初始化函数。
 __webpack_require__用来加载模块,返回module.exports。- 通过 
WEBPACK_REQUIRE_METHOD(0)启动程序。 
一个简易的 webpack 需要支持一下特性:
- 支持将 es6 转换成 es5。
- 通过 
parse生成 AST。 - 通过 
transformFromAstSync将 AST 重新生成 es5 源码。 
 - 通过 
 - 可以分析模块之间的依赖关系。
- 通过 
traverse的ImportDeclaration方法获取依赖属性。 
 - 通过 
 - 生成的 js 文件可以在浏览器中运行。
 
编写步骤
- 编写 
minipack.config.js。 - 编写 
parser.js,实现将 es6 的代码转换成 AST,然后分析依赖,将 AST 转换成 es5 代码。 - 编写 
compiler.js,实现开始构建、构建模块、将结果输出到磁盘功能。 
实现的源码我放在了这里,点击查看
编写 loader 和插件
loader 的链式调用和执行顺序
一个最简单的 loader 代码结构
定义:loader 是一个导出为函数的 js 模块:
module.exports = function (source) {
  return source;
};
复制代码
多 loader 时的执行顺序
- 串行执行:前一个的执行结果会传递给后一个 loader。
 - 按从右往左的顺序执行。
 
module.exports = {
  module: {
    rules: [
      {
        test: /\.less/,
        use: ['style-loader', 'css-loader', 'less-loader'],
      },
    ],
  },
};
复制代码
函数组合的两种情况
- Unix 中的 pipline
 - Compose
 
const compose =
  (f, g) =>
  (...args) =>
    f(g(...args));
复制代码
验证 loader 顺序的执行
执行 yarn build 能查看 loader 日志打印的顺序。
使用 loader-runner 高效进行 loader 的调试
上一小节验证 loader 执行顺序的时候,需要先安装
webpack webpack-cli,然后编写webpack.config.js,将编写的 loader 引入对应的配置文件中,这个过程比较繁琐,可以使用更高效的 loader-runner,进行 loader 的开发和调试。它允许在不安装 webpack 的情况下运行 loader。
loader-runner 的作用:
- 作为 webpack 的依赖,webpack 中使用它执行 loader。
 - 进行 loader 的开发和调试。
 
编写 raw-loader,使用 loader-runner 运行
实现 raw-loader:
module.exports = function rawLoader(source) {
  const str = JSON.stringify(source)
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029'); // 模板字符串存在安全问题,这里对模板字符串进行转义处理
  return `export default ${str}`; // 将文件内容转换成模块
};
复制代码
在 loader-runner 中调用 raw-loader:
const path = require('path');
const { runLoaders } = require('loader-runner');
const fs = require('fs');
runLoaders(
  {
    resource: path.join(__dirname, './src/info.txt'),
    loaders: [path.join(__dirname, './loaders/raw-loader.js')],
    context: { minimize: true }, // 接收的上下文
    readResource: fs.readFile.bind(fs), // 读取文件的方式
  },
  (err, result) => {
    if (err) {
      console.log(err);
    } else {
      console.log(result);
    }
  }
);
复制代码
执行 node run-loader.js,以下是输出结果:
更复杂的 loader 的开发场景
loader 的参数获取
通过 loader-utils 的 getOptions 方法获取。
修改 run-loader.js,改成可以传递参数的形式:
runLoaders(
  {
    resource: path.join(__dirname, './src/info.txt'),
    loaders: [
      {
        loader: path.join(__dirname, './loaders/raw-loader.js'),
        options: { name: 'ywhoo' }, // 传递了 name 参数
      },
    ],
    context: { minimize: true },
    readResource: fs.readFile.bind(fs),
  },
  (err, result) => {
    if (err) {
      console.log(err);
    } else {
      console.log(result);
    }
  }
);
复制代码
在 raw-loader.js 中使用该参数:
const loaderUtils = require('loader-utils');
module.exports = function rawLoader(source) {
  const { name } = loaderUtils.getOptions(this); // 引入参数
  console.log(name, 'name');
  const str = JSON.stringify(source)
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
  return `export default ${str}`;
};
复制代码
loader 的异常处理
同步:
- throw
 - this.callback
- 返回处理结果
 - 抛出异常
 - 回传更多的值
 
 
// throw
throw new Error('error');
// this.callback
this.callback(err: Error | null, content: string | Buffer, sourceMap?: SourceMap, meta?: any);
复制代码
异步处理
在开发 loader 过程中,可能需要处理异步,如异步读取文件,等读取完成后将内容返回。
可以通过 this.async() 实现,修改 raw-loader 中的例子,增加异步文件的读取:
// ...
module.exports = function rawLoader(source) {
  const callback = this.async();
  fs.readFile(
    path.join(__dirname, '../src/async.txt'),
    'utf-8',
    (err, data) => {
      callback(null, data);
    }
  );
};
复制代码
在 loader 中使用缓存
- webpack 中默认开启 loader 缓存
- 使用 
this.cacheable(false)关闭默认缓存 
 - 使用 
 - 缓存条件:loader 的结果在相同的输入下有确定的输出
- 有依赖的 loader 无法使用缓存
 
 
关掉缓存:
module.exports = function () {
  this.cacheable(false);
};
复制代码
在 loader 中输出文件到磁盘中
使用 this.emitFile 进行文件写入。
在 loader-a.js 中输出文件,最终会在 dist 下生成 demo.txt:
module.exports = function (source) {
  console.log('loader a is running');
  this.emitFile('demo.txt', '在 loader 中输出文件。');
  return source;
};
复制代码
或使用 loader-utils,在本例中,会在 dist 下生成 index.js:
const loaderUtils = require('loader-utils');
module.exports = function (source) {
  console.log('loader a is running');
  // 匹配出符合规则的文件名称,如这里会匹配到 index.js
  const filename = loaderUtils.interpolateName(this, '[name].[ext]', source);
  this.emitFile(filename, source);
  return source;
};
复制代码
实战开发一个自动合成雪碧图的 loader
雪碧图的应用可以减少 http 的请求次数,有效提升页面加载的速度。
实现将多张图片合成一张,支持如下效果:
如何将两张图片合成一张图片
使用 sprite**ith。
实现 sprite-loader:
const path = require('path');
const Sprite**ith = require('sprite**ith');
const fs = require('fs');
module.exports = function (source) {
  const callback = this.async();
  const regex = /url\((\S*)\?__sprite\S*\)/g;
  let imgs = source.match(regex); // [ "url('./images/girl.jpg?__sprite", "url('./images/glasses.jpg?__sprite" ]
  imgs = imgs.map((img) => {
    const imgPath = img.match(/\/(images\/\S*)\?/)[1];
    return path.join(__dirname, '../src', imgPath);
  });
  Sprite**ith.run({ src: imgs }, function handleResult(err, result) {
    // 将生成的图片写入 dist/sprites.jpg
    // 在 webpack 中,应该使用 emitFile 来写入文件
    fs.writeFileSync(
      path.join(process.cwd(), 'dist/sprites.jpg'),
      result.image
    );
    const code = source.replace(regex, (match) => "url('./sprites.jpg')");
    // 输出 index.css
    fs.writeFileSync(path.join(process.cwd(), 'dist/index.css'), code);
    callback(null, code);
  });
  return source;
};
复制代码
在 loader-runner 中使用 sprite-loader:
const path = require('path');
const { runLoaders } = require('loader-runner');
const fs = require('fs');
runLoaders(
  {
    resource: path.join(__dirname, './src/index.css'),
    loaders: [
      {
        loader: path.join(__dirname, './loaders/sprites-loader.js'),
      },
    ],
    context: { minimize: true },
    readResource: fs.readFile.bind(fs),
  },
  (err, result) => {
    if (err) {
      console.log(err);
    } else {
      console.log(result);
    }
  }
);
复制代码
执行 node run-sprites-loader.js,结果如下:
body {
  background: url('./sprites.jpg');
}
.banner {
  background: url('./sprites.jpg');
}
复制代码
插件基本结构介绍
loader 负责处理资源,即将各种资源当成模块来处理。而插件可以介入 webpack 构建的生命周期中。
- 插件没有像 loader 那样独立的运行环境(loader-runner)。
 - 只能在 webpack 里面运行。
 
例子:
module.exports = class MyPlugin {
  constructor(options) {
    console.log(options);
  }
  apply(compiler) {
    console.log('执行 my-plugin');
  }
};
复制代码
const path = require('path');
const MyPlugin = require('./plugins/my-plugin'); // 自定义插件
module.exports = {
  entry: path.join(__dirname, './src/index.js'),
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, './dist'),
  },
  plugins: [new MyPlugin({ name: 'ywhoo' })],
};
复制代码
更复杂的插件开发场景
插件的错误处理
throw new Error('error');。- 通过 compilation 对象的 warnings 和 errors 接收。
compilation.warnings.push('warning');compilation.errors.push('error');
 
通过 Compilation 进行文件写入
文件的生成在 emit 阶段,可以监听 emit,然后获取到 compilation 对象。
Compilation 上的 assets 可以用于文件写入
- 可以将 zip 资源包设置到 compilation.assets 对象上。
 
文件写入需要使用 webpack-sources 库,示例:
const { RawSource } = require('webpack-sources');
module.exports = class DemoPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    const { name } = this.options;
    compiler.compilation.hooks.emit.tap('emit', (compilation, cb) => {
      compilation.assets[name] = new RawSource('demo');
      cb();
    });
  }
};
复制代码
插件扩展:编写插件的插件
插件自身也可以通过暴露 hooks 的方式进行自身扩展,以 html-webpack-plugin 为例,它支持一下 hook:
- html-webpack-plugin-after-chunks(sync)
 - html-webpack-plugin-before-html-generation(async)
 - html-webpack-plugin-after-asset-tags(async)
 - html-webpack-plugin-after-html-processing(async)
 - html-webpack-plugin-after-emit(async)
 
实战开发一个压缩构建资源为 zip 包的插件
要求:
- 生成的 zip 包文件名称可以通过插件传入。
 - 需要使用 compiler 对象上的 hooks 进行资源的生成。
 
准备知识
nodejs 里使用 jszip 创建和编辑 zip 包。
复习:Compiler 上负责文件生成的 hook
emit, 一个异步的 hook(AsyncSeriesHook)
emit 生成文件阶段,读取的是 compilation.assets 对象的值。
- 将 zip 资源包设置到 compilation.assets 对象上。
 
实现 zip-plugin.js:
const JSZip = require('jszip');
const path = require('path');
const { Compilation, sources } = require('webpack');
module.exports = class ZipPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    const { filename } = this.options;
    // 监听 compilation 的 hooks
    compiler.hooks.compilation.tap('ZipPlugin', (compilation) => {
      // 监听 processAssets hook,即在处理构建资源的过程中,可以拿到静态资源
      compilation.hooks.processAssets.tapPromise(
        {
          name: 'ZipPlugin',
          stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
        },
        (assets) => {
          return new Promise((resolve, reject) => {
            const zip = new JSZip();
            // 创建压缩包
            const folder = zip.folder(filename);
            Object.entries(assets).forEach(([fname, source]) => {
              // 将打包好的资源文件添加到压缩包中
              folder.file(fname, source.source());
            });
            zip.generateAsync({ type: 'nodebuffer' }).then(function (content) {
              // /Users/yewei/Project/source-code-realize/play-webpack/source/mini-plugin/dist/ywhoo.zip
              const outputPath = path.join(
                compilation.options.output.path,
                `${filename}.zip`
              );
              // 相对路径 ywhoo.zip
              const relativeOutputPath = path.relative(
                compilation.options.output.path,
                outputPath
              );
              // 将 buffer 转船 raw source
              // 将 zip 包添加到 compilation 的构建资源中
              compilation.emitAsset(
                relativeOutputPath,
                new sources.RawSource(content)
              );
              resolve();
            });
          }).catch((e) => {
            console.log(e, 'e');
          });
        }
      );
    });
  }
};
复制代码
使用插件:
const path = require('path');
const ZipPlugin = require('./plugins/zip-plugin');
module.exports = {
  entry: path.join(__dirname, './src/index.js'),
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, './dist'),
  },
  plugins: [new ZipPlugin({ filename: 'ywhoo' })],
};
复制代码
运行 yarn build,会在 dist 目录下生成 ywhoo.zip。
总结
如果跟着实践笔记走到了这里,相信大家对 webpack 不再是那么陌生,而且对原理也有了一定程度的了解,能够编写 loader 处理静态资源,编写插件控制 webpack 构建过程中的每一个阶段。最后想说一句话与大家共勉,行路虽难,但贵在看到曙光的那一刻。
