Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

javascript与字符集 #28

Open
leefinder opened this issue Mar 4, 2021 · 0 comments
Open

javascript与字符集 #28

leefinder opened this issue Mar 4, 2021 · 0 comments

Comments

@leefinder
Copy link
Owner

Javascript 与字符编码

1. 几个概念

  • Character:字符,这里可以理解成用户实际看到的实体,比如:“A”、“好”等等;
  • Code Point:码点/码位,对应Unicode 字符集中每个Character 的数字编号,比如“好”的码点是:U+597D;
  • Code Unit:编码方案对码点进行编码后的结果,比如“好”的UTF-16 编码结果为:Ox597D,UTF-8 编码结果为:E5A5BD;
  • Normalization:字符标识标准化,有时候,一个字符看起来是多个字符的组成,比如“ö”,可以看成一个字符,也可以看成由字符“ o ”和“ ¨ ”组合而成,而在Unicode 通过对每个字符对应一个码点而达到标准化字符标识的目的;
  • BMP:Basic Multilingual Plane,基本平面,Unicode Code Point处于U+0000 - U+FFFF之间的字符;
  • SMP:supplementary planes 或astral planes,辅助平面,Unicode Code Point处于U+10000 - U+10FFFF之间的字符;

2. 相关字符编码

  • UTF-16:使用两个字节的十六进制数字表示BMP 的字符,使用四个字节的十六进制数字表示SMP 的字符;
  • UCS-2:与UTF-16 类似,使用两个字节的十六进制数字表示BMP 的字符,没有办法表示SMP 的字符;
  • UTF-8:使用可变字节来标示BMP、SMP 的字符;

更详细的字符编码相关资料请参考:字符编码笔记

3. Javascript 使用哪种字符编码

在参考了一些博客、资料之后,个人觉得可以从这三个角度看Javascript 与字符编码的关系:

  1. 对Javascript 文件解码时;
  2. Javascript 引擎解析源码时;
  3. 代码运行时Javascript 语言本身的行为;

3.1. 解码js 文件时

解析文件就是按编码方案将文件的字节流解码为字符串,也就是源码。解码的行为跟Javascript 本身没有太大关系,但如果解码错误,就会造成非预期的运行结果,一般在代码编译时就会报错了。

解码使用的编码方案按优先级高到低由下面几个方面来决定:

  1. 如果文件有BOM 标记,则会使用对应的Unicode 编码,比如FFFE、FEFF 就会使用UTF-16;
  2. 由HTTP(S)请求的相应头来决定,比如:Content-Type: application/javascript; charset=utf-8
  3. <script/> 标签的charset 属性决定,比如:<script charset="utf-8" src="./main.js"></script>
    4. 由html 本身的charset 决定,比如:<meta charset="UTF-8">

3.2. Javascript 引擎解析源码时

下面引用自ECMAScript 语言标准中对源码的编码定义:

ECMAScript source text is represented as a sequence of characters in the Unicode character encoding, version 3.0 or later. ……ECMAScript source text is assumed to be a sequence of 16-bit code units for the purposes of this specification. Such a source text may include sequences of 16-bit code units that are not valid UTF-16 character encodings. If an actual source text is encoded in a form other than 16-bit code units it must be processed as if it was first converted to UTF-16.

上面主要表达的一点是, Javascipt 源码支持UTF-16 编码。更实用点的理解是:Javascript 引擎总会尝试把源码转成UTF-16 编码的文本。

再来看一段引用自ECMAScript 语言标准中定义的UTF-16 转义序列的使用方法:

In string literals, regular expression literals, and identifiers, any character (code unit) may also be expressed as a Unicode escape sequence consisting of six characters, namely \u plus four hexadecimal digits. Within a comment, such an escape sequence is effectively ignored as part of the comment. Within a string literal or regular expression literal, the Unicode escape sequence contributes one character to the value of the literal. Within an identifier, the escape sequence contributes one character to the identifier.

上面主要表达的有以下几点:

  • 字符串字面量、正则表达式对象字面量、变量名支持使用\u加四位十六进制数值来表示UTF-16 编码的字符,比如:\u0061表示英文字母小写a;
  • 在注释中会忽略转义序列;
  • 字符串字面量、正则表达式对象字面量中一个转义序列对应一个字符;
  • 变量名中一个转义序列对应一个字符;

关于在变量名中使用转义序列,有一点需要提醒:不要在变量名中使用包含非BMP 字符的转义序列,例如:var \uD83D\uDC04 = 'foo',这行代码在解析阶段就会出错。原因,Javascript 会把将代理码对看作两个独立的Code Unit,而由于代理码对的Code Unit 要结合起来才能对应到一个Code Point,单独的一个Code Unit 不能对应一个有效的字符,导致变量名不符合ECMAScript 标准要求,所以出错了。

3.3. 代码运行时

虽然在上文提到,Javascript 源码解析支持UTF-16 编码,但由于在语言设计之初,没有考虑到SMP 字符的出现,也就是认为每个字符都一定能用一个Code Unit 来表示,没想到SMP 字符需要利用代理码对,也就是要用两个Code Unit 来表示一个字符。而语言本身的这个特性会造成在处理包含SMP 字符的字符串时出现非预期结果,比如,字符“🐄” 的UTF-16 编码是:OxD83D OxDC04,对应就会有两个Code Unit,理想中的结果是这个字符长度等于1,但实际上,Javascript 会认为长度等于2,因为有两个Code Unit。

上面的行为几乎与UCS-2 编码一样,造成这种现状的原因其实很简单,那就是_生不逢时_,1990 年UCS-2 编码方案发布,1995 年Javascript 设计完成并完成解析,1996 年UTF-16 编码方案发布。也就是说,当Javascript 在设计的时候,UTF-16 还没诞生,所以唯有使用UCS-2。由于UCS-2 编码方案是对UCS 字符集的一种编码实现,而当时UCS 字符集在发布时,所收纳的字符数量可以用两个字节的字符码范围来一一对应,导致UCS-2 编码只能对BMP 字符进行编码,对SMP 字符则无能为力了。

下文会更具体的阐述Javascript 这种讲一个Code Unit 看作一个字符的特性会给我们编写的程序带来什么影响。

4. Javascript 与Unicode

高潮来啦啦啦~~~

先归纳一下前文提到几个关键点:

  1. Javascript 源码支持UTF-16 编码,解析引擎总会尝试将源码字符串转成UTF-16 编码;
  2. Javascript 语言中,一个Code Unit 对应一个Unicode 字符;
  3. Javascript 字符串字面量、正则表达式对象字面量、变量名支持使用\u加四位十六进制数值来表示UTF-16 编码的字符,使用\x加两位十六进制数值来表示U+0000U+00FF的UTF-16 编码字符;
  4. 保证Javascript 源码解码时使用了正确的编码方案,不然引擎将源码字符串转成UTF-16 编码时并进行解析时可能会出错;

下面重点说下第二点对程序运行的影响。

4.1. 计算字符串长度

在计算字符串长度时,一般是使用.length这个属性,比如:

> 'samhwang1990@gmail.com'.length
22

> 'young'.length
5

> 'A'.length
1

> 'B'.length
1

> '\u0042'.length   // 大写拉丁字母B
1

> '\u0042' == 'B'
true

上面的例子表明,当使用length属性来获取BMP 字符长度是没有问题的。

4.2. 计算SMP 字符长度

来看看length属性应用到SMP 字符时的表现:

> '𝐀'.length  // U+1D400 MATHEMATICAL BOLD CAPITAL A
2

> '𝐀' == '\uD835\uDC00'
true

>> '𝐁'.length // U+1D401 MATHEMATICAL BOLD CAPITAL B
2

>> '𝐁' == '\uD835\uDC01'
true

>> '💩'.length // U+1F4A9 PILE OF POO
2

>> '💩' == '\uD83D\uDCA9'
true

上面的例子表明,由于UTF-16 对SMP 字符的编码是将一个字符Code Point 编码成两个Code Unit,即代理码对,同时,由于Javascript 一个字符对应一个Code Unit,导致,length的返回结果中,一个SMP 字符的长度等于2。

这种结果并不是我们想要的,期望中是每个SMP 字符的返回长度等于1,下面展示一个解决办法:

var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;

function countSymbols(_string) {
  var bmpString = _string.replace(regexAstralSymbols, '_')
  return bmpString.length;
}

思路是,将代理码对替换成一个BMP 字符,替换的结果是,生成一个新的字符串,其中全部都是BMP 字符,此时length的行为是合理的。

使用上面的方法看能不能解决问题:

> countSymbols('A');
1
> countSymbols('𝐀');
1
> countSymbols('💩');
1

4.3. 计算组合字符长度

先来解释下什么是组合字符。举个栗子:字符ñ可以由ñ组合形成,而这个字符在Unicode 字符集中代表:Latin Small Letter N with Tilde,码点是U+00F1,组合字符的表现就是,字符串\u00F1\u006E\u0303的渲染结果是一样的,均为ñ,但明显,字符组成是完全不一样的。

> 'mañana' == 'mañana'
false

> 'ma\xF1ana' == 'ma\x6E\u0303ana'
false

那这种组合字符在计算长度时会有什么表现呢:

> 'ma\xF1ana'.length
6

> 'ma\x6E\u0303ana'.length
7

上面的例子说明,看起来一样字符串,长度不一定相同哦,外貌可以骗人,但Javascript 不会骗人,哈哈哈~~~

要避免上面的情况,可以使用ES6 的String.prototype.normalize先让字符串标准化,具体怎么用下文会讲。

4.4. 字符串逆序排列

字符串逆序排列,一个听起来很简单的问题,方法有很多,随便遍历一下就出来了,下面展示其中一个思路:

function reverse(string) {
  return string.split('').reverse().join('');
}

这个看起来很完美:

> reverse('samhwang');
"gnawhmas"
> reverse('young');
"gnuoy"
> reverse('mañana') // U+00F1
'anañam'

但,真的已经解决了吗?如果我要逆序排列一个包含SMP 字符或者组合字符的字符串,上面的方法还能用吗?来看看:

>> reverse('mañana') // U+006E + U+0303
'anãnam' // 注意,字符“~”现在被加到“a”而不是“n” 上了

> reverse('💩') // U+1F4A9
'��' // '\uDCA9\uD83D', 字符“💩”的代理码对高低位顺序错了,对应不上任一字符

不管是遍历还是上面的reverse方法,本质上都是将Code Unit 逆序排序。对于SMP 字符的代理码对调换顺序的话就会失效,对不上任一字符,而组合字符调换顺序后的结果也是很难预期的,容易出问题。所以,上面的思路显然不好。

更好的方法是使用ES6 Array.from() 或者引用第三方库:esrever

4.5. 字符串对象的方法与Unicode

4.5.1. String.fromCharCode

String.fromCharCode根据传入的Unicode 字符Code Point 创建字符串,该方法是String 类型的静态方法,不是实例方法。

语法:

String.fromCharCode(numX,numX,...,numX)

其中numX 就是Code Point。

该方法只在传入BMP 字符Code Point 时工作正常,如果传入SMP 字符的Code Point,是得不到我们想要的字符串的,举个栗子:

> String.fromCharCode(0x0041) // U+0041
'A' // U+0041

> String.fromCharCode(0x1F4A9) // U+1F4A9
'' // U+F4A9, not U+1F4A9

如果真的要使用这个方法来创建SMP 字符串,那就要吧代理码对拆开来单独传进去,或者使用ES6 的String.fromCodePoint:

> String.fromCharCode(0xD83D, 0xDCA9)
"💩"

> String.fromCodePoint(0x1F4A9)
"💩"

4.5.2. String.prototype.charAt()

String.prototype.charAt(position)返回指定位置的字符,由于SMP 字符在Javascript 中视为两个字符长度,所以,会影响position 的定位,也会影响返回结果,举个栗子:

> 'samhwang'.charAt(3)
"h"
> 'sam💩hwang'.charAt(4)
"�"       // U+DCA9,本意是返回字符'h'
> 'sam💩hwang'.charAt(3)
"�"       // U+D83D,本意是返回字符'💩'

由于“💩”是SMP 字符,以代理码对形式表示,即长度为2,导致上面第二个例子中实际返回的是“💩”代理码对中的第二个Code Unit,而第三个例子则返回了“💩”代理码对中的第一个Code Unit。

解决办法是使用ES7 草案中的String.prototype.at(position)方法:

>> '💩'.at(0) // U+1F4A9
'💩' // U+1F4A9

4.5.3. String.prototype.charCodeAt()

String.prototype.charCodeAt(position)返回指定位置的Code Point,返回结果是十进制的,为了适应平常的Code Point 十六进制表示,需要的话可以转成十六进制。该方法的问题与String.prototype.charAt(position) 一样。

解决办法是使用ES6 中的String.prototype.codePointAt(position)方法:

> '💩hwang'.charCodeAt(0)
55357       // 返回结果是十进制的
> '💩hwang'.charCodeAt(0).toString(16)
"d83d"      // 将返回结果转成十六进制

> '💩hwang'.codePointAt(0)
128169      // 返回结果是十进制的
> '💩hwang'.codePointAt(0).toString(16);
"1f4a9"     // 将返回结果转成十六进制

前两个例子展示了String.prototype.charCodeAt(position)在处理SMP 字符的问题,即返回了代理码对的第一个Code Unit。

其实不仅仅是上面的提到的方法,只要是String 类型对象中,要跟索引位置、字符串长度、字符编码打交道的,基本都会受Javascript 中,一个Code Unit 对应一个Unicode 字符 这个特性的影响,使用时要格外留意。

4.6. 字符串遍历

阅读完上面的内容之后,大概也猜到了,平时在遍历字符串时是不能简简单单的直接for ...,然后使用索引对应位置上的字符进行处理,更好的做法是,遍历字符串,将每个有效的字符串,包括BMP、SMP 字符取出来,并放到一个临时数组中,然后遍历该数组再做处理,代码如下:

function getSymbols(string) {
  var index = 0;
  var length = string.length;
  var output = [];
  for (; index < length - 1; ++index) {
    var charCode = string.charCodeAt(index);
    if (charCode >= 0xD800 && charCode <= 0xDBFF) {
      charCode = string.charCodeAt(index + 1);
      if (charCode >= 0xDC00 && charCode <= 0xDFFF) {
        output.push(string.slice(index, index + 2));
        ++index;
        continue;
      }
    }
    output.push(string.charAt(index));
  }
  return output;
}

var symbols = getSymbols('💩');
symbols.forEach(function(symbol) {
  assert(symbol == '💩');
});

上面方法的核心逻辑是,当遇到字符Code Unit 在代理码对高位范围(U+D800-U+DBFF)时,尝试获取下一个字符的Code Unit,如果在代理码对低位范围(U+DC00-U+DFFF)就将这两个字符拼在一起推入临时数组中。

哈,如果要简单而且不出错的遍历字符串,使用ES6 的for...of吧:

for (let symbol of '💩') {
  console.log(symbol == '💩');    // true
}

4.7. 正则表达式与Unicode

4.7.1. 正则元字符介绍及其Unicode 编码

首先列举一下Javascript 正则语法中常用的几个元字符及其定义:

  • \d:数字字符,数字0 到9
  • \D:非数字字符
  • \w:单词,仅包括A-Z、a-z、0-9 和 _
  • \W:非单词
  • \b:单词边界,单词字符与非单词字符之间的位置
  • \B:非单词边界
  • \s:空白字符,
  • \S:非空白字符
  • .:单个字符,除了换行符
  • ^:匹配每一行字符串的开始位置
  • $:匹配每一行字符串的结束位置

在ECMAScript 标准中,\s\S.^$中涉及的空白字符(whitespace)以及换行符(newline)都支持Unicode 标准中的字符,
而其他的\d\D\w\W\b\B中涉及的数字、单词字符以及单词边界都只支持ASCII 范围的字符。

下面重点列举下空白字符(whitespace)以及换行符(newline)都包含了哪些字符及其Unicode 编码:

换行符:

  • \u000a — Line feed — \n
  • \u000d — Carriage return — \r
  • \u2028 — Line separator
  • \u2029 — Paragraph separator

空白字符:

  • \u0009 — Tab — \t
  • \u000a — Line feed — \n — (newline character)
  • \u000b — Vertical tab — \v
  • \u000c — Form feed — \f
  • \u000d — Carriage return — \r — (newline character)
  • \u0020 — Space
  • \u00a0 — No-break space
  • \u1680 — Ogham space mark
  • \u180e — Mongolian vowel separator
  • \u2000 — En quad
  • \u2001 — Em quad
  • \u2002 — En space
  • \u2003 — Em space
  • \u2004 — Three-per-em space
  • \u2005 — Four-per-em space
  • \u2006 — Six-per-em space
  • \u2007 — Figure space
  • \u2008 — Punctuation space
  • \u2009 — Thin space
  • \u200a — Hair space
  • \u2028 — Line separator — (newline character)
  • \u2029 — Paragraph separator — (newline character)
  • \u202f — Narrow no-break space
  • \u205f — Medium mathematical space
  • \u3000 — Ideographic space

介绍完元字符后,要说说在使用元字符匹配Unicode 字符时要注意的地方了。

4.7.2. 匹配Unicode 字符

如果要匹配任一字符,第一个想到的估计就是.元字符了,但该元字符不能匹配换行符,所以,如果有换行符需求的,可以使用[\s\S]来匹配任一字符。

满足了吗?不,因为不管是.还是[\s\S],都只能匹配到一个字符,而由于Javascript 暴露SMP 字符的方式是使用代理码对,也就是SMP 字符是由两个Code Unit 组成的,所以如果使用上面介绍的两种方式来尝试匹配SMP 字符,就只能匹配到代理码对中的第一个Code Unit,而不是匹配到整个SMP 字符。所以,在使用上面说的两个方式来匹配字符时,要心里有数。

那如果想要匹配所有Unicode 收录的字符,怎么做呢?正则条件编写的思路可以是分区块来,把BMP 所有字符都包含进去,然后把SMP 代理码对作为整体包含进去,同时,分别筛选掉代理码对中的高位和低位的Code Point,因为这部分的Code Point 如果单独存在的话,在Unicode 中是对应不了任一有效字符的,所以,最终的正则是:

 [\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]

哈,这个正则如果程序员手工写估计够呛,这里介绍一位大牛编写的库:regenerate,这个库牛逼的地方是,我们能将要包含在正则中的Unicode 字符码点(Code Point)以及不想包含的码点以参数穿进去,就能返回满足条件的、可读性强的正则,比如上面的正则可以这样来获取:

> regenerate().addRange(0x0, 0x10FFFF).toString()
'[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]'

4.7.3. 匹配有效的Unicode 字符

好,紧接上面的话题,如果我要获取Unicode 字符集中所有有效的Code Point,也就是要排除代理码对中高低位的码点范围,要怎么做呢?还是使用上面的regenerate库,来,爽一把:

> regenerate()
    .addRange(0x0, 0x10FFFF)     // all Unicode code points
    .removeRange(0xD800, 0xDBFF) // minus high surrogates
    .removeRange(0xDC00, 0xDFFF) // minus low surrogates
    .toRegExp()
/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/

4.7.4. 匹配SMP 字符范围

先来个例子熟悉一下匹配字符范围是什么东西,[0-9]用于匹配0到9 之间的数字字符,[a-z]用于匹配字母a 到字母z 之间的字符。同样的,如果我们想要匹配某两个SMP 字符之间的任一字符,比如要匹配💩💫之间的字符,那该怎么写正则条件呢?

第一个思路是:/[💩-💫]/,这是直接套[0-9]这种模式,我们来看看是否正确:

> /[💩-💫]/
SyntaxError: Invalid regular expression: Range out of order in character class

额,这段正则貌似在代码解析阶段就报错了,其实这段正则如果从Code Unit 上看,等同于:/[\uD83D\uDCA9-\uD83D\uDCAB]/,匹配条件是字符\uD83D,或者字符范围\uDCA9-\uD83D,或者字符\uDCAB,而报错是在尝试去匹配\uDCA9-\uD83D这个字符范围时发生的,因为字符范围中,起始字符的Code Point 不能比结束字符的Code Point 大。

所以,思路一明显不行。来看思路二,这里继续使用regenerage这个库:

> regenerate().addRange('💩', '💫')
'\uD83D[\uDCA9-\uDCAB]'

> /^\uD83D[\uDCA9-\uDCAB]$/.test('💩') // match U+1F4A9
true

> /^\uD83D[\uDCA9-\uDCAB]$/.test('💪') // match U+1F4AA
true

> /^\uD83D[\uDCA9-\uDCAB]$/.test('💫') // match U+1F4AB
true

完美运行。

5. ES2015 与Unicode

前文的讲解大致上已经把Javascript 字符串处理中的一些坑给列举了一遍,并针对这些坑给出了一些解决办法。在部分解决办法中有提到可以使用ES6 的新特性解决,下面,就来详细介绍下,ES6,又称为ES2015,在Unicode 支持以及字符处理上的增强吧!

说在前的观点:ES6 中已经能自动将SMP 字符的代理码对识别为一个字符,只是,为了兼容旧版本代码,并没有对现有的方法、行为作出修正,而是通过新添加的方式来达到目的,这也正是本节关注的重点。

5.1. 支持Code Point 转义序列

ES6 新增Code Point 转义序列,用法如下:

> '\x41\x42\x43'
"ABC"

> '\u{41}\u{42}\u{43}'
"ABC"

> '\uD83D\uDCA9'
'💩' // U+1F4A9 PILE OF POO

> '\u{1F4A9}'
'💩' // U+1F4A9 PILE OF POO

也就是使用\u加上大括号包围的Unicode Code Point 就能表示对应的字符,这对于SMP 字符来说是极大的方便,因为不用再去计算字符的代理码对,直接使用Code Point 就行了。

5.2. 字符串遍历

ES6 推荐的遍历字符串的方式是使用新的for..of循环,这种方式能正确的将SMP 字符代理码对识别为单一字符。

> var _char;
> for (_char of 'sam💩Hwang') {
    console.log(_char);
  }
s
a
m
💩
H
w
a
n
g

5.3. 获取字符串长度

ES6 推荐的获取字符串长度的方式是Array.from(string).length,这种方式关键点是使用数组类型新增的静态方法:Array.from(),该方法能遍历字符串并返回包含每个字符的新数组,这里的遍历方式与上文提到的for...of都能正确识别SMP 字符,所以,这种方式返回的结果是准确的字符串长度,即使该字符串包含SMP 字符。

> Array.from('sam💩Hwang').length
9

5.4. 字符串处理函数

5.4.1. String.fromCodePoint()

String.fromCodePoint根据传入的Unicode Code Point 创建字符串,该方法是String 类型的静态方法,不是实例方法。

语法:

String.fromCodePoint(numX,numX,...,numX)

其中numX 就是Code Point。

该方法ES6 是对String.fromCharCode()的一种修正,以增强对SMP 字符Code Point 的支持:

> String.fromCharCode(0x1F4A9, 0x0041);
"A"

> String.fromCodePoint(0x1F4A9, 0x0041);
"💩A"

5.4.2. String.prototype.at()

String.prototype.at(position)返回字符串中指定位置的字符,该方法将SMP 字符的代理码对视为单一字符,相当于对String.prototype.charAt(position)的行为修正。

> '💩'.charAt(0) // U+1F4A9
'\uD83D' // U+D83D, i.e. the first surrogate half for U+1F4A9

> '💩'.at(0) // U+1F4A9
'💩' // U+1F4A9

不过,该方法其实是属于ES7 草案的

5.4.3. String.prototype.codePointAt()

String.prototype.codePointAt(position)返回字符串中指定位置的Unicode Code Point,返回结果是十进制的整数,该方法将SMP 字符的代理码对视为单一字符,相当于对String.prototype.charCodeAt(position)的行为修正。

> '💩'.charCodeAt(0)
55357
> '💩'.charCodeAt(0).toString(16)
"d83d"

> '💩'.codePointAt(0)
128169
> '💩'.codePointAt(0).toString(16)
"1f4a9"

5.5. 正则表达式相关

ES6 新增/u修饰符来让.\s\S这些元字符支持SMP 字符,及其Code Point 或代理码对的匹配。

首先,看一下结合/u以及SMP 字符Code Point 和代理码对来匹配SMP 字符的例子:

> /^.$/.test('💩')
false

> /^.$/u.test('💩')
true

> /\uD83D\uDCA9/.test('💩');
true

> /\u{1F4A9}/u.test('💩');
true

> /\uD83D\uDCA9/.test('\u{1F4A9}');
true

然后再来看下,/u修饰符如何让我们匹配SMP 字符范围的:

> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCA9') // match U+1F4A9
true

> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4A9}') // match U+1F4A9
true

> /[💩-💫]/u.test('💩') // match U+1F4A9
true

> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAA') // match U+1F4AA
true

> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AA}') // match U+1F4AA
true

> /[💩-💫]/u.test('💪') // match U+1F4AA
true

> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAB') // match U+1F4AB
true

> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AB}') // match U+1F4AB
true

> /[💩-💫]/u.test('💫') // match U+1F4AB
true

5.6. Unicode 字符标准化

首先来了解一下Unicode 标准化是个什么概念。

在Unicode 字符集中,为了兼容一些现存的标准,会收录了很多特殊字符,而这些字符在视觉上或者语义上可能会跟其他字符或者字符序列等价,
比如,字符“ñ”跟字符序列“n”、“~”是等价的,又比如字符“Å”跟字符序列“A”、“°”是等价的,前者的“ñ”可以成为组合字符,后者的“Å”可以称为注音字符。

Unicode 有两种类型的字符序列等价关系,分别是:标准等价兼容等价 。前者是后者的子集,也就是说,如果两个字符序列是标准等价的,那么就一定是兼容等价,反过来则没有这个关系。
标准等价是指保持视觉上和功能上的等价,而兼容等价则更关注字符文字本身的等价,举个例子,上标数字“⁵” 与普通数字“5”在视觉上和功能上都是不等价的,所以,不属于标准等价,但由于从纯文字上看,又都是数字“5”,所以,两者属于兼容等价。
再例如全角和半角的片假名也是一种兼容等价但不是标准等价。

Unicode 标准化就是用来定义哪些字符序列是等价的,或者说是定义了哪些Code Point 或者Code Point 序列是等价的,同时提供将彼此等价的序列转化成唯一序列的算法。
而针对标准等价和兼容等价,各有两种标准化模式,分别是完全分解模式和完全合成模式,所以,Unicode 的标准化其实是用四种模式的,列举如下:

  • NFD,Normalization Form Canonical Decomposition,标准等价分解模式;
  • NFC,Normalization Form Canonical Composition,标准等价合成模式;
  • NFKD,Normalization Form Compatibility Decomposition,兼容等价分解模式;
  • NFKC,Normalization Form Compatibility Composition,兼容等价合成模式;

这里举个例子来说下什么是分解模式,什么是合成模式:像上面提到的字符“ñ”就是合成模式,对应的Code Point 序列是U+00F1,对应的分解模式就是U+006E U+0303

ES6 提供了String.prototype.normalize(type)方法来实现字符串的Unicode 标准化,参数type允许五个值:'NFC'''NFD'''NFKC'''NFKD' 以及空值,如果空,则默认值是'NFC'

> '\u004F\u030C'.normalize('NFC') == '\u01D1'
true

> '\u004F\u030C'.normalize('NFC') == "Ǒ"       // 这里的"Ǒ"是组合字符'\u004F\u030C'
false

> '\u01D1'.normalize('NFD') == '\u004F\u030C'
true

> '\u01D1'.normalize('NFD') == "Ǒ"             // 这里的"Ǒ"是组合字符'\u01D1'
false

> '⁵'.normalize('NFKC')
"5"

6. URL 编码基础

Javascript 提供两个方法来对URL 中的字符进行编码,分别是:encodeURI()encodeURIComponent()

在讲述这两个方法的区别之前,先来了解下为什么需要对URI 进行字符编码。

HTTP 协议规定请求头分为三部分:状态行、请求头、消息主体,类似于:

<method> <request-URL> <version>
<headers>

<entity-body>

在上面的<request-URL>中,只允许使用ASCII 字符集的子集,该子集包括[a-zA-Z]、[0-9]、[- _ . ! ~ * ' ( )]、[; , / ? : @ & = + $]
,而不在该子集范围内的字符都需要进行编码,以%加上编码字节流的十六进制表示,以求经过编码后的URL 只包含上面ASCII 字符集子集中的字符。

字符进行编码后的形式类似于:%xx,其中的“xx”时编码后字节流的十六进制表示,举个例子:

对中文字“很好”进行URL 编码后(这里使用UTF-8)得到的是:%E5%BE%88%E5%A5%BD
对字符“&”进行URL 编码后(这里使用UTF-8)得到的是:%26

下面具体来说下哪些字符是需要被编码,为什么需要编码:

6.1. ASCII 控制字符

原因:这些字符是不可见的,所以,如果真的要使用这些字符,请先编码。

包括:ASCII 字符集中的00-1F 以及7F,对应的Unicode Code Point 是U+0000 - U+001F以及U+007F。

6.2. 非ASCII 字符

原因:HTTP 协议规定URL 中不能包含非ASCII 字符。

包括:Unicode Code Point 大于U+007F。

6.3. URL 保留字符

原因:URL 规范中,有一部分字符是有明确用途的,如果这些字符在不恰当的地方出现,可能会导致URL 格式错误,
所以,如果要使用这些保留字符作为URL 中的一部分,则需要将这些字符进行编码。

包括:[; , / ? : @ & = + $]。

例子:字符:在URL 格式中紧跟在协议类型后面的,比如https:,所以,如果我们的URL 中想要使用这个字符,我们的URL 应该类似于:
http://www.demo%3A1.com而不是http://www.demo:1.com,刚才例子中,由于第二个:是保留字而且不是URL 的保留用途,所以,需要编码。

6.4. 不安全字符

空格(Space)

原因:如果URL 中包含空格,会带来两个问题,
第一个是连续的空格将会被转化为一个空格,
第二个是使用application/x-www-form-urlencoded encrypt 类型的Post 请求时,body 参数中的空格将会被转化为字符+
也就是从码点U+0020 编程了U+002B
可能上面的行为并不是发起请求的人所期望的,所以,建议对空格进行编码。

" < >

原因:上面这些字符一般用于在字符串将URL 与其他字符隔开,所以将这些字符进行编码,避免分隔符过早结束。

例子:在一段字符串中包含以下文本:"Cold Play 新专可以在这个链接看到 http://www.a.com/index.html#name="coldplay,真的吗,好开心!",这段字符串的结果的双引号""会提前到name=之后就结束了,但预期是到最后才结束双引号匹配,所以,这是就需要对URL 进行编码:
"Cold Play 新专可以在这个链接看到 http://www.a.com/index.html#name=%22coldplay,真的吗,好开心!"

#

原因:该字符在URL 格式中用于定义URL 片段(fragment,或者叫hash)的分界,这个fragment 在SPA 中由其重要,所以,为了避免程序获取的fragment 错误,
最好是将字符#编码。

例子:对于URLhttp://a.com/in#dex.html#mail,预期中的fragment 是mail,但如果不对第一个#进行编码,会导致拿到的fragment 是dex.html#mail,这就明显错误了,所以,看编码后正确的用法是:http://a.com/in%23dex.html#mail

%

原因:这个字符是用来表示URL 字符编码前缀的,所以,如果URL 中出现%,后端decode 时会尝试拿该字符后面的两位进行decode,此时如果该字符后面不是紧接字节流的十六进制表示的数字,可能会decode 出错,即使没decode 出错,但decode 出来的结果可能不是预期的,毕竟,此时我不一定想要进行decode,只是刚好字符%后面带了两个十六进制数字而已。

其他字符

还有一些字符,根据过往经验,也是建议对其进行编码的,包括:

[{ } | \ ^ ~ [ ] `]

7. Javascript 与URL 编码

上文提到,为了让应用发起的请求报文的行为符合预期,我们需要按HTTP 协议规定来编码,尤其是URL 以及post data 部分的编码。

Javascript 提供两个方法来对URL 中的字符进行编码,分别是:encodeURI()encodeURIComponent()

这两个方法用途都是对URL 字符串进行UTF-8 编码,并用%作为十六进制字节流的前缀,目的是让编码后的URL 只包含合法字符,这里的合法字符指的是以下字符范围中字符:

[a-zA-Z]、[0-9]、[- _ . ! ~ * ' ( )]、[; , / ? : @ & = + $]

下面讨论下这两个方法不同之处。

7.1. 不同的编码范围

  • encodeURI() 不对这些字符进行编码:[a-zA-Z][0-9][- _ . ! ~ * ' ( )][; , / ? : @ & = + $]
  • encodeURIComponent() 则不对这些字符进行编码:[a-zA-Z][0-9][- _ . ! ~ * ' ( )]

可以看出,两个方法的根本差别在与如何对待这些字符:[; , / ? : @ & = + $]

7.2. 不同的使用场景

针对整个URL 的编码使用encodeURI(),避免一些保留字符被错误编码,比如:

> encodeURI("https://github.com/SamHwang1990/blog/iss ues/1");
"https://github.com/SamHwang1990/blog/iss%20ues/1"

如果是针对URL 参数进行编码,可建议使用encodeURIComponent(),URL 参数的形式类似于[key]=[value]
而这个场景中提到的参数编码主要是针对[value]部分,意思就是,建议对用户提交上来的任何表单参数都进行编码,避免用户提交上来的数据中包含某些特殊字符导致最终请求的参数错误,比如:
用户通过表单提交了值“Thyme &time=again” 给参数“comment”,如果不使用encodeURIComponent()进行编码,会导致最终拿到两个URL 参数,分别是“comment=Thyme”和“time=again”,
但预期的行为是只有一个参数,这个参数的key 是“comment”,值是“Thyme &time=again”,而如果用encodeURIComponent()来对用户提交的值进行编码,就能得到一个预期的URL 参数:comment=Thyme%20%26time%3Dagain

最后,再提醒两点:

首先,使用application/x-www-form-urlencoded encrypt 类型的Post 请求时,body 参数中的空格将会被转化为字符+,所以,如果真的需要空格,请进行URL Encode;

另外,在 RFC 3986中将这些字符添加到URL 保留字符中了:[! ' ( ) *],所以,为了保持对该版本规范的兼容性,也建议对这些字符进行编码。

function fixedEncodeURIComponent(str) {
    return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
    return '%' + c.charCodeAt(0).toString(16);
  });
}

8. HTML 字符实体

说到编码,就不得不提下HTML 字符实体了。

字符实体有两种形式,一种称为实体名称,一种称为实体编号。

实体名称大概就是为HTML 中支持的字符实体另外起多一个名字,比如,字符“∀”的实体名称是“∀”,
空格的实体名称是“ ”。

实体编号是使用字符的Unicode Code Point 来代替HTML 中的字符,编号的组成形式:
'&#' + Unicode Code Point的十进制数字 + ';'或者 '&#x' +Unicode Code Point的十六进制数字 + ';' 。
举个例子:字符“∀”的字符实体编码是“∀”或者“&#x2200”。

HTML 支持的字符实体范围是有限的,详见:HTML ISO-8859-1 参考手册

个人感觉,字符实体的好处有两个:

  • 为不容易通过键盘键入的符号提供了方便的表达方法;
  • 方便Javascript 根据字符Code Unit 或者Code Point 插入字符到HTML 文档中;

9. Javascript 进制转换

上文多次提到十六进制、十进制等整数类型,于是,本节列举一下Javascript 中的进制转换的方法。

9.1. 十进制转换为其他进制

这里使用方法Int.toString(mode),在十进制的整数上调用该方法,并传入目标进制,就能将十进制的整数转换为目标进制数字的字符串形式,举个例子:

> var tempInt = 8704
undefined
> tempInt.toString(16)
"2200"
> tempInt.toString(8)
"21000"
> tempInt.toString(2)
"10001000000000"

9.2. 其他进制转换为十进制

这里使用方法parseInt(val, mode),该方法是全局方法,参数val是要转换的其他进制数字的字符串形式,参数mode用于声明参数val的当前进制模式,默认的mode 是10,而好的使用方法是总是显式声明当前进制,举个例子:

> parseInt('2200', 16)
8704
> parseInt('2200', 8)
1152
> parseInt('2200', 10)
2200

10. 参考资料

全文终!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant