1. 字符的 Unicode 表示法

unicode 是一个字符集,包含了世界上几乎所有的字符,并且为每个字符分配一个唯一的码点,unicode 的出现是为了能在计算机上更好的处理多国家的语言文字。unicode 每年都还在更新,每年都会加入很多新的字符。广义的 unicode 还包括了一系列的编码规则(UTF-8,UTF-16,UTF-32等等)。

JavaScript 有以下表示字符的方法

'\z' === 'z'  // true
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true

其中 JavaScript 允许采用 \uxxxx 形式表示一个字符,其中 xxxx 表示字符的 Unicode 码点

'\u0061' \\ a
'\u2210' \\ 

但是当表示的字符的 Unicode 码点超过 0xFFFF 的时候,也就是从第 65537 (2的16次方) 个开始, 就没办法正常表示字符了

'\u22104' \\ 4

// 采用这种方式可以正确表达字符
'\ud848\udd04' \\ 𢄄

而 ES6 中只要将码点放入大括号中,就能正确表示该字符

'\u{22104}' \\ 𢄄
'\u{61}\u{62}\u{63}' \\ abc

2. codePointAt()

JavaScript内部使用 utf-16 的格式储存字符,每个字符固定长度为 2 字节。而字符码点大于 0xFFFF 的字符,需要 4 个字节的空间,JavaScript会认为它们是两个字符

var a = '𢄄'
a.length // 2
a.charAt(0) // ''
a.charAt(1) // ''
a.charCodeAt(0) // 55368 
a.charCodeAt(1) // 56580

‘𢄄’ 的码点为 0x22104(十进制为 139524),其 UTF-16 编码为 0xd848 0xdd04(十进制为 55368 56580), 占用 4 个字节的储存空间,JavaScript 会误判这个字符长度为 2,charAt 方法无法读取整个字符,charCodeAt 也只能返回前两个字节和后两个字节的值。

ES6 提供了 codePointAt 方法,可以正确处理 4 个字节储存的字符

var a = '𢄄'
a.codePointAt(0) // 139524
a.codePointAt(0).toString(16) // 22104
a.codePointAt(1) // 56580

var b = '𢄄a'
a.codePointAt(0) // 139524
a.codePointAt(1) // 56580
a.codePointAt(2) // 97 a码点为 0x61 十进制为 97

通过上面的代码可以看到,字母 a 正确的序号位置应该是 1 才对的,然而,需要传给 codePointAt 方法的参数为 2 的时候才能获取到正确的十进制码点

解决这个问题可以使用 for…of 循环

var b = '𢄄a'
for (let ch of b) {
    console.log(ch.codePointAt(0).toString(16))
}

// 22104
// 61

codePointAt 方法还可以用来测试一个字符是由 2 个字符还是 4 个字符组成的

function is32Bit(str) {
    return str.codePointAt(0) > 0xFFFF
}

is32Bit('a') // false
is32Bit('𢄄') // false

3. String.fromCodePoint()

ES5 提供了 String.fromCharCode 方法用于从码点返回字符

String.fromCharCode(97) // a
String.fromCharCode(0x61) // a
String.fromCharCode(0x61, 0x62) // ab

String.fromCharCode(0x22104) // "℄"
String.fromCharCode(0x2104) // "℄"

但是当把这个方法用在 4 个字节长度的字符上的时候,就会出现错误了,返回的字符不是我们所期待的字符。这是因为 String.fromCharCode 方法不能识别大于 0xFFFF 的码点,所以 0x22104 就会发生溢出,最高位的 2 被舍弃了,所以最后返回的是码点 0x2104 的字符。

而 ES6 的 String.fromCodePoint 方法就是用来解决这个问题的

String.fromCodePoint(97) // a
String.fromCodePoint(0x22104) // 𢄄
String.fromCodePoint(0x22104, 97) // 𢄄a

4. 字符串的遍历器接口

ES6 为字符串添加了遍历器接口,使得字符串可以被 for…of 循环遍历

for (let codePoint of 'foo') {
    console.log(codePoint)
}

// 'f'
// 'o'
// 'o'

for…of 循环有个优点就是能正确识别 4 个字节长度的字符,而传统的 for 循环是没办法做到的

var b = '𢄄a'
for (let ch of b) {
    console.log(ch.codePointAt(0).toString(16))
}

// 22104
// 61

5. * at()

ES5 有个方法用于返回给定位置的字符,该方法同样不能识别大于 0xFFFF 的字符

'abc'.charAt(0) // a
'𢄄'.charAt(0) // ''

提案提出一个字符串实例的 at 方法,可以识别大于 0xFFFF 的字符

'abc'.at(0) // a
'𢄄'.at(0) // ''

6. normalize()

许多欧洲语言有语调符号和重音符号。为了表示它们,Unicode 提供了两种方法。一种是直接提供带重音符号的字符,比如Ǒ(\u01D1)。另一种是提供合成符号(combining character),即原字符与重音符号的合成,两个字符合成一个字符,比如O(\u004F)和ˇ(\u030C)合成Ǒ(\u004F\u030C)

这两种表示方法,在视觉和语义上都等价,但是 JavaScript 不能识别。

'\u01D1'==='\u004F\u030C' //false

'\u01D1'.length // 1
'\u004F\u030C'.length // 2

ES6 提供字符串实例的normalize()方法,用来将字符的不同表示方法统一为同样的形式,这称为 Unicode 正规化。

'\u01D1'.normalize() === '\u004F\u030C'.normalize() // true

normalize方法可以接受一个参数来指定normalize的方式,参数的四个可选值如下。

  • NFC,默认参数,表示“标准等价合成”(Normalization Form Canonical Composition),返回多个简单字符的合成字符。所谓“标准等价”指的是视觉和语义上的等价。
  • NFD,表示“标准等价分解”(Normalization Form Canonical Decomposition),即在标准等价的前提下,返回合成字符分解的多个简单字符。
  • NFKC,表示“兼容等价合成”(Normalization Form Compatibility Composition),返回合成字符。所谓“兼容等价”指的是语义上存在等价,但视觉上不等价,比如“囍”和“喜喜”。(这只是用来举例,normalize方法不能识别中文。)
  • NFKD,表示“兼容等价分解”(Normalization Form Compatibility Decomposition),即在兼容等价的前提下,返回合成字符分解的多个简单字符。
'\u004F\u030C'.normalize('NFC').length // 1
'\u004F\u030C'.normalize('NFD').length // 2

7.includes(), startsWith(), endsWith()

ES6 之前有个 indexOf 方法,用于确定一个字符串是否包含在另一个字符串中。

ES6 提供了三种新的方法

  • includes():返回布尔值,表示是否找到了参数字符串
  • startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
  • endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。
const str = 'Hello World!'

str.includes('o') // true
str.startsWith('Hello') // true
str.endsWidth('!') // true

三种方法都支持第二个参数,表示开始搜索的位置

str.startsWith('W', 6) // true
str.startsWith('W', 7) // false
str.endWith('!', 1) // true
str.includes('W', 7) // false

8. repeat()

repeat 将原字符串重复 n 次以后,返回一个新的字符串

'x'.repeat(3) // xxx

参数如果是小数,将取证

'x'.repeat(2.5) // xxx

如果参数是负数或者是 Infinity,会报错

'x'.repeat(Infinity) // RangeError
'x'.repeat(-1) // RangeError

如果参数是0到-1之间的话,取整为 0, 如果参数为 NaN,也等同于 0

'x'.repeat(-0.9) // ''
'x'.repeat(NaN) // ''
'x'.repeat(0) // ''

如果参数是字符串,则先转换成数字

'x'.repeat('x') // ''
'x'.repeat('3') // 333

9. padStart(),padEnd()

ES2017 引入了字符串补全的功能,如果某个字符不够指定长度,会在头部或者尾部不全。padStart 方法用于头部补全,padEnd 用于尾部补全

'x'.padStart(5, 'ab') // ababx
'x'.padEnd(5, 'ab') // xabab

上面代码中,第一个参数用来用来指定输出字符串的最小长度,如果长度大于或者等于指定的最小字符串,则返回原字符串

'xxxxx'.padStart(3, 'ab') // xxxxx
'xxxxx'.padEnd(3, 'ab') // xxxxx

如果用来补全的字符串和原字符串的和大于指定的最小字符串,则截去超出位数的补全字符串

'x'.padStart(6, 'ab') // ababax
'x'.padEnd(6, 'abcdef') // xabcde

第二参数用来指定补全的字符串,如果省略,则用空格补全

'x'.padStart(3) // '  x'
'x'.padEnd(3) // 'x  '

padStart 方法常见的有两个用途 一个是用来为数值补全制定位数

'1'.padStart(10, '0') // "0000000001"
'12'.padStart(10, '0') // "0000000012"
'123456'.padStart(10, '0') // "0000123456"

另一个用途是用于提示字符串格式

'11'.padStart('YYYY-MM-DD') // YYYY-MM-11
'10-11'.padStart('YYYY-MM-DD') // YYYY-10-11

10. 模版字符串

传统的方法,输出模版通常是这么写的

$('#article').html(
  '<div>' + 
    '<h1>' + post.title + '</h1>' + 
    '<p>' + post.content + '</p>' +
  '</div>'
)

这种写法真的太不方便了,看起来又杂乱无比。ES6 引入模版字符串,这个真的很好用

$('#article').html(`
  <div> 
    <h1>${post.title}</h1> 
    <p>${post.content}</p>
  </div>
`)

模版字符串其实就是增强版的字符串,用反引号(`)来标识 它可以当作普通字符串来使用

`this is text line 1`

也可以定义多行字符串

`this is text line 1
 this is text line 2`

最重要的是在字符串嵌入变量的方式

const name = 'kobe' 
`my name is ${name}`

如果在模版字符串里想要使用反引号,则需要用反斜杠定义

let str = `my name is \`kobe\``

模版字符串中的空格和换行都将保留,这里 标签前有个换行,可以使用 trim 方法去掉

$('#list').html(`
<ul>
  <li>first</li>
  <li>second</li>
</ul>
`.trim())

模版字符串变量名写在 ${} 之中

const name = 'kobe' 
`my name is ${name}`

大括号内可以进行运算,可以引用对象属性

let x = 1
let y = 2

`${x} + ${y} = ${x + y}` 

let obj = {x: 1, y: 2}
`${obj.x} + ${obj.y} * 2 = ${obj.x + obj.y * 2}`

模版字符串中还能调用函数

function f() {
  return 'Hello World!'
}
`foo ${f()} bar`

如果大括号中的值不是字符串,将按照一般的规则,使用 toString 方法转换为字符串

模版字符串可以嵌套

const tmpl = addrs => `
  <table>
    ${addrs.map(addr => `
      <tr><td>${addr.first}</td></tr>
      <tr><td>${addr.last}</td></tr>
    `).join('')}
  </table>
`
const data = [
  { first: '<Jane>', last: 'Bond' },
  { first: 'Lars', last: '<Croft>' },
]
tmpl(data)
// <table>
//   <tr><td><Jane></td></tr>
//   <tr><td>Bond</td></tr>

//   <tr><td>Lars</td></tr>
//   <tr><td><Croft></td></tr>

// </table>

如果需要引用模版字符串本身,在需要时执行,可以这样写

// 写法一 
let str = 'return `Hello ${name}!`'
let f = new Function('name', str)

// 写法二 
let str = 'name => `Hello ${name}`'
let f = eval.call(null, str)

11. 实例:模版编译

这里将演示一个通过模板字符串,生成正式模板的实例

let template = `
<ul>
  <% for (let i = 0; i < data.suppies.length; i++) { %>
    <li><%= data.suplies[i] %></li>
  <%} %>
</ul>
`

编译这个模板字符串的思路就是,将其先转换为如下的 JavaScript 表达式

echo('<ul>')
for (let i = 0; i < data.suppies.length; i++>) {
  echo('<li>')
  echo(data.suplies[i])
  echo('<li>')
}
echo('<ul>')

转换方法使用正则替换

let evalExpr = /<%=(.+?)%>/g
let expr = /<%([\s\S]+?)%>/g

template = template
  .replace(evalExpr, '`); \n echo( $1 ); \n echo(`')
  .replace(expr, '`); \n $1 \n echo(`')

template = 'echo(`' + template + '`);' 

然后,将 template 封装在函数里,然后返回这个函数即可

let script = `
(function parse(data) {
  let output = ''

  function echo(html) {
    output += html
  }

  ${ template }

  return output
})
`
return script

将上面的内容拼装起来,就是一个模板编译函数了,这个函数命名为 compile

function compile() {
  let evalExpr = /<%=(.+?)%>/g
  let expr = /<%([\s\S]+?)%>/g

  template = template
    .replace(evalExpr, '`); \n echo( $1 ); \n echo(`')
    .replace(expr, '`); \n $1 \n echo(`')

  template = 'echo(`' + template + '`);' 

  let script = `
  (function parse(data) {
    let output = ''

    function echo(html) {
      output += html
    }

    ${ template }

    return output
  })
  `
  return script
}

用法如下

let parse = eval(compile(template))
parse({ supplies: [ "broom", "mop", "cleaner" ] })
//   <ul>
//     <li>broom</li>
//     <li>mop</li>
//     <li>cleaner</li>
//   </ul>

12. 标签模板

模版字符串还可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串,这被称为“标签模板”功能(tagged template)

alert`123`
// 相当于
alert(123)

标签模板其实并不是模板,而是函数调用的一种特殊形式。“标签”指的是那个函数,紧跟在函数后面的模板字符串就是参数。

当模板字符串里有变量的时候,就不再只是简单的调用了,而是会将模板字符串处理成多个参数,然后在调用

let a = 1
let b = 3

tag`Hello ${a}  World! ${a+b}`
// 等同于
tag(['Hello ', ' World', ''], 1, 4)

函数 tag 会接收到多个参数

function tag(stringArr, value1, value2) {
  // ...
}

// 等同于

function tag(stringArr, ...value) {
  // ...
}

tag 函数的第一个参数是一个数组,成员是没有变量替换的部分,成员与成员之间是变量替换的部分

其它参数分别都是变量替换之后的值

所以, tag 函数所有参数实际的值如下

  • 第一个参数: [‘Hello ‘, ' World’, ‘']
  • 第一个参数: 1
  • 第一个参数: 4

也就是说,tag 函数实际上以下面的形式调用

tag(['Hello ', ' World', ''], 1, 4)

模板处理函数的第一个参数(模板字符串数组),还有一个 raw 属性

console.log`123`;
// ['123', raw: Array[1]]

上面的代码中,console.log 实际接受的参数实际上是一个数组。该数组有一个 raw 属性,保存的是转义后的原字符串。

再看一个例子加深了解

tag`First line\nSecond line`

function tag(strings) {
  console.log(strings.raw[0]);
  // strings.raw[0] 为 "First line\\nSecond line"
  // 打印输出 "First line\nSecond line"
}

上面代码中,tag 函数的第一个参数 strings,有一个 raw 属性,也指向一个数组。该数组的成员和 strings 数组是完全一致的。比如 strings 数组是[“First line\nSecond line”],那么 strings.raw 数组就是[“First line\nSecond line”]。两者唯一的区别是,后者的字符串里面的斜杠都被转义了。这个属性就是为了取得转义之前的原始模板而设计的。

下面是另一个复杂点的例子,将展示如何

let total = 30
let msg = passthru`The total is ${total}(${total * 1.05} with tax)`

function passthru(literals) {
  let result = ''
  let i = 0

  while (i < literals.length) {
    result += literals[i++]
    if (i < arguments.length) {
      result += arguments[i]
    }
  }

  return result
}

msg // "The total is 30 (31.5 with tax)"

上面的例子采用 rest 参数的写法如下

function passthru(literals, ...values) {
  let result = ''
  let index = 0

  for (index = 0; index < values.length; index++) {
    result += literals[index] + values[index]
  }

  result += literals[index]

  return result
}

标签模板的一个重要应用,就是过滤 HTML 字符串,防止用户输入恶意内容。这个例子中函数只过滤变量中的字符串,因为变量往往就是用户提供的。

let message = SaferHTML`<p>${sender} has sent you a message.</p>`

function SaferHTML(templateData) {
  let s = templateData[0]

  for (let i = 1; i < arguments.length; i++) {
    let arg = String(arguments[i])

    s += arg
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')

    s += templateData[i]
  }
  return s
}

let sender = '<script>alert("abc")</script>'; // 恶意代码
let message = SaferHTML`<p>${sender} has sent you a message.</p>`;

message
// <p>&lt;script&gt;alert("abc")&lt;/script&gt; has sent you a message.</p>

13. String.raw()

ES6 为原生的 String 对象提供了一个 raw 方法

String.raw 往往作为模板字符串的处理函数,用于返回一个斜杠都被转义(即斜杠前再加一个斜杠)的字符串,对应于替换变量后的模板字符串

String.raw`Hi\n${2+3}`
// "Hi\\n5!"

String.raw`Hi\u000A!`
// 'Hi\\u000A!'

如果原字符串的斜杠已经被转义,那么 String.raw 方法将不做任何处理

String.raw`Hi\\n`
// "Hi\\n"

String.raw 也可以作为正常的函数使用。它的第一个参数应该有个 raw 属性,该属性的值应该是一个数组

String.raw({ raw: 'test' }, 0, 1, 2);
// 't0e1s2t'

// 等同于
String.raw({ raw: ['t','e','s','t'] }, 0, 1, 2);