ECMAScript 6入门学习(一)

自定义标题

ECMAScript 6简介

ECMAScript是一种由Ecma国际(前身为欧洲计算机制造商协会,European Computer Manufacturers Association)通过ECMA-262标准化的脚本程序设计语言。这种语言在万维网上应用广泛,它往往被称为JavaScript或JScript,所以它可以理解为是JavaScript的一个标准,但实际上后两者是ECMA-262标准的实现和扩展。

ECMAScript 6.0(以下简称 ES6)是 JavaScript 语言的下一代标准。ES6的目标是是JavaScript语言可用来编写成复杂的大型应用程序,称为企业级开发语言。

Node.js

Node.js 是 JavaScript 的服务器运行环境(runtime)。它对 ES6 的支持度更高。除了那些默认打开的功能,还有一些语法功能已经实现了,但是默认没有打开。使用下面的命令,可以查看 Node.js 默认没有打开的 ES6 实验性语法。

// Linux & Mac
$ node --v8-options | grep harmony

// Windows
$ node --v8-options | findstr harmony

Babel 转码器

Babel 是一个广泛使用的 ES6 转码器,可以将 ES6 代码转为 ES5 代码,从而在老版本的浏览器执行。这意味着,你可以用 ES6 的方式编写程序,又不用担心现有环境是否支持。下面是一个例子。

// 转码前
input.map(item => item + 1);

// 转码后
input.map(function (item) {
  return item + 1;
});

上面的原始代码用了箭头函数,Babel 将其转为普通函数,就能在不支持箭头函数的 JavaScript 环境执行了。

下面的命令在项目目录中,安装 Babel。

$ npm install --save-dev @babel/core

配置文件.babelrc

Babel 的配置文件是.babelrc,存放在项目的根目录下。使用 Babel 的第一步,就是配置这个文件。

该文件用来设置转码规则和插件,基本格式如下。

{
  "presets": [],
  "plugins": []
}

presets字段设定转码规则,官方提供以下的规则集,你可以根据需要安装。

# 最新转码规则
$ npm install --save-dev @babel/preset-env

# react 转码规则
$ npm install --save-dev @babel/preset-react

然后,将这些规则加入.babelrc。

  {
    "presets": [
      "@babel/env",
      "@babel/preset-react"
    ],
    "plugins": []
  }

注意,以下所有 Babel 工具和模块的使用,都必须先写好.babelrc

命令行转码

Babel 提供命令行工具@babel/cli,用于命令行转码。

安装命令

// 安装命令
$ npm install --save-dev @babel/cli$ npm install --save-dev @babel/cli

基本用法

// 基本用法
# 转码结果输出到标准输出
$ npx babel example.js

# 转码结果写入一个文件
# --out-file 或 -o 参数指定输出文件
$ npx babel example.js --out-file compiled.js
# 或者
$ npx babel example.js -o compiled.js

# 整个目录转码
# --out-dir 或 -d 参数指定输出目录
$ npx babel src --out-dir lib
# 或者
$ npx babel src -d lib

# -s 参数生成source map文件
$ npx babel src -d lib -s

babel-node

@babel/node 模块的 babel-node 命令,提供一个支持 ES6 的 REPL 环境。它支持 Node 的 REPL 环境的所有功能,而且可以直接运行 ES6 代码。

$ npm install --save-dev @babel/node

然后,执行babel-node就进入 REPL 环境。

$ npx babel-node
> (x => x * 2)(1)
2

babel-node命令可以直接运行 ES6 脚本。将上面的代码放入脚本文件es6.js,然后直接运行。

# es6.js 的代码
# console.log((x => x * 2)(1));
$ npx babel-node es6.js
2

@babel/register 模块

@babel/register模块改写require命令,为它加上一个钩子。此后,每当使用require加载.js、.jsx、.es和.es6后缀名的文件,就会先用 Babel 进行转码。

$ npm install --save-dev @babel/register

使用时,必须首先加载@babel/register。

// index.js
require('@babel/register');
require('./es6.js');

然后,就不需要手动对index.js转码了。

$ node index.js

需要注意的是,@babel/register只会对require命令加载的文件转码,而不会对当前文件转码。另外,由于它是实时转码,所以只适合在开发环境使用。

babel API

如果某些代码需要调用 Babel 的 API 进行转码,就要使用@babel/core模块。

var babel = require('@babel/core');

// 字符串转码
babel.transform('code();', options);
// => { code, map, ast }

// 文件转码(异步)
babel.transformFile('filename.js', options, function(err, result) {
  result; // => { code, map, ast }
});

// 文件转码(同步)
babel.transformFileSync('filename.js', options);
// => { code, map, ast }

// Babel AST转码
babel.transformFromAst(ast, code, options);
// => { code, map, ast }

配置对象options,可以参看官方文档http://babeljs.io/docs/usage/options/

@babel/polyfill

Babel 默认只转换新的 JavaScript 句法(syntax),而不转换新的 API,比如Iterator、Generator、Set、Map、Proxy、Reflect、Symbol、Promise等全局对象,以及一些定义在全局对象上的方法(比如Object.assign)都不会转码。

举例来说,ES6 在Array对象上新增了Array.from方法。Babel 就不会转码这个方法。如果想让这个方法运行,必须使用babel-polyfill,为当前环境提供一个垫片。

安装命令:$ npm install --save-dev @babel/polyfill

然后,在脚本头部,加入如下一行代码。

import '@babel/polyfill';
// 或者
require('@babel/polyfill');

Babel 默认不转码的 API 非常多,详细清单可以查看babel-plugin-transform-runtime模块的definitions.js文件。

浏览器环境

Babel 也可以用于浏览器环境,使用@babel/standalone模块提供的浏览器版本,将其插入网页。

<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
// Your ES6 code
</script>

注意,网页实时将 ES6 代码转为 ES5,对性能会有影响。生产环境需要加载已经转码完成的脚本。

Babel 提供一个REPL 在线编译器,可以在线将 ES6 代码转为 ES5 代码。转换后的代码,可以直接作为 ES5 代码插入网页运行。


let 命令

用途:声明变量

用法:与var类似,但是所声明的变量,只在let命令所在的代码块内有效。

使用场景:for循环的计数器,计数器i只在循环体内有效,另外,for循环的一个特别之处在于设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

let命令不存在变量提升,一定要先声明后使用。
变量提升:变量可以在声明之前使用,值为underfined。

暂时性死区(temporal dead zone,简称 TDZ)
定义:ES6 明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

因此,在代码块内,使用let命令声明变量之前,该变量都是不可用的。只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

let不允许在相同作用域内,重复声明同一个变量。


块级作用域

ES5 只有全局作用域和函数作用域,没有块级作用域,但是let实际上是为JavaScript新增了块级作用域。

没有块级作用域带来的不合理场景:

  1. 内层变量可能会覆盖外层变量。

  2. 用来计数的循环变量泄露为全局变量。

function f1() {
  let n = 5;
  if (true) {
    let n = 10;
  }
  console.log(n); // 5
}

上面函数的两个代码块中都声明了变量n,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用var定义变量n,最后输出的值才是 10。

ES6 允许块级作用域的任意嵌套,较外层作用域无法读取较内层作用域的内部变量,内层作用域可以定义外层作用域的同名变量。

块级作用域与函数声明
ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。

为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。

  • 允许在块级作用域内声明函数。
  • 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
  • 同时,函数声明还会提升到所在的块级作用域的头部

考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。

// 块级作用域内部的函数声明语句,建议不要使用
{
  let a = 'secret';
  function f() {
    return a;
  }
}

// 块级作用域内部,优先使用函数表达式
{
  let a = 'secret';
  let f = function () {
    return a;
  };
}

ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。

严格模式下,函数只能声明在当前作用域的顶层。


const 命令

用法:声明一个只读的常量。一旦声明,常量的值就不能改变,即一旦声明变量,就必须初始化。

与let命令相同:

  • 只在声明所在的块级作用域内有效。
  • const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
  • const声明的常量,不可重复声明。

本质
const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。
对于简单类型的数据(数值、字符串、布尔值),变量指向的是保存值的地址,等同于常量。
对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。


变量和解构赋值

解构: ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

数组的解构赋值

// 以前,为变量赋值只能直接指定值
let a=1;
let b=2;
// ES6 允许 
let [a,b,c] = [1,2,3];
// 从数组中提取值,按照对应位置,对变量赋值

本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。对应位置进行赋值。
如果解构不成功,变量的值就等于 underfined。

不完全解构:即等号左边的模式,只匹配一部分的等号右边的数组。(可以成功)

默认值

let [foo = true] = [];  // foo = true
let [x, y = 'b'] = ['a']; // x='a', y='b'
let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'

解构赋值允许指定默认值。
注意,ES6 内部使用严格相等运算符(===),判断一个位置是否有值。所以,只有当一个数组成员严格等于undefined,默认值才会生效。
如: null 不严格等于 undefined,所以当数组成员是 null 时,默认值不生效。

默认值是表达式时,只有在用到的时候才会求值。

对象的解构赋值

对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。

let { bar, foo } = { foo: 'aaa', bar: 'bbb' };
foo // "aaa"
bar // "bbb"

let { baz } = { foo: 'aaa', bar: 'bbb' };
baz // undefined
// 没有对应的同名属性,导致取不到值,最后等于 undefined

如果解构失败,变量的值等于 undefined 。

let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"
foo // error: foo is not defined

上面代码中,foo是匹配的模式,baz才是变量。真正被赋值的是变量baz,而不是模式foo。

解构也可以用于嵌套结构的对象。

let obj = {};
let arr = [];

({ foo: obj.prop, bar: arr[0] } = { foo: 123, bar: true });

obj // {prop:123}
arr // [true]

如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。

默认值
与数组相同,对象的解构也可以指定默认值。

条件:对象的属性值严格等于undefined。因为null与undefined不严格相等,所以是个有效的赋值,导致默认值不会生效。

注意

  1. 如果要将一个已经声明的变量用于解构赋值,必须非常小心。
    只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能避免发生语法错误。
  2. 解构赋值允许等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。虽然毫无意义,但是是合法的,可执行的。
  3. 由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。

字符串的解构赋值

字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。

类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值

const [a, b, c, d, e] = 'hello';
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"

let {length : len} = 'hello';
len // 5

数值和布尔值的解构赋值

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined和null无法转为对象,所以对它们进行解构赋值,都会报错。

函数参数的解构赋值

函数的参数也可以使用解构赋值,下面是两个例子

function add([x, y]){
  return x + y;
}
add([1, 2]); // 3

[[1, 2], [3, 4]].map(([a, b]) => a + b);
// [ 3, 7 ]

函数add的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x和y。对于函数内部的代码来说,它们能感受到的参数就是x和y。

默认值

// 函数move的参数是一个对象,通过对这个对象进行解构,得到变量x和y的值。如果解构失败,x和y等于默认值。
function move({x = 0, y = 0} = {}) {
  return [x, y];
}

move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, 0]
move({}); // [0, 0]
move(); // [0, 0]

undefined就会触发函数参数的默认值。

[1, undefined, 3].map((x = 'yes') => x);
// [ 1, 'yes', 3 ]

圆括号

解构赋值虽然方便,但是解析起来很不容易。关于圆括号怎么处理,ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。

但是,这条规则实际上不那么容易辨别,处理起来相当麻烦。因此,建议只要有可能,就不要在模式中放置圆括号。

  • 不使用圆括号的情况
    1.变量声明语句
    2.函数参数(函数参数也属于变量声明,因此不能带有圆括号。)
    3.赋值语句的模式

  • 可以使用圆括号的情况
    可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。

变量的解构赋值用途

1,交换变量的值

let x = 1;
let y = 2;

[x, y] = [y, x];

2,从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取值就变得方便。

// 返回一个数组

function example() {
  return [1, 2, 3];
}
let [a, b, c] = example();

// 返回一个对象

function example() {
  return {
    foo: 1,
    bar: 2
  };
}
let { foo, bar } = example();

3,函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来

4,提取 JSON 数据

let jsonData = {
  id: 42,
  status: "OK",
  data: [867, 5309]
};

let { id, status, data: number } = jsonData;

console.log(id, status, number);
// 42, "OK", [867, 5309]

上面代码可以快速提取 JSON 数据的值。

5,函数参数的默认值
指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || ‘default foo’;这样的语句。

6,遍历 Map 结构
任何部署了 Iterator 接口的对象,都可以用for…of循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。

const map = new Map();
map.set('first', 'hello');
map.set('second', 'world');

for (let [key, value] of map) {
  console.log(key + " is " + value);
}
// first is hello
// second is world

7,输入模块的指定方法
加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。

const { SourceMapConsumer, SourceNode } = require("source-map");

字符串的扩展

字符的unicode 表示法

ES6 加强了对 Unicode 的支持,允许采用\uxxxx形式表示一个字符,其中xxxx表示字符的 Unicode 码点。

但是,这种表示法只限于码点在\u0000~\uFFFF之间的字符。超出这个范围的字符,必须用两个双字节的形式表示。

"\uD842\uDFB7"
// "𠮷"

"\u20BB7"
// " 7"

上面代码表示,如果直接在\u后面跟上超过0xFFFF的数值(比如\u20BB7),JavaScript 会理解成\u20BB+7。由于\u20BB是一个不可打印字符,所以只会显示一个空格,后面跟着一个7。

ES6 对这一点做出了改进,只要将码点放入大括号,就能正确解读该字符。

"\u{20BB7}"
// "𠮷"

"\u{41}\u{42}\u{43}"
// "ABC"

let hello = 123;
hell\u{6F} // 123

'\u{1F680}' === '\uD83D\uDE80'
// true

上面代码中,最后一个例子表明,大括号表示法与四字节的 UTF-16 编码是等价的。

有了这种表示法之后,JavaScript 共有 6 种方法可以表示一个字符。

'\z' === 'z'  // true
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true
'\u{7A}' === 'z' // true

字符串的遍历器接口

ES6 为字符串添加了遍历器接口(详见《Iterator》一章),使得字符串可以被for…of循环遍历

for (let codePoint of 'foo') {
  console.log(codePoint)
}
// "f"
// "o"
// "o"

除了遍历字符串,这个遍历器最大的优点是可以识别大于0xFFFF的码点,传统的for循环无法识别这样的码点。

let text = String.fromCodePoint(0x20BB7);

for (let i = 0; i < text.length; i++) {
  console.log(text[i]);
}
// " "
// " "

for (let i of text) {
  console.log(i);
}
// "𠮷"

上面代码中,字符串text只有一个字符,但是for循环会认为它包含两个字符(都不可打印),而for…of循环会正确识别出这一个字符。

直接输入 U+2028 和 U+2029

JavaScript 字符串允许直接输入字符,以及输入字符的转义形式。举例来说,“中”的 Unicode 码点是 U+4e2d,你可以直接在字符串里面输入这个汉字,也可以输入它的转义形式\u4e2d,两者是等价的。

但是,JavaScript 规定有5个字符,不能在字符串里面直接使用,只能使用转义形式。

  1. U+005C:反斜杠(reverse solidus)
  2. U+000D:回车(carriage return)
  3. U+2028:行分隔符(line separator)
  4. U+2029:段分隔符(paragraph separator)
  5. U+000A:换行符(line feed)

举例来说,字符串里面不能直接包含反斜杠,一定要转义写成\或者\u005c

这个规定本身没有问题,麻烦在于 JSON 格式允许字符串里面直接使用 U+2028(行分隔符)和 U+2029(段分隔符)。这样一来,服务器输出的 JSON 被JSON.parse解析,就有可能直接报错。

const json = '"\u2028"';
JSON.parse(json); // 可能报错

JSON 格式已经冻结(RFC 7159),没法修改了。为了消除这个报错,ES2019 允许 JavaScript 字符串直接输入 U+2028(行分隔符)和 U+2029(段分隔符)

const PS = eval("'\u2029'");

根据这个提案,上面的代码不会报错。

注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为 JSON 本来就不允许直接包含正则表达式。

模版字符串

对比传统 JavaScript 语言

// 传统 JavaScript 语言
$('#result').append(
  'There are <b>' + basket.count + '</b> ' +
  'items in your basket, ' +
  '<em>' + basket.onSale +
  '</em> are on sale!'
);

// ES6引入模版字符串
$('#result').append(`
  There are <b>${basket.count}</b> items
   in your basket, <em>${basket.onSale}</em>
  are on sale!
`);

模板字符串(template string)是增强版的字符串,用反引号(`)标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。

// 普通字符串
`In JavaScript '\n' is a line-feed.`

// 多行字符串
`In JavaScript this is
 not legal.`

console.log(`string text line 1
string text line 2`);

// 字符串中嵌入变量
let name = "Bob", time = "today";
`Hello ${name}, how are you ${time}?`// 普通字符串
`In JavaScript '\n' is a line-feed.`

// 多行字符串
`In JavaScript this is
 not legal.`

console.log(`string text line 1
string text line 2`);

// 字符串中嵌入变量
let name = "Bob", time = "today";
`Hello ${name}, how are you ${time}?`

上面代码中的模板字符串,都是用反引号表示。如果在模板字符串中需要使用反引号,则前面要用反斜杠转义。

如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中

上面代码中,所有模板字符串的空格和换行,都是被保留的,比如

  • 标签前面会有一个换行。 如果你不想要这个换行,可以使用trim方法消除它

// 使用模板字符串表示多行字符串
$('#list').html(`
<ul>
  <li>first</li>
  <li>second</li>
</ul>
`);

// 使用trim方法删除消除换行
$('#list').html(`
<ul>
  <li>first</li>
  <li>second</li>
</ul>
`.trim());
  • 模板字符串中嵌入变量,需要将变量名写在 ${} 之中。
    大括号内部可以放入任意的 JavaScript 表达式,可以进行运算,以及引用对象属性。
function authorize(user, action) {
  if (!user.hasPrivilege(action)) {
    throw new Error(
      // 传统写法为
      // 'User '
      // + user.name
      // + ' is not authorized to do '
      // + action
      // + '.'
      `User ${user.name} is not authorized to do ${action}.`);
  }
}
  • 模板字符串之中还能调用函数。
    如果大括号中的值不是字符串,将按照一般的规则转为字符串。比如,大括号中是一个对象,将默认调用对象的toString方法

    如果模板字符串中的变量没有声明,将报错。

function fn() {
  return "Hello World";
}

`foo ${fn()} bar`
// foo Hello World bar

由于模板字符串的大括号内部,就是执行 JavaScript 代码,因此如果大括号内部是一个字符串,将会原样输出

  • 模板字符串甚至还能嵌套。
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>' },
];

console.log(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 func = (name) => `Hello ${name}!`;
func('Jack') // "Hello Jack!"

标签模版

“标签模板”功能(tagged template):
模板字符串的功能,不仅仅是上面这些。它可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串。

alert`hello`
// 等同于
alert(['hello'])

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

如果模板字符里面有变量,就不是简单的调用了,而是会将模板字符串先处理成多个参数,再调用函数。

例子1

let a = 5;
let b = 10;

tag`Hello ${ a + b } world ${ a * b }`;
// 等同于
tag(['Hello ', ' world ', ''], 15, 50);

上面代码中,模板字符串前面有一个标识名tag,它是一个函数。整个表达式的返回值,就是tag函数处理模板字符串后的返回值。

函数tag依次会接收到多个参数。

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

// 等同于

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

tag函数的第一个参数是一个数组,该数组的成员是模板字符串中那些没有变量替换的部分,也就是说,变量替换只发生在数组的第一个成员与第二个成员之间、第二个成员与第三个成员之间,以此类推。

tag函数的其他参数,都是模板字符串各个变量被替换后的值。由于本例中,模板字符串含有两个变量,因此tag会接受到value1和value2两个参数。

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

第一个参数:['Hello ', ’ world ', ‘’]
第二个参数: 15
第三个参数:50
也就是说,tag函数实际上以下面的形式调用。

tag(['Hello ', ' world ', ''], 15, 50)

我们可以按照需要编写tag函数的代码。下面是tag函数的一种写法,以及运行结果。

let a = 5;
let b = 10;

function tag(s, v1, v2) {
  console.log(s[0]);
  console.log(s[1]);
  console.log(s[2]);
  console.log(v1);
  console.log(v2);

  return "OK";
}

tag`Hello ${ a + b } world ${ a * b}`;
// "Hello "
// " world "
// ""
// 15
// 50
// "OK"

模板字符串的限制

前面提到标签模板里面,可以内嵌其他语言。但是,模板字符串默认会将字符串转义,导致无法嵌入其他语言

模板字符串会将\u00FF和\u{42}当作 Unicode 字符进行转义,所以\unicode解析时报错;而\x56会被当作十六进制字符串转义,所以\xerxes会报错。也就是说,\u和\x在 LaTEX 里面有特殊含义,但是 JavaScript 将它们转义了。

为了解决这个问题,ES2018 放松了对标签模板里面的字符串转义的限制。如果遇到不合法的字符串转义,就返回undefined,而不是报错,并且从raw属性上面可以得到原始字符串。

function tag(strs) {
  strs[0] === undefined
  strs.raw[0] === "\\unicode and \\u{55}";
}
tag`\unicode and \u{55}`

上面代码中,模板字符串原本是应该报错的,但是由于放松了对字符串转义的限制,所以不报错了,JavaScript 引擎将第一个字符设置为undefined,但是raw属性依然可以得到原始字符串,因此tag函数还是可以对原字符串进行处理。

注意,这种对字符串转义的放松,只在标签模板解析字符串时生效,不是标签模板的场合,依然会报错。

let bad = `bad escape sequence: \unicode`; // 报错

字符串的新增方法

1. String.fromCodePoint()

String.fromCodePoint() : 从 Unicode 码点返回对应字符

ES6 提供了String.fromCodePoint()方法,可以识别大于0xFFFF的字符,弥补了String.fromCharCode()方法的不足。在作用上,正好与下面的codePointAt()方法相反。

String.fromCodePoint(0x20BB7)
// "𠮷"
String.fromCodePoint(0x78, 0x1f680, 0x79) === 'x\uD83D\uDE80y'
// true

上面代码中,如果String.fromCodePoint方法有多个参数,则它们会被合并成一个字符串返回。

注意,fromCodePoint方法定义在String对象上,而codePointAt方法定义在字符串的实例对象上。

2. String.raw() :

String.raw() : 返回一个斜杠都被转义(即斜杠前面再加一个斜杠)的字符串,往往用于模板字符串的处理方法。

如果原字符串的斜杠已经转义,那么String.raw()会进行再次转义。

String.raw()本质上是一个正常的函数,只是专用于模板字符串的标签函数。如果写成正常函数的形式,它的第一个参数,应该是一个具有raw属性的对象,且raw属性的值应该是一个数组,对应模板字符串解析后的值。

// `foo${1 + 2}bar`
// 等同于
String.raw({ raw: ['foo', 'bar'] }, 1 + 2) // "foo3bar"

上面代码中,String.raw()方法的第一个参数是一个对象,它的raw属性等同于原始的模板字符串解析后得到的数组。

作为函数,String.raw()的代码实现基本如下。

String.raw = function (strings, ...values) {
  let output = '';
  let index;
  for (index = 0; index < values.length; index++) {
    output += strings.raw[index] + values[index];
  }

  output += strings.raw[index]
  return output;
}

3. codePointAt()

codePointAt():能够正确处理 4 个字节储存的字符,返回一个字符的码点

let s = '𠮷a';

s.codePointAt(0) // 134071
s.codePointAt(1) // 57271

s.codePointAt(2) // 97

codePointAt()方法的参数,是字符在字符串中的位置(从 0 开始)。上面代码中,JavaScript 将“𠮷a”视为三个字符,codePointAt 方法在第一个字符上,正确地识别了“𠮷”,返回了它的十进制码点 134071(即十六进制的20BB7)。在第二个字符(即“𠮷”的后两个字节)和第三个字符“a”上,codePointAt()方法的结果与charCodeAt()方法相同。

总之,codePointAt()方法会正确返回 32 位的 UTF-16 字符的码点。对于那些两个字节储存的常规字符,它的返回结果与charCodeAt()方法相同。

let s = '𠮷a';

s.codePointAt(0).toString(16) // "20bb7"
s.codePointAt(2).toString(16) // "61"

上面代码中,字符a在字符串s的正确位置序号应该是 1,但是必须向codePointAt()方法传入 2。解决这个问题的一个办法是使用for…of循环,因为它会正确识别 32 位的 UTF-16 字符。

let s = '𠮷a';
for (let ch of s) {
  console.log(ch.codePointAt(0).toString(16));
}
// 20bb7
// 61

// 或者,使用扩展运算符(...)进行展开运算。
let arr = [...'𠮷a']; // arr.length === 2
arr.forEach(
  ch => console.log(ch.codePointAt(0).toString(16))
);
// 20bb7
// 61

codePointAt()方法是测试一个字符由两个字节还是由四个字节组成的最简单方法。

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

is32Bit("𠮷") // true
is32Bit("a") // false

4. normalize()

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),即在兼容等价的前提下,返回合成字符分解的多个简单字符。

不过,normalize方法目前不能识别三个或三个以上字符的合成。这种情况下,还是只能使用正则表达式,通过 Unicode 编号区间判断。

5. includes(), startsWith(), endsWith()

includes():返回布尔值,表示是否找到了参数字符串。
startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。

let s = 'Hello world!';

s.startsWith('Hello') // true
s.endsWith('!') // true
s.includes('o') // true


// 支持第二个参数,表示开始的位置
let s = 'Hello world!';

s.startsWith('world', 6) // true
s.endsWith('Hello', 5) // true
s.includes('Hello', 6) // false

上面代码表示,使用第二个参数n时,endsWith的行为与其他两个方法有所不同。它针对前n个字符,而其他两个方法针对从第n个位置直到字符串结束。

6. repeat()

repeat方法返回一个新字符串,表示将原字符串重复n次。

注意

  • 参数如果是小数,会被取整。
  • 如果repeat的参数是负数或者Infinity,会报错。
  • 如果参数是 0 到-1 之间的小数,则等同于 0,这是因为会先进行取整运算。0 到-1 之间的小数,取整以后等于-0,repeat视同为 0。
  • 参数NaN等同于 0
  • 如果repeat的参数是字符串,则会先转换成数字。

7. padStart(),padEnd() :补全长度

padStart():用于头部补全。
padEnd():用于尾部补全。

'x'.padStart(5, 'ab') // 'ababx'
'x'.padStart(4, 'ab') // 'abax'

'x'.padEnd(5, 'ab') // 'xabab'
'x'.padEnd(4, 'ab') // 'xaba'

上面代码中,padStart()和padEnd()一共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串。

注意

  • 如果原字符串的长度,等于或大于最大长度,则字符串补全不生效,返回原字符串。
  • 如果用来补全的字符串与原字符串,两者的长度之和超过了最大长度,则会截去超出位数的补全字符串。
  • 如果省略第二个参数,默认使用空格补全长度。

padStart()的常见用途是为数值补全指定位数。下面代码生成 10 位的数值字符串。另一个用途是提示字符串格式。

// 生成 10 位的数值字符串
'1'.padStart(10, '0') // "0000000001"
'12'.padStart(10, '0') // "0000000012"
'123456'.padStart(10, '0') // "0000123456"

//提示字符串格式
'12'.padStart(10, 'YYYY-MM-DD') // "YYYY-MM-12"
'09-12'.padStart(10, 'YYYY-MM-DD') // "YYYY-09-12"

8. trimStart(),trimEnd() :消除空格

trimStart()消除字符串头部的空格。
trimEnd()消除尾部的空格。

除了空格键,这两个方法对字符串头部(或尾部)的 tab 键、换行符等不可见的空白符号也有效。

浏览器还部署了额外的两个方法,trimLeft()trimStart()的别名,trimRight()trimEnd()的别名。

const s = '  abc  ';

s.trim() // "abc"
s.trimStart() // "abc  "
s.trimEnd() // "  abc"

浏览器还部署了额外的两个方法,trimLeft()trimStart()的别名,trimRight()trimEnd()的别名。

9. matchAll():返回正则表达式在当前字符串的所有匹配

matchAll():返回一个正则表达式在当前字符串的所有匹配


正则的扩展

RegExp 构造函数

在 ES5 中,RegExp构造函数的参数有两种情况。

  1. 参数是字符串,这时第二个参数表示正则表达式的修饰符(flag)。
  2. 参数是一个正则表示式,这时会返回一个原有正则表达式的拷贝。
// 参数是字符串
var regex = new RegExp('xyz', 'i');
// 等价于
var regex = /xyz/i;

// 参数是正则表达式
var regex = new RegExp(/xyz/i);
// 等价于
var regex = /xyz/i;

但是,ES5 不允许此时使用第二个参数添加修饰符,否则会报错。

ES6 改变了这种行为。如果RegExp构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。

new RegExp(/abc/ig, 'i').flags
// "i"

上面代码中,原有正则对象的修饰符是ig,它会被第二个参数i覆盖。

字符串的正则方法

字符串对象共有 4 个方法,可以使用正则表达式:match()replace()search()split()

ES6 将这 4 个方法,在语言内部全部调用RegExp的实例方法,从而做到所有与正则相关的方法,全都定义在RegExp对象上。

  • String.prototype.match 调用 RegExp.prototype[Symbol.match]
  • String.prototype.replace 调用 RegExp.prototype[Symbol.replace]
  • String.prototype.search 调用 RegExp.prototype[Symbol.search]
  • String.prototype.split 调用 RegExp.prototype[Symbol.split]

u 修饰符

ES6 对正则表达式添加了u修饰符,含义为“Unicode 模式”,用来正确处理大于\uFFFF的 Unicode 字符。也就是说,会正确处理四个字节的 UTF-16 编码。

一旦加上u修饰符号,就会修改下面这些正则表达式的行为。

(1) 点字符
点(.)字符在正则表达式中,含义是除了换行符以外的任意单个字符。对于码点大于0xFFFF的 Unicode 字符,点字符不能识别,必须加上u修饰符。

var s = '𠮷';

/^.$/.test(s) // false
/^.$/u.test(s) // true

上面代码表示,如果不添加u修饰符,正则表达式就会认为字符串为两个字符,从而匹配失败。

(2) Unicode 字符表示法
ES6 新增了使用大括号表示 Unicode 字符,这种表示法在正则表达式中必须加上u修饰符,才能识别当中的大括号,否则会被解读为量词。

/\u{61}/.test('a') // false
/\u{61}/u.test('a') // true
/\u{20BB7}/u.test('𠮷') // true

上面代码表示,如果不加u修饰符,正则表达式无法识别\u{61}这种表示法,只会认为这匹配 61 个连续的u。

(3) 量词
使用u修饰符后,所有量词都会正确识别码点大于0xFFFF的 Unicode 字符。

(4) 预定义模式
u修饰符也影响到预定义模式,能否正确识别码点大于0xFFFF的 Unicode 字符。

利用这一点,可以写出一个正确返回字符串长度的函数。

function codePointLength(text) {
  var result = text.match(/[\s\S]/gu);
  return result ? result.length : 0;
}

var s = '𠮷𠮷';

s.length // 4
codePointLength(s) // 2

(5) i 修饰符

有些 Unicode 字符的编码不同,但是字型很相近,比如,\u004B与\u212A都是大写的K。

/[a-z]/i.test('\u212A') // false
/[a-z]/iu.test('\u212A') // true

(6) 转义
没有u修饰符的情况下,正则中没有定义的转义(如逗号的转义,)无效,而在u模式会报错。

RegExp.prototype.unicode 属性

正则实例对象新增unicode属性,表示是否设置了u修饰符。

const r1 = /hello/;
const r2 = /hello/u;

r1.unicode // false
r2.unicode // true

y 修饰符

y修饰符,叫做“粘连”(sticky)修饰符。

y修饰符的作用与g修饰符类似,也是全局匹配,后一次匹配都从上一次匹配成功的下一个位置开始。不同之处在于,g修饰符只要剩余位置中存在匹配就可,而y修饰符确保匹配必须从剩余的第一个位置开始,这也就是“粘连”的涵义。

var s = 'aaa_aa_a';
var r1 = /a+/g;
var r2 = /a+/y;

r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]

r1.exec(s) // ["aa"]
r2.exec(s) // null

上面代码有两个正则表达式,一个使用g修饰符,另一个使用y修饰符。这两个正则表达式各执行了两次,第一次执行的时候,两者行为相同,剩余字符串都是_aa_a。由于g修饰没有位置要求,所以第二次执行会返回结果,而y修饰符要求匹配必须从头部开始,所以返回null

如果改一下正则表达式,保证每次都能头部匹配,y修饰符就会返回结果了。

var s = 'aaa_aa_a';
var r = /a+_/y;

r.exec(s) // ["aaa_"]
r.exec(s) // ["aa_"]

lastIndex属性

使用lastIndex属性,可以更好地说明y修饰符。

const REGEX = /a/g;

// 指定从2号位置(y)开始匹配
REGEX.lastIndex = 2;

// 匹配成功
const match = REGEX.exec('xaya');

// 在3号位置匹配成功
match.index // 3

// 下一次匹配从4号位开始
REGEX.lastIndex // 4

// 4号位开始匹配失败
REGEX.exec('xaya') // null

y修饰符同样遵守lastIndex属性,但是要求必须在lastIndex指定的位置发现匹配。

const REGEX = /a/y;

// 指定从2号位置开始匹配
REGEX.lastIndex = 2;

// 不是粘连,匹配失败
REGEX.exec('xaya') // null

// 指定从3号位置开始匹配
REGEX.lastIndex = 3;

// 3号位置是粘连,匹配成功
const match = REGEX.exec('xaya');
match.index // 3
REGEX.lastIndex // 4

y修饰符的设计本意,就是让头部匹配的标志^在全局匹配中都有效。

字符串对象的replace方法的例子。

const REGEX = /a/gy;
'aaxa'.replace(REGEX, '-') // '--xa'

上面代码中,最后一个a因为不是出现在下一次匹配的头部,所以不会被替换。

单单一个y修饰符对match方法,只能返回第一个匹配,必须与g修饰符联用,才能返回所有匹配。

'a1a2a3'.match(/a\d/y) // ["a1"]
'a1a2a3'.match(/a\d/gy) // ["a1", "a2", "a3"]

y修饰符的一个应用,是从字符串提取 token(词元),y修饰符确保了匹配之间不会有漏掉的字符。

const TOKEN_Y = /\s*(\+|[0-9]+)\s*/y;
const TOKEN_G  = /\s*(\+|[0-9]+)\s*/g;

tokenize(TOKEN_Y, '3 + 4')
// [ '3', '+', '4' ]
tokenize(TOKEN_G, '3 + 4')
// [ '3', '+', '4' ]

function tokenize(TOKEN_REGEX, str) {
  let result = [];
  let match;
  while (match = TOKEN_REGEX.exec(str)) {
    result.push(match[1]);
  }
  return result;
}

上面代码中,如果字符串里面没有非法字符,y修饰符与g修饰符的提取结果是一样的。

但是,一旦出现非法字符,两者的行为就不一样了。g修饰符会忽略非法字符,而y修饰符不会,这样就很容易发现错误。

RegExp.prototype.sticky 属性

sticky属性:表示是否设置了y修饰符。
返回值:布尔值。

RegExp.prototype.flags 属性

flags属性:会返回正则表达式的修饰符。

// ES5 的 source 属性
// 返回正则表达式的正文
/abc/ig.source
// "abc"

// ES6 的 flags 属性
// 返回正则表达式的修饰符
/abc/ig.flags
// 'gi'

s 修饰符:dotAll 模式

正则表达式中,点(.)是一个特殊字符,代表任意的单个字符,但是有两个例外。一个是四个字节的 UTF-16 字符,这个可以用u修饰符解决;另一个是行终止符(line terminator character)。

终止符:就是该字符表示一行的终结。以下四个字符属于“行终止符”。

  1. U+000A 换行符(\n)
  2. U+000D 回车符(\r)
  3. U+2028 行分隔符(line separator)
  4. U+2029 段分隔符(paragraph separator)

ES2018 引入s修饰符,使得.可以匹配任意单个字符

/foo.bar/.test('foo\nbar') // false

/foo.bar/s.test('foo\nbar') // true

这被称为dotAll模式,即点(dot)代表一切字符。所以,正则表达式还引入了一个dotAll属性,这被称为dotAll模式,即点(dot)代表一切字符。

dotAll属性,返回一个布尔值,表示该正则表达式是否处在dotAll模式。

const re = /foo.bar/s;
// 另一种写法
// const re = new RegExp('foo.bar', 's');

re.test('foo\nbar') // true
re.dotAll // true
re.flags // 's'

/s修饰符和多行修饰符/m不冲突,两者一起使用的情况下,.匹配所有字符,而^和$匹配每一行的行首和行尾。

后行断言

JavaScript 语言的正则表达式,只支持先行断言(lookahead)和先行否定断言(negative lookahead),不支持后行断言(lookbehind)和后行否定断言(negative lookbehind)。
ES2018 引入后行断言,V8 引擎 4.9 版(Chrome 62)已经支持。

先行断言”指的是,x只有在y前面才匹配,必须写成/x(?=y)/。比如,只匹配百分号之前的数字,要写成/\d+(?=%)/。“先行否定断言”指的是,x只有不在y前面才匹配,必须写成/x(?!y)/。比如,只匹配不在百分号之前的数字,要写成/\d+(?!%)/。

“后行断言”正好与“先行断言”相反,x只有在y后面才匹配,必须写成/(?<=y)x/。比如,只匹配美元符号之后的数字,要写成/(?<=$)\d+/。“后行否定断言”则与“先行否定断言”相反,x只有不在y后面才匹配,必须写成/(?<!y)x/。比如,只匹配不在美元符号后面的数字,要写成/(?<!$)\d+/。

“后行断言”的实现,需要先匹配/(?<=y)x/的x,然后再回到左边,匹配y的部分。这种“先右后左”的执行顺序,与所有其他正则操作相反,导致了一些不符合预期的行为。

首先,后行断言的组匹配,与正常情况下结果是不一样的。

/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]
/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]

上面代码中,需要捕捉两个组匹配。没有“后行断言”时,第一个括号是贪婪模式,第二个括号只能捕获一个字符,所以结果是105和3。而“后行断言”时,由于执行顺序是从右到左,第二个括号是贪婪模式,第一个括号只能捕获一个字符,所以结果是1和053。

其次,“后行断言的反斜杠引用,也与通常的顺序相反,必须放在对应的那个括号之前

/(?<=(o)d\1)r/.exec('hodor')  // null
/(?<=\1d(o))r/.exec('hodor')  // ["r", "o"]

上面代码中,如果后行断言的反斜杠引用(\1)放在括号的后面,就不会得到匹配结果,必须放在前面才可以。因为后行断言是先从左到右扫描,发现匹配以后再回过头,从右到左完成反斜杠引用。

Unicode 属性类

\p{...}\P{...},允许正则表达式匹配符合 Unicode 某种属性的所有字符。

Unicode 属性类要指定属性名和属性值。对于某些属性,可以只写属性名,或者只写属性值。

\P{…}是\p{…}的反向匹配,即匹配不满足条件的字符。

注意,这两种类只对 Unicode 有效,所以使用的时候一定要加上u修饰符。如果不加u修饰符,正则表达式使用\p和\P会报错,ECMAScript 预留了这两个类。

\p{Number}甚至能匹配罗马数字。

具名组匹配

ES2018 引入了具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用

onst RE_DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;

const matchObj = RE_DATE.exec('1999-12-31');
const year = matchObj.groups.year; // 1999
const month = matchObj.groups.month; // 12
const day = matchObj.groups.day; // 31

上面代码中,“具名组匹配”在圆括号内部,模式的头部添加“问号 + 尖括号 + 组名”(?<year>),然后就可以在exec方法返回结果的groups 属性上引用该组名。同时,数字序号(matchObj[1])依然有效。

具名组匹配等于为每一组匹配加上了 ID,便于描述匹配的目的。如果组的顺序变了,也不用改变匹配后的处理代码。

如果具名组没有匹配,那么对应的groups对象属性会是undefined

解构赋值和替换

有了具名组匹配以后,可以使用解构赋值直接从匹配结果上为变量赋值。

字符串替换时,使用$<组名>引用具名组。

let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;

'2015-01-02'.replace(re, '$<day>/$<month>/$<year>')
// '02/01/2015'

上面代码中,replace方法的第二个参数是一个字符串,而不是正则表达式。

replace方法的第二个参数也可以是函数.

'2015-01-02'.replace(re, (
   matched, // 整个匹配结果 2015-01-02
   capture1, // 第一个组匹配 2015
   capture2, // 第二个组匹配 01
   capture3, // 第三个组匹配 02
   position, // 匹配开始的位置 0
   S, // 原字符串 2015-01-02
   groups // 具名组构成的一个对象 {year, month, day}
 ) => {
 let {day, month, year} = groups;
 return `${day}/${month}/${year}`;
});

具名组匹配在原来的基础上,新增了最后一个函数参数:具名组构成的一个对象。函数内部可以直接对这个对象进行解构赋值。

正则匹配索引

正则匹配结果的开始位置和结束位置,目前获取并不是很方便。正则实例的exex()方法,返回结果有一个index属性,可以获取整个匹配结果的开始位置,但是如果包含组匹配,每个组匹配的开始位置,很难拿到。

现在有一个第三阶段提案,为exec()方法的返回结果加上indices属性,在这个属性上面可以拿到匹配的开始位置和结束位置。

const text = 'zabbcdef';
const re = /ab/;
const result = re.exec(text);

result.index // 1
result.indices // [ [1, 3] ]

注意,开始位置包含在匹配结果之中,但是结束位置不包含在匹配结果之中。比如,匹配结果为ab,分别是原始字符串的第1位和第2位,那么结束位置就是第3位

  • 如果正则表达式包含组匹配,那么indices属性对应的数组就会包含多个成员,提供每个组匹配的开始位置和结束位置。

  • 如果正则表达式包含具名组匹配,indices属性数组还会有一个groups属性。该属性是一个对象,可以从该对象获取具名组匹配的开始位置和结束位置。

  • 如果获取成功组匹配,indices属性数组的对应成员则为undefined.indices.groups属性对象的对应成员也是undefined

String.prototype.matchAll()

ES2020 增加了String.prototype.matchAll()方法,可以一次性取出所有匹配。不过,它返回的是一个遍历器(Iterator),而不是数组。

const string = 'test1test2test3';

// g 修饰符加不加都可以
const regex = /t(e)(st(\d?))/g;

for (const match of string.matchAll(regex)) {
  console.log(match);
}
// ["test1", "e", "st1", "1", index: 0, input: "test1test2test3"]
// ["test2", "e", "st2", "2", index: 5, input: "test1test2test3"]
// ["test3", "e", "st3", "3", index: 10, input: "test1test2test3"]

上面代码中,由于string.matchAll(regex)返回的是遍历器,所以可以用for…of循环取出。相对于返回数组,返回遍历器的好处在于,如果匹配结果是一个很大的数组,那么遍历器比较节省资源。

遍历器转为数组是非常简单的,使用…运算符和Array.from()方法就可以了


数值的扩展

二进制和八进制表示法

ES6 提供了二进制和八进制数值的新的写法,分别用前缀0b(或0B)和0o(或0O)表示。

从 ES5 开始,在严格模式之中,八进制就不再允许使用前缀0表示,ES6 进一步明确,要使用前缀0o表示。

如果要将0b和0o前缀的字符串数值转为十进制,要使用Number方法。

Number.isFinite(), Number.isNaN()

ES6 在Number对象上,新提供了Number.isFinite()Number.isNaN()两个方法。

  • Number.isFinite() :用来检查一个数值是否为有限的(finite),即不是Infinity。
    注意,如果参数类型不是数值,Number.isFinite一律返回false。

  • Number.isNaN()用来检查一个值是否为NaN。
    如果参数类型不是NaN,Number.isNaN一律返回false。

它们与传统的全局方法isFinite()isNaN()的区别在于,传统方法先调用Number()将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()对于非数值一律返回false, Number.isNaN()只有对于NaN才返回true,非NaN一律返回false

isFinite(25) // true
isFinite("25") // true
Number.isFinite(25) // true
Number.isFinite("25") // false

isNaN(NaN) // true
isNaN("NaN") // true
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
Number.isNaN(1) // false

Number.parseInt(), Number.parseFloat()

ES6 将全局方法parseInt()parseFloat(),移植到Number对象上面,行为完全保持不变。

这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。

// ES5的写法
parseInt('12.34') // 12
parseFloat('123.45#') // 123.45

// ES6的写法
Number.parseInt('12.34') // 12
Number.parseFloat('123.45#') // 123.45

// 语句模块化
Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true

Number.isInteger()

Number.isInteger()用来判断一个数值是否为整数。

如果参数不是数值,Number.isInteger返回false。

JavaScript 内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。

  • 注意
    由于 JavaScript 采用 IEEE 754 标准,数值存储为64位双精度格式,数值精度最多可以达到 53 个二进制位(1 个隐藏位与 52 个有效位)。如果数值的精度超过这个限度,第54位及后面的位就会被丢弃,这种情况下,Number.isInteger可能会误判。

    类似的情况还有,如果一个数值的绝对值小于Number.MIN_VALUE(5E-324),即小于 JavaScript 能够分辨的最小值,会被自动转为 0。这时,Number.isInteger也会误判。

总之,如果对数据精度的要求较高,不建议使用Number.isInteger()判断一个数值是否为整数。

Number.EPSILON

ES6 在Number对象上面,新增一个极小的常量Number.EPSILON。根据规格,它表示 1 与大于 1 的最小浮点数之间的差。

对于 64 位浮点数来说,大于 1 的最小浮点数相当于二进制的1.00…001,小数点后面有连续 51 个零。这个值减去 1 之后,就等于 2 的 -52 次方。

Number.EPSILON实际上是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了。所以让我们知道,浮点数计算是不精确的。

0.1 + 0.2
// 0.30000000000000004

0.1 + 0.2 - 0.3
// 5.551115123125783e-17

5.551115123125783e-17.toFixed(20)
// '0.00000000000000005551'

0.1 + 0.2 === 0.3 // false

Number.EPSILON可以用来设置“能够接受的误差范围”。比如,误差范围设为 2 的-50 次方(即Number.EPSILON * Math.pow(2, 2)),即如果两个浮点数的差小于这个值,我们就认为这两个浮点数相等。

因此,Number.EPSILON的实质是一个可以接受的最小误差范围。

安全整数和 Number.isSafeInteger()

JavaScript 能够准确表示的整数范围在-253到253之间(不含两个端点),超过这个范围,无法精确表示这个值。

ES6 引入了Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER这两个常量,用来表示这个范围的上下限。

Number.isSafeInteger()则是用来判断一个整数是否落在这个范围之内。

这个函数的实现很简单,就是跟安全整数的两个边界值比较一下。

Number.isSafeInteger = function (n) {
  return (typeof n === 'number' &&
    Math.round(n) === n &&
    Number.MIN_SAFE_INTEGER <= n &&
    n <= Number.MAX_SAFE_INTEGER);
}

实际使用这个函数时,需要注意。验证运算结果是否落在安全整数的范围内,不要只验证运算结果,而要同时验证参与运算的每个值。

如果只验证运算结果是否为安全整数,很可能得到错误结果。下面的函数可以同时验证两个运算数和运算结果。

function trusty (left, right, result) {
  if (
    Number.isSafeInteger(left) &&
    Number.isSafeInteger(right) &&
    Number.isSafeInteger(result)
  ) {
    return result;
  }
  throw new RangeError('Operation cannot be trusted!');
}

trusty(9007199254740993, 990, 9007199254740993 - 990)
// RangeError: Operation cannot be trusted!

trusty(1, 2, 

Math 对象的扩展

Math.trunc()

Math.trunc方法用于去除一个数的小数部分,返回整数部分。

对于非数值,Math.trunc内部使用Number方法将其先转为数值。
对于空值和无法截取整数的值,返回NaN。

对于没有部署这个方法的环境,可以用下面的代码模拟。

Math.trunc = Math.trunc || function(x) {
  return x < 0 ? Math.ceil(x) : Math.floor(x);
};
Math.sign()

Math.sign方法用来判断一个数到底是正数、负数、还是零。对于非数值,会先将其转换为数值。

它会返回下面五种值。

  • 参数为正数,返回+1;
  • 参数为负数,返回-1;
  • 参数为 0,返回0;
  • 参数为-0,返回-0;
  • 其他值,返回NaN

如果参数是非数值,会自动转为数值。对于那些无法转为数值的值,会返回NaN。

对于没有部署这个方法的环境,可以用下面的代码模拟

Math.sign = Math.sign || function(x) {
  x = +x; // convert to a number
  if (x === 0 || isNaN(x)) {
    return x;
  }
  return x > 0 ? 1 : -1;
};
Math.cbrt()

Math.cbrt方法用于计算一个数的立方根。

对于非数值,Math.cbrt方法内部也是先使用Number方法将其转为数值。

对于没有部署这个方法的环境,可以用下面的代码模拟。

Math.cbrt = Math.cbrt || function(x) {
  var y = Math.pow(Math.abs(x), 1/3);
  return x < 0 ? -y : y;
};
Math.clz32()

Math.clz32()方法将参数转为 32 位无符号整数的形式,然后返回这个 32 位值里面有多少个前导 0。

Math.clz32(0) // 32
Math.clz32(1) // 31
Math.clz32(1000) // 22
Math.clz32(0b01000000000000000000000000000000) // 1
Math.clz32(0b00100000000000000000000000000000) // 2

上面代码中,0 的二进制形式全为 0,所以有 32 个前导 0;1 的二进制形式是0b1,只占 1 位,所以 32 位之中有 31 个前导 0;1000 的二进制形式是0b1111101000,一共有 10 位,所以 32 位之中有 22 个前导 0。

左移运算符(<<)与Math.clz32方法直接相关

对于小数,Math.clz32方法只考虑整数部分。

对于空值或其他类型的值,Math.clz32方法会将它们先转为数值,然后再计算。

Math.imul()

Math.imul方法返回两个数以 32 位带符号整数形式相乘的结果,返回的也是一个 32 位的带符号整数。

Math.imul(2, 4)   // 8
Math.imul(-1, 8)  // -8
Math.imul(-2, -2) // 4

如果只考虑最后 32 位,大多数情况下,Math.imul(a, b)与a * b的结果是相同的,即该方法等同于(a * b)|0的效果(超过 32 位的部分溢出)。

之所以需要部署这个方法,是因为 JavaScript 有精度限制,超过 2 的 53 次方的值无法精确表示。这就是说,对于那些很大的数的乘法,低位数值往往都是不精确的,Math.imul方法可以返回正确的低位数值

Math.fround()

Math.fround方法返回一个数的32位单精度浮点数形式。

注意

  • 对于32位单精度格式来说,数值精度是24个二进制位(1 位隐藏位与 23 位有效位),所以对于 -224 至 224 之间的整数(不含两个端点),返回结果与参数本身一致。

  • 如果参数的绝对值大于 224,返回的结果便开始丢失精度。

对于 NaNInfinity,此方法返回原值。对于其它类型的非数值,Math.fround 方法会先将其转为数值,再返回单精度浮点数。

对于没有部署这个方法的环境,可以用下面的代码模拟.

Math.fround = Math.fround || function (x) {
  return new Float32Array([x])[0];
};
Math.hypot()

Math.hypot方法返回所有参数的平方和的平方根.

如果参数不是数值,Math.hypot方法会将其转为数值。只要有一个参数无法转为数值,就会返回 NaN

对数方法

Math.expm1()

Math.expm1(x)返回 ex - 1,即Math.exp(x) - 1

对于没有部署这个方法的环境,可以用下面的代码模拟。

Math.expm1 = Math.expm1 || function(x) {
  return Math.exp(x) - 1;
};
Math.log1p()

Math.log1p(x)方法返回1 + x的自然对数,即Math.log(1 + x)。如果x小于-1,返回NaN

对于没有部署这个方法的环境,可以用下面的代码模拟。

 Math.log1p = Math.log1p || function(x) {
  return Math.log(1 + x);
};
Math.log2()

Math.log2(x)返回以 2 为底的x的对数。如果x小于 0,则返回 NaN

对于没有部署这个方法的环境,可以用下面的代码模拟。

Math.log2 = Math.log2 || function(x) {
  return Math.log(x) / Math.LN2;
};

双曲函数方法

ES6 新增了 6 个双曲函数方法。

  • Math.sinh(x)返回x的双曲正弦(hyperbolic sine)
  • Math.cosh(x) 返回x的双曲余弦(hyperbolic cosine)
  • Math.tanh(x) 返回x的双曲正切(hyperbolic tangent)
  • Math.asinh(x) 返回x的反双曲正弦(inverse hyperbolic sine)
  • Math.acosh(x) 返回x的反双曲余弦(inverse hyperbolic cosine)
  • Math.atanh(x) 返回x的反双曲正切(inverse hyperbolic tangent)

指数运算符

ES2016 新增了一个指数运算符(**)。

这个运算符的一个特点是右结合,而不是常见的左结合。多个指数运算符连用时,是从最右边开始计算的。

// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2
// 512

指数运算符可以与等号结合,形成一个新的赋值运算符(**=)

let a = 1.5;
a **= 2;
// 等同于 a = a * a;

let b = 4;
b **= 3;
// 等同于 b = b * b * b;

注意,V8 引擎的指数运算符与Math.pow的实现不相同,对于特别大的运算结果,两者会有细微的差异。

BigInt 数据类型

JavaScript 所有数字都保存成 64 位浮点数,这给数值的表示带来了两大限制。一是数值的精度只能到 53 个二进制位(相当于 16 个十进制位),大于这个范围的整数,JavaScript 是无法精确表示的,这使得 JavaScript 不适合进行科学和金融方面的精确计算。二是大于或等于2的1024次方的数值,JavaScript 无法表示,会返回Infinity

ES2020 引入了一种新的数据类型 BigInt(大整数),来解决这个问题。BigInt 只用来表示整数,没有位数的限制,任何位数的整数都可以精确表示。

  • 为了与 Number 类型区别,BigInt 类型的数据必须添加后缀n

  • BigInt 同样可以使用各种进制表示,都要加上后缀n。

  • BigInt 与普通整数是两种值,它们之间并不相等。(42n === 42 // false

  • typeof运算符对于 BigInt 类型的数据返回bigint

  • BigInt 可以使用负号(-),但是不能使用正号(+),因为会与 asm.js 冲突。

BigInt 对象

JavaScript 原生提供BigInt对象,可以用作构造函数生成 BigInt 类型的数值。转换规则基本与Number()一致,将其他类型的值转为 BigInt。

BigInt(123) // 123n
BigInt('123') // 123n
BigInt(false) // 0n
BigInt(true) // 1n

BigInt()构造函数必须有参数,而且参数必须可以正常转为数值

注意:

  • 字符串123n无法解析成 Number 类型,所以会报错。
  • 参数如果是小数,也会报错

BigInt 对象继承了 Object 对象的两个实例方法。

  • BigInt.prototype.toString()
  • BigInt.prototype.valueOf()

它还继承了 Number 对象的一个实例方法。

  • BigInt.prototype.toLocaleString()

此外,还提供了三个静态方法

  • BigInt.asUintN(width, BigInt): 给定的 BigInt 转为 0 到 2width - 1 之间对应的值。
  • BigInt.asIntN(width, BigInt):给定的 BigInt 转为 -2width - 1 到 2width - 1 - 1 之间对应的值。
  • BigInt.parseInt(string[, radix]):近似于Number.parseInt(),将一个字符串转换成指定进制的 BigInt。

对于二进制数组,BigInt 新增了两个类型BigUint64ArrayBigInt64Array,这两种数据类型返回的都是64位 BigInt。DataView对象的实例方法DataView.prototype.getBigInt64()DataView.prototype.getBigUint64(),返回的也是 BigInt。

转换规则

可以使用Boolean()Number()String()这三个方法,将 BigInt 可以转为布尔值、数值和字符串类型。

Boolean(0n) // false
Boolean(1n) // true
Number(1n)  // 1
String(1n)  // "1"

上面代码中,注意最后一个例子,转为字符串时后缀n会消失。

另外,取反运算符(!)也可以将 BigInt 转为布尔值

数学运算

数学运算方面,BigInt 类型的+、-、*和**这四个二元运算符,与 Number 类型的行为一致。除法运算/会舍去小数部分,返回一个整数。

几乎所有的数值运算符都可以用在 BigInt,但是有两个例外。

  • 不带符号的右移位运算符>>>
  • 一元的求正运算符+

上面两个运算符用在 BigInt 会报错。前者是因为>>>运算符是不带符号的,但是 BigInt 总是带有符号的,导致该运算无意义,完全等同于右移运算符>>。后者是因为一元运算符+在 asm.js 里面总是返回 Number 类型,为了不破坏 asm.js 就规定+1n会报错。

BigInt 不能与普通数值进行混合运算。

如果一个标准库函数的参数预期是 Number 类型,但是得到的是一个 BigInt,就会报错。

// 错误的写法
Math.sqrt(4n) // 报错

// 正确的写法
Math.sqrt(Number(4n)) // 2

上面代码中,Math.sqrt的参数预期是 Number 类型,如果是 BigInt 就会报错,必须先用Number方法转一下类型,才能进行计算。

asm.js 里面,|0跟在一个数值的后面会返回一个32位整数。根据不能与 Number 类型混合运算的规则,BigInt 如果与|0进行运算会报错。

其他运算

BigInt 对应的布尔值,与 Number 类型一致,即0n会转为false,其他值转为true

if (0n) {
  console.log('if');
} else {
  console.log('else');
}
// else

上面代码中,0n对应false,所以会进入else子句。

比较运算符(比如>)和相等运算符(==)允许 BigInt 与其他类型的值混合计算,因为这样做不会损失精度。

BigInt 与字符串混合运算时,会先转为字符串,再进行运算

学习文献:https://es6.ruanyifeng.com/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值