ECMAScript2015,算是新时代标准的代表版本
1.相比于上一个版本ES5.1变化比较大
2.从这个版本开始,标准命名规则就发生了变化
所以很多开发者喜欢用ES6这个名称泛指之后的所有的新版本
(比如async await是ES2017的标准)
可以看看这个链接里的内容简单对ES6的变更有一个认知
我们要谈到的此版本的内容主要有以下四个:
1.解决原有语法的一些不足或者问题(let const块级作用域)
2.对原有语法进行增强(解构,展开,参数默认值)
3.全新的对象,全新的方法,全新的功能
4.全新的数据类型,数据结构
块级作用域 & let关键词
花括号包裹起来的范围就叫做块。以前块是没有单独的作用域的,导致我们定义的成员在外部也可以访问到
for (var i = 0; i < 3; i++) {
for (var i = 0; i < 3; i++) {
console.log(i)
}
}
// 打印三次0,1,2
为什么会这样?
内层也用var声明了i,并且覆盖了外层的i,导致内层跑了三次之后,i = 3,外层的循环不再调用,所以只打印了三次
修改为let的话就会作为内部的块级作用域的局部成员,并不会影响外部,也就是运行九次。
为了方便后期理解代码,不要使用同名的计数器
再来一个例子:
var elements = [{}, {}, {}]
for (var i = 0; i < elements.length; i++) {
elements[i].onclick = function () {
console.log(i)
}
}
elements[0].onclick()
elements[1].onclick()
elements[2].onclick()
// 全都是3
显而易见,在访问onclick的时候其实循环已经结束,var i的值已经增加完毕了,累加到了3,无论打印哪一个元素,都是结果3,这其实也是闭包的典型应用:(使用函数作用域摆脱全局作用域的影响)
var elements = [{}, {}, {}]
for (var i = 0; i < elements.length; i++) {
elements[i].onclick = (function (i) {
return function () {
console.log(i)
}
})(i)
}
elements[0].onclick()
但是现在有了块级作用域之后就没有这么麻烦了,我们可以使用let将i限制在一个作用域内被访问,问题就迎刃而解了。
其实这个内部也是一个闭包的机制,在onclick事件触发的时候,循环机制早就结束了,实际上的i早就销毁掉了,因为闭包的机制,我们才可以拿到原本循环里的i的值。
for循环会造成两层作用域:
for (let i = 0; i < 3; i++) {
let i = 'foo'
console.log(i)
}
// 可以拆成下面的逻辑:
let i = 0
if (i < 3) {
let i = 'foo'
console.log(i)
}
i++
if (i < 3) {
let i = 'foo'
console.log(i)
}
i++
if (i < 3) {
let i = 'foo'
console.log(i)
}
i++
let i = 'foo'这个实际是if内部的局部变量,外部的let 实际上是外部的局部变量,互不影响。
let的声明不会出现提升的情况,var会提升到最开始
console.log(foo)
var foo = 'zce'
// zce
console.log(foo)
let foo = 'zce'
// undefined
在ES6语法里,修改了这个bug,要求先声明变量,再调用变量
tips:为什么不直接对var进行升级?而是定义了新的关键词let
因为如果对var直接进行升级会造成以前的很多项目无法使用
const
声明常量,在let的基础上多了一个只读特性
这个部分需要注意三个点:
变量经过声明之后不能再经过修改
在声明变量的时候就应该赋值,而不是先声明,再赋值
恒量只是要求内层指向不允许被修改,对于数据成员的修改是没有问题的
const name = 'zce'
// 恒量声明过后不允许重新赋值
name = 'jack'
// 恒量要求声明同时赋值
const str
str = 'zce'
// 恒量只是要求内层指向不允许被修改
const obj = {}
// 对于数据成员的修改是没有问题的
obj.name = 'zce'
// 不允许
obj = {}
最佳实践:不用var,主要使用const,let配合使用
数组的解构
通过一个代码例子就能够很清晰的明白用法了:
// 数组的解构
const arr = [100, 200, 300];
const [foo, bar, baz] = arr;
console.log(foo, bar, baz);
const [, , baz] = arr;
console.log(baz);
// ...只能在解构写法的最后一位使用
// [200, 300]
const [foo, ...rest] = arr;
console.log(rest);
// undefined
const [foo, bar, baz, more] = arr;
console.log(more);
//123, default value
const [foo, bar, baz = 123, more = "default value"] = arr;
console.log(bar, more);
const path = "/foo/bar/baz";
// const tmp = path.split('/')
// const rootdir = tmp[1]
const [, rootdir] = path.split("/");
console.log(rootdir);
对象的解构
// 对象的解构
const obj = { name: "zce", age: 18 };
const { name } = obj;
console.log(name);
// 同名的变量会引发冲突,可以使用:重命名变量
// zce
const name = "tom";
const { name: objName } = obj;
console.log(objName);
// 可以在重命名的时候添加默认值
// jack
const name = "tom";
const { name: objName = "jack" } = obj;
console.log(objName);
const { log } = console;
log("foo");
log("bar");
log("123");
模板字符串
// 模板字符串
// 反引号包裹
const str = `hello es2015, this is a string`;
// 允许换行(传统字符串不支持换行)
const str = `hello es2015,
this is a \`string\``;
console.log(str);
const name = "tom";
// 可以通过 ${} 插入表达式,表达式的执行结果将会输出到对应位置,不光是变量,如何标准的js表达式都可以(最终有return返回值的都可以)
const msg = `hey, ${name} --- ${1 + 2} ---- ${Math.random()}`;
console.log(msg);
模板字符串标签函数
// 带标签的模板字符串
// 模板字符串的标签就是一个特殊的函数,
// 使用这个标签就是调用这个函数
// const str = console.log`hello world`
const name = "tom";
const gender = false;
function myTagFunc(strings, name, gender) {
console.log(strings, name, gender);
// ['hey, ', ' is a ', '.']
const sex = gender ? "man" : "woman";
return strings[0] + name + strings[1] + sex + strings[2];
}
const result = myTagFunc`hey, ${name} is a ${gender}.`;
console.log(result);
// 'hey, tom is a woman.'
字符串的常见的拓展方法
- includes()
- startsWith()
- endsWith()
// 字符串的扩展方法
const message = "Error: foo is not defined.";
console.log(
// true 是否以Error开头
message.startsWith("Error"),
// true 是否以.结尾
message.endsWith("."),
// true 是否包含foo字段
message.includes("foo")
);
参数默认值
// 函数参数的默认值
// 以前的默认值写法:
function foo (enable) {
// 短路运算很多情况下是不适合判断默认参数的,例如 0 '' false null
// enable = enable || true
enable = enable === undefined ? true : enable
console.log('foo invoked - enable: ')
console.log(enable)
}
// ES6:
// 默认参数一定是在形参列表的最后
function foo (enable = true) {
console.log('foo invoked - enable: ')
console.log(enable)
}
foo(false)
剩余参数
// 剩余参数
// 伪数组arguments
function fooBase() {
// { '0': 1, '1': 2, '2': 3, '3': 4 }
console.log(arguments);
}
// ES6 剩余参数:
function foo(first, ...args) {
// [2, 3, 4]
// 这种操作符只能使用一次,出现在形参的最后一位
console.log(args);
}
foo(1, 2, 3, 4);
fooBase(1, 2, 3, 4);
展开数组
// 展开数组参数
const arr = ["foo", "bar", "baz"];
// apply可以用数组的形式接受形参列表
console.log.apply(console, arr);
// ES6
console.log(...arr);
// foo bar baz
箭头函数
function inc (number) {
return number + 1
}
// 最简方式
const inc = n => n + 1
// 完整参数列表,函数体多条语句,返回值仍需 return
const inc = (n, m) => {
console.log("inc invoked");
return n + 1;
};
const arr = [1, 2, 3, 4, 5, 6, 7];
// arr.filter(function (item) {
// return item % 2
// })
// 常用场景,回调函数
arr.filter((i) => i % 2);
箭头函数与this
箭头函数不会改变this的指向
// 箭头函数与 this
// 箭头函数不会改变 this 指向
const person = {
name: "tom",
// sayHi: function () {
// console.log(`hi, my name is ${this.name}`)
// }
sayHi: () => {
// undefined
console.log(`hi, my name is ${this.name}`);
},
sayHiAsync: function () {
// const _this = this
// setTimeout(function () {
// console.log(_this.name)
// }, 1000)
// 上面的代码this需要使用闭包先行保存一下↑↑↑
console.log(this);
// 箭头函数始终指向当前作用域的this
setTimeout(() => {
// console.log(this.name)
console.log(this);
}, 1000);
},
};
person.sayHiAsync();
对象字面量增强
// 对象字面量
const bar = '345'
const obj = {
foo: 123,
// bar: bar
// 属性名与变量名相同,可以省略 : bar
bar,
// method1: function () {
// console.log('method111')
// }
// 方法可以省略 : function
method1 () {
console.log('method111')
// 这种方法就是普通的函数,同样影响 this 指向。
console.log(this)
},
// Math.random(): 123 // 不允许
// 通过 [] 让表达式的结果作为属性名
[bar]: 123
}
// obj[Math.random()] = 123
console.log(obj)
obj.method1()
对象扩展方法
Object.assign
将多个源对象的属性复制到一个目标对象中
// Object.assign 方法
const source1 = {
a: 123,
b: 123,
};
const source2 = {
b: 789,
d: 789,
};
const target = {
a: 456,
c: 456,
};
// 用后面对象中的属性去覆盖第一个对象
const result = Object.assign(target, source1, source2);
// assign返回值就是第一个对象
console.log(target);
// { a: 123, c: 456, b: 789, d: 789 }
console.log(result === target);
应用场景:
function func(obj) {
// 在函数内部修改数据,使用assign就是全新的对象不会影响外部的数据
const funcObj = Object.assign({}, obj);
funcObj.name = "func obj";
console.log(funcObj);
}
const obj = { name: "global obj" };
func(obj);
console.log(obj);
Object.is
判断两个值是否相等
// Object.is
console
.log
// 两等于号会在比较之前自动转换类型
// 0 == false // => true
// 三等于号严格对比两者数值类型是否相等
// 0 === false // => false
// 但是+0和-0是无法对比的
// +0 === -0 // => true
// 非数字,无限种可能,所以不相等,但是在目前来看NaN只不过是特殊的值,所以应该相等
// NaN === NaN // => false
// 通过Object.is可以区分+0和-0,NaN也是相等的。
// Object.is(+0, -0) // => false
// Object.is(NaN, NaN) // => true
();
此方法运用的并不多,更多时候应该使用===
Proxy
捕获对象属性的读写过程:Object.defineProperty
Vue3之前的版本就是使用这个方法实现的数据响应,完成的双向数据绑定
Proxy就是专门为对象设置对象代理的,通过他可以轻松监视对象的读写
// Proxy 对象
const person = {
name: "zce",
age: 20,
};
const personProxy = new Proxy(person, {
// 监视属性读取
get(target, property) {
// target是目标对象,property是调用的属性比如name, gender
return property in target ? target[property] : "default";
},
// 监视属性设置
set(target, property, value) {
if (property === "age") {
if (!Number.isInteger(value)) {
throw new TypeError(`${value} is not an int`);
}
}
target[property] = value;
// console.log(target, property, value)
},
});
personProxy.age = 100;
personProxy.gender = true;
console.log(personProxy.name);
console.log(personProxy.xxx);
Proxy 与Object.defineProperty对比
1.defineProperty只能监视到对象数据的读写,Proxy能够监视到更多的对象操作:对象里的方法调用,delete等
const person = {
name: "zce",
age: 20,
};
const personProxy = new Proxy(person, {
deleteProperty(target, property) {
console.log("delete", property);
delete target[property];
},
});
delete personProxy.age;
console.log(person);
其他的操作比如:
2.Proxy可以更方便的监控数组操作,defineProperty要对数组进行操作还需要重写相关方法
const list = [];
const listProxy = new Proxy(list, {
set(target, property, value) {
console.log("set", property, value);
target[property] = value;
return true; // 表示设置成功
},
});
listProxy.push(100);
listProxy.push(100);
3.Proxy不需要侵入对象
Proxy是以非侵入的方式监管了整个对象的读写,已经定好的对象,不需要对对象进行操作,就可以监视到读写,但是Object.defineProperty要求必须要通过特定的方式单独定义对象中需要被监视的属性
Object.defineProperty:
const person = {}
Object.defineProperty(person, 'name', {
get () {
console.log('name 被访问')
return person._name
},
set (value) {
console.log('name 被设置')
person._name = value
}
})
Object.defineProperty(person, 'age', {
get () {
console.log('age 被访问')
return person._age
},
set (value) {
console.log('age 被设置')
person._age = value
}
})
person.name = 'jack'
console.log(person.name)
Proxy:
const person2 = {
name: "zce",
age: 20,
};
const personProxy = new Proxy(person2, {
get(target, property) {
console.log("get", property);
return target[property];
},
set(target, property, value) {
console.log("set", property, value);
target[property] = value;
},
});
personProxy.name = "jack";
console.log(personProxy.name);
Reflect
ES6的一个全新的内置对象,统一的对象操作API
Reflect是一个静态类。只能调用其中的一些静态方法(类似Math)
Reflect内部封装了一系列对对象的底层操作
Reflect的成员方法就是Proxy处理对象的默认实现
// Reflect 对象
const obj = {
foo: "123",
bar: "456",
};
const proxy = new Proxy(obj, {
get(target, property) {
console.log("watch logic~");
return Reflect.get(target, property);
},
});
console.log(proxy.foo)
使用Proxy的get或者set的逻辑时,更标准的做法是:先去实现自己的监视逻辑,最后返回通过Reflect中对应方法的一个调用结果。
Reflect的存在价值?
他提供了统一的一套操作对象的API
const obj = {
name: "zce",
age: 18,
};
console.log("name" in obj);
console.log(delete obj["age"]);
console.log(Object.keys(obj));
console.log(Reflect.has(obj, "name"));
console.log(Reflect.deleteProperty(obj, "age"));
console.log(Reflect.ownKeys(obj));
Promise
这个也是ES6的,之前已经讲过了,这里不赘述
Promise
class类
独立定义类的语法,比起函数定义来说,结果会更清晰一点
// class 关键词
// function Person (name) {
// this.name = name
// }
// Person.prototype.say = function () {
// console.log(`hi, my name is ${this.name}`)
// }
class Person {
constructor (name) {
this.name = name
}
say () {
console.log(`hi, my name is ${this.name}`)
}
}
const p = new Person('tom')
p.say()
静态成员
static,可以直接用class的类调用
比如Promise.all就是静态成员
这其中,static静态成员的this,指向的就是类型。而不是实例对象
// static 方法
class Person {
constructor (name) {
this.name = name
}
say () {
console.log(`hi, my name is ${this.name}`)
}
static create (name) {
return new Person(name)
}
}
const tom = Person.create('tom')
tom.say()
类的继承
// extends 继承
class Person {
constructor (name) {
this.name = name
}
say () {
console.log(`hi, my name is ${this.name}`)
}
}
class Student extends Person {
constructor (name, number) {
super(name) // 父类构造函数
this.number = number
}
hello () {
super.say() // 调用父类成员
console.log(`my school number is ${this.number}`)
}
}
const s = new Student('jack', '100')
s.hello()
Set数据结构
每一个值在同一个set中是唯一的
// Set 数据结构
const s = new Set();
s.add(1).add(2).add(3).add(4).add(2);
// console.log(s);
// s.forEach((i) => console.log(i));
// for (let i of s) {
// console.log(i);
// }
// 与数组的length差不多是一个意思
// console.log(s.size)
// 是否包含100
// console.log(s.has(100))
// 删除某个值
// console.log(s.delete(3))
// console.log(s)
// 清空集合的全部内容
// s.clear()
// console.log(s)
// 应用场景:数组去重
// const arr = [1, 2, 1, 3, 4, 1];
// const result = Array.from(new Set(arr));
// const result = [...new Set(arr)];
// console.log(result);
// 弱引用版本 WeakSet
// 差异就是 Set 中会对所使用到的数据产生引用
// 即便这个数据在外面被消耗,但是由于 Set 引用了这个数据,所以依然不会回收
// 而 WeakSet 的特点就是不会产生引用,
// 一旦数据销毁,就可以被回收,所以不会产生内存泄漏问题。
Map数据结构
与对象非常类似,都是键值对集合
但是对象结构的键,只能是字符串类型
Map才算得上严格意义的键值对集合
// Map 数据结构
// const obj = {}
// obj[true] = 'value'
// obj[123] = 'value'
// obj[{ a: 1 }] = 'value'
// console.log(Object.keys(obj))
// console.log(obj['[object Object]'])
const m = new Map();
const tom = { name: "tom" };
m.set(tom, 90);
// Map(1) { { name: 'tom' } => 90 }
console.log(m);
console.log(m.get(tom));
// m.has()
// m.delete()
// m.clear()
m.forEach((value, key) => {
console.log(value, key);
});
// 弱引用版本 WeakMap
// 差异就是 Map 中会对所使用到的数据产生引用
// 即便这个数据在外面被消耗,但是由于 Map 引用了这个数据,所以依然不会回收
// 而 WeakMap 的特点就是不会产生引用,
// 一旦数据销毁,就可以被回收,所以不会产生内存泄漏问题。
Symbol
一种全新的原始数据类型
const s = Symbol();
console.log(s);
console.log(typeof s);
// 两个 Symbol 永远不会相等
console.log(Symbol() === Symbol());
// 使用 Symbol 为对象添加用不重复的键
// const obj = {}
// obj[Symbol()] = '123'
// obj[Symbol()] = '456'
// console.log(obj)
// 也可以在计算属性名中使用
// const obj = {
// [Symbol()]: 123
// }
// console.log(obj)
// 案例2:Symbol 模拟实现私有成员
// a.js ======================================
const name = Symbol();
const person = {
[name]: "zce",
say() {
console.log(this[name]);
},
};
// 只对外暴露 person
// b.js =======================================
// 由于无法创建出一样的 Symbol 值,
// 所以无法直接访问到 person 中的「私有」成员
// person[Symbol()]
person.say();
最主要的作用就是为对象添加一个独一无二的属性名
需要注意几点:
可以使用symbol的for方法来传入字符串获得一样的symbol数据(全局注册表)
如果内部传入的不是字符串,这个方法会自动转换为字符串,就会导致获得一样的symbol
const s1 = Symbol.for('foo')
const s2 = Symbol.for('foo')
console.log(s1 === s2)
console.log(Symbol.for(true) === Symbol.for('true'))
内置 Symbol 常量:
console.log(Symbol.iterator);
console.log(Symbol.hasInstance);
const obj = {
[Symbol.toStringTag]: "XObject",
};
console.log(obj.toString());
//[object XObject]
toStringTag就是一个内置的symbol常量
获取symbol属性名:
// Symbol 属性名获取
const obj = {
[Symbol()]: "symbol value",
foo: "normal value",
};
for (var key in obj) {
// 无法拿到symbol类型的属性名
console.log(key)
}
// symbol会被忽略掉
console.log(Object.keys(obj))
console.log(JSON.stringify(obj))
// 获取symbol属性名的方法:
console.log(Object.getOwnPropertySymbols(obj));
for...of 循环
以后会作为遍历所有数据结构的统一方式
1.对比于forEach循环,for of可以随时终止循环(break关键字)
2.伪数组数据也可以使用for of遍历,比如arguments数据。DOM操作的节点列表等
Set和Map:
// 遍历 Set 与遍历数组相同
const s = new Set(['foo', 'bar'])
for (const item of s) {
console.log(item)
}
// 遍历 Map 可以配合数组结构语法,直接获取键值
const m = new Map()
m.set('foo', '123')
m.set('bar', '345')
for (const [key, value] of m) {
console.log(key, value)
}
可迭代接口
for of以后会作为遍历所有数据结构的统一方式,但是普通对象不能被直接 for...of 遍历
原因:
ES中能够表示有结构的数据类型越来越多,为了给各种各样的数据结构提供一种统一的遍历方式,ES6提出了一个叫做Iterable的接口,而,实现Iterable接口就是for of的前提,只要数据结构实现了可迭代接口,那就可以使用for of遍历
可以在浏览器里对数据进行展开检查,在原型对象里,找到一个
这个就是interator接口约定的就是对象中必须要挂载的方法,调用这个方法:
再访问这个next方法:
持续调用next,value变成了bar,done还是false,调用第三次的时候,baz。done便成为了true。每调用一次next,指针就往后移动一位,done就是调用完的标志。
总结:所有的可以被for of遍历的数据结构都必须要实现这个可迭代接口,在内部必须要挂载一个这个interator的方法,这里面还有一个带有next方法的对象,不断调用next方法,就可以实现所有数据的遍历
// 迭代器(Iterator)
const set = new Set(['foo', 'bar', 'baz'])
const iterator = set[Symbol.iterator]()
// console.log(iterator.next())
// console.log(iterator.next())
// console.log(iterator.next())
// console.log(iterator.next())
// console.log(iterator.next())
while (true) {
const current = iterator.next()
if (current.done) {
break // 迭代已经结束了,没必要继续了
}
console.log(current.value)
}
实现interable可迭代接口
const obj = {
store: ["foo", "bar", "baz"],
[Symbol.iterator]: function () {
// 维护一个下标
let index = 0;
// 接受下当前的this
const self = this;
return {
next: function () {
const result = {
value: self.store[index],
done: index >= self.store.length,
};
index++;
return result;
},
};
},
};
for (const item of obj) {
console.log("循环体", item);
}
迭代器模式
// 迭代器设计模式
// 场景:你我协同开发一个任务清单应用
// 我的代码 ===============================
const todos = {
life: ['吃饭', '睡觉', '打豆豆'],
learn: ['语文', '数学', '外语'],
work: ['喝茶'],
// 提供统一遍历访问接口
each: function (callback) {
const all = [].concat(this.life, this.learn, this.work)
for (const item of all) {
callback(item)
}
},
// 提供迭代器(ES2015 统一遍历访问接口)
[Symbol.iterator]: function () {
const all = [...this.life, ...this.learn, ...this.work]
let index = 0
return {
next: function () {
return {
value: all[index],
done: index++ >= all.length
}
}
}
}
}
// 你的代码 ===============================
// for (const item of todos.life) {
// console.log(item)
// }
// for (const item of todos.learn) {
// console.log(item)
// }
// for (const item of todos.work) {
// console.log(item)
// }
todos.each(function (item) {
console.log(item)
})
console.log('-------------------------------')
for (const item of todos) {
console.log(item)
}
该使用场景就是要让你的代码和我的代码解耦,我的属性如论如何变化,都提供一个迭代器方法给你调用。
迭代器的意义就是:给外部提供一个统一的接口,让外部不再关心内部的结构到底是什么样子的。
生成器 generator
这个知识点之前的文章也提到了:
生成器generator
不再赘述,这里提供一个generator应用:
// Generator 应用
// 案例1:发号器
function * createIdMaker () {
let id = 1
while (true) {
yield id++
}
}
const idMaker = createIdMaker()
console.log(idMaker.next().value)
console.log(idMaker.next().value)
console.log(idMaker.next().value)
console.log(idMaker.next().value)
// 案例2:使用 Generator 函数实现 iterator 方法
const todos = {
life: ['吃饭', '睡觉', '打豆豆'],
learn: ['语文', '数学', '外语'],
work: ['喝茶'],
[Symbol.iterator]: function * () {
const all = [...this.life, ...this.learn, ...this.work]
for (const item of all) {
yield item
}
}
}
for (const item of todos) {
console.log(item)
}
他最重要的是避免异步编程回调函数嵌套过深,不过最后都可以使用async await语法糖搞定,也没啥好说的。。
ES2016、ES2017
- 数组的includes方法:
检查数组是否包含指定元素(相对于indexOf而言,它还可以查找NaN这样的数值) 指数运算符:
console.log(Math.pow(2, 10)) // 等效于 console.log(2 ** 10)
- Object.values
返回对象中所有值组成的数组 - Object.entries
是以数组的形式返回对象中所有的键值对,可以直接使用for of循环去遍历普通对象了。 Object.getOwnPropertyDescriptors
获取对象中的属性的完整描述信息。const p1 = { firstName: "chen", lastName: "ny", get fullName() { return this.firstName + this.lastName; }, }; // 直接使用assign,是把fullName当成了普通的属性去复制,这种情况可以使用getOwnPropertyDescriptors,获取对象属性的完整描述信息。 const descriptors = Object.getOwnPropertyDescriptors(p1); const p2 = Object.defineProperties({}, descriptors); p2.firstName = "zhao"; console.log(p2.fullName);
padStart / padEnd
String的原型方法添加了padStart / padEndconst books = { html: 5, css: 16, javascript: 128, }; for (const [name, value] of Object.entries(books)) { console.log(`${name.padEnd(16, "-")}|${value.toString().padStart(3, "0")}`); }
用给定的字符串去填充目标字符串的开始或者结束位置,直到达到指定的长度为止。
- 还有一个小变化,在函数参数中添加尾逗号
不影响到功能。这是一种写法上的标准。
- Async / Await
从这里开始彻底解决了异步嵌套过深的问题