跳到主要内容

Array.from与字符串迭代器

· 阅读需 11 分钟

众所周知 Array.from 可以从伪数组对象(字符串、NodeList...)中生成真正的数组,只要这个伪数组对象满足含有length或者是个可迭代对象,今天来思考一个问题:有字符串 123😂,长度为5,为什么 Array.from('123😂').length 是4?

那么这里面的变换过程发生了什么?要理解发生了什么从查阅文档开始,先来看看MDN怎么说。

MDN

MDN大量的篇幅是在讲 Array.from 本身的用法,在具体实现方面没有做详细阐述,只有在涉及 Polyfill 的时候直接给出了兼容方案的替代方法,而且仔细看该替代方法中的代码,可以发现并没有按照规范去实现:

// Production steps of ECMA-262, Edition 6, 22.1.2.1
if (!Array.from) {
Array.from = (function () {
return function from(arrayLike/*, mapFn, thisArg */) {
// 1. Let C be the this value.
var C = this;

// 2. Let items be ToObject(arrayLike).
var items = Object(arrayLike);

// 获取伪数组的长度
var len = toLength(items.length);

// 创建一个新数组
var A = isCallable(C) ? Object(new C(len)) : new Array(len);

// 遍历伪数组
var k = 0;
var kValue;
while (k < len) {
kValue = items[k];
// 赋值过程略
k += 1;
}
// 18. Let putStatus be Put(A, "length", len, true).
A.length = len;
// 20. Return A.
return A;
};
}());
}

可以看到这个 Polyfill 的核心内容是获取伪数组的长度之后,用 while 循环遍历一次,然后依次赋值给新数组,可以预见新数组的长度必然等于输入的伪数组长度,最后还有一个 A.length = len; 的步骤,也必然跟实际结果不符。不过这一点在文档中也给出了说明:

...此外,鉴于无法使用 Polyfill 实现真正的的迭代器,该实现不支持规范中定义的泛型可迭代元素。

而我们的字符串恰好是可迭代元素。

// 将 Polyfill 方法加到 fromPolyfill 上然后尝试调用它
Array.fromPolyfill('123😂')
// ["1", "2", "3", "\ud83d", "\ude02"]

Array.from('123😂')
// ["1", "2", "3", "😂"]

ECMA规范

既然从MDN找不到答案,那么就从实现的源头去找答案,js的源头自然是 ECMA规范,找到章节 Array.from 的定义:

Array.from 规范

看似密密麻麻一堆,将字符串代入其中,按它给出的步骤走一遍就会清晰很多。

这里直接来到第4步,可以发现第4步是通过 GetMethod(items, @@iterator) 方法获取字符串的 @@iterator 方法即 "Symbol.iterator" 并将其定义为 usingIterator

那么接着走第5步,由于知道字符串是有 "Symbol.iterator" 的,所以 usingIterator is not undefined 成立,那后面的步骤自然就走里面的流程了,接着我们聚焦步骤5,看里面是怎么做的,就不用管后面的6789...了。

聚焦步骤5

大致浏览一遍,可以发现 5.c 通过 GetIterator 拿到字符串的迭代器,然后利用迭代器在步骤 5.e 中遍历字符串,直到在 5.e.iii 中通过IteratorStep 得到 next === false 之后将数组的长度置为 k 并返回数组(k 初始值为0,并在每一次迭代之后 +1)。

那么这里又有问题了,为什么字符串的长度是 123😂 的长度是5,数组长度只返回了4呢,难道是字符串的迭代器只执行了4次吗?稍微花点时间验证一下:

var sIterator = ('123😂')[Symbol.iterator]()

sIterator.next() // {value: "1", done: false}
sIterator.next() // {value: "2", done: false}
sIterator.next() // {value: "3", done: false}
sIterator.next() // {value: "😂", done: false}
sIterator.next() // {value: undefined, done: true}

可以发现确实是这样,所以现在的焦点应该转移到字符串的迭代器的规范上,那么接下去就追根溯源,看看它是怎么做的。

追踪字符串的迭代器规范

那么也一样找到相应的 规范章节,也可以直接看下图:

String.prototype [ @@iterator ] () 规范

浏览一遍整个过程,很容易发现,这里头的奥义还是遍历,定义 position 为0,字符串长度为 len,在执行循环步骤 3.c 中判断 position < len,执行后续操作。

不过这时候重点来了,仔细看步骤 3.c.ii,这里的 nextIndex 并不是简单的 position + 1,而是 position + cp.[[CodeUnitCount]];同时可以发现后面的 3.c.iii 中也并不是简单的获取字符串中的 s[position] 字符,而是通过 substring 方法截取出一个子字符串。

到这里,经过一步步抽丝剥茧,已经越来越接近核心的地方了,上面提到下一次循环的初始位置是 position + cp.[[CodeUnitCount]],那么很显然下面要探索的必然是在步骤 3.c.i 中的 cp 是怎么来的。

探索 CodePointAt 方法

在各个页面反复横跳,感觉快要看到曙光了,照例先找到相关规范定义

CodePointAt 规范

这里是重头戏,一步步来解释下:

  1. 获取字符串 string 长度 size
  2. 判断 position 合法性;
  3. 定义 firststring 中位置为 position 的编码单元;
  4. 定义 cpfirst 的码点;

那么到这里位置都好理解,并没有触及什么盲区,到第5步,发现了两个并不认识的东西,leading surrogate(译作前导代理) 和 trailing surrogate(译作后尾代理),不过文档写的很详细,根据文档很容易得到分别代表:

名词取值范围
leading surrogate0xD800 ~ 0xDBFF(55296 ~ 56319)
trailing surrogate0xDC00 ~ 0xDFFF(56320 ~ 57343)

那么后面的几个判断步骤就非常清晰了:

  1. 如果 first 既不属于 leading surrogate 也不属于 trailing surrogate
  2. 返回 { CodePoint: cp, CodeUnitCount: 1, IsUnpairedSurrogate: false }
  3. 如果 first 属于 trailing surrogate 或者当前位置 position 是末尾:
  4. 返回 { CodePoint: cp, CodeUnitCount: 1, IsUnpairedSurrogate: true }
  5. 定义 secondstring 中位置为 position + 1 的编码单元;
  6. 如果 second 不属于 trailing surrogate
  7. 返回 { CodePoint: cp, CodeUnitCount: 1, IsUnpairedSurrogate: true }
  8. 调用 UTF16SurrogatePairToCodePoint 函数 赋值给 cp
  9. 返回 { CodePoint: cp, CodeUnitCount: 2, IsUnpairedSurrogate: false }
('123😂').charCodeAt(3) // 55357
('123😂').charCodeAt(4) // 56834

很显然对于 position = 3 的情况,firstleading surrogate 同时 secondtrailing surrogate,所以会一直走到步骤9和10,

根据步骤9的计算方法,得到新的 cp

/**
* 1. Assert: lead is a leading surrogate and trail is a trailing surrogate.
* 2. Let cp be (lead - 0xD800) × 0x400 + (trail - 0xDC00) + 0x10000.
* 3. Return the code point cp.
**/
var cp = (55357 - 55296) * 1024 + (56834 - 56320) + 65536; // 128514 / 0x1F602

// 其实这一部分就是 codePointAt 的底层方法
var cp2 = ('123😂').codePointAt(3) // 128514

0x1F602unicode 中刚好是表情符号 😂 的码点。

总结

到这里,整个问题算是解决了,对于内置迭代器对象,Array.from 会利用对象本身的迭代器,根据内置迭代器执行结果生成数组;而对于不包含迭代器的其他伪数组对象,则必须得有长度值,通过遍历拿到他的每一项值构成新数组。而字符串迭代之后长度会缩短,是因为字符串的迭代器对读取每个字符的过程做了特殊处理,并不每次只读取单个字符,当第n个字符为 leading surrogate、n+1个字符为 trailing surrogate 时,这两个字符会“合并输出”,造成了长度的变化。

不过这里也带来了一个新的困惑,这里面的 leading surrogatetrailing surrogate 界定有什么讲究呢?其中有个术语叫 surrogate pair,译作 代理对

一开始尝试将 😂 转换为 UTF-8 发现结果对不上,后面在ECMA的相关文档描述中可以发现了 UTF-16 字样,怀疑可能使用这种方式存储了 Unicode 字符:

// 尝试将 Unicode 字符 😂 用 UTF-16 方式存储
// 根据 UTF-16 的存储规则,码点超过 0x10000 的字符,需要用 4 字节存储,分为 2 字节前导代理,2 字节后尾代理,计算规则如下
const val = (0x1F602 - 0x10000).toString(2);
// 0000 1111 0110 0000 0010

// 将 val 前面补 0 成 20 位,取前后 10 位分别计算
const leadSurrogates = 0xD800 + 0b0000111101 // 55357
const trailingSurrogate = 0xDC00 + 0b1000000010 // 56834

计算结果符合前面通过 charCodeAt 获得的数值,也就能推导出在 JS 中字符串的存储方式确实是 UTF-16