性能文章>玩转 webpack5(下)>

玩转 webpack5(下)原创

2年前
450613

前言

这是接玩转 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。

polyfill.io

体积优化策略总结

  • 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

执行流程:

  1. 判断 webpack-cli 是否存在。
  2. 不存在则抛出异常,存在则直接到第 6 步。
  3. 判断当前使用的包管理器是 yarn/npm/pnpm 中的哪一种。
  4. 使用包管理器自动安装 webpack-cil(runCommand -> yarn webpack-cli -D
  5. 安装成功后执行 runCli
  6. 执行 runCli(执行 webpack-cli/bin/cli.js

总结:webpack 最终会找到 webpack-cli 这个包,并且执行 webpack-cli。

webpack-cli 源码阅读

webpack-cli 做的事情:

  • 引入 Commander.js,对命令行进行定制。
  • 分析命令行参数,对各个参数进行转换,组成编译配置项。
  • 引用 webpack,根据生成的配置项进行编译和构建。

命令行工具包 Commander.js 介绍

完成的 nodejs 命令行解决方案。

  • 提供命令和分组参数。
  • 动态生成 help 帮助信息。

具体的执行流程:

  1. 接着上面一小节的执行 webpack-cli,也就是执行 node_modules/webpack-cli/bin/cli.js
  2. 检查是否有 webpack,有的话执行 runCli,它主要做的是实例化 node_modules/webpack-cli/webpack-cli.js,然后调用它的 run 方法。
  3. 使用 Commander.js 定制命令行参数。
  4. 解析命令行的参数,如果是内置参数,则调用 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 的打包流程可以分为三个阶段:

  1. 准备:初始化参数,为对应的参数注入插件
  2. 模块编译和打包
  3. 模块优化、代码生成和输出到磁盘。

webpack 的编译按照下面钩子的调用顺序进行:

  1. entry-option: 初始化 option。
  2. run: 开始编译。
  3. make: 从 entry 开始递归地分析依赖,对每个依赖模块进行 build。
  4. before-resolve: 对模块位置进行解析。
  5. build-module: 开始构建某个模块。
  6. normal-module-loader: 将 loader 加载完成的 module 进行编译,生成 AST 树。
  7. program: 遍历 AST,当遇到 require 等一些调用表达式,收集依赖。
  8. seal: 所有依赖 build 完成,开始优化。
  9. emit: 输出到 dist 目录。

entry-option

首先第一步,在目录下查询 entryOption 字符串的位置:

grep "\.entryOption\." -rn ./node_modules/webpack
复制代码

得到如下结果:

可以看到,在 EntryOptionPluginDllPlugin 中有绑定该 hook,在 WebpackOptionsApply 中触发该 hook。

WebpackOptionsApply
  1. 将所有的配置 options 参数转换成 webpack 内部插件。

如:

  • options.externals 对应 ExternalsPlugin。
  • options.output.clean 对应 CleanPlugin。
  • options.experiments.syncWebAssembly 对应 WebAssemblyModulesPlugin。
  1. 绑定 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 的构建为例,说说它的过程:

  1. 构建,通过 loader 解析:build -> doBuild -> runLoaders。
  2. 分析及添加依赖:parser.parse(将 loader 编译过得代码使用 acron 解析并添加依赖)。
  3. 将最终得到的结果存储到 compilation.modules。
  4. hook.finishMake 完成构建。

chunk 生成算法

  1. webpack 先将 entry 中对应的 module 都生成一个新的 chunk。
  2. 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中。
  3. 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖。
  4. 重复上面的过程,知道生成所有的 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

AST 在线解析引擎

webpack 的模块机制

  • 打包出来的是一个 IIFE(匿名闭包函数)。
  • modules 是一个数组,每一项是一个模块初始化函数。
  • __webpack_require__ 用来加载模块,返回 module.exports
  • 通过 WEBPACK_REQUIRE_METHOD(0) 启动程序。

一个简易的 webpack 需要支持一下特性:

  • 支持将 es6 转换成 es5。
    • 通过 parse 生成 AST。
    • 通过 transformFromAstSync 将 AST 重新生成 es5 源码。
  • 可以分析模块之间的依赖关系。
    • 通过 traverseImportDeclaration 方法获取依赖属性。
  • 生成的 js 文件可以在浏览器中运行。

编写步骤

  1. 编写 minipack.config.js
  2. 编写 parser.js,实现将 es6 的代码转换成 AST,然后分析依赖,将 AST 转换成 es5 代码。
  3. 编写 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 构建过程中的每一个阶段。最后想说一句话与大家共勉,行路虽难,但贵在看到曙光的那一刻。

点赞收藏
分类:
Ywhoo

和风,网名Ywhoo,现任产研公司资深前端工程师,多年前端开发经验,喜欢追寻新技术,同时致力于前端工程化,热衷于编写更优雅的代码,热爱开源与分享,有大型SPA项目的架构及开发经验。

请先登录,查看1条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
1
Lv2
Ywhoo

徽章

和风,网名Ywhoo,现任产研公司资深前端工程师,多年前端开发经验,喜欢追寻新技术,同时致力于前端工程化,热衷于编写更优雅的代码,热爱开源与分享,有大型SPA项目的架构及开发经验。