Things I Learned (2020-01)

Git Auto Correct🔗

Git

在 Git 中,如果输错了一个命令,Git 会给出相应的提示。比如,如果输入 git stattus,那么会有如下的输出:

git: 'stattus' is not a git command. See 'git --help'.

The most similar command is
        status

除了报错之外,Git 也给出了可能的正确答案。需要注意的是,不仅仅是 Git 自身的命令,所有配置的 Alias 也可以享受同样的待遇。比如,假设已经设置了一个 delbranch 的 Alias,那么在输入 git dlbranch 之后,也会得到如下的输出:

git: 'dlbranch' is not a git command. See 'git --help'.

The most similar command is
        delbranch

一个很直观的想法是:既然 Git 可以计算出可能的正确输入是什么,那么直接让 Git 执行那个结果,就可以避免一次重新输入了。

根据 Git 给出的文档,可以通过类似如下的配置,来打开自动纠错的功能:

[help]
    autocorrect = 30

根据 Git 文档的描述,上面提到的 30 配置,是“三秒”的意思。也就是说,配置之后,Git 会给用户三秒的时间反悔,否则就会执行(可以通过 Ctrl+C 阻止纠错被自动执行)。输出如下:

WARNING: You called a Git command named 'stattus', which does not exist.
Continuing in 3.0 seconds, assuming that you meant 'status'.

另,The Fuck 也是一个类似思路的 Bash 自动纠错解决方案,避免重复输入。


Git Force Push with Lease🔗

Git

在一些 Git 开发流程中,需要使用 Git Rebase 来确保分支同步(比如在某一个 feature 分支上开发代码,通过 Rebase 来保证分支上一直可以有最新的开发分支上的所有提交内容)。这种情况下,在 Rebase 完 master(或其他代码提交分支)的代码之后,往往需要通过 Force Push 的方式,将 Rebase 的结果覆盖远程的工作分支。然而,Git Force Push 是一个有潜在危险的工作:当一个人在进行 Force Push 操作的时候,如果正好有另一个开发者在远端提交了新的代码,Force Push 会将远端他人的改动直接覆盖(删除)掉,导致一些代码提交丢失。

造成这一问题的直接原因,是 Force Push 的时候,代码没有带上最新的远端改动。然而,要解决这一问题却不是非常容易。因为无论进行 Rebase 的开发者如何小心,在进行网络操作进行提交的过程中,都有可能因为潜在的时间差,导致覆盖提交。

Git 为此设计了一个新的 API --force-with-lease 来解决这一问题。解决的思路是这样的:

当使用 --force-with-lease Flag 进行提交的时候,Git 会将当前提交者本地远程分支内的提交和真正远程服务器上的提交进行比较。如果两者是相同的,那么就会允许这一次的 Force Push 操作;如果发现是不同的,那么很大概率就是远端有了他人的新提交,这时 Force Push 就不会成功了。

此时,提交者应该通过 git fetch 的方式拿到最新的代码,确认是否需要进行更新改动,然后再次提交。

需要注意的是,因为 Git 只是进行了本地远程分支和远程分支的比较,因此 git fetch 之后即使什么也不做,直接再一次进行 git push --force-with-lease 操作也是可以成功的。所以操作的安全性最终还是需要人来保证,Git 只是提供了工具以确保人不会在无意间犯错误。


Git Branch Sort by Latest Commit🔗

Git

在一个大型项目中,有多个分支并行处理需求、线上问题修复是很常见的事情。然而,分支一旦多了,就不好管理了。

常规情况下,如果直接通过 git branch 命令将所有分支列出来,可能会是一个长长的列表,一下子也找不到重点。

在 Git 中,可以通过以下的方法将分支按最后提交 Commit 的日期进行排序:

git branch --sort=committerdate   # ASC
git branch --sort=-committerdate  # DESC

其中,用 -committerdate 排序时,最新的会出现在列表的最上面;而使用 committerdate 排序时,最新的会出现在列表的最下面。如果本地的开发分支非常多,terminal 一屏展示不下,可以使用 --sort=committerdate 将最新的放到最下面,方便查看。


Parse Yarn Lock File🔗

JavaScript

在使用 Yarn 管理项目的依赖时,会在项目根目录生成一个 yarn.lock 文件。这个文件的内容格式,大体如下:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


ansi-styles@^3.2.0, ansi-styles@^3.2.1:
  version "3.2.1"
  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
  dependencies:
    color-convert "^1.9.0"

一些简单的解释:

  1. 最开始的两行,是一些注释内容。这里 Yarn 使用的还是 v1 版本,v2 版本的 Yarn 依然在规划中,目前的进度可以查看这里。根据规划,v2 版本的 yarn.lock 将会成为 YAML 的一个子集,在格式上也会和 v1 有些许不同(比如 #5892 提到的去除 registry 的改动);
  2. ansi-styles@^3.2.0 是在项目某个依赖的 package.json 中使用到的依赖。需要注意的是,这里 ^3.2.0 是一个依赖的允许范围,而不是某个固定的版本号(具体允许的范围参考 semver 的定义)。只规定一个版本范围而不是某个固定的版本号,这在 Node.js 中是非常常见的,但也容易因此造成问题(如,各个环境具体安装的版本号不一致,导致运行结果有差异);
  3. Yarn 为了解决上面一条提到的问题,通过 yarn.lock 文件锁死了版本号。上面例子中,version "3.2.1" 表示的就是,最终 Yarn 使用 3.2.1 这个版本。如果有多个依赖最终使用相同的一个版本,Yarn 会将这些内容合并成一条显示,并用 , 进行分割;
  4. resolved 这个字段,表示当前的依赖应该从哪个位置进行下载。在 v1 中,这个地址是一个包含 registry 的完整地址;在 v2 版本中,前面的 registry 会被隐去,方便开发者进行 registry 的切换(参考 #5892 的讨论);
  5. integrity 这个字段,表示当前下载的包对应的 Hash 值。这个值会被用于检查开发者下载的包是否符合预期,如果下载的结果 Hash 值不同,Yarn 会报错并停止安装的步骤;
  6. dependencies 这个字段,表示当前的包还有哪些需要的依赖,这部分的字段和该包内 package.json 中写的 dependencies 字段内容是一一对应的。

如果开发某些工具,需要解析 yarn.lock 文件的内容,可以使用 Yarn 官方提供的 @yarnpkg/lockfile 工具(npm 地址见这里,GitHub 地址在这里)。

@yarnpkg/lockfile 提供了两个 API,分别是 parse(负责读)和 stringify(负责写)。用起来也很简单,参考官方给出的例子:

const fs = require('fs');
const lockfile = require('@yarnpkg/lockfile');
// or (es6)
import fs from 'fs';
import * as lockfile from '@yarnpkg/lockfile';
 
let file = fs.readFileSync('yarn.lock', 'utf8');
let json = lockfile.parse(file);
 
console.log(json);
 
let fileAgain = lockfile.stringify(json);
 
console.log(fileAgain);

经过 @yarnpkg/lockfile 解析后的 Yarn.lock,返回的对象结构如下:

{
  "type": "success",
  "object": {

  }
}

这里,type 字段有三种可能的结果,分别是(代码见这里):

  • success:表示正常的 yarn.lock 文件;
  • merge:表示存在 Git Merge Conflict 且自动 merge 的 yarn.lock 文件;
  • conflict:表示存在 Git Merge Conflict 且无法自动 merge 的 yarn.lock 文件。

object 对象中存储的内容是 yarn.lock 文件真正的解析结果(其中 conflict 情况下输出空对象,见这里)。

还是以最开始的 yarn.lock 内容为例,经过 @yarnpkg/lockfile 解析之后,object 中的对象,结构如下:

{
  "ansi-styles@^3.2.0": {
    "version": "3.2.1",
    "resolved": "xxx",
    "integrity": "sha512-xxx",
    "dependencies": {
      "color-convert": "^1.9.0"
    }
  },
  "ansi-styles@^3.2.1": {
    "version": "3.2.1",
    "resolved": "xxx",
    "integrity": "sha512-xxx",
    "dependencies": {
      "color-convert": "^1.9.0"
    }
  }
}

可以看到基本上和之前 yarn.lock 文件给出的数据是一一对应的。如果将这个对象传递给 stringify 函数,会得到一个 yarn.lock 文件的字符串,可以用于更新 yarn.lock 文件的内容。其中,这里无论给的对象是 {type:"success",object:{}} 还是仅 object 字段内的对象,都是可以正确生成 yarn.lock 文件的。


Symbol.toStringTag🔗

JavaScript

在 JavaScript 中,如果试图将一个对象(Object)转化为字符串,会得到:[object Object]。类似的,JavaScript 中常见如下的代码来检查一个对象的类型:

Object.prototype.toString.call('foo');     // "[object String]"
Object.prototype.toString.call([1, 2]);    // "[object Array]"
Object.prototype.toString.call(3);         // "[object Number]"
Object.prototype.toString.call(true);      // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null);      // "[object Null]"

Object.prototype.toString.call(new Map());       // "[object Map]"
Object.prototype.toString.call(function* () {}); // "[object GeneratorFunction]"
Object.prototype.toString.call(Promise.resolve()); // "[object Promise]"

然而,这一判断对其他一般的对象或者类并不是非常的友好,比如:

class A { }
Object.prototype.toString.call(new A()); // "[object Object]"

JavaScript 中新增加了 Symbol.toStringTag 这一 Symbol 值。通过对 Symbol.toStringTag 进行赋值,可以改变输出时候的行为,达到自定义 Tag 的效果。举例来说:

class A {
  get [Symbol.toStringTag]() {
    return 'A';
  }
}
Object.prototype.toString.call(new A()); // "[object A]"

在回到最开始举的例子上。前面的六种情况,是 JavaScript 默认就有的行为。而最后的三种,则是对应类型在 prototype 上对 Symbol.toStringTag 进行了赋值导致的行为。具体来说:

Array.prototype[Symbol.toStringTag] // => undefined
Map.prototype[Symbol.toStringTag] // => "Map"

两类有细微的差别。

更多关于 Object.prototype.toString 的行为说明,可以参考 MDNSymbol.toStringTag 相关可以参考这里


Electron version🔗

Electron

Electron 主要由 Node.js + Chromium 组成,不同版本对应的 Node.js 以及 Chromium 的版本都是不一样的。如果想要知道 Electron 具体使用了哪个版本的 Node.js 以及 Chromium,可以参考如下的方法:

  1. 在官方 Release 文档中查找对应版本号的具体情况;
  2. 在 Electron App 的运行环境中,使用 process.versions 来获取当前 Electron 使用到的各个组件的版本号,包括 Node.js,Chromium,V8 等。举例来说,以下是 Electron 7.1.7 所使用的具体版本信息:
{
  "node": "12.8.1",
  "v8": "7.8.279.23-electron.0",
  "uv": "1.30.1",
  "zlib": "1.2.11",
  "brotli": "1.0.7",
  "ares": "1.15.0",
  "modules": "75",
  "nghttp2": "1.39.2",
  "napi": "4",
  "llhttp": "1.1.4",
  "http_parser": "2.8.0",
  "openssl": "1.1.0",
  "icu": "64.2",
  "unicode": "12.1",
  "electron": "7.1.7",
  "chrome": "78.0.3904.130"
}

keyCode, key and pressing Enter🔗

JavaScript

在之前的 JavaScript 代码中,经常通过 keyCode 这个 keydown 事件中的属性,来判断当前用户按下的键是哪一个。然而,这个属性已经被废弃,不再被建议使用了(见 MDN)。取而代之的,是使用 key 或者 code 这类属性来进行类似的判断。

如果原先有如下的代码来判断用户是否按下了回车(Enter)键:

dom.addEventListener('keydown', (event) => {
  if (event.keyCode === 13 /* enter */) {
    // do something
  }
});

那么,现在使用 key 属性,可以将代码改写为:

dom.addEventListener('keydown', (event) => {
  if (!event.isComposing && event.key === 'enter') {
    // do something
  }
});

几点说明:

  1. key 属性表示当前用户按下的键是哪一个,因此可以通过 'Enter' 来判断按下的是否是回车键,更多介绍可以参考 MDN
  2. 在用 keyCode 的时候,不是所有按回车的情况都会得到 13:如果当前正在进行输入法的输入(composing),那么按回车得到的值是 229。这里,229 专门用来表示当前的按键操作由 IME(输入法)处理,等价于事件中的 isComposing 属性。相关的介绍可以参考 MDN

注:这也是从 keyCode 转换到 key 属性使用时,非常容易遇到的问题。在使用 keyCode 的时候,由于输入法输入时的 keyCode229 而不是 13,因此并不会走到 if (keyCode === 13) 的逻辑里面去;但是 key 的属性会忠实于用户的输入,无论是否是输入法状态都会返回 'Enter',如果有需求在输入法状态下按回车不响应事件,就需要额外判断一下。

  1. isComposing 属性可以用于判断当前是否正在进行输入法输入,也就是在 compositionstartcompositionend 事件的中间状态,见 MDN。注:isComposing 这个属性 IE 并不支持,可以通过 compositionstartcompositionend 来手动获得该状态。

Legacy Decorator with Computed Property🔗

JavaScript

下面这段 TypeScript / JavaScript 代码,如果使用 Babel 编译,会报错(可以在这里尝试一下):

const name = 'key';
class Something {
  @decorator [name]() { return 'value' };
}

报错内容为:Unexpected token。但如果使用 tsc,就可以正常编译。

事实上,Babel 对 TypeScript 的处理基本上就是简单的把类型定义部分给删掉,变成一段普通的 JavaScript 代码,然后再通过 Babel 转译成指定的版本。因此,本质上还是 Babel 在处理 JavaScript 的 decorator 的时候,出现了问题。

需要事先说明的是,上面这段代码在 Babel 转译的时候需要使用到 babel-plugin-proposal-decorators 中的 legacy 模式;对应 TypeScript 则是开启 compilerOption 中的 experimentalDecorators。这里使用的 decorator 语法并不是当下 stage-2 的提案版本,而是之前版本的 stage-2 提案。两者其实有着非常显著的区别:被废弃的提案更灵活,功能也更加的丰富,但灵活性/动态性也让静态代码分析变得很困难;新版本功能更受限制,但也让静态分析变得更容易了(相关的说明可以参考提案中的解释)。

回到上面的报错,这里之所以 Babel 会给出语法错误的提示,也正是因为老版本 decorator 的动态性。

具体来说,当 decorator 和 [] 一起被使用的时候,其实有两种可能性:

  1. 开发者是希望将 decorator 应用到一个计算属性上:这里的 [name] 是一个计算属性值,比如上面的代码就等价于 @decorator key = 'value'
  2. 开发者是希望将 decorator 这个对象中的 name 属性给取出来,作为真正的 decorator 来使用:也就是说,@decorator [name] 其实等价于 @decorator[name]

显然,Babel 在处理的时候,选择了第二种解释的方案,而 TypeScript 选择了第一种。正因为如此,由于 @decorator[name] 这个 decorator 的后面缺少了被装饰的属性名称,Babel 就报错了。

注:在 JavaScript 中,取下标的时候是可以添加空格的,JavaScript 会忽略这里的空格。比如,下面的取值语句并没有问题:

const map = { key: 'value' };
console.log(map ['key']); // works!

而如果将 babel-plugin-proposal-decorators 改为非 legacy 模式,上述的编译就不会报错了。这是因为,根据最新的提案,@decorator 是被定义为 decorator 的,因此不存在还需要从 decorator 中取 name 属性来作为 decorator 的情况。

更多关于新提案的细节,可以参考这里

关于这个问题本身,之前在 GitHub 上有提出 issue 作为讨论,可以在这里找到。

另外,tsc 编译不会出错是因为 TypeScript 不允许类似 @obj[key] 这样的写法。下面是一段在 TypeScript 中会报错,但是在 Babel 中不会报错的代码:

function enumerable(target: any, prop: string, descriptor: PropertyDescriptor) {
  descriptor.enumerable = true;
}

const obj = { enumerable };

class A {
  @enumerable
  works() { }

  @(obj[enumerable])
  error() {  }
}

可以在这里尝试 TypeScript 编译,在这里尝试 Babel 编译。


prefers-reduced-motion🔗

CSS

前庭系统(Vestibular System)位于人的内耳,对于人的运动和平衡能力起关键性的作用(来源)。一般常见的晕动病(Motion Sickness)就与前庭系统有关:当人眼所见到的运动与前庭系统感觉到的运动不相符时,就会有昏厥、恶心、食欲减退等症状出现(来源)。这其中,就包括了看网页上的各种动画引起的身理上的不适。需要注意的是,除了前庭系统受损外,随着年龄的增长,器官功能本身也在衰退,这些都有可能造成晕动病的症状。根据 vestibular.org 给出的数据,在美国,年龄四十及以上的成年人中,至少有 35% 的人受前庭系统疾病的困扰。显然,这不是一个小众的问题。

在各类操作系统中,都有类似的配置来减少动画,以减轻使用者的负担。比如:

  1. Windows 10 可以在 Settings > Ease of Access > Display > Show animations 中配置;
  2. MacOS 可以在 System Preferences > Accessibility > Display > Reduce motion 中配置;
  3. iOS 可以在 Settings > General > Accessibility > Reduce Motion 中配置;
  4. Android 9+ 可以在 Settings > Accessibility > Remove animations 中配置。

(完整的设置列表可以参考 MDN 列出的数据)

然而,这些是系统层面的设置,对应的是系统的一些行为。在 Web 中,可以通过 prefers-reduced-motion 这个媒体选择器来获取当前系统配置的信息。这个选择器可能的值分别是:no-preferencereduce,其中后者表示用户进行了减少动画的配置。

一个简单的使用例子:

@media (prefers-reduced-motion: reduce) {
  .something {
    animation: none;
  }
}

如此,在一般的浏览器中,.something 元素可以有一些动画效果;但是当用户配置了减少动画之后,就不再显示任何动画效果。和 Dark Mode 的配置类似(对应的笔记见这里),除了 CSS 之外,也可以从 JavaScript 和 HTML 的层面响应这一媒体选择器:

const reduceAnimation =
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;
<picture>
  <source srcset="static-image.jpg" media="(prefers-reduced-motion: reduce)">
  <img src="eye-catching-animation.gif">
</picture>

浏览器的兼容性可以查看 Caniuse

注:可以把这个媒体选择器看作一种渐进增强的功能,浏览器的适配情况不必太过在意。


bundledDependencies🔗

Build

由于 semver 的设计,在使用 npm 对项目依赖进行管理的时候,很容易遇到安装版本不完全一致的情况。而一旦开发版本和线上编译版本使用的版本不能保证一致,就非常容易出现意料之外且难于测试的问题,造成线上代码的不稳定。Yarn 和后来的 npm 都分别通过 yarn.lock 以及 package-lock.json 文件来锁定版本,保证开发和线上编译依赖的一致性。

但是,这种保证只是针对最终使用者的。具体来说,使用某个依赖的开发者可以通过 yarn.lock 文件来保证所有同组的开发者一定都使用了相同版本号的依赖;但是这个依赖的开发者没法通过 yarn.lock 来保证当用户使用自己开发的库的时候,可以使用和自己一样的依赖。

举个简单的例子:在 react-resize-detector 这个库(v4.2.0)里,附带了一份 yarn.lock 文件,指明了使用的 lodash 版本(lodash ^4.17.11)为 4.17.11。然而通过下面的命令在新的一个项目中安装,可以看到实际安装的版本会是 4.17.15:

yarn add lodash@4.17.15 react-resize-decector@4.2.0

也就是说,yarn 在安装的时候并没有参考项目内自身携带的 yarn.lock 的配置。

为此,npm 在 package.json 中设计了 bundledDependencies 这个字段来满足上述的需求。根据 npm 给出的说明,当 package.json 中指定了 bundledDependencies 字段后,这些指定的包也将在发布的时候一并被打包。这样,当其他人使用这个包的时候,就可以直接使用打包在项目内的依赖,而不需要在通过包管理器去下载了。

具体定义的方法如下:

{
  "name": "awesome-web-framework",
  "version": "1.0.0",
  "bundledDependencies": [
    "renderized", "super-streams"
  ]
}

如此定义后,renderizedsuper-streams 这两个依赖就会被一并打包发布。

bundle-dependencies 这个包为例,项目的 package.json 中写明了需要将 yargs 打包。因此,在下载到的包中可以看到,yargs 是作为 node_modules 的一个部分,连带其所有 dependencies(包括 dependencies 的 dependencies)全部都打包在内的(因而体积也比较大,tgz 有 237 kB)。

通过下面的命令也可以进一步验证,有了 bundledDependencies 之后,就可以锁定安装的依赖的 dependencies 版本号了:

yarn add yargs@4.8.1 bundle-dependencies@1.0.2

上述命令安装完毕后,bundle-dependencies 需要的 yargs v4.1.0 版本依然存放在其 node_modules 内,最外层有一个 yargs 4.8.1 版本,供其他模块使用。

需要注意的一点是,如果有了 bundledDependencies,那么即使其他地方使用到了相同版本的库,yarn 也不会将内部 node_modules 中的内容提升出来。举个例子:

yarn add yargs@4.1.0 bundle-dependencies@1.0.2

上述命令最终会安装两个 4.1.0 版本的 yargs,一个在最外层的 node_modules 内,一个在 bundle-dependencies 的 node_modules 内,两者互不干扰。


esModuleInterop🔗

TypeScript

以 React 的使用为例,在 commonjs 的环境下,React 的引用方式是:

const React = require('react');

而在 ES5 的语法中,React 官方使用的引用方式是:

import React from 'react';

(见 create-react-app 中生成的代码)

这就造成了一个问题:require('react') 这样的语法,对应的 import 语法应该怎么写?显然,require 语法无法直接转译成 import 语法:因为根据 import 的语法规则,import React from 'react'; 实际是将 default 引入,而 require('react') 的时候,并没有 .default 字段参与。

无论是 Webpack,Babel 还是 TypeScript,在这个问题上都采取了相同的策略,就是在将 import 转译成 require 语句的时候,多套一层:如果 require 的部分有 .default 字段,就使用这个字段;否则的话,就将整体当作是 .default 的值。

在 Webpack 中,使用的是 __webpack_require.n 这个函数:

__webpack_require__.n = function(module) {
  var getter = module && module.__esModule ?
    function getDefault() { return module['default']; } :
    function getModuleExports() { return module; };
  __webpack_require__.d(getter, 'a', getter);
  return getter;
};

对于 Babel,使用的是 _interopRequireDefault 这个函数:

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}
var react = _interopRequireDefault(require('react'));

对于 TypeScript 来说,在 compilerOptions 中增加 esModuleInterop 这个参数,就可以让 tsc 在编译 import 代码的时候进行一层包转转换,使用 __importDefault 确保无论是否是 ES6 module 的输出,都可以被正确的 require:

var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
const react = __importStar(require("react"));

如果编译结果需要在 Node.js 环境下运行(如进行 Jest 单元测试等),可以考虑上面的配置方案。


Dark Mode in Electron 5.0🔗

Electron

新版浏览器提供了 prefers-color-scheme 这个 Media Query 用于检测当前的系统环境是否为 Dark Mode(对应的笔记见这里)。这个 API 在 Chrome 76 及 Electron 7.0 版本中提供。

对于 Electron 5 以及 Electron 6 的版本,可以通过 systemPreferences 这个 API 来进行判断。

示意代码如下:

const { systemPreferences } = require('electron');

console.log('Current: ', systemPreferences.isDarkMode());

systemPreferences.subscribeNotification(
  'AppleInterfaceThemeChangedNotification',
  function theThemeHasChanged () {
    console.log(systemPreferences.isDarkMode());
  }
);

几点说明:

  1. 代码需要在主进程执行;
  2. systemPreferences.isDarkMode API 已经废弃了(见文档),在 Electron 7.0+ 版本中,应该使用 nativeTheme.shouldUseDarkColors 进行判断(见文档)。

Electron Builder with node_modules hoist🔗

Build

在 Electron 项目打包的过程中,多次出现了一个非常奇怪的现象:深层次 node_modules 文件目录内的同名包被“提升”到了最顶层的 node_modules 目录下,多个不同版本的 npm 包在打包后只保留了一个版本。由于只有某一个版本的 npm 包,因此在实际运行的过程中,很容易出现因 API 不兼容而导致的线上事故。

举个例子,假设有如下的 node_modules 目录结构:

- A
  - node_modules
    - react-dom@16.10.2
- B
- react-dom@16.9.0

其中 A 依赖 react-dom 的版本是 16.10.2,而 B 依赖的版本是 16.9.0。(注:这里,B 依赖的版本被提升到了顶层目录下,而 A 的依然存放在自己的 node_modules 目录内)

electron-builder 打包完成后,app.asar 内的目录结构变为:

- A
- B
- react-dom

原本多份的 react-dom 库只剩下了一份,A 和 B 都同时使用顶层的 react-dom 库。

这里,如果使用 starter 创建一个简单的 demo 并测试编译结果,会发现并不能复现上述的问题。其中,复现使用的 package.json 文件如下:

{
  "name": "electron-webpack-quick-start",
  "version": "0.0.0",
  "license": "MIT",
  "scripts": {
    "dev": "electron-webpack dev",
    "compile": "electron-webpack",
    "dist": "yarn compile && electron-builder",
    "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null"
  },
  "dependencies": {
    "source-map": "^7.0.0",
    "source-map-support": "^0.5.12",
    "@babel/core": "^7.5.0"
  },
  "devDependencies": {
    "electron": "5.0.6",
    "electron-builder": "^22.2.0",
    "electron-webpack": "^2.7.4",
    "webpack": "~4.35.3"
  }
}

其中,electron-builder 使用 22.2.0 版本,为当前的最新版本。因为项目本身针对 electron-builder 进行了二次开发,因而并不能非常确定是 electron-builder 本身的问题,还是二次开发的代码造成了这个编译的现象。故只能通过阅读源码以及调试的方法排查问题。

大体的排查过程如下:

首先,从目前已知的情报,可以大致推断出是 asar 的打包出现了问题。故选择从 app-builder-lib 这个库入手。首先从 packages/app-builder-lib/src/platformPackager.ts 中 AsarPackager 调用的 pack 函数开始查起。 这里分析函数的定义和实际运行时各个参数的具体值,注意到两点:

  1. fileSets 这个参数中包含了 node_modules/xxx/node_modules/react-dom/package.json 这个文件;
  2. 打包程序确实读取了 react-dom/package.json 的数据并进行了写操作(对应这段代码

也就是说,打包程序确实将各个版本的 react-dom 都写入到了 app.asar 文件中,但是实际在读取的时候,只能找到一份。

由此基本可以推断,是 asar 的头部数据写入出现了问题。

这里通过观察上面写入数据时候用到的 this.fs.header 不难发现,其中不包含 xxx/node_modules/react-dom 数据。因而问题进一步转化为,this.fs.header 的数据为什么出现了记录错误?

这里修改 this.fs.header 的地方基本集中在 createPackageFromFiles 函数内。注意到,在这个函数调用中,针对有问题的模块,比如 xxx/node_modules/react-dom/package.json 文件,this.fs.addFileNode 函数被执行到了(调用位置),但是 this.fs.getOrCreateNode 函数却没有被执行(调用位置)。这也就导致了文件本身被写入了,但是目录却没有被正确创建。分析 this.fs.getOrCreateNode 函数没有被执行的原因(也就是 if (currentDirPath !== fileParent) 这个判断),不难发现是 fileParent 这个变量的值有问题,归根溯源,就是 pathInArchive 这个变量的获取不对。而这个变量的值获取,依赖于 getDestinationPath 这个函数的调用。通过分析这里面的代码,基本可以定位到,是 fileSet.destination 这个变量的值出现了问题(对应使用的位置)。还是以 react-dom 为例,这里的几个变量值分别是:

  • file = ~/node_modules/xxx/node_modules/react-dom/package.json
  • fileSet.src = ~/xxx/node_modules
  • fileSet.destination = ~/Electron.app/Contents/Resources/app/node_modules

(注:这里的 ~ 指代当前的工作目录,仅用于省略无用的信息)

这里正确的 destination 应该以 app/node_modules/xxx/node_modules 结尾。

通过代码往上溯源 fileSet 的产生,可以查到是 appFileCopier.ts 中的 computeNodeModuleFileSets 函数destination 赋值了。

显然,当错误的情况出现的时候,代码运行到了 else 语句中,直接将本来是深层的 node_modules 目录强行写成了 destination = mainMatcher.to + path.sep + "node_modules"代码),也就是根目录的 node_modules 目录。这导致了当有多个不同版本的包时,最终会重复写入到同一个 node_modules 位置,并且最终只有一个版本存在。

注:这里会执行到 else 语句中,是因为项目使用了 lerna 进行管理,同时采用了 Electron 项目常见的双 package.json 目录结构,因此 app 中的 npm 包被提升到了项目的顶层目录中,本身并不存在于 app/node_modules 目录下。

这里,修复的逻辑也非常简单:无论 node_modules 是否存在于 app 目录下,当被打包到 Electron 项目中的时候,node_modules 本身的层级结构应该要被保留。

官方的 electron-builder 已经通过这个 PR 修复了问题,只需要升级到最新的代码即可。

整体修复的代码,简单介绍如下:

首先,在计算 destination 的时候,不再考虑目录是否在实际打包的根目录(app)下,全部都统一调用 getDestinationPath 函数:

const destination =
  getDestinationPath(
    source,
    {
      src: mainMatcher.from,
      destination: mainMatcher.to,
      files: [],
      metadata: null as any
    }
  );

然后在 getDestinationPath 函数内,针对这种情况,进行如下的处理:

if (
  file.length > src.length &&
  file.startsWith(src) &&
  file[src.length] === path.sep
) {
  // 这种情况种 file 就在打包的根目录(app)下,略
} else {
  // 这种是出现问题的情况

  // 这里 NODE_MODULES_PATTERN === "/node_modules/"
  // 这种情况下,返回的值应该是 dest + 第一层 node_modules 后所有的内容
  // 举例来说:
  // 如果 file 的目录是:~/node_modules/xxx/node_modules
  // 那么最终返回的结果就是 dest + xxx/node_modules
  //
  // 这里 dest 就是最终打包结果,app.asar 的位置
  // 以 Mac 为例,就是 xxx/Electron.app/Contents/Resources/app,
  // 这里 xxx 是 Electron.app 打包的具体目录
  // 根据打包的配置,Electron.app 的名字可能有所不同
  let index = file.indexOf(NODE_MODULES_PATTERN)
  if (index < 0 && file.endsWith(`${path.sep}node_modules`)) {
    // 这种情况下 file 是以 /node_modules 结尾的,13 === '/node_modules'.length
    // 此时,返回的值应该就是 dest + /node_modules
    index = file.length - 13
  }
  if (index < 0) {
    throw new Error('xxx');
  }
  return dest + file.substring(index);
}

Sentry & console.log🔗

JavaScript

Sentry 是 JavaScript 项目中非常常见的错误监控模块,一般会在 production 环境开启,且在 Sentry 加载之后会对环境中的很多 API 进行改写(比如改写 console.log API)。

这导致了一个潜在的风险,就是 production 环境和 development 环境进行 console.log 调用的“成本”是不同的。

在 Sentry v4.x 版本中,如果试图使用 console.log 输出一个 React FiberNode,很可能会造成线上代码无响应,最终触发程序 Out of Memory 的报错。

造成这一问题的原因是:

当使用 Sentry 包装过的 console.log API 进行 FiberNode 打印时,Sentry 会进行“增加面包屑”的步骤(见 @sentry/browser/src/integrations/breadcrumbs.ts)。在这一步骤中,Sentry 会试图将当前这次 console.log 调用的信息记录下来,包括调用的 API 名称、调用的参数等等。为了更好的保存数据,Sentry 会将这次的调用数据进行处理,并存储成可以方便网络传输的格式(调用的过程从 @sentry/hub/src/scope.ts@sentry/utils/src/object.ts)。在处理的过程中,Sentry 会试图去除当前 Object 潜在的循环引用,以方便 JSON 进行序列化操作(见 decycle 函数)。

decycle 的代码不难了解,Sentry 使用的是深度优先遍历搜索,遍历整个对象上的各个字段,并将所有访问过的字段都存储到 memo 中。如果访问到一个已经存在于 memo 中的字段,就认为出现了循环引用,这时候通过返回 '[Circular ~]' 字符串而不是真是的对象,来删去这个循环引用的节点。

而问题就出在这里。每一个 React FiberNode 上本身就存储了大量的信息(比如 memoizedPropsmemoizedStatetypechild 等),同时双向链表的数据结构让 FiberNode 的节点可以非常深。这使得 Sentry 分析整个对象需要花费大量的计算成本,也需要记录大量已经访问过的节点。最终呈现出来的现象就是线上程序的卡死,以及 Out of Memory 的报错。

总结来说一句话:线上程序,不要输出 console.log