最近跟同事谈到浏览器的排序操作,说以前因为排序导致信息展示出过一些事故,虽然影响不大,但是感觉还挺有必要了解一下标准和浏览器的实现。于是打开 https://ecma-international.org 去翻了一下标准,发现从 ES10
开始就已经要求浏览器实现的排序必须是稳定排序了,又去 v8 搜了一下,发现这个修改在 v8 博客上也单独用了一片文章去讲,还提到是 v8 团队对标准提的修改建议(看来大家都深受不稳定排序的荼毒)。
看完排序的修改又在标准里瞎逛,突然眼前一亮发现一个叫 Abstract Operations
的东西,这又是个什么玩意儿?打开子目录一看:
Type Conversion
Testing and Comparison Operations
Operations on Objects
Operations 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
,简单分一下类大概是:
开发者常用型
ToNumber
ToNumeric
ToInteger
ToString
ToBoolean
ToObject
似曾相识型
ToPrimitive
ToPropertyKey
ToLength
ToIndex
CanonicalNumericIndexString
陌路人型
ToInt32
ToUint32
ToInt16
...
ToUint8Clamp
未来可期型
ToBigInt
ToBigInt64
ToBigUint64
StringToBigInt
开发者常用型
大家日常开发中很常见的一些操作,但可能跟我们实际用到的还有一些差别。
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
。