Things I Learned (2019-11)

Conflicting order in mini-css-extract-plugin🔗

Build

在使用了 mini-css-extract-plugin 的项目中,有时会遇到如下的 Warning 输出:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order between:
* css xxx/css-loader/dist/cjs.js!./e1.css
* css xxx/css-loader/dist/cjs.js!./e2.css
* css xxx/css-loader/dist/cjs.js!./e3.css

tl;dr

上面这段 Warning 的实际含义是:

由于没法找到最合适的解,plugin 被迫使用了相对最优的解,将 e1.css 放到了编译产物中。在某些 chunk group 中,e2.csse3.css 这两个文件引用位置在 e1.css 之前;而在 plugin 将 e1.css 加入编译产物的时候,e2.csse3.css 还没有被添加。

换句话说,如果 e1.css 本来的用途是覆盖 e2.css 的内容,且覆盖依靠的是同优先级下 CSS 定义出现的先后顺序,那么在 plugin 编译完成之后,这个覆盖就会失效。

造成 Warning 的例子

以下通过一个直观的例子来说明 plugin 在何时可能会输出上述 Warning:

假设有两个文件 entry1.js

import './e1.css';
import './e2.css';

entry2.js

import './e2.css';
import './e1.css';

且 Webpack 配置了需要将 e1.csse2.css 打包到同一个文件中。如果 plugin 选择将 e1.css 放在 e2.css 的前面,那么就不满足 entry2.js 的使用顺序;反之则不满足 entry1.js 的。正是在这样的“矛盾”情况下,plugin 输出了一个 Warning,并选择了一个相对最优的解。

更好的 Warning

上面提到的 mini-css-extract-plugin Warning 并不是非常直观,也很难知道具体是哪一个 chunk group 出现了问题。新提交的 PR #465 以及 #468 对此做了优化。优化后的输出类似:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order. Following module has been added:
 * css xxx!./e1.css
despite it was not able to fulfill desired ordering with these modules:
 * css xxx!./e2.css
   - couldn't fulfill desired order of chunk group(s) entry2
   - while fulfilling desired order of chunk group(s) entry1

根据 Warning 的提示,只需要观察 entry2 中对应的代码,就可以找到不满足的引入顺序了。

是否需要关心

这个 Warning 是否需要关注,取决于 CSS 在项目中是如何被使用的:只要在项目的使用过程中,没有 CSS 是通过加载的顺序进行优先级覆盖的,那么就可以忽略 plugin 给出的 Warning;反之,如果有这样的情况,则一定需要根据 Warning 修正输出,防止编译导致的意外。

当然,最保险的做法是启用 CSS Module,从源头上保证各个模块间的 CSS 是不会相互覆盖的。

代码分析

以下从这个 Warning 入手,分析 plugin 是如何从 modules 生成最终的打包产物的。

观察 mini-css-extract-plugin 的源代码,不难找到这个 Warning 的具体输出代码。接下来,将针对这段代码所在的 renderContentAsset 进行分析,了解 plugin 生成打包产物的算法。

首先观察 renderContentAsset 函数最开始的比较语句

if (typeof chunkGroup.getModuleIndex2 === 'function') {
  // ...
} else {
  // ...
}

这里,chunkGroup.getModuleIndex2 是 Webpack 4 中的 API,在之前的版本中不存在(Webpack 3 及之前使用的是 extract-text-webpack-plugin)。接下来,重点关心 Webpack 4 对应的代码段。

首先看 moduleDependencies 变量的初始化(源码):

const moduleDependencies = new Map(modules.map((m) => [m, new Set()]));

moduleDependencies 为每一个 module 都定义了一个对应的空 Set(具体 Set 内的值会在后续填充)。

接下来看 modulesByChunkGroup 变量的定义(源码):

const modulesByChunkGroup = Array.from(chunk.groupsIterable, (cg) => {
  // ...
  return sortedModules;
});

可以看到,modulesByChunkGroup 本质上,是将所有的 chunk group(chunk.groupsIterable)转化成了对应的 sortedModules。这里 sortedModules 变量的定义为(源码):

const sortedModules = modules
  .map((m) => {
    return {
      module: m,
      index: cg.getModuleIndex2(m),
    };
  })
  // eslint-disable-next-line no-undefined
  .filter((item) => item.index !== undefined)
  .sort((a, b) => b.index - a.index)
  .map((item) => item.module);

简单来说,做了几件事情:

  1. modules 中不属于当前 chunk group(cg)的部分剔除,对应的是 .filter((item) => item.index !== undefined)
  2. 根据 module 在 chunk group 中实际出现的位置,按从后往前进行排序,对应的是 .sort((a, b) => b.index - a.index)

这里,越是先出现的 module 在最终的 sortedModules 数组中排的越靠后。换句话说,出现在数组最后的一个 module,没有任何前置的依赖(在该 chunk group 中,这个 module 是第一个被引入的);而理论上来说,数组的第一个 module 依赖了数组后面的所有 modules(从 CSS 的角度来说,这个模块出现在最后。在所有选择器优先级一样的前提下,这个 module 理论上可以对之前所有的 module 进行覆盖)。这里采用倒叙的方式组织数组,是为了后续可以方便的使用 Array.prototype.pop 函数去获取当前没有依赖的 module。

接下来,代码sortedModules 变量值进行了填充:

for (let i = 0; i < sortedModules.length; i++) {
  const set = moduleDependencies.get(sortedModules[i]);

  for (let j = i + 1; j < sortedModules.length; j++) {
    set.add(sortedModules[j]);
  }
}

如上所述,sortedModules 中后出现的 module 是先出现 module 的依赖。这里的 moduleDependencies 变量记录了各个 module 的所有依赖(不仅仅是当前 chunk group 的依赖,所有 chunk group 的依赖最终都会被写入到这个 Set 中),其中 key 是各个 module,而对应的 value 则是一个 Set,Set 中的每个元素都是当前这个 module 的依赖。

之后定义了 usedModulesunusedModulesFilter 函数(源码),目的是为了判断某一个 module 是否已经被当前的 plugin 使用了。

接下来的代码需要确保所有的 modules 都会根据某个具体的算法在最终的编译产物中被使用到。这里判断的方法就是 usedModules 是否包含了 modules 中所有的内容。

while (usedModules.size < modules.length) {
  let success = false;
  let bestMatch;
  let bestMatchDeps;
  // ...
}

至此,准备工作都做完了。接下来就是核心的部分:如何在各个 chunk group 中选取合适的 module,依次放到最终生成的 CSS 文件中。代码如下:

for (const list of modulesByChunkGroup) {
  // skip and remove already added modules
  while (list.length > 0 && usedModules.has(list[list.length - 1])) {
    list.pop();
  }

  // skip empty lists
  if (list.length !== 0) {
    const module = list[list.length - 1];
    const deps = moduleDependencies.get(module);
    // determine dependencies that are not yet included
    const failedDeps = Array.from(deps).filter(unusedModulesFilter);

    // store best match for fallback behavior
    if (!bestMatchDeps || bestMatchDeps.length > failedDeps.length) {
      bestMatch = list;
      bestMatchDeps = failedDeps;
    }

    if (failedDeps.length === 0) {
      // use this module and remove it from list
      usedModules.add(list.pop());
      success = true;
      break;
    }
  }
}

理想情况下,两个 CSS module 在最终编译产物中的先后顺序,应该和这两个 CSS module 在某一个 chunk group 中的先后顺序是一致的。如果顺序上无法保证一致,那么应该尽可能将不一致的情况降到最低。mini-css-extract-plugin 就是遵循这一个原则来对 module 进行排序的。在每一次的选取步骤中,算法都会依次遍历每一个 chunk group,做下面几个事情:

  1. 拿到当前该 chunk group 中没有依赖的那个 CSS module(const module = list[list.length - 1];);
  2. 判断这个拿到的 CSS module 是否有前置的依赖还没有被放到最终产物中去(const failedDeps = Array.from(deps).filter(unusedModulesFilter));
  3. 如果没有前置依赖了,那么这个 CSS module 就可以被“安全”的放到当前的编译产物中去(if (failedDeps.length === 0) { 对应的部分);
  4. 如果有不满足的前置依赖,那么就去寻找不满足情况最少的一个(bestMatchDeps.length > failedDeps.length),然后记下来(bestMatchDeps = failedDeps;

如果找到了没有前置依赖的 module,代码就直接 break,跳到下一次 while 循环中去了;如果没有找到完美匹配的情况,就会进入接下来的代码

if (!success) {
  // no module found => there is a conflict
  // use list with fewest failed deps
  // and emit a warning
  const fallbackModule = bestMatch.pop();
  if (!this.options.ignoreOrder) {
    compilation.warnings.push(
      new Error(
        `chunk ${chunk.name || chunk.id} [${pluginName}]\n` +
          'Conflicting order between:\n' +
          ` * ${fallbackModule.readableIdentifier(
            requestShortener
          )}\n` +
          `${bestMatchDeps
            .map((m) => ` * ${m.readableIdentifier(requestShortener)}`)
            .join('\n')}`
      )
    );
  }

  usedModules.add(fallbackModule);
}

上面代码中的 bestMatch 就是对应了最佳情况时候 modules 排序的数组,这里 fallbackModule = bestMatch.pop() 就可以拿到当前这个最佳情况的 chunk group 中,没有依赖的那个 CSS module。和之前代码中 module = list[list.length - 1] 拿到的数据是一样的。

同时,bestMatchDeps 对应的就是上面代码里的 failedDeps 数组,表示的是当前这个 fallbackModule 被选中时,有哪些该 module 的前置依赖并没有被事先放到编译产物中去。

接下来就是向 Webpack 输出 Warning 的代码了。再来看下面这段 Warning:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order between:
* css xxx/css-loader/dist/cjs.js!./e1.css
* css xxx/css-loader/dist/cjs.js!./e2.css
* css xxx/css-loader/dist/cjs.js!./e3.css

可知道,具体的含义是:由于没法找到最合适的解,plugin 被迫使用了相对最优的解,将 e1.css 放到了编译产物中。在某些 chunk group 中,e2.csse3.css 这两个文件引用位置在 e1.css 之前;而在 plugin 将 e1.css 加入编译产物的时候,e2.csse3.css 还没有被添加。


Chunk Group🔗

Build

Chunk Group 是 Webpack 4 中新产生的一个概念。

在 Webpack 中,一个文件/资源在打包时就是一个 module,一些 module 被打包到一起,生成一个 chunk。在原来的 Webpack 中,chunk 之间的依赖是一种父子关系:如果一个 chunk 有父 chunk,那么就可以认定,在这个 chunk 被加载的时候,至少其一个父 chunk 已经被加载了。在这种假设下,Webpack 可以对 chunk 进行一些优化,比如:如果一个 chunk 中的某个 module 被所有父 chunk 使用了,那么这个 module 就可以从 chunk 中删除(因为在加载 chunk 的时候,至少有一个父 chunk 已经被加载了,故这个 module 肯定已经存在了)。

然而,这种父子关系的模式,并不利于 chunk 的拆分:在 CommonsChunkPlugin 中,如果一个 chunk 被拆分了出来,那么这个 chunk 会被“当作”是原来 chunk 的父 chunk 进行处理。这种父子关系其实是不正确的,因此也会影响到其他一些优化的进行。

因此,在 Webpack 4 中,引入了 chunk group 的概念,目的就是修正这一概念。

一个 chunk group,顾名思义,就是一组 chunk 的集合。一个 Webpack 的加载入口、或是一个异步加载点就是一个 chunk group,这个 group 中所有的 chunk 都是可以被并行加载的。同时,一个 chunk 也可以归属于多个不同的 chunk group。有了 chunk group 的概念,在通过 SplitChunksPlugin 进行拆分的时候,只需要将拆出来的 chunk 划分到对应的各个 chunk group 中去,就可以保证正常的使用了。

参考文档见这里


isEqualNode🔗

JavaScript

Node.isEqualNode 可以用于比较当前节点(Node)和指定节点是否是相同的。和 Node.isSameNode 不同,.isEqualNode API 并不需要两个被比较的节点是同一个。只需要满足以下的条件,两个节点就会被认为是相同的:

  1. 两个节点的 nodeType 是相同的;
  2. (省略非 Element 比较的情况,具体细节可以参考 DOM 规范);
  3. 如果节点是 element 的话,那么对 A.isEqualNode(B) 来说,A 中所有的属性,都可以在 B 上找到相同的值(反之亦然);
  4. 两个节点应该有等长的 children
  5. 两个节点的 children 的每个相同位置上的值都是相同的(递归调用 isEqualNode 的定义)。

这里需要说明的几点是:

  1. 在属性判断的时候,并不需要关心顺序:
const first = document.createElement('div');
const second = document.createElement('div');
const id = 'id';
const className = 'className';

first.id = id;
first.className = className;

second.className = className;
second.id = id;

// first: <div id="id" className="className"></div>
// second: <div className="className" id="id"></div>
first.isEqualNode(second); // => true
  1. 属性的判断是直接对值进行比较的,因此 style 的顺序不同会造成结果的不同:
const first = document.createElement('div');
const second = document.createElement('div');

first.style.display = 'block';
first.style.color = 'red';

second.style.color = 'red';
second.style.display = 'block';

// first: <div style="display:block;color:red"></div>
// second: <div style="color:red;display:block"></div>
first.isEqualNode(second); // => false
  1. 属性的比较是顺序无关的,但是 children 的比较是顺序相关的:
const first = document.createElement('div');
const second = document.createElement('div');
const childA = document.createElement('div');
const childB = document.createElement('div');

first.appendChild(childA);
first.appendChild(childB);

second.appendChild(childB);
second.appendChild(childA);

first.isEqualNode(second); // => false
  1. Node 是 Element 的“父类”,除了一般的 DOM 节点之外,节点上的 attributes,节点中的 comment 等也是 Node。这些节点也有 isEqualNode API 可以用于比较。对于一般的 Element 来说,可以简单的认为类型,属性和子节点一样,isEqualNode 就会返回 true

原始的比较算法,可以参考 DOM 规范;MDN 的介绍在这里


isSameNode🔗

JavaScript

Node.isSameNode 这个 API 的作用,是判断另一个 Node 节点和当前节点是否是相同的。举例来说:

const a = document.querySelector('#a');
const b = document.querySelector('#b');
const c = a;

a.isSameNode(c); // => true
a.isSameNode(b); // => false

因为在一个同一个 document 中,一个 Node 实际只有一个引用,因此 .isSameNode API 的实际效果其实和 ===== 运算是一致的。简单来说,上面的代码,可以等价于:

a === c; // => true
a === b; // => false

DOM (Living Standard) 规范中,也可以看到相关的注释,说明 .isSameNode 本质上只是因为历史原因而给出的 === 的别名(alias)。

然而在某些有限的场景下,.isSameNode 依然有发挥的应用场景,目前可以想到的有以下几点:

  1. 在节点相关算法(如 Diff 算法)中作为抽象方法直接使用。比如,在 morphdom 中,就使用了 .isSameNode 这个 API 来比较两个节点是否相同,从而节省比较的次数(源码)。根据 morphdom 给出的文档可以看到,morphdom 的算法也支持对 virtual dom 进行比较,只需要 virtual dom 也对节点实现了相应的 .isSameNode API,就有可能可以在比较的时候节省一定的计算次数。这里,.isSameNode 在 morphdom 中就被作为抽象方法使用了,算法本身并不在意真正在 diff 的对象是真实的 DOM 还是 virtual DOM,只要节点实现了符合要求的 API,算法就可以正确的进行。
  2. 通过重写方法来达到“代理节点”的功能。现在大多数的 UI 库,都通过声明式的方式来定义组件。在这种情况下,开发者并不需要显示的写出在何时通过何种方式创建或更新一个节点,只需要写出 state => UI 这样的映射函数,UI 库就会在 state 更新后,通过映射函数去得到新的 UI 组件,然后通过 diff 算法去计算得到需要修改的部分,最终将必要的部分进行更新。在这种情况下,就没有办法通过 === 去比较两个节点是否相同了,因为流程上是需要通过新的 state 生成节点,然后再和已有的 Node 进行比较。这种情况下,通过改写 .isSameNode 就可以达到人为控制的目的。

举一个 nanocomponent 中提到的例子:

const html = require('nanohtml');

const el1 = html`<div>pink is the best</div>`;
const el2 = html`<div>blue is the best</div>`;

// 对 el1 进行代理操作
const proxy = html`<div></div>`;
proxy.isSameNode = function (targetNode) {
  return (targetNode === el1);
}

el1.isSameNode(el1);   // true
el1.isSameNode(el2);   // false
proxy.isSameNode(el1); // true
proxy.isSameNode(el2); // false

虽然 proxyel1 并不是真的一样的两个节点,但是因为对 isSameNode 进行了改写,因而在 diff 算法中,两个节点会被当作是一致的。这有助于节省比较的次数。

.isSameNode API 的支持情况,可以查看 Can I Use;文档可以参考 MDN


Export Variable using Private Name🔗

TypeScript

在 TypeScript 编译过程中,可能会遇到如下的报错:

Exported variable <variable name> has or is using private name <private name>

这一报错只会出现在开启了 declaration 输出之后。开启的方式是编译时增加 --declaration,或者在 tsconfig.json 中加入:

{
  "compilerOptions": {
    "declaration": true
  }
}

出现这一报错的原因是,最终被使用的某一个类型,引用到了某一个没有被公开(export)的类型。简单的例子如下:

interface A {
  // ...
}

interface B {
  // ...
}

export declare type Props = A | B;

这里,之所以会出现问题,理由很简单:TypeScript 试图输出一个定义类型的文件,其中就包括了 Props 的定义。然而,如果要明确定义 Props,就需要用到两个类型 AB。在这里,AB 这两个类型因为没有被公开(export),因而是私有(private)的。故,理论上来说,TypeScript 的导出定义文件中不应该包含这两个类型。而没有这两个类型的话,TypeScript 就没有办法定义 Props 了。最终,TypeScript 只能报错。

官方给出的解释可以参考这里

要解决这个问题,方法也很简单:所有使用到的类型,全部都公开(export)就好了。


Progress of dd🔗

Bash

dd 是一个 Bash 命令,可以用于文件/硬盘的整体拷贝。比如,希望将 Raspberry Pi 的 SD 卡复制一份,就可以使用 dd 这个命令来进行。

但是默认的 dd 命令并没有进度提示,在完整执行完之前,默认在 stdout 中不会看到任何输出。

如果想要获得当前 dd 的执行进度,可以尝试如下的一些方法:

  1. 通过 Control + TSIGINFO 发送给 dd 命令,dd 收到后会输出当前的进度信息;
  2. 类似的,也可以通过 pkill 命令将 SIGINFO 发送给 ddpkill -INFO -x dd

其中,针对第二点的命令,可以写一个简单的脚本来定时输出当前的进度:

while pgrep ^dd; do pkill -INFO dd; sleep 10; done

dd 的输出结果示例如下:

1000+0 records in
1000+0 records out
67108864000 bytes transferred in 3.720346 secs (18038339571 bytes/sec)

更多方法(原理都是发送 SIGINFOdd),可以参考这里


Clone SD Card🔗

Bash

Raspberry Pi 的操作系统写在 SD Card 中。如果想将这个当前的系统做克隆(用于备份或存储迁移),可以通过 dd 命令来进行。

  1. 将原始的 SD Card 以及新的 SD Card 插入电脑;
  2. 通过 diskutil 命令来查看当前两张 SD Card 在 dev 中分别的命名是怎样的:
diskutil list

运行后的结果大致如:

/dev/disk2
   #:                       TYPE NAME                    SIZE       IDENTIFIER
  ...

/dev/disk3
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   ...

其中 /dev/disk2/dev/disk3 就分别是插入的两个 SD Card(具体在不同的机器上可能有所不同,需要根据 diskutil 列出的数据进行区分)。

接下来,需要将 /dev/disk3(也就是新的 SD Card)进行 unmount 操作,因为 SD Card 最终要写成的格式并不是 MacOS “理解”的格式(这里只是进行了 unmount,文件系统已经不可访问了,但是物理的 SD Card 依然是系统可以访问的,因而可以被写成任意的格式):

diskutil unmountDisk /dev/disk2

最后,使用 dd 命令进行数据的克隆就可以了:

sudo dd if=/dev/disk2 of=/dev/disk3

当然,如果不需要克隆到新的 SD Card,只是做一个简单的备份,也可以将内容保存到本地的文件中:

sudo dd if=/dev/disk2 of=/path/to/file.dmg

还原备份只需要:

sudo dd if=/path/to/file.dmg of=/dev/disk3

Ant Design Style Overwrite🔗

JavaScript

现状

当前的 Ant Design,常见的样式覆盖方案,大体上有两种:

  1. 使用 Ant Design 提供的 LESS 变量来覆盖原有的样式(详情可以参考官方的文档);
  2. 先一次性载入完整的 Ant Design CSS/LESS 文件,然后再载入新的覆盖样式(一些文章提到了这样的处理方法,比如这里)。

第一种方案的主要缺点是写法不太直观,优点是替换非常彻底,而且是官方推荐的方案;第二种方案,优点是覆盖的写法非常的直观,直接写 CSS/LESS 覆盖原有样式就可以了,但是缺点是需要一次性加载所有的样式,再覆盖。

按需加载的困难点

如果使用了 babel-plugin-import 对样式进行按需加载,再想要进行样式的覆盖,就很容易出现问题。

在了解具体可能存在的问题前,先来看一下 babel-plugin-import 的按需加载是如何运作的:在 JavaScript / TypeScript 文件遇到任何 Ant Design 组件的引用,就会同时将该组件的样式也插入到引用的位置。也就是说,如果有下面的 JavaScript 代码:

import { Select } from 'antd';

那么,编译转化后的代码大体如下:

import 'antd/lib/select/style';
import Select from 'antd/lib/select';

这里实际载入的样式文件来自 antd/lib/select/style 目录下的 index.js 文件。在这个文件中,具体引用了需要用到的各个 LESS 文件。对 Select 来说,这个样式引用的代码是:

require("../../style/index.less");

require("./index.less");

require("../../empty/style");

可以看到,除了 Select 自身的 index.less 文件外,先后还引用了 ../../style/index.less 文件和 ../../empty/style 文件。换句话说,babel-plugin-import 在处理按需加载的时候,并不是仅加载了当前使用组件的样式,还包含了一些组件需要的隐含依赖样式。这一点,在 AutoComplete 这样的复杂组件中更为明显。在使用 AutoComplete 的时候,其 style/index.js 内容如下:

require("../../style/index.less");

require("./index.less");

require("../../select/style");

require("../../input/style");

可以看到,除了自身的样式之外,Select 和 Input 的样式代码也被加载了一遍。换句话说,如果希望做按需加载的样式覆盖,在加载 AutoComplete 组件的时候,除了需要加载样式覆盖 AutoComplete 的部分,还需要额外加载样式将 Select 和 Input 的样式也覆盖一遍。而这些隐含的样式依赖,在代码上是不容易被察觉的。一旦漏了 Select 和 Input 的样式覆盖,就容易出现问题:明明 Select 的样式在加载的时候已经覆盖过了,但是在加载了 AutoComplete 组件之后,原先已经被覆盖的样式,又被新载入的 Select 原始样式给覆盖回去了。

因为 Ant Design 的样式没有采用 CSS Module,因此 CSS/LESS 的样式覆盖就强依赖于正确的加载顺序。覆盖的样式必须在原始样式的后面加载,否则结果就会出现错乱。

解决方案

为了确保按需加载的情况下,样式的覆盖顺序也是正确的,一个可行的思路是使用 Webpack 中的 loader 功能。根据 Webpack 打包的原理,任何的非 JavaScript 代码,都需要通过合适的 loader 转化成 JavaScript 文件,最终被打包到 bundle 中。而不管 babel-plugin-import 插件如何处理 Ant Design 的样式加载,这些最终被引用的 LESS 文件,都需要经过一些 loader 最终处理成可执行的文件(一般需要用到的 loader 包括 less-loadercss-loaderstyle-loader)。

既然 Webpack 的打包已经保证了统一的处理入口,那么就可以考虑在 loader 这一层,将样式的覆盖处理掉。

示例代码如下:

const fs = require('fs');
const path = require('path');

const pattern = /antd\/lib\/([^\/]+)\/style\/index.less/;

module.exports = function (content/*, map, meta */) {
  /**
   * 这里的 resourcePath 就是具体被使用的 LESS 文件的目录,详情可以参考 Webpack 文档:
   * https://webpack.js.org/api/loaders/#thisresourcepath
   */
  const { resourcePath } = this;
  const match = pattern.exec(resourcePath);
  /**
   * 1. 如果不是 Antd 相关的 LESS 文件,直接忽略不处理
   */
  if (!match) return content;
  const component = match[1];
  /**
   * 2. 根据使用的 Component 组件,找到对应的覆盖样式文件,赋值给 customizedLessPath
   */
  const customizedLessPath = getCustomizedLessFile(component);
  if (!customizedLessPath) return content;
  /**
   * 3. 如果找到了覆盖文件,就将覆盖文件插入到 LESS 的最后面,保证调用顺序
   */
  return [
    content,
    `@import "${customizedLessPath}";`,
  ].join('\n');
}

接下来,在 Webpack 中配置对应的 LESS 文件处理 loader,确保这个自定义的 loader 在 less-loader 的前面:

{
  test: /\.less$/,
  use: [
    'postcss-loader',
    {
      loader: 'less-loader',
      options: {
        // ...
      }
    },
    {
      loader: require.resolve('path-to-custom-loader')
    }
  ]
},

这样,假设 babel-plugin-import 插件插入了一段 Select 的 LESS 文件:

@select-prefix-cls: ~'@{ant-prefix}-select';
// ...

经过上面的自定义 loader 处理之后,就会变成:

@select-prefix-cls: ~'@{ant-prefix}-select';
// ...
@import "customized-less-path";

可以看到,自定义的 LESS 文件一定会在原始 LESS 文件的后面,从顺序上可以保证样式一定可以正确的被覆盖。剩下的事情,就交给 less-loader 及后续 loader 去处理就可以了。

延伸阅读

关于 Webpack loader 的写法,可以参考 Webpack 官方的教学文档


stopImmediatePropagation🔗

JavaScript

在 JavaScript 的 DOM 事件中,可以通过 .stopPropagation 来阻止事件冒泡。比如,如果有如下的一个 DOM 结构:

<div id=parent>
  <div id=child></div>
</div>

同时有如下的 JavaScript 代码:

const parent = document.getElementById('parent');
const child = document.getElementById('child');

parent.addEventListener('click', function (event) {
  console.log('click (parent): capture');
}, true);

child.addEventListener('click', function (event) {
  console.log('click (child): capture');
}, true);

child.addEventListener('click', function (event) {
  console.log('click (child): bubble');
}, false);

parent.addEventListener('click', function (event) {
  console.log('click (parent): bubble');
}, false);

那么,点击 child 元素,console 中的输出的结果如下:

click(parent): capture
click(child): capture
click(child): bubble
click(parent): bubble

这里,代码有意保持输出顺序和回调函数注册顺序的一致性。如果在上面四个回调函数中依次加上 event.stopPropagation(),那么之后所有的内容将不会在继续输出。

以上是关于 DOM 中冒泡和捕获事件处理的一般流程。这里,如果在一个 DOM 节点上注册了不止一个的事件回调函数,那么浏览器将按照事件注册的先后顺序,依次执行对应的回调函数。需要注意的一点是,event.stopPropagation() 是无法阻止同级回调函数被执行的。简单将上面的代码进行修改,可以得到如下的测试代码:

parent.addEventListener('click', function (event) {
  event.stopPropagation();
  console.log('click (parent): first capture');
}, true);

parent.addEventListener('click', function (event) {
  console.log('click (parent): second capture');
}, true);

child.addEventListener('click', function (event) {
  console.log('click (child): capture');
}, true);

那么,在点击 child 元素的时候,可以得到如下的输出结果:

click (parent): first capture
click (parent): second capture

parent 上的 click 回调函数都依次执行完毕了,而 child 上的部分则因为 event.stopPropagation() 没有被执行到。这里,如果希望连同层的其他回调函数也不要继续执行,可以改用 event.stopImmediatePropagation(),代码修改如下:

parent.addEventListener('click', function (event) {
  event.stopImmediatePropagation();
  console.log('click (parent): first capture');
}, true);

parent.addEventListener('click', function (event) {
  console.log('click (parent): second capture');
}, true);

child.addEventListener('click', function (event) {
  console.log('click (child): capture');
}, true);

修改后的代码,执行效果如下:

click (parent): first capture

几点说明:

  1. React 的合成事件只有 stopPropagation 没有 stopImmediatePropagation,如果需要使用的话,可以用如下的方法调用真正的 DOM API:.nativeEvent.stopImmediatePropagation。这里 React 不需要 stopImmediatePropagation 的理由非常简单,因为在 JSX 中,每个事件在 Component 上只能绑定一个回调函数,因此 stopImmediatePropagation 是多余的;
  2. 由于浏览器天然维护了一个 EventListener 的队列用于按顺序执行回调函数,stopImmediatePropagation 配合上回调函数的注销(removeEventListener),可以用于小成本实现一个 FIFO 的队列。示例代码如下:
function register(dom) {
  function callback(event) {
    if (event.key !== 'Escape') return;
    event.stopImmediatePropagation();
    window.removeEventListener('keydown', callback, true);
    dom.attributeStyleMap.set('display', 'none');
  }
  dom.attributeStyleMap.set('display', 'block');
  window.addEventListener('keydown', callback, true);
}

Array.from(document.querySelectorAll('ul li'))
  .forEach(register);

以上代码执行后,按下 ESC 键,将会依次将 ul 下的 li 元素一个一个的隐藏。

  1. stopImmediatePropagation API 的浏览器支持比较好,在 IE 9 及以上的浏览器中都可以使用,参考 Can I Use
  2. 更多关于这个 API 的介绍,可以参考 MDN

DevTools of DevTools🔗

Chrome

Google Chrome 的 DevTools 本质上也是一个由 Web 技术编写的应用,在必要的时候,可以通过以下的方式打开 DevTools 的 DevTools:

  1. 首先打开 DevTools;
  2. 选择将 DevTools 在独立窗口中打开,然后按下 Cmd + Opt + I(Mac)或 Ctrl + Shift + I(Windows)

这样,就可以打开 DevTools 的 DevTools 了。

注:这里一定要选择将最开始的 DevTools 在独立窗口打开,然后再按 Cmd + Opt + I;否则对于嵌入在页面中的 DevTools 来说,按下上面这个组合键,会将 DevTools 收回,而不是打开 DevTools 的 DevTools。

另一个稍微麻烦一些的方法是:

  1. 打开一个 DevTools;
  2. 在 Chrome 中打开:chrome://inspect
  3. 选择 Other,就可以找到刚才打开的 DevTools 了,点击 inspect 链接,就可以打开这个指定 DevTools 的 DevTools 了。

一个可以在 DevTools 的 DevTools 中进行的操作,是查看和修改 DevTools 中记录的 snippets。对应的 API 分别是:

InspectorFrontendHost.getPreferences(
  _ => console.log(JSON.parse(_.scriptSnippets))
);

InspectorFrontendHost.setPreference(
  'scriptSnippets',
  JSON.stringify(yourSnippets)
);

关于通过代码来管理 Chrome DevTools 中的 snippets,可以参考 GitHub 上的这个讨论

同时,Chrome DevTools 本身也是开源的,代码可以在 GitHub 上找到。


Invisible Characters🔗

JavaScript

if (true) {
  console.log('incorrect statement');
}
if (true) {
  console.log('correct statement');
}

上面的这段代码,看上去两个 if 语句并无差别。但是在实际执行的过程中,却会发现,第一个 if 语句有语法报错,而第二个 if 语句却没有。TypeScript 给出的报错信息是:Invalid character.(1127)

“看上去”一样的代码,在解释器看来却非常不同。究其原因,是因为第一个 if 语句中,有一个“看不见”的字符:

console.log(`if (true) {`.charCodeAt(0));

执行上面的这段代码,会看到在 console 中输出 8。这里,8 是 Backspace 的 ASCII 码编号。在很多的文本编辑器中,这类特殊字符并不会显示出来,但对于解释器来说,这个字符确实真是存在的。(当然,并不是所有的文本编辑器都不会显示,比如把上面的代码复制到 Chrome DevTools 中,就会看到一个 🔴 符号,用于表示这个看不见的 Backspace)

这一类的特殊字符除了 Backspace 还有很多,比如 Unicode 中的零宽空格(U+200b)、左至右符号(U+200e)、右至左符号(U+200f)等。


Require Resolve🔗

Node.js

Node.js 中的 require API 在加载 commonjs 模块的时候,会做两个事情:

  1. 根据 Node.js 的算法,查找到对应的模块文件;
  2. 加载查找到的模块文件并运行。

这里,如果只是想执行第一步,但并不真的运行这个被找到的模块,可以使用 Node.js 提供的 require.resolve API。具体的调用示例如下:

const modulePath = require('some-package');

console.log(modulePath);

上面代码中,modulePath 是一个完整的文件路径,指向的位置就是 some-package 这个模块的 entry 文件(定义在 package.json 的 main 中)。默认情况下,这个 require.resolve 的查找路径是和 require API 一致的:在查找的时候,会从当前文件的目录开始,逐级往上查找 node_modules 目录下是否有需要的库。

举例来说,假设有下面这样一个目录结构:

A.js
node_modules
|- some-package
   |- index.js
|- other-package
   |- index.js
utils
|- B.js
   node_modules
   |- some-package
      |- index.js

且,假设 A.js 的代码为:

require('./utils/B');

console.log('A: ', require.resolve('some-package'));
console.log('A: ', require.resolve('other-package'));

B.js 的代码为:

console.log('B: ', require.resolve('some-package'));
console.log('B: ', require.resolve('other-package'));

那么,输出的结果为:

B: utils/node_modules/some-package/index.js
B: node_modules/other-package/index.js
A: node_modules/some-package/index.js
A: node_modules/other-package/index.js

但有的时候,只是希望可以使用 Node.js 的查找算法,但是查找的目录位置,并不一定是从当前文件所在的目录开始的。这种时候,就需要用到 require.resolve 的第二个参数了:

require.resolve('some-package', {
  paths: [
    'where-to-start-searching',
    'other-possible-search-location',
  ]
});

这里,paths 是一个数组,表示所有的搜索起始位置。Node.js 会依次以这些路径为起始点,查找各个层级往上的 node_modules 目录。一旦找到需要的库,就停止查找,否则就会一直往上直到根目录。到达根目录后,当前的查找就以失败告终。如果还有其他的查找路径,就会继续上面的操作,否则程序会抛出异常。

第二个参数的一个实际应用场景如下:假设有一个 CLI 可以用于代理执行 Webpack 命令,这时候就需要首先通过 require.resolve 命令找出当前执行 CLI 命令的目录内,Webpack 库在什么位置。然后才可以用 require 命令去加载真正在项目中使用到的 Webpack 版本,而不是 CLI 内部可能依赖的一个 Webpack 版本。

更多关于 require.resolve 命令的说明,可以参考官方的文档


Nullish Coalescing🔗

TypeScript

Nullish Coalescing 当前在 TC39 Stage 3 的阶段,TypeScript 在 3.7 中也将这一功能引入了进来。(Coalesce 是“合并;联合;接合”的意思)

Nullish Coalescing 的简单用法如下:

let x = foo ?? bar();

foo 的值是 null 或者 undefined 的时候,x 的值由后面的 bar() 决定,否则 x 的值就是 foo 本身。这一行为,一般会被用于给变量赋初始值。在之前的 JavaScript / TypeScript 中,一般会这么写:

function getNumber(num: number) {
  return num || 5;
}

但是,这样写有一个问题,就是当 num 的值是 0 的时候,最终的值依然是 5 而不是 0。这一行为很可能并不是开发者希望的。

和 Optional Chaining 一样,Nullish Coalescing 只有在原值是 null 或者 undefined 的时候,才进行操作;其他的 falsy 值,都会保持原样,并不会做特殊的处理(根据 Proposal 中的说明,这两个规范将会在“何时处理”上保持一致)。这很大程度上减少了 JavaScript 在类型上导致隐藏问题的可能性。

需要注意的一点是,这一行为和 JavaScript 中的默认参数是有一点不一样的。上面的代码如果改写成默认参数的形式:

function getNumber(num: number = 5) {
  return num;
}

那么,将会在 getNumber(null) 的时候产生行为上的分歧。使用 Nullish Coalescing 将会返回 5,也就是进行了默认值赋值;而默认参数的方案将会返回 null,因为默认参数只有在 undefined 的情况下才会进行默认值赋值操作。

Optional Chaining 和 Nullish Coalescing 可以放在一起操作,确保值不存在的时候,有一个兜底的默认值可以给程序使用:

let x = foo?.bar?.() ?? 'default';

Object Deconstructing without Declaration🔗

JavaScript

在 JavaScript 中,新的规范定义了 object rest spread 运算符,可以用于对象的解构。

简单的用法如下:

const { value } = { value: 1, others: 2 };

除了这种解构同时赋值给新变量的情况,也可以通过解构运算,赋值给一个已有的变量:

let value = 'old';
({ value } = { value: 'new', others: 'value' });

这里需要注意的一点是,解构加赋值的运算,必须要加上括号。下面的写法会报语法错误:

let value = 'old';
{ value } = { value: 'new', others: 'value' };

会报错的原因是,前面的 { value } =,如果不加上括号,会被当成一般的代码块(Block),而不是一个解构的对象(Object),因此解析语法树的时候,在 = 这里就报错了(Uncaught SyntaxError: Unexpected token '=')。注:如果不加最后的 ;,语法也是正确的。

更多相关的相关介绍,可以参考 MDN


Optional Chaining in TypeScript🔗

TypeScript

在 TC39 将 Optional Chaining 转移到 Stage 3 之后,TypeScript 在 3.7 版本中也带来了对应的 Optional Chaining 功能。总体上,TypeScript 的 Optional Chaining 功能和 JavaScript 的提案是保持一致的。总结来说,就是:

如果属性值是 undefined 或者 null,就会直接返回 undefined,否则会进一步获取真实的属性值。

TypeScript Playground 中可以尝试一下这个新的功能。以下面这段 TypeScript 为例:

let x = foo?.bar?.();

最终会被转译成下面的这段 JavaScript:

"use strict";
var _a, _b, _c;
let x = (_c = (_a = foo) === null || _a === void 0 ?
  void 0 :
  (_b = _a).bar) === null || _c === void 0 ?
    void 0 :
    _c.call(_b);

几点简单的说明:

  1. 即使值是 null,最终返回的结果也会是 undefined(上面代码中是 void 0,是等价的);
  2. 只有 nullundefined 的情况会被直接返回。这一点,和之前 foo && foo.bar 这样的写法是有区别的。主要是 JavaScript 对哪些值是 falsy 的判断,范围会比 null & undefined 更广,还包括了 NaN0false 等;
  3. Optional Chaining 在函数调用中也是可以用的,写法是 xx?.(),如果不存在,函数不会调用,而是直接返回 undefined
  4. Optional Chaining 也可以使用如下的写法:foo?.[0]foo?.['var-name']foo?.[variableName]

官方的发布介绍文档见这里


console.assert🔗

JavaScript

console.assert API 可以用于判断某个条件是否满足,并在不满足的时候,在 Console 里打印出相关的数据。整体 API 和 console.error 比较类似,但是第一个参数是一个判断条件。整个调用,只会在第一个参数是 falsy 值的时候,才会将后面的数据打印出来。打印的方式和 console.error 类似,输出的是 error 信息。需要注意的一点是,根据 MDN 的描述,在 Node.js 10 版本前,除了输出之外,还会抛出一个 AssertionError。这个行为是错误的,console API 不应该影响主流程的代码,Node.js 在 10 修复了问题。

下面是一段示例代码:

function foo() {
  console.log('before');
  console.assert(false, 'incorrect with error message');
  console.log('after');
}

foo();

输出的结果是:

before
incorrect with error message
after

其中,incorrect with error message 这一条,还会额外输出调用的堆栈信息,方便调试。

总结来说,在代码中实现类似 Chrome 中 conditional breakpoint,使用 console.assert 是一个不错的选择:只在出现问题的时候打印必要的信息,可以尽可能的减少对 Console 输出的污染。


console.trace🔗

JavaScript

console.trace API 支持可选参数,输出的效果和 console.log / console.info 类似。但是除了输出参数指定的内容之外,还会连带将当前的调用堆栈一起输出。可以看 MDN 中给出的一个例子:

function foo() {
  function bar() {
    console.trace();
  }
  bar();
}

foo();

输出的结果类似:

bar
foo
<anonymous>

其中,<anonymous> 是因为 foo 函数是在 console 中直接运行的。当然,这个只能在调试阶段进行代码的检查。如果需要在线上环境,对可能出问题的地方收集调用堆栈信息,直接使用 console.trace 就不满足需求了。可以转而使用 Error 中的 stack 字段:

function foo() {
  function bar() {
    const error = new Error();
    console.log(error.stack);
  }
  bar();
}

foo();

输出结果类似:

Error
    at bar (<anonymous>:3:19)
    at foo (<anonymous>:6:3)
    at <anonymous>:9:1

Require Performance in Node.js🔗

Node.js

在 Node.js 中,可以通过使用 Performance API 来对 require 模块的性能进行检测。这里的 Performance 模块,是 Node.js 根据 W3C Performance Timeline 规范,实现的一套和 Web 相同的 API 接口集合。一般的时间测量,可以通过 Performance.markPerformance.measure 的组合来进行,使用的方法大体上和 Web 中一致(但是需要使用 PerformanceObserver 来获取测量的结果,这一点和 Web 不太相同,具体可以参考官方的文档)。

和 Web 不同的是,在 Node.js 的 Performance 模块中,还提供了一个 timerify 的接口,可以简便的对一个函数进行封装,从而测量出这个函数的实际调用时间。

有了这个接口,就可以很容易的测量 Node.js 中加载模块的耗时了。示例代码如下:

const {
  performance,
  PerformanceObserver
} = require('perf_hooks');
const fs = require('fs');
const mod = require('module');

mod.Module.prototype.require =
  performance.timerify(mod.Module.prototype.require);

const obs = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  fs.writeFileSync('./profile.json', JSON.stringify(entries), 'utf8');
  obs.disconnect();
});
obs.observe({
  entryTypes: ['function'],
  name: 'Module.require',
  buffered: true,
});

require('some-path');

这里有几点可以说明一下:

  1. mod.Module.prototype.requiretimerify 之后,所有模块在 require 的时候,都会使用被 timerify 过的版本;
  2. PerformanceObserver 的作用是获取 entries 的结果;
  3. obs.disconnect 用于解除连接,不再进行后续的接听;
  4. obs.observer 设置 entryTypes: ['function'],确保这里 timerify 的结果都可以被获取到;
  5. obs.observer 中设置 buffered: true,确保 observer 的回调函数不会被立刻执行,而是用 setImmediate 延迟调用。这样的好处是,一次 require 后,该模块的调用时间和该模块内部调用子模块的耗时都会一次性通过回调函数返回。(注:默认这里的值是 false,见文档);
  6. 官方给出的示例,还 timerifyrequire 函数(见这里),这样做会导致当前模块中 require 的调用,生成两份 Performance 数据(一份来自 require,一份来自 Module.require)。出于精简的考虑,上面的示例代码中去掉了对 require 函数的 timerify

在上面的示例代码中,最终得到的结果,存放在了一个 JSON 文件内,大体的格式如下:

[
  {
    "0": "required-module-name",
    "name": "Module.require",
    "entryType": "function",
    "startTime": 7397.399892,
    "duration": 112.681678
  }
]

这里,0 表示第一个参数的值,对于 require 来说就是具体引用的模块的名称/地址;name 表示是哪个函数的调用,在示例中就是被 timerify 过的 Module.require 函数;entryType 是固定的 function,因为这个值是通过 timerify 拿到的;startTimeduration 分别表示调用开始的时间以及实际调用的耗时。