JavaScript

当访问一个对象的成员时, 如果在对象上找不到该成员, 就会逐级查找它的原型链, 以此类推, 直到原型为 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

优点:

  • 性能更好

优点:

  • 可以获取原始对象(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.

特点:

  • 具有对代码环境的控制
  • 高性能
  • 同步代码

缺点:

  • 使用主线程运行代码, 可能导致主线程崩溃

特点:

  • 单独的线程
  • 异步代码

缺点:

  • 资源占用量少
  • 没有DOM(在一定程度上可以用JSDOM弥补)

特点:

  • 单独的线程
  • 具有对代码运行环境最大程度的控制
  • 异步代码

缺点:

  • 资源占用量少
  • 没有DOM(在一定程度上可以用JSDOM弥补)

特点:

  • 具有DOM

缺点:

  • 资源占用量大

https://github.com/laverdet/isolated-vm

JavaScript具有一种可转移所有权的抽象接口Transferable, 由Web宿主实现.
实现所有权转移的接口可以在Worker之间直接转移内存的所有权, 而无需复制数据.

只有少数对象实现了此接口:

  • ArrayBuffer
  • MessagePort(SharedWorker, MessageChannel)
  • ImageBitmap
  • OffscreenCanvas

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毫秒.

迭代器有两种完成方式:

  • 耗尽: 迭代器内容耗尽, 返回 { done: true }.
  • 结束: 迭代器由于 IteratorClose 而提前结束, 返回 { done: true }.

当手动使用迭代器时, 很容易忘记迭代器可能提前结束的情况, 导致资源泄漏.

参考: https://exploringjs.com/es6/ch_iteration.html#sec_closing-iterators

迭代器应该在生成器的finally块或返回对象的return方法里释放资源, 例如关闭文件句柄.

const iterable = {
[Symbol.iterator]() {
function hasNextValue() { ··· }
function getNextValue() { ··· }
function cleanUp() { ··· }
let returnedDoneResult = false;
return {
next() {
if (hasNextValue()) {
const value = getNextValue()
return { done: false, value: value }
} else {
if (!returnedDoneResult) {
cleanUp()
returnedDoneResult = true
}
return { done: true, value: undefined }
}
}
, return() {
cleanUp()
}
}
}
}

生成器/迭代器的内部抛出错误时, 迭代器本身并不会结束(即使使用 for...of 也一样).
如果迭代器抛出的错误被捕获, 程序未能崩溃, 则相关资源会泄漏.

使用者需要在 finally 块里手动调用 iterator.return().

请查看同节的陷阱部分, 以避免错误.

使用者如果也是一个迭代器, 则应该创建对应的return方法, 以能够将内部使用的迭代器关闭.

请查看同节的陷阱部分, 以避免错误.

有一种"最佳实践"认为, 应该总是尝试调用return方法, 但这么做可能会带来意想不到的副作用:
迭代器的next方法在耗尽时会采取与return方法相同的动作,
当迭代器耗尽时, 如果还手动调用return方法, 会导致资源释放过程 被执行两次.

为了避免此问题, 迭代器的使用者有必要在使用迭代器时, 创建一个变量存储迭代器的状态,
以避免对已耗尽的迭代器调用return方法.

这种场景下, 使用者耗尽迭代器是预期情况, 但实际运行时可能发生意外情况, 例如代码抛出异常.
如果代码抛出异常, 而异常又被它的调用者捕获, 则程序不会崩溃, 此时迭代器仍然没有被手动关闭, 会发生泄漏.

Iterator有next, return, throw三个方法, 主要使用的是next方法.
return方法是可选的, 用于提前结束迭代器, 有return方法的迭代器被称为closable.
throw方法是next的抛出异常版本, 生成器内的代码会在等待的yield处抛出异常值,
该方法由于缺乏应用场景而极少被使用.

for...of 在中断时(break, throw, return), 会将iterator关闭.
理论上, 这是通过调用Iterator的return方法实现的, 这在内部被称为 IteratorClose.

迭代器是否有可接续性, 取决于两点:

  • 它是否会被关闭.
    如果一个迭代器不会被关闭, 则迭代器可以被重用.
  • 它的内容来源是外部状态还是内部状态.
    如果内容来源是外部状态, 则即使迭代器被关闭, 后来新创建的迭代器也会沿着之前的输出继续.
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传统的回调函数模式.