Things I Learned (JavaScript)

Dropped Frame Count in Video🔗

JavaScript

如果在哔哩哔哩播放视频的过程中,出现了比较明显的卡顿,播放器的左下角会出现一个 Toast,提示“丢帧严重,已自动关闭防挡弹幕”。那么,播放器是如何检测出当前的丢帧是否严重的呢?

通过阅读哔哩哔哩播放器的部分源码,不难发现核心实现如下:

i.prototype.autoVisible = function() {
  var i = this;
  i.danmakuMask.maskIsShow() && (i.dropFramesTimer = setTimeout((() => {
    // 默认检测间隔是 600ms
    i.dropFramesTime = 600;
    // 当前总丢失帧数
    var e = i.droppedFrameCount();
    // i.dropFrames 是上次检查时总丢失帧数
    // 丢失超过 14 帧就认为丢帧严重
    if (e - i.dropFrames > 14)
      return i.player.toast.addTopHinter("丢帧严重,已自动关闭防挡弹幕", 2e3),
      i.maskDanmaku.value(!1),
      void i.danmakuMask.setting("dmask", !1);
    // 更新总丢失帧数
    i.dropFrames = e,
    i.autoVisible()
  }
  ), i.dropFramesTime))
}
,
i.prototype.droppedFrameCount = function() {
  return this.player.video && this.player.video.webkitDroppedFrameCount || 0
}

简单来说,代码判断了 600 毫秒内,是否有超过 14 帧丢失,如果有的话,就认为丢帧严重,需要做一些计算的降级。而这里判断丢帧数量的核心代码,是获取了 video 上的 webkitDroppedFrameCount 属性。因为是 webkit 开头,可以知道这个是一个非标准的 API。

通过查询 MDN 相关文档,不难发现,在正式的标准(草稿阶段)中,对应的应该是 VideoPlaybackQuality 中的 droppedVideoFrames 属性。

要获取当前 video 的丢失情况,可以使用下面的代码:

const quality = video.getVideoPlaybackQuality();
const droppedVideoFrames = quality.droppedVideoFrames;

这里 droppedVideoFrameswebkitDroppedFrameCount 是一致的。

如果想知道丢帧的百分比,可以配合 totalVideoFrames 一起判断:

const quality = video.getVideoPlaybackQuality();
const percent = quality.droppedVideoFrames / quality.totalVideoFrames;

注:视频解码前后都有可能发生丢帧。只要浏览器判断认为当前帧已经不可能在正确的时间被播放,就会触发丢帧。


Multiple Object Destructuring Assignment🔗

JavaScript

在 JavaScript 中,可以通过 destructuring assignment 来对对象内的属性进行赋值。比如:

const a = { b: 1 };
const { b: c } = a;
console.log(c); // => 1

有意思的是,同一个属性可以被多次使用,比如:

const obj = { inner: { value: 1 } };
const { inner: wholeObj, inner: { value } } = obj;

console.log(wholeObj); // => { value: 1 }
console.log(value); // => value

甚至,将一个属性赋值给多个变量也是可行的:

const obj = { key: 'value' };
const { key: key1, key: key2 } = obj;

console.log(key1); // => 'value'
console.log(key2); // => 'value'

.bind multiple times🔗

JavaScript

在 JavaScript 注册事件回调的时候,经常会用到 .bind 方法绑定 this 的指向。(关于 .bind 方法的使用,可以参考 MDN)。

这里有一个关于 .bind 使用的细节:根据 ECMAScript 规范的描述,.bind 只能修改 this 指向一次

具体的规范如下:

NOTE 2: If Target is an arrow function or a bound function exotic object then the thisArg passed to this method will not be used by subsequent calls to F.

这里规范明确了两个事情:

  1. .bind 函数对已经 bind 过的函数是无效的;
  2. .bind 函数对箭头函数是无效的。

可以简单通过下面的代码验证这个行为:

function test(...args) {
  console.log(this.name, ...args);
}

const first = test.bind({ name: 'first' }, 1);
const second = first.bind({ name: 'second' }, 2);

first();  // output: "first 1"
second(); // output: "first 1 2"

可见,.bind 函数可以继续增加预置的函数参数,但是对 this 的指向无法继续改变了。

另外,在规范里还提到了一个细节:经过 .bind 之后的函数不再有 prototype 了。具体的规范如下:

NOTE 1: Function objects created using Function.prototype.bind are exotic objects. They also do not have a “prototype” property.

验证代码:

function Animal() { }
Animal.prototype.say = function () { console.log(this.name); }

const Cat = Animal.bind({ name: 'cat' });
console.log(Cat.prototype); // output: undefined

Mnemonic Device of Array.sort🔗

JavaScript

在 JavaScript 中进行数组的排序,需要写一个比较函数,通过函数返回的结果来判断元素的顺序:如果返回的是负数,那么第一个参数在前;如果返回的是正数,那么第一个参数在后;如果返回 0,则表示两个值相等。(MDN 文档见这里

如果觉得上面提到的这种行为不容易记忆的话,可以考虑下面的这种“直观”的写法:

const list = [8, 6, 7, 5, 3, 0, 9, 2, 4, 1];

// Ascending: 最终的数组排序结果类似从 a 排序到 z
list.sort((a, z) => a - z);

// Descending: 最终的数组排序结果类似从 z 排序到 a
list.sort((a, z) => z - a);

通过 a - z 这样的“形式”来表达从 a 排序到 z(即从小到大排序);z - a 来表达从 z 排序到 a(即从大到小排序)。最终的效果非常直观。Credit: David K. 🎹

其他类似的“直观”写法,比如说:

let i = 3;
while (i --> 0) {
  console.log(i);
}

上述表达式中,通过 --> 来创建一个类似“趋向于”的表达方式(当然,其实常见的写法应该是 i-- > 0)。

这样的写法可以在语法允许的范围内创造一些更“直观”的表达方式,至于好坏,可能见仁见智。


isFrozen🔗

JavaScript

在 JavaScript 中,可以通过 Object.freeze API 将一个对象“冻结”起来。操作之后,对象就无法被修改,添加或删除属性。

如果需要判断一个对象是否被执行过 Object.freeze 操作,最简单的方法,就是使用 Object.isFrozen API:

const obj = { key: 'value' };
const frozen = Object.freeze(obj);

console.log(Object.isFrozen(obj));    // => false;
console.log(Object.isFrozen(frozen)); // => true;

具体来说,Object.isFrozen API 主要是针对对象做了以下几点判断:

  1. 对象是否是不可扩展的;
  2. 对象的所有属性是否都是不可配置的(configurable = false);
  3. 对象的数据属性(没有 getter/setter 访问器属性)都是不可写的(writable = false)。

除了 Object.freeze 后的对象 isFrozen 一定为 true 外,根据上面的规则还可以构造出一些特殊的情况也返回 true:

const empty = { };
Object.preventExtensions(empty);
// 满足第一条;且因为没有其他属性,因此满足第二,第三条
console.log(Object.isFrozen(empty)); // => true

const obj = { key: 'value' };
// 满足第一条
Object.preventExtensions(obj);
// 满足第二条 & 第三条
Object.defineProperty(obj, 'key', { configurable: false, writable: false });
console.log(Object.isFrozen(obj)); // => true

const accessor = { get key() { return 'value' } };
// 满足第一条
Object.preventExtensions(accessor);
// 满足第二条
Object.defineProperty(accessor, 'key', { configurable: false });
// 因为没有数据属性,因此满足第三条
console.log(Object.isFrozen(accessor)); // => true

Object.freeze 类似,当 Object.isFrozen API 传入值类型/null/undefined 的时候,在 ES5 和 ES2015 环境下的行为略有不同:在 ES5 下,API 会直接报错(同 Object.freeze);在 ES2015 中则会直接返回 true


Object.freeze🔗

JavaScript

在 JavaScript 中,如果使用 const 定义变量,那么变量就无法再被修改。但是这种“不可变”仅限于值类型,对于引用类型来说,不可变的只有引用指针本身,其指向的内容还是可变的。

比如:

const obj = { a: 1 };

// works
obj.a = 2;

如果希望定义的对象里面的值也不可以被修改,可以使用 Object.freeze API。如:

const obj = Object.freeze({ a: 1 });

// throw error in strict mode
obj.a = 2;

事实上,Object.freeze 处理过的对象,不仅不能修改已有的数据(包括值、可枚举性、可配置性、可写性),也不能添加和删除字段。

注:如果只是单纯的希望不能增加/删除字段,但是依然可以改变对象的值,可以考虑使用 Object.seal API。

几点 Object.freeze 值得注意的细节:

  1. Object.freeze 执行后如果试图改变对象,在 strict mode 下会报错,在非 strict mode 下不会报错,但改动行为同样不会执行:
const obj = Object.freeze({ a: 1 });

// 在非 strict mode 下,下面的语句不会报错
obj.a = 2;

console.log(obj.a); // => 1
  1. Object.freeze 返回的其实就是原来的对象。因此,即使不重新赋值,原来的对象也已经变成不可变的了:
const obj = { a: 1 };
Object.freeze(obj);

// not work
obj.a = 2;
  1. 如果在 Object.freeze 之前定义了 setter,那么在 Object.freeze 之后依然是可以其效果的。但是 Object.freeze 之后就不能再定义新的 setter 了。举例来说:
(function () {
  'use strict';
  const obj = { a: 0 };
  let val = 'value';
  Object.defineProperty(obj, 'key', {
    get() {
      return val;
    },
    set(value) {
      console.log('setter triggered with value: ', value);
      val = value;
    },
  });
  Object.freeze(obj);
  // throw error:
  // obj.a = 1;

  // not throw error:
  obj.key = 'value';

  // throw error:
  Object.defineProperty(obj, 'b', {
    get() { return 1; },
    set() { },
  })
}());
  1. Object.freeze API 不会递归对 Object 内的属性进行“冻结”操作,即:
const obj = Object.freeze({ deeper: { a: 1 } });
obj.deeper.a = 2;

console.log(obj.deeper.a); // => 2

如果希望将整个对象都进行冻结,就需要递归对所有的属性进行 Object.freeze 操作:

function deepFreeze(obj) {
  for (const name of Object.getOwnPropertyNames(obj)) {
    const value = obj[name];
    if (value && typeof value === 'object') {
      deepFreeze(obj);
    }
  }
  return Object.freeze(obj);
}
  1. 在 ES2015 中,如果 Object.freeze 传入的参数是值类型/null/undefined,会直接返回;在 ES5 中传入则会报错。

querySelector with escape🔗

JavaScript

通过 ID 选择 DOM 上的节点时,一般常用的 API 是 document.getElementById 或者更普适的 document.querySelector

问题

其中,对于后者(document.querySelector)来说,需要额外考虑一些特殊 ID 的处理。

举例来说,如果一个 ID 是以数字开头的,那么可以通过 document.getElementById 获取到,但是使用 document.querySelector 却会出现报错:

const div = document.createElement('div');
div.id = '1';
document.body.appendChild(div);

document.getElementById('1'); // => div
document.querySelector('#1'); // => Error: '#1' is not a valid selector.

同理,如果 ID 带有 : 字符,也会出现上述的问题。

这是因为,这样的命名方式,并不是合法的 CSS Selector。换句话说,不仅仅是 JavaScript API 无法生效,CSS 也无法直接生效。: 的情况很好理解,ID 中存在这样的字符,就无法很好的和 Pseudo Class 进行区分了(比如 :before)。

解决方案

要处理这样的情况,最简单的方案是使用 document.getElementById。但是,在一些场景下,可能需要生成 CSS Selector 来标记元素,方便统一的存储和使用(比如 unique-selector 这样的使用场景)。此时,可以考虑如下的几种方案去绕过这一限制:

  1. \ 的方式处理 : 字符(同 CSS 的处理方案),比如 a:b 变为 #a\:b(当然,在 JavaScript 实际使用的时候,需要注意字符串内 \ 本身的 Escape,最终的结果:document.querySelector('#a\\:b'));
  2. 使用 Unicode 来 Escape,比如将 : 改写成 \3A,对于 JavaScript 来说可能需要一次额外的 Escape,最终写法是 document.querySelector('#\\3A')
  3. 使用 [id=""] 的方案进行 ID 的选取,如 document.querySelector('[id="a:b"]')。因为属性选择器本身是在寻找字符串,因此其中就算有 : 这样的特殊字符,也不会有问题;

参考

针对 CSS Selector 的 Escape 方案(第一种和第二种),可以参考这篇文章,其中还详细列举了 CSS 中的特殊字符。

unique-selector 使用了第三种方案,具体代码可以参考这里


Trust Event🔗

JavaScript

在 JavaScript 中,除了用户操作触发事件外,也可以通过 Element.dispatchEvent 去触发一个事件。举例来说,对于点击事件,除了用户触发外,也可以通过下面的代码触发:

const button = document.createElement('button');
button.addEventListener('click', (event) => {
  console.log('clicked: ', event);
});
button.textContent = 'Click Me';
document.body.appendChild(button);

button.dispatchEvent(new MouseEvent('click'));

其中,点击事件更为特殊一些,还可以通过 Element 上本身自带的 .click 方法触发:

button.click();

那么,如何可以从代码层面区分,一次事件到底是用户触发的,还是程序触发的呢?

JavaScript 的 Event 中提供了一个 isTrusted 只读属性,用于标记当前的属性是否是由用户触发的。如果是,那么 isTrusted 就是 true,否则都是 false。这里无论是 dispatchEvent 还是直接调用 .click 方法,结果都是一样的(false)。

同时,因为 isTrusted 属性是只读属性,因此想要进行修改也是不会成功的:

const event = new MouseEvent('click');
console.log(event.isTrusted); // => false
event.isTrusted = true;
console.log(event.isTrusted); // => false;

而如果试图通过 Proxy 去包装 Event,最终虽然可以拿到 isTrusted=true,但是却无法被 dispatchEvent 认可:

const event = new MouseEvent('click');
const proxy = new Proxy(event, {
  get(target, prop, receiver) {
    if (prop === 'isTrusted') return true;
    return Reflect.get(target, prop);
  },
});
console.log(proxy.isTrusted); // => true
console.log(proxy instanceof MouseEvent) // => true
console.log(proxy.constructor) // => MouseEvent

// Error: Failed to execute 'dispatchEvent' on 'EventTarget':
//        parameter 1 is not of type 'Event'.
document.body.dispatchEvent(proxy);

// Success
document.body.dispatchEvent(event);

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 相关可以参考这里


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 编译。


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


Function toString🔗

JavaScript

众所周知,Sentry 在运行的时候,会改写原生的 console API,用于记录上下文相关的一些信息。然而,在一个有 Sentry 的页面上输入 console.log 并回车,会看到输出的内容是:

ƒ log() { [native code] }

但是如果真的输入 console.log('hello world') 执行一下,又会看到输出的文件来自于 breadcrumbs.ts 而不是常见的 VMxxx

这里,Sentry 确实重写了 console 的 API,而之所以会输出 ƒ log() { [native code] } 是因为 Sentry 通过改写 Function.prototype.toString 函数,再一次改写了输出结果,从而达到了迷惑的作用(有些代码会通过判断 toString 是否包含 native code 来判断当前的 API 是否被改写了)。

具体的代码可以在这里找到,大体如下:

originalFunctionToString = Function.prototype.toString;

Function.prototype.toString =
  function(this: WrappedFunction, ...args: any[]): string {
    const context = this.__sentry_original__ || this;
    // tslint:disable-next-line:no-unsafe-any
    return originalFunctionToString.apply(context, args);
  };

如果需要判断当前的 console.log 是否被改写了,针对 Sentry 的话只需要判断 console.log__sentry_original__ 是否存在就可以了。或者,看一下 console.log.toString.toString() 的值也是可以的,因为 Sentry 并没有对 Function.prototype.toString 也做一样的 toString 改写。

如果希望可以做更好的隐藏,那么可以考虑把 Function.prototype.toString 也改写掉:

function wrap(obj, api, f) {
  const original = obj[api];
  obj[api] = f;
  obj[api].__wrapped__ = true;
  obj[api].__wrapped_original__ = original;
}

wrap(Function.prototype, 'toString', function (...args) {
  const context = this.__wrapped__ ? this.__wrapped_original__ : this;
  return Function.prototype.toString.__wrapped_original__.apply(context, args);
});
wrap(console, 'log', function (...args) {
  return console.log.__wrapped_original__.apply(this, ['extra: '].concat(args));
});

Performance Memory🔗

JavaScript

Chrome 浏览器在 performance 对象上加上了 memory 属性,通过获取 performance.memory 可以得到一组当前页面使用内存数据的信息。具体如下:

  • jsHeapSizeLimit:表示当前页面最多可以获得的 JavaScript 堆大小;
  • totalJSHeapSize:表示当前页面已经分配的 JavaScript 堆大小;
  • usedJsHeapSize:表示当前页面 JavaScript 已经使用的堆大小。

这里,三个值的单位是字节(byte),且有恒定的不等式:jsHeapSizeLimit >= totalJSHeapSize >= usedJsHeapSize

Chrome 在分配内存的时候,会一次性向系统申请一块内存,然后在 JavaScript 需要的时候直接提供使用,因而 usedJSHeapSize 总是大于 usedJsHeapSize 的。如果 JavaScript 需要的内存多于已经申请的量,就会继续申请一块,直到达到 jsHeapSizeLimit 的上限,触发页面崩溃。注:根据之前 Gmail 团队的分享,Chrome 的进程模型,在浏览器打开非常多 Tab 的时候,会出现多个 Tab 共享一个进程的情况。因此,如果共享的几个页面中有一个内存大户,可能会导致一批 Tab 全部崩溃。

通过观察 jsHeapSizeLimittotalJSHeapSize 这两个字段,可以用于监控当前的页面是否有耗尽内存的危险;同时,如果内存一直在涨,不见回落,很可能需要排查是否有潜在的内存泄漏危险。

需要注意的几点:

  1. 出于安全方面的考虑,API 并不会给出非常准确的数据,并且给出的数据会额外加上一些干扰(参考这个 Proposal,以及这个改动);
  2. 这不是一个标准的 API,目前只有 Chrome / Opera 可以使用(参考 caniuse);
  3. 安全问题是这个 API 没有被广泛实施的原因,详情可以参考这里的讨论;Proposal 也因为安全问题不好解决(参考这里给出的解释)而暂停了;
  4. 如果需要 Chrome 给出精确的内存数据,可以在启动的时候加上 --enable-precise-memory-info

MacOS 可以通过如下的命令启动 Chrome:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --enable-precise-memory-info
  1. performance.memory 无法通过 JSON.stringify 获取到数据(结果是 {}),一些分析和解决办法可以参考这篇文章

Cost of parsing JSON🔗

JavaScript

在 JavaScript 中,直接定义一个对象(Object),性能上远不如定义一个 JSON.parse() 的表达式。具体来说,下面的两行,JSON.parse 的表达式会有更好的性能表现:

const slow = { foo: 42, bar: 1337 };
const fast = JSON.parse('{"foo":42,"bar":1337}');

同样的效果,但是在 JavaScript 引擎中的表现却差别很大。根据这里给出的测试数据,JSON.parse 的速度是直接写对象速度的 1.7 倍。而且这不仅仅只是 V8 表现上的不同,在各类 JavaScript 引擎上都有类似的表现,性能差异均非常明显(JavaScriptCore 的性能差异可以到两倍)。

这里差异的主要原因在于,引擎在解析时候算法复杂度有着巨大的差异。简单来说,JSON 的数据结构是非常简单且固定的,因而在解析的时候可以有更好的表现。这种简单体现在以下几个方面:

  1. JSON 的数据支持类型不多,只有字符串,数组,数字,NULL,对象这几种;相比之下,JavaScript 中一个对象的支持类型非常的复杂,情况更多;
  2. 从抽象语法树(AST)的角度看,JSON.parse 的情况比单纯写一个 JavaScript 对象要简单的多。对于前者来说,就是一个 CallExpression 和一个 StringLiteral;而对于一个 JavaScript 对象来说,涉及到大量的 ObjectExpression,当中可能还包含 StringLiteral,NumericLiteral,Identifier 等等;
  3. JSON 的解析是上下文无关的;而 JavaScript 对象的解析却需要结合当前的上下文(context)来确定;

举一个例子来说明:假设有这样一个 JavaScript 代码片段:

const x = 1;
const y = ({ x }

这里的 x 代表什么含义,其实有非常多的可能性,比如:

  • const y = ({ x }),此时 x 的值和上下文中的 x 变量是相关的,定义是一个 JavaScript 对象;
  • const y = ({ x } = { x: 2 }),此时 x 和上下文是相关的,但定义的是一个赋值语句,而不是对象(根据语法,对 const 二次赋值导致语法错误);
  • const y = ({ x }) => x;,此时 x 的值和上面的 x 无关,是一个函数的参数;

换句话说,当 JavaScript 引擎在解析一个 JavaScript 对象的时候,需要考虑很多的可能性,在解析的过程中很可能无法确定当前的类型,甚至连语法是否正确也不能确定。但反观 JSON,定义就简单的多,在解析的当下,引擎就可以很清楚的知道当前的内容是一个数组,还是一个对象,亦或是有语法错误。

除了上述提到的性能比较数据之外,这里还有一份针对 Redux 应用的优化分析。数据显示,使用 JSON.parse 调用之后 TTI (Time To Interactive) 时间缩短了 0.74s (18%)。考虑到整个改动是非常“简单”的,这一性能提升显得非常客观。

这里之所以说改动是非常“简单”的,是因为整个优化思路非常的明确,完全可以通过对应的工具在编译时完成。目前开源社区已经提供了各类相关的工具,可以直接使用,列举一些如下:


arguments.callee🔗

JavaScript

arguments.callee 是一个不应该被使用的 API,在严格模式下使用会直接报错。这里仅仅是作为了解,记录一下该 API 的作用。

在早期的 JavaScript 版本中,不允许写带名字的函数表达式,在这种情况下,如果需要做递归调用,就无法显式得指明需要调用的函数名称。arguments.callee 这个值,指向了当前被调用的函数本身,因此可以在匿名函数递归调用中被使用。举例来说,在早期的 JavaScript 中,Array.prototype.map 函数给定的回调函数只能是匿名的,如果要实现一个阶乘函数,只能这么写:

[1, 2, 3].map(function (num) {
  if (n > 1) return arguments.callee(num - 1) * num;
  return 1;
});

然而,arguments.callee 的调用会导致 this 的指向出现问题(具体见 MDN),使用起来比较危险。

在 ECMAScript 3 中已经支持了带函数名的表达式,因此上面的代码可以简单的改写为一下这种正常的写法:

[1, 2, 3].map(function factorial(num) {
  if (n > 1) return factorial(num - 1) * num;
  return 1;
});

换句话说,只需要给函数指定名称,就可以规避绝大多数的 arguments.callee 使用了(注:匿名函数/箭头函数无法指定名称,但同时规范也明确了匿名函数中没有 arguments)。

MDN 给出了一个 arguments.callee 无法替换的场景:

function createPerson(sIdentity) {
  var oPerson = new Function('alert(arguments.callee.identity);');
  oPerson.identity = sIdentity;
  return oPerson;
}

var john = createPerson('John Smith');

john();

这里的函数 oPerson 是通过 new Function 创建的。在字符串内无法“得知”函数会被赋值的名称,因此只能通过 arguments.callee 去获取。在某些非常特殊的业务场景中,可能会有需求将某些表达式通过字符串进行存储,并通过 new Function 构建执行。这种时候,使用 arguments.callee 获取数据类似于传参。当然,如果只是传参的需求,其实可以写成:

const script = 'alert(arg.identity)';
function createPerson(identity) {
  const closure = new Function([
    'const arg = arguments[0];',
    `return function () { ${script} }`,
  ].join('\n'));
  return closure({ identity });
}

var john = createPerson('John Smith');

john();

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


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

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)等。


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


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

computed and getter in Mobx🔗

JavaScript

Mobx 中,可以直接通过 observable 的方式来控制内部的 state,而不再使用 React 自带的 state 功能。一般的写法如下:

import React from 'react';
import { observer } from 'mobx-react';
import { observable } from 'mobx';

@observer
class Demo extends React.Component {
  @observable num: number = 1;
  onClick = () => {
    this.num += 1;
  };
  render() {
    return (
      <button onClick={this.onClick}>Clicked: {this.num}</button>
    )
  }
}

这样写的优势在于,可以将何时渲染的判断交给了 Mobx 去处理,不用手动去处理。

对于需要用到 observable 组合数据的情况,可以使用 computed 来生成一个新的 observable 值,也可以直接使用 getter 函数。以下的两个方案在效果上是等价的:

@observer
class Demo extends React.Component {
  @observable num: number = 1;
  onClick = () => {
    this.num += 1;
  };
  get isMany() {
    return this.num > 5;
  }
  render() {
    return (
      <button onClick={this.onClick}>
        Clicked {this.isMany ? 'many' : 'few'} times
      </button>
    );
  }
}
import { observable, computed } from 'mobx';

@observer
class Demo extends React.Component {
  @observable num: number = 1;
  onClick = () => {
    this.num += 1;
  };
  @computed
  get isMany() {
    return this.num > 5;
  }
  render() {
    return (
      <button onClick={this.onClick}>
        Clicked {this.isMany ? 'many' : 'few'} times
      </button>
    );
  }
}

之所以两者是等价的,理由很简单。在执行 render 函数的时候,Mobx 注意到 this.isMany 被使用了,而在调用这个 getter 函数的时候,实际使用到了 this.num 这个 observable。因此,当 this.num 发生了变化之后,Mobx 知道需要重新调用 render 函数进行绘制。而对于使用了 computed 的情况来说,情况会更简单一些,this.num 这个 observable 的变化触发了 this.isMany 的重新计算,最终在 this.isMany 值变化之后触发了 render 函数的重新计算。

然而需要注意的一点是,两者只是在效果上等价。在实际运算过程中,computed 的方案有两个优势:

  1. 代码看上去更清晰。render 是因为 computed 的数据触发的,这一点在代码上可以很容易的看出来;而第一种方案,是否触发 getter 函数,其实需要多思考一下才能确定;
  2. 实际执行效率更高。使用 getter 的方案,由于 render 函数实际上是和 this.num 这个 observable 进行关联的,因此哪怕 this.isMany 这个 getter 函数没有发生值的变化,只要 this.num 变了,render 函数都需要被执行;而对于使用 computed 的情况,因为 render 是和 this.isMany 进行关联的,实际 this.isMany 没有变化的时候,是不需要触发重绘的。换句话说,前者 getter 的方案,在 this.num 从 1 涨到 6 的过程中,一共触发了五次重新渲染;而后者 computed 的方案,只触发了一次重新渲染(当 this.num = 6 的时候)

针对第二点,Mobx 的 GitHub issue 中作者也有相关的说明,见这里


Get Current IP Address🔗

JavaScript

在 Node.js 中,可以通过 os 模块的 networkInterfaces API 来获取当前机器的 IP 数据。返回的结果类似于 ifconfigipconfig 命令。

以获取当前主机的 IPv4 地址为例,可以写类似如下的代码:

function getIPAddress() {
  const interfaces = require('os').networkInterfaces();
  const results = Object.values(interfaces)
    .flat()
    .filter(interface => interface.family === 'IPv4')
    .filter(interface => !interface.internal);
  if (results.length === 0) return null;
  return results[0].address;
}

简单的说明如下:

  • internal 用于表示当前的地址是否是本地回环地址或是其他外部无法访问的地址(例:127.0.0.1);
  • family 用于表示当前地址的类型,将会是 IPv4IPv6 中的一种;
  • address 用于表示当前的 IP 地址;
  • os.networkInterfaces 的返回是一个对象,key 用于表示 network interface,比如常见的 lo 或者 eth0 等。

更多的返回数据及解释,可以参考官方文档


Document DesignMode🔗

JavaScript

document.designMode 这个属性,可以用于控制当前的整个页面是否可以直接被编辑。可以设置的属性值包括 onoff 两种。如果设置为 on,那么相当于开启了全页面范围的 contenteditable=true。默认情况下,这个值是 off

通过开关这个值,非程序员也可以轻松的对当前页面进行简单的修改(主要是文案的部分)。一些简单的需求,PM 和 UX 就可以直接进行尝试,而不需要再借助程序员的帮忙了。当然,对页面“造假”的门槛也变低了。

可以通过下面的按钮来实际体验一下这个功能。

更多的说明及浏览器支持情况(基本可以认为全支持),可以参考 MDN


Get Npm Package Info🔗

JavaScript

要获取一个 NPM 包所有的版本信息,可以使用 npm view 这个命令。比如,检查 React 这个包的所有版本,并输出成 JSON 格式:

npm view react versions --json

当然,以上只是 CLI 的操作方式,如果希望可以通过编程的方式去了解一个 NPM 包的相关信息,需要换一个方式。注意到 NPM 本身也是一个 NPM 包,对应的源码可以在 GitHub 上找到。其中,npm view 这个命令,对应的代码是 lib/view.js

通过观察这个文件,不难发现,NPM 底层依赖的其实是 libnpm 这个库。其中,获取包信息的部分,使用的是 libnpm/packument 这个部分。而根据文档,这里 libnpm/packument 本质上就是将 pacote 中的 packument 接口开放了出来。

实际的使用方法如下:

const packument = require('libnpm/packument');

async function getVersions(package) {
  const { versions } = await packument(package, {
    // to use custom registry
    registry: 'https://registry.npm.taobao.org',
    // get all meta data
    fullMetadata: true,
    // prefer to get latest online data
    'prefer-online': true
  });
  return versions;
}

其中,packument 这个 API 的返回数据格式,可以参考 @types/pacote 中的相关定义

需要额外注意的一点是:npm 和一些 registry 服务使用的数据格式可能略有区别。举例来说,npm 的返回数据里,每个版本的 dist 中可能包含 unpackedSize 数据(optional),表示该版本文件实际的大小;而 cnpm 返回的数据中,dist 内包含的是 size 数据(源代码),表示该版本的压缩文件 tar 的大小。


MouseDown to Click🔗

JavaScript

在日常的开发过程中,对于一个按钮或者链接,一般会附上一个 onClick 事件,以响应用户的点击操作。当用户实际按下按钮或链接之后,再通过 onClick 事件去触发之后要进行的流程(比如网络请求或是链接跳转等)。

如果对于用户操作后的反馈速度有一定的要求,这里的行为就需要进行优化。以链接为例,一个常见的操作方法是(比如 quicklink),用程序对可视范围内的链接地址进行预加载(使用 prefetch)。这样,当用户真正点击的时候,资源很可能已经得到了加载,打开速度就会显著提升。

当然,这样的行为是没有预判的,纯粹暴力的进行可能的预备操作。如果预备操作损耗较多,这样的操作就显得不方便了。

一个更加“智能”的操作是,仅当用户“点击”了之后才进行预加载。实际上,即使是一个点击的的操作,也会分成好几个不同的事件,包括 MouseDownMouseUpClick。在 MouseDownClick 之间,差着大约 100ms 的时间。

换句话说,如果在 MouseDown 的时候就开始预处理,等到 Click 时才真正进行加载,那么整体的加载时间会减少 100ms 左右。在某些情况下,这也是个不小的提升了。

可以用下面的这段代码实际测试一下,MouseDown 事件和 Click 事件之间的时间差(具体时间差因人而异):

const button = document.createElement('button');
button.textContent = 'Click Me';
button.onmousedown = function () { console.log(`Mousedown: ${Date.now()}`); };
button.onclick = function () { console.log(`Click: ${Date.now()}`); };
document.body.appendChild(button);

也可以直接点击下面这个按钮尝试:

当然,比这个略激进的操作,可以将 MouseDown 事件换成 MouseEnter 事件,这样在 Hover 的时候就会开始预加载。大概能提前 300ms 左右开始操作,当然存在一定的误判风险(比如用户只是划过了鼠标,并没有想要点击的意愿)。

可以参考 InstantClick 了解更多实现的细节。


Performance Measure🔗

JavaScript

浏览器提供了 performance 用于测量 JavaScript 的一些运行效率,并在浏览器的对应位置(如 Chrome 的 Performance Tab)生成火焰图,可以方便的查看程序调用栈的执行效率。简单的操作如下:

function getMarkName(name) {
  return `mark: ${name}`;
}
function beginMark(name) {
  performance.mark(getMarkName(name));
}
function endMark(name) {
  const markName = getMarkName(name);
  try {
    performance.measure(name, markName);
  } catch (e) {
    // 如果 markName 无法被找到(也就是 beginMark 函数没有被调用)
    // 那么程序在 performance.measure 的时候会报错
    // 这里无需将报错抛出,直接吞掉就可以了
  }
  performance.clearMarks(markName);
  performance.clearMeasure(name);
}

function main() {
  beginMark('label name');
  // 需要进行的操作
  endMark('label name');
}

具体来说,通过 performance.mark 函数标记一个点,然后在需要测量的程序执行完成之后,通过 performance.measure 来计算当前和最初 mark 的点之间的运行时间。最终,这一段结果会在 Chrome 的 Performance Timings 中形成对应的火焰图数据。

performance.measure 也支持三个参数的调用,三个参数分别是 label 的名称,起始 mark 的名称以及终止 mark 的名称。如果省略最后一个参数,那么终止的时间点就是当前 performance.measure 调用的时间点。

最后,通过 performance.clearMarksperformance.clearMeasure 删除标记,清理不必要的内存使用。

更多的介绍,可以参考 MDN 的文档。React 中也使用了类似的技术用于在 Performance 中生成每个 Component 渲染花费的时间,相关的代码可以参考 ReactDebugFiberPerf.js


Mobile Shake🔗

JavaScript

JavaScript 中提供了 devicemotion 事件,可以用于监听设备各个方向上受到的力(加速度)。有了这个事件,就可以用于判断当前用户是否在进行类似“摇一摇”之类的操作,方便开发基于特定交互的一些功能。

具体来说,devicemotion 事件会提供 accelerationIncludingGravity 数据,作为一个对象分别提供 xyz 三个方向上的加速度。通过不同时间点上加速度值的不同,就可以判断当前用户是否在进行摇晃手机的操作了。

使用 devicemotion 的示例代码如下:

function handler(event) {
  const { x, y, z } = event.accelerationIncludingGravity;
  // do stuff here
}
window.addEventListener('devicemotion', handler);

判断是否在摇晃手机,简单来说,只需要判断当前的各方向加速度之差,是否有至少两个超过了给定的阈值。shake.js 中就使用了这样的方法来判断当前用户是否在摇晃手机,具体的代码可以参考源码

devicemotion 更多的信息,可以参考 MDN


Suspense & Lazy in React🔗

JavaScript

在用 React 处理业务的过程中,经常会遇到这样的场景:某一个 UI 需要等待网络请求来展示,在等待的过程中,需要显示 Loading 界面,并在请求完成后,显示真正的 UI。这种情况,和按需加载模块的行为非常类似。既然 React.Suspense + React.lazy 可以组合用于按需加载模块时候的 UI 展示,那么是否可以使用同样的组合来完成类似等待网络请求的 UI 显示呢?答案是肯定的。下面给出一个示例代码:

function sleep(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}

const fakeFetch = () => sleep(1000).then(() => "finished!");

const Component = React.lazy(() =>
  fakeFetch().then(text => ({
    default: () => <div>{text}</div>
  }))
);

const App = () => (
  <div className="App">
    <React.Suspense fallback={<div>loading...</div>}>
      <h1>Hello World</h1>
      <Component />
    </React.Suspense>
  </div>
);

如此一来,在 Promise 没有返回的时候,组件会显示 <div>loading...</div>。而等到 Promise resolve 之后,就会显示真正的 UI。

几点说明:

  1. React.lazy 本身是为 import() 设计的,所以在 Promise 返回的时候,需要将组件放到 default 属性下面,保持和 import() 的行为一致;
  2. React.SuspenseReact.lazy 的组合,本质上内部是使用了 throw + componentDidCatch 的方式进行实现的,因而如果不使用 React.lazy,直接在组件内 throw Promise,也可以达到类似的效果:
const fakeFetch = fn => sleep(1000).then(() => fn("finished!"));

let data = "before";
const Component = () => {
  if (data === "before") {
    throw fakeFetch(newData => {
      data = newData;
    });
  }
  return <div>{data}</div>;
};

const App = () => (
  <div className="App">
    <React.Suspense fallback={<div>loading...</div>}>
      <h1>Hello World</h1>
      <Component />
    </React.Suspense>
  </div>
);

Open Window Async🔗

JavaScript

一般情况下,只有当用户有操作的情况下,在一个 tick 里,JavaScript 通过 window.open 或是 <a target="_blank"> HTML 元素直接 click 打开新的窗口才能正常弹出。如果一旦涉及到异步的操作,弹框就会默认被浏览器阻止,无法正常显示。

这样设计的初衷,是为了防止前端随意弹框,影响到用户正常的体验。然而,在某些情况下,用户操作后需要经过网络请求,返回结果后才知道应该如何展示弹框。这种情况下,简单的 fetch().then(() => window.open()) 肯定是不行的。需要一些 Hack 的方案,如下。

在用户进行了操作之后,首先先打开一个新的窗口,等到异步操作返回之后,再通过 JavaScript 修改这个窗口的地址,从而达到异步打开窗口的目的。示例代码如下:

element.onclick = async () => {
  const win = window.open('');
  // 模拟异步操作
  await sleep(5000);
  win.location.href = 'actual location';
};

这样操作可能的问题及解决方法:

  1. 如果在异步的过程中本窗口被关闭了,就会留下一个空白的新窗口。因而,需要监听 beforeunload 事件,以保证必要时候可以关闭新打开的窗口;
  2. 如果异步的时间比较长,打开一个空白的窗口用户体验较差(打开后默认会获得焦点)。这种情况下,可以打开一个静态的页面,展示一个 loading 的 UI 以告诉用户当前正在进行的操作。待异步操作完成,再通过 postMessage 等方式通知窗口进行页面的跳转。

compareDocumentPosition🔗

JavaScript

在判断一个 DOM 节点是否包含另一个节点的时候,常常用到 contains 这个 API。在实际的使用从过程中,也经常会遇到这样的情况,需要判断 A 是否包含 B,返回是 false,但经过排查,发现其实 A 和 B 就是同一个节点。这种情况下,光用 contains API 就有点不够用了。同时,也暴露了这个 API 本身能力的局限性。

在 DOM Level 3 的规范中,定义了一个新的 API,compareDocumentPosition。相比于 containscompareDocumentPosition 提供了更强大的判断结果。

compareDocumentPosition 这个 API 比较后会返回一个数字,通过二进制位的比较,可以用于判断两个节点之间的关系。假设调用的函数为 A.compareDocumentPosition(B),那么返回值具体支持的类型如下:

常量名 含义
Node.DOCUMENT_POSITION_DISCONNECTED 1 不在一个文档中
Node.DOCUMENT_POSITION_PRECEDING 2 B 在 A 之前
Node.DOCUMENT_POSITION_FOLLOWING 4 B 在 A 之后
Node.DOCUMENT_POSITION_CONTAINS 8 B 包含 A
Node.DOCUMENT_POSITION_CONTAINED_BY 16 A 包含 B
Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC 32 A 和 B 的位置关系取决于具体的实现方式(不由规范确定)

这里之所以使用二进制位表示位置关系,一个很重要的原因就是:API 有可能会一次性返回多个结果。举个例子,假设 A.contains(B) 返回 true。那么,在调用 A.compareDocumentPosition(B) 的时候,返回值是 20,也就是 Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_CONTAINED_BY 的结果。换句话说,B 元素在文档中的位置在 A 的后面,同时 B 也是 A 的一个子元素。

这里,Node.DOCUMENT_POSITION_DISCONNECTED 表示两个节点不再同一个文档中,有几种可能的情况:

  1. A 和 B 中某一个存在于 iframe 中,因而两者不属于同一个文档(A.ownerDocument !== B.ownerDocument);
  2. A 和 B 中某一个元素被删除了(或没有插入到 DOM 中),导致两者不属于同一个文档(可以通过 A.parentElementB.parentElement 判断是否被删除,被删后就没有父元素了)

另外,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC 有两种情况:

  1. A 和 B 没有任何相同的 container,这种情况和 Node.DOCUMENT_POSITION_DISCONNECTED 是等价的。换句话说,当有 Node.DOCUMENT_POSITION_DISCONNECTED 的时候,一定同时有 Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC
  2. A 和 B 是同一个元素的两个属性值,这种情况下,谁先谁后是由具体实现决定的。比如,Element.attributes 返回一个 NamedNodeMap。根据规范 的定义,NamedNodeMap 不维护一个具体的顺序,但同时提供使用 index 访问的 API。也就是说,Element.attributes 中的任意两个字段,是没有定义上的先后之分的(虽然可能通过不同的下标获取到)。具体来说:
// div = <div id="id" class="class></div>
const attributes = div.attributes;
const result = attributes[0].compareDocumentPosition(attributes[1]);
// result = 36
console.log(result);

这里,compareDocumentPosition 返回的结果是 36,即 Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC | Node.DOCUMENT_POSITION_PRECEDING。因此,在实际使用 API 的时候,有必要检查是否有 Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC 这一位,如果有的话,其他的结果都可以忽略不计了。

另外,如果 A 和 B 是同一个元素,那么返回的结果将是 0,因为 A 和 B 的关系不属于上面列出的任何一种情况。同时,也不难发现,只有当 A 和 B 是同一个元素的时候,才会出现返回值是 0 的情况。


Download Chunk via ServiceWorker🔗

JavaScript

现在 Web 端的视频播放,大多采用基于 HLS 或是 MPEG Dash 的方案,将视频内容分解成一系列小型 HTTP 文件片段,每段都包含很短长度的可播放片段,由前端逐个拉取片段并播放,最终形成完整的播放视频。

对于一些云存储网站来说,也可以通过类似的方案来为用户提供下载服务。在分片下载文件的过程中,服务商可以对下载的用户进行校验。同时,由于需要分段下载内容并拼接,避免了单一 URL 造成盗链等问题。然而,一个用户体验的问题是,这种形式的下载如何可以给用户一个更好的用户体验:显然不能将分段的下载内容直接呈现给用户,用户也不应该关心这些分片的内容;如果要等到前端将所有内容下载完成并拼接后再呈现给客户,那么在文件较大的时候会让用户等待很久,用户体验不佳。

这时候,就可以用到 Service Worker 的 Proxy 功能了,可以在前端进行拼接数据的过程中,给用户等同于一般下载文件的体验。

大致的流程代码如下:

首先,需要在 Service Worker 和 Main 线程见建立一个通信机制。比如,可以选择使用 MessageChannel。在 Main 线程创建一个 MessageChannel,然后将 Channel 发送给 Service Worker。之后两者通过这个 Channel 进行数据的沟通(主要是 Main 将下载好的文件片段发送给 Service Worker)。

接着,在 Service Worker 端的 MessageChannel 收到新的数据之后,创建一个 ReadableStream 并将数据写入这个 Stream。

最后,Main 会通过 JavaScript 访问一个不存在的下载链接,里面应该包含一个 ID,用于指明需要的文件具体是哪一个(主要是考虑到多个文件同时下载的情况)。Service Worker 通过 fetch 事件拦截这个请求,并通过 URL 中的 ID 找到对应的 ReadableStream,并将这个 Stream 作为 Response 返回。这样,在浏览器的下载页面就可以看到该文件正在被下载。和原生的下载体验一致,这里也可以看到下载的名称、当前的速度、剩余的时间等信息。

如此,一个完整的流程就走完了。前端下载文件分片,将分片数据发送给 Service Worker,Service Worker 收到数据之后,将数据写入到 ReadableStream 中去;同时,这个 ReadableStream 以 Response 的形式返回给 Main 线程,将这个拼接中的文件逐步下载到本地。

Fetch 事件的代理代码如下:

self.onfetch = function(event) {
  const { url } = event.request;
  // 跳过一般的请求
  if (!isDownloadUrl(url)) return event.respondWith(fetch(event.request));

  const headers = new Headers();
  headers.append('Content-Type', 'application/octet-stream; charset=UTF-8');

  // 获取 URL 中的 ID 数据
  // 相当于 Main 线程通过 URL 传递参数给 Service Worker,用于表示想要下载的具体数据
  const id = getDownloadFileID(url);
  const streamInfo = streamMapping[id];

  if (!streamInfo) {
    // 没有找到数据的情况,返回 404
    return event.respondWith(new Response('Not Found', {
      headers,
      status: 404,
      statusText: 'Not Found'
    }));
  }

  const { filename } = streamInfo;
  // Content-Disposition 中的 filename 必须是 US-ASCII
  // http://tools.ietf.org/html/rfc2183#section-2.3
  const asciiName = filename.replace(/[^\x20-\x7e\xa0-\xff]/g, '?');
  // 通过 filename*=UTF-8''xxx 这样的方式,可以让浏览器使用 UTF-8 的文件名
  const encodedName = encodeURIComponent(filename)
    .replace(/['()]/g, escape)
    .replace(/\*/g, "%2A");
  headers.append(
    'Content-Disposition',
    `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
  );
  headers.append('Content-Length', `${streamInfo.filesize}`);
  headers.append('X-Content-Type-Options', 'nosniff');
  // 将 Service Worker 中的 stream 作为 Response 返回
  // 只要 Stream 没有完结,浏览器的下载行为就会继续,直到 Stream 停止
  return event.respondWith(new Response(streamInfo.stream, {
    headers
  }));
}

关于上面代码的两个延伸阅读:

  1. 虽然 Content-Disposition 默认只能写 ASCII 的文件名,但是 UTF-8 的文件名也是可以设置的。关于 filename*=UTF-8''xxx 这种设置方案,在 StackOverflow 上有相关讨论
  2. X-COntent-Type-Options 设置为 nosniff 可以阻止浏览器的 MIME 类型嗅探,更多讨论可以参考 MDN

创建和使用 Stream 的代码如下:

function getStream() {
  return new Promise((resolve) => {
    const stream = new ReadableStream({
      start: function(controller) {
        resolve(stream);
      },
      pull: function(controller) {
        return new Promise((resolve, reject) => {
          // 当从 Stream 获取数据的时候,返回一个 Promise
          // 并在 onUpdate 赋值,等待 Main 线程的数据
          // 当 Main 线程传递新数据之后,调用这里的 onUpdate 函数,将 data 传入
          // 接下来通过 FileReader 读取数据,转化成 Uint8Array,放入 Stream 中
          // 在清除 onUpdate 函数,等待下一次 Pull
          streamInfo.onUpdate = (data, callback) => {
            const reader = new FileReader();
            reader.onload = (e) => {
              const { target } = e;
              if (streamInfo.stream) {
                controller.enqueue(new Uint8Array(target.result));
                streamInfo.onUpdate = null;
                resolve();
                callback();
              } else {
                reject();
              }
            };
            reader.readAsArrayBuffer(data);
          };
        });
      }
    });
  });
}

从 Main 获取数据并更新给 Stream 的代码如下:

self.onmessage = function (event) {
  const { data, ports } = event;
  const [portA = null, portB = null] = ports;
  const promise = new Promise((resolve) => {
    // 根据消息类型,选择创建一个新的 stream 或是往一个已经创建的 stream 中写入数据
    if (data.type === 'create') {
      getStream().then(stream => {
        streamInfo[data.id] = {
          filesize: data.filesize,
          filename: data.filename,
          stream
        };
        resolve();
      });
    } else if (data.type === 'insert') {
      portB.onmessage = (msg) => {
        const { chunk } = msg.data;
        if (streamInfo[data.id].onUpdate) {
          streamInfo[data.id].onUpdate(chunk, resolve);
        } else {
          // 等待 onUpdate API 创建...
        }
      }
    }
  });
  event.waitUntil(promise);
}

MyAirBridge 网站使用了类似上面提到的技术来下载中的文件内容。Service Worker 的代码参考这里


Clipboard Event🔗

JavaScript

JavaScript 提供了 copy 事件,可以针对一般的复制动作进行一些额外的操作。比如,一些网站出于版本的考虑,可能会禁止拷贝;或者,一些网站允许拷贝,但是会希望在拷贝的内容后面加上一些版权的声明。这些操作,都可以通过 copy 事件进行处理。

举例来说,如果希望禁止网站上内容的拷贝,可以写:

window.addEventListener('copy', (event) => {
  event.preventDefault();
});

在上面的代码中,通过 event.preventDefault API 的调用,可以组织浏览器默认的复制行为。这样,即使用户进行了复制,实际上剪贴板也不会被更新。

第二个例子,假设希望在复制的内容后面加上额外的数据,可以写:

window.addEventListener('copy', (event) => {
  const selection = document.getSelection();
  const text = selection.toString();
  event.clipboardData.setData('text/plain', text + '\nExtra Text at Bottom');
  event.preventDefault();
});

需要注意几点:

  1. setData 的第一个参数是数据的格式,支持的类型可以参考 MDN,主要就是 MIME 类型,一般纯文本可以使用 text/plain
  2. 在调用 setData 之后,需要调用 event.preventDefault 才能保证设置成功,否则最终复制出来的依然是原始的文案

最后,需要说明的一点是:出于测试或者其他目的,JavaScript 也支持创建一个 ClipboardEvent 并发送给监听的元素,如:

window.addEventListener('copy', (event) => {
  console.log('copy event triggered');
  event.clipboardData.setData('text/plain', 'hello world');
  event.preventDefault();
});
const event = new ClipboardEvent('copy', { clipboardData: new DataTransfer() });
window.dispatchEvent(event);

那么,回调函数可以正常执行(Console 可以看到输出),但是 event 内尝试设置 Clipboard 的数据并不会成功。

总体上来说,浏览器端提供的 Copy 事件,只能获取/修改浏览器内发生的剪贴板复制操作;对于用户本身剪贴板操作内有的数据是无法读取的,在非用户触发的情况下,剪贴板的数据也是无法直接被修改的。

延伸阅读


URLSearchParams🔗

JavaScript

前端项目,总免不了写一些操作 URL 中 query string 的 API 代码,比如读取当前 URL 中的 query 数据,或是根据一个 Object 对象拼接出一个 query string,等等。

其实,现代浏览器中已经提供了 URLSearchParams 类,可以大大简化这部分的操作,也无需再自己维护一个 qs 或是类似的包了。

以下介绍如何通过 URLSearchParams 实现 qs.stringifyqs.parse API 的方法:

qs.parse 的方法比较简单,只需要将字符串传递给 URLSearchParams 并创建实例就可以了,实例本身自带了 iterator,也提供 getkeys 等 API 能很方便的获取需要的数据。

const searchParams = new URLSearchParams(location.search);
for (const param of searchParams) {
  const [key, value] = param;
  console.log(key, value)
}

需要注意的一点是,不论参数传入的字符串是否以 ? 字符开头,URLSearchParams 都默认可以正确处理,不需要像 qs 包一样显示的指明给定的字符串是否有 ? 开头(ignoreQueryPrefix)。

要实现 qs.stringify 的功能也不难,URLSearchParams 的构造器支持传入一个数组或一个对象,也提供了 append API 可以将 key value 一组一组的加入到对象中,最后只要使用 toString 拼接出一个完整的字符串就可以了:

// result in: `a=b`
(new URLSearchParams({ a: 'b' })).toString();
// result in: `c=d&e=f`
(new URLSearchParams([ ['c', 'd'], ['e', 'f'] ])).toString();

注意,toString 方法得到的字符串,最开头并没有带上 ? 字符,如果有需要的话,可以自行加上。

综上,下面的等式是成立的(假定 location.search 不是一个空字符串):

location.search === `?${(new URLSearchParams(location.search)).toString()}`

当然,URLSearchParams 在某些场景下还是不能替换 qs 之类的库,比如:

  1. 项目需要支持老浏览器,如 IE 时。URLSearchParams 的浏览器支持情况见:CanIUse,总体来说,现代的浏览器都已经支持了,但是 IE 完全没有。
  2. 需要使用一些比较冷门的解析功能时,如 qs 提供了很多复杂的解析方案(详情见文档

但总体来说,绝大部分的应用场景下,URLSearchParams 都可以轻松应对,不需要额外的库进行志愿了。

更多关于 URLSearchParams 的介绍,可以参考 MDNEasy URL Manipulation with URLSearchParamsWHATWG Spec


Replace All Substring🔗

JavaScript

JavaScript 内建的 String.prototype.replace 函数,如果传入的第一个参数是字符串,那么替换行为只会发生一次。如果需要将一个字符串内所有某子字符串都替换掉,往往需要一些额外的操作。以下提供一些可行的方案:

  1. 使用循环进行多次替换

最直观的想法,就是替换完成后通过 indexOf 等方案查找字符串,如果还有就继续替换:

function replace(input, from, to) {
  while (input.indexOf(from) >= 0) input = input.replace(input, from, to);
  return input;
}

当然,这并不是一个优雅的解决方案。

  1. 使用正则表达式

String.prototype.replace 支持第一个参数传递正则表达式。有了正则表达式,只要设置上 g 标签,就可以全局匹配并替换所有的情况了。示例代码如下:

function replace(input, from, to) {
  return input.replace(new RegExp(from, 'g'), to);
}

这个方案的劣势在于,如果需要替换的内容中含有某些正则表达式特有的匹配符号,可能会导致非预期的结果。举个例子来说,如果希望把 .+ 这个字符串替换成 +. 这样,上面的函数并不能达到预期的效果,因为 .+ 在正则表达式中可以匹配任意的字符。replace('hello.+world', '.+', '+.') 的执行结果是 +.

  1. 使用 split & join

这是一个比较取巧的方案,先用 split 函数将字符串进行拆分,然后再用 join 将拆分后的结果重新拼接起来。示例代码如下:

function replace(input, from, to) {
  return input.split(from).join(to);
}

这个方案代码比较简洁,也不会有正则表达式中提到的问题。虽然计算会产生中间变量(数组),但只要不是频繁或在大规模数据上使用,效率的影响可以忽略不计。


Glob in NPM🔗

JavaScript

在使用 stylelint 的时候,发现了一个有趣的问题:如果直接使用 stylelint 的 bin 文件对批量 LESS 文件进行检查,程序可以如预期的运行;但是如果把同样的命令写到 package.json 中,以 npm script 的方式进行运行,最终被检查的文件就少了很多,实际只有一个文件参与了检查。

具体来说,./node_modules/.bin/stylelint src/**/*.less 这个命令可以检查所有的 LESS 文件,但是把 stylelint src/**/*.less 写到 package.josn 中之后,再运行却只检查了一个文件。

通过检查 stylelint 的文档,发现官方在写命令的时候,写法和上述略有不同,为:stylelint "src/**/*.less"

经过排查问题,发现根源在于:npm 使用了 sh 来执行代码,而 shzsh 在解析 Glob 的时候,行为是不同的。

npm,包括其他 Linux 进程,在使用 shell 的时候,默认使用的都是 sh,除非有其他明确的指定。这意味着,即使当前正在使用的 shell 是 zsh,在运行 npm 命令的时候,还是默认使用了 sh 对脚本进行执行。也就是说,./node_modules/.bin/stylelint src/**/*.less 这个命令执行,使用的是当前打开的 shell 程序(比如 zsh);而当这个命令写到 package.json 中,并以 npm script 的方式进行运行的时候,执行 shell 的就是 sh 了。

使用不同的 shell 程序,难免就会在行为上造成不一致。这里的 Glob 解析就是一个例子。在 zsh 里面可以简单的做一个实验。执行如下的命令:

ls src/**/*.less

可以看到,zsh 给出了当前 src 目录下所有的 LESS 文件, 不管这个文件是在多深的子目录下;而如果先在 zsh 中执行 shbash 进入到 shbash 的工作环境中,再执行同样的命令,可以看到输出的结果可能就是不同的。实际上,对于 sh 来说,它本身并不识别 ** 这个语法,这个表示在 sh 中会被简单的识别为 *src/**/*.lesssh 中等价于 src/*/*.less。换句话说,在 sh 的环境中,上述命令只会寻找所有在 src 目录下一级子目录中的 LESS 文件,一旦层级大于一层,就不会被找到了。

这也是为什么同样的命令,直接执行和在 npm 中执行会有差异的原因。

最后,加上双引号 stylelint "src/**/*.less" 就可以解决这一问题的原因在于:一旦加上了双引号,这一个 Glob 就不会被 shell 直接解析,而是会以字符串的形式直接传递给 stylelint。(具体来说,如果不加双引号,shell 会先将 Glob 解析成一组具体的文件,stylelint 实际拿到的 process.env.argv 很可能会是一个很长的字符串数组,每一个元素都是一个具体的文件;而如果加上了双引号,stylelint 拿到的只有一个 Glob 表达式字符串。)有了这个 Glob 的字符串,stylelint 内部就可以使用相应的 package 来进行解析,从而得到一串具体的文件列表。因为使用了 stylelint 内部自带的 Glob 解析,就可以保证在不同的 shell 环境中都得到一致的结果了。

参考


Big Number in JavaScript🔗

JavaScript

JavaScript 中可以很方便在字符串和数字之间进行转换,比如:+'123' => 123(123).toString() => '123'

然而,需要注意的一点是,JavaScript 中的数字并不是整数,而是浮点数。更确切的说,数字使用的是 64 bit 双精度浮点数来表示的。这意味着,如果服务器存储的数字是一个 Int64,那么在给到前端的时候,很有可能会出现转化上的问题。对于双精度浮点数来说,能够表示的最大的数是 25312^{53}-1,超过的部分就会被截断,无法精确表示。

比如:

console.log(+'9223372036854775808');
console.log(2 ** 63)
// output: 9223372036854776000

JavaScript 提供了 Number.isSafeInteger 这个 API 来判断一个数字是否是在可表示的安全范围内。比如:

console.log(Number.isSafeInteger(2 ** 63));
// output: false
console.log(Number.isSafeInteger(9223372036854776000));
// output: false
console.log(Number.isSafeInteger(2 ** 53 - 1));
// output: true

这里,2 ** 53 - 1 就是 JavaScript 中可以表示的最大整数,Number.MAX_SAFE_INTEGER 这个常量也等于这个值。超过这个数值的所有值都会被认为是不安全的,哪怕该值实际表示的结果“凑巧”是正确的。上例中,9223372036854776000 这个数字的表示结果“刚好”就是 9223372036854776000 本身,但是因为这个数已经超过了 25312^{53} - 1,所以依然被判定为是不安全的。

虽然 JavaScript 本身的数字不支持大数,但是 Chrome 已经集成了 BigInt 数据类型,它可以被用于表示任意大的整形数字,可以用于这样的使用场景。(注:BigInt 本身还在 staging 3,并不是标准的一部分)

简单的使用方法如下:

const num = BigInt(2 ** 63);
// or:
// const num = BigInt('9223372036854776000');
console.log(num);
// output: 9223372036854775808n
console.log(typeof num);
// output: bigint

需要注意的是,BigInt 不可以使用 new 运算符,否则会报错。直接像函数一样传递参数调用就可以了。

BigInt 也是支持数字运算的,运算的结果依然是 BigInt

console.log(1n + 2n); // => 3n
console.log(3n - 1n); // => 2n
console.log(2n * 3n); // => 6n
console.log(5n / 2n); // => 2n

特别需要注意的是,因为是整型数字之间的转换,所以在做除法的时候,不会出现小数。在上面的例子中,5n2n 的除法,结果是 2n 而不是 2.5,这一个行为和 C 中两个 Integer 之间除法的行为是一致的。

另外,BigInt 不支持和其他的数据类型进行混合计算。比如:1n + 2 这样的计算是会报错的,需要显式的进行类型转换后,才可以进行运算。这一点,和 JavaScript 中其他数据类型之间随意混乱的运算行为是不同的(比如,1 + '2' 这样的计算 JavaScript 就不会报错,还会得到 '12' 这样怪异的结果)。

虽然 BigInt 不允许和一般的 Number 进行混合计算,但是比较运算符是可以在两者之间进行比较的。比如:1n < 22n > 1 这些都是成立的。BigIntNumber 之间无法取得 === 的严格等价关系,但是 == 的比较是可能成立的。换句话说:1n == 1 是成立的,但是 1n === 1 是不成立的。

更多关于 BigInt 的行为,可以参考 MDN


Jest with Ant Design🔗

JavaScript

当 Ant Design 和 Jest 一起使用的时候,在某些情况下(开启 coverage 的时候)会导致单元测试运行失败。一个可能造成问题的 Ant Design 代码如下:

import { Input } from 'antd';

const { TextArea } = Input;

Jest 会报错:

ReferenceError: Input is not defined

  1 | import { Input } from 'ant-design';
  2 |
> 3 | const { TextArea } = Input;

报错的直接原因,是使用了 Ant Design 推荐的 babel-plugin-import 和 Jest 计算 coverage 使用的 babel-plugin-istanbul 造成的。在这里这里等 GitHub Issue 中都有相应的讨论。

要修复这个问题,只需要在 Jest 或者单元测试环境中,不使用 babel-plugin-import 这个转换插件就可以了。参考代码如下,在 .babelrc 中:

{
  "env": {
    "development": {
      "plugins": [
        [
          "import",
          {
            "libraryName": "antd",
            "style": true
          }
        ]
      ]
    },
    "production": {
      // same as above
    }
  },
  "plugins": [
    // rest of plugins...
  ]
}

如此一来,只有在 NODE_ENVproductiondevelopment 的情况下,Babel 才会启用 babel-plugin-import 这个转换插件。对于 Jest 来说,因为默认设置了环境变量 NODE_ENVtest,所以 Plugin 不会起效。

这样造成的问题是 Jest 的运行速度会有所降低。


Import Chunkname with Babel Plugin🔗

JavaScript

默认情况下,Webapck 会用 Chunk ID 为 import() 产生的独立文件命名,最终的结果就是类似于 0.bundle.js 这样的文件。这样的文件并不方便理解和管理,所以一般会使用 webpackChunkName 这个注释来让 Webapck 使用更加有意义的命名。例子:

import(/* webpackChunkName: "module-name" */ 'path-to-bundle');

最终产生的文件为 module-name.bundle.js(这里假设在 Webpack 中配置了 output.filename[name].bundle.js)。

然而,每次要手写这样的注释有些麻烦。如果动态加载的模块本身存放位置有规律可循(比如是在 pages 目录下,每个目录有一个入口文件),那么也可以考虑使用 Babel 插件的方式,自动为每个 import() 增加合适的 bundle name。

参考代码如下:

function addComments(arg, name) {
  // only add leading comment when not found
  if (arg.leadingComments) return;
  arg.leadingComments = [{
    type: 'CommentBlock',
    value: ` webpackChunkName: '${name}' `,
  }];
}

function getChunkNameFromImportPath(importPath) {
  // find a way to transform from import path to chunk name
  // example: from 'path/to/file' to 'path.to.file' as chunk name
  return importPath.replace(/\//g, '.');
}

module.exports = function(babel) {
  const { types: t } = babel;

  return {
    name: 'add-bundle-name',
    visitor: {
      CallExpression: function(path) {
        const { node } = path;
        if (!t.isImport(node.callee)) return;
        const [firstArg] = node.arguments;
        const importPath = firstArg.value;
        addComments(firstArg, getChunkNameFromImportPath(importPath));
      },
    },
  }
}

navigator.platform🔗

JavaScript

navigator.platform 可以获取到当前浏览器所在的操作系统信息。一般来说会得到一个字符串用以表示操作系统,但某些情况下也可能会拿到空字符串(浏览器拒绝或不能给出操作系统信息)。

需要注意的一点是,即使是 64 位的 Windows 操作系统,得到的结果很可能是 Win32 而不是 Win64。根据 MDN 的数据,Internet Explorer 和 v63 前的 Firefox 会使用 Win64,其他的一般返回 Win32

综上,可以使用如下的方法检测当前是否是 Windows 系统:

const isWindows = navigator.platform.indexOf('Win') === 0;

navigator.platform 基本没有浏览器兼容性问题,可以放心使用。


console.count🔗

JavaScript

console 中可以通过 console.count 来进行记数。

简单的使用方法如下:

function callMe() {
  console.count('callMe func');
}
console.countReset('callMe func');

callMe(); // output => callMe func: 1
callMe(); // output => callMe func: 2
callMe(); // output => callMe func: 3

几点说明:

  • console.countReset 函数可以用于清空记数
  • 传递的参数可以用于标记 count 的类别,如果不传就是默认的 default
  • 不同类别之间的 count 不会共享数据

Detect True Encoding of File and Convert🔗

JavaScript

在 Windows 上,很多文本文件并不是以 UTF-8 的格式进行存储的。比如,中文可能的存储格式是 GB2312 或是 BIG5。这导致,在其他系统中,如果直接以 UTF-8 的格式打开对应的文本文件,就会得到一串乱码。

如果不知道之前是以什么格式存储的文件,这时就会有点束手无策了。

一个可行的简单方法是用 VSCode 的“猜测”功能。在 VSCode 中,如果选择 Reopen with Encoding,会得到 VSCode 猜测的当前文本编码格式。如果使用新的编码重新打开文本,看到的不再是乱码,那么很可能 VSCode 就猜测正确了。一般建议再以 UTF-8 的格式保存一下,以后再次打开就不会有乱码的困扰了。

然而,这个方法不适应大规模批量修改的需求。既然如此,不如直接从 VSCode 的源码入手,看看这个文本编码检测的功能是如何实现的。

VSCode 相关的代码,可以在 src/vs/base/node/encoding.ts 中找到,GitHub 的代码在这里

精简后的代码如下:

const fs = require('fs');
const jschardet = require('jschardet');
const iconv = require('iconv-lite');

const JSCHARDET_TO_ICONV_ENCODINGS = {
  'ibm866': 'cp866',
  'big5': 'cp950'
};

const fromPath = '/path/to/read/file';
const toPath = '/path/to/save/file';

const buffer = fs.readFileSync(fromPath);
const { encoding } = jschardet.detect(buffer);
const iconvEncoding = JSCHARDET_TO_ICONV_ENCODINGS[encoding] || encoding;
const content = iconv.decode(buffer, iconvEncoding);
fs.writeFileSync(filename, content, 'utf8');

这里主要用到了两个库,jschardeticonv-lite

jschardet 是 Python chardet 的一个 JavaScript 移植版本,用于检测当前的二进制流(Buffer)是什么类型的编码。检测的大致原理,可以在 chardet 的网站上找到(这里)。

iconv-lite 用于将二进制流转化成指定编码格式的字符串,是一个纯 JavaScript 的 iconv 库。这里 iconv 的全称是 internationalization conversion,在类 Unix 系统中,这是一个用于转换不同编码字符串的命令行工具。

需要注意的是,iconv-lite 并不通过编码来区分 UTF-8 和 UTF-8 with BOM,而是通过第二个参数 { addBOM } 来完成的。因此,转化 UTF-8 with BOM 的时候,需要稍微手动处理一下。(处理的方法可以参考 VSCode 中的相关函数,比如 encode

另外,jschardeticonv-lite 对编码的命名有些不同,使用前需要转化。上面示例代码中的 JSCHARDET_TO_ICONV_ENCODINGS 就是做的这个事情。


Inspect React Node without DevTool🔗

JavaScript

在 React 开发过程中,使用 Facebook 提供的官方 Chrome DevTool Extension 可以很方便的查看,修改页面上的 React 组件。然而,有时候也需要在没有 DevTool 的情况下,对 React 组件进行 Debug。比如:在测试电脑上查看一个即时出现的问题,或是在 Internet Explorer / Safari 上调试一个出现的问题等。

以下介绍如何在不借助 Chrome DevTool Extension 的情况下,完成对当前 React 组件的检查。

首先,需要获取到某个需要查看的 DOM 元素。可以用 querySelector 或是在 Chrome DevTool 中选中某个元素,然后在 Console 中使用 $0 获得该元素。

React 会在元素上添加额外的属性,用于记录当前这个 React 节点的相关数据。可以通过下面的代码来获取这个属性数据:

function getInstance(element) {
  const key = Object.keys(element)
    .filter(key => key.startsWith('__reactInternalInstance$'))[0];
  if (!key) return null;
  return element[key];
}

React 在添加属性的时候,属性名称会增加一个随机字符串作为后缀(各个 React Node 使用的随机字符串是一致的)。所以需要通过检查 startsWith 来判断当前属性是否是 React 使用的属性。

拿到的这个对象,有一些有用的数据,包括:

  • child - 当前元素子元素的头个元素
  • elementType/type - 该 React 节点对应的类型。如果是 HTML Element,那么就是一个字符串,比如 "div";如果是一个自定义的 React 元素,则是一个函数(class 或 function)。也就是 React.createElement 函数的第一个参数。
  • memoizedProps - 当前元素使用的 props。对于任何一个 React 生成的 HTML 元素,对会有对应的 Props。(写 JSX 的时候,每一个属性,包括 children,都是一个 Props 的属性值)
  • memoizedState - 当前元素使用的 state
  • return - 当前元素所在双向链表的上一个元素
  • sibling - 当前元素下一个兄弟元素
  • stateNode - 当前元素在组件内可以用的 this,包含了 propsrefsstatecontext 以及其他 React Component 的方法。在这个对象的原型链上,还有 React Component 组件的各个方法(比如生命周期函数如 componentDidMount)以及 setState 等可用方法。换句话说,这里的对象就是一个 React Component Class 生成的实例。如果组件是一个 Functional Component,那么这里就是 null 了;而如果是 React 的内置组件(比如 <div /><span /> 这类),那么 stateNode 就是对应生成的 DOM 元素。

根据 React 16 中 Fiber 的设计,元素之间是一个双向链表的关系,每一个节点会连结其上一个元素(return),子元素的首个元素(child),下一个兄弟元素(sibling),因而从任意一个中间的 HTML 元素开始,都可以遍历整个 React 树。

注:从一个元素 A 的 sibling 抵达下一个元素 B 后,该元素 B 的 return 是他的上一个兄弟元素,也就是 A,而不是真正意义上 React / HTML 树的父元素。只有当 B 是 A 的第一个子元素的时候,B 的 return 才是它在树上的父元素。


Trigger onChange for React Input🔗

JavaScript

在 React 16 中(包括 React 15.6 及之后的版本),如果想要用 JavaScript 在外部触发一个 input 组件的 onChange 事件,需要做如下的几个事情:

  1. 首先,让 React 记录下新的 value 值。React 通过 defineProperty 封装了 valueset 方法,因而直接调用 input.value = xxx 并不能达到预期的效果(实际触发的是 React 封装的 setter,而不是实际 DOM 的 setter)。为此,可以使用如下的方法绕过去:
var input = document.querySelector('.xxx');
var originalSetter =
  Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
originalSetter.call(input, 'your value here');
  1. 其次,需要触发一次 onChange 的事件,好让 React 的组件可以在原来既定的回调函数中处理新的数据:
var event = new Event('input', { bubbles: true });
input.dispatchEvent(event);

以下是一个完整可用的方法:

function change(input, value) {
  const setter =
    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setter.call(input, value);

  const event = new Event('input', { bubbles: true });
  input.dispatchEvent(event);
}

Object Spread and Proxy🔗

JavaScript

ES2018 增加了 Object Spread 操作符的官方支持。Object Spread 操作符和 Object.assign 的一些区别,可以参考这篇文章

在 JavaScript 执行 Object Spread 操作符的时候,需要进行如下的几步操作:

  1. 确定对象有哪些自己的属性。原型链上的部分是不会被 Object Spread 操作符接收的,举个例子:
const obj = Object.create(
  { parent: 1 },
  {
    current: {
      enumerable: true,
      value: 2,
    },
  },
);
console.log({ ...obj });
// output => { current: 2 }
  1. 确定第一步拿到的属性中,有那些是可枚举的(enumerable)。不可枚举的部分,不会被接收,举个例子:
const obj = Object.create(
  { parent: 1 },
  {
    current: {
      enumerable: false,
      value: 2,
    },
  },
);
console.log({ ...obj });
// output => { }
  1. 分别获取到这些属性的值

根据以上的规则,现在可以考虑这样一个场景:假设需要一个 Proxy 来修改访问对象属性的行为,比如对对象任意属性的取值,都从它的某一个子属性中去拿。例子:

const obj = { key: 'value', child: { key: 'inner-value' } };
const handler = { /* todo */ };
const proxy = new Proxy(obj, handler);
console.log(proxy.key); // => output: inner-value

这里的 handler 并不难写,只需要:

const handler = {
  get(target, key) {
    return target.child[key];
    // or:
    // return Reflect.get(target.child, key);
  },
};

现在,如果需要 Proxy 也同样可以支持 Object Spread 的功能,那么就需要对 handler 做更多的处理。从上面的分析来看,第一步获取自有属性,需要用到 ownKeys;第二步获取可枚举属性,需要用到 getOwnPropertyDescriptor;最后一步获得属性值,依然需要 get

代码如下:

const obj = { key: 'value', child: { key: 'inner-value' } };
const handler = {
  get(target, key) {
    return Reflect.get(target.child, key);
  },
  ownKeys(target) {
    return Reflect.ownKeys(target.child);
  },
  getOwnPropertyDescriptor(target, key) {
    return Reflect.getOwnPropertyDescriptor(target.child, key);
  }
};
const proxy = new Proxy(obj, handler);
console.log({ ...proxy }); // => output: { key: 'inner-value' }

Array.from🔗

JavaScript

Array.from 是 JavaScript 中一个较新的 API,可以将一个类数组或可迭代对象转化成一个真正的数组。

类数组(array-like)常见于 DOM API 中取到的数据,比如 .querySelectorAll。得到的结果有 .length 属性,也可以通过下标获取到数据,但是本身却不是一个数组,没有 Array.prototype 上的 API 可以直接用。

可迭代对象则是指那些定义了 Symbol.iterator 属性的对象。

Array.from 可以将上述的两种对象直接转化成一个标准的数组:

const iterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
  },
};
console.log(Array.from(iterable)); // => [1, 2];

const arrayLike = document.querySelectorAll('span');
console.log(Array.from(arrayLike)); // => [span, span, ...]

除了上述常见的应用之外,Array.from 还有一些特殊的应用。

首先,只要指定了 length 属性,Array.from 就可以创建一个数组。这一行为可以用来创建一个指定长度的数组:

Array.from({ length: 5 }).map((_, i) => i);
// => [0, 1, 2, 3, 4]

其次,Array.from 函数其实接受不止一个参数。第二个参数是一个 map 函数,第三个参数是 thisArg,用于指定 map 函数的 this 对象。有了这个 map 函数的支持,上面这个例子就可以进一步改写成:

Array.from({ length: 5 }, (_, i) => i);

在转化 DOM 类数组的时候,直接通过指定 map 函数进行进一步的转化,是比较方便的。可以省略一个 .map 函数的嵌套,也节省一个中间数组对象的创建。

Array.prototype.map 函数可以指定 thisArg,在 Array.from 中也可以通过第三个参数指定 thisArg。以下是一个例子:

const mapper = {
  shift: 1,
  run(_, i) {
    return this.shift + i;
  },
};
Array.from({ length: 5 }, mapper.run, mapper);
// => [1, 2, 3, 4, 5];

上述写法等价于:

Array.from({ length: 5 }).map(mapper.run, mapper);

Get Available Port Number🔗

JavaScript

前端的开发工程,经常需要开启一些调试用的服务器,一旦调试的服务器多了,难免会出现网络端口号的冲突;类似的,如果在一台开发机上有多个人同时开发,开发脚本也就不能写死一个固定的端口号了。在这类情况下,如果要手动解决这些冲突(修改端口号或者手写端口号的分配规则),不免有些麻烦。

事实上,创建服务的时候,可以设置 0 作为端口号。这样的话,系统就会指定一个当前空闲可用的端口号,以保证不发生端口号冲突的情况。

下面是一段 Node.js 的示意代码:

const http = require('http');
const server  = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/html' });
  response.write([
    '<html>',
    '<head><title>Node.js Server</title></head>',
    '<body><h1>Hello World</h1></body>',
    '</html>',
  ].join(''));
  response.end();
});

// ask for an available port
server.listen(0);

server.on('listening', function() {
  // ask for actually used port
  var port = server.address().port;
  console.log(`server is listening: ${port}`);
});

其中,server.address().port 可以拿到当前系统具体分配的端口号。有了这个端口号,就可以自动/手动的打开对应的 URL 地址以访问新生成的服务了。


Detect Overflow🔗

JavaScript

下面展示的这段 CSS 设置非常常见,可以让文字超过宽度的时候,显示 ...

.ellipsis {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

在实际业务中,经常会碰到这样的需求:只有当显示出 ... 的时候,才做某些事情(比如,才在鼠标悬停的时候展示 Tooltip,用以显示完整的文字内容)。CSS 本身并没有提供足够的接口,让开发者可以直观的了解到当前的文字内容是否已经超出了可以显示的范围。如果需要判断当前内容是否超出了元素的宽度(因而让 CSS 渲染出 ...),可以用下面的 JavaScript 代码:

function isOverflow(element) {
  return element.scrollWidth !== element.clientWidth;
}

根据 MDN 的介绍,scrollWidth 是一个制度元素,其值等于该元素实际的宽度(包含了元素因为 overflow 没有展示出来的部分);而于此相对的,clientWidth 是元素实际展示的宽度。注,对行内元素和没有 CSS 的元素来说,clientWidth 是 0,具体见 MDN。针对判断是否出 ... 的情况,这里不需要考虑 clientWidth 的特殊情况。因为 scrollWidth 实际给出的值包含了被隐去的部分,因而当 scrollWidthclientWidth 不想等的时候,就可以认定当前有部分内容被隐去了,因而 ... 也出现了。


Unzip using JavaScript🔗

JavaScript

使用 JavaScript 库 jszip 可以对传入的本地 zip 包进行解压缩并读取其中的文件。

一段参考代码如下:

const filename = 'example.txt';
async function onDrop(event: DragEvent<HTMLDivElement>) {
  const file: File = event.dataTransfer.files[0];
  const zip = await jszip.loadAsync(file);
  const content = await zip.files[filename].async('text');
  console.log('content of example.txt: ', content);
}

这里,loadAsync 函数接受的参数是一个 Blob,而无论是 input[type=file] 还是通过 drag-n-drop 得到的文件(File),都是一种特殊的 Blob,因而可以直接传递给 loadAsync 使用。

.async('text') 可以将结果异步转化成字符串。API 也支持其他的格式类型,比如 ArrayBuffer 等。(文档

除了读取内容之外,jszip 也支持创建修改 zip 包的内容。完整的 API 文档可以参考这里


HashHistory & State🔗

JavaScript

react-router 底层使用了 history 库来处理路由。一般有两种选择,一种是使用 BrowserHistory(背后的实现依赖了 History API),一种是使用 HashHistory(背后的实现主要依赖 window.location.hash)。

如果尝试在 HashHistory 上调用 push API 并传递 state 参数:

var history = HashHistory();
history.push('path', { state: 'value' });

会在 console 看到如下的报错,并且 state 并没有传递成功:

Hash history cannot push state; it is ignored

这个是 history 库的默认行为,具体的代码可以参考 modules/createHashHistory.jspush 的代码:

function push(path, state) {
  warning(
    state === undefined,
    'Hash history cannot push state; it is ignored'
  );

  const action = 'PUSH';
  const location = createLocation(
    path,
    undefined,
    undefined,
    history.location
  );
  // ...

上面的代码可以看到,如果传递了第二个参数 state,那么就会输出报错。同时,在 createLocation 函数调用的时候,第二个参数本来应该是 state,这里显式地写成了 undefined,明确拒绝了传递 state 的做法。

然而,这并不意味着就完全不能传递 state 了。

事实上,push 函数有两种 API 可供选择,一种是 path 是字符串,然后 state 作为第二个参数传递;另一种则第一个参数就是一个对象,其中一个属性就是 state。

如果使用下面的方法调用,state 就可以在 react-router 中被传递了:

hashHistory.push({ path: 'path', state: { state: 'value' } });

但是,需要指出的是。这个只是 state传递了,并不代表 state 真的被存储了下来。事实上,HashHistory 并没有依赖浏览器的 History API 功能。因此,这里的 state 传递之后,会出现在 history.location.state 中,但是在浏览器前进/后退的操作中,数据会被丢弃,无法找到。

一个简单的例子是:

var h = createHashHistory();
h.listen((location) => {
  console.log(location.state);
});
h.push({ pathname: 'a', state: 1 });
// output: 1
h.push({ pathname: 'b', state: 2 });
// output: 2
h.goBack();
// output: undefined
h.goForward();
// output: undefined

如果将 HashHistory 改成 BrowserHistory,则可以正确输出:

var h = createBrowserHistory();
h.listen((location) => {
  console.log(location.state);
});
h.push({ pathname: 'a', state: 1 });
// output: 1
h.push({ pathname: 'b', state: 2 });
// output: 2
h.goBack();
// output: 1
h.goForward();
// output: 2

Generate v4 UUID🔗

JavaScript

这里有一段生成 UUID v4 非常短小的 JavaScript 代码。虽然有一些注释,但总体上并不是非常的好理解。

首先,用 TypeScript 翻译一遍原始的代码:

const slot = '10000000-1000-4000-8000-100000000000';

function rand(from: number, to: number) {
  return Math.floor(Math.random() * (to - from + 1) + from);
}

function getRandomChar(input: number) {
  const seed = input === 8 ? rand(8, 11) : rand(0, 15);
  return seed.toString(16);
}

function uuidReplacer(char: string) {
  return getRandomChar(+char);
}

function uuid() {
  return slot.replace(/[018]/g, uuidReplacer);
}

接下来对原始的代码中的部分做一些解释:

a ^ Math.random() * 16 >> a/4

这个部分的代码不太好理解。首先,先根据运算符的优先级,给上面这个算式加上帮助理解的括号:

a ^ ( (Math.random() * 16) >> (a / 4) )

接下来,再来看每个部分都是什么意思:

Math.random() * 16

创建了一个 0~16 范围内的随机数,>> (a / 4) 这个运算之后,会得到几种不同的结果(注意,这里 a 只可能是 '0''1''8' 三种情况):

  • '0''1' 的时候,结果是 0~15 的随机数(整数)
  • '8' 的时候,等价于 >> 2,所以结果是一个 0~3 的随机数(整数)

最后一步异或运算 ^,得到的可能结果分别是:

  • '0' 的时候,异或结果不变,是 0~15 的随机数(整数)
  • '1' 的时候,异或结果最后一个比特位的值正好相反,最终的结果仍然是 0~15 的随机数(整数)
  • '8' 的时候,最终结果是 8~11 的随机数(整数)。因为 8 的第四位二进制是 1 其他都是 0,而 0~3 这几个数的二进制位数不超过两位,所以位数之间不存在交集,异或运算相当于是 8 + nn0~3 中的某一个数。最终得到的就是 8~11 的随机数(整数)。

最终,将生成的不超过 16 的整数转化成十六进制的对应字符。

第二部分的代码用到了正则匹配和替换(注意,这里 '4' 没有被替换,依然保留),主要不容易理解的是下面这个部分:

[1e7] + -1e3 + -4e3 + -8e3 + -1e11    

这里用到了 JavaScript 比较奇怪的类型转化功能。因为最开始是一个数组,所以这里的相加实际上是字符串的拼接。等价于 '10000000-1000-4000-8000-100000000000' 这个字符串。上面这样写主要是字符数上比较少。


TextDecoder🔗

JavaScript

在 JavaScript 中,如果得到了一串字节(比如 Uint8Array),要转化成对应的字符串,就可以用到 TextDecoder。简单的使用方法如下:

const decoder = new TextDecoder('utf-8');
const bytes = new Uint8Array([
  104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100
]);
const result = decoder.decode(bytes);

console.log(result); // => "hello world"

当然,上面的这个例子是比较简单的。不使用 TextDecoder 也可以直接转化成字符串:

const bytes = new Uint8Array([
  104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100
]);
const result = String.fromCharCode(...bytes);

console.log(result); // => "hello world"

TextDecoder 的主要优势,需要在非 ASCII 码范围内才体现出来。特别是 utf-8 这类变长字符串编码,直接处理比较困难。交给现成的 API 来处理,简单方便。

参考文档:MDN


global Provider for storybook🔗

JavaScript

一个项目工程里的组件,很可能需要依赖于某些项目顶层定义的 Provider 才能正确使用。比如,mobx 的项目可能会在顶层通过 mobx-react 中的 Provider 提供 store 参数。

如果需要在每一个 story 中都写:

<Provider {...info}>
  <ComponentForThisStory />
</Provider>

显然太啰嗦了。

storybook 提供了全局定义 decorator 的方法,可以以此来注册一些全局都用得到的改动。举例如下:

import { configure, addDecorator } from '@storybook/react';
import { Provider } from 'mobx-react';

function withProvider(story) {
  return <Provider {...info}>{story()}</Provider>
}

function loadStories() {
  // ...
  addDecorator(withProvider);
  // ...
}

configure(loadStories, module);

这样,在 story 中,只需要简单的提供组件就可以了,decorator 会自动为组件加上合适的外层 Provider

参考文档


Transform node_modules in Jest🔗

JavaScript

默认情况下,Jest 配置文件中的 transform 属性,是不会被应用到 node_modules 目录下的。如果引用的库本身使用了非 JavaScript 文件(比如 CSS 文件),会造成 Jest 无法正确处理。

一个可行的替代方案,是用 moduleNameMapper 来代替 transform 的功能。

以 CSS 的处理为例:

{
  // ...
  transform: {
    "^.+\\.(less|css)$": "jest-transform-stub"
  },
  // ...
}

上面这个是常规方案,但是对 CSS / Less 的处理不包含 node_modules 的部分。

{
  // ...
  moduleNameMapper: {
    "^.+\\.(less|css)$": "jest-transform-stub"
  },
  // ...
}

上面这个方案,可以达到一样的效果,但是 node_module 内的 CSS 引用也会被正确的处理。

两种方案没有优劣,主要是看使用的场景。


rxjs and hooks🔗

JavaScript

一直以来,rxjsreact 都不太搭,要在 React 中使用 rxjs 往往需要写并不怎么优雅的代码,比如:

class Example extends React.Component {
  constructor(props) {
    super(props);
    const initial = -1;
    this.state = {
      value: initial,
    };
    this.subscription = null;
  }
  componentDidMount() {
    this.register();
  }
  componentDidUpdate(prevProps) {
    if (prevProps.value$ !== this.props.value$) {
      this.unregister();
      this.register();
    }
  }
  componentWillUnmount() {
    this.unregister();
  }
  register() {
    this.subscription = this.props.value$
      .subscribe((value) => {
        this.setState({ value });
      });
  }
  unregister() {
    if (this.subscription) this.subscription.unsubscribe();
  }
  render() {
    const { value } = this.state;
    return (
      <div>{value}</div>
    )
  }
}

ReactDOM.render(<Example value$={interval(1000)} />, document.body);

上面这段代码,会根据 value$ 这个 Observable 的数据,通过 React State 这个桥梁,去更新 UI。并且,代码考虑到了给定的 value$ 可能后续变化的情况。如果不考虑后续 props 的修改,上面的代码依然需要在 componentDidMount 的时候注册回调并更新,然后在 componentWillUnmount 的时候注销,显得非常的啰嗦。

LeetCode 提供的 rxjs-hooks 提供了一个更为优雅的解决方案:

const Example = (props) => {
  const initial = -1;
  const value = useObservable(
    (inputs$) => inputs$.pipe(
      switchMap(([value$]) => value$),
    ),
    initial,
    [props.value$]
  );
  return (<div>{value}</div>);
};

ReactDOM.render(<Example value$={interval(1000)} />, document.body);

rxjs-hooks 另外提供了 useEventCallback 来更好的处理事件流,具体可以查看官方的文档


classnames & css module🔗

JavaScript

classnames 库提供了一个 bind API,用于处理 CSS Module 的情况。

在 Webpack 中用 CSS Module 的方案编译 CSS 文件,后续在 JavaScript 中 import style from 'xxx.css'; 后,style 就是一个对象。这个对象的大体结构如下:

style = {
  foo: 'foo-abcde',
  bar: 'bar-12345',
  // ...
};

其中,对象的 key 是原始的 class name,而 value 则是施加 CSS Module 之后得到的唯一名称。

如果直接使用 classnames 的标准 API,那么写起来就需要大量使用 computed property name 的语法,比如:

<div className={classnames({ [style.foo]: true, [style.bar: false ]})} />

而使用 bind API,可以事先告知 classnames class name 的对应关系(通过指定 this),后续只需要使用字符串,classnames 就可以自动使用合适的结果:

import classNames from 'classnames/bind';
import styles from './style.css';

const cx = classNames.bind(styles);

const Component = () => (
  // result in: className="foo-abcde"
  <div className={cx({ foo: true, bar: false })} />
);

Object.create(null)🔗

JavaScript

Object.create 可以用提供的对象做新对象的 __proto__。这导致了一个很有趣的现象,如果使用 Object.create(null) 来创建新对象,那么这个新对象上就没有任何 __proto__。因为 JavaScript 的对象经常被用来做字典使用,Object.create(null) 可以让这个功能使用更加的纯粹。

const dict = Object.create(null);
console.log(Object.getPrototypeOf(dict));
// output: null
console.log(typeof dict.hasOwnProperty);
// output: "undefined"
const obj = { };
console.log(Object.getPrototypeOf(obj));
// output:
// {
//   constructor,
//   hasOwnProperty,
//   isPrototypeOf,
//   propertyIsEnumerable,
//   toLocaleString,
//   toString,
//   valueOf,
//   ...
// }

同样,因为没有 prototype,理论上来说,后续如果有人对 Object.prototype 做操作,也不会影响到使用。

const dict = Object.create(null);
// ...
Object.prototype.addSomething = () => { };
console.log(typeof dict.addSomething);
// output: undefined
for (const key in dict) console.log(key);
// no output

console.log(typeof ({}).addSomething);
// output: function
for (const key in {}) console.log(key);
// output: 'addSomething'

所以,如果判断对象有某个字段,那么一定是他自身有这个字段,而不会是因为原型链上的定义。也就是说,不需要用:

if (Object.prototype.hasOwnProperty.call(dict, 'addSomething') { }) {
  // ...
}

而只需要写:

if (dict.addSomething) {
  // ...
}

当然,这也会有一些弊端,比如默认 Object.prototype 的东西就没了,如果需要 toString 之类的函数,得自己写。


performance data via JavaScript🔗

JavaScript

JavaScript 的 performance 除了常用的 now / mark 之外,也提供了和页面加载相关的很多接口。通过调用这些接口,就可以很方便的收集页面加载的相关指标,方便了解不同用户的实际体验。

perfomance.getEntries 返回的数据,有三种类型:navigationresourcepaint

其中,navigation 包含了 PerformanceNavigationTiming,里面记录了和页面导航相关的时间信息,比如 connection 的起始/结束时间等。可以通过下面的代码拿到完整的数据:

performance.getEntriesByType('navigation')[0].toJSON();
// output:
// connectEnd: xxx
// connectStart: xxx
// ...

resource 包含了所有的 PerformanceResourceTiming。每一个资源的请求,对应一个 PerformanceResourceTiming。例子:

performance.getEntriesByType('resource').forEach(({ name, duration }) => {
  console.log(`resource: ${name} use ${duration} milliseconds to load`);
  // output:
  // resource: https://xxxx use xxx milliseconds to load
  // ...
});

paint 包含了所有的 PerformancePaintTiming。一共有两个,分别是 first-paint 和 first-contentful-paint。例子:

performance.getEntriesByType('paint').forEach(({ name, startTime }) => {
  console.log(`name: ${name}, startTime: ${startTime}`);
  // output:
  // first-paint: xxxx
  // first-contentful-paint: xxx
});

download third party resource🔗

JavaScript

在 HTML 中,如果一个 a 标签,带上了 download 的属性,链接地址就会被浏览器直接用于下载。使用方法如下:

<a href="link_here" download="filename.suffix">Link</a>

同样,如果需要 JavaScript 能够直接触发一个资源的下载,可以创建带 download 属性的 a 标签,然后调用这个元素的 click 方法。

const a = document.createDocument('a');
a.href = 'link_here';
a.download = 'filename';
document.body.appendChild(a);
a.click(); // trigger download
document.body.removeChild(a);

download 的支持情况见这里

这个方案有一个问题:如果是跨域的资源,直接这样的 a 标签点击是不能调用下载的(因为执行了严格的同源策略),行为上就会和一个普通的导航没有区别(比如,增加 target=_blank 之后就会打开一个新窗口展示资源)。

解决跨域的一个前端方案是:fetch 资源,然后将结果转化成 Blob,然后将这个 Blob 生成一个 URL。代码如下:

fetch('link_here')
  .then(repsonse => response.blob())
  .then(blob => URL.createObjectURL(blob))
  .then((link) => {
    const a = document.createElement('a');
    a.href = link;
    a.download = 'filename.here';
    document.body.appendChild();
    a.click();
    document.body.removeChild();
  });

Promise.allSettled🔗

JavaScript

Promise.allSettled 已经在 Chrome 76 中上线了。

一个简单的例子:

const promises = [
  Promise.resolve('fulfilled'),
  Promise.reject('rejected'),
];

Promise.allSettled(promises)
  .then((result) => {
    /**
     * output:
     * [
     *   { status: 'fulfilled', value: 'fulfilled' },
     *   { status: 'rejected', reason: 'rejected' },
     * ]
     */
    console.log(result);
  });

只有所有数组中的 Promise 的结果不再是 pending.allSettled 才会返回结果。

.all.race 两个 API 最大的区别在于,.allSettled 不会提前结束。.all 会在任意一个 Promise reject 的时候失败,而 .race 则会在任意一个 Promise fulfilled 的时候成功。.allSettled 会等到所有结果都出来之后,再如实返回(以 fulfilled 的状态)。

需要注意的是,返回的结果是一个数组,其中的每一个元素都是一个对象。其中,每个对象都有 status 的字段,表示对应的 Promise 最终的结果是 fulfilled 还是 rejected。如果是 fulfilled 状态,那么对象会有 value 字段,值相当于 .then 回调中的第一个参数;如果是 rejected 状态,那么对象会有 reason 字段,值相当于 .catch 回调中的第一个参数。


custom display time of notification🔗

JavaScript

浏览器显示 Notification 默认是有一个自动消失时间的。不同的浏览器,这里的消失时间并不一致,从测试来看:

  • Chrome: ~6s
  • Firefox: ~19s
  • Edge: ~6s

从目前浏览器公开的 API 来看,并没有一个接口可以直观的修改这里的消失时间。一个可行的解决方案是:用 requireInteraction 来强制要求浏览器不自动关闭 Notification,然后设置 setTimeout 并在合适的时机手动关闭这个显示的 Notification。

示例代码如下:

var delay = 10 * 1000;
Notification.requestPermission(function (status) {
  if (status === "granted") {
    var notification = new Notification(
      "Hi! ",
      {
        requireInteraction: true,
      },
    );
    var timer = setTimeout(function () {
      notification.close();
    }, delay);
  }
});

目前 requireInteraction 的浏览器支持情况并不非常理想,只有 Chrome, Edge(17+) 和 Opera 做了支持。具体的支持列表,可以看这里

另外,从实际的使用上来看,Edge 浏览器中即使设置了 requireInteraction,notification 在一定时间之后也会消失,只是消失的时间会比原来默认的情况要长一些,大约是 25 秒。Chrome 的 Notification 如果设置了 requireInteraction,会多一个 Close 的按钮,展示效果和没有 requireInteraction 的情况有所不同。


contenteditable style🔗

JavaScript

style 本身是一个标准的 HTML 标签,在里面写的 CSS 样式,会被应用到页面上。同时,作为一个 HTML 标签,style 本身也可以被赋予一定的展示样式(比如将默认的 style { display: none; } 给覆盖掉)。加上 contenteditable 的属性,就会得到一个可编写的 style 标签。通过直接编写其中的 CSS 样式,页面会自动更新,展示应用样式后的效果。

上面展示的这个圆点,鼠标悬停之后,就会显示一个可输入的框。在里面输入一些 CSS 可以看到对页面元素的修改。比如,可以试试输入:

article small:nth-child(3) { color: #007acc; }

几点注意:

  1. 直接复制上面的 CSS 然后黏贴不会起效,因为样式也被黏贴到 style 里面去了,这会导致 style 里的内容不是合法的 CSS,无法应用样式
  2. CSS 需要写在一行里面,回车会导致插入 <br />,同样会导致 CSS 语法错误,无法应用样式

restore source map🔗

JavaScript

如果拿到了一份带有 source map 的 JavaScript 代码,那么理论上就可以通过这份 source map 去尽可能的还原出原始的文件内容。

首先,source map 本质上是一个 JSON 文件。在其中,sourceContent 数组就记录了所有源文件的纯文本内容,而这些文件的文件路径及文件名则存放在了 sources 数组中。两者相互对应,理论上来说参照这两者的数据,就可以将源文件还原到原始的目录下。

然而,Webpack 的打包结果,文件的路径名称都带上了 webpack:/// 的前缀。在实际处理的过程中,可以直接使用已有的库,比如 restore-source-tree

这个库因为已经比较老了,对 Webpack 3/4 等新版本的支持存在问题。在原库合并 PR 之前,可以先使用改进过的版本 restore-source-tree

这个修改过的版本,除了修复对新版 Webpack 编译结果的支持外,也加入了 glob 的支持,可以更方便的进行批量 source map 还原。

参考代码如下:

restore-source-tree -o output_folder path/to/source-maps/*.map

最终生成的文件会存放在 output_folder 下。


use case of switch🔗

JavaScript

在《JavaScript: The Good Parts》里,作者并不赞成 switch 语句的使用(主要是因为 fall-through 的情况很容易造成错误)。然而在实际的代码里,还是有不少地方可以看到 switch 的使用。目的各不相同,有不少可以借鉴的地方。

默认值设置

React 的 Scheduler 中,有这样一段代码:

switch (priorityLevel) {
  case ImmediatePriority:
  case UserBlockingPriority:
  case NormalPriority:
  case LowPriority:
  case IdlePriority:
    break;
  default:
    priorityLevel = NormalPriority;
}

不失为设置默认值的一种写法,看上去比使用 if 来得更明确一些:

if (
  priorityLevel !== ImmediatePriority &&
  priorityLevel !== UserBlockingPriority &&
  priorityLevel !== NormalPriority &&
  priorityLevel !== LowPriority &&
  priorityLevel !== IdlePriority
) {
  priorityLevel = NormalPriority;
}

防止代码篡改的判定

上面的需求,也很容易写成下面这种数组的方案:

const allowedValues = [
  ImmediatePriority,
  UserBlockingPriority,
  NormalPriority,
  LowPriority,
  IdlePriority,
];
const isNot = value => comparedTo => value !== comparedTo;
if (allowedValues.every(isNot(priorityLevel))) {
  priorityLevel = NormalPriority;
}

然而,这样的代码方式,可能存在被入侵的危险。不论是上面例子中的 every 函数,还是用 Array.prototype 上的任意函数,都有被篡改的可能性。如果其他地方的代码修改了 Array.prototype.every 的行为,让这里的返回值发生了变化,那么代码最终就会产生意料之外的行为。

在 Scheduler 中当然不需要考虑这个问题,但是在其他的应用场景下,这可能是不得不考虑的问题。举例来说,如果一个 Web 应用允许第三方脚本的运行,同时自身有对数据进行白名单检查的需求,那么就只能使用 switch 硬编码所有的情况,而不能使用数组或者对象,否则第三方的脚本有可能对最终的行为做篡改。

Microsoft Teams 的代码里,就有类似的应用场景(见 extracted/lib/renderer/preload_sandbox.js):

const isChannelAllowed = (channel) => {
  // ...
  let isAllowed = false;
  // IMPORTANT - the allowList must be a hardcorded switch statement.
  // Array and object methods can be overridden and forced to return true.
  switch (channel) {
    case xxx:
    // ...
    case zzz:
      isAllowed = true;
      break;
    default:
      isAllowed = false;
      break;
  }
}