[ 面试系列 ] - 九:ES6 语法知道哪些,分别怎么用?

2,289 阅读16分钟

系列文章

ES6 是什么?

既然要回答问题,那首先要搞清楚的是,这个问题问的是什么。那么对于标题的这个问题,我们首先需要搞清楚的就是:ES6 是什么?

首先,

对于这个问题,我们先来看看另一个常见的问题:ECMAScript 和 JavaScript 是什么关系?

要说明这个问题,需要回顾一下 JavaScript 的历史:

1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版。

也就是说:

  • ECMAScript 是 JavaScript 的一种规范
  • JavaScript 是 ECMAScript 的一种实现

那么 ES6 是什么呢?

对于这个问题,就我所知,有两种答案:

  • ES6 既是一个历史名词,也是一个泛指,含义是 5.1 版以后的 JavaScript 的下一代标准,涵盖了 ES2015、ES2016、ES2017 等等

  • ES6 即 ES2015 标准

参见:

目前来说,更流行的是第二种说法,所以也有 ES7、ES8、ES9...等说法。当然,置于为什么 ES2015 标准就是 ES6 的问题,这里就不再继续展开,感兴趣的同学可以在阮老师的书里找到答案。

ES6 新特性

let && const

想必学过别的语言的同学一定对 JavaScript 的 var 印象深刻:

  • 变量提升
  • 可重复声明
  • 没有块级作用于

以上几点,放在很多语言里,都是不敢想象的——声明个变量居然会有这么多鬼问题——而这也导致了很多对于语言机制不够熟悉的同学会写出一些莫名其妙的 bug(当然,现在它们更多出现在面试题中)

letconst 的出现就是为了解决以上的痛点,其中 let 用于声明变量,而 const 用于声明常量。

这里有个比较有意思的问题:let 和 const 是否存在变量提升?

事实上,想要完整的回答这个问题,并不是一两句话就能说清楚的事情。首先我们来看看 MDN 对 let 的描述:

let 允许你声明一个作用域被限制在 块级中的变量、语句或者表达式。与 var 关键字不同的是, var 声明的变量只能是全局或者整个函数块的。 var 和 let 的不同之处在于后者是在编译时才初始化。

从这段话我们可以得到以下关键点:

  • 块级作用域
  • 编译的时候才初始化

块级作用于很好理解,但这编译的时候才初始化,是什么意思呢?

不急,我们接着看 MDN 给出的例子:

与通过 var 声明的有初始化值 undefined 的变量不同,通过 let 声明的变量直到它们的定义被执行时才初始化。在变量初始化前访问该变量会导致 ReferenceError。该变量处在一个自块顶部到初始化处理的“暂存死区”中。

比如如下代码:

function do_something({
  console.log(bar); // undefined
  console.log(foo); // ReferenceError
  var bar = 1;
  let foo = 2;
}

显然,这是引用错误,说明 let 没有被提升。但紧接着 MDN 给出的另一个例子就相当有意思了:

// prints out 'undefined'
console.log(typeof undeclaredVariable);

// results in a 'ReferenceError'
console.log(typeof i);
let i = 10;

我们都知道,使用 typeof 的时候,即使对一个为声明的变量,也并不会报引用错误,而是返回一个 undefined,但这里对于一个使用 let 定义的变量,竟然会报引用错误。而这也与我们前面的结论相矛盾:

  • 如果 let 定义的变量不存在提升,那么 typeof 打印的应该是一个不存在的变量,应该返回 undifiend
  • 如果 let 定义的变量提升,那么上一个例子应该会打印 undefined,而不是引用错误

关于这个问题,TC39 的成员 Rick Waldron 给出了一个 gist 来解释:

In JavaScript, all binding declarations are instantiated when control flow enters the scope in which they appear. Legacy var and function declarations allow access to those bindings before the actual declaration, with a "value" of undefined. That legacy behavior is known as "hoisting". let and const binding declarations are also instantiated when control flow enters the scope in which they appear, with access prevented until the actual declaration is reached; this is called the Temporal Dead Zone. The TDZ exists to prevent the sort of bugs that legacy hoisting can create.

(日常为难英语差的胖虎???)

翻译过来就是说:

  • 在 JavaScript 中,当控制流程执行到对应范围的时候,其中的所有声明都会实例化
  • 其中 varfunction 允许在实际声明之前访问这些变量,并且返回这些变量的值为 undefined,这种现象称为 hoisting
  • letconst 声明的变量,在执行到他们的声明之前,禁止被访问,这种现象称为暂存死区(TDZ)
  • TDZ 的存在是为了防止 hoisting 可能造成的一些 bug(也就是说修复语言的 bug)

也就是说,关于 letconst 是否存在变量提升,我们可以给出这样的答案:

let && const 声明的变量同样会被提升到对应作用域的顶部,但是由于存在 TDZ,导致我们无法在执行声明语句之前访问它们,这会抛出一个 ReferenceError 的异常。

解构赋值(析构赋值)

解构赋值语法是一种 Javascript 表达式。通过解构赋值, 可以将属性/值从对象/数组中取出,赋值给其他变量。

解构赋值在日常工作中使用频率非常高,不过通常仅仅是粗浅的使用而已,实际上,解构赋值的内容其实并不算少。

在 ES6,解构赋值有以下几种情况(按日常使用频率为优先级排序):

  • 数组
  • 对象
  • 函数参数
  • 其他

这里依次说明一下:

数组解构赋值

在数组的解构赋值中,按照如下格式:

let/const [a, b, c...] = x

可以将值从右边的可迭代对象中取出,具体可见下面例子:

// 完全解构
let [a, b, c] = [123]; // a = 1, b = 2, c = 3
let [a, [[b], c]] = [1, [[2], 3]]; // a = 1, b = 2, c = 3
let [a, b, c, ...o] = [1234567]; // a = 1, b = 2, c = 3, o = [4, 5, 6, 7]

// 不完全解构
let [a, b] = [123]; // a = 1, b = 2
let [a, [b], d] = [1, [23], 4]; // a = 1, b = 2, d = 4

// 解构失败
let [a] = []; // a = undefined
let [a, b] = [1]; // a = 1, b = undefined

// 不可迭代对象
let [a] = 1// Uncaught TypeError: 1 is not iterable
let [a] = false// Uncaught TypeError: false is not iterable
let [a] = {}; // Uncaught TypeError: {} is not iterable

事实上,只要等号右边的数据结构具有 Iterator 接口,就可以用数组的解构赋值方式解构:

let [a, b, c] = new Set([123]); // a = 1, b = 2, c = 3

functiongenerator({
  let a = 0;
  while (true) {
    yield a;
    [a] = [a + 1];
  }
}
let [a, b, c] = generator(); // a = 1, b = 2, c = 3

(关于 function 后面的 * 和函数里的 yield,在本章后面的生成器中会详细提到。)

在日常工作中,解构赋值除了方便将等号右边解构出我们想要的值以外,还有以下小技巧:

// 数组合并let a = [1, 2, 3];
let b = [456];
let [...c] = [...a, ...b]; // c = [1, 2, 3, 4, 5, 6]

// 类数组转数组
let arr = [...arrLike]

// 交换变量的值
let a = 1;
let b = 2;
[b, a] = [a, b];

值得一提的是,解构赋值操作是浅拷贝

let org = [1, [23], 4];
let [...tar] = org;
tar[1][1] = 5// org = [1, [2, 5], 4]

对象解构赋值

同数组的解构赋值类似,对象的解构赋值也可以按照如下格式从右边的对象中取出对应的值:

let { a: a, b: b, c: c, d: d } = { a1b2c3 }
// a = 1
// b = 2
// c = 3
// d = undefined

从上述代码可以知道关于对象解构赋值的几个基本特征:

  • : 左边是匹配模式,匹配右边的同名属性名
  • : 右边是被赋值的变量
  • 如果匹配模式在右边无法找到同名属性名,则返回 undefined

当然,如果是同名赋值的话,我们可以简写成如下形式:

let { a, b, c, d } = { a1b2c3 }
// a = 1
// b = 2
// c = 3
// d = undefined

与数组的解构赋值类似,对象的解构赋值也可以嵌套使用,见下:

let { out: { mid: { in: myValue } } } = { out: { mid: { in"value" } } } // myValue = "value"
let { out: [a, b, { in: myValue }] } = { out: [12, { in"value" }] } // a = 1, b = 2, myValue = "value"

值得一提的是,在 JavaScript 中,数组也是一种特殊的对象,所以对于数组,我们也可以使用对象的解构赋值进行解构:

let arr = [1234567]
let { 0: first, [arr.length - 1]: last } = arr // first = 1, last = 7

最后,对于已经定义的变量,想要使用解构赋值的话,需要借助圆括号(因为如果以 { 作为开头的话, JavaScript 引擎会将这里当做一个代码块):

let a
{ a } = { a"hello" } // SyntaxError: Unexpected token
({ a } = { a"hello" }) // a = "hello"

函数参数的解构赋值

同数组和对象的解构赋值,在参数位置进行结构赋值,可以将参数中对应的值取出:

const fn = ([a, b]) => a + b
const fn = ({ a, b }) => [a, b]

配合 es6 新增的 rest 参数,可以帮助我们方便地将一组参数与变量名对应起来

const fn = ({ a = 1, b = 2, c = 3 }, ...args) => [a, b, c, ...args].reduce((pre, cur) => pre + cur, 0)
fn({ c11a5 }, 85334// 41

其他

当对非对象进行解构赋值操作的时候,会先将 = 右边的值转化成一个对象:

// 数值
let { toString: n2s } = 1
n2s === Number.prototype.toString // true

// 布尔值
let { toString: b2s } = true
b2s === Boolean.prototype.toString // true

// 字符串
/* 由于字符串对象是一个类数组,所以可以用以下方式解构赋值 */
let [a, b, c] = "str" // a = 's', b = 't', c = 'r'
let { 0: a, 1: b, 2: c, length: len, toString: s2s } = "str" // a = 's', b = 't', c = 'r', len = 3
s2s === String.prototype.toString // true

// null && undefined
let { a } = null // TypeError: Cannot destructure property `a` of 'undefined' or 'null'
let { a } = undefined // TypeError: Cannot destructure property `a` of 'undefined' or 'null'

字符串扩展

ES6 中对字符串的扩展主要在模板字符串字符串新增方法中体现,这里分别介绍一下。

模板字符串

模板字符串在 ES6 规范中叫做 Template Literals,翻译过来应该叫模板字面量,而在规范文档更早的版本中被叫做 Template Strings,所以也有大量中文文档将其翻译成模板字符串。

在日常开发中,我们大多仅仅将模板字符串当成一种拼接字符串的语法糖来用,如下:

const flag = 'flag'
const strEs5 = 'this is a ' + flag + ' !'
const strEs6 = `this is a ${flag} !`

事实上,模板字符串能做的相当多,在 ${} 中可以放入任意的 JavaScript 表达式:

// 比较
const DEBUG = 'ON'
const flag = `${DEBUG === 'ON'}`

// 执行函数
const str = `${(() => 'return a string')()}`

// 生成模板
const map = [
  [nullnullnull,],
  [nullnullnull,],
  [nullnullnull,]
]

const checkerboard = checkerboard => `
<div class="checkerboard">
    ${checkerboard.map(row => `
    <div class="checkerboard-row">
    ${row.map(() => `
      <div class="checkerboard-col">
      </div>
    `
).join('')}

    </div>
    `
).join('')}

</div>`

(ps:生成模板这一幕,想必用过 react 的同学可能会有一种熟悉的感觉—— react 在 2013 年 5 月就开源了,但事实上 ES6 虽然是 2015 的规范,其中的绝大部分内容却在多年前没过审的 ES4 中就有了,所以当初 react 也有一定可能是从这里的到的灵感,从而实现了 jsx。)

其实到这里已经可以体现模板字符串的强大了,然而它的功能还不止这些,它可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串,这被叫做标签字符串,这是什么意思呢?看看下面的代码就明白了:

fn`hello world` // fn([ 'hello world' ])

const a = 1
const b = 2
fn`hello world ${a} ${b}` // fn([ 'hello world ', ' ', '' ], 1, 2)

通过上面的代码可以知道,标签字符串将模板字符串中那些没有变量替换的部分组合成一个数组作为函数的第一个参数,将剩下的部分作为剩余参数传给进行调用操作的函数,即 fn 可以这样接收参数:

const fn = (stringArgs, ...templateArgs) => {
  console.log("stringArr", stringArgs);
  console.log("templateArgs", templateArgs);
}

那么这样的操作,有什么特殊的作用吗?

事实上,标签字符串主要有两个作用:

  • 过滤 HTML 中的字符串,防止用户输入恶意内容
  • 国际化处理

具体例子见下面代码:

// 过滤 HTML 中的字符串
const info = '<script>alert("xss")</script>';
let message =
  safaHtml`<p>This ${info} will be sent to the server</p>`;

function safaHtml(stringArgs, ...templateArgs{
  let s = stringArgs[0];
  templateArgs.forEach((arg, index) => {
    s += arg.replace(/&/g"&amp;")
      .replace(/</g"&lt;")
      .replace(/>/g"&gt;");
    s += stringArgs[index + 1];
  })
  return s;
}
// message = <p>This &lt;script&gt;alert("xss")&lt;/script&gt; will be sent to the server</p>

新增方法

ES6 为字符串新增了很多方法,这里只简单介绍一下日常工作中使用频率比较高的一些。

方法名 功能 参数 返回值 例子
includes 判断一个字符串是否是另一个字符串的子串 String Boolean 'abcd'.includes('b') // true
startsWith 判断一个字符串是否以某字符串为开头 String Boolean 'abcd'.startsWith('a') // true
endsWith 判断一个字符串是否以某字符串为结尾 String Boolean 'abcd'.endsWith('d') // true
repeat 返回一个新字符串,表示将原字符串重复 n Number String 'x'.repeat(3) // xxx
padStart 返回一个新字符串,表示将原字符串按照某字符串从开头补全到固定位数 Number, String String 'x'.padStart(3, 'a') // aax
padEnd 返回一个新字符串,表示将原字符串按照某字符串从尾部补全到固定位数 Number, String String 'x'.padEnd(3, 'a') // xaa

数值扩展

ES6 对数值的扩展有很多:

  • 新增了二进制和八进制的表示方法
  • 指数运算符
  • BigInt
  • Math 扩展
  • 新增方法

在日常前端开发中,我们主要关注其中一部分,下面单独介绍一下。

isInteger

Number.isInteger() 用于判断一个数是否是整数:

Number.isInteger(7// true
Number.isInteger(3.9// false

需要注意的是,由于在 JavaScript 内部,整数和浮点数的存储方式相同,所以 xxx.0 会被认为是整数:

Number.isInteger(7.0// true
Number.isInteger(9.0// true

另外,由于 JavaScript 采用的是 IEEE 754 - 双精度浮点数 来存储数值类型,即:

  • 1 个符号位
  • 11 个指数位
  • 52 个小数位

img-23-01

导致 JavaScript 的精度只能达到 10 进制小数点后的 16 位,一旦超出,则 Number.isInteger() 可能出现误判:

Number.isInteger(7.00000000000000001// true
Number.isInteger(7.00000000000000003// true
Number.isInteger(7.00000000000000009// false

其具体计算过程可以参考:【算法】解析IEEE 754 标准

isSafeInteger

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

Number.isSafeInteger() 则用于判断某个整数是否落在这个区间之内:

Number.isSafeInteger(3// true
Number.isSafeInteger(1.2// false
Number.isSafeInteger(9007199254740990// true
Number.isSafeInteger(9007199254740992// false