JavaScript
JavaScript不支持多线程, 现有的多线程方案是基于Worker的, 这种多线程有很严重的性能问题.
一般情况下, 在编程语言里使用多线程是为了能够让程序的总运行时间低于单线程情况下的运行时间,
一般的编程语言在发挥多线程性能上的主要障碍是线程上下文切换成本,
由于线程上下文切换成本实际上不是很高, 因此总算还是可以通过优化代码来让多线程程序有意义.
在Go语言这样基于有栈协程的语言里, 上下文切换成本被进一步减小, 使得多线程程序跑得非常快.
而Worker的通信成本要远远高于协程上下文切换和线程上下文切换, 优化Worker的通信基本上是不可能的,
几乎不可能让它达到令人满意的水平, 在绝大多数情况下, 这种多线程程序的效率都不会高于单线程程序.
一般的编程语言在发挥多线程性能上的主要障碍是线程上下文切换成本,
由于线程上下文切换成本实际上不是很高, 因此总算还是可以通过优化代码来让多线程程序有意义.
在Go语言这样基于有栈协程的语言里, 上下文切换成本被进一步减小, 使得多线程程序跑得非常快.
而Worker的通信成本要远远高于协程上下文切换和线程上下文切换, 优化Worker的通信基本上是不可能的,
几乎不可能让它达到令人满意的水平, 在绝大多数情况下, 这种多线程程序的效率都不会高于单线程程序.
Worker之间几乎无法共享内存, 尽管可以把它美其名曰Actor模型, 但它显然是一种低效率的Actor模型,
因为无法共享内存的事实已经确实严重拖累了Worker的内存空间效率.
一个在一般的编程语言里能够以500MB内存运行的程序, 在使用Worker的情况下需要将其复制到每个线程里, 这是不可接受的.
因为无法共享内存的事实已经确实严重拖累了Worker的内存空间效率.
一个在一般的编程语言里能够以500MB内存运行的程序, 在使用Worker的情况下需要将其复制到每个线程里, 这是不可接受的.
消息传递需要序列化和反序列化也是不可接受的:
假如正在传输的是一个200MB的数据, 则序列化后可以预测内存中会有约400MB的数据,
然后由于另一端需要反序列化, 这会进一步导致总内存用量上升到600MB.
假如正在传输的是一个200MB的数据, 则序列化后可以预测内存中会有约400MB的数据,
然后由于另一端需要反序列化, 这会进一步导致总内存用量上升到600MB.
即使通信成本, 序列化和反序列化成本现在都被视作可容忍的, 也仍然存在问题.
发送数据所使用的结构化克隆算法支持的数据类型是有限的, 如果需要克隆一个自定义的类实例, 则会多出一重转换成本:
需要先在发送端将类实例转换为可传输的结构, 再在接收端将其还原回类的实例.
所以在最糟糕的情况下, 一个原本能够通过共享内存直接实现的用例, 在JavaScript里需要付出最高5倍的内存峰值为代价才能实现.
发送数据所使用的结构化克隆算法支持的数据类型是有限的, 如果需要克隆一个自定义的类实例, 则会多出一重转换成本:
需要先在发送端将类实例转换为可传输的结构, 再在接收端将其还原回类的实例.
所以在最糟糕的情况下, 一个原本能够通过共享内存直接实现的用例, 在JavaScript里需要付出最高5倍的内存峰值为代价才能实现.
Buffer是一种非常基础的线性内存空间.
除非程序员能够完全基于Buffer进行程序开发,
即直接将Buffer视作内存, 在Buffer里实现它需要的数据结构(例如https://github.com/Bnaya/objectbuffer/),
否则通过共享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) // fooProxy并不是一种完美的动态方案, 它会产生很严重的性能问题, 并且trap并不总是能用反射来实现, 因为handlers对返回值或target有要求.
当访问一个对象的成员时, 如果在对象上找不到该成员, 就会逐级查找它的原型链, 以此类推, 直到原型为
由于原型链机制, 访问一个不存在的对象成员对JavaScript引擎来说会需要付出比较高的运行成本.
null 的对象为止.由于原型链机制, 访问一个不存在的对象成员对JavaScript引擎来说会需要付出比较高的运行成本.
类或构造函数才有的属性, 用于指定该类或构造函数创建出的对象所具有的原型.
一个不可枚举, 可修改, 可配置的成员.
该成员默认存在, 并且指向构造函数.
该成员默认存在, 并且指向构造函数.
.prototype.constructor 在语义上并没有特殊意义, 它的存在与否对 new 运算符的行为不构成任何影响.大多数原型继承示例会确保
.prototype.constructor 的存在, 但这通常只是为了维护与默认原型的行为一致.在实际使用中,
.prototype.constructor 的主要作用是让实例能够通过 this.constructor 访问自己的构造函数.ES5引入的方法.
在运行时获取对象使用的原型对象.
class Foo {}Object.getPrototypeOf(new Foo()) === Foo.prototypeES6引入的方法, 可以替代
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 = Rectimmer会在重建对象时重用未修改的引用, 和手动创建不可变新对象时的思路一致.
由于需要遵守不可变性, 包括根对象在内, 被修改路径上的对象都会被重新创建.
由于需要遵守不可变性, 包括根对象在内, 被修改路径上的对象都会被重新创建.
const baseState = { a: {}, b: { c: {} , d: { e: {} , f: {} } }}const nextState = produce(baseState, draft => { draft.b.d.f = {}})console.log(baseState === nextState) // falseconsole.log(baseState.a === nextState.a) // trueconsole.log(baseState.b === nextState.b) // falseconsole.log(baseState.b.c === nextState.b.c) // trueconsole.log(baseState.b.d === nextState.b.d) // falseconsole.log(baseState.b.e === nextState.b.e) // trueconsole.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
https://github.com/tc39/ecma262/pull/1340
let valtry { 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 是为了能够重复声明 errvar [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库, 例如
这些库仍然属于Parser, 与传统Parser的区别是它们不只可以接受文本输入, 还接受其他的数据类型.
lit-html.这些库仍然属于Parser, 与传统Parser的区别是它们不只可以接受文本输入, 还接受其他的数据类型.
函数的this会在运行时进行绑定, 其值取决于调用时点(
.)运算符前面的对象.以下方式可以改变这一机制:
- 使用bind方法手动绑定this对象
- 使用apply, call, Reflect.apply方法在调用函数时手动指定this值.
- 使用箭头函数, 其this在创建此箭头函数时就已绑定.
在非严格模式下, 当函数不以点运算符的形式调用时, this的值为
globalThis.在严格模式下, 当函数不以点运算符的形式调用时, this的值为
undefined.JavaScript具有一种可转移所有权的抽象接口Transferable, 由Web宿主实现.
实现所有权转移的接口可以在Worker之间直接转移内存的所有权, 而无需复制数据.
实现所有权转移的接口可以在Worker之间直接转移内存的所有权, 而无需复制数据.
只有少数对象实现了此接口, 主要是类ArrayBuffer结构和各种句柄.
详见: https://developer.mozilla.org/en-US/docs/Glossary/Transferable_objects
JavaScript的宿主环境主要提供了以下两个高分辨率函数用于测量运行时间:
performance.markperformance.measure
mark相当于给当前时间点打上一个标记, measure用于计算各个标记之间经过的时间.
浏览器本身也会给一些类型行为打上标记.
浏览器本身也会给一些类型行为打上标记.
对于Math.random()来说, 其结果值的取值范围为[0, 1).
开发人员可能对于[0, 1)的取值范围不满, 因为[0, 1]显然更好.
开发人员可能对于[0, 1)的取值范围不满, 因为[0, 1]显然更好.
从实用角度来说, [0, 1]的取值范围是没有意义的, 因为值正好是1的概率为2^-64:
即使API实现了[0,1]的取值范围, 每秒生成100万次随机值, 也需要60万年才能随机到1.
即使API实现了[0,1]的取值范围, 每秒生成100万次随机值, 也需要60万年才能随机到1.
JavaScript中的+0和-0来自于IEEE 754标准, 并不是语言本身的设计.
// 在绝大多数情况下, +0(可以缩写为0) 和 -0 都是0, 没有区别+0 === -0 // true0 === -0 // true// 用 Object.is 可以发现二者之间的区别Object.is(+0, -0) // falseObject.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.
为了解决此问题, 需要为模块设置正确的peer dependencies.
Promise的构造函数如果是同步函数, 则会自动将抛出的异常调用reject.
// worknew Promise(() => { throw 'Oops!'}).catch(console.log)// not worknew Promise(async () => { throw 'Oops!'}).catch(console.log)https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence
await的优先级高于绝大多数严格意义上的二元运算符, 高于通常语境下的所有二元运算符.
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.nownew Date.now() // TypeError: Date.now is not a constructor解构赋值时可以设置默认值, 也可用于函数参数的解构赋值:
function test({ a = 1 } = {}) { console.log(a)}test() // 1test({}) // 1test({ a: 2 }) // 2结论: 即使相关上下文始终使用null或undefined其中的一个,
也应该思考这种选择的合理性, 而不是一味地盲从自以为是的选择.
典型的代表是DOM环境中几乎所有情况都在使用null替代undefined, 这显然是不合语义的.
也应该思考这种选择的合理性, 而不是一味地盲从自以为是的选择.
典型的代表是DOM环境中几乎所有情况都在使用null替代undefined, 这显然是不合语义的.
代表一个未被定义的存在, 即 引用不存在.
是一种 占位符, 通常由编译器在幕后使用.
是一种 占位符, 通常由编译器在幕后使用.
JavaScript在设计之初没有"异常", 因此经常在原本应该抛出异常的地方由undefined替代.
在ES3引入
检查对象是否具有某个成员需要依赖于undefined的相等性检查.
在ES3引入
try...catch, in 和 hasOwnProperty 之前,检查对象是否具有某个成员需要依赖于undefined的相等性检查.
使用场景:
- 仅完成声明, 却未进行初始化的变量.
- 不存在的函数参数.
- 不存在的对象成员.
- 函数的缺省返回值.
- 内置函数
Array::find.
以编程方式定义为空, 代表一个空值, 是一种 人为/有意/明确设置的空值.
引用存在, 但引用的对象是空的.
在非DOM场景里, null更偏向于那些会创造新对象的函数的缺省返回值.
引用存在, 但引用的对象是空的.
在非DOM场景里, null更偏向于那些会创造新对象的函数的缺省返回值.
在历史上, null值的访问性能比undefined低, 因为undefined来自于编译器.
使用场景:
- JSON里的空值.
对于已定义却未赋值的对象成员, 无论其值是undefined或null, 最后都会被转换为JSON的null. - 用户定义的空值.
这是null的典型应用场景, 代表这个值是存在的, 只不过它是一个空值. - 内置函数
String::match. - 无原型对象的原型值
Object.getPrototypeOf(Object.prototype). - DOM总是倾向于使用null, 考虑到它是一个外部环境, 使用null很可能是因为null在系统级编程语言里用起来更方便:
Element::querySelectordocument.getElementByIdElement.nextSiblingElement.parentElementElement.onxxx(元素的事件成员)
WeakMap是弱引用版本的Map, WeakSet是弱引用版本的Set.
"弱引用"是指这些对象里的引用是"弱的":
当进行垃圾回收时, 只带有弱引用的对象会被视作无引用, 于是对象就可以被垃圾回收.
当进行垃圾回收时, 只带有弱引用的对象会被视作无引用, 于是对象就可以被垃圾回收.
不难发现, 弱引用的一大用途就是用于memoize函数.
在memoize里使用WeakMap, 可以将参数里的对象引用保存起来用于之后的匹配,
同时又不会因为memoize的强引用而导致对象不能被垃圾回收.
在memoize里使用WeakMap, 可以将参数里的对象引用保存起来用于之后的匹配,
同时又不会因为memoize的强引用而导致对象不能被垃圾回收.
在一些社区memoize库里, 库作者执着于将WeakMap作为缓存, 这些memoize库通常不使用LRU之类的缓存替换或过期策略.
由于WeakMap只能以对象为键, 以WeakMap作为缓存时, 实际上只能缓存第一个参数是对象的函数.
因此诞生出了一种WeakMap与Map结合的变体, Map用来缓存第一个参数并非对象的情况.
由于WeakMap只能以对象为键, 以WeakMap作为缓存时, 实际上只能缓存第一个参数是对象的函数.
因此诞生出了一种WeakMap与Map结合的变体, Map用来缓存第一个参数并非对象的情况.
这些做法看似很聪明, 但在绝大多数情况下缺乏实用性:
对象作为函数参数是可变的, 同一个对象在不同的时间很可能有不同的状态, 不应该仅仅因为引用相等就返回相同的结果.
将参数序列化后保存hash结果, 而不是保存对象的引用, 显然更加可取.
对象作为函数参数是可变的, 同一个对象在不同的时间很可能有不同的状态, 不应该仅仅因为引用相等就返回相同的结果.
将参数序列化后保存hash结果, 而不是保存对象的引用, 显然更加可取.
我所知道的唯一一种例外情况是用于Redux选择器的memoize解决方案,
WeakMap此时之所以可行, 是因为Redux是少数能在哲学层面有坚实理由的, 建立在数据不变性上的项目
(在非纯函数式语言里, 几乎所有与数据不变性有关的项目都是叶公好龙: 要么不严格, 要么没必要).
WeakMap此时之所以可行, 是因为Redux是少数能在哲学层面有坚实理由的, 建立在数据不变性上的项目
(在非纯函数式语言里, 几乎所有与数据不变性有关的项目都是叶公好龙: 要么不严格, 要么没必要).
function* test() { yield Date.now() yield Date.now()}const iter = test()console.log(Date.now()) // 时间点A: 0await delay(1000)console.log(iter.next().value) // 时间点B: 1000await delay(1000)console.log(iter.next().value) // 时间点C: 2000console.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.return或iterator.throw结束迭代器.
有一种"最佳实践"认为, 应该总是尝试调用return方法, 但这么做可能会带来意想不到的副作用:
迭代器的next方法在耗尽时会采取与return方法相同的动作,
当迭代器已经完成时, 如果还手动调用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 = numfor (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 = numfor (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 = numfor (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()).valueconst chunk2 = (await iter.next()).value// ...const chunkN = (await iter.next()).valueAsyncIterator的next方法返回值为
而Iterator的next方法返回值为
Promise<IteratorResult>,而Iterator的next方法返回值为
IteratorResult.可以通过以下两种方式用Iterator模拟AsyncIterator:
// 方式1const asyncIterable = { [Symbol.iterator]() { return { i: 0, next() { return Promise.resolve({ done: false, value: this.i++ }) } } }}// 方式2const asyncIterable = { async *[Symbol.iterator]() { let i = 0 while (true) { yield i++ } }}唯一的不同在于Iterator的done属性可以同步检查, 而AsyncIterator的done属性必须等待Promise完成.
尽管异步函数可以通过Generator实现
(因为Generator提供暂停(
但异步生成器只是一种为Generator添加了await运算符的生成器而已, await本身并不参与Generator的行为.
(因为Generator提供暂停(
yield)和恢复(iter.next(value))函数运行的功能),但异步生成器只是一种为Generator添加了await运算符的生成器而已, await本身并不参与Generator的行为.
Async Generator与Generator的区别在于返回值接口的不同:
Generator的返回值是只具有
Async 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式, 因为能转动"给我数据"的手柄的处理函数只能有一个.
源往往会被迫从pull式转为push式, 因为能转动"给我数据"的手柄的处理函数只能有一个.
需要注意的是, Stream和Observable是不同的:
Stream是pull式的流处理, 只是滥用回调的编码风格让它看起来像push, 它支持真正的背压处理.
Stream是pull式的流处理, 只是滥用回调的编码风格让它看起来像push, 它支持真正的背压处理.
因此AsyncIterator不仅可以被转换为Stream, Stream的下游还可以控制上游的速率.
在Node.js里, 在使用pipe()时, 流模块将自动完成背压控制, 手动write的情况则需要开发者自己处理背压.
Node.js文档提供了将AsyncIterator转换为Stream的方式.
Node.js文档提供了将AsyncIterator转换为Stream的方式.
在Web的Stream里, 也有类似的机制和背压处理方式, 只是不够为人所知.
二者在语义上的区别很模糊, 因为目的是相似的,
之所以使用不同的接口, 常常是受实现方式的限制: 代码内部是否存在并发.
在实现之前, 很可能无法确定应该使用哪种接口作为返回值.
之所以使用不同的接口, 常常是受实现方式的限制: 代码内部是否存在并发.
在实现之前, 很可能无法确定应该使用哪种接口作为返回值.
AsyncIterable的具体实现需是一个接一个地返回结果,
允许中途暂停和放弃(但由于缺乏手动关闭迭代器的API, 只能等待垃圾回收), 意味着实现部分不包含并发.
Promise的具体实现需是一次性返回结果, 意味着实现部分包含并发.
允许中途暂停和放弃(但由于缺乏手动关闭迭代器的API, 只能等待垃圾回收), 意味着实现部分不包含并发.
Promise的具体实现需是一次性返回结果, 意味着实现部分包含并发.
从表面上看,
但是, 直到AsyncIterator返回的Promise返回结果之前, 无法得知AsyncIterator是否耗尽,
因此只会转换出具有无限个结果的Iterator, 没有实用价值.
AsyncIterator<T> 可以转换为 Iterator<Promise<T>>.但是, 直到AsyncIterator返回的Promise返回结果之前, 无法得知AsyncIterator是否耗尽,
因此只会转换出具有无限个结果的Iterator, 没有实用价值.
可行.
绝大多数AsyncIterator暗示了数据具有顺序, 产生迭代值的过程很可能是串行的.
将AsyncIterator转换为并发任务只是将这一串行过程之间不必要的等待消除,
这实际上更像是Node.js传统的回调函数模式.
将AsyncIterator转换为并发任务只是将这一串行过程之间不必要的等待消除,
这实际上更像是Node.js传统的回调函数模式.
打包会导致浏览器在首次访问网站时载入过大的JavaScript文件.
为了让只加载那些被当前页面用到模块, 需要实行代码拆分.
为了让只加载那些被当前页面用到模块, 需要实行代码拆分.
通过CRA, Next.js, Gatsby创建的React项目不需要手动配置代码拆分,
它们会在编译时自己实现基于路由的代码拆分.
如果需要更精确的代码拆分, 可以使用这些项目提供的与动态导入(dynamic import)相关的API.
它们会在编译时自己实现基于路由的代码拆分.
如果需要更精确的代码拆分, 可以使用这些项目提供的与动态导入(dynamic import)相关的API.
https://loadable-components.com/
React官方推荐的代码拆分工具, 支持服务器渲染.
它基本上是不需要Suspense版本的
它基本上是不需要Suspense版本的
React.lazy, 但也可以支持Suspense.React.lazy 是一个与Suspense方案协同使用的代码拆分API,它可以把动态导入包装成常规React组件, 使用时需要通过Suspense载入.
和Suspense一样,
React.lazy 不支持服务器渲染.优先级从高到低排列:
- 1.browser字段
- 2.module字段
- 3.main字段
browser字段的优先级更高是可以理解的, 但在实际使用中, 经常会希望module字段的优先级更高,
因为browser字段经常是指向已经打包好的模块.
因为browser字段经常是指向已经打包好的模块.
更合适的做法是, 让包作者在browser字段里提供对特定文件的浏览器兼容映射,
而不是将browser字段直接绑在打包好的文件上:
https://github.com/webpack/webpack/issues/4674#issuecomment-355853969
而不是将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将变量填充为相对路径.
即使使用
webpack是少数支持填充它们的打包器, 在配置之后可以通过内部插件NodeStuffPlugin将变量填充为相对路径.
即使使用
__filename 和 __dirname 的是外部模块, 相对路径也会被正确填充.但webpack将变量填充为相对路径的行为在一些情况下仍然不够:
只有服务器项目可以在填充为相对路径的情况下正常工作, 因为服务器项目的程序启动路径和往往和相对路径的起点(即项目根目录)一致.
对于CLI项目, 将相应变量填充为相对路径不起作用, 因为程序的启动路径是npm的根目录
只有服务器项目可以在填充为相对路径的情况下正常工作, 因为服务器项目的程序启动路径和往往和相对路径的起点(即项目根目录)一致.
对于CLI项目, 将相应变量填充为相对路径不起作用, 因为程序的启动路径是npm的根目录
C:\Users\User\AppData\Roaming\npm.解决此问题的唯一方案是将相应变量的填充值在运行时恢复为绝对路径, 然而webpack并没有内置此解决方案.
如需解决此问题, 开发者要么改变项目获取路径时的策略, 要么编写插件改变填充值.
如需解决此问题, 开发者要么改变项目获取路径时的策略, 要么编写插件改变填充值.
通常情况下, 可以认为从当前被打包文件向上查找到的第一个
package.json 所在的目录是当前项目的根目录.在CommonJS作为入口点的项目里, 无论在哪个文件里调用, 总是可以通过
require.main.path 向上查找 package.json 文件来找到项目根目录.在ESM作为入口点的项目里,
由于在CommonJS项目的入口点里,
因此在最终打包为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一样主打更好的人体工程学.
v8之前的版本使用esbuild和rollup, v8开始使用rolldown.
v8之前的版本使用esbuild和rollup, v8开始使用rolldown.
- 对monorepo的CommonJS项目不友好: https://github.com/vitejs/vite/issues/5668
- 导入转译支持有限:
一个能通过webpack转译的包和能通过Node.js导入的包可以通过vite的构建,
但却可能在浏览器中导入失败.
解决方案: https://vike.dev/broken-npm-package - 开发模式下没有tree shaking: https://github.com/vitejs/vite/issues/8237
这是Vite为了表面上的性能而投机取巧的地方, 也使其不如Webpack有用: - 大型项目将会加载特别多的文件, 导致与之相关的一切变得缓慢低效.
- 一部分依赖Node.js的脚本因此被打包,
这些脚本可能在加载过程中就需要调用Node.js相关的API, 进而导致失败.
该问题据称会在Vite 8支持rolldown后通过Full Bundle Mode解决,
目前虽然可以通过experimental.bundledDev实验性地启用此功能,
但还能未解决相关问题.
专用于打包为浏览器编写的库的模式, 类似于rollup的使用场景.
库作者是否应该使用这个模式, 取决于库在应用里打包时是否方便.
例如, 如果库包含一些需要特别配置Vite或安装插件才能打包的资源,
那么或许就应该使用库模式以降低使用库的摩擦.
例如, 如果库包含一些需要特别配置Vite或安装插件才能打包的资源,
那么或许就应该使用库模式以降低使用库的摩擦.
在配置中启用清单后, 将在构建时一并生成一个
清单文件包含包的元数据, 主要用于与服务端的集成.
manifest.json 文件.清单文件包含包的元数据, 主要用于与服务端的集成.
主要用于 客户端入口文件, 对服务端入口文件而言几乎无意义.
在配置中启用SSR打包, 会根据指定的 服务端入口文件 生成一个用于Node.js的包.
该包会保留依赖项的导入语句, 因为Node.js显然不需要打包依赖项.
该包会保留依赖项的导入语句, 因为Node.js显然不需要打包依赖项.
开发服务器可通过API
vite.ssrLoadModule() 一次性完成SSR入口的构建和导入.这是一项单独的功能, 不要将其与SSR打包和一般的清单文件混淆.
从 客户端入口文件 生成一个清单文件,
该清单文件包含"服务端的文件导入路径"到"包中文件的路径"之间的映射关系.
该清单文件包含"服务端的文件导入路径"到"包中文件的路径"之间的映射关系.
主要用于在服务端实现基于HTML link标签的资源预加载,
从而把body部分的资源加载提前到head部分.
从而把body部分的资源加载提前到head部分.
这似乎是一个为Vue定制的功能,
只有Vue的
只有Vue的
renderToString() 会提供"服务端的文件导入路径".该选项与Node.js有关:
- https://nodejs.org/api/cli.html#cli_preserve_symlinks
- https://github.com/nodejs/node/issues/34866
启用preserveSymlinks后, 允许Vite跟随符号链接来确定模块.
这对于Windows这样文件系统很糟糕的操作系统有一定作用,
使其成为一些问题的临时解决方案被采用.
这对于Windows这样文件系统很糟糕的操作系统有一定作用,
使其成为一些问题的临时解决方案被采用.
然而, 该选项在启用后会引入一些意外的副作用, 从而导致难以调试的问题.
例如, 在启用选项后, 虽然Vite HMR有效, 但页面却会重新载入, 这严重破坏了正常的开发流程.
例如, 在启用选项后, 虽然Vite HMR有效, 但页面却会重新载入, 这严重破坏了正常的开发流程.
建议彻底放弃该选项, 转而使用其他的解决方案.
基于Vite的测试框架.
提供与Jest兼容的API, 开箱即用.
提供与Jest兼容的API, 开箱即用.
在使用@testing-library/jest-dom时仍存在障碍:
- https://github.com/testing-library/jest-dom/issues/427
- https://github.com/vitest-dev/vitest/issues/517
配置
poolOptions 下的 singleX 选项可使Vitest像启用了 runInBand 选项的Jest那样运行.早期版本的Vitest默认使用threads作为池, 后续版本改为forks.
兼容多种打包器和框架的打包可视化分析工具.
这是最初级也最常见的混淆方式, 对代码执行性能没有影响.
变量名替换最常见的使用场景是缩小代码体积, 所有现代代码压缩器都具备此功能.
由于混淆的目的与代码压缩不同,
其变量名倾向于使用
其变量名倾向于使用
0oO, 1iIlL 等字形相似的字符来干扰人类阅读.遍历AST获得标识符, 由LLM对标识符进行重命名.
当前实现由于是对标识符逐一重命名, 工作效率很低.
一个潜在的效率改进路线是从AST生成GBNF等语法约束格式,
由约束采样来保证语法结构不变, 然后对整段代码进行重命名.
由约束采样来保证语法结构不变, 然后对整段代码进行重命名.
最强大的免费混淆器(因此也经常被用于恶意软件), 会明显降低代码的执行性能.
JavaScript Obfuscator的收费版本.
提供基于虚拟机的混淆(与其说是混淆不如说是创建了一个新语言), 对代码执行性能的负面影响很严重.
代码量会飞跃式增长, 在官方例子中把16行代码混淆成了1589行代码.
代码量会飞跃式增长, 在官方例子中把16行代码混淆成了1589行代码.
基于硬编码的AST变换规则进行反混淆的反混淆器.
在JsDeObsBench Leaderboard中作为LLM的对照组取得了最佳成绩.
利用LLM和JSIR(JavaScript中间表示)进行反混淆.
有一些将JavaScript编译成V8字节码以取代代码混淆的尝试, 例如bytenode.
这不是一个好方法, 因为V8并未设计成可以只靠字节码运行, 其字节码与.NET, JVM, Python的字节码定位不同:
- 每个版本的V8都可能改变内部字节码格式, 因此只适用于随V8引擎一同分发的Node.js, Electron, NW.js等项目.
- 执行字节码实际上是利用V8缓存机制实现的一种hack, 这导致异步箭头函数等功能无法在编译成字节码后正确处理.
只要V8一日不实现稳定的字节码格式, 这种替代方案就缺乏可靠性和可持续性.
此外, 一旦V8实现稳定的字节码格式, 就可能出现针对字节码格式的反编译器.
此外, 一旦V8实现稳定的字节码格式, 就可能出现针对字节码格式的反编译器.
ESLint, Acorn, Babel使用的语法树规范.
jQuery的解析器, 支持到ES2019.
Babel的解析器, 基于acorn.
支持JSX, Flow, TypeScript.
ESLint的解析器, 基于acorn.
支持TypeScript.
SWC的解析器.
Oxc的解析器, 返回ESTree.
tree-sitter解析器官方的语法支持.