深入理解ES6

269 阅读10分钟

本文为笔者阅读阮一峰大佬的《ES6 标准入门》期间整理的一些知识点,持续更新中~

ES6 是新一代的 JS 语言标准,对分 JS 语言核心内容做了升级优化,规范了 JS 使用标准,新增了 JS 原生方法,使得 JS 使用更加规范,更加优雅,更适合大型应用的开发。

ES6 泛指下一代JS语言标准,包含 ES2015、ES2016、ES2017、ES2018 等。现阶段在绝大部分场景下,ES2015 默认等同 ES6。ES5 泛指上一代语言标准。ES2015 可以理解为 ES5 和 ES6 的时间分界线。

let 和 const

letconst 之前先要了解下块级作用域。

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。 通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

ES6 之前 JS 只有全局作用域和函数作用域,没有块级作用域而导致出现很多不合理的场景:

  1. 内层变量会覆盖外层变量
var a = 'outside';
function f() {
    console.log(a);
    if(false) {
        var a = 'inside'; // 存在变量提升
    }
}
f(); // undefined 
  1. 用来循环计数的变量会污染全局变量
for(var i = 0; i < 10; i++){
    // ...
}
console.log(i); // 10

块级作用域

什么是块级作用域呢?通俗的讲就是 使用一对大括号包裹的一段代码,如函数、判断、循环语句。块级作用域很好的隔绝了内外层作用域变量的联系,内层作用域可以定义外层作用域的同名变量,但外层作用域访问不到。

let a = 'outside';
function f() {
    console.log(a);
    if(false) {
        let a = 'inside';
    }
}
f(); // outside 

使用 let 就能很好解决上述所说的两个不合理场景。

let 命令

在 ES6 之前,JS 定义变量都是通过 var 定义,其有一个特别奇怪的特性——变量提升,即变量可以在声明之前使用,值为 undefined

console.log(a); // undefined
var a = 1;
// --------------------
// 以上代码存在变量提升
// 在js执行如下
// --------------------
var a; // 变量提升
console.log(a); // undefined
a = 1;

ES6 新增的 let 命令用来声明变量,并且纠正了这种语法行为,它所声明的变量一定要在声明后使用,否则报错。

console.log(a); // Error
let a = 1;

暂时性死区

ES6 中有明确的规定:如果区块中存在 letconst 命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。 通俗点讲就是 let 声明的变量与该块级作用域 绑定,不会受外部作用域影响。

var me = 'jinle';
{
    me = 'other'; // 暂时性死区 Error
    let me;
}

使用 babel 编译后该段代码为:

"use strict";

var me = 'jinle';
{
    _me = 'other'; // 暂时性死区 Error
    var _me;
}

ES6 规定暂时性死区和 letconst 语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在 ES5 是很常见的,现在有了这种规定,避免此类错误就很容易了。

const 命令

constlet 的特性都是一样的(变量不提升、暂时性死区等),唯一不同就是 const 是用来声明常量的,其一旦声明就不能改变。所以其也必须在初始化时赋值,否则也会报错。

const name = 'jinle';
name = 'other'; // Error

const age; // Error

const 实际上保证的不是变量的值不改变,而是 存放变量的地址不改变。 对于简单变量而言,其保存的地址是指向栈中保存的值,而引用变量(复杂变量)其保存的地址实际是 栈中指向堆结构的一个指针const 能保证的是这个指针是指向是正确的,以至于指向的数据改变即不可控了。

const obj = {};
obj.a = 1;
console.log(obj.a); // 1
obj = {}; // Error

以上是变量为对象的情况,数组也是如此。只要不改变变量的指向地址,随意修改变量是允许的。

如果强行修改 const 定义的常量,babel 编译出来是这样的:

"use strict";

function _readOnlyError(name) { throw new TypeError("\"" + name + "\" is read-only"); }

var name = 'jinle';
'other', _readOnlyError("name"); // Error

如果想冻结对象,可以使用 Object.freeze(),除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。

function constantize(obj) {
    Object.freeze(obj);
    Object.keys(obj).forEach( (key, i) => {
        if ( typeof obj[key] === 'object' ) {
            constantize( obj[key] );
        }
    });
};

顶层对象

顺便说说顶层对象,即是浏览器环境中的 window 对象,Node 环境中的 global 对象。

在 ES5 之前用 var 定义的变量都属于顶层对象下的属性,而 ES6 在兼容前者的同时规定 letconst 不属于顶层对象的属性。直接上代码:

var a = 1;
window.a; // 1

let b = 2;
window.b; // undefined

解构赋值

ES6 提供了一种方便的赋值的写法,可以按照一定的模式从数组或对象中提取值,对变量进行赋值,直接上代码:

// 数组
let [a, b, c] = [1, 2, 3]; // a=1 b=2 c=3
let [x, y = 2] = [1]; // x=1 y=2

// 对象
let {a:x, b:y, c:z} = {a: 1, b: 2, c: 3}; // x=1 y=2 z=3

以下是几种常用的场景:

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

// 提取JSON数据
let jsonData = {
    id: 0,
    status: "OK",
    data: [867, 5309]
};
let { id, status, data: number } = jsonData;

// 函数参数默认值
ajax = function(url, {
    async = true,
    cache = true,
    complete = function () {},
    // config
} = {}) {
    // ...
}

// 导入模块指定方法
import { myFunction } from 'myModule';

模板字符串

ES6 提供了一种用反引号 ` 来书写字符串的方式,这种方式不仅可以多行书写还支持 ${} 插入变量或表达式。

let name = 'jinle'
let str = `
    hello world
    my name is ${name}
`

函数rest参数

ES6 引入 rest 参数(形式为 ...变量名),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。 rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

函数的 length 属性不包括 rest 参数。

function add(...values) {
    let sum = 0;
    for (var val of values) {
        sum += val;
    }
    return sum;
}
add(2, 5, 3) // 10
add.length // 0

箭头函数

ES6 新增了一种函数的写法,使用 => 书写函数,称为箭头函数。

const f = (a) => { return a * a }
// 等价于
const f = a => a * a
// 等价于
const f = function(a) {
    return a * a
}

箭头函数的一个最大用处就是简化函数的书写,同时也支持上述的 rest 参数。

箭头函数虽好用,但还是需要注意几点问题:

  1. 箭头函数没有自己的 this 对象,其 this 永远指向上级作用域。
  2. 不可以当作构造函数,即不可以使用 new 指令,否则会报错。
  3. 没有 arguments 对象,使用 rest 参数代替。
  4. 不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。

this指向问题

JS 的 this 指向问题一直是一个令人头大的问题,全局下的 this 无疑是指向 window,但是函数下的 this 就有很多种情况:

  1. 全局下的函数调用 this 指向的是 window,可理解为全局对象调用该函数
  2. 通过一个对象调用其内部的 this 指向的是该对象
  3. callapplybind 函数可以修改函数的 this 指向
  4. 箭头函数的 this 指向上一作用域

所以,多数情况下,this 指向调用它所在 函数 的那个 对象。 可以简单理解为 this 的指向是在调用时决定的,而不是在书写时决定的 (不包括箭头函数的情况)。

大概理解了普通函数的 this 指向问题,接下来说说箭头函数的 this 指向问题,箭头函数内部没有自己的 this 对象,即其内部的 this 是一个 普通变量 且指向 定义时上层所在的对象

需要重点注意的是,箭头函数的 this 指向的是定义时上层所在的对象,即其 this 指向在函数书写时即已经有明确的指向,这点与普通函数恰好相反。也就是说,箭头函数的 this 指向是固定的,这一点很好的减少了在开发过程中因为某些原因而使 this 指向混乱出现 bug 的情况。

function foo() {
    setTimeout(() => {
        console.log('id:', this.id);
    }, 100);
}
var id = 1;
foo.call({ id: 2 }); // id: 2

上述代码中,如果 foo() 函数中的 setTimeout 传入的是普通函数即内部 this 指向全局 window,这时应该输出 1。但是箭头函数因其 this 指向上一作用域即 foo() 函数对象({id:2}),所以输出的是 2

下面看看 babel 是如何转化箭头函数的:

// ES6
function foo() {
    setTimeout(() => {
        console.log('id:', this.id);
    }, 100);
}

// ES5
function foo() {
  var _this = this;
  setTimeout(function () {
    console.log('id:', _this.id);
  }, 100);
}

不难看出,其实 babel 就是在定义箭头函数时在其上一作用域存储该作用域指向,并将箭头函数内部的this 指向该作用域,这也就清楚的说明了箭头函数没有自己的 this 对象,而是指向上一作用域。

Set

ES6 新增了一种数据结构 Set,类似于数组,每个成员的值都是唯一的、不重复的,常用于数组去重操作。

const set = new Set([1,2,3,3,4]);
[...set]; // [1,2,3,4]

set.size; // 4

// 添加值
set.add(5);

// 删除值
set.delete(1);

// 判断是否包含
set.has(2); // true

// 清除
set.clear();

Map

ES6 新增的 Map 数据结构,类似于对象,也是键值对的集合。但不同于对象的是,传统对象只能用字符串当作键,而 Map 并没有限制,可以使用任何数据结构(包括对象),是一种更完善的 Hash 结构。

let map = new Map([
    ['name', 'jinle'],
    ['age', 22],
    ['job', 'FE']
]);

map.size(); // 3

// 获取值
map.get('name'); // jinle

// 设置值
map.set('age', 23);
map.get('age'); 23

// 判断值
map.has('name'); // true

// 删除值
map.delete('job');
map.has('job'); false

// 清除
map.clear();

let map = new Map([
    ['name', 'jinle'],
    ['age', 22],
    ['job', 'FE']
]);
[...map.keys()]; // ['name', 'age', 'job']
[...map.values()]; // ['jinle', 22, 'FE']
[...map.entries()]; // [['name', 'jinle'], ['age', 22], ['job', 'FE']]
[...map]; // [['name', 'jinle'], ['age', 22], ['job', 'FE']]

map.forEach((value, key, map) => {
    // ...
})

Promise

Promise 是一种异步编程的解决方法,其主要是解决了层层嵌套的回调函数引起的 回调地狱 问题。

简单来讲,Promise 就是一个容器,里面保存了未来才会结束的事件的结果,其提供了一套完整的 API 给开发者处理异步事件。Promise 内部自己封装有自己的状态且不受外界影响,其状态一旦改变就不会再变,任何时候都可以得到这个结果。

基本用法

ES6 规定,Promise 对象是一个构造函数,用来生成 Promise 实例。

首先要了解 Promise 的状态管理,Promise 定义有三种状态,pending(进行中)、fulfilled(已成功)和 rejected(已失败)。Promise 的任何状态改变是不可逆的

Promise 作为构造函数,接收两个参数 resolverejectresolve 函数将 Promise 的状态改为 fulfilled,并触发 .then() 里第一个参数回调函数。reject 函数将 Promise 的状态改为 rejected,并触发 .then() 里第二个参数回调函数(或者 .catch() 里的回调函数)。

const promise1 = new Promise((resolve, reject) => {
    resolve('resolve');
});
const promise2 = new Promise((resolve, reject) => {
    reject('reject');
});

promise1.then(res => {
    console.log(res); // resolve
});
promise2.then(res => {
    console.log(res); // 不执行
}, err => {
    console.log(err); // reject
});

Promise.prototype.then()

.then() 方法应该可以说是 Promise 原型上最重要的一个方法,其作用是为 Promise 添加状态改变时的回调函数。

.then() 接收两个参数,第一个是 resolved 状态的回调函数,第二个是 rejected 状态的回调函数。两个参数都可以省略。

.then() 返回的是一个新的 Promise 实例 (不是原来那个 Promise 实例)。 因此可以采用链式写法,即 .then() 后面再调用另一个 .then()

const promise = new Promise((resolve, reject) => {
    // ...
});
promise.then(res => {
    return 'ok' // 如果不return则返回一个空的Promise
}, err => {
    // ...
}).then(res => {
    console.log(res); // ok
})

不推荐在 .then() 中处理 rejected 状态的回调函数,因为如果每次都要写两个参数会使代码臃肿,可读性下降,一般都是使用 .catch() 来捕获错误。

Promise.prototype.catch()

.catch() 方法可以看作是 .then(null, rejection) 的简写形式,用于指定发生错误时的回调函数。 Promise 对象的错误具有 冒泡 性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 .catch() 语句捕获。

promise.then(res => {
    // ...
    return promise;
}).then(res => {
    // ...
}).catch(err => {
    console.log(err); // 捕获前面两个then函数的错误
})

一般总是建议,Promise 对象后面要跟 .catch(),这样可以处理 Promise 内部发生的错误。

.catch() 返回的还是一个 Promise 对象,因此后面还可以接着调用 .then()

promise.then(res => {
    throw new Error('this is error')
}).catch(err => {
    console.log(err) // Error: this is error
    return 'carry on' // 如果不return则返回一个空的Promise
}).then(res => {
    console.log(res) // carry on
})

Promise.prototype.finally()

.finally() 用于不管 Prmise 返回的状态如何都执行回调函数的操作。.finally() 不接收任何参数,因此无法获取 Promise 的状态结果,其本质也是 .then() 的特例。

promise.finally(() => {
    // do something...
});
// 等同于
promise.then(() => {
    // do something...
}, () => {
    // do something...
})

Promise 回调之后不管状态如何都要执行的操作可以放在 .finally() 中执行,比如关闭弹窗、关闭 loading。

Promise.all()

all() 用于将多个 Promise 实例包装成一个新的 Promise 实例后,再进一步处理。

const p = promise.all([p1, p2, p3]);

封装完的实例 pp1p2p3 的状态决定。 如果三个实例状态都为 fulfilledp 的状态才会变为 fulfilled,此时 p1p2p3 的返回值组成一个新的数组传递给 p 的回调函数。 如果三个实例有任意一个实例的状态为 rejectedp 的状态才会变为 rejected,此时 第一个rejected 的实例的返回值会传递给 p 的回调函数。

需要注意的是,如果作为 all() 的参数的 Promise 实例有定义自己的 .catch() 回调的话,一旦被 rejeceted,将不会触发 all().catch(),其实际是返回该 Promise 实例的 .catch() 返回的 fulfilled 状态的新的 Promise 实例,所以该新实例是增加到返回的数组里面的。

const p1 = new Promise((resolve, reject) => {
  resolve('hello');
})
.then(result => result)
.catch(e => e);

const p2 = new Promise((resolve, reject) => {
  throw new Error('报错了');
})
.then(result => result)
.catch(e => e);

Promise.all([p1, p2])
.then(result => console.log(result))
.catch(e => console.log(e));
// ["hello", Error: 报错了]

如果 p2 没有自己的 .catch(),就会调用 Promise.all().catch()

Promise.race()all() 一样都是封装一组 Promise 实例。不同的是,race() 封装后的实例的状态是由实例数组中 第一个改变状态 的实例决定的,那个率先改变的 Promise 实例的返回值,就传递给封装后的实例的回调函数,这里就不赘述了,详细可以去看文档~

Promise.resolve()

Promise 还有两个常用的方法可以快速的返回新的 Promise 实例对象 —— Promise.resolve()Promise.reject()。 两者用法大同小异,这里就主要介绍下 Promise.resolve()

Promise.resolve() 用于将现有的对象转为 Promise 并直接赋予 fulfilled 状态。等价于下面的写法。

Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))