JavaScript

JavaScript不支持多线程, 现有的多线程方案是基于Worker的, 这种多线程有很严重的性能问题.
一般情况下, 在编程语言里使用多线程是为了能够让程序的总运行时间低于单线程情况下的运行时间,
一般的编程语言在发挥多线程性能上的主要障碍是线程上下文切换成本,
由于线程上下文切换成本实际上不是很高, 因此总算还是可以通过优化代码来让多线程程序有意义.
在Go语言这样基于有栈协程的语言里, 上下文切换成本被进一步减小, 使得多线程程序跑得非常快.
而Worker的通信成本要远远高于协程上下文切换和线程上下文切换, 优化Worker的通信基本上是不可能的,
几乎不可能让它达到令人满意的水平, 在绝大多数情况下, 这种多线程程序的效率都不会高于单线程程序.
Worker之间几乎无法共享内存, 尽管可以把它美其名曰Actor模型, 但它显然是一种低效率的Actor模型,
因为无法共享内存的事实已经确实严重拖累了Worker的内存空间效率.
一个在一般的编程语言里能够以500MB内存运行的程序, 在使用Worker的情况下需要将其复制到每个线程里, 这是不可接受的.
消息传递需要序列化和反序列化也是不可接受的:
假如正在传输的是一个200MB的数据, 则序列化后可以预测内存中会有约400MB的数据,
然后由于另一端需要反序列化, 这会进一步导致总内存用量上升到600MB.
即使通信成本, 序列化和反序列化成本现在都被视作可容忍的, 也仍然存在问题.
发送数据所使用的结构化克隆算法支持的数据类型是有限的, 如果需要克隆一个自定义的类实例, 则会多出一重转换成本:
需要先在发送端将类实例转换为可传输的结构, 再在接收端将其还原回类的实例.
所以在最糟糕的情况下, 一个原本能够通过共享内存直接实现的用例, 在JavaScript里需要付出最高5倍的内存峰值为代价才能实现.
Buffer是一种非常基础的线性内存空间.
除非程序员能够完全基于Buffer进行程序开发,
即直接将Buffer视作内存, 在Buffer里实现它需要的数据结构(例如https://github.com/Bnaya/objectbuffer/),
否则通过共享Buffer能够实现的功能是非常有限的.
https://github.com/BlackGlory/buffer-structures
一次在ArrayBuffer上建立数据结构的尝试, 证明了基于Buffer进行程序开发即使在成本上可行, 在性能上也不可行.
https://github.com/microsoft/napajs
基于V8的多线程JavaScript运行时, 已停止维护.
https://github.com/nodejs/node/issues/37080
允许用户将对象标记为可转移的.
https://github.com/littledan/serializable-objects
ES2022的新特性, 相当于类静态成员的构造函数.
一个类可以包含多个静态初始化块, 它们按先后顺序执行.
参考:
  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Static_initialization_blocks
  • https://2ality.com/2021/09/class-static-block.html
该特性的一个特殊用例是用于在类的声明结构内为类添加原型属性, 这在过去只能在类的声明结构外实现.
class Class {
static {
this.prototype.property = 'foo'
}
}
const instance = new Class()
console.log(instance.property) // foo
Proxy并不是一种完美的动态方案, 它会产生很严重的性能问题, 并且trap并不总是能用反射来实现, 因为handlers对返回值或target有要求.
当访问一个对象的成员时, 如果在对象上找不到该成员, 就会逐级查找它的原型链, 以此类推, 直到原型为 null 的对象为止.
由于原型链机制, 访问一个不存在的对象成员对JavaScript引擎来说会需要付出比较高的运行成本.
类或构造函数才有的属性, 用于指定该类或构造函数创建出的对象所具有的原型.
一个不可枚举, 可修改, 可配置的成员.
该成员默认存在, 并且指向构造函数.
.prototype.constructor 在语义上并没有特殊意义, 它的存在与否对 new 运算符的行为不构成任何影响.
大多数原型继承示例会确保 .prototype.constructor 的存在, 但这通常只是为了维护与默认原型的行为一致.
在实际使用中, .prototype.constructor 的主要作用是让实例能够通过 this.constructor 访问自己的构造函数.
ES5引入的方法.
在运行时获取对象使用的原型对象.
class Foo {}
Object.getPrototypeOf(new Foo()) === Foo.prototype
ES6引入的方法, 可以替代 Object.create 在原型链继承上的作用.
该方法并不被推荐使用, 因为"设置对象原型"对大多数JavaScript引擎都是一项很慢的操作.
function Shape() {
this.x = 0
this.y = 0
}
Shape.prototype.move = function (x, y) {
this.x = x
this.y = y
}
function Rect(width: number, height: number) {
// 在自身上调用Shape的构造函数
Shape.call(this)
this.width = width
this.height = height
}
// 直接将Shape.prototype作为Rect.prototype的原型, 免去了创建新对象和重新绑定构造函数.
Object.setPrototypeOf(Rect.prototype, Shape.prototype)
ES5引入的方法.
以第一个参数为原型, 创建一个对象.
Object.create(obj)
利用原型链机制, JavaScript的对象天生可以达到类似写时复制(CoW)的效果, 从而节省内存空间.
Object.create(null)
字面量 {} 创建出的"空对象"是以 Object.prototype 为原型的, Object.create(null) 没有原型, 因此更精简.
Object.create 被广泛用于原型链继承.
尽管ES6的Object.setPrototypeOf在语义上是完成相同任务的更好方法, 但它在JavaScript引擎上通常性能表现不佳.
function Shape() {
this.x = 0
this.y = 0
}
Shape.prototype.move = function (x, y) {
this.x = x
this.y = y
}
function Rect(width: number, height: number) {
// 在自身上调用Shape的构造函数
Shape.call(this)
this.width = width
this.height = height
}
// 以Shape的原型创建一个新的空对象作为Rect的原型.
// 不能用`Rect.prototype = Shape.prototype`替代, 因为此时对Rect原型的修改会同时改变Shape的原型.
// 在引入`Object.create`之前, 原型链继承的对象出于方便起见通常是用`new Shape()`创建的, 然而这种方法会引入你不需要的实例成员.
Rect.prototype = Object.create(Shape.prototype)
// 由于原型被重新赋值, 新原型丢失了构造函数, 在此处重新绑定构造函数.
// 直接赋值的做法其实是不严谨的, 因为constructor还具有不可枚举的性质.
Rect.prototype.constructor = Rect
immer会在重建对象时重用未修改的引用, 和手动创建不可变新对象时的思路一致.
由于需要遵守不可变性, 包括根对象在内, 被修改路径上的对象都会被重新创建.
const baseState = {
a: {}
, b: {
c: {}
, d: {
e: {}
, f: {}
}
}
}
const nextState = produce(baseState, draft => {
draft.b.d.f = {}
})
console.log(baseState === nextState) // false
console.log(baseState.a === nextState.a) // true
console.log(baseState.b === nextState.b) // false
console.log(baseState.b.c === nextState.b.c) // true
console.log(baseState.b.d === nextState.b.d) // false
console.log(baseState.b.e === nextState.b.e) // true
console.log(baseState.b.d.f === nextState.b.d.f) // false
优点:
  • 性能更好
优点:
  • 可以获取原始对象(original), 使producer能够判断引用相等性.
  • 返回的结果适用于React, 以减少不必要的重新渲染.
缺点:
  • 只支持单向树, 不支持循环结构.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics
曾经ECMAScript没有规定Array#sort的稳定性, 这一点在2019年得到改变, 现在的主流实现应该都是稳定的:
https://github.com/tc39/ecma262/pull/1340
let val
try {
val = mayThrows()
} catch (e) {
// handle errors
}
// use val
缺点:
  • 标识符val是可变的
  • 变量的声明与初始化位置分离
try {
const val = mayThrows()
// use val
} catch (e) {
// handle errors
}
缺点:
  • try块内的所有异常都挤在同一个catch块里处理
  • try块内的代码可能会很长, 影响可读性
const val = go(() => {
try {
return mayThrows()
} catch (e) {
// handle errors
}
})
// use val
缺点:
  • catch块无法控制外部函数的返回
// 使用 var 是为了能够重复声明 err
var [err, val] = getErrorResult(() => mayThrows())
if (err) {
// handle errors
}
// use val
缺点:
  • 标识符val是可变的
  • TypeScript不能用if语句判断出 [err, undefined] | [undefined, val] 的类型差异,
    为此需要 assert(isntUndefined(val)), 这同时造成val的值不能是undefined.
最常见的用途, 通常需要配合common-tag库的stripIndent函数使用.
模板字面量的插值在这种情况下被用来描述非字符串的内容.
最常见的应用是各种使用模板字面量来生成组件的HTML库, 例如 lit-html.
这些库仍然属于Parser, 与传统Parser的区别是它们不只可以接受文本输入, 还接受其他的数据类型.
函数的this会在运行时进行绑定, 其值取决于调用时点(.)运算符前面的对象.
以下方式可以改变这一机制:
  • 使用bind方法手动绑定this对象
  • 使用apply, call, Reflect.apply方法在调用函数时手动指定this值.
  • 使用箭头函数, 其this在创建此箭头函数时就已绑定.
在非严格模式下, 当函数不以点运算符的形式调用时, this的值为 globalThis.
在严格模式下, 当函数不以点运算符的形式调用时, this的值为 undefined.
JavaScript具有一种可转移所有权的抽象接口Transferable, 由Web宿主实现.
实现所有权转移的接口可以在Worker之间直接转移内存的所有权, 而无需复制数据.
只有少数对象实现了此接口, 主要是类ArrayBuffer结构和各种句柄.
详见: https://developer.mozilla.org/en-US/docs/Glossary/Transferable_objects
JavaScript的宿主环境主要提供了以下两个高分辨率函数用于测量运行时间:
  • performance.mark
  • performance.measure
mark相当于给当前时间点打上一个标记, measure用于计算各个标记之间经过的时间.
浏览器本身也会给一些类型行为打上标记.
对于Math.random()来说, 其结果值的取值范围为[0, 1).
开发人员可能对于[0, 1)的取值范围不满, 因为[0, 1]显然更好.
从实用角度来说, [0, 1]的取值范围是没有意义的, 因为值正好是1的概率为2^-64:
即使API实现了[0,1]的取值范围, 每秒生成100万次随机值, 也需要60万年才能随机到1.
JavaScript中的+0和-0来自于IEEE 754标准, 并不是语言本身的设计.
// 在绝大多数情况下, +0(可以缩写为0) 和 -0 都是0, 没有区别
+0 === -0 // true
0 === -0 // true
// 用 Object.is 可以发现二者之间的区别
Object.is(+0, -0) // false
Object.is(0, -0) // false
参考: https://2ality.com/2012/03/signedzero.html
该错误由于历史原因, 已经被用作各种错误的通用基类, 因此 其含义不能直接通过名称来理解.
instanceof依赖JavaScript的原型链机制工作, 在不逐级查找原型链的情况下相当于 Object.getPrototypeOf(obj) === Class.prototype.
有几种情况会导致instanceof出现意料之外的结果:
  • 类的静态方法Symbol.hasInstance遭到重写.
  • 实现不正确的(原型)继承.
  • 跨宿主环境:
    例如跨Document(可以用iframe创建另一个Document宿主环境),
    这也是Array.isArray这样的内置方法出现的原因.
  • 跨依赖模块:
    在多个模块依赖同一个模块的不同版本时, 可能出现此问题.
    但更大的问题来自于npm或yarn在一些情况下
    会在两个不同的模块的node_modules目录内安装同一个模块,
    即使两个模块的版本号完全相同
    (因为还有别的模块存在目标模块其他版本的依赖, 因此node_modules最顶层的该依赖位置被占用, 导致依赖相同版本模块的路径无法共用).
    于是在导入模块时, 两个模块实际上导入了不同路径下的模块文件, 导致对象拥有不同的原型.
    删除版本锁定文件并重新安装所有依赖也不能解决此问题, 经测试yarn和npm都无法共用一个模块.
npm包的分层结构注定了在使用多个依赖于同一个模块的模块时, 会出现依赖项实际上不同的问题.
为了解决此问题, 需要为模块设置正确的peer dependencies.
Promise的构造函数如果是同步函数, 则会自动将抛出的异常调用reject.
// work
;(new Promise(() => {
throw 'fucked'
})).catch(console.log)
// not work
;(new Promise(async () => {
throw 'fucked'
})).catch(console.log)
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence
await的优先级高于绝大多数严格意义上的二元运算符, 高于通常语境下的所有二元运算符.
await Promise.resolve(undefined) ?? 'default' // 'default'
await (Promise.resolve(undefined) ?? 'default') // undefined
(await Promise.resolve(undefined)) ?? 'default' // 'default'
new的优先级高于通常语境下的所有运算符.
new … ( … )… . … 运算符的优先级相同.
new … 的优先级低于 new … ( … )… . ….
// 有括号的new优先级与点运算符相同, 所以从左到右先后执行new和点运算符.
new URL('http://localhost').href
// 无括号的new优先级低于点运算符, 所以先执行了Date.now
new Date.now() // TypeError: Date.now is not a constructor
解构赋值时可以设置默认值, 也可用于函数参数的解构赋值:
function test({ a = 1 } = {}) {
console.log(a)
}
test() // 1
test({}) // 1
test({ a: 2 }) // 2
结论: 即使相关上下文始终使用null或undefined其中的一个,
也应该思考这种选择的合理性, 而不是一味地盲从自以为是的选择.
典型的代表是DOM环境中几乎所有情况都在使用null替代undefined, 这显然是不合语义的.
代表一个未被定义的存在, 即 引用不存在.
是一种 占位符, 通常由编译器在幕后使用.
JavaScript在设计之初没有"异常", 因此经常在原本应该抛出异常的地方由undefined替代.
在ES3引入 try...catch, inhasOwnProperty 之前,
检查对象是否具有某个成员需要依赖于undefined的相等性检查.
使用场景:
  • 仅完成声明, 却未进行初始化的变量.
  • 不存在的函数参数.
  • 不存在的对象成员.
  • 函数的缺省返回值.
  • 内置函数 Array::find.
以编程方式定义为空, 代表一个空值, 是一种 人为/有意/明确设置的空值.
引用存在, 但引用的对象是空的.
在非DOM场景里, null更偏向于那些会创造新对象的函数的缺省返回值.
在历史上, null值的访问性能比undefined低, 因为undefined来自于编译器.
使用场景:
  • JSON里的空值.
    对于已定义却未赋值的对象成员, 无论其值是undefined或null, 最后都会被转换为JSON的null.
  • 用户定义的空值.
    这是null的典型应用场景, 代表这个值是存在的, 只不过它是一个空值.
  • 内置函数 String::match.
  • 无原型对象的原型值 Object.getPrototypeOf(Object.prototype).
  • DOM总是倾向于使用null, 考虑到它是一个外部环境, 使用null很可能是因为null在系统级编程语言里用起来更方便:
    Element::querySelector
    document.getElementById
    Element.nextSibling
    Element.parentElement
    Element.onxxx (元素的事件成员)
WeakMap是弱引用版本的Map, WeakSet是弱引用版本的Set.
"弱引用"是指这些对象里的引用是"弱的":
当进行垃圾回收时, 只带有弱引用的对象会被视作无引用, 于是对象就可以被垃圾回收.
不难发现, 弱引用的一大用途就是用于memoize函数.
在memoize里使用WeakMap, 可以将参数里的对象引用保存起来用于之后的匹配,
同时又不会因为memoize的强引用而导致对象不能被垃圾回收.
在一些社区memoize库里, 库作者执着于将WeakMap作为缓存, 这些memoize库通常不使用LRU之类的缓存替换或过期策略.
由于WeakMap只能以对象为键, 以WeakMap作为缓存时, 实际上只能缓存第一个参数是对象的函数.
因此诞生出了一种WeakMap与Map结合的变体, Map用来缓存第一个参数并非对象的情况.
这些做法看似很聪明, 但实际上缺乏实用性:
对象作为函数参数是可变的, 同一个对象在不同的时间很可能有不同的状态.
在大多数使用memoize的场景下, 都不应该仅仅因为引用相等就返回相同的结果.
因此更建议将参数序列化后hash保存, 而不是保存对象的引用.
function* test() {
yield Date.now()
yield Date.now()
}
const iter = test()
console.log(Date.now()) // 时间点A: 0
await delay(1000)
console.log(iter.next().value) // 时间点B: 1000
await delay(1000)
console.log(iter.next().value) // 时间点C: 2000
console.log(Date.now()) // 时间点D: 2000
// 时间点A, B说明生成器在初始化后是暂停的, 没有运行任何内部代码.
// 否则时间点B应该打印0.
// 时间点B, C, D说明当生成器yield之后, 生成器会立即暂停, 而不是运行到下一个yield之前才暂停.
// 否则时间点C应该打印与时间点B相同的值, 且时间点D应该与时间点C相差1000毫秒.
yield关键字是一个一元运算符, 它的元实际上是可省略的, 因此可以直接在生成器里写 yield 而无需在之后添加任何表达式.
yield* 基本上是yield的语法糖, 但有两处容易忽视的区别:
  • 其右值不能省略, 省略时会直接语法错误.
  • 返回值与yield不一样.
yield* 的返回值是其右值完成时返回的value, 也就是说 yield* [1, 2, 3] 会返回 undefined, 因为数组迭代完成时会返回 { done: true }.
这也意味着在使用 yield* 时, 调用者通过 generator.next 方法传入的值无法被使用 yield* 的这一级生成器接收到, 只能被右值本身的迭代器接收到:
function* foo() {
console.log(yield* bar()) // undefined
yield 'C'
}
function* bar() {
console.log(yield 'A') // 1
console.log(yield 'B') // 2
}
const iter = foo()
console.log(iter.next()) // { value: 'A', done: false }
console.log(iter.next(1)) // { value: 'B', done: false }
console.log(iter.next(2)) // { value: 'C', done: false }
console.log(iter.next(3)) // { done: true }
typed-redux-saga通过 yield* 给redux-saga加上了TypeScript的类型.
TypeScript本身对于Generator的支持很薄弱, 在需要支持yield的返回值类型的情况下, 可读性最好的写法如下:
function* gen() {
const result1: A = yield call('fnA')
const result2: B = yield call('fnB')
}
缺点很明显: 这事实上需要手动声明返回值的类型.
typed-redux-saga重新包装了redux-sage的方法, 将所有 yield 改为 yield* 来解决此问题:
function* gen() {
const result1 = yield* call('fnA') // call的返回值是一个会返回`{ done: true, value: A }`的迭代器
const result2 = yield* call('fnB') // call的返回值是一个会返回`{ done: true, value: B }`的迭代器
}
迭代器有以下完成方式:
  • 耗尽: 迭代器内容耗尽, 返回 { done: true }.
  • 提前结束: 迭代器由于 IteratorClose 而提前结束, 返回 { done: true }.
  • 出错: 迭代器抛出错误.
当手动使用迭代器时, 很容易忘记迭代器可能提前结束的情况, 导致资源泄漏.
for...of 会自动关闭iterator:
for...of 在中断时(break, throw, return), 会自动将iterator关闭.
理论上, 这是通过调用Iterator的return方法实现的, 在内部被称为 IteratorClose.
参考: https://exploringjs.com/es6/ch_iteration.html#sec_closing-iterators
迭代器应该在生成器的finally块里释放资源, 例如关闭文件句柄.
手动实现的迭代器应该尽可能模仿生成器的行为, 以便重用生成器的心智模型.
对于一个生成器, 它有以下行为:
  • 调用 generator.throw 时, 如果生成器内部没用捕获到错误, 则会在调用处抛出错误.
    之后所有 generator.next 调用都只返回 { done: true }.
  • 调用 generator.throw 时, 如果生成器内部捕获到错误, 调用处不会抛出错误.
    这种情况下, generator.throw 会像 generator.next 那样返回下一个IteratorResult.
  • generator.return 返回一个IteratorResult, 它是 { done: true }.
  • 在调用 generator.return 后, 所有 generator.next 调用都只返回 { done: true }.
  • generator.next, generator.throw, generator.return 都可以重复调用, 并且在生成器完成时仍然可以调用.
  • 生成器完成后的 generator.return 调用无法改变生成器内部的value, 之后调用 generator.next 得到的仍然是完成时的value.
不难发现, generator.throw 在手动实现的迭代器里没什么意义, 因为迭代器本身并不像生成器那样是一种子过程.
因此, 手动实现的迭代器一般不实现 generator.throw 方法.
手动实现的迭代器的next方法抛出错误时, 迭代器本身并不会自动结束, 因为它本质上只是一种接口.
因此, 如果迭代器的使用者捕获和处理了错误, iterator.next 函数将能够继续调用下去.
有两种方式让处理变得符合预期:
  • 让迭代器记录一个内部状态, 在出现错误后, 使下一个next调用返回 { done: true } 或总是抛出相同错误.
  • 让迭代器的调用者主动调用 iterator.returniterator.throw 结束迭代器.
有一种"最佳实践"认为, 应该总是尝试调用return方法, 但这么做可能会带来意想不到的副作用:
迭代器的next方法在耗尽时会采取与return方法相同的动作,
当迭代器已经完成时, 如果还手动调用return方法, 在迭代器实现不做检查的情况下, 会导致资源释放过程 被执行两次.
为了避免此问题, 迭代器的使用者有必要在使用迭代器时, 创建一个变量存储迭代器的状态, 以避免不必要地调用return方法.
迭代器是否有可接续性, 取决于两点:
  • 它是否会被关闭.
    如果一个迭代器不会被关闭, 则迭代器可以被重用.
  • 它的内容来源是外部状态还是内部状态.
    如果内容来源是外部状态, 则即使迭代器被关闭, 后来新创建的迭代器也会沿着之前的输出继续.
function* num() {
yield 1
yield 2
yield 3
}
// 即使改用 const iter = num()[Symbol.iterator](),
// 或是从iter里再调用Symbol.iterator创建出一个iterable也是一样
const iter = num()
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 不会运行
}
使用spread syntax的版本:
function* num() {
yield 1
yield 2
yield 3
}
const iter = num()
for (const val of iter) {
console.log(val) // 1
break
}
console.log([...iter]) // []
function wrapper(iterator) {
return {
[Symbol.iterator]() {
return {
next() {
return iterator.next()
}
}
}
}
}
function* num() {
yield 1
yield 2
yield 3
}
const iter = wrapper(num())
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
function* num() {
yield 1
yield 2
yield 3
}
// 生成器函数的原型是一个Generator实例, 为return赋值只是遮蔽掉原型方法, 因此不会影响其他生成器函数, 异步生成器函数同理.
num.prototype.return = undefined;
const iter = num()
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
const num = {
i: 0
, [Symbol.iterator]() {
// 如果在此处插入一个console.log, 会发现此iterator被创建了两次
return {
next() {
if (num.i < 3) {
return { done: false, value: ++num.i }
} else {
return { done: true, value: undefined }
}
}
}
}
}
const iter = num
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
即使改用generator也是一样, 其根本原因在于变量i是一个外部状态.
const num = {
i: 0
, *[Symbol.iterator]() {
// 如果在此处插入一个console.log, 会发现此iterator被创建了两次
while (num.i < 3) {
yield ++num.i
}
}
}
const iter = num
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
加入return方法.
const num = {
i: 0
, [Symbol.iterator]() {
// 如果在此处插入一个console.log, 会发现此iterator被创建了两次
return {
next() {
if (num.i < 3) {
return { done: false, value: ++num.i }
} else {
return { done: true, value: undefined }
}
}
, return(...args) {
console.log('return', args)
return { done: true }
}
}
}
}
const iter = num
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
// 输出顺序为:
// 1
// return []
// 2
// 3
其原因是因为内置的 String.prototype[@@iterator] 为每个迭代维护了一个外部状态.
const num = [1, 2, 3]
const iter = num[Symbol.iterator]()
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
使用spread syntax的效果一样:
const num = [1, 2, 3]
const iter = num[Symbol.iterator]()
for (const val of iter) {
console.log(val) // 1
break
}
console.log([...iter]) // [2, 3]
其原因是因为内置的 Array.prototype[@@iterator] 为每个迭代维护了一个外部状态.
const num = '123'
const iter = num[Symbol.iterator]()
for (const val of iter) {
console.log(val) // 1
break
}
for (const val of iter) {
console.log(val) // 2, 3
}
使用spread syntax的效果一样:
const num = '123'
const iter = num[Symbol.iterator]()
for (const val of iter) {
console.log(val) // 1
break
}
console.log([...iter]) // ["2", "3"]
JavaScript的 for await...of 是用于串行处理流式数据的语法.
for await (const chunk of stream) {
// ...
}
等效于
const iter = stream[Symbol.asyncIterator]()
const chunk1 = (await iter.next()).value
const chunk2 = (await iter.next()).value
// ...
const chunkN = (await iter.next()).value
AsyncIterator的next方法返回值为 Promise<IteratorResult>,
而Iterator的next方法返回值为 IteratorResult.
可以通过以下两种方式用Iterator模拟AsyncIterator:
// 方式1
const asyncIterable = {
[Symbol.iterator]() {
return {
i: 0,
next() {
return Promise.resolve({ done: false, value: this.i++ })
}
}
}
}
// 方式2
const asyncIterable = {
async *[Symbol.iterator]() {
let i = 0
while (true) {
yield i++
}
}
}
唯一的不同在于Iterator的done属性可以同步检查, 而AsyncIterator的done属性必须等待Promise完成.
尽管异步函数可以通过Generator实现
(因为Generator提供暂停(yield)和恢复(iter.next(value))函数运行的功能),
但异步生成器只是一种为Generator添加了await运算符的生成器而已, await本身并不参与Generator的行为.
Async Generator与Generator的区别在于返回值接口的不同:
Generator的返回值是只具有 Symbol.iterator 的Iterator.
Async Generator的返回值是只具有 Symbo.asyncIterator 的AsyncIterator.
for await...of 会优先调用 Symbol.asyncIterator.
for await...of 会像 await 一样自动处理Promise,
这使得当 for await...of 作用于返回的value为Promise的Iterator时, 会出现意料之外的行为.
AsyncIterator 只是辅助生成数据流的工具.
for await...of 按顺序串行运行, 在处理完一个 Promise 前不会向 AsyncIterator 索要下一部分.
如果需要并发处理一个可迭代对象, 请使用 Iterator, 而不是 AsyncIterator.
for await...of 也可同时处理同步可迭代对象, 与 await 的设计一致.
Observable 是 push 式的流处理, AsyncIterator 是 pull 式的流处理.
pull 式的流处理可以无损转换成 push 式的流处理, 但 push 式的流处理不能无损转换成 pull 式的流处理.
其原因在于 pull 由代码决定何时从底层得到下一组数据, 而 push 必须接收所有数据到内存,
后者可能遭遇消费者-生产者问题/背压(backpressure):
即缓冲区积累数据的速度比处理数据的速度快, 导致内存不堪重负.
pull 式的流处理让底层代码能够进行流控制: 暂停和调整流量窗口.
像AsyncIterator这样的pull式流处理机制在将一个数据源用于多个处理函数时会不方便,
源往往会被迫从pull式转为push式, 因为能转动"给我数据"的手柄的处理函数只能有一个.
需要注意的是, Stream和Observable是不同的:
Stream是pull式的流处理, 只是滥用回调的编码风格让它看起来像push, 它支持真正的背压处理.
因此AsyncIterator不仅可以被转换为Stream, Stream的下游还可以控制上游的速率.
在Node.js里, 在使用pipe()时, 流模块将自动完成背压控制, 手动write的情况则需要开发者自己处理背压.
Node.js文档提供了将AsyncIterator转换为Stream的方式.
在Web的Stream里, 也有类似的机制和背压处理方式, 只是不够为人所知.
二者在语义上的区别很模糊, 因为目的是相似的,
之所以使用不同的接口, 常常是受实现方式的限制: 代码内部是否存在并发.
在实现之前, 很可能无法确定应该使用哪种接口作为返回值.
AsyncIterable的具体实现需是一个接一个地返回结果,
允许中途暂停和放弃(但由于缺乏手动关闭迭代器的API, 只能等待垃圾回收), 意味着实现部分不包含并发.
Promise的具体实现需是一次性返回结果, 意味着实现部分包含并发.
从表面上看, AsyncIterator<T> 可以转换为 Iterator<Promise<T>>.
但是, 直到AsyncIterator返回的Promise返回结果之前, 无法得知AsyncIterator是否耗尽,
因此只会转换出具有无限个结果的Iterator, 没有实用价值.
可行.
绝大多数AsyncIterator暗示了数据具有顺序, 产生迭代值的过程很可能是串行的.
将AsyncIterator转换为并发任务只是将这一串行过程之间不必要的等待消除,
这实际上更像是Node.js传统的回调函数模式.
打包会导致浏览器在首次访问网站时载入过大的JavaScript文件.
为了让只加载那些被当前页面用到模块, 需要实行代码拆分.
通过CRA, Next.js, Gatsby创建的React项目不需要手动配置代码拆分,
它们会在编译时自己实现基于路由的代码拆分.
如果需要更精确的代码拆分, 可以使用这些项目提供的与动态导入(dynamic import)相关的API.
https://loadable-components.com/
React官方推荐的代码拆分工具, 支持服务器渲染.
它基本上是不需要Suspense版本的 React.lazy, 但也可以支持Suspense.
React.lazy 是一个与Suspense方案协同使用的代码拆分API,
它可以把动态导入包装成常规React组件, 使用时需要通过Suspense载入.
和Suspense一样, React.lazy 不支持服务器渲染.
优先级从高到低排列:
  1. 1.
    browser字段
  2. 2.
    module字段
  3. 3.
    main字段
browser字段的优先级更高是可以理解的, 但在实际使用中, 经常会希望module字段的优先级更高,
因为browser字段经常是指向已经打包好的模块.
更合适的做法是, 让包作者在browser字段里提供对特定文件的浏览器兼容映射,
而不是将browser字段直接绑在打包好的文件上:
https://github.com/webpack/webpack/issues/4674#issuecomment-355853969
{
"type": "module",
"exports": {
"node": "./index-node.js",
"worker": "./index-worker.js",
"default": "./index.js"
}
}
由于exports字段会导致未列出的文件无法导入, 因此这种做法对一些包来说可能并不是那么有意义.
  • webpack-dev-server Web开发热重载
  • webpack-ext-reloader 浏览器扩展程序热重载
这两个来自Node.js的变量是绝对路径, 在大部分bundler里都无法获得填充.
webpack是少数支持填充它们的打包器, 在配置之后可以通过内部插件NodeStuffPlugin将变量填充为相对路径.
即使使用 __filename__dirname 的是外部模块, 相对路径也会被正确填充.
但webpack将变量填充为相对路径的行为在一些情况下仍然不够:
只有服务器项目可以在填充为相对路径的情况下正常工作, 因为服务器项目的程序启动路径和往往和相对路径的起点(即项目根目录)一致.
对于CLI项目, 将相应变量填充为相对路径不起作用, 因为程序的启动路径是npm的根目录 C:\Users\User\AppData\Roaming\npm.
解决此问题的唯一方案是将相应变量的填充值在运行时恢复为绝对路径, 然而webpack并没有内置此解决方案.
如需解决此问题, 开发者要么改变项目获取路径时的策略, 要么编写插件改变填充值.
通常情况下, 可以认为从当前被打包文件向上查找到的第一个 package.json 所在的目录是当前项目的根目录.
在CommonJS作为入口点的项目里, 无论在哪个文件里调用, 总是可以通过 require.main.path 向上查找 package.json 文件来找到项目根目录.
在ESM作为入口点的项目里, require.main 将始终为 undefined, 即使相关变量是从cjs模块里访问的, 但这不意味着ESM项目就无法找到根目录:
由于在CommonJS项目的入口点里, module 等同于 require.main (即 require.main === module 成立),
因此在最终打包为ESM文件的项目里, 实际上可以用 module.path 来替代 require.main.path.
为Webpack编写插件是不划算的:
  • webpack的插件系统虽然提供了TypeScript类型定义, 但其定义并不严谨, 实际使用中会经常出现any, 导致很难编写插件.
  • 大量内部插件仍在使用过时风格的JavaScript编写, 缺乏可维护性.
  • 一些API甚至没有文档.
基于更先进的工具链创造出来的Webpack替代品, 和Vue一样主打更好的人体工程学, 因其使用esbuild和浏览器原生的ESM支持而获得了极高的性能.
不推荐与Jest整合使用, 因为Jest无法直接重用Vite的配置, 建议使用Vitest.
对monorepo的CommonJS项目不友好: https://github.com/vitejs/vite/issues/5668
提供与Jest兼容的API, 开箱即用.
在使用@testing-library/jest-dom时仍存在障碍:
  • https://github.com/testing-library/jest-dom/issues/427
  • https://github.com/vitest-dev/vitest/issues/517
配置 singleThread: true 可使得Vitest像启用了 runInBand 选项的Jest那样运行.
poolOptions: {
threads: {
singleThread: true
}
}