小贼先生

小贼先生 查看完整档案

北京编辑  |  填写毕业院校奇虎360  |  前端开发工程师 编辑 ronffy.github.io 编辑
编辑

Dream is a shit, beat it first !

个人动态

小贼先生 关注了用户 · 2020-12-21

关注 3130

小贼先生 收藏了文章 · 2020-12-21

webpack原理

webpack原理

查看所有文档页面:前端开发文档,获取更多信息。
原文链接:webpack原理,原文广告模态框遮挡,阅读体验不好,所以整理成本文,方便查找。

工作原理概括

基本概念

在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

流程概括

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

流程细节

Webpack 的构建流程可以分为以下三大阶段:

  1. 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
  2. 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  3. 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用,下面来一一介绍。

初始化阶段

事件名解释
初始化参数从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句 new Plugin()
实例化 Compiler用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
加载插件依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API。
environment开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。
after-plugins调用完所有内置的和配置的插件的 apply 方法。
after-resolvers根据配置初始化完 resolverresolver 负责在文件系统中寻找指定路径的文件。
空格空格
空格空格
空格空格

编译阶段

事件名解释
run启动一次新的编译。
watch-runrun 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
compile该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
compilationWebpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
after-compile一次 Compilation 执行完成。
invalid当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。
空格空格
空格空格

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:

事件名解释
build-module使用对应的 Loader 去转换一个模块。
normal-module-loader在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。
program从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk。

输出阶段

事件名解释
should-emit所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。
emit确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
after-emit文件输出完毕。
done成功完成一次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。

输出文件分析

虽然在前面的章节中你学会了如何使用 Webpack ,也大致知道其工作原理,可是你想过 Webpack 输出的 bundle.js 是什么样子的吗? 为什么原来一个个的模块文件被合并成了一个单独的文件?为什么 bundle.js 能直接运行在浏览器中? 本节将解释清楚以上问题。

先来看看由 安装与使用 中最简单的项目构建出的 bundle.js 文件内容,代码如下:

<p data-height="565" data-theme-id="0" data-slug-hash="NMQzxz" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上看上去复杂的代码其实是一个立即执行函数,可以简写为如下:

(function(modules) {

  // 模拟 require 语句
  function __webpack_require__() {
  }

  // 执行存放所有模块数组中的第0个模块
  __webpack_require__(0);

})([/*存放所有模块的数组*/])

bundle.js 能直接运行在浏览器中的原因在于输出的文件中通过 __webpack_require__ 函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require 语句。

原来一个个独立的模块文件被合并到了一个单独的 bundle.js 的原因在于浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。

如果仔细分析 __webpack_require__ 函数的实现,你还有发现 Webpack 做了缓存优化: 执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。

分割代码时的输出

例如把源码中的 main.js 修改为如下:

// 异步加载 show.js
import('./show').then((show) => {
  // 执行 show 函数
  show('Webpack');
});

重新构建后会输出两个文件,分别是执行入口文件 bundle.js 和 异步加载文件 0.bundle.js

其中 0.bundle.js 内容如下:

// 加载在本文件(0.bundle.js)中包含的模块
webpackJsonp(
  // 在其它文件中存放着的模块的 ID
  [0],
  // 本文件所包含的模块
  [
    // show.js 所对应的模块
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);

bundle.js 内容如下:

<p data-height="565" data-theme-id="0" data-slug-hash="yjmRyG" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

这里的 bundle.js 和上面所讲的 bundle.js 非常相似,区别在于:

  • 多了一个 __webpack_require__.e 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件;
  • 多了一个 webpackJsonp 函数用于从异步加载的文件中安装模块。

在使用了 CommonsChunkPlugin 去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 __webpack_require__.ewebpackJsonp。 原因在于提取公共代码和异步加载本质上都是代码分割。

编写 Loader

Loader 就像是一个翻译员,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个翻译员翻译。

以处理 SCSS 文件为例:

  • SCSS 源代码会先交给 sass-loader 把 SCSS 转换成 CSS;
  • sass-loader 输出的 CSS 交给 css-loader 处理,找出 CSS 中依赖的资源、压缩 CSS 等;
  • css-loader 输出的 CSS 交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码;

可以看出以上的处理过程需要有顺序的链式执行,先 sass-loadercss-loaderstyle-loader。 以上处理的 Webpack 相关配置如下:

<p data-height="365" data-theme-id="0" data-slug-hash="YLmbeQ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="编写 Loader" class="codepen">See the Pen 编写 Loader by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

Loader 的职责

由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。

Loader 基础

由于 Webpack 是运行在 Node.js 之上的,一个 Loader 其实就是一个 Node.js 模块,这个模块需要导出一个函数。 这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。

一个最简单的 Loader 的源码如下:

module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的一个文件的原内容
  // 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};

由于 Loader 运行在 Node.js 中,你可以调用任何 Node.js 自带的 API,或者安装第三方模块进行调用:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};

Loader 进阶

以上只是个最简单的 Loader,Webpack 还提供一些 API 供 Loader 调用,下面来一一介绍。

获得 Loader 的 options

在最上面处理 SCSS 文件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在自己编写的 Loader 中获取到用户传入的 options 呢?需要这样做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 获取到用户给当前 Loader 传入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};

返回其它结果

上面的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东西。

例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。 为了把 Source Map 也一起随着 ES5 代码返回给 Webpack,可以这样写:

module.exports = function(source) {
  // 通过 this.callback 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps);
  // 当你使用 this.callback 返回内容时,该 Loader 必须返回 undefined,
  // 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,而不是 return 中 
  return;
};

其中的 this.callback 是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。 this.callback 的详细使用方法如下:

this.callback(
    // 当无法转换原内容时,给 Webpack 返回一个 Error
    err: Error | null,
    // 原内容转换后的内容
    content: string | Buffer,
    // 用于把转换后的内容得出原内容的 Source Map,方便调试
    sourceMap?: SourceMap,
    // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
    // 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
    abstractSyntaxTree?: AST
);
Source Map 的生成很耗时,通常在开发环境下才会生成 Source Map,其它环境下不用生成,以加速构建。 为此 Webpack 为 Loader 提供了 this.sourceMap API 去告诉 Loader 当前构建环境下用户是否需要 Source Map。 如果你编写的 Loader 会生成 Source Map,请考虑到这点。

同步与异步

Loader 有同步和异步之分,上面介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。

在转换步骤是异步时,你可以这样:

module.exports = function(source) {
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通过 callback 返回异步执行后的结果
        callback(err, result, sourceMaps, ast);
    });
};

处理二进制数据

在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。 为此,你需要这样编写 Loader:

module.exports = function(source) {
    // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
    source instanceof Buffer === true;
    // Loader 返回的类型也可以是 Buffer 类型的
    // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
    return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据 
module.exports.raw = true;

以上代码中最关键的代码是最后一行 module.exports.raw = true;,没有该行 Loader 只能拿到字符串。

缓存加速

在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时, 是不会重新调用对应的 Loader 去执行转换操作的。

如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:

module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};

其它 Loader API

除了以上提到的在 Loader 中能调用的 Webpack API 外,还存在以下常用 API:

  • this.context:当前处理文件的所在目录,假如当前 Loader 处理的文件是 /src/main.js,则 this.context 就等于 /src
  • this.resource:当前处理文件的完整请求路径,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:当前处理文件的路径,例如 /src/main.js
  • this.resourceQuery:当前处理文件的 querystring
  • this.target:等于 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去获得 request 对应文件的处理结果。
  • this.resolve:像 require 语句一样获得指定文件的完整路径,使用方法为 resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为 addDependency(file: string)
  • this.addContextDependency:和 addDependency 类似,但 addContextDependency 是把整个目录加入到当前正在处理文件的依赖中。使用方法为 addContextDependency(directory: string)
  • this.clearDependencies:清除当前正在处理文件的所有依赖,使用方法为 clearDependencies()
  • this.emitFile:输出一个文件,使用方法为 emitFile(name: string, content: Buffer|string, sourceMap: {...})

加载本地 Loader

在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 在前面的章节中,使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};

如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules 目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。

解决以上问题的便捷方法有两种,分别如下:

Npm link

Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules 目录下,让项目可以直接使用本地的 Npm 模块。 由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。

完成 Npm link 的步骤如下:

  • 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的 package.json 已经正确配置好;
  • 在本地 Npm 模块根目录下执行 npm link,把本地模块注册到全局;
  • 在项目根目录下执行 npm link loader-name,把第2步注册到全局的本地 Npm 模块链接到项目的 node_moduels 下,其中的 loader-name 是指在第1步中的 package.json 文件中配置的模块名称。

链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用于配置 Webpack 如何寻找 Loader。 默认情况下只会去 node_modules 目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在项目目录中的 ./loaders/loader-name 中,则需要如下配置:


module.exports = {
  resolveLoader:{
    // 去哪些目录下寻找 Loader,有先后顺序之分
    modules: ['node_modules','./loaders/'],
  }
}

加上以上配置后, Webpack 会先去 node_modules 项目下寻找 Loader,如果找不到,会再去 ./loaders/ 目录下寻找。

实战

上面讲了许多理论,接下来从实际出发,来编写一个解决实际问题的 Loader。

该 Loader 名叫 comment-require-loader,作用是把 JavaScript 代码中的注释语法:

// @require '../style/index.css'

转换成:

require('../style/index.css');

该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。

该 Loader 的使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};

该 Loader 的实现非常简单,完整代码如下:

function replace(source) {
    // 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}

module.exports = function (content) {
    return replace(content);
};

编写 Plugin

Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

一个最基础的 Plugin 的代码是这样的:

class BasicPlugin{
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options){
  }

  // Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler){
    compiler.plugin('compilation',function(compilation) {
    })
  }
}

// 导出 Plugin
module.exports = BasicPlugin;

在使用这个 Plugin 时,相关配置代码如下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}

Webpack 启动后,在读取配置的过程中会先执行 new BasicPlugin(options) 初始化一个 BasicPlugin 获得其实例。 在初始化 compiler 对象后,再调用 basicPlugin.apply(compiler) 给插件实例传入 compiler 对象。 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件。 并且可以通过 compiler 对象去操作 Webpack。

通过以上最简单的 Plugin 相信你大概明白了 Plugin 的工作原理,但实际开发中还有很多细节需要注意,下面来详细介绍。

CompilerCompilation

在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:

  • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 optionsloadersplugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
  • Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。

事件流

Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:

/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);

/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(params) {

});

同理,compilation.applycompilation.plugin 使用方法和上面一致。

在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。

在开发插件时,还需要注意以下两点:

  • 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
  • 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
  • 有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
 compiler.plugin('emit',function(compilation, callback) {
    // 支持处理逻辑

    // 处理完毕后执行 callback 以通知 Webpack 
    // 如果不执行 callback,运行流程将会一直卡在这不往下执行 
    callback();
  });

常用 API

插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。

emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:

<p data-height="585" data-theme-id="0" data-slug-hash="RJwjPj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="emit" class="codepen">See the Pen emit by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

监听文件变化

Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。

在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:

<p data-height="255" data-theme-id="0" data-slug-hash="jKOabJ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Compilation" class="codepen">See the Pen Compilation by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
    compilation.fileDependencies.push(filePath);
    callback();
});

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。

所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。

设置 compilation.assets 的代码如下:

compiler.plugin('emit', (compilation, callback) => {
  // 设置名称为 fileName 的输出资源
  compilation.assets[fileName] = {
    // 返回文件内容
    source: () => {
      // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
      return fileContent;
      },
    // 返回文件大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});

读取 compilation.assets 的代码如下:


compiler.plugin('emit', (compilation, callback) => {
  // 读取名称为 fileName 的输出资源
  const asset = compilation.assets[fileName];
  // 获取输出资源的内容
  asset.source();
  // 获取输出资源的文件大小
  asset.size();
  callback();
});

判断 Webpack 使用了哪些插件

在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:

// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使用的插件列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}

实战

下面我们举一个实际的例子,带你一步步去实现一个插件。

该插件的名称取名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;
    new EndWebpackPlugin(() => {
      // Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
    }, (err) => {
      // Webpack 构建失败,err 是导致错误的原因
      console.error(err);        
    })
  ]
}

要实现该插件,需要借助两个事件:

  • done:在成功构建并且输出了文件后,Webpack 即将退出时发生;
  • failed:在构建出现异常导致构建失败,Webpack 即将退出时发生;

实现该插件非常简单,完整代码如下:

class EndWebpackPlugin {

  constructor(doneCallback, failCallback) {
    // 存下在构造函数中传入的回调函数
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.plugin('done', (stats) => {
        // 在 done 事件中回调 doneCallback
        this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
        // 在 failed 事件中回调 failCallback
        this.failCallback(err);
    });
  }
}
// 导出插件 
module.exports = EndWebpackPlugin;

从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。 在 工作原理概括 中详细介绍过 Webpack 在运行过程中广播出常用事件,你可以从中找到你需要的事件。

调试 Webpack

在编写 Webpack 的 Plugin 和 Loader 时,可能执行结果会和你预期的不一样,就和你平时写代码遇到了奇怪的 Bug 一样。 对于无法一眼看出问题的 Bug,通常需要调试程序源码才能找出问题所在。

虽然可以通过 console.log 的方式完成调试,但这种方法非常不方便也不优雅,本节将教你如何断点调试 工作原理概括 中的插件代码。 由于 Webpack 运行在 Node.js 之上,调试 Webpack 就相对于调试 Node.js 程序。

在 Webstorm 中调试

Webstorm 集成了 Node.js 的调试工具,因此使用 Webstorm 调试 Webpack 非常简单。

1. 设置断点

在你认为可能出现问题的地方设下断点,点击编辑区代码左侧出现红点表示设置了断点。

2. 配置执行入口

告诉 Webstorm 如何启动 Webpack,由于 Webpack 实际上就是一个 Node.js 应用,因此需要新建一个 Node.js 类型的执行入口。

以上配置中有三点需要注意:

  • Name 设置成了 debug webpack,就像设置了一个别名,方便记忆和区分;
  • Working directory 设置为需要调试的插件所在的项目的根目录;
  • JavaScript file 即 Node.js 的执行入口文件,设置为 Webpack 的执行入口文件 node_modules/webpack/bin/webpack.js

3. 启动调试

经过以上两步,准备工作已经完成,下面启动调试,启动时选中前面设置的 debug webpack

4. 执行到断点

启动后程序就会停在断点所在的位置,在这里你可以方便的查看变量当前的状态,找出问题。

原理总结

Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。

对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。

Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。

通过本章的学习,希望你不仅能学会如何编写 Webpack 扩展,也能从中领悟到如何设计好的系统架构。

查看原文

小贼先生 赞了文章 · 2020-12-21

webpack原理

webpack原理

查看所有文档页面:前端开发文档,获取更多信息。
原文链接:webpack原理,原文广告模态框遮挡,阅读体验不好,所以整理成本文,方便查找。

工作原理概括

基本概念

在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

流程概括

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

流程细节

Webpack 的构建流程可以分为以下三大阶段:

  1. 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
  2. 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  3. 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用,下面来一一介绍。

初始化阶段

事件名解释
初始化参数从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句 new Plugin()
实例化 Compiler用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
加载插件依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API。
environment开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。
after-plugins调用完所有内置的和配置的插件的 apply 方法。
after-resolvers根据配置初始化完 resolverresolver 负责在文件系统中寻找指定路径的文件。
空格空格
空格空格
空格空格

编译阶段

事件名解释
run启动一次新的编译。
watch-runrun 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
compile该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
compilationWebpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
after-compile一次 Compilation 执行完成。
invalid当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。
空格空格
空格空格

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:

事件名解释
build-module使用对应的 Loader 去转换一个模块。
normal-module-loader在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。
program从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk。

输出阶段

事件名解释
should-emit所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。
emit确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
after-emit文件输出完毕。
done成功完成一次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。

输出文件分析

虽然在前面的章节中你学会了如何使用 Webpack ,也大致知道其工作原理,可是你想过 Webpack 输出的 bundle.js 是什么样子的吗? 为什么原来一个个的模块文件被合并成了一个单独的文件?为什么 bundle.js 能直接运行在浏览器中? 本节将解释清楚以上问题。

先来看看由 安装与使用 中最简单的项目构建出的 bundle.js 文件内容,代码如下:

<p data-height="565" data-theme-id="0" data-slug-hash="NMQzxz" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上看上去复杂的代码其实是一个立即执行函数,可以简写为如下:

(function(modules) {

  // 模拟 require 语句
  function __webpack_require__() {
  }

  // 执行存放所有模块数组中的第0个模块
  __webpack_require__(0);

})([/*存放所有模块的数组*/])

bundle.js 能直接运行在浏览器中的原因在于输出的文件中通过 __webpack_require__ 函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require 语句。

原来一个个独立的模块文件被合并到了一个单独的 bundle.js 的原因在于浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。

如果仔细分析 __webpack_require__ 函数的实现,你还有发现 Webpack 做了缓存优化: 执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。

分割代码时的输出

例如把源码中的 main.js 修改为如下:

// 异步加载 show.js
import('./show').then((show) => {
  // 执行 show 函数
  show('Webpack');
});

重新构建后会输出两个文件,分别是执行入口文件 bundle.js 和 异步加载文件 0.bundle.js

其中 0.bundle.js 内容如下:

// 加载在本文件(0.bundle.js)中包含的模块
webpackJsonp(
  // 在其它文件中存放着的模块的 ID
  [0],
  // 本文件所包含的模块
  [
    // show.js 所对应的模块
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);

bundle.js 内容如下:

<p data-height="565" data-theme-id="0" data-slug-hash="yjmRyG" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

这里的 bundle.js 和上面所讲的 bundle.js 非常相似,区别在于:

  • 多了一个 __webpack_require__.e 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件;
  • 多了一个 webpackJsonp 函数用于从异步加载的文件中安装模块。

在使用了 CommonsChunkPlugin 去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 __webpack_require__.ewebpackJsonp。 原因在于提取公共代码和异步加载本质上都是代码分割。

编写 Loader

Loader 就像是一个翻译员,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个翻译员翻译。

以处理 SCSS 文件为例:

  • SCSS 源代码会先交给 sass-loader 把 SCSS 转换成 CSS;
  • sass-loader 输出的 CSS 交给 css-loader 处理,找出 CSS 中依赖的资源、压缩 CSS 等;
  • css-loader 输出的 CSS 交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码;

可以看出以上的处理过程需要有顺序的链式执行,先 sass-loadercss-loaderstyle-loader。 以上处理的 Webpack 相关配置如下:

<p data-height="365" data-theme-id="0" data-slug-hash="YLmbeQ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="编写 Loader" class="codepen">See the Pen 编写 Loader by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

Loader 的职责

由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。

Loader 基础

由于 Webpack 是运行在 Node.js 之上的,一个 Loader 其实就是一个 Node.js 模块,这个模块需要导出一个函数。 这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。

一个最简单的 Loader 的源码如下:

module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的一个文件的原内容
  // 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};

由于 Loader 运行在 Node.js 中,你可以调用任何 Node.js 自带的 API,或者安装第三方模块进行调用:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};

Loader 进阶

以上只是个最简单的 Loader,Webpack 还提供一些 API 供 Loader 调用,下面来一一介绍。

获得 Loader 的 options

在最上面处理 SCSS 文件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在自己编写的 Loader 中获取到用户传入的 options 呢?需要这样做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 获取到用户给当前 Loader 传入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};

返回其它结果

上面的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东西。

例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。 为了把 Source Map 也一起随着 ES5 代码返回给 Webpack,可以这样写:

module.exports = function(source) {
  // 通过 this.callback 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps);
  // 当你使用 this.callback 返回内容时,该 Loader 必须返回 undefined,
  // 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,而不是 return 中 
  return;
};

其中的 this.callback 是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。 this.callback 的详细使用方法如下:

this.callback(
    // 当无法转换原内容时,给 Webpack 返回一个 Error
    err: Error | null,
    // 原内容转换后的内容
    content: string | Buffer,
    // 用于把转换后的内容得出原内容的 Source Map,方便调试
    sourceMap?: SourceMap,
    // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
    // 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
    abstractSyntaxTree?: AST
);
Source Map 的生成很耗时,通常在开发环境下才会生成 Source Map,其它环境下不用生成,以加速构建。 为此 Webpack 为 Loader 提供了 this.sourceMap API 去告诉 Loader 当前构建环境下用户是否需要 Source Map。 如果你编写的 Loader 会生成 Source Map,请考虑到这点。

同步与异步

Loader 有同步和异步之分,上面介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。

在转换步骤是异步时,你可以这样:

module.exports = function(source) {
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通过 callback 返回异步执行后的结果
        callback(err, result, sourceMaps, ast);
    });
};

处理二进制数据

在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。 为此,你需要这样编写 Loader:

module.exports = function(source) {
    // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
    source instanceof Buffer === true;
    // Loader 返回的类型也可以是 Buffer 类型的
    // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
    return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据 
module.exports.raw = true;

以上代码中最关键的代码是最后一行 module.exports.raw = true;,没有该行 Loader 只能拿到字符串。

缓存加速

在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时, 是不会重新调用对应的 Loader 去执行转换操作的。

如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:

module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};

其它 Loader API

除了以上提到的在 Loader 中能调用的 Webpack API 外,还存在以下常用 API:

  • this.context:当前处理文件的所在目录,假如当前 Loader 处理的文件是 /src/main.js,则 this.context 就等于 /src
  • this.resource:当前处理文件的完整请求路径,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:当前处理文件的路径,例如 /src/main.js
  • this.resourceQuery:当前处理文件的 querystring
  • this.target:等于 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去获得 request 对应文件的处理结果。
  • this.resolve:像 require 语句一样获得指定文件的完整路径,使用方法为 resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为 addDependency(file: string)
  • this.addContextDependency:和 addDependency 类似,但 addContextDependency 是把整个目录加入到当前正在处理文件的依赖中。使用方法为 addContextDependency(directory: string)
  • this.clearDependencies:清除当前正在处理文件的所有依赖,使用方法为 clearDependencies()
  • this.emitFile:输出一个文件,使用方法为 emitFile(name: string, content: Buffer|string, sourceMap: {...})

加载本地 Loader

在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 在前面的章节中,使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};

如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules 目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。

解决以上问题的便捷方法有两种,分别如下:

Npm link

Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules 目录下,让项目可以直接使用本地的 Npm 模块。 由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。

完成 Npm link 的步骤如下:

  • 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的 package.json 已经正确配置好;
  • 在本地 Npm 模块根目录下执行 npm link,把本地模块注册到全局;
  • 在项目根目录下执行 npm link loader-name,把第2步注册到全局的本地 Npm 模块链接到项目的 node_moduels 下,其中的 loader-name 是指在第1步中的 package.json 文件中配置的模块名称。

链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用于配置 Webpack 如何寻找 Loader。 默认情况下只会去 node_modules 目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在项目目录中的 ./loaders/loader-name 中,则需要如下配置:


module.exports = {
  resolveLoader:{
    // 去哪些目录下寻找 Loader,有先后顺序之分
    modules: ['node_modules','./loaders/'],
  }
}

加上以上配置后, Webpack 会先去 node_modules 项目下寻找 Loader,如果找不到,会再去 ./loaders/ 目录下寻找。

实战

上面讲了许多理论,接下来从实际出发,来编写一个解决实际问题的 Loader。

该 Loader 名叫 comment-require-loader,作用是把 JavaScript 代码中的注释语法:

// @require '../style/index.css'

转换成:

require('../style/index.css');

该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。

该 Loader 的使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};

该 Loader 的实现非常简单,完整代码如下:

function replace(source) {
    // 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}

module.exports = function (content) {
    return replace(content);
};

编写 Plugin

Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

一个最基础的 Plugin 的代码是这样的:

class BasicPlugin{
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options){
  }

  // Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler){
    compiler.plugin('compilation',function(compilation) {
    })
  }
}

// 导出 Plugin
module.exports = BasicPlugin;

在使用这个 Plugin 时,相关配置代码如下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}

Webpack 启动后,在读取配置的过程中会先执行 new BasicPlugin(options) 初始化一个 BasicPlugin 获得其实例。 在初始化 compiler 对象后,再调用 basicPlugin.apply(compiler) 给插件实例传入 compiler 对象。 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件。 并且可以通过 compiler 对象去操作 Webpack。

通过以上最简单的 Plugin 相信你大概明白了 Plugin 的工作原理,但实际开发中还有很多细节需要注意,下面来详细介绍。

CompilerCompilation

在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:

  • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 optionsloadersplugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
  • Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。

事件流

Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:

/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);

/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(params) {

});

同理,compilation.applycompilation.plugin 使用方法和上面一致。

在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。

在开发插件时,还需要注意以下两点:

  • 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
  • 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
  • 有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
 compiler.plugin('emit',function(compilation, callback) {
    // 支持处理逻辑

    // 处理完毕后执行 callback 以通知 Webpack 
    // 如果不执行 callback,运行流程将会一直卡在这不往下执行 
    callback();
  });

常用 API

插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。

emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:

<p data-height="585" data-theme-id="0" data-slug-hash="RJwjPj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="emit" class="codepen">See the Pen emit by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

监听文件变化

Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。

在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:

<p data-height="255" data-theme-id="0" data-slug-hash="jKOabJ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Compilation" class="codepen">See the Pen Compilation by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
    compilation.fileDependencies.push(filePath);
    callback();
});

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。

所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。

设置 compilation.assets 的代码如下:

compiler.plugin('emit', (compilation, callback) => {
  // 设置名称为 fileName 的输出资源
  compilation.assets[fileName] = {
    // 返回文件内容
    source: () => {
      // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
      return fileContent;
      },
    // 返回文件大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});

读取 compilation.assets 的代码如下:


compiler.plugin('emit', (compilation, callback) => {
  // 读取名称为 fileName 的输出资源
  const asset = compilation.assets[fileName];
  // 获取输出资源的内容
  asset.source();
  // 获取输出资源的文件大小
  asset.size();
  callback();
});

判断 Webpack 使用了哪些插件

在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:

// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使用的插件列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}

实战

下面我们举一个实际的例子,带你一步步去实现一个插件。

该插件的名称取名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;
    new EndWebpackPlugin(() => {
      // Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
    }, (err) => {
      // Webpack 构建失败,err 是导致错误的原因
      console.error(err);        
    })
  ]
}

要实现该插件,需要借助两个事件:

  • done:在成功构建并且输出了文件后,Webpack 即将退出时发生;
  • failed:在构建出现异常导致构建失败,Webpack 即将退出时发生;

实现该插件非常简单,完整代码如下:

class EndWebpackPlugin {

  constructor(doneCallback, failCallback) {
    // 存下在构造函数中传入的回调函数
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.plugin('done', (stats) => {
        // 在 done 事件中回调 doneCallback
        this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
        // 在 failed 事件中回调 failCallback
        this.failCallback(err);
    });
  }
}
// 导出插件 
module.exports = EndWebpackPlugin;

从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。 在 工作原理概括 中详细介绍过 Webpack 在运行过程中广播出常用事件,你可以从中找到你需要的事件。

调试 Webpack

在编写 Webpack 的 Plugin 和 Loader 时,可能执行结果会和你预期的不一样,就和你平时写代码遇到了奇怪的 Bug 一样。 对于无法一眼看出问题的 Bug,通常需要调试程序源码才能找出问题所在。

虽然可以通过 console.log 的方式完成调试,但这种方法非常不方便也不优雅,本节将教你如何断点调试 工作原理概括 中的插件代码。 由于 Webpack 运行在 Node.js 之上,调试 Webpack 就相对于调试 Node.js 程序。

在 Webstorm 中调试

Webstorm 集成了 Node.js 的调试工具,因此使用 Webstorm 调试 Webpack 非常简单。

1. 设置断点

在你认为可能出现问题的地方设下断点,点击编辑区代码左侧出现红点表示设置了断点。

2. 配置执行入口

告诉 Webstorm 如何启动 Webpack,由于 Webpack 实际上就是一个 Node.js 应用,因此需要新建一个 Node.js 类型的执行入口。

以上配置中有三点需要注意:

  • Name 设置成了 debug webpack,就像设置了一个别名,方便记忆和区分;
  • Working directory 设置为需要调试的插件所在的项目的根目录;
  • JavaScript file 即 Node.js 的执行入口文件,设置为 Webpack 的执行入口文件 node_modules/webpack/bin/webpack.js

3. 启动调试

经过以上两步,准备工作已经完成,下面启动调试,启动时选中前面设置的 debug webpack

4. 执行到断点

启动后程序就会停在断点所在的位置,在这里你可以方便的查看变量当前的状态,找出问题。

原理总结

Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。

对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。

Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。

通过本章的学习,希望你不仅能学会如何编写 Webpack 扩展,也能从中领悟到如何设计好的系统架构。

查看原文

赞 285 收藏 334 评论 21

小贼先生 收藏了文章 · 2020-12-03

基于 React Redux 的错误处理

本文主要分为以下三个部分:

  • Error 的分类
  • 分步骤详细讲解如何利用 Redux 统一处理 Error
  • 错误信息的收集

本文的案例使用的技术栈包括: ReactReduxTypeScriptAxiosLodash

Error 的分类

HTTP 请求错误

HTTP 请求错误通常可以归为以下几类:

服务器有响应的错误

服务器有响应,表示服务器响应了,并且返回了相应的错误信息

如果你不期望每一个请求都显示服务器返回的特定错误信息,还可以根据 HTTP Status Code 对错误信息进一步归类:

  • 4xx客户端错误: 表示客户端发生了错误,妨碍了服务器的处理。比如:

    • 400 Bad Request
    • 401 Unauthorized
    • 403 Forbidden
    • 404 Not Found
    • 408 Request Timeout
    • 409 Conflict
  • 5xx服务器错误: 表示服务器无法完成合法的请求。可能是服务器在处理请求的过程中有错误或者异常状态发生。比如:

    • 500 Internal Server Error
    • 501 Not Implemented
    • 503 Service Unavailable

服务器无响应的错误

服务器无响应,表示请求发起了,但是服务器没有响应

这种情况可能是因为网络故障(无网/弱网),或着跨域请求被拒绝(生产环境通常不会有跨域的情况出现,所以这个错误一般不用考虑)。如果你使用的 HTTP Client 没有返回错误信息,可以考虑显示一个通用错误信息(General Error Message)。

应用程序错误

代码错误

通常是由于 JS 代码编写错误,导致 JavaScript 引擎无法正确执行,从而报错。这一类错误在生产环境一般不会出现,因此可以根据业务需求决定是否处理这一类错误。常见的有:

  • SyntaxError语法错误
  • ReferenceError引用错误
  • TypeError类型错误

Throw Error

应用中根据业务需求而 Throw 的 Error。

Redux 中的 Error 处理

在上面的章节中我们已经对应用中的 Error 进行了分类。 利用 Redux 我们可以对 HTTP Request Error 进行统一的处理。

Step1: 裂变 HTTP Request Action

在进行 HTTP 请求的时候,我们通常会发起一个 Action。如果将请求成功和失败的状态裂变成两个 Action,RequestSuccessActionRequestFailedAction,那么通过 RequestFailedAction,就能够对所有 HTTP 请求的错误进行统一处理。

requestMiddleware.ts

export const requestMiddleware: any = (client: AxiosInstance) => {
  return ({ dispatch }: MiddlewareAPI<any>) =>
    (next: Dispatch<any>) =>
      (action: IRequestAction) => {
        if (isRequestAction(action)) {
          dispatch(createReqStartAction(action));
          return client.request(action.payload)
            .then((response: AxiosResponse) => {
              return dispatch(createSuccessAction(action, response));
            })
            .catch((error: AxiosError) => {
              return dispatch(createFailedAction(action, error, action.meta.omitError));
            });
        }
        return next(action);
      };
};

Step2: 创建 errorMiddleware,将 Error 转化为 Notification Action

将 HTTP 请求的失败状态转化成 RequestFailedAction 之后,我们需要写一个 Middleware 来处理它。

这里有人可能会问了,既然已经有 RequestFailedAction 了,还需要 Middleware 吗?能不能直接在 Reducer 中去处理它?其实也是可以的。但是写在 Reducer 里面,同一个 Action 修改了多个 State 节点,会导致代码耦合度增加,所以在这里我们还是使用 Middleware 的方式来处理。思路如下:

  1. 如果 Action 是一个 RequestFailedAction,那么根据错误的分类,将错误的类型和信息存储到 addNotificationAction 中。在这里我们并不需要将所有的错误信息都存起来,因为 UI 只关心 Error 的类型和信息。
  2. 根据 Error 的分类,Dispatch 带有不同 Error Type 和 Error Message 的 Action。
  3. 创建 createNotification 函数,生成一个带有 UUID 的 Notification,以便删除时使用。因为 notification 可能不止一个。
  4. 通过 Dispatch removeNotificationAction 来移除 Notification。
export interface INotification {
  [UUID: number]: {
    type: string;
    msg: string;
  };
}

const createNotification = ({ type, msg }: { type: string; msg: string }): INotification => {
  const id = new Date().getTime();
  return {
    [id]: {
      type,
      msg,
    },
  };
};

完整代码如下:

errorMiddleware.ts

import { AnyAction, Dispatch, MiddlewareAPI } from "redux";
import { isRequestFailedAction } from "../request";
import {
  addNotification,
  INotification,
} from "./notificationActions";

export enum ErrorMessages {
  GENERAL_ERROR = "Something went wrong, please try again later!",
}

enum ErrorTypes {
  GENERAL_ERROR = "GENERAL_ERROR",
}

export const createNotification = ({ type, msg }: { type: string; msg: string }): INotification => {
  const id = new Date().getTime();
  return {
    [id]: {
      type,
      msg,
    },
  };
};

export const errorMiddleware = ({ dispatch }: MiddlewareAPI) => {
  return (next: Dispatch<AnyAction>) => {
    return (action: AnyAction) => {
      if (isRequestFailedAction(action)) {
        const error = action.payload;
        if (error.response) {
          dispatch(
            addNotification(
              createNotification({
                type: error.response.error,
                msg: error.response.data.message,
              }),
            ),
          );
        } else {
          dispatch(
            addNotification(
              createNotification({
                type: ErrorTypes.GENERAL_ERROR,
                msg: ErrorMessages.GENERAL_ERROR,
              }),
            ),
          );
        }
      }
      return next(action);
    };
  };
};

notificationActions.ts

import { createAction } from "redux-actions";

export interface INotification {
  [UUID: number]: {
    type: string;
    msg: string;
  };
}

export const addNotification = createAction(
  "@@notification/addNotification",
  (notification: INotification) => notification,
);

export const removeNotification = createAction("@@notification/removeNotification", (id: number) => id);

export const clearNotifications = createAction("@@notification/clearNotifications");
服务器需要保证每一个 HTTP Reqeust 都有相应的 Error Message,不然前端就只能根据 4xx 或者 5xx 这种粗略的分类来显示 Error Message。

Step3: 处理 Notification Action

notificationsReducer.ts

import { omit } from "lodash";
import { Action, handleActions } from "redux-actions";
import { addNotification, clearNotifications, removeNotification } from "./notificationActions";

export const notificationsReducer = handleActions(
  {
    [`${addNotification}`]: (state, action: Action<any>) => {
      return {
        ...state,
        ...action.payload,
      };
    },
    [`${removeNotification}`]: (state, action: Action<any>) => {
      return omit(state, action.payload);
    },
    [`${clearNotifications}`]: () => {
      return {};
    },
  },
  {},
);

Step4: 从 Store 中获取 Notification,并通过 React Child Render 提供给子组件。

这一步就很简单了,从 Store 中拿到 Notifications,然后通过 React Child Render 将它提供给子组件,子组件就可以根据它去显示 UI 了。

WithNotifications.tsx

import { isEmpty } from "lodash";
import * as React from "react";
import {
  connect,
  DispatchProp,
} from "react-redux";
import {
  clearNotifications,
  INotification,
} from "./notificationActions";

interface IWithNotificationsCoreInnerProps {
  notifications: INotification;
}

interface IWithNotificationsCoreProps extends DispatchProp {
  notifications: INotification;
  children: (props: IWithNotificationsCoreInnerProps) => React.ReactNode;
}

class WithNotificationsCore extends React.Component<IWithNotificationsCoreProps> {
  componentWillUnmount() {
    this.props.dispatch(clearNotifications());
  }

  render() {
    if (isEmpty(this.props.notifications)) {
      return null;
    }

    return this.props.children({
      notifications: this.props.notifications,
    });
  }
}

const mapStateToProps = (state: any) => {
  return {
    notifications: state.notifications,
  };
};

export const WithNotifications = connect(mapStateToProps)(WithNotificationsCore);

Step5: 显示 Error Messages

因为 Notification 是一个通用的组件,所以我们一般会把它放到根组件 (Root) 上。

<WithNotifications>
  {({ notifications }) => (
    <>
      {map(notifications, (notification: { type: string; msg: string }, id: number) => {
        return (
          <div>
            {notification.msg} // 将你的 Notification 组件放到这里            
            {id} // 你可以用 id 去删除对应的 Notification
          </div>
        );
      })}
    </>
  )}
</WithNotifications>

Step6: 添加白名单

当然,并不是所有的 API 请求出错我们都需要通知给用户。这时候你就需要加一个白名单了,如果在这个白名单内,则不将错误信息通知给用户。可以考虑在 Requst Action 的 Meta 中加一个 omitError 的 flag,当有这个 flag 的时候,则不进行通知。让我们修改一下 errorMiddleware,如下:

errorMiddleware.ts

export const errorMiddleware = ({ dispatch }: MiddlewareAPI) => {
  return (next: Dispatch<AnyAction>) => {
    return (action: AnyAction) => {
    const shouldOmitError = get(action, "meta.omitError", false);
      if (isRequestFailedAction(action) && !shouldOmitError) {
        const error = action.payload;
        if (error.response) {
          // same as before
        } else {
          // same as before
      }
      return next(action);
    };
  };
};

Step7: 测试

在测试 errorMiddleware 的时候,可能会遇到一个问题,就是我们的 Notification 是根据一个以时间戳为 key 的对象,时间戳是根据当前时间生成的,每次跑测试时都会发生变化,如何解决呢?Mock getTime 方法就好啦。如下:

 beforeEach(() => {
    class MockDate {
      getTime() {
        return 123456;
      }
    }

    global.Date = MockDate as any;
  });

  afterEach(() => {
    global.Date = Date;
  });

错误信息的收集

componentDidCatch

利用 React componentDidCatch 生命周期方法将错误信息收集到 Error Reporting 服务。这个方法有点像 JS 的 catch{},只不过是针对组件的。大多数时候我们希望 ErrorBoundary 组件贯穿我们的整个应用,所以一般会将它放在根节点上 (Root)。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
注意:对 ErrorBoundary 组件来说,它只会捕获在它之下的组件,它不会捕获自身组件内部的错误。
查看原文

小贼先生 收藏了文章 · 2020-12-03

基于 React Redux 的错误处理

本文主要分为以下三个部分:

  • Error 的分类
  • 分步骤详细讲解如何利用 Redux 统一处理 Error
  • 错误信息的收集

本文的案例使用的技术栈包括: ReactReduxTypeScriptAxiosLodash

Error 的分类

HTTP 请求错误

HTTP 请求错误通常可以归为以下几类:

服务器有响应的错误

服务器有响应,表示服务器响应了,并且返回了相应的错误信息

如果你不期望每一个请求都显示服务器返回的特定错误信息,还可以根据 HTTP Status Code 对错误信息进一步归类:

  • 4xx客户端错误: 表示客户端发生了错误,妨碍了服务器的处理。比如:

    • 400 Bad Request
    • 401 Unauthorized
    • 403 Forbidden
    • 404 Not Found
    • 408 Request Timeout
    • 409 Conflict
  • 5xx服务器错误: 表示服务器无法完成合法的请求。可能是服务器在处理请求的过程中有错误或者异常状态发生。比如:

    • 500 Internal Server Error
    • 501 Not Implemented
    • 503 Service Unavailable

服务器无响应的错误

服务器无响应,表示请求发起了,但是服务器没有响应

这种情况可能是因为网络故障(无网/弱网),或着跨域请求被拒绝(生产环境通常不会有跨域的情况出现,所以这个错误一般不用考虑)。如果你使用的 HTTP Client 没有返回错误信息,可以考虑显示一个通用错误信息(General Error Message)。

应用程序错误

代码错误

通常是由于 JS 代码编写错误,导致 JavaScript 引擎无法正确执行,从而报错。这一类错误在生产环境一般不会出现,因此可以根据业务需求决定是否处理这一类错误。常见的有:

  • SyntaxError语法错误
  • ReferenceError引用错误
  • TypeError类型错误

Throw Error

应用中根据业务需求而 Throw 的 Error。

Redux 中的 Error 处理

在上面的章节中我们已经对应用中的 Error 进行了分类。 利用 Redux 我们可以对 HTTP Request Error 进行统一的处理。

Step1: 裂变 HTTP Request Action

在进行 HTTP 请求的时候,我们通常会发起一个 Action。如果将请求成功和失败的状态裂变成两个 Action,RequestSuccessActionRequestFailedAction,那么通过 RequestFailedAction,就能够对所有 HTTP 请求的错误进行统一处理。

requestMiddleware.ts

export const requestMiddleware: any = (client: AxiosInstance) => {
  return ({ dispatch }: MiddlewareAPI<any>) =>
    (next: Dispatch<any>) =>
      (action: IRequestAction) => {
        if (isRequestAction(action)) {
          dispatch(createReqStartAction(action));
          return client.request(action.payload)
            .then((response: AxiosResponse) => {
              return dispatch(createSuccessAction(action, response));
            })
            .catch((error: AxiosError) => {
              return dispatch(createFailedAction(action, error, action.meta.omitError));
            });
        }
        return next(action);
      };
};

Step2: 创建 errorMiddleware,将 Error 转化为 Notification Action

将 HTTP 请求的失败状态转化成 RequestFailedAction 之后,我们需要写一个 Middleware 来处理它。

这里有人可能会问了,既然已经有 RequestFailedAction 了,还需要 Middleware 吗?能不能直接在 Reducer 中去处理它?其实也是可以的。但是写在 Reducer 里面,同一个 Action 修改了多个 State 节点,会导致代码耦合度增加,所以在这里我们还是使用 Middleware 的方式来处理。思路如下:

  1. 如果 Action 是一个 RequestFailedAction,那么根据错误的分类,将错误的类型和信息存储到 addNotificationAction 中。在这里我们并不需要将所有的错误信息都存起来,因为 UI 只关心 Error 的类型和信息。
  2. 根据 Error 的分类,Dispatch 带有不同 Error Type 和 Error Message 的 Action。
  3. 创建 createNotification 函数,生成一个带有 UUID 的 Notification,以便删除时使用。因为 notification 可能不止一个。
  4. 通过 Dispatch removeNotificationAction 来移除 Notification。
export interface INotification {
  [UUID: number]: {
    type: string;
    msg: string;
  };
}

const createNotification = ({ type, msg }: { type: string; msg: string }): INotification => {
  const id = new Date().getTime();
  return {
    [id]: {
      type,
      msg,
    },
  };
};

完整代码如下:

errorMiddleware.ts

import { AnyAction, Dispatch, MiddlewareAPI } from "redux";
import { isRequestFailedAction } from "../request";
import {
  addNotification,
  INotification,
} from "./notificationActions";

export enum ErrorMessages {
  GENERAL_ERROR = "Something went wrong, please try again later!",
}

enum ErrorTypes {
  GENERAL_ERROR = "GENERAL_ERROR",
}

export const createNotification = ({ type, msg }: { type: string; msg: string }): INotification => {
  const id = new Date().getTime();
  return {
    [id]: {
      type,
      msg,
    },
  };
};

export const errorMiddleware = ({ dispatch }: MiddlewareAPI) => {
  return (next: Dispatch<AnyAction>) => {
    return (action: AnyAction) => {
      if (isRequestFailedAction(action)) {
        const error = action.payload;
        if (error.response) {
          dispatch(
            addNotification(
              createNotification({
                type: error.response.error,
                msg: error.response.data.message,
              }),
            ),
          );
        } else {
          dispatch(
            addNotification(
              createNotification({
                type: ErrorTypes.GENERAL_ERROR,
                msg: ErrorMessages.GENERAL_ERROR,
              }),
            ),
          );
        }
      }
      return next(action);
    };
  };
};

notificationActions.ts

import { createAction } from "redux-actions";

export interface INotification {
  [UUID: number]: {
    type: string;
    msg: string;
  };
}

export const addNotification = createAction(
  "@@notification/addNotification",
  (notification: INotification) => notification,
);

export const removeNotification = createAction("@@notification/removeNotification", (id: number) => id);

export const clearNotifications = createAction("@@notification/clearNotifications");
服务器需要保证每一个 HTTP Reqeust 都有相应的 Error Message,不然前端就只能根据 4xx 或者 5xx 这种粗略的分类来显示 Error Message。

Step3: 处理 Notification Action

notificationsReducer.ts

import { omit } from "lodash";
import { Action, handleActions } from "redux-actions";
import { addNotification, clearNotifications, removeNotification } from "./notificationActions";

export const notificationsReducer = handleActions(
  {
    [`${addNotification}`]: (state, action: Action<any>) => {
      return {
        ...state,
        ...action.payload,
      };
    },
    [`${removeNotification}`]: (state, action: Action<any>) => {
      return omit(state, action.payload);
    },
    [`${clearNotifications}`]: () => {
      return {};
    },
  },
  {},
);

Step4: 从 Store 中获取 Notification,并通过 React Child Render 提供给子组件。

这一步就很简单了,从 Store 中拿到 Notifications,然后通过 React Child Render 将它提供给子组件,子组件就可以根据它去显示 UI 了。

WithNotifications.tsx

import { isEmpty } from "lodash";
import * as React from "react";
import {
  connect,
  DispatchProp,
} from "react-redux";
import {
  clearNotifications,
  INotification,
} from "./notificationActions";

interface IWithNotificationsCoreInnerProps {
  notifications: INotification;
}

interface IWithNotificationsCoreProps extends DispatchProp {
  notifications: INotification;
  children: (props: IWithNotificationsCoreInnerProps) => React.ReactNode;
}

class WithNotificationsCore extends React.Component<IWithNotificationsCoreProps> {
  componentWillUnmount() {
    this.props.dispatch(clearNotifications());
  }

  render() {
    if (isEmpty(this.props.notifications)) {
      return null;
    }

    return this.props.children({
      notifications: this.props.notifications,
    });
  }
}

const mapStateToProps = (state: any) => {
  return {
    notifications: state.notifications,
  };
};

export const WithNotifications = connect(mapStateToProps)(WithNotificationsCore);

Step5: 显示 Error Messages

因为 Notification 是一个通用的组件,所以我们一般会把它放到根组件 (Root) 上。

<WithNotifications>
  {({ notifications }) => (
    <>
      {map(notifications, (notification: { type: string; msg: string }, id: number) => {
        return (
          <div>
            {notification.msg} // 将你的 Notification 组件放到这里            
            {id} // 你可以用 id 去删除对应的 Notification
          </div>
        );
      })}
    </>
  )}
</WithNotifications>

Step6: 添加白名单

当然,并不是所有的 API 请求出错我们都需要通知给用户。这时候你就需要加一个白名单了,如果在这个白名单内,则不将错误信息通知给用户。可以考虑在 Requst Action 的 Meta 中加一个 omitError 的 flag,当有这个 flag 的时候,则不进行通知。让我们修改一下 errorMiddleware,如下:

errorMiddleware.ts

export const errorMiddleware = ({ dispatch }: MiddlewareAPI) => {
  return (next: Dispatch<AnyAction>) => {
    return (action: AnyAction) => {
    const shouldOmitError = get(action, "meta.omitError", false);
      if (isRequestFailedAction(action) && !shouldOmitError) {
        const error = action.payload;
        if (error.response) {
          // same as before
        } else {
          // same as before
      }
      return next(action);
    };
  };
};

Step7: 测试

在测试 errorMiddleware 的时候,可能会遇到一个问题,就是我们的 Notification 是根据一个以时间戳为 key 的对象,时间戳是根据当前时间生成的,每次跑测试时都会发生变化,如何解决呢?Mock getTime 方法就好啦。如下:

 beforeEach(() => {
    class MockDate {
      getTime() {
        return 123456;
      }
    }

    global.Date = MockDate as any;
  });

  afterEach(() => {
    global.Date = Date;
  });

错误信息的收集

componentDidCatch

利用 React componentDidCatch 生命周期方法将错误信息收集到 Error Reporting 服务。这个方法有点像 JS 的 catch{},只不过是针对组件的。大多数时候我们希望 ErrorBoundary 组件贯穿我们的整个应用,所以一般会将它放在根节点上 (Root)。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
注意:对 ErrorBoundary 组件来说,它只会捕获在它之下的组件,它不会捕获自身组件内部的错误。
查看原文

小贼先生 收藏了文章 · 2020-12-03

基于 React Redux 的错误处理

本文主要分为以下三个部分:

  • Error 的分类
  • 分步骤详细讲解如何利用 Redux 统一处理 Error
  • 错误信息的收集

本文的案例使用的技术栈包括: ReactReduxTypeScriptAxiosLodash

Error 的分类

HTTP 请求错误

HTTP 请求错误通常可以归为以下几类:

服务器有响应的错误

服务器有响应,表示服务器响应了,并且返回了相应的错误信息

如果你不期望每一个请求都显示服务器返回的特定错误信息,还可以根据 HTTP Status Code 对错误信息进一步归类:

  • 4xx客户端错误: 表示客户端发生了错误,妨碍了服务器的处理。比如:

    • 400 Bad Request
    • 401 Unauthorized
    • 403 Forbidden
    • 404 Not Found
    • 408 Request Timeout
    • 409 Conflict
  • 5xx服务器错误: 表示服务器无法完成合法的请求。可能是服务器在处理请求的过程中有错误或者异常状态发生。比如:

    • 500 Internal Server Error
    • 501 Not Implemented
    • 503 Service Unavailable

服务器无响应的错误

服务器无响应,表示请求发起了,但是服务器没有响应

这种情况可能是因为网络故障(无网/弱网),或着跨域请求被拒绝(生产环境通常不会有跨域的情况出现,所以这个错误一般不用考虑)。如果你使用的 HTTP Client 没有返回错误信息,可以考虑显示一个通用错误信息(General Error Message)。

应用程序错误

代码错误

通常是由于 JS 代码编写错误,导致 JavaScript 引擎无法正确执行,从而报错。这一类错误在生产环境一般不会出现,因此可以根据业务需求决定是否处理这一类错误。常见的有:

  • SyntaxError语法错误
  • ReferenceError引用错误
  • TypeError类型错误

Throw Error

应用中根据业务需求而 Throw 的 Error。

Redux 中的 Error 处理

在上面的章节中我们已经对应用中的 Error 进行了分类。 利用 Redux 我们可以对 HTTP Request Error 进行统一的处理。

Step1: 裂变 HTTP Request Action

在进行 HTTP 请求的时候,我们通常会发起一个 Action。如果将请求成功和失败的状态裂变成两个 Action,RequestSuccessActionRequestFailedAction,那么通过 RequestFailedAction,就能够对所有 HTTP 请求的错误进行统一处理。

requestMiddleware.ts

export const requestMiddleware: any = (client: AxiosInstance) => {
  return ({ dispatch }: MiddlewareAPI<any>) =>
    (next: Dispatch<any>) =>
      (action: IRequestAction) => {
        if (isRequestAction(action)) {
          dispatch(createReqStartAction(action));
          return client.request(action.payload)
            .then((response: AxiosResponse) => {
              return dispatch(createSuccessAction(action, response));
            })
            .catch((error: AxiosError) => {
              return dispatch(createFailedAction(action, error, action.meta.omitError));
            });
        }
        return next(action);
      };
};

Step2: 创建 errorMiddleware,将 Error 转化为 Notification Action

将 HTTP 请求的失败状态转化成 RequestFailedAction 之后,我们需要写一个 Middleware 来处理它。

这里有人可能会问了,既然已经有 RequestFailedAction 了,还需要 Middleware 吗?能不能直接在 Reducer 中去处理它?其实也是可以的。但是写在 Reducer 里面,同一个 Action 修改了多个 State 节点,会导致代码耦合度增加,所以在这里我们还是使用 Middleware 的方式来处理。思路如下:

  1. 如果 Action 是一个 RequestFailedAction,那么根据错误的分类,将错误的类型和信息存储到 addNotificationAction 中。在这里我们并不需要将所有的错误信息都存起来,因为 UI 只关心 Error 的类型和信息。
  2. 根据 Error 的分类,Dispatch 带有不同 Error Type 和 Error Message 的 Action。
  3. 创建 createNotification 函数,生成一个带有 UUID 的 Notification,以便删除时使用。因为 notification 可能不止一个。
  4. 通过 Dispatch removeNotificationAction 来移除 Notification。
export interface INotification {
  [UUID: number]: {
    type: string;
    msg: string;
  };
}

const createNotification = ({ type, msg }: { type: string; msg: string }): INotification => {
  const id = new Date().getTime();
  return {
    [id]: {
      type,
      msg,
    },
  };
};

完整代码如下:

errorMiddleware.ts

import { AnyAction, Dispatch, MiddlewareAPI } from "redux";
import { isRequestFailedAction } from "../request";
import {
  addNotification,
  INotification,
} from "./notificationActions";

export enum ErrorMessages {
  GENERAL_ERROR = "Something went wrong, please try again later!",
}

enum ErrorTypes {
  GENERAL_ERROR = "GENERAL_ERROR",
}

export const createNotification = ({ type, msg }: { type: string; msg: string }): INotification => {
  const id = new Date().getTime();
  return {
    [id]: {
      type,
      msg,
    },
  };
};

export const errorMiddleware = ({ dispatch }: MiddlewareAPI) => {
  return (next: Dispatch<AnyAction>) => {
    return (action: AnyAction) => {
      if (isRequestFailedAction(action)) {
        const error = action.payload;
        if (error.response) {
          dispatch(
            addNotification(
              createNotification({
                type: error.response.error,
                msg: error.response.data.message,
              }),
            ),
          );
        } else {
          dispatch(
            addNotification(
              createNotification({
                type: ErrorTypes.GENERAL_ERROR,
                msg: ErrorMessages.GENERAL_ERROR,
              }),
            ),
          );
        }
      }
      return next(action);
    };
  };
};

notificationActions.ts

import { createAction } from "redux-actions";

export interface INotification {
  [UUID: number]: {
    type: string;
    msg: string;
  };
}

export const addNotification = createAction(
  "@@notification/addNotification",
  (notification: INotification) => notification,
);

export const removeNotification = createAction("@@notification/removeNotification", (id: number) => id);

export const clearNotifications = createAction("@@notification/clearNotifications");
服务器需要保证每一个 HTTP Reqeust 都有相应的 Error Message,不然前端就只能根据 4xx 或者 5xx 这种粗略的分类来显示 Error Message。

Step3: 处理 Notification Action

notificationsReducer.ts

import { omit } from "lodash";
import { Action, handleActions } from "redux-actions";
import { addNotification, clearNotifications, removeNotification } from "./notificationActions";

export const notificationsReducer = handleActions(
  {
    [`${addNotification}`]: (state, action: Action<any>) => {
      return {
        ...state,
        ...action.payload,
      };
    },
    [`${removeNotification}`]: (state, action: Action<any>) => {
      return omit(state, action.payload);
    },
    [`${clearNotifications}`]: () => {
      return {};
    },
  },
  {},
);

Step4: 从 Store 中获取 Notification,并通过 React Child Render 提供给子组件。

这一步就很简单了,从 Store 中拿到 Notifications,然后通过 React Child Render 将它提供给子组件,子组件就可以根据它去显示 UI 了。

WithNotifications.tsx

import { isEmpty } from "lodash";
import * as React from "react";
import {
  connect,
  DispatchProp,
} from "react-redux";
import {
  clearNotifications,
  INotification,
} from "./notificationActions";

interface IWithNotificationsCoreInnerProps {
  notifications: INotification;
}

interface IWithNotificationsCoreProps extends DispatchProp {
  notifications: INotification;
  children: (props: IWithNotificationsCoreInnerProps) => React.ReactNode;
}

class WithNotificationsCore extends React.Component<IWithNotificationsCoreProps> {
  componentWillUnmount() {
    this.props.dispatch(clearNotifications());
  }

  render() {
    if (isEmpty(this.props.notifications)) {
      return null;
    }

    return this.props.children({
      notifications: this.props.notifications,
    });
  }
}

const mapStateToProps = (state: any) => {
  return {
    notifications: state.notifications,
  };
};

export const WithNotifications = connect(mapStateToProps)(WithNotificationsCore);

Step5: 显示 Error Messages

因为 Notification 是一个通用的组件,所以我们一般会把它放到根组件 (Root) 上。

<WithNotifications>
  {({ notifications }) => (
    <>
      {map(notifications, (notification: { type: string; msg: string }, id: number) => {
        return (
          <div>
            {notification.msg} // 将你的 Notification 组件放到这里            
            {id} // 你可以用 id 去删除对应的 Notification
          </div>
        );
      })}
    </>
  )}
</WithNotifications>

Step6: 添加白名单

当然,并不是所有的 API 请求出错我们都需要通知给用户。这时候你就需要加一个白名单了,如果在这个白名单内,则不将错误信息通知给用户。可以考虑在 Requst Action 的 Meta 中加一个 omitError 的 flag,当有这个 flag 的时候,则不进行通知。让我们修改一下 errorMiddleware,如下:

errorMiddleware.ts

export const errorMiddleware = ({ dispatch }: MiddlewareAPI) => {
  return (next: Dispatch<AnyAction>) => {
    return (action: AnyAction) => {
    const shouldOmitError = get(action, "meta.omitError", false);
      if (isRequestFailedAction(action) && !shouldOmitError) {
        const error = action.payload;
        if (error.response) {
          // same as before
        } else {
          // same as before
      }
      return next(action);
    };
  };
};

Step7: 测试

在测试 errorMiddleware 的时候,可能会遇到一个问题,就是我们的 Notification 是根据一个以时间戳为 key 的对象,时间戳是根据当前时间生成的,每次跑测试时都会发生变化,如何解决呢?Mock getTime 方法就好啦。如下:

 beforeEach(() => {
    class MockDate {
      getTime() {
        return 123456;
      }
    }

    global.Date = MockDate as any;
  });

  afterEach(() => {
    global.Date = Date;
  });

错误信息的收集

componentDidCatch

利用 React componentDidCatch 生命周期方法将错误信息收集到 Error Reporting 服务。这个方法有点像 JS 的 catch{},只不过是针对组件的。大多数时候我们希望 ErrorBoundary 组件贯穿我们的整个应用,所以一般会将它放在根节点上 (Root)。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
注意:对 ErrorBoundary 组件来说,它只会捕获在它之下的组件,它不会捕获自身组件内部的错误。
查看原文

小贼先生 赞了回答 · 2020-11-17

解决js中的正则支持条件匹配吗

很遗憾,目前Javascript是不支持的。 C#, php, python等都支持这个高级语法。

关注 1 回答 1

小贼先生 收藏了文章 · 2020-11-03

网页应该如何录屏呢?

摘要: 网页应该如何录屏呢?

Fundebug经授权转载,版权归原作者所有。

关键点

  • 首先,每一次会话都有一个唯一的session ID,这是串联起所有行为的纽带。
  • 其次,用户行为又分成两个部分,其一是用户的操作,比如鼠标滑动,点击,页面滚动等,其二是页面的变化。这两者我们都统称为用户行为,记录在同一个队列中。
  • 一开始的时候,系统会记录下初始的页面作为第一帧,这是唯一的一次完整页面记录。
  • 针对用户操作,我们会记录事件的类型,鼠标位置等关键信息,保存到队列中。
  • 针对页面变动,我们会起一个mutationObserve侦听页面的改动,每次只记录改动的部分,保存到队列中。
  • 无论是事件还是页面改动,都是对等的一帧,每一帧都会有当前时间,与上一帧间隔时间等基本信息用户还原
  • 一旦出错,SDK就把队列发送到监控系统,并清空当前队列。
  • 还原端根据记录的行为队列,根据时间逐一播放出来。最终形成一个类似于视频的效果。

初步思路

方式一:

  • 前端收集信息,首先,初始化的时候记录一个页面的初始状态,然后利用 MutationObserver 监听dom的改变事件,然后监听所有的鼠标事件、滚动事件等等所有的页面变化。
  • 在合理的时机把这些信息队列上传到服务器,如页面出错时等。
  • 后台分析前端收集到的信息,转为图片,然后形成"视频",或者用户行为栈。提供对应的调用 api。
  • 前端需要查找问题时,根据用户id等信息找到对应的出错栈。

方式二:

  • 前端根据 html 转为对应的图片(可以转为 base64 格式)
  • 将图片发送给后台
  • 后台将图片按序组成"视频"

现有SDK

Fundebug

录屏(截图)

  • html2canvas
  • puppeteer
  • rrweb+rrweb-player+rrweb-snapshot

html2canvas介绍

html2canvas 是通过分析页面中已加载好的 DOM 元素,然后 canvas 将生成的 DOM 节点绘制在画布上,最后转换为图片。它不是真正的截屏,只是根据页面元素信息还原出图片,所以并不是 100% 和页面相同的。

局限性

  • 页面中的图片不能跨域
  • 不是所有的 css 特性都支持,如不支持 box-shadow、filter 等
  • 不支持截取插件内容,如 Flash
  • 不支持 iframe 内容

浏览器支持

  • Firefox 3.5+
  • Google Chrome
  • Opera 12+
  • IE9+
  • Edge
  • Safari 6+

puppeteer介绍

Puppeteer 是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,它是一个 Node 库,提供了一个高级的 API 来控制 DevTools协议上的无头版 Chrome 。

局限性

  • Puppeteer 需要 Chromium。其主要应用在自动化测试上。

功能

  • 生成页面的截图和PDF。
  • 抓取SPA并生成预先呈现的内容(即"SSR")。
  • 从网站抓取你需要的内容。
  • 自动表单提交,UI测试,键盘输入等
  • 创建一个最新的自动化测试环境。使用最新的JavaScript和浏览器功能,直接在最新版本的Chrome中运行测试。
  • 捕获您的网站的时间线跟踪,以帮助诊断性能问题。

结论html2canvas 更适合于 C 端的用户行为截图跟踪,而 Puppeteer 适用于自动化测试。

rrweb介绍

rrweb 主要由 3 部分组成:

  • rrweb-snapshot,包含 snapshot 和 rebuild 两个功能。snapshot 用于将 DOM 及其状态转化为可序列化的数据结构并添加唯一标识;rebuild 则是将 snapshot 记录的数据结构重建为对应的 DOM。
  • rrweb,包含 record 和 replay 两个功能。record 用于记录 DOM 中的所有变更(mutation);replay 则是将记录的变更按照对应的时间一一重放。
  • rrweb-player,为 rrweb 提供一套 UI 控件,提供基于 GUI 的暂停、快进、拖拽至任意时间点播放等功能。

rrweb适用场景:

  • 用户行为分析;
  • 远程debug;
  • 录制操作;
  • 实时协作;

局限性

  • 社区资源较少
  • 部分代码用较旧的模式写的,有未知坑

最终结论

综合来看,结合思路一,基于 rrweb 来开发是最可行最快捷的。

Demo

目前,我基于 rrweb 已经做了个 demo 出来。以下是初步成果:demo代码

补充资料

rrweb的一些思路原理

rrweb:打开 web 页面录制与回放的黑盒子

MutationObserver介绍

Mutation Observer API 用来监视 DOM 变动。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。

特点

  • 它等待所有脚本任务完成后,才会运行(即异步触发方式)。
  • 它把 DOM 变动记录封装成一个数组进行处理,而不是一条条个别处理 DOM 变动。
  • 它既可以观察 DOM 的所有类型变动,也可以指定只观察某一类变动。

example

Select the node that will be observed for mutations var targetNode = document.getElementById('some-id');

// Options for the observer (which mutations to observe)
var config = { attributes: true, childList: true, subtree: true };

// Callback function to execute when mutations are observed
var callback = function(mutationsList, observer) {
    for (var mutation of mutationsList) {
        if (mutation.type == "childList") {
            console.log("A child node has been added or removed.");
        } else if (mutation.type == "attributes") {
            console.log(
                "The " + mutation.attributeName + " attribute was modified."
            );
        }
    }
};

// Create an observer instance linked to the callback function
var observer = new MutationObserver(callback);

// Start observing the target node for configured mutations
observer.observe(targetNode, config);

// Later, you can stop observing
observer.disconnect();

observe方法接受两个参数,第一个是所要观察的DOM元素是article,第二个是所要观察的变动类型(子节点变动和属性变动),方法调用时必须指定一种或多种变动类型,否则报错,变动类型如下:

boolean childList = false;
boolean attributes;
boolean characterData;
boolean subtree = false; //表示是否将该观察器应用于该节点的所有后代节点。
boolean attributeOldValue; //表示观察attributes变动时,是否需要记录变动前的属性值。
boolean characterDataOldValue; //表示观察characterData变动时,是否需要记录变动前的值。
sequence<DOMString> attributeFilter;//数组,表示需要观察的特定属性(比如['class','src'])

disconnect方法用来停止观察。调用该方法后,DOM 再发生变动,也不会触发观察器。 takeRecords方法用来清除变动记录,即不再处理未处理的变动。该方法返回变动记录的数组。

MutationRecord对象

DOM 每次发生变化,就会生成一条变动记录(MutationRecord 实例)。该实例包含了与变动相关的所有信息。Mutation Observer 处理的就是一个个MutationRecord实例所组成的数组。 MutationRecord对象包含了DOM的相关信息,有如下属性:

type:观察的变动类型(attribute、characterData或者childList)。
target:发生变动的DOM节点。
addedNodes:新增的DOM节点。
removedNodes:删除的DOM节点。
previousSibling:前一个同级节点,如果没有则返回null。
nextSibling:下一个同级节点,如果没有则返回null。
attributeName:发生变动的属性。如果设置了attributeFilter,则只返回预先指定的属性。
oldValue:变动前的值。这个属性只对attribute和characterData变动有效,如果发生childList变动,则返回null。

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了20亿+错误事件,付费客户有阳光保险、核桃编程、荔枝FM、掌门1对1、微脉、青团社等众多品牌企业。欢迎大家免费试用!

查看原文

小贼先生 发布了文章 · 2020-08-21

【深度全面】前端JavaScript模块化规范进化论

image

前言

JavaScript 语言诞生至今,模块规范化之路曲曲折折。社区先后出现了各种解决方案,包括 AMD、CMD、CommonJS 等,而后 ECMA 组织在 JavaScript 语言标准层面,增加了模块功能(因为该功能是在 ES2015 版本引入的,所以在下文中将之称为 ES6 module)。
今天我们就来聊聊,为什么会出现这些不同的模块规范,它们在所处的历史节点解决了哪些问题?

何谓模块化?

或根据功能、或根据数据、或根据业务,将一个大程序拆分成互相依赖的小文件,再用简单的方式拼装起来。

全局变量

演示项目

为了更好的理解各个模块规范,先增加一个简单的项目用于演示。

# 项目目录:
├─ js              # js文件夹
│  ├─ main.js      # 入口
│  ├─ config.js    # 项目配置
│  └─ utils.js     # 工具
└─  index.html     # 页面html

Window

在刀耕火种的前端原始社会,JS 文件之间的通信基本完全依靠window对象(借助 HTML、CSS 或后端等情况除外)。

// config.js
var api = 'https://github.com/ronffy';
var config = {
  api: api,
}
// utils.js
var utils = {
  request() {
    console.log(window.config.api);
  }
}
// main.js
window.utils.request();
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>小贼先生:【深度全面】JS模块规范进化论</title>
</head>
<body>

  <!-- 所有 script 标签必须保证顺序正确,否则会依赖报错 -->
  <script data-original="./js/config.js"></script>
  <script data-original="./js/utils.js"></script>
  <script data-original="./js/main.js"></script>
</body>
</html>

IIFE

浏览器环境下,在全局作用域声明的变量都是全局变量。全局变量存在命名冲突、占用内存无法被回收、代码可读性低等诸多问题。

这时,IIFE(匿名立即执行函数)出现了:

;(function () {
  ...
}());

用IIFE重构 config.js:

;(function (root) {
  var api = 'https://github.com/ronffy';
  var config = {
    api: api,
  };
  root.config = config;
}(window));

IIFE的出现,使全局变量的声明数量得到了有效的控制。

命名空间

依靠window对象承载数据的方式是“不可靠”的,如window.config.api,如果window.config不存在,则window.config.api就会报错,所以为了避免这样的错误,代码里会大量的充斥var api = window.config && window.config.api;这样的代码。

这时,namespace登场了,简约版本的namespace函数的实现(只为演示,不要用于生产):

function namespace(tpl, value) {
  return tpl.split('.').reduce((pre, curr, i) => {
    return (pre[curr] = i === tpl.split('.').length - 1
      ? (value || pre[curr])
      : (pre[curr] || {}))
  }, window);
}

namespace设置window.app.a.b的值:

namespace('app.a.b', 3); // window.app.a.b 值为 3

namespace获取window.app.a.b的值:

var b = namespace('app.a.b');  // b 的值为 3
 
var d = namespace('app.a.c.d'); // d 的值为 undefined 

app.a.c值为undefined,但因为使用了namespace, 所以app.a.c.d不会报错,变量d的值为undefined

AMD/CMD

随着前端业务增重,代码越来越复杂,靠全局变量通信的方式开始捉襟见肘,前端急需一种更清晰、更简单的处理代码依赖的方式,将 JS 模块化的实现及规范陆续出现,其中被应用较广的模块规范有 AMD 和 CMD。

面对一种模块化方案,我们首先要了解的是:1. 如何导出接口;2. 如何导入接口。

AMD

异步模块定义规范(AMD)制定了定义模块的规则,这样模块和模块的依赖可以被异步加载。这和浏览器的异步加载模块的环境刚好适应(浏览器同步加载模块会导致性能、可用性、调试和跨域访问等问题)。

本规范只定义了一个函数define,它是全局变量。

/**
 * @param {string} id 模块名称
 * @param {string[]} dependencies 模块所依赖模块的数组
 * @param {function} factory 模块初始化要执行的函数或对象
 * @return {any} 模块导出的接口
 */
function define(id?, dependencies?, factory): any

RequireJS

AMD 是一种异步模块规范,RequireJS 是 AMD 规范的实现。

接下来,我们用 RequireJS 重构上面的项目。

在原项目 js 文件夹下增加 require.js 文件:

# 项目目录:
├─ js                # js文件夹
│  ├─ ...
│  └─ require.js     # RequireJS 的 JS 库
└─  ...
// config.js
define(function() {
  var api = 'https://github.com/ronffy';
  var config = {
    api: api,
  };
  return config;
});
// utils.js
define(['./config'], function(config) {
  var utils = {
    request() {
      console.log(config.api);
    }
  };
  return utils;
});
// main.js
require(['./utils'], function(utils) {
  utils.request();
});
<!-- index.html  -->
<!-- ...省略其他 -->
<body>

  <script data-main="./js/main" data-original="./js/require.js"></script>
</body>
</html>

可以看到,使用 RequireJS 后,每个文件都可以作为一个模块来管理,通信方式也是以模块的形式,这样既可以清晰的管理模块依赖,又可以避免声明全局变量。

更多 AMD 介绍,请查看文档
更多 RequireJS 介绍,请查看文档

特别说明:
先有 RequireJS,后有 AMD 规范,随着 RequireJS 的推广和普及,AMD 规范才被创建出来。

CMD和AMD

CMD 和 AMD 一样,都是 JS 的模块化规范,也主要应用于浏览器端。
AMD 是 RequireJS 在的推广和普及过程中被创造出来。
CMD 是 SeaJS 在的推广和普及过程中被创造出来。

二者的的主要区别是 CMD 推崇依赖就近,AMD 推崇依赖前置:

// AMD
// 依赖必须一开始就写好
define(['./utils'], function(utils) {
  utils.request();
});

// CMD
define(function(require) {
  // 依赖可以就近书写
  var utils = require('./utils');
  utils.request();
});

AMD 也支持依赖就近,但 RequireJS 作者和官方文档都是优先推荐依赖前置写法。

考虑到目前主流项目中对 AMD 和 CMD 的使用越来越少,大家对 AMD 和 CMD 有大致的认识就好,此处不再过多赘述。

更多 CMD 规范,请查看文档
更多 SeaJS 文档,请查看文档

随着 ES6 模块规范的出现,AMD/CMD 终将成为过去,但毋庸置疑的是,AMD/CMD 的出现,是前端模块化进程中重要的一步。

小贼先生-文章原址

CommonJS

前面说了, AMD、CMD 主要用于浏览器端,随着 node 诞生,服务器端的模块规范 CommonJS 被创建出来。

还是以上面介绍到的 config.js、utils.js、main.js 为例,看看 CommonJS 的写法:

// config.js
var api = 'https://github.com/ronffy';
var config = {
  api: api,
};
module.exports = config;
// utils.js
var config = require('./config');
var utils = {
  request() {
    console.log(config.api);
  }
};
module.exports = utils;
// main.js
var utils = require('./utils');
utils.request();
console.log(global.api)

执行node main.jshttps://github.com/ronffy被打印了出来。
在 main.js 中打印global.api,打印结果是undefined。node 用global管理全局变量,与浏览器的window类似。与浏览器不同的是,浏览器中顶层作用域是全局作用域,在顶层作用域中声明的变量都是全局变量,而 node 中顶层作用域不是全局作用域,所以在顶层作用域中声明的变量非全局变量。

module.exports和exports

我们在看 node 代码时,应该会发现,关于接口导出,有的地方使用module.exports,而有的地方使用exports,这两个有什么区别呢?

CommonJS 规范仅定义了exports,但exports存在一些问题(下面会说到),所以module.exports被创造了出来,它被称为 CommonJS2 。
每一个文件都是一个模块,每个模块都有一个module对象,这个module对象的exports属性用来导出接口,外部模块导入当前模块时,使用的也是module对象,这些都是 node 基于 CommonJS2 规范做的处理。

// a.js
var s = 'i am ronffy'
module.exports = s;
console.log(module);

执行node a.js,看看打印的module对象:

{
  exports: 'i am ronffy',
  id: '.',                                // 模块id
  filename: '/Users/apple/Desktop/a.js',  // 文件路径名称
  loaded: false,                          // 模块是否加载完成
  parent: null,                           // 父级模块
  children: [],                           // 子级模块
  paths: [ /* ... */ ],                   // 执行 node a.js 后 node 搜索模块的路径
}

其他模块导入该模块时:

// b.js
var a = require('./a.js'); // a --> i am ronffy

当在 a.js 里这样写时:

// a.js
var s = 'i am ronffy'
exports = s;

a.js 模块的module.exports是一个空对象。

// b.js
var a = require('./a.js'); // a --> {}

module.exportsexports放到“明面”上来写,可能就更清楚了:

var module = {
  exports: {}
}
var exports = module.exports;
console.log(module.exports === exports); // true

var s = 'i am ronffy'
exports = s; // module.exports 不受影响
console.log(module.exports === exports); // false

模块初始化时,exportsmodule.exports指向同一块内存,exports被重新赋值后,就切断了跟原内存地址的关系。

所以,exports要这样使用:

// a.js
exports.s = 'i am ronffy';

// b.js
var a = require('./a.js');
console.log(a.s); // i am ronffy

CommonJS 和 CommonJS2 经常被混淆概念,一般大家经常提到的 CommonJS 其实是指 CommonJS2,本文也是如此,不过不管怎样,大家知晓它们的区别和如何应用就好。

CommonJS与AMD

CommonJS 和 AMD 都是运行时加载,换言之:都是在运行时确定模块之间的依赖关系。

二者有何不同点:

  1. CommonJS 是服务器端模块规范,AMD 是浏览器端模块规范。
  2. CommonJS 加载模块是同步的,即执行var a = require('./a.js');时,在 a.js 文件加载完成后,才执行后面的代码。AMD 加载模块是异步的,所有依赖加载完成后以回调函数的形式执行代码。
  3. [如下代码]fschalk都是模块,不同的是,fs是 node 内置模块,chalk是一个 npm 包。这两种情况在 CommonJS 中才有,AMD 不支持。
var fs = require('fs');
var chalk = require('chalk');

UMD

Universal Module Definition.

存在这么多模块规范,如果产出一个模块给其他人用,希望支持全局变量的形式,也符合 AMD 规范,还能符合 CommonJS 规范,能这么全能吗?
是的,可以如此全能,UMD 闪亮登场。

UMD 是一种通用模块定义规范,代码大概这样(假如我们的模块名称是 myLibName):

!function (root, factory) {
  if (typeof exports === 'object' && typeof module === 'object') {
    // CommonJS2
    module.exports = factory()
    // define.amd 用来判断项目是否应用 require.js。
    // 更多 define.amd 介绍,请[查看文档](https://github.com/amdjs/amdjs-api/wiki/AMD#defineamd-property-)
  } else if (typeof define === 'function' && define.amd) {
    // AMD
    define([], factory)
  } else if (typeof exports === 'object') {
    // CommonJS
    exports.myLibName = factory()
  } else {
    // 全局变量
    root.myLibName = factory()
  }
}(window, function () {
  // 模块初始化要执行的代码
});

UMD 解决了 JS 模块跨模块规范、跨平台使用的问题,它是非常好的解决方案。

小贼先生-文章原址

ES6 module

AMD 、 CMD 等都是在原有JS语法的基础上二次封装的一些方法来解决模块化的方案,ES6 module(在很多地方被简写为 ESM)是语言层面的规范,ES6 module 旨在为浏览器和服务器提供通用的模块解决方案。长远来看,未来无论是基于 JS 的 WEB 端,还是基于 node 的服务器端或桌面应用,模块规范都会统一使用 ES6 module。

兼容性

目前,无论是浏览器端还是 node ,都没有完全原生支持 ES6 module,如果使用 ES6 module ,可借助 babel 等编译器。本文只讨论 ES6 module 语法,故不对 babel 或 typescript 等可编译 ES6 的方式展开讨论。

导出接口

CommonJS 中顶层作用域不是全局作用域,同样的,ES6 module 中,一个文件就是一个模块,文件的顶层作用域也不是全局作用域。导出接口使用export关键字,导入接口使用import关键字。

export导出接口有以下方式:

方式1

export const prefix = 'https://github.com';
export const api = `${prefix}/ronffy`;

方式2

const prefix = 'https://github.com';
const api = `${prefix}/ronffy`;
export {
  prefix,
  api,
}

方式1和方式2只是写法不同,结果是一样的,都是把prefixapi分别导出。

方式3(默认导出)

// foo.js
export default function foo() {}

// 等同于:
function foo() {}
export {
  foo as default
}

export default用来导出模块默认的接口,它等同于导出一个名为default的接口。配合export使用的as关键字用来在导出接口时为接口重命名。

方式4(先导入再导出简写)

export { api } from './config.js';

// 等同于:
import { api } from './config.js';
export {
  api
}

如果需要在一个模块中先导入一个接口,再导出,可以使用export ... from 'module'这样的简便写法。

导入模块接口

ES6 module 使用import导入模块接口。

导出接口的模块代码1:

// config.js
const prefix = 'https://github.com';
const api = `${prefix}/ronffy`;
export {
  prefix,
  api,
}

接口已经导出,如何导入呢:

方式1

import { api } from './config.js';

// or
// 配合`import`使用的`as`关键字用来为导入的接口重命名。
import { api as myApi } from './config.js';

方式2(整体导入)

import * as config from './config.js';
const api = config.api;

将 config.js 模块导出的所有接口都挂载在config对象上。

方式3(默认导出的导入)

// foo.js
export const conut = 0;
export default function myFoo() {}
// index.js
// 默认导入的接口此处刻意命名为cusFoo,旨在说明该命名可完全自定义。
import cusFoo, { count } from './foo.js';

// 等同于:
import { default as cusFoo, count } from './foo.js';

export default导出的接口,可以使用import name from 'module'导入。这种方式,使导入默认接口很便捷。

方式4(整体加载)

import './config.js';

这样会加载整个 config.js 模块,但未导入该模块的任何接口。

方式5(动态加载模块)

上面介绍了 ES6 module 各种导入接口的方式,但有一种场景未被涵盖:动态加载模块。比如用户点击某个按钮后才弹出弹窗,弹窗里功能涉及的模块的代码量比较重,所以这些相关模块如果在页面初始化时就加载,实在浪费资源,import()可以解决这个问题,从语言层面实现模块代码的按需加载。

ES6 module 在处理以上几种导入模块接口的方式时都是编译时处理,所以importexport命令只能用在模块的顶层,以下方式都会报错:

// 报错
if (/* ... */) {
  import { api } from './config.js'; 
}

// 报错
function foo() {
  import { api } from './config.js'; 
}

// 报错
const modulePath = './utils' + '/api.js';
import modulePath;

使用import()实现按需加载:

function foo() {
  import('./config.js')
    .then(({ api }) => {

    });
}

const modulePath = './utils' + '/api.js';
import(modulePath);

特别说明:
该功能的提议目前处于 TC39 流程的第4阶段。更多说明,请查看TC39/proposal-dynamic-import

CommonJS 和 ES6 module

CommonJS 和 AMD 是运行时加载,在运行时确定模块的依赖关系。
ES6 module 是在编译时(import()是运行时加载)处理模块依赖关系,。

CommonJS

CommonJS 在导入模块时,会加载该模块,所谓“CommonJS 是运行时加载”,正因代码在运行完成后生成module.exports的缘故。当然,CommonJS 对模块做了缓存处理,某个模块即使被多次多处导入,也只加载一次。

// o.js
let num = 0;
function getNum() {
  return num;
}
function setNum(n) {
  num = n;
}
console.log('o init');
module.exports = {
  num,
  getNum,
  setNum,
}
// a.js
const o = require('./o.js');
o.setNum(1);
// b.js
const o = require('./o.js');
// 注意:此处只是演示,项目里不要这样修改模块
o.num = 2;
// main.js
const o = require('./o.js');

require('./a.js');
console.log('a o.num:', o.num);

require('./b.js');
console.log('b o.num:', o.num);
console.log('b o.getNum:', o.getNum());

命令行执行node main.js,打印结果如下:

  1. o init
    模块即使被其他多个模块导入,也只会加载一次,并且在代码运行完成后将接口赋值到module.exports属性上。
  2. a o.num: 0
    模块在加载完成后,模块内部的变量变化不会反应到模块的module.exports
  3. b o.num: 2
    对导入模块的直接修改会反应到该模块的module.exports
  4. b o.getNum: 1
    模块在加载完成后即形成一个闭包。

ES6 module

// o.js
let num = 0;
function getNum() {
  return num;
}
function setNum(n) {
  num = n;
}
console.log('o init');
export {
  num,
  getNum,
  setNum,
}
// main.js
import { num, getNum, setNum } from './o.js';

console.log('o.num:', num);
setNum(1);

console.log('o.num:', num);
console.log('o.getNum:', getNum());

我们增加一个 index.js 用于在 node 端支持 ES6 module:

// index.js
require("@babel/register")({
  presets: ["@babel/preset-env"]
});

module.exports = require('./main.js')

命令行执行npm install @babel/core @babel/register @babel/preset-env -D安装 ES6 相关 npm 包。

命令行执行node index.js,打印结果如下:

  1. o init
    模块即使被其他多个模块导入,也只会加载一次。
  2. o.num: 0
  3. o.num: 1
    编译时确定模块依赖的 ES6 module,通过import导入的接口只是值的引用,所以num才会有两次不同打印结果。
  4. o.getNum: 1

对于打印结果3,知晓其结果,在项目中注意这一点就好。这块会涉及到“Module Records(模块记录)”、“module instance(模快实例)” “linking(链接)”等诸多概念和原理,大家可查看ES modules: A cartoon deep-dive进行深入的研究,本文不再展开。

ES6 module 是编译时加载(或叫做“静态加载”),利用这一点,可以对代码做很多之前无法完成的优化:

  1. 在开发阶段就可以做导入和导出模块相关的代码检查。
  2. 结合 Webpack、Babel 等工具可以在打包阶段移除上下文中未引用的代码(dead-code),这种技术被称作“tree shaking”,可以极大的减小代码体积、缩短程序运行时间、提升程序性能。

后记

大家在日常开发中都在使用 CommonJS 和 ES6 module,但很多人只知其然而不知其所以然,甚至很多人对 AMD、CMD、IIFE 等概览还比较陌生,希望通过本篇文章,大家对 JS 模块化之路能够有清晰完整的认识。
JS 模块化之路目前趋于稳定,但肯定不会止步于此,让我们一起学习,一起进步,一起见证,也希望能有机会为未来的模块化规范贡献自己的一点力量。
本人能力有限,文中可能难免有一些谬误,欢迎大家帮助改进,文章github地址,我是小贼先生。

参考

AMD 官方文档
阮一峰:Module 的加载实现

查看原文

赞 11 收藏 8 评论 0

小贼先生 收藏了文章 · 2020-08-18

ES module工作原理

本文参考 https://hacks.mozilla.org/201...,建议大家读原文。

ES6发布了官方的,标准化的Module特性,这一特性花了整整10年的时间。但是,在这之前,大家也都在模块化地编写JS代码。比如在server端的NodeJS,它是对CommonJS的一个实现;Require.js则是可以在浏览器使用,它是对AMD的一个实现。

ES6官方化了模块,使得在浏览器端不再需要引入额外的库来实现模块化的编程(当然浏览器的支持与否,这里暂不讨论)。ES Module的使用也很简单,相关语法也很少,核心是import和export。但是,对于ES module到底是如何工作的,它又和之前的CommonJS和AMD有什么差别呢?这是接下来将要讨论的内容。

一:没有模块化的编程存在什么问题?

编写JS代码,主要是对于对变量的操作:给变量赋值或者变量之间进行各种运算。正因为大部分代码都是对变量的操作,所以如何组织代码里面的变量对于如何写好代码和代码维护就显得至关重要了。

当只有少量的变量需要考虑的时候,JavaScript提供了“scope(作用域)”来帮助你。因为在JavaScript里面,一个function不能访问定义在别的function里面的变量。

但是,这同时也带来一个问题,假如functionA想要使用functionB的变量怎么办呢?一个通用的办法就是把functionB的变量放到functionA的上一层作用域。典型的就是jQuery时代,如果要使用jQuery的API,先要保证jQuery在全局作用域。
但是这样做的问题也很多:

1: 所有的script标签必须保证正确的顺序,这使得代码的维护变得异常艰难。
2: 全局作用域被污染。

二:模块化编程如何解决上面提到的问题?

模块,把相关的变量和function组织到一起,形成一个所谓的module scope(模块作用域)。在这个作用域里面的变量和function之间彼此是可见的。

与function不同的是,一个模块可以决定自己内部的哪些变量,类,或者function可以被其他模块可见,这个决定我们叫做“export(导出)”。而其他的模块也就可以选择性地使用这个模块导出的内容,我们通过“import(导入)”来实现。

一旦有了导入和导出,我们就可以把我们的程序按照指责划分为一个个模块,大的模块可以继续划分为更小的模块,最终这些模块组合到一起,搭建起了我们整个程序,就像乐高一样。

三:ES Module的工作原理之Module Instances

当你在模块化编程的时候,你就会创建一棵依赖树。不同依赖之间的链接来源于你使用的每一条"import"语句。

就是通过这些"import"语句,浏览器和Node才知道它们到底要加载哪些代码。你给浏览器或者Node一个依赖树的入口文件,从这个入口文件开始,浏览器或者Node就沿着每一条"import"语句找到下面的代码。
图片描述

但是,浏览器却使用不了这些文件。所有的文件都必须要转变为一系列被叫做“Module Records(模块记录)的数据结构,这样浏览器才能明白这些文件的内容。
图片描述

在这之后,module record需要被转化为“module instance(模快实例)”。一个module instance包含2种东西:code和state。

code就是一系列的操作指令,就像菜单一样。但是,光有菜单,并不能作出菜,你还需要原材料。而state就是原材料。State就是变量在每一个特地时间点的值。当然,这些变量只是内存里面一个个保存着值的小盒子的小名而已。

而我们真正需要的就是每一个模块都有一个module instance。模块的加载就是从这个入口文件开始,最后得到包含所有module instance的完整图像。

四:Module Instances的产生步骤

对于,ES Module来说,这需要经历三个步骤:

1: Construction(构造)- 找到,下载所有的文件并且解析为module records。
2: Instantiation(实例化)- 在内存里找到所有的“盒子”,把所有导出的变量放进去(但是暂时还不求值)。然后,让导出和导入都指向内存里面的这些盒子。这叫做“linking(链接)”。
3: Evaluation(求值)- 执行代码,得到变量的值然后放到这些内存的“盒子”里。

图片描述

大家都说ES Module是异步的。你可以认为它是异步的,因为这些工作被分成了三个不同的步骤 - loading(下载),instantiating(实例化)和evaluating(求值) - 并且这些步骤可以单独完成。

这意味着ES Module规范采用了一种在CommonJS里面不存在的异步机制。在CommonJS里面,对于一个模块和它底下的依赖来说,下载,实例化,和求值都是一次性完成的,步骤相互之间没有任何停顿。

然而,这并不意味这这些步骤必须是异步的,它们也可以同步完成。这依赖于“loading(下载)”是由谁去做的。因为,并不是所有的东西都由ES module规范控制。事实上,确实有两部分的工作是由别的规范负责的。

ES module规范 陈述了你应该怎样把文件解析为module records,和怎样初始化模块以及求值。然而,它却没有说在最开始要怎样得到这些文件。

是loader(下载器)去获取到了文件。而loader对于不同的规范来说是特定的。对于浏览器来说,这个规范是HTML 规范。你可以根据你所使用的平台来得到不同的loader。

图片描述

loader也控制着模块如何加载。它会调用ES module的方法--ParseModule, Module.Instantiate,和Module.Evaluate。loader就像傀儡师,操纵着JS引擎的线。

现在让我们来具体聊一聊每一个步骤。
五:Module Instances的产生步骤之Construction

对于每一个模块来说,在这一步会经历以下几个步骤

1: 弄清楚去哪里下载包含模块的文件(又叫“ module resolution(模块识别)”)
2: 获取文件(通过从一个URL下载或者从文件系统加载)
3: 把文件解析为module record(模块记录)

step1: Finding the file and fetching it 找到文件并获取文件

loader会负责找到文件并下载。首先,需要找到入口文件,在HTML文件里,我们通过使用<script>标签告诉loader哪里去找到入口文件。

图片描述

但是,loader如何找到接下来的一系列模块 - 也就是main.js所直接依赖的哪些模块呢?这就轮到import语句登场了。import语句的某一部分又被叫做“模块说明符”。它告诉loader在哪儿可以找到下一个模块。
图片描述

关于“模块说明符”,有一点需要说明:某些时候,不同的浏览器和Node之间,需要不同的处理方式。每一个平台都有它们自己的方法去诠释“模块说明符”字符串。而这通过“模块识别算法”完成,不同的平台不一样。就目前来说,一些在Node环境工作的模块识别符在浏览器里面并不工作,但是这一情况正在被处理修复

而在修复之前,浏览器只接受URL作为模块标识符。浏览器会从那个URL下载模块文件。但是,对于整个依赖图来说,在同一时间是不可能的。因为直到解析了这个文件,你才知道这个模块需要哪些依赖。。。但是,你又不能解析这个文件除非你获取了它。

这意味着,要解析一个文件,我们必须一层一层地遍历这颗依赖树,理清楚他所有的依赖,然后找到并且下载这些依赖。但是,假如主线程一直在等待这些文件下载,那么大量的其他的任务就被卡在队列里面。这是因为,在浏览器里面进行下载工作,会耗费大量的时间。

像这样阻塞主线程,会导致使用了模块的app太慢了,这也是ES module规范把算法分割成多个步骤的其中一个原因。把construction(构建)单独划分到一个步骤,这就允许浏览器可以在进入到instantiating(实例化)的一系列同步工作之前可以先获取文件并且建立模块之间的依赖树。

把这个算法分割到不同的步骤--正是ES Module和CommonJS module之间的其中一个关键区别。

CommonJS可以做不同于ES Module的处理,是因为从文件系统里面加载文件比从网络上下载文件要花少得多的时间。这就意味着,Node可以在加载文件的时候阻塞主线程。又因为文件已经加载好了,那么实例化和求值(这两步在CommomJS里面是没有分开的)也显得很有道理。这意味着,在你返回这个模块之前,其依赖树上所有的依赖都完成了loading(加载),instantiating(实例化)和evaluating(求值)。
图片描述

CommonJS的方法会带来一些后果,后面会解释。但是,其中有一点是在Node里面的CommomJS module, 你可以在模块说明符里面使用变量。在你寻找下一个模块之前,你会执行完本模块的所有代码。这就意味着当你去做模块识别的时候,这个变量已经有值了。

但是,在ES Module里面,你是在任何求值之前先建立了完整的依赖树。这说明,你不能在模块说明符里面使用变量,因为这个变量目前还没有值。

图片描述

但是动态模块,在实际生产中又是有用的。所以有一个提议叫做动态导入,可以用来满足类似这样的需求:import(${path}/foo.js).

动态导入的工作原理是,任何使用import()来导入的文件,都会作为一个入口文件从而创建一棵单独的依赖树,被单独处理。

图片描述

但有一点需要注意的是 - 任何同时存在于两棵依赖树的模块都指向同一个模块实例。这是因为loader把模块实例缓存起来了。对于每一个模块来说,在一个特定的全局作用域内,只会有一个模版实例。

这对JS引擎来说,就意味着更少的工作量。举个例子,无论多少模块依赖着某一个模块,但是这个模块文件都只会被获取一次。loader使用module map来管理这些缓存,每一个全局作用域使用独立的module map来管理各自的缓存。

当loader通过一个URL去获取文件的时候,它会把这个URL放入module map并且做上“正在获取”的标志。然后它发出请求,进而继续下一个文件的获取工作。

当别的模块也依赖同一个文件的时候,会发生什么呢?Loader会查询module map里面的每一个URL,如果它看到这个URL有“正在获取“的标志,那它就不管了,继续下一个URL的处理。

module map不只是看哪个文件正在被下载,它同时也管理这模块的缓存,这就是下面的内容。

step2: Parsing

现在我们已经获取到了文件,我们需要把它解析为一个module record。这有助于浏览器理解模块的不同之处是什么。

图片描述

一旦module record创建完成,它就会被放到module map里面去。这意味着无论何时被请求,loader都可以从module map里面提取它。

图片描述

在解析的时候,有一个看起来琐碎但是却会产生巨大影响的细节:所有的模块都是在相当于在文件顶部使用了“use strict”(严格模式)下被解析的。除此之外,也还有其他的一些不同,例如:关键字await被保留在模块的最高层的代码里;this的值是undefined

不同的解析方法被称作“解析目标”。假如你用不同的解析目标解析同一个文件,你将会得到不同的解析结果。因为,在解析之前,你需要知道将要被解析的文件是否是模块。

在浏览器里面,这十分简单。你只需要给<script>标签加一个type="module"。这就告诉了浏览器这个文件需要被当成是一个模块来解析。因为只有模块才可以被导入,所以浏览器知道导入的文件也是模块。

但是Node不使用HTML相关的标签,所以无法使用type来表示。而在Node里面是通过文件的扩展名".mjs"来表明这是一个ES Module的。

不管是哪种方式,最终都是loader来决定这个文件是否当作一个模块来解析。假如它是一个module或者有import,那就会开始这个进程,直到所有的文件被下载和解析。

这一步骤就结束了。在加载进程结束之后,我们就从拥有一个入口文件到最后拥有一系列的module record。

图片描述

下一步就是实例化这些模块,并且把所有的实例链接起来。
六:Module Instances的产生步骤之Instantiation

如我之前提过的那样,一个实例结合了code和state。state存在于内存中,因此实例化这一步就是关于怎样把东西链接到内存里面的。

首先,JS引擎创建了一个“模块环境记录(module environment record)”。它管理着module record的变量,然后它在内存里面找到所有导出(export)的变量的“盒子”。module environment record会一直监控着内存里面的哪个盒子和哪个export是相关联的。

这些内存里面的盒子还没有获得它们的值,只有在求值这一步骤完成之后,真正的值才会被填充进去。但是这里有个小小的警告:任何导出的function定义,都是在这一步初始化的,这使得求值变得相对简单一些。

为了实例化模块图(module graph),JS引擎会做一个所谓的“深度优先后序遍历”的操作。意思就是说,JS引擎会先走到模块图的最底层--找到不依赖任何其他模块的那些模块,并且设置好它们的导出(export)。
图片描述

当JS引擎完成一个模块的所有导出的链接,它就会返回上一个层级去设置来自于这个模块的导入(import)。需要注意的是,导出和导入都是指向同一片内存地址。先链接导出保证了所有的导入都能找到对应的导出。
图片描述

这和CommonJS的模块不同。在CommonJS,导入的对象是基于导出拷贝的。这就意味着导出的任何的数值(例如数字)都是拷贝。这就意味着,如果导出模块在之后修改了一些值,导入的模块并不会被同步到这些修改。
图片描述

于此相反的是,ES module使用所谓的“实时绑定”,导出的模块和导入的模块都指向同一段内存地址。如果,导出模块修改了一个值,那么这个修改会在导入模块里面也得到体现。

导出值的模块可以在任何时间修改这些值,但是导入模块却不能修改它们导入的值。意思就是,如果一个模块导出了一个对象(object),那它可以修改这个对象的属性值。

“实时绑定”的好处是,不需要跑任何的代码,就可以链接起所有的模块。这有助于当存在循环依赖情况下的求值。

在这一步的最后,我们使得所有的模块实例导出/导入的变量的内存地址链接起来了。

接下来,我们就开始对代码求值,并且把得到的值填入对应的内存地址中。

七:Module Instances的产生步骤之Evaluation

最后一步是把值都填入内存地址中。JS引擎通过执行最上层的代码-也就是function以外的代码,来实现这一目的。
图片描述

除了往内存地址里面填值,对代码求值有可能也会触发副作用。举个例子,一个模块有可能会向server做请求。因为这个副作用,你只想求模块求值一次。和在实例化阶段的链接无论执行多少次都会得到同一个结果不同,求值会根据你进行了多少次求值操作而得到不同的结果。

这也是为什么需要module map。Module map根据URL来缓存模块,因为每一个模块都只有一个module record,这也保证了每一个模块只会被执行一次。和实例化一样,求值也是按照深度优先倒序的规则来的。

在一个循环依赖的情况下,最终会在依赖树里得到一个环,为了仅仅是说明问题,这里就用一个最简单的例子:
图片描述
我们先来看看CommonJS,它是怎么工作的。首先,main模块会执行到require语句,然后进入到counter模块。Counter模块尝试去从访问导出的对象里面的message变量。但是,因为这个变量还没有在main模块里面被求值,所以会返回undefined。JS引擎会在内存里面为这个本地变量开辟一段地址并把它的值设置为undefined。
图片描述

求值一直继续到counter模块的最底部。我们想知道最终是否能得到message的值(也就是main模块求值之后),于是我们设置了一个timeout。然后,同样的求值过程在main模块重新开始。

图片描述

message变量会被初始化并且放到内存中。但是,因为这两者之间已经没有任何链接,所以在counter模块里,message变量会保持为undefined。
图片描述

假如这个导出是用“实时绑定”处理的,counter模块最终就能得到正确的值。到timeout执行的时候,main模块的求值就完成了并且得到最终的值。

支持循环依赖,是ES module设计的一个重要基础。正是前面的“三个阶段”使得这一切成为可能。

查看原文

认证与成就

  • 获得 165 次点赞
  • 获得 6 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • immer 中文文档

    immer 是 mobx 作者的 immutable 库,简单易用、体量小巧、设计巧妙,满足了我们对JS不可变数据结构的需求。 无奈网络上完善的文档实在太少,所以自己写了一份,以贴近实战的思路和流程,对 immer进行了全面的讲解。

注册于 2017-10-23
个人主页被 1.2k 人浏览