特别说明,为便于查阅,文章转自https://github.com/getify/You-Dont-Know-JS
粘性标志
另一个加入ES6正则表达式的模式标志是y
,它经常被称为“粘性模式(sticky mode)”。粘性 实质上意味着正则表达式在它开始时有一个虚拟的锚点,这个锚点使正则表达式仅以自己的lastIndex
属性所指示的位置为起点进行匹配。
为了展示一下,让我们考虑两个正则表达式,第一个没有使用粘性模式而第二个有:
var re1 = /foo/,
str = "++foo++";
re1.lastIndex; // 0
re1.test( str ); // true
re1.lastIndex; // 0 —— 没有更新
re1.lastIndex = 4;
re1.test( str ); // true —— `lastIndex`被忽略了
re1.lastIndex; // 4 —— 没有更新
关于这个代码段可以观察到三件事:
-
test(..)
根本不在意lastIndex
的值,而总是从输入字符串的开始实施它的匹配。 - 因为我们的模式没有输入的起始锚点
^
,所以对"foo"
的搜索可以在整个字符串上自由向前移动。 -
lastIndex
没有被test(..)
更新。
现在,让我们试一下粘性模式的正则表达式:
var re2 = /foo/y, // <-- 注意粘性标志`y`
str = "++foo++";
re2.lastIndex; // 0
re2.test( str ); // false —— 在`0`没有找到“foo”
re2.lastIndex; // 0
re2.lastIndex = 2;
re2.test( str ); // true
re2.lastIndex; // 5 —— 在前一次匹配后更新了
re2.test( str ); // false
re2.lastIndex; // 0 —— 在前一次匹配失败后重置
于是关于粘性模式我们可以观察到一些新的事实:
-
test(..)
在str
中使用lastIndex
作为唯一精确的位置来进行匹配。在寻找匹配时不会发生向前的移动 —— 匹配要么出现在lastIndex
的位置,要么就不存在。 - 如果发生了一个匹配,
test(..)
就更新lastIndex
使它指向紧随匹配之后的那个字符。如果匹配失败,test(..)
就将lastIndex
重置为0
。
没有使用^
固定在输入起点的普通非粘性范例可以自由地在字符串中向前移动来搜索匹配。但是粘性模式制约这个范例仅在lastIndex
的位置进行匹配。
正如我在这一节开始时提到过的,另一种考虑的方式是,y
暗示着一个虚拟的锚点,它位于正好相对于(也就是制约着匹配的起始位置)lastIndex
位置的范例的开头。
警告: 在关于这个话题的以前的文献中,这种行为曾经被声称为y
像是在范例中暗示着一个^
(输入的起始)锚点。这是不准确的。我们将在稍后的“锚定粘性”中讲解更多细节。
粘性定位
对反复匹配使用y
可能看起来是一种奇怪的限制,因为匹配没有向前移动的能力,你不得不手动保证lastIndex
恰好位于正确的位置上。
这是一种可能的场景:如果你知道你关心的匹配总是会出现在一个数字(例如,0
,10
,20
,等等)倍数的位置。那么你就可以只构建一个受限的范例来匹配你关心的东西,然后在每次匹配那些固定位置之前手动设置lastIndex
。
考虑如下代码:
var re = /f../y,
str = "foo far fad";
str.match( re ); // ["foo"]
re.lastIndex = 10;
str.match( re ); // ["far"]
re.lastIndex = 20;
str.match( re ); // ["fad"]
然而,如果你正在解析一个没有像这样被格式化为固定位置的字符串,在每次匹配之前搞清楚为lastIndex
设置什么东西的做法可能会难以维系。
这里有一个微妙之处要考虑。y
要求lastIndex
位于发生匹配的准确位置。但它不严格要求 你 来手动设置lastIndex
。
取而代之的是,你可以用这样的方式构建你的正则表达式:它们在每次主匹配中都捕获你所关心的东西的前后所有内容,直到你想要进行下一次匹配的东西为止。
因为lastIndex
将被设置为一个匹配末尾之后的下一个字符,所以如果你已经匹配了到那个位置的所有东西,lastIndex
将总是位于下次y
范例开始的正确位置。
警告: 如果你不能像这样足够范例化地预知输入字符串的结构,这种技术可能不合适,而且你可能不应使用y
。
拥有结构化的字符串输入,可能是y
能够在一个字符串上由始至终地进行反复匹配的最实际场景。考虑如下代码:
var re = /\d+\.\s(.*?)(?:\s|$)/y
str = "1. foo 2. bar 3. baz";
str.match( re ); // [ "1. foo ", "foo" ]
re.lastIndex; // 7 —— 正确位置!
str.match( re ); // [ "2. bar ", "bar" ]
re.lastIndex; // 14 —— 正确位置!
str.match( re ); // ["3. baz", "baz"]
这能够工作是因为我事先知道输入字符串的结构:总是有一个像"1. "
这样的数字的前缀出现在期望的匹配("foo"
,等等)之前,而且它后面要么是一个空格,要么就是字符串的末尾($
锚点)。所以我构建的正则表达式在每次主匹配中捕获了所有这一切,然后我使用一个匹配分组( )
使我真正关心的东西被方便地分离出来。
在第一次匹配("1. foo "
)之后,lastIndex
是7
,它已经是开始下一次匹配"2. bar "
所需的位置了,如此类推。
如果你要使用粘性模式y
进行反复匹配,那么你就可能想要像我们刚刚展示的那样寻找一个机会自动地定位lastIndex
。
粘性对比全局
一些读者可能意识到,你可以使用全局匹配标志位g
和exec(..)
方法来模拟某些像lastIndex
相对匹配的东西,就像这样:
var re = /o+./g, // <-- 看,`g`!
str = "foot book more";
re.exec( str ); // ["oot"]
re.lastIndex; // 4
re.exec( str ); // ["ook"]
re.lastIndex; // 9
re.exec( str ); // ["or"]
re.lastIndex; // 13
re.exec( str ); // null —— 没有更多的匹配了!
re.lastIndex; // 0 —— 现在重新开始!
虽然使用exec(..)
的g
范例确实从lastIndex
的当前值开始它们的匹配,而且也在每次匹配(或失败)之后更新lastIndex
,但这与y
的行为不是相同的东西。
注意前面代码段中被第二个exec(..)
调用匹配并找到的"ook"
,被定位在位置6
,即便在这个时候lastIndex
是4
(前一次匹配的末尾)。为什么?因为正如我们前面讲过的,非粘性匹配可以在它们的匹配过程中自由地向前移动。一个粘性模式表达式在这里将会失败,因为它不允许向前移动。
除了也许不被期望的向前移动的匹配行为以外,使用g
代替y
的另一个缺点是,g
改变了一些匹配方法的行为,比如str.match(re)
。
考虑如下代码:
var re = /o+./g, // <-- 看,`g`!
str = "foot book more";
str.match( re ); // ["oot","ook","or"]
看到所有的匹配是如何一次性地被返回的吗?有时这没问题,但有时这不是你想要的。
与test(..)
和match(..)
这样的工具一起使用,粘性标志位y
将给你一次一个的推进式的匹配。只要保证每次匹配时lastIndex
总是在正确的位置上就行!
锚定粘性
正如我们早先被警告过的,将粘性模式认为是暗含着一个以^
开头的范例是不准确的。在正则表达式中锚点^
拥有独特的含义,它 没有 被粘性模式改变。^
总是 一个指向输入起点的锚点,而且 不 以任何方式相对于lastIndex
。
在这个问题上,除了糟糕/不准确的文档,一个在Firefox中进行的老旧的前ES6粘性模式实验不幸地加深了这种困惑,它确实 曾经 使^
相对于lastIndex
,所以这种行为曾经存在了许多年。
ES6选择不这么做。^
在一个范例中绝对且唯一地意味着输入的起点。
这样的后果是,一个像/^foo/y
这样的范例将总是仅在一个字符串的开头找到"foo"
匹配,如果它被允许在那里匹配的话。如果lastIndex
不是0
,匹配就会失败。考虑如下代码:
var re = /^foo/y,
str = "foo";
re.test( str ); // true
re.test( str ); // false
re.lastIndex; // 0 —— 失败之后被重置
re.lastIndex = 1;
re.test( str ); // false —— 由于定位而失败
re.lastIndex; // 0 —— 失败之后被重置
底线:y
加^
加lastIndex > 0
是一种不兼容的组合,它将总是导致失败的匹配。
注意: 虽然y
不会以任何方式改变^
的含义,但是多行模式m
会,这样^
就意味着输入的起点 或者 一个换行之后的文本的起点。所以,如果你在一个范例中组合使用y
和m
,你会在一个字符串中发现多个开始于^
的匹配。但是要记住:因为它的粘性y
,将不得不在后续的每次匹配时确保lastIndex
被置于正确的换行的位置(可能是通过匹配到行的末尾),否者后续的匹配将不会执行。
正则表达式flags
在ES6之前,如果你想要检查一个正则表达式来看看它被施用了什么标志位,你需要将它们 —— 讽刺的是,可能是使用另一个正则表达式 —— 从source
属性的内容中解析出来,就像这样:
var re = /foo/ig;
re.toString(); // "/foo/ig"
var flags = re.toString().match( /\/([gim]*)$/ )[1];
flags; // "ig"
在ES6中,你现在可以直接得到这些值,使用新的flags
属性:
var re = /foo/ig;
re.flags; // "gi"
虽然是个细小的地方,但是ES6规范要求表达式的标志位以"gimuy"
的顺序罗列,无论原本的范例中是以什么顺序指定的。这就是出现/ig
和"gi"
的区别的原因。
是的,标志位被指定和罗列的顺序无所谓。
ES6的另一个调整是,如果你向构造器RegExp(..)
传递一个既存的正则表达式,它现在是flags
敏感的:
var re1 = /foo*/y;
re1.source; // "foo*"
re1.flags; // "y"
var re2 = new RegExp( re1 );
re2.source; // "foo*"
re2.flags; // "y"
var re3 = new RegExp( re1, "ig" );
re3.source; // "foo*"
re3.flags; // "gi"
在ES6之前,构造re3
将抛出一个错误,但是在ES6中你可以在复制时覆盖标志位。
数字字面量扩展
在ES5之前,数字字面量看起来就像下面的东西 —— 八进制形式没有被官方指定,唯一被允许的是各种浏览器已经实质上达成一致的一种扩展:
var dec = 42,
oct = 052,
hex = 0x2a;
注意: 虽然你用不同的进制来指定一个数字,但是数字的数学值才是被存储的东西,而且默认的输出解释方式总是10进制的。前面代码段中的三个变量都在它们当中存储了值42
。
为了进一步说明052
是一种非标准形式扩展,考虑如下代码:
Number( "42" ); // 42
Number( "052" ); // 52
Number( "0x2a" ); // 42
ES5继续允许这种浏览器扩展的八进制形式(包括这样的不一致性),除了在strict模式下,八进制字面量(052
)是不允许的。做出这种限制的主要原因是,许多开发者似乎习惯于下意识地为了将代码对齐而在十进制的数字前面前缀0
,然后遭遇他们完全改变了数字的值的意外!
ES6延续了除十进制数字之外的数字字面量可以被表示的遗留的改变/种类。现在有了一种官方的八进制形式,一种改进了的十六进制形式,和一种全新的二进制形式。由于Web兼容性的原因,在非strict模式下老式的八进制形式052
将继续是合法的,但其实应当永远不再被使用了。
这些是新的ES6数字字面形式:
var dec = 42,
oct = 0o52, // or `0O52` :(
hex = 0x2a, // or `0X2a` :/
bin = 0b101010; // or `0B101010` :/
唯一允许的小数形式是十进制的。八进制,十六进制,和二进制都是整数形式。
而且所有这些形式的字符串表达形式都是可以被强制转换/变换为它们的数字等价物的:
Number( "42" ); // 42
Number( "0o52" ); // 42
Number( "0x2a" ); // 42
Number( "0b101010" ); // 42
虽然严格来说不是ES6新增的,但一个鲜为人知的事实是你其实可以做反方向的转换(好吧,某种意义上的):
var a = 42;
a.toString(); // "42" —— 也可使用`a.toString( 10 )`
a.toString( 8 ); // "52"
a.toString( 16 ); // "2a"
a.toString( 2 ); // "101010"
事实上,以这种方你可以用从2
到36
的任何进制表达一个数字,虽然你会使用标准进制 —— 2,8,10,和16 ——之外的情况非常少见。
Unicode
我只能说这一节不是一个穷尽了“关于Unicode你想知道的一切”的资料。我想讲解的是,你需要知道在ES6中对Unicode改变了什么,但是我们不会比这深入太多。Mathias Bynens (http://twitter.com/mathias) 大量且出色地撰写/讲解了关于JS和Unicode (参见 https://mathiasbynens.be/notes/javascript-unicode 和 http://fluentconf.com/javascript-html-2015/public/content/2015/02/18-javascript-loves-unicode)。
从0x0000
到0xFFFF
范围内的Unicode字符包含了所有的标准印刷字符(以各种语言),它们都是你可能看到过和互动过的。这组字符被称为 基本多文种平面(Basic Multilingual Plane (BMP))。BMP甚至包含像这个酷雪人一样的有趣字符: ☃ (U+2603)。
在这个BMP集合之外还有许多扩展的Unicode字符,它们的范围一直到0x10FFFF
。这些符号经常被称为 星形(astral) 符号,这正是BMP之外的字符的16组 平面 (也就是,分层/分组)的名称。星形符号的例子包括𝄞 (U+1D11E)和💩 (U+1F4A9)。
在ES6之前,JavaScript字符串可以使用Unicode转义来指定Unicode字符,例如:
var snowman = "\u2603";
console.log( snowman ); // "☃"
然而,\uXXXX
Unicode转义仅支持四个十六进制字符,所以用这种方式表示你只能表示BMP集合中的字符。要在ES6以前使用Unicode转义表示一个星形字符,你需要使用一个 代理对(surrogate pair) —— 基本上是两个经特殊计算的Unicode转义字符放在一起,被JS解释为一个单独星形字符:
var gclef = "\uD834\uDD1E";
console.log( gclef ); // "𝄞"
在ES6中,我们现在有了一种Unicode转义的新形式(在字符串和正则表达式中),称为Unicode 代码点转义:
var gclef = "\u{1D11E}";
console.log( gclef ); // "𝄞"
如你所见,它的区别是出现在转义序列中的{ }
,它允许转义序列中包含任意数量的十六进制字符。因为你只需要六个就可以表示在Unicode中可能的最高代码点(也就是,0x10FFFF),所以这是足够的。
Unicode敏感的字符串操作
在默认情况下,JavaScript字符串操作和方法对字符串值中的星形符号是不敏感的。所以,它们独立地处理每个BMP字符,即便是可以组成一个单独字符的两半代理。考虑如下代码:
var snowman = "☃";
snowman.length; // 1
var gclef = "𝄞";
gclef.length; // 2
那么,我们如何才能正确地计算这样的字符串的长度呢?在这种场景下,下面的技巧可以工作:
var gclef = "𝄞";
[...gclef].length; // 1
Array.from( gclef ).length; // 1
回想一下本章早先的“for..of
循环”一节,ES6字符串拥有内建的迭代器。这个迭代器恰好是Unicode敏感的,这意味着它将自动地把一个星形符号作为一个单独的值输出。我们在一个数组字面量上使用扩散操作符...
,利用它创建了一个字符串符号的数组。然后我们只需检查这个结果数组的长度。ES6的Array.from(..)
基本上与[...XYZ]
做的事情相同,不过我们将在第六章中讲解这个工具的细节。
警告: 应当注意的是,相对地讲,与理论上经过优化的原生工具/属性将做的事情比起来,仅仅为了得到一个字符串的长度就构建并耗尽一个迭代器在性能上的代价是高昂的。
不幸的是,完整的答案并不简单或直接。除了代理对(字符串迭代器可以搞定的),一些特殊的Unicode代码点有其他特殊的行为,解释起来非常困难。例如,有一组代码点可以修改前一个相邻的字符,称为 组合变音符号(Combining Diacritical Marks)
考虑这两个数组的输出:
console.log( s1 ); // "é"
console.log( s2 ); // "é"
它们看起来一样,但它们不是!这是我们如何创建s1
和s2
的:
var s1 = "\xE9",
s2 = "e\u0301";
你可能猜到了,我们前面的length
技巧对s2
不管用:
[...s1].length; // 1
[...s2].length; // 2
那么我们能做什么?在这种情况下,我们可以使用ES6的String#normalize(..)
工具,在查询这个值的长度前对它实施一个 Unicode正规化操作:
var s1 = "\xE9",
s2 = "e\u0301";
s1.normalize().length; // 1
s2.normalize().length; // 1
s1 === s2; // false
s1 === s2.normalize(); // true
实质上,normalize(..)
接受一个"e\u0301"
这样的序列,并把它正规化为\xE9
。正规化甚至可以组合多个相邻的组合符号,如果存在适合他们组合的Unicode字符的话:
var s1 = "o\u0302\u0300",
s2 = s1.normalize(),
s3 = "ồ";
s1.length; // 3
s2.length; // 1
s3.length; // 1
s2 === s3; // true
不幸的是,这里的正规化也不完美。如果你有多个组合符号在修改一个字符,你可能不会得到你所期望的长度计数,因为一个被独立定义的,可以表示所有这些符号组合的正规化字符可能不存在。例如:
var s1 = "e\u0301\u0330";
console.log( s1 ); // "ḛ́"
s1.normalize().length; // 2
你越深入这个兔子洞,你就越能理解要得到一个“长度”的精确定义是很困难的。我们在视觉上看到的作为一个单独字符绘制的东西 —— 更精确地说,它称为一个 字形 —— 在程序处理的意义上不总是严格地关联到一个单独的“字符”上。
提示: 如果你就是想看看这个兔子洞有多深,看看“字形群集边界(Grapheme Cluster Boundaries)”算法(http://www.Unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries)。
字符定位
与长度的复杂性相似,“在位置2上的字符是什么?”,这么问的意思究竟是什么?前ES6的原生答案来自charAt(..)
,它不会遵守一个星形字符的原子性,也不会考虑组合符号。
考虑如下代码:
var s1 = "abc\u0301d",
s2 = "ab\u0107d",
s3 = "ab\u{1d49e}d";
console.log( s1 ); // "abćd"
console.log( s2 ); // "abćd"
console.log( s3 ); // "ab𝒞d"
s1.charAt( 2 ); // "c"
s2.charAt( 2 ); // "ć"
s3.charAt( 2 ); // "" <-- 不可打印的代理字符
s3.charAt( 3 ); // "" <-- 不可打印的代理字符
那么,ES6会给我们Unicode敏感版本的charAt(..)
吗?不幸的是,不。在本书写作时,在后ES6的考虑之中有一个这样的工具的提案。
但是使用我们在前一节探索的东西(当然也带着它的限制!),我们可以黑一个ES6的答案:
var s1 = "abc\u0301d",
s2 = "ab\u0107d",
s3 = "ab\u{1d49e}d";
[...s1.normalize()][2]; // "ć"
[...s2.normalize()][2]; // "ć"
[...s3.normalize()][2]; // "𝒞"
警告: 提醒一个早先的警告:在每次你想得到一个单独的字符时构建并耗尽一个迭代器……在性能上不是很理想。对此,希望我们很快能在后ES6时代得到一个内建的,优化过的工具。
那么charCodeAt(..)
工具的Unicode敏感版本呢?ES6给了我们codePointAt(..)
:
var s1 = "abc\u0301d",
s2 = "ab\u0107d",
s3 = "ab\u{1d49e}d";
s1.normalize().codePointAt( 2 ).toString( 16 );
// "107"
s2.normalize().codePointAt( 2 ).toString( 16 );
// "107"
s3.normalize().codePointAt( 2 ).toString( 16 );
// "1d49e"
那么从另一个方向呢?String.fromCharCode(..)
的Unicode敏感版本是ES6的String.fromCodePoint(..)
:
String.fromCodePoint( 0x107 ); // "ć"
String.fromCodePoint( 0x1d49e ); // "𝒞"
那么等一下,我们能组合String.fromCodePoint(..)
与codePointAt(..)
来得到一个刚才的Unicode敏感charAt(..)
的更好版本吗?是的!
var s1 = "abc\u0301d",
s2 = "ab\u0107d",
s3 = "ab\u{1d49e}d";
String.fromCodePoint( s1.normalize().codePointAt( 2 ) );
// "ć"
String.fromCodePoint( s2.normalize().codePointAt( 2 ) );
// "ć"
String.fromCodePoint( s3.normalize().codePointAt( 2 ) );
// "𝒞"
还有好几个字符串方法我们没有在这里讲解,包括toUpperCase()
,toLowerCase()
,substring(..)
,indexOf(..)
,slice(..)
,以及其他十几个。它们中没有任何一个为了完全支持Unicode而被改变或增强过,所以在处理含有星形符号的字符串是,你应当非常小心 —— 可能干脆回避它们!
还有几个字符串方法为了它们的行为而使用正则表达式,比如replace(..)
和match(..)
。值得庆幸的是,ES6为正则表达式带来了Unicode支持,正如我们在本章早前的“Unicode标志”中讲解过的那样。
好了,就是这些!有了我们刚刚讲过的各种附加功能,JavaScript的Unicode字符串支持要比前ES6时代好太多了(虽然还不完美)。
Unicode标识符名称
Unicode还可以被用于标识符名称(变量,属性,等等)。在ES6之前,你可以通过Unicode转义这么做,比如:
var \u03A9 = 42;
// 等同于:var Ω = 42;
在ES6中,你还可以使用前面讲过的代码点转义语法:
var \u{2B400} = 42;
// 等同于:var 𫐀 = 42;
关于究竟哪些Unicode字符被允许使用,有一组复杂的规则。另外,有些字符只要不是标识符名称的第一个字符就允许使用。
注意: 关于所有这些细节,Mathias Bynens写了一篇了不起的文章 (https://mathiasbynens.be/notes/javascript-identifiers-es6)。
很少有理由,或者是为了学术上的目的,才会在标识符名称中使用这样不寻常的字符。你通常不会因为依靠这些深奥的功能编写代码而感到舒服。
Symbol
在ES6中,长久以来首次,有一个新的基本类型被加入到了JavaScript:symbol
。但是,与其他的基本类型不同,symbol没有字面形式。
这是你如何创建一个symbol:
var sym = Symbol( "some optional description" );
typeof sym; // "symbol"
一些要注意的事情是:
- 你不能也不应该将
new
与Symbol(..)
一起使用。它不是一个构造器,你也不是在产生一个对象。 - 被传入
Symbol(..)
的参数是可选的。如果传入的话,它应当是一个字符串,为symbol的目的给出一个友好的描述。 -
typeof
的输出是一个新的值("symbol"
),这是识别一个symbol的主要方法。
如果描述被提供的话,它仅仅用于symbol的字符串化表示:
sym.toString(); // "Symbol(some optional description)"
与基本字符串值如何不是String
的实例的原理很相似,symbol也不是Symbol
的实例。如果,由于某些原因,你想要为一个symbol值构建一个封箱的包装器对像,你可以做如下的事情:
sym instanceof Symbol; // false
var symObj = Object( sym );
symObj instanceof Symbol; // true
symObj.valueOf() === sym; // true
注意: 在这个代码段中的symObj
和sym
是可以互换使用的;两种形式可以在symbol被用到的地方使用。没有太多的理由要使用封箱的包装对象形式(symObj
),而不用基本类型形式(sym
)。和其他基本类型的建议相似,使用sym
而非symObj
可能是最好的。
一个symbol本身的内部值 —— 称为它的name
—— 被隐藏在代码之外而不能取得。你可以认为这个symbol的值是一个自动生成的,(在你的应用程序中)独一无二的字符串值。
但如果这个值是隐藏且不可取得的,那么拥有一个symbol还有什么意义?
一个symbol的主要意义是创建一个不会和其他任何值冲突的类字符串值。所以,举例来说,可以考虑将一个symbol用做表示一个事件的名称的值:
const EVT_LOGIN = Symbol( "event.login" );
然后你可以在一个使用像"event.login"
这样的一般字符串字面量的地方使用EVT_LOGIN
:
evthub.listen( EVT_LOGIN, function(data){
// ..
} );
其中的好处是,EVT_LOGIN
持有一个不能被其他任何值所(有意或无意地)重复的值,所以在哪个事件被分发或处理的问题上不可能存在任何含糊。
注意: 在前面的代码段的幕后,几乎可以肯定地认为evthub
工具使用了EVT_LOGIN
参数值的symbol值作为某个跟踪事件处理器的内部对象的属性/键。如果evthub
需要将symbol值作为一个真实的字符串使用,那么它将需要使用String(..)
或者toString(..)
进行明确强制转换,因为symbol的隐含字符串强制转换是不允许的。
你可能会将一个symbol直接用做一个对象中的属性名/键,如此作为一个你想将之用于隐藏或元属性的特殊属性。重要的是,要知道虽然你试图这样对待它,但是它 实际上 并不是隐藏或不可接触的属性。
考虑这个实现了 单例 模式行为的模块 —— 也就是,它仅允许自己被创建一次:
const INSTANCE = Symbol( "instance" );
function HappyFace() {
if (HappyFace[INSTANCE]) return HappyFace[INSTANCE];
function smile() { .. }
return HappyFace[INSTANCE] = {
smile: smile
};
}
var me = HappyFace(),
you = HappyFace();
me === you; // true
这里的symbol值INSTANCE
是一个被静态地存储在HappyFace()
函数对象上的特殊的,几乎是隐藏的,类元属性。
替代性地,它本可以是一个像__instance
这样的普通属性,而且其行为将会是一模一样的。symbol的使用仅仅增强了程序元编程的风格,将这个INSTANCE
属性与其他普通的属性间保持隔离。
Symbol注册表
在前面几个例子中使用symbol的一个微小的缺点是,变量EVT_LOGIN
和INSTANCE
不得不存储在外部作用域中(甚至也许是全局作用域),或者用某种方法存储在一个可用的公共位置,这样代码所有需要使用这些symbol的部分都可以访问它们。
为了辅助组织访问这些symbol的代码,你可以使用 全局symbol注册表 来创建symbol。例如:
const EVT_LOGIN = Symbol.for( "event.login" );
console.log( EVT_LOGIN ); // Symbol(event.login)
和:
function HappyFace() {
const INSTANCE = Symbol.for( "instance" );
if (HappyFace[INSTANCE]) return HappyFace[INSTANCE];
// ..
return HappyFace[INSTANCE] = { .. };
}
Symbol.for(..)
查询全局symbol注册表来查看一个symbol是否已经使用被提供的说明文本存储过了,如果有就返回它。如果没有,就创建一个并返回。换句话说,全局symbol注册表通过描述文本将symbol值看作它们本身的单例。
但这也意味着只要使用匹配的描述名,你的应用程序的任何部分都可以使用Symbol.for(..)
从注册表中取得symbol。
讽刺的是,基本上symbol的本意是在你的应用程序中取代 魔法字符串 的使用(被赋予了特殊意义的随意的字符串值)。但是你正是在全局symbol注册表中使用 魔法 描述字符串值来唯一识别/定位它们的!
为了避免意外的冲突,你可能想使你的symbol描述十分独特。这么做的一个简单的方法是在它们之中包含前缀/环境/名称空间的信息。
例如,考虑一个像下面这样的工具:
function extractValues(str) {
var key = Symbol.for( "extractValues.parse" ),
re = extractValues[key] ||
/[^=&]+?=([^&]+?)(?=&|$)/g,
values = [], match;
while (match = re.exec( str )) {
values.push( match[1] );
}
return values;
}
我们使用魔法字符串值"extractValues.parse"
,因为在注册表中的其他任何symbol都不太可能与这个描述相冲突。
如果这个工具的一个用户想要覆盖这个解析用的正则表达式,他们也可以使用symbol注册表:
extractValues[Symbol.for( "extractValues.parse" )] =
/..some pattern../g;
extractValues( "..some string.." );
除了symbol注册表在全局地存储这些值上提供的协助以外,我们在这里看到的一切其实都可以通过将魔法字符串"extractValues.parse"
作为一个键,而不是一个symbol,来做到。这其中在元编程的层次上的改进要多于在函数层次上的改进。
你可能偶然会使用一个已经被存储在注册表中的symbol值来查询它底层存储了什么描述文本(键)。例如,因为你无法传递symbol值本身,你可能需要通知你的应用程序的另一个部分如何在注册表中定位一个symbol。
你可以使用Symbol.keyFor(..)
取得一个被注册的symbol描述文本(键):
var s = Symbol.for( "something cool" );
var desc = Symbol.keyFor( s );
console.log( desc ); // "something cool"
// 再次从注册表取得symbol
var s2 = Symbol.for( desc );
s2 === s; // true
Symbols作为对象属性
如果一个symbol被用作一个对象的属性/键,它会被以一种特殊的方式存储,以至这个属性不会出现在这个对象属性的普通枚举中:
var o = {
foo: 42,
[ Symbol( "bar" ) ]: "hello world",
baz: true
};
Object.getOwnPropertyNames( o ); // [ "foo","baz" ]
要取得对象的symbol属性:
Object.getOwnPropertySymbols( o ); // [ Symbol(bar) ]
这表明一个属性symbol实际上不是隐藏的或不可访问的,因为你总是可以在Object.getOwnPropertySymbols(..)
的列表中看到它。
内建Symbols
ES6带来了好几种预定义的内建symbol,它们暴露了在JavaScript对象值上的各种元行为。然而,正如人们所预料的那样,这些symbol 没有 没被注册到全局symbol注册表中。
取而代之的是,它们作为属性被存储到了Symbol
函数对象中。例如,在本章早先的“for..of
”一节中,我们介绍了值Symbol.iterator
:
var a = [1,2,3];
a[Symbol.iterator]; // native function
语言规范使用@@
前缀注释指代内建的symbol,最常见的几个是:@@iterator
,@@toStringTag
,@@toPrimitive
。还定义了几个其他的symbol,虽然他们可能不那么频繁地被使用。
注意: 关于这些内建symbol如何被用于元编程的详细信息,参见第七章的“通用Symbol”。
复习
ES6给JavaScript增加了一堆新的语法形式,有好多东西要学!
这些东西中的大多数都是为了缓解常见编程惯用法中的痛点而设计的,比如为函数参数设置默认值和将“剩余”的参数收集到一个数组中。解构是一个强大的工具,用来更简约地表达从数组或嵌套对象的赋值。
虽然像箭头函数=>
这样的特性看起来也都是关于更简短更好看的语法,但是它们实际上拥有非常特殊的行为,你应当在恰当的情况下有意地使用它们。
扩展的Unicode支持,新的正则表达式技巧,和新的symbol
基本类型充实了ES6语法的发展演变。