最近跟同事谈到浏览器的排序操作,说以前因为排序导致信息展示出过一些事故,虽然影响不大,但是感觉还挺有必要了解一下标准和浏览器的实现。于是打开 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 的隐式类型转换坑过不少次(EslintTypeScript 解您忧)。但是其实隐式类型转换是很有用且很常用的,比如下面一个例子:

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)

这个表还是很有意思的,比如参数为 NullBoolean(true值) 时返回的是 +0;参数为 SymbolBigInt 时抛出错误,而在浏览器和 Node 中直接调用 Number(10n)) 时并不会抛出错误,说明这个操作并是代表直接调用 Number 产生一个包装类型的实现(Number Constructor 定义在标准 20.1.1 部分);Object 类型则是单独先调用 ToPrimitive 方法,然后再做一次 ToNumber 转换。

参数为 String 类型时,这个操作非常复杂,以至于单独列出了一个小章节讲述,有兴趣的可以参考 7.1.4.1 ToNumber Applied to the String Type

除了 ToNumber,还有一个 ToNumericToInteger,看起来也是跟数值类型相关的转换,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-0NaN,返回 false,否则返回 true
String 长度为0,返回 false,否则返回 true
Symbol true
BigInt 参数为 0n,返回 false,否则返回 true
Object true

跟我们习惯中的到布尔值的转换基本一致。

ToObject

这个操作可以按照参数分为三种类型:

  • 参数为 UndefinedNull,抛出 TypeError
  • 参数为 Object,不做任何操作,原路返回参数
  • 参数为 BooleanNumberStringSymbolBigInt,返回一个包装类型,这个包装类型对应的 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],想必也是中了这招。

ToLengthToIndex

数组对象的长度和索引值大家都很熟悉,这两个都是不能小于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,发现只有 ToInt32ToUint32 的实现,Uint8Clamp 只有一个 NumberToUint8Clamp 的方法,但是是在编译器代码里,已经超出了我的理解范围,具体代码在 v8 仓库的 src/compiler/operation-typer.cc,有兴趣的可以了解,或者指点我一下。

未来可期型

前端的发展日新月异,JavaScript 中的 Number 类型只能表示 Number.MAX_SAFE_INTEGER(2^53 - 1) 以内的数字,已经无法满足未来的需求了,所以 JS 中添加了新的基本类型 BigInt,专门用来表示超出这个范围的数值。这部分的操作也就是关于其它类型到 BigInt 类型的转换。

ToBigInt

这个操作遵循以下步骤:

  1. 通过 ToPrimitive(argument, hint Number) 将参数转换为基本类型
  2. 得到 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,有一些小的变化,不过基本步骤一致。

ToBigInt64ToBigUint64

看类型名大概可以看出来,这两个转换的操作分别将参数转换为 -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

参考