最近跟同事谈到浏览器的排序操作,说以前因为排序导致信息展示出过一些事故,虽然影响不大,但是感觉还挺有必要了解一下标准和浏览器的实现。于是打开 https://ecma-international.org 去翻了一下标准,发现从 ES10 开始就已经要求浏览器实现的排序必须是稳定排序了,又去 v8 搜了一下,发现这个修改在 v8 博客上也单独用了一片文章去讲,还提到是 v8 团队对标准提的修改建议(看来大家都深受不稳定排序的荼毒)。
看完排序的修改又在标准里瞎逛,突然眼前一亮发现一个叫 Abstract Operations 的东西,这又是个什么玩意儿?打开子目录一看:
Type ConversionTesting and Comparison OperationsOperations on ObjectsOperations on Iterator Objects
有两个熟悉的名词,类型转换和迭代器对象,感觉有点意思。所以本着学习的态度,打算翻一翻这部分,看看到底是个什么东西,于是就有了这个系列(吧,不定期更新,主要看我什么时候能想起来)。
艺名
标准的前言中是这样描述这部分的:
They(Abstract Operations) are defined here to solely to aid the specification of the semantics of the ECMAScript language
这部分标准的第一部分,Type Conversion,我想大家都应该不陌生,不管爱它还是恨它,你都离不开它。关于 Type Conversion,又是这么讲的:
To clarify the semantics of certain constructs it is useful to define a set of conversion abstract operations. The conversion abstract operations are polymorphic; they can accept a value of any ECMAScript language type.
总的来说,就是它不是开发者接触到的语言的一部分,但是它规定了语言的实现者在遇到一些的情况时该如何处理,以增强 ECMAScript 语言的语义。这些处理对开发者是完全不可见的,你只能默默接受它给出的结果,就像是你没有进行任何努力,却喜得一子一样。所以关于这部分,我决定给它一个艺名:不可名状的操作。
Type Conversion
类型转换,很好理解,毕竟大家肯定都被 JavaScript 的隐式类型转换坑过不少次(Eslint、TypeScript 解您忧)。但是其实隐式类型转换是很有用且很常用的,比如下面一个例子:
var obj = {}
if (obj) { /* do something */ }
其实这是一个很典型的从对象到布尔值的类型转换,只是大家用得太多,可能根本没有注意到这个情况。
这部分标准里面规定了 22 个 abstract operations,简单分一下类大概是:
开发者常用型
ToNumberToNumericToIntegerToStringToBooleanToObject
似曾相识型
ToPrimitiveToPropertyKeyToLengthToIndexCanonicalNumericIndexString
陌路人型
ToInt32ToUint32ToInt16...ToUint8Clamp
未来可期型
ToBigIntToBigInt64ToBigUint64StringToBigInt
开发者常用型
大家日常开发中很常见的一些操作,但可能跟我们实际用到的还有一些差别。
ToNumber
ToNumber 有一张表,列举了不同类型的参数到 Number 类型的转换:
| 参数类型 | 结果 |
|---|---|
Undefined |
NaN |
Null |
+0 |
Boolean |
argument is true, return 1. argument is false, return +0. |
Number |
argument(no conversion). |
String |
见下 |
Symbol |
TypeError |
BigInt |
TypeError |
Object |
let primValue = ?ToPrimitive(argument, hint Number). Return ?ToNumber(primValue) |
这个表还是很有意思的,比如参数为 Null 和 Boolean(true值) 时返回的是 +0;参数为 Symbol 和 BigInt 时抛出错误,而在浏览器和 Node 中直接调用 Number(10n)) 时并不会抛出错误,说明这个操作并是代表直接调用 Number 产生一个包装类型的实现(Number Constructor 定义在标准 20.1.1 部分);Object 类型则是单独先调用 ToPrimitive 方法,然后再做一次 ToNumber 转换。
参数为 String 类型时,这个操作非常复杂,以至于单独列出了一个小章节讲述,有兴趣的可以参考 7.1.4.1 ToNumber Applied to the String Type。
除了 ToNumber,还有一个 ToNumeric 和 ToInteger,看起来也是跟数值类型相关的转换,ToNumber 的伪代码是:
let primValue = ?ToPrimValue(value, hint Number)
if Type(primValue) === BigInt {
return primValue
}
return ?ToNumber(primValue)
如果参数不是 BigInt 类型最终还是调用 ToNumber。
ToInteger 也是类似,先调用 ToNumber,然后再判断值,针对几种特殊的值做特殊处理。
ToString
这个方法将参数转换为一个字符串,遵循的规则是:
| 参数类型 | 结果 |
|---|---|
Undefined |
"undefined" |
Null |
"null" |
Boolean |
true -> "true", false -> "false" |
Number |
!Number::toString(argument) |
String |
argument |
Symbol |
TypeError |
BigInt |
!BigInt::toString(argument) |
Object |
let primValue = ?ToPrimitive(argument, hint String). Return ?ToString(primValue) |
其中最后一步和 ToNumber 很类似,只是替换了 hint 和最后将 primValue 转换为其它类型的方法。
ToBoolean
ToBoolean 应该是这部分当中最简单易懂的部分了,也有一张表:
| 参数类型 | 结果 |
|---|---|
Undefined/Null |
false |
Boolean |
argument |
Number |
参数为 +0、-0、NaN,返回 false,否则返回 true |
String |
长度为0,返回 false,否则返回 true |
Symbol |
true |
BigInt |
参数为 0n,返回 false,否则返回 true |
Object |
true |
跟我们习惯中的到布尔值的转换基本一致。
ToObject
这个操作可以按照参数分为三种类型:
- 参数为
Undefined或Null,抛出TypeError - 参数为
Object,不做任何操作,原路返回参数 - 参数为
Boolean、Number、String、Symbol、BigInt,返回一个包装类型,这个包装类型对应的internal slot就是参数。 本来以为这个操作可能会比较复杂,原来竟然这么简约(而不简单)。
似曾相识型
我们在学习 JavaScript 的过程中,肯定都见到过一些特殊的表达,比如 RangeError、[Object Object] 等,其实这部分就跟这几个操作有关。
ToPrimitive
这个操作会将 input 参数转换为一个非对象类型,从签名 ToPrimitive(input [, PreferredType]) 来看,这个操作还接收了一个 PreferredType 作为指引。具体转换的伪代码是:
Assert(input, ECMAScript language value)
if input is object {
if preferredType not present {
hint = "default"
} else if preferredType is hint String {
hont = "string"
} else {
Assert(preferredType, hint Number)
hint = "number"
}
exoticToPrim = GetMethod(input, @@toPrimitive)
if exoticToPrim not undefined {
result = Call(exoticToPrim, input, hint)
if result not object {
return result
}
throw TypeError
}
if hint === "default" {
hint = "number"
}
return OridinaryToPrimitive(input, hint)
}
return input
这部分的关键在于调用 toPrimitive 方法,其中 exoticToPrim = GetMethod(input, @@toPrimitive) 这一段猜的话大概也可以猜出来,其实就是在对象的原型链上查找 Symbol.toPrimitive 方法,像下面这样:
someClass.prototype[Symbol.toPromitive] = function () {}
其实就可以复写这个方法,从而改变对象 toPrimitive 的结果。标准中也有提到,在所有标准定义的对象中,只有 Date 对象和 Symbol 复写了这个方法,而 Date 优先采用 string 作为 preferredType。
ToPropertyKey
这个操作很容易看懂:将参数转换为属性key。操作更简单:调用 toPrimitive,然后判断是否为 Symbol,为 Symbol 直接返回;不为 Symbol,再继续调用一次 ToString。这个操作我个人猜测也是 [Object Object] 这个不经意间就会出现的问题的根源。笔者曾经在石墨文档的 HTML 代码里面发现了一个元素的类名中存在 [Object Object],想必也是中了这招。
ToLength、ToIndex
数组对象的长度和索引值大家都很熟悉,这两个都是不能小于0或者为小数的,这部分比较好理解。所以就有了这两个操作:
ToLength将参数转换为一个 0 到 2^53 - 1 之间的整数ToIndex首先使用ToInteger将参数转换为整数 A,如果转换后小于 0,抛出RangeError,之后再使用ToLength,得到一个新值 B,再将 A 和 B 进行对比,如果不为同一值,则再抛出RangeError,否则返回值 B
CanonicalNumericIndexString
这个操作的解释是:
The abstract operation CanonicalNumericIndexString returns argument converted to a Number value if it is a String representation of a Number that would be produced by ToString, or the string "-0". Otherwise, it returns undefined.
尽我力所能及的英语知识,翻译过来就是:
如果参数是一个
Number通过调用ToString方法产生的字符串或者 "-0",就将其转换为一个数值并返回,否则返回undefined。
其实调用步骤还是挺简单的:
Assert(Type(argument), String)
if argument == "-0" {
return -0
}
let n = ToNumber(argument)
// 这一步为关键,要将得到的数值类型转换为字符串再与参数比较
// 确定这个字符串是一个 `Number` 通过调用 `ToString` 方法产生的字符串
if !SameValue(ToString(n), argument) {
return undefined
}
return n
陌路人型
在 JavaScript 中,数值类型只有 Number 一种(抛开 BigInt 不谈),可 Int,可 Double,可 Float,我们不用关心具体的细节,只需要使用即可,所以这部分不做详细的描述了。
关于这部分的操作我查了一下 v8,发现只有 ToInt32 和 ToUint32 的实现,Uint8Clamp 只有一个 NumberToUint8Clamp 的方法,但是是在编译器代码里,已经超出了我的理解范围,具体代码在 v8 仓库的 src/compiler/operation-typer.cc,有兴趣的可以了解,或者指点我一下。
未来可期型
前端的发展日新月异,JavaScript 中的 Number 类型只能表示 Number.MAX_SAFE_INTEGER(2^53 - 1) 以内的数字,已经无法满足未来的需求了,所以 JS 中添加了新的基本类型 BigInt,专门用来表示超出这个范围的数值。这部分的操作也就是关于其它类型到 BigInt 类型的转换。
ToBigInt
这个操作遵循以下步骤:
- 通过
ToPrimitive(argument, hint Number)将参数转换为基本类型 - 得到
primValue,然后根据下表返回值
| 参数类型 | 结果 |
|---|---|
Undefined / Null |
TypeError |
Boolean |
true -> 1n, false -> 0n |
BigInt |
primValue |
Number |
TypeError |
String |
StringToBigInt(primValue) -\> n, n 为 NaN,返回 SyntaxError,否则返回 n |
Symbol |
TypeError |
StringToBigInt
这部分要参考 ToNumber 章节的 7.1.4.1 ToNumber Applied to the String Type,有一些小的变化,不过基本步骤一致。
ToBigInt64、ToBigUint64
看类型名大概可以看出来,这两个转换的操作分别将参数转换为 -2^63 到 2^63 - 1 和 0 到 2^64 - 1 之间的数值,大致的操作也基本一致,Int 和 Uint 之间的区别只在于最后一步:
// ToBigInt64
ToBigInt(argument)
int64bit = n modulo 2^64
If int64bit ≥ 263, return int64bit - 264; otherwise return int64bit.
// ToBigUint64
ToBigInt(argument)
int64bit = n modulo 2^64
Return int64bit.
关于 BigInt
在 ECMAScript 中,BigInt 没有任何隐式的类型转换,开发者必须显式调用 BigInt 才可以将其它类型转换为 BigInt。
结语
这些就是标准里规定的 conversion abstract operations。关于标准本身,其实只是浏览器实现的一个参考,不管标准有没有定义某一行为,其实浏览器都可以选择要不要实现以及如何实现。不过标准的统一化却是能给行业的发展带来很多好处,很难想象如果脱离了标准和浏览器对于新技术的实现和推进,现在的前端开发会是什么样。最后希望各大浏览器都能紧跟时代的步伐,快速跟进标准,让天下前端没有难调的兼容性bug。