是先有鸡,还是先有蛋?
['🐔', '🥚'].sort()
💻:["🐔", "🥚"]
初步是有结果了,但是好像不太严谨。需要加一个参照元素,如果以进化论为背景,单细胞生物一定先于鸡和蛋。
const originalOrder = ['🥚', '🦠', '🐔'].sort()
得到原始数据后要进行修正,确保遵循先出现单细胞生物的基准。
const isCell = x => x === '🦠'
const cellPos = originalOrder.findIndex(isCell)
const revised = {
0: () => originalOrder,
1: () => '结果无效',
2: () => originalOrder.reverse()
}
看看加入规则后的结果。
const showResult = revised[cellPos]
showResult()
💻:["🦠", "🥚", "🐔"]
尽管这个问题在生物学上存在分歧,争论点也大都是对于鸡和蛋的定义上。但这个结果应该算是让人满意,至少在这次尝试中得到了相反的两个答案。
为什么会产生这样的结果。为了查明其中原委,还得回到最初的问题,用sort来判定先后是很自然的想法,那究竟发生了什么,查看一下标准中对其的描述:
# ecma262 23.1.3.27.1 SortCompare
# 当 "comparefn" 未定义时
0 Let xString be ToString(x).
1 Let yString be ToString(y).
2 Let xSmaller be IsLessThan(xString, yString, true).
3 If xSmaller is true, return -1.
4 Let ySmaller be IsLessThan(yString, xString, true).
5 If ySmaller is true, return 1.
6 Return +0.
简化一下步骤:
# 返回值为负排到左边,为正排到右边
0 x = ToString(x), y = ToString(y)
a. x < y return -1
b. x > y return 1
c. x = y return 0
因此在sort之下实则是数组内元素之间的比较。这好理解,对一组数据进行排序需要遵循一定的准则,那如何把准则以及源数据转化成想要的结果,自然是在数据间进行比较。
至此,已经有了一些眉目,显然下一步是探索如何进行比较。再回到标准,这次的落脚点在其提到的IsLessThan这个操作:
If the LeftFirst flag is true, then
a. Let px be ToPrimitive(x, number).
b. Let py be ToPrimitive(y, number).
If Type(px) is String and Type(py) is String, then
a. If IsStringPrefix(py, px) is true, return false.
b. If IsStringPrefix(px, py) is true, return true.
c. Let k be the smallest non-negative integer such that the code unit at index k within px is different from the code unit at index k within py. (There must be such a k, for neither String is a prefix of the other.)
d. Let m be the integer that is the numeric value of the code unit at index k within px.
e. Let n be the integer that is the numeric value of the code unit at index k within py.
f. If m < n, return true. Otherwise, return false.
乍看有些冗长,稍作分析来简化一下。
首先,IsLessThan接收三个参数,分别是两个比较值和一个LeftFirst参数,这个值是用作语义化的一个flag,代表从左至右即:参数1 is less than 参数2。接下来的ToPrimitive很直观,即转化为基本类型。所以第一个判断可以简化成:
0 px = ToPrimitive(x), py = ToPrimitive(y)
来到第二个判断,这里做了划分,IsStringPrefix对判断效率作了增强,如果一个字符串是另一个字符串的前缀则判定为小于,可以省略这个步骤。
之后的内容是重点,需要仔细分析一下。伪代码中有提到code unit,查一下维基百科的定义:
A code unit is a bit sequence used to encode each character of a repertoire within a given encoding form.
也就是说它通过特定编码系统来对每个字符进行编码,继续深入,在javascript或相关引擎中使用UCS-2或者UTF-16(主要)编码系统。到这儿开始变得有趣,来展开探索一下。
为了先理清一些概念,在维基百科中找到了一些线索,看能不能串联起一个完整的逻辑:
A code point of a coded character set is any allowed value in the character set or code space.
The convention to refer to a character in Unicode is to start with 'U+' followed by the codepoint value in hexadecimal.
UTF-16: ... Therefore, any code point with a scalar value less than U+10000 is encoded with a single code unit. Code points with a value U+10000 or higher require two code units each. These pairs of code units have a unique term in UTF-16: "Unicode surrogate pairs".
总结一下,UTF-16中的一个code unit(代码单元)由16个比特组成,也就是4个16进制位,一个code unit可以用来给Unicode中U+0000到U+FFFF(BMP)之间的任意字符进行编码,字符对应的值被称为code point(码点)。从U+10000到U+10FFFF之间的字符需要两个code units的code point来表示,称作Unicode surrogate pairs,也是补充字符。
有了一定的了解,拿使用频率最高(大约10%)的emoji😂来做一些验证,先尝试参照概念猜测一下,emoji属于补充字符,所以它由两个单位的code unit来存储code point,也是由BMP中的某些字符组合而成,有了猜想接下来就是找到合适的工具,javascript的字符串提供了一系列和Unicode相关的方法,从中选几个来相互验证:
- 验证
code point value大于等于0x10000
String.prototype.codePointAt()获取code point value(包括补充字符)
const MIN_SUPPLEMENTARY_CHARACTER_VALUE = parseInt(10000, 16)
const FACE_WITH_TEARS_OF_JOY = '😂'
const codePointValue = FACE_WITH_TEARS_OF_JOY.codePointAt()
codePointValue >= MIN_SUPPLEMENTARY_CHARACTER_VALUE // true 验证成功
- 验证
Unicode surrogate pairs
-
String.prototype.charCodeAt()获取一个code unit所储存的code point value -
String.fromCodePoint()把一串code point values转义成字符
const length = FACE_WITH_TEARS_OF_JOY.length
const pairs = Array.from({ length })
for (let idx in pairs) pairs[idx] = FACE_WITH_TEARS_OF_JOY.charCodeAt(idx)
String.fromCharCode(...pairs) === FACE_WITH_TEARS_OF_JOY // true 验证成功
猜想已经得到了验证,继续回到对IsLessThan的简化工作,经过对code unit的探究,简化工作就轻松了许多,按照标准的描述存在一个最小的k索引使得两个比较值在该code unit所储存的值不同,实则就是比较两个值的code point value,继续进行简化得到:
1 m = px.codePointAt(k), n = py.codePointAt(k)
a. m < n return true
b. m > n return false
很好,深入后似乎真相已经渐渐浮出水面。看回最初的推导式,尝试把它的内部运转逻辑梳理一下:
- 出发点是对
🐔和🥚两个字符进行sort分出先后; - 因为
sort没有入参(comparefn),所以进到默认逻辑比较🐔和🥚; - 又因为
🐔和🥚不存在前缀关系,所以可以找到一个最小的索引值k使得🐔和🥚在该索引下的码点值不相同得以进行比较; 🐔和🥚都是单字符,因此最小索引k为0,故而得到了两个字符的码点值;- 比较码点值得到结果,因为
🥚的码点值较大,所以排到了🐔的右边。
0. x = ToPrimitive(ToString('🐔')), y = ToPrimitive(ToString('🥚'))
1. m = x.codePointAt(0), n = y.codePointAt(0)
a. m < n return -1 👈
b. m > n return 1
c. m = n return 0
最终通过一系列调查得到了结果产生的原因。
番外
-
🐔的提案被Unicode组织在2010年通过,🥚在2016年通过。 -
值得注意的是emoji中也存在拼接的意象,例如
Black Cat 🐈⬛就是由Cat、ZWJ(零宽连接符)和Black拼接而成。 -
Unicode中的每个plane指代了连续的16*16*16*16个码点,文中的BMP是plane 0,共有17个plane,plane 0之外的也叫做astral planes,其中有许多码点都被保留以供私用。 -
文中提到的
Unicode surrogate pairs其实也有一定的规则,它由一个high surrogate(U+D800到U+DBFF)和一个low surrogate(U+DC00到U+DFFF)组成。例如😂的码点为U+1F602,它的代理对为高代理U+D83D和低代理U+DE02。
<!-- html -->
<span>😂</span> <!-- 10进制 -->
<span>😂</span> <!-- 16进制 -->
/* css */
span::before {
content: '\01f602'
}
// javascript
console.log('\ud83d\ude02') // ⬅ plane 0
console.log('\u{1f602}') // ⬅ es6 in the astral planes