目录
难点:Generator,Generator异步,Async函数
Reflect.get(target, name, receiver)
Class 的取值函数(getter)和存值函数(setter)
前言
本文主要是记录学习阮一峰老师的ECMAScript 6 入门的学习笔记,自我总结出每个部分最常用,且必须知晓的内容。
正文
难点:Generator,Generator异步,Async函数
常用篇
1.正是因为export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。
// 正确
export var a = 1;
// 正确 var a = 1;
export default a;
// 错误
export default var a = 1;
因为export default本质是将该命令后面的值,赋给default变量以后再默认,所以直接将一个值写在export default之后。
// 正确
export default 42;
// 报错
export 42;
2.箭头函数
// es6 箭头函数写法,当函数直接被return时,可以省略函数体的括号 const fn = (a, b) => a + b;
3.变量提升
var具有变量提升,let不具有,const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。 const常量定义比let使用场景多
{ let a = 20; }
console.log(a); // a is not defined
而这个简单的例子,会被编译为:
{ let _a = 20; }
console.log(a); // a is not defined
// ES5
console.log(a);// undefined
var a = 20;
// ES6
console.log(a);// a is not defined
let a = 20;
用来计数的循环变量泄露为全局变量。
var s = 'hello';
for (var i = 0; i < s.length; i++) {
console.log(s[i]);
}
console.log(i); // 5
上面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。
4.箭头函数中,没有this。如果你在箭头函数中使用了this,那么该this一定就是外层的this。
var person = {
name: 'tom',
getName: function() {
return this.name;
}
}
// 我们试图用ES6的写法来重构上面的对象
const person = {
name: 'tom',
getName: () => this.name
}
// 但是编译结果却是
var person = {
name: 'tom',
getName: function getName() {
return undefined.name;
}
};
// 可以稍做改动
const person = {
name: 'tom',
getName: function() {
return setTimeout(() => this.name, 1000);
}
}
// 编译之后变成
var person = {
name: 'tom',
getName: function getName() {
var _this = this; // 使用了我们在es5时常用的方式保存this引用
return setTimeout(function() {
return _this.name;
}, 1000);
}
};
5.模板字符串
// es6
const a = 20;
const b = 30;
const string = `${a}+${b}=${a+b}`;
// es5
var a = 20;
var b = 30;
var string = a + "+" + b + "=" + (a + b);
// 普通字符串
`In JavaScript '\n' is a line-feed.`
// 多行字符串
`In JavaScript this is
not legal.`
console.log(`string text line 1
string text line 2`);
//如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中。
$('#list').html(`
<ul>
<li>first</li>
<li>second</li>
</ul>
`);
//对象
let obj = {x: 1, y: 2};
`${obj.x + obj.y}`
// "3"
//函数
function fn() {
return "Hello World";
}
`foo ${fn()} bar`
// foo Hello World bar
使用 `` 将整个字符串包裹起来,而在其中使用 ${} 来包裹一个变量或者一个表达式。
如果大括号中的值不是字符串,将按照一般的规则转为字符串。比如,大括号中是一个对象,将默认调用对象的toString
方法。
由于模板字符串的大括号内部,就是执行 JavaScript 代码,因此如果大括号内部是一个字符串,将会原样输出。
`Hello ${'World'}`
如果需要引用模板字符串本身,在需要时执行,可以像下面这样写。
// 写法一
let str = 'return ' + '`Hello ${name}!`';
let func = new Function('name', str);
func('Jack') // "Hello Jack!"
// 写法二
let str = '(name) => `Hello ${name}!`';
let func = eval.call(null, str);
func('Jack') // "Hello Jack!"
6.解析结构
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
let { bar, foo } = { foo: "aaa", bar: "bbb" };
foo // "aaa"
bar // "bbb"
let { baz } = { foo: "aaa", bar: "bbb" };
baz // undefined
// es6
const { loading, clicked } = props; // 给一个默认值,当props对象中找不到loading时,loading就等于该默认值
const { loading = false, clicked } = props;
// 比如
// section1
import React, { Component } from 'react';
// section2
export { default } from './Button';
如果变量名与属性名不一致,必须写成下面这样。
let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"
let obj = { first: 'hello', last: 'world' };
let { first: f, last: l } = obj;
f // 'hello'
l // 'world'
这实际上说明,对象的解构赋值是下面形式的简写(参见《对象的扩展》一章)。
let { foo: foo, bar: bar } = { foo: "aaa", bar: "bbb" };
也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
let { foo: baz } = { foo: "aaa", bar: "bbb" };
baz // "aaa"
foo // error: foo is not defined
上面代码中,foo
是匹配的模式,baz
才是变量。真正被赋值的是变量baz
,而不是模式foo
。
如果要将一个已经声明的变量用于解构赋值,必须非常小心。
// 错误的写法
let x;
{x} = {x: 1};
// SyntaxError: syntax error
上面代码的写法会报错,因为 JavaScript 引擎会将{x}
理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。
// 正确的写法
let x;
({x} = {x: 1});
另外,数组也有属于自己的解析结构。
// es6
const arr = [1, 2, 3];
const [a, b, c] = arr;
数组以序列号一一对应,这是一个有序的对应关系。
而对象根据属性名一一对应,这是一个无序的对应关系
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。
let [foo, [[bar], baz]] = [1, [[2], 3]];
foo // 1
bar // 2
baz // 3
解构赋值允许指定默认值。
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,默认值才会生效。
let [x = 1] = [undefined];
x // 1
let [x = 1] = [null];
x // null
默认值可以引用解构赋值的其他变量,但该变量必须已经声明。
let [x = 1, y = x] = []; // x=1; y=1
let [x = 1, y = x] = [2]; // x=2; y=2
let [x = 1, y = x] = [1, 2]; // x=1; y=2
let [x = y, y = 1] = []; // ReferenceError: y is not defined
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
const [a, b, c, d, e] = 'hello';
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"
类似数组的对象都有一个length
属性,因此还可以对这个属性解构赋值。
let {length : len} = 'hello';
len // 5
解构赋值用途
变量的解构赋值用途很多。
(1)交换变量的值
let x = 1; let y = 2; [x, y] = [y, x];
上面代码交换变量
x
和y
的值,这样的写法不仅简洁,而且易读,语义非常清晰。(2)从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
// 返回一个数组 function example() { return [1, 2, 3]; } let [a, b, c] = example(); // 返回一个对象 function example() { return { foo: 1, bar: 2 }; } let { foo, bar } = example();
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。
// 参数是一组有次序的值 function f([x, y, z]) { ... } f([1, 2, 3]); // 参数是一组无次序的值 function f({x, y, z}) { ... } f({z: 3, y: 2, x: 1});
(4)提取 JSON 数据
解构赋值对提取 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)函数参数的默认值
jQuery.ajax = function (url, { async = true, beforeSend = function () {}, cache = true, complete = function () {}, crossDomain = false, global = true, // ... more config } = {}) { // ... do stuff };
指定参数的默认值,就避免了在函数体内部再写
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
如果只想获取键名,或者只想获取键值,可以写成下面这样。
// 获取键名 for (let [key] of map) { // ... } // 获取键值 for (let [,value] of map) { // ... }
(7)输入模块的指定方法
加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。
const { SourceMapConsumer, SourceNode } = require("source-map");
6.函数默认参数
function add(x = 20, y = 30) {
return x + y;
}
console.log(add());
在实际开发中给参数添加适当的默认值,可以让我们对函数的参数类型有一个直观的认知。
const ButtonGroupProps = {
size: 'normal',
className: 'xxxx-button-group',
borderColor: '#333'
}
export default function ButtonGroup(props = ButtonGroupProps) { ......
}
7.展开运算符
在ES6中用...来表示展开运算符,它可以将数组方法或者对象进行展开
const arr1 = [1, 2, 3];
const arr2 = [...arr1, 10, 20, 30]; // 这样,arr2 就变成了[1, 2, 3, 10, 20, 30];
当然,展开对象数据也是可以得到类似的结果
const obj1 = {
a: 1,
b: 2,
c: 3
}
const obj2 = { ...obj1,
d: 4,
e: 5,
f: 6
}
展开运算符还常常运用在解析结构之中,例如我们在Raect封装组件的时候常常不确定props到底还有多少数据会传进来,就会利用展开运算符来处理剩余的数据。
// 这种方式在react中十分常用
const props = {
size: 1,
src: 'xxxx',
mode: 'si'
}
const {
size,
...others
} = props;
console.log(others) // 然后再利用暂开运算符传递给下一个元素,再以后封装react组件时会大量使用到这种方式,正在学习react的同学一定要搞懂这种使用方式 <button {...others} size={size} />
展开运算符还用在函数的参数中,来表示函数的不定参。只有放在最后才能作为函数的不定参,否则会报错。
// 所有参数之和
const add = (a, b, ...more) => {
return more.reduce((m, n) => m + n) + a + b //reduce 将数组元素执行函数处理
}
console.log(add(1, 23, 1, 2, 3, 4, 5)) // 39
8.对象字面量 与 class
- 当属性与值的变量同名时。
const name = 'Jane';
const age = 20
// es6
const person = {
name,
age
}
// es5
var person = {
name: name,
age: age
};
除了属性之外,对象字面量写法中的方法也可以有简写方式。
// es6
const person = {
name,
age,
getName() { // 只要不使用箭头函数,this就还是我们熟悉的this return this.name } }
// es5
var person = {
name: name,
age: age,
getName: function getName() {
return this.name;
}
};
- 在对象字面量中可以使用中括号作为属性,表示属性也能是一个变量了。
const name = 'Jane';
const age = 20
const person = {
[name]: true,
[age]: true
}
- class
// ES6 class
Person {
constructor(name, age) {
// 构造函数
this.name = name;
this.age = age;
}
getName() {
// 原型方法
return this.name
}
}
babel会将ES6的写法编译成为利用Object.defineProperty实现的方式,这个方法的具体用处大家可以在《JavaScript高级编程3》中学习了解,包括get,set,等都有详细的说明
除此之外,我们还需要特别注意在实际使用中的几种写法方式的不同,在下面的例子注释中,我说明了他们分别对应的ES5中的含义。
class Person {
constructor(name, age) {
// 构造函数
this.name = name;
this.age = age;
}
getName() {
// 这种写法表示将方法添加到原型中
return this.name
}
static a = 20; // 等同于 Person.a = 20
c = 20; // 表示在构造函数中添加属性 在构造函数中等同于 this.c = 20
// 箭头函数的写法表示在构造函数中添加方法,在构造函数中等同于this.getAge = function() {}
getAge = () => this.age
}
箭头函数需要注意的仍然是this的指向问题,因为箭头函数this指向不能被改变的特性,因此在react组件中常常利用这个特性来在不同的组件进行传值会更加方便。
- 继承 extends
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
getName() {
return this.name
}
}
// Student类继承Person类
class Student extends Person {
constructor(name, age, gender, classes) {
super(name, age);
this.gender = gender;
this.classes = classes;
}
getGender() {
return this.gender;
}
}
在继承的构造函数中,我们必须如上面的例子那么调用一次super方法,它表示构造函数的继承,与ES5中利用call/apply继承构造函数是一样的功能。
明确的知道哪些属性方法是放在构造函数中,哪些属性方法是放到了原型中
8.“暂时性死区”也意味着typeof不再是一个百分之百安全的操作。
typeof x; // ReferenceError
let x;
上面代码中,变量x使用let命令声明,所以在声明之前,都属于x的“死区”,只要用到该变量就会报错。因此,typeof运行时就会抛出一个ReferenceError。
作为比较,如果一个变量根本没有被声明,使用typeof反而不会报错。
typeof undeclared_variable // "undefined"
9.考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
// 函数声明语句
{
let a = 'secret';
function f() {
return a;
}
}
// 函数表达式
{
let a = 'secret';
let f = function() {
return a;
};
}
字符串篇
1.字符串的遍历器接口
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
循环会正确识别出这一个字符。
codePointAt
方法是测试一个字符由两个字节还是由四个字节组成的最简单方法。
function is32Bit(c) {
return c.codePointAt(0) > 0xFFFF;
}
is32Bit("?") // true
is32Bit("a") // false
2.合成字符串比较法 normalize()
'\u01D1'==='\u004F\u030C' //false
'\u01D1'.length // 1
'\u004F\u030C'.length // 2
上面代码表示,JavaScript 将合成字符视为两个字符,导致两种表示方法不相等。
ES6 提供字符串实例的normalize()
方法,用来将字符的不同表示方法统一为同样的形式,这称为 Unicode 正规化。
'\u01D1'.normalize() === '\u004F\u030C'.normalize()
// true
3.确定一个字符串是否包含在另一个字符串中
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
个位置直到字符串结束。
4.重复字符串
repeat
方法返回一个新字符串,表示将原字符串重复n
次。
'x'.repeat(3) // "xxx"
'hello'.repeat(2) // "hellohello"
'na'.repeat(0) // ""
5.补全字符串
如果某个字符串不够指定长度,会在头部或尾部补全。padStart()
用于头部补全,padEnd()
用于尾部补全。
如果用来补全的字符串与原字符串,两者的长度之和超过了指定的最小长度,则会截去超出位数的补全字符串。
'abc'.padStart(10, '0123456789')
// '0123456abc'
padStart
的常见用途是为数值补全指定位数。下面代码生成 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"
正则表达式篇
除了u
修饰符,ES6 还为正则表达式添加了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_"]
实际上,y
修饰符号隐含了头部匹配的标志^
。
/b/y.exec('aba')
// null
上面代码由于不能保证头部匹配,所以返回null
。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
修饰符的提取结果是一样的。但是,一旦出现非法字符,两者的行为就不一样了。
tokenize(TOKEN_Y, '3x + 4')
// [ '3' ]
tokenize(TOKEN_G, '3x + 4')
// [ '3', '+', '4' ]
上面代码中,g
修饰符会忽略非法字符,而y
修饰符不会,这样就很容易发现错误。
2.s 修饰符:dotAll 模式
/foo.bar/.test('foo\nbar')
// false
上面代码中,因为.
不匹配\n
,所以正则表达式返回false
。
但是,很多时候我们希望匹配的是任意单个字符,这时有一种变通的写法。
/foo[^]bar/.test('foo\nbar')
// true
这种解决方案毕竟不太符合直觉,ES2018 引入s
修饰符,使得.
可以匹配任意单个字符。
/foo.bar/s.test('foo\nbar') // true
3.具名组匹配
使用exec
方法,就可以将这三组匹配结果提取出来。
const RE_DATE = /(\d{4})-(\d{2})-(\d{2})/;
const matchObj = RE_DATE.exec('1999-12-31');
const year = matchObj[1]; // 1999
const month = matchObj[2]; // 12
const day = matchObj[3]; // 31
组匹配的一个问题是,每一组的匹配含义不容易看出来,而且只能用数字序号(比如matchObj[1]
)引用,要是组的顺序变了,引用的时候就必须修改序号。
ES2018 引入了具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用。
const 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
4.解构赋值和替换
有了具名组匹配以后,可以使用解构赋值直接从匹配结果上为变量赋值。
let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');
one // foo
two // bar
字符串替换时,使用$<组名>
引用具名组。
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}`;
});
具名组匹配在原来的基础上,新增了最后一个函数参数:具名组构成的一个对象。函数内部可以直接对这个对象进行解构赋值。
5.String.prototype.matchAll
如果一个正则表达式在字符串里面有多个匹配,现在一般使用g
修饰符或y
修饰符,在循环里面逐一取出。
var regex = /t(e)(st(\d?))/g;
var string = 'test1test2test3';
var matches = [];
var match;
while (match = regex.exec(string)) {
matches.push(match);
}
matches
// [
// ["test1", "e", "st1", "1", index: 0, input: "test1test2test3"],
// ["test2", "e", "st2", "2", index: 5, input: "test1test2test3"],
// ["test3", "e", "st3", "3", index: 10, input: "test1test2test3"]
// ]
上面代码中,while
循环取出每一轮的正则匹配,一共三轮。
目前有一个提案,增加了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
方法就可以了。
// 转为数组方法一
[...string.matchAll(regex)]
// 转为数组方法二
Array.from(string.matchAll(regex));
正则表达式实践:
全局匹配:如果不加标志g,会出现死循环
let reg = new RegExp(this.state.regex, "g");
while ((result = reg.exec(prevState.regexString))) {
results = [...results, result + "\n"];
}
数值 篇
Number.NaN 是一个特殊值,说明某些算术运算(如求负数的平方根)的结果不是数字。方法 parseInt() 和 parseFloat() 在不能解析指定的字符串时就返回这个值。对于一些常规情况下返回有效数字的函数,也可以采用这种方法,用 Number.NaN 说明它的错误情况。
ES6 提供了二进制和八进制数值的新的写法,分别用前缀0b
(或0B
)和0o
(或0O
)表示。
0b111110111 === 503 // true
0o767 === 503 // true
如果要将0b
和0o
前缀的字符串数值转为十进制,要使用Number
方法。
Number('0b111') // 7
Number('0o10') // 8
2.Number.isFinite(), Number.isNaN()
范围 Number.isFinite() > Number.isNaN()
ES6 在Number
对象上,新提供了Number.isFinite()
和Number.isNaN()
两个方法。
Number.isFinite()
用来检查一个数值是否为有限的(finite),即不是Infinity
。
Number.isFinite(15); // true
Number.isFinite(0.8); // true
Number.isFinite(NaN); // false
Number.isFinite(Infinity); // false
Number.isFinite(-Infinity); // false
Number.isFinite('foo'); // false
Number.isFinite('15'); // false
Number.isFinite(true); // false
注意,如果参数类型不是数值,Number.isFinite
一律返回false
。
Number.isNaN()
用来检查一个值是否为NaN
。(NaN属于Number类型)
Number.isNaN(NaN) // true
Number.isNaN(15) // false
Number.isNaN('15') // false
Number.isNaN(true) // false
Number.isNaN(9/NaN) // true
Number.isNaN('true' / 0) // true
Number.isNaN('true' / 'true') // true
如果参数类型不是NaN
,Number.isNaN
一律返回false
。
3.Number.isInteger()
用来判断一个数值是否为整数。
Number.isInteger(25) // true
Number.isInteger(25.1) // false
JavaScript 内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。
Number.isInteger(25) // true
Number.isInteger(25.0) // true
Math.trunc
方法用于去除一个数的小数部分,返回整数部分。
Math.trunc(4.1) // 4
Math.trunc(4.9) // 4
Math.trunc(-4.1) // -4
Math.trunc(-4.9) // -4
Math.trunc(-0.1234) // -0
对于没有部署这个方法的环境,可以用下面的代码模拟。
Math.trunc = Math.trunc || function(x) {
return x < 0 ? Math.ceil(x) : Math.floor(x);
};
函数 篇
1.参数默认值应用
- 函数参数默认值惰性求值
一个容易忽略的地方是,参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。
let x = 99;
function foo(p = x + 1) {
console.log(p);
}
foo() // 100
x = 100;
foo() // 101
上面代码中,参数p
的默认值是x + 1
。这时,每次调用函数foo
,都会重新计算x + 1
,而不是默认p
等于 100。
- 与解构赋值默认值结合使用
参数默认值可以与解构赋值的默认值,结合起来使用。
function foo({x, y = 5}) {
console.log(x, y);
}
foo({}) // undefined 5
foo({x: 1}) // 1 5
foo({x: 1, y: 2}) // 1 2
foo() // TypeError: Cannot read property 'x' of undefined
上面代码只使用了对象的解构赋值默认值,没有使用函数参数的默认值。只有当函数foo
的参数是一个对象时,变量x
和y
才会通过解构赋值生成。如果函数foo
调用时没提供参数,变量x
和y
就不会生成,从而报错。通过提供函数参数的默认值,就可以避免这种情况。
function foo({x, y = 5} = {}) {
console.log(x, y);
}
foo() // undefined 5
上面代码指定,如果没有提供参数,函数foo
的参数默认为一个空对象。
作为练习,请问下面两种写法有什么差别?
// 写法一
function m1({x = 0, y = 0} = {}) {
return [x, y];
}
// 写法二
function m2({x, y} = { x: 0, y: 0 }) {
return [x, y];
}
上面两种写法都对函数的参数设定了默认值,区别是写法一函数参数的默认值是空对象,但是设置了对象解构赋值的默认值;写法二函数参数的默认值是一个有具体属性的对象,但是没有设置对象解构赋值的默认值。
// 函数没有参数的情况
m1() // [0, 0]
m2() // [0, 0]
// x 和 y 都有值的情况
m1({x: 3, y: 8}) // [3, 8]
m2({x: 3, y: 8}) // [3, 8]
// x 有值,y 无值的情况
m1({x: 3}) // [3, 0]
m2({x: 3}) // [3, undefined]
// x 和 y 都无值的情况
m1({}) // [0, 0];
m2({}) // [undefined, undefined]
m1({z: 3}) // [0, 0]
m2({z: 3}) // [undefined, undefined]
-
参数默认值的位置
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
// 例一
function f(x = 1, y) {
return [x, y];
}
f() // [1, undefined]
f(2) // [2, undefined])
f(, 1) // 报错
f(undefined, 1) // [1, 1]
// 例二
function f(x, y = 5, z) {
return [x, y, z];
}
f() // [undefined, 5, undefined]
f(1) // [1, 5, undefined]
f(1, ,2) // 报错
f(1, undefined, 2) // [1, 5, 2]
如果传入undefined
,将触发该参数等于默认值,null
则没有这个效果。
function foo(x = 5, y = 6) {
console.log(x, y);
}
foo(undefined, null)
// 5 null
参数默认值 应用
1.利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
function throwIfMissing() {
throw new Error('Missing parameter');
}
function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}
foo()
// Error: Missing parameter
上面代码的foo
函数,如果调用的时候没有参数,就会调用默认值throwIfMissing
函数,从而抛出一个错误。
从上面代码还可以看到,参数mustBeProvided
的默认值等于throwIfMissing
函数的运行结果(注意函数名throwIfMissing
之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行。如果参数已经赋值,默认值中的函数就不会运行。
另外,可以将参数默认值设为undefined
,表明这个参数是可以省略的。
function foo(optional = undefined) { ··· }
2.传参个数-length 函数的 length 属性
指定了默认值以后,函数的length
属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length
属性将失真。
(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 5) {}).length // 2
这是因为length
属性的含义是,该函数预期传入的参数个数.
如果设置了默认值的参数不是尾参数,那么length
属性也不再计入后面的参数了。
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1
var x = 1;
function foo(x, y = function() { x = 2; }) {
var x = 3;
y();
console.log(x);
}
foo() // 3
x // 1
3.作用域
上面代码中,函数foo
的参数形成一个单独作用域。这个作用域里面,首先声明了变量x
,然后声明了变量y
,y
的默认值是一个匿名函数。这个匿名函数内部的变量x
,指向同一个作用域的第一个参数x
。函数foo
内部又声明了一个内部变量x
,该变量与第一个参数x
由于不是同一个作用域,所以不是同一个变量,因此执行y
后,内部变量x
和外部全局变量x
的值都没变。
如果将var x = 3
的var
去除,函数foo
的内部变量x
就指向第一个参数x
,与匿名函数内部的x
是一致的,所以最后输出的就是2
,而外层的全局变量x
依然不受影响。
var x = 1;
function foo(x, y = function() { x = 2; }) {
x = 3;
y();
console.log(x);
}
foo() // 2
x // 1
4.箭头函数
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
// 报错
let getTempItem = id => { id: id, name: "Temp" };
// 不报错
let getTempItem = id => ({ id: id, name: "Temp" });
下面是一种特殊情况,虽然可以运行,但会得到错误的结果。
let foo = () => { a: 1 };
foo() // undefined
上面代码中,原始意图是返回一个对象{ a: 1 }
,但是由于引擎认为大括号是代码块,所以执行了一行语句a: 1
。这时,a
可以被解释为语句的标签,因此实际执行的语句是1;
,然后函数就结束了,没有返回值。
箭头函数使用注意点
箭头函数有几个使用注意点。
(1)函数体内的this
对象,就是定义时所在的对象,而不是使用时所在的对象。
(2)不可以当作构造函数,也就是说,不可以使用new
命令,否则会抛出一个错误。
(3)不可以使用arguments
对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
(4)不可以使用yield
命令,因此箭头函数不能用作 Generator 函数。
上面四点中,第一点尤其值得注意。this
对象的指向是可变的,但是在箭头函数中,它是固定的。
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}
var id = 21;
foo.call({ id: 42 });
// id: 42
上面代码中,setTimeout
的参数是一个箭头函数,这个箭头函数的定义生效是在foo
函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时this
应该指向全局对象window
,这时应该输出21
。但是,箭头函数导致this
总是指向函数定义生效时所在的对象(本例是{id: 42}
),所以输出的是42
。
箭头函数可以让setTimeout
里面的this
,绑定定义时所在的作用域,而不是指向运行时所在的作用域。下面是另一个例子。
function Timer() {
this.s1 = 0;
this.s2 = 0;
// 箭头函数
setInterval(() => this.s1++, 1000);
// 普通函数
setInterval(function () {
this.s2++;
}, 1000);
}
var timer = new Timer();
setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);
// s1: 3
// s2: 0
上面代码中,Timer
函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this
绑定定义时所在的作用域(即Timer
函数),后者的this
指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1
被更新了 3 次,而timer.s2
一次都没更新。
箭头函数可以让this
指向固定化,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。
var handler = {
id: '123456',
init: function() {
document.addEventListener('click',
event => this.doSomething(event.type), false);
},
doSomething: function(type) {
console.log('Handling ' + type + ' for ' + this.id);
}
};
上面代码的init
方法中,使用了箭头函数,这导致这个箭头函数里面的this
,总是指向handler
对象。否则,回调函数运行时,this.doSomething
这一行会报错,因为此时this
指向document
对象。
this
指向的固定化,并不是因为箭头函数内部有绑定this
的机制,实际原因是箭头函数根本没有自己的this
,导致内部的this
就是外层代码块的this
。正是因为它没有this
,所以也就不能用作构造函数。
5.尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A
的内部调用函数B
,那么在A
的调用帧上方,还会形成一个B
的调用帧。等到B
运行结束,将结果返回到A
,B
的调用帧才会消失。如果函数B
内部还调用函数C
,那就还有一个C
的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
// 等同于
function f() {
return g(3);
}
f();
// 等同于
g(3);
上面代码中,如果函数g
不是尾调用,函数f
就需要保存内部变量m
和n
的值、g
的调用位置等信息。但由于调用g
之后,函数f
就结束了,所以执行到最后一步,完全可以删除f(x)
的调用帧,只保留g(3)
的调用帧。
这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
function addOne(a){
var one = 1;
function inner(b){
return b + one;
}
return inner(a);
}
上面的函数不会进行尾调用优化,因为内层函数inner
用到了外层函数addOne
的内部变量one
。
尾递归 § ⇧
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}
factorial(5) // 120
上面代码是一个阶乘函数,计算n
的阶乘,最多需要保存n
个调用记录,复杂度 O(n) 。
如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5, 1) // 120
还有一个比较著名的例子,就是计算 Fibonacci 数列,也能充分说明尾递归优化的重要性。
非尾递归的 Fibonacci 数列实现如下。
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10) // 89
Fibonacci(100) // 堆栈溢出
Fibonacci(500) // 堆栈溢出
尾递归优化过的 Fibonacci 数列实现如下。
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
if( n <= 1 ) {return ac2};
return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity
由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,ES6 中只要使用尾递归,就不会发生栈溢出,相对节省内存。
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量total
,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算5
的阶乘,需要传入两个参数5
和1
?
两个方法可以解决这个问题。
- 方法一是在尾递归函数之外,再提供一个正常形式的函数。
function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n - 1, n * total);
}
function factorial(n) {
return tailFactorial(n, 1);
}
factorial(5) // 120
- 方法二就简单多了,就是采用 ES6 的函数默认值。
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5) // 120
上面代码中,参数total
有默认值1
,所以调用时不用提供这个值。
ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。
此前,函数定义和调用时,都不允许最后一个参数后面出现逗号。
function clownsEverywhere(
param1,
param2
) { /* ... */ }
clownsEverywhere(
'foo',
'bar'
);
上面代码中,如果在param2
或bar
后面加一个逗号,就会报错。
如果像上面这样,将参数写成多行(即每个参数占据一行),以后修改代码的时候,想为函数clownsEverywhere
添加第三个参数,或者调整参数的次序,就势必要在原来最后一个参数后面添加一个逗号。这对于版本管理系统来说,就会显示添加逗号的那一行也发生了变动。这看上去有点冗余,因此新的语法允许定义和调用时,尾部直接有一个逗号。
function clownsEverywhere(
param1,
param2,
) { /* ... */ }
clownsEverywhere(
'foo',
'bar',
);
这样的规定也使得,函数参数与数组和对象的尾逗号规则,保持一致了。
数组 篇
1.扩展运算符
扩展运算符后面还可以放置表达式。
const arr = [
...(x > 0 ? ['a'] : []),
'b',
];
由于扩展运算符可以展开数组,所以不再需要apply
方法,将数组转为函数的参数了。
// ES5 的写法
function f(x, y, z) {
// ...
}
var args = [0, 1, 2];
f.apply(null, args);
// ES6的写法
function f(x, y, z) {
// ...
}
let args = [0, 1, 2];
f(...args);
下面是扩展运算符取代apply
方法的一个实际的例子,应用Math.max
方法,简化求出一个数组最大元素的写法。
扩展运算符的应用 § ⇧
(1)复制数组
数组是复合的数据类型,直接复制的话,只是复制了指向底层数据结构的指针,而不是克隆一个全新的数组。
const a1 = [1, 2];
const a2 = a1;
a2[0] = 2;
a1 // [2, 2]
上面代码中,a2
并不是a1
的克隆,而是指向同一份数据的另一个指针。修改a2
,会直接导致a1
的变化。
ES5 只能用变通方法来复制数组。
const a1 = [1, 2];
const a2 = a1.concat();
a2[0] = 2;
a1 // [1, 2]
上面代码中,a1
会返回原数组的克隆,再修改a2
就不会对a1
产生影响。
扩展运算符提供了复制数组的简便写法。
const a1 = [1, 2];
// 写法一
const a2 = [...a1];
// 写法二
const [...a2] = a1;
上面的两种写法,a2
都是a1
的克隆。
与解构赋值结合
扩展运算符可以与解构赋值结合起来,用于生成数组。
// ES5
a = list[0], rest = list.slice(1)
// ES6
[a, ...rest] = list
下面是另外一些例子。
const [first, ...rest] = [1, 2, 3, 4, 5];
first // 1
rest // [2, 3, 4, 5]
const [first, ...rest] = [];
first // undefined
rest // []
const [first, ...rest] = ["foo"];
first // "foo"
rest // []
如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。
const [...butLast, last] = [1, 2, 3, 4, 5];
// 报错
const [first, ...middle, last] = [1, 2, 3, 4, 5];
// 报错
字符串
扩展运算符还可以将字符串转为真正的数组。
[...'hello']
// [ "h", "e", "l", "l", "o" ]
上面的写法,有一个重要的好处,那就是能够正确识别四个字节的 Unicode 字符。
'x\uD83D\uDE80y'.length // 4
[...'x\uD83D\uDE80y'].length // 3
上面代码的第一种写法,JavaScript 会将四个字节的 Unicode 字符,识别为 2 个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数,可以像下面这样写。
function length(str) {
return [...str].length;
}
length('x\uD83D\uDE80y') // 3
凡是涉及到操作四个字节的 Unicode 字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
let str = 'x\uD83D\uDE80y';
str.split('').reverse().join('')
// 'y\uDE80\uD83Dx'
[...str].reverse().join('')
// 'y\uD83D\uDE80x'
Array.from
方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。
下面是一个类似数组的对象,Array.from
将它转为真正的数组。
let arrayLike = {
'0': 'a',
'1': 'b',
'2': 'c',
length: 3
};
// ES5的写法
var arr1 = [].slice.call(arrayLike); // ['a', 'b', 'c']
// ES6的写法
let arr2 = Array.from(arrayLike); // ['a', 'b', 'c']
Array.from
还可以接受第二个参数,作用类似于数组的map
方法,用来对每个元素进行处理,将处理后的值放入返回的数组。
Array.from(arrayLike, x => x * x);
// 等同于
Array.from(arrayLike).map(x => x * x);
Array.from([1, 2, 3], (x) => x * x)
// [1, 4, 9]
下面的例子是取出一组 DOM 节点的文本内容。
let spans = document.querySelectorAll('span.name');
// map()
let names1 = Array.prototype.map.call(spans, s => s.textContent);
// Array.from()
let names2 = Array.from(spans, s => s.textContent)
下面的例子将数组中布尔值为false
的成员转为0
。
Array.from([1, , 2, , 3], (n) => n || 0)
// [1, 0, 2, 0, 3]
另一个例子是返回各种数据的类型。
function typesOf () {
return Array.from(arguments, value => typeof value)
}
typesOf(null, [], NaN)
// ['object', 'object', 'number']
Array.from()
可以将各种值转为真正的数组,并且还提供map
功能。这实际上意味着,只要有一个原始的数据结构,你就可以先对它的值进行处理,然后转成规范的数组结构,进而就可以使用数量众多的数组方法。
Array.from({ length: 2 }, () => 'jack')
// ['jack', 'jack']
上面代码中,Array.from
的第一个参数指定了第二个参数运行的次数。这种特性可以让该方法的用法变得非常灵活。
Array.from()
的另一个应用是,将字符串转为数组,然后返回字符串的长度。因为它能正确处理各种 Unicode 字符,可以避免 JavaScript 将大于\uFFFF
的 Unicode 字符,算作两个字符的 bug。
function countSymbols(string) {
return Array.from(string).length;
}
Array.of
方法用于将一组值,转换为数组。
Array.of(3, 11, 8) // [3,11,8]
Array.of(3) // [3]
Array.of(3).length // 1
这个方法的主要目的,是弥补数组构造函数Array()
的不足。因为参数个数的不同,会导致Array()
的行为有差异。
Array.of
基本上可以用来替代Array()
或new Array()
,并且不存在由于参数不同而导致的重载。它的行为非常统一。
Array.of() // []
Array.of(undefined) // [undefined]
Array.of(1) // [1]
Array.of(1, 2) // [1, 2]
4.数组实例的 copyWithin()
// 将3号位复制到0号位
[1, 2, 3, 4, 5].copyWithin(0, 3, 4)
// [4, 2, 3, 4, 5]
// -2相当于3号位,-1相当于4号位
[1, 2, 3, 4, 5].copyWithin(0, -2, -1)
// [4, 2, 3, 4, 5]
// 将3号位复制到0号位
[].copyWithin.call({length: 5, 3: 1}, 0, 3)
// {0: 1, 3: 1, length: 5}
// 将2号位到数组结束,复制到0号位
let i32a = new Int32Array([1, 2, 3, 4, 5]);
i32a.copyWithin(0, 2);
// Int32Array [3, 4, 5, 4, 5]
// 对于没有部署 TypedArray 的 copyWithin 方法的平台
// 需要采用下面的写法
[].copyWithin.call(new Int32Array([1, 2, 3, 4, 5]), 0, 3, 4);
// Int32Array [4, 2, 3, 4, 5]
5.数组实例的 find() 和 findIndex() § ⇧
数组实例的find
方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true
的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined
。
[1, 4, -5, 10].find((n) => n < 0)
// -5
上面代码找出数组中第一个小于 0 的成员。
[1, 5, 10, 15].find(function(value, index, arr) {
return value > 9;
}) // 10
上面代码中,find
方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。
数组实例的findIndex
方法的用法与find
方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1
。
[1, 5, 10, 15].findIndex(function(value, index, arr) {
return value > 9;
}) // 2
对象 篇
ES6 允许直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。
const foo = 'bar';
const baz = {foo};
baz // {foo: "bar"}
// 等同于
const baz = {foo: foo};
上面代码表明,ES6 允许在对象之中,直接写变量。这时,属性名为变量名, 属性值为变量的值。
除了属性简写,方法也可以简写。
const o = {
method() {
return "Hello!";
}
};
// 等同于
const o = {
method: function() {
return "Hello!";
}
};
CommonJS 模块输出一组变量,就非常合适使用简洁写法。
let ms = {};
function getItem (key) {
return key in ms ? ms[key] : null;
}
function setItem (key, value) {
ms[key] = value;
}
function clear () {
ms = {};
}
module.exports = { getItem, setItem, clear };
// 等同于
module.exports = {
getItem: getItem,
setItem: setItem,
clear: clear
};
属性名表达式
JavaScript 定义对象的属性,有两种方法。
// 方法一
obj.foo = true;
// 方法二
obj['a' + 'bc'] = 123;
属性名表达式与简洁表示法,不能同时使用,会报错。
// 报错
const foo = 'bar';
const bar = 'abc';
const baz = { [foo] };
// 正确
const foo = 'bar';
const baz = { [foo]: 'abc'};
注意,属性名表达式如果是一个对象,默认情况下会自动将对象转为字符串[object Object]
,这一点要特别小心。
const keyA = {a: 1};
const keyB = {b: 2};
const myObject = {
[keyA]: 'valueA',
[keyB]: 'valueB'
};
myObject // Object {[object Object]: "valueB"}
上面代码中,[keyA]
和[keyB]
得到的都是[object Object]
,所以[keyB]
会把[keyA]
覆盖掉,而myObject
最后只有一个[object Object]
属性。
由于数组是特殊的对象,所以对象的扩展运算符也可以用于数组。
let foo = { ...['a', 'b', 'c'] };
foo
// {0: "a", 1: "b", 2: "c"}
扩展运算符的覆盖作用:
这用来修改现有对象部分的属性就很方便了。
let newVersion = {
...previousVersion,
name: 'New Name' // Override the name property
};
上面代码中,newVersion
对象自定义了name
属性,其他属性全部复制自previousVersion
对象。
与数组的扩展运算符一样,对象的扩展运算符后面可以跟表达式。
const obj = {
...(x > 1 ? {a: 1} : {}),
b: 2,
};
2.Object.assign :合并对象操作
数组的处理
Object.assign
可以用来处理数组,但是会把数组视为对象。
Object.assign([1, 2, 3], [4, 5])
// [4, 5, 3]
上面代码中,Object.assign
把数组视为属性名为 0、1、2 的对象,因此源数组的 0 号属性4
覆盖了目标数组的 0 号属性1
。
常见用途
Object.assign
方法有很多用处。
(1)为对象添加属性
class Point {
constructor(x, y) {
Object.assign(this, {x, y});
}
}
上面方法通过Object.assign
方法,将x
属性和y
属性添加到Point
类的对象实例。
(2)为对象添加方法
Object.assign(SomeClass.prototype, {
someMethod(arg1, arg2) {
···
},
anotherMethod() {
···
}
});
// 等同于下面的写法
SomeClass.prototype.someMethod = function (arg1, arg2) {
···
};
SomeClass.prototype.anotherMethod = function () {
···
};
上面代码使用了对象属性的简洁表示法,直接将两个函数放在大括号中,再使用assign
方法添加到SomeClass.prototype
之中。
(3)克隆对象
function clone(origin) {
return Object.assign({}, origin);
}
上面代码将原始对象拷贝到一个空对象,就得到了原始对象的克隆。
不过,采用这种方法克隆,只能克隆原始对象自身的值,不能克隆它继承的值。如果想要保持继承链,可以采用下面的代码。
function clone(origin) {
let originProto = Object.getPrototypeOf(origin);
return Object.assign(Object.create(originProto), origin);
}
(4)合并多个对象
将多个对象合并到某个对象。
const merge =
(target, ...sources) => Object.assign(target, ...sources);
如果希望合并后返回一个新对象,可以改写上面函数,对一个空对象合并。
const merge =
(...sources) => Object.assign({}, ...sources);
(5)为属性指定默认值
const DEFAULTS = {
logLevel: 0,
outputFormat: 'html'
};
function processContent(options) {
options = Object.assign({}, DEFAULTS, options);
console.log(options);
// ...
}
上面代码中,DEFAULTS
对象是默认值,options
对象是用户提供的参数。Object.assign
方法将DEFAULTS
和options
合并成一个新对象,如果两者有同名属性,则option
的属性值会覆盖DEFAULTS
的属性值。
注意,由于存在浅拷贝的问题,DEFAULTS
对象和options
对象的所有属性的值,最好都是简单类型,不要指向另一个对象。否则,DEFAULTS
对象的该属性很可能不起作用。
const DEFAULTS = {
url: {
host: 'example.com',
port: 7070
},
};
processContent({ url: {port: 8000} })
// {
// url: {port: 8000}
// }
上面代码的原意是将url.port
改成 8000,url.host
不变。实际结果却是options.url
覆盖掉DEFAULTS.url
,所以url.host
就不存在了。
3.Object.fromEntries()
方法是Object.entries()
的逆操作,用于将一个键值对数组转为对象。
Object.fromEntries([
['foo', 'bar'],
['baz', 42]
])
// { foo: "bar", baz: 42 }
该方法的主要目的,是将键值对的数据结构还原为对象,因此特别适合将 Map 结构转为对象。
// 例一
const entries = new Map([
['foo', 'bar'],
['baz', 42]
]);
Object.fromEntries(entries)
// { foo: "bar", baz: 42 }
// 例二
const map = new Map().set('foo', true).set('bar', false);
Object.fromEntries(map)
// { foo: true, bar: false }
该方法的一个用处是配合URLSearchParams
对象,将查询字符串转为对象。
Object.fromEntries(new URLSearchParams('foo=bar&baz=qux'))
// { foo: "bar", baz: "qux" }
Symbol 篇
1.作为属性名的 Symbol
由于每一个 Symbol 值都是不相等的,这意味着 Symbol 值可以作为标识符,用于对象的属性名,就能保证不会出现同名的属性。这对于一个对象由多个模块构成的情况非常有用,能防止某一个键被不小心改写或覆盖。
let mySymbol = Symbol();
// 第一种写法
let a = {};
a[mySymbol] = 'Hello!';
// 第二种写法
let a = {
[mySymbol]: 'Hello!'
};
// 第三种写法
let a = {};
Object.defineProperty(a, mySymbol, { value: 'Hello!' });
// 以上写法都得到同样结果
a[mySymbol] // "Hello!"
注意,Symbol 值作为对象属性名时,不能用点运算符。
const mySymbol = Symbol();
const a = {};
a.mySymbol = 'Hello!';
a[mySymbol] // undefined
a['mySymbol'] // "Hello!"
Symbol 类型还可以用于定义一组常量,保证这组常量的值都是不相等的。
const log = {};
log.levels = {
DEBUG: Symbol('debug'),
INFO: Symbol('info'),
WARN: Symbol('warn')
};
console.log(log.levels.DEBUG, 'debug message');
console.log(log.levels.INFO, 'info message');
const obj = {};
let foo = Symbol("foo");
Object.defineProperty(obj, foo, {
value: "foobar",
});
for (let i in obj) {
console.log(i); // 无输出
}
Object.getOwnPropertyNames(obj)
// []
Object.getOwnPropertySymbols(obj)
// [Symbol(foo)]
上面代码中,使用Object.getOwnPropertyNames
方法得不到Symbol
属性名,需要使用Object.getOwnPropertySymbols
方法。
另一个新的 API,Reflect.ownKeys
方法可以返回所有类型的键名,包括常规键名和 Symbol 键名。
let obj = {
[Symbol('my_key')]: 1,
enum: 2,
nonEnum: 3
};
Reflect.ownKeys(obj)
// ["enum", "nonEnum", Symbol(my_key)]
Symbol.for()
与Symbol()
这两种写法,都会生成新的 Symbol。它们的区别是,前者会被登记在全局环境中供搜索,后者不会。Symbol.for()
不会每次调用就返回一个新的 Symbol 类型的值,而是会先检查给定的key
是否已经存在,如果不存在才会新建一个值。比如,如果你调用Symbol.for("cat")
30 次,每次都会返回同一个 Symbol 值,但是调用Symbol("cat")
30 次,会返回 30 个不同的 Symbol 值。
Symbol.for("bar") === Symbol.for("bar")
// true
Symbol("bar") === Symbol("bar")
// false
上面代码中,由于Symbol()
写法没有登记机制,所以每次调用都会返回一个不同的值。
Symbol.keyFor
方法返回一个已登记的 Symbol 类型值的key
。
let s1 = Symbol.for("foo");
Symbol.keyFor(s1) // "foo"
let s2 = Symbol("foo");
Symbol.keyFor(s2) // undefined
上面代码中,变量s2
属于未登记的 Symbol 值,所以返回undefined
。
需要注意的是,Symbol.for
为 Symbol 值登记的名字,是全局环境的,可以在不同的 iframe 或 service worker 中取到同一个值。
iframe = document.createElement('iframe');
iframe.src = String(window.location);
document.body.appendChild(iframe);
iframe.contentWindow.Symbol.for('foo') === Symbol.for('foo')
// true
上面代码中,iframe 窗口生成的 Symbol 值,可以在主页面得到。
2.Symbol.hasInstance
对象的Symbol.hasInstance
属性,指向一个内部方法。当其他对象使用instanceof
运算符,判断是否为该对象的实例时,会调用这个方法。比如,foo instanceof Foo
在语言内部,实际调用的是Foo[Symbol.hasInstance](foo)
。
class MyClass {
[Symbol.hasInstance](foo) {
return foo instanceof Array;
}
}
[1, 2, 3] instanceof new MyClass() // true
上面代码中,MyClass
是一个类,new MyClass()
会返回一个实例。该实例的Symbol.hasInstance
方法,会在进行instanceof
运算时自动调用,判断左侧的运算子是否为Array
的实例。
下面是另一个例子。
class Even {
static [Symbol.hasInstance](obj) {
return Number(obj) % 2 === 0;
}
}
// 等同于
const Even = {
[Symbol.hasInstance](obj) {
return Number(obj) % 2 === 0;
}
};
1 instanceof Even // false
2 instanceof Even // true
12345 instanceof Even // false
内置的 Symbol 值
除了定义自己使用的 Symbol 值以外,ES6 还提供了 11 个内置的 Symbol 值,指向语言内部使用的方法。主要作用于自定义api中的成员方法
1.Symbol.hasInstance
对象的Symbol.hasInstance
属性,指向一个内部方法。当其他对象使用instanceof
运算符,判断是否为该对象的实例时,会调用这个方法。比如,foo instanceof Foo
在语言内部,实际调用的是Foo[Symbol.hasInstance](foo)
。
class MyClass {
[Symbol.hasInstance](foo) {
return foo instanceof Array;
}
}
[1, 2, 3] instanceof new MyClass() // true
上面代码中,MyClass
是一个类,new MyClass()
会返回一个实例。该实例的Symbol.hasInstance
方法,会在进行instanceof
运算时自动调用,判断左侧的运算子是否为Array
的实例。
下面是另一个例子。
class Even {
static [Symbol.hasInstance](obj) {
return Number(obj) % 2 === 0;
}
}
// 等同于
const Even = {
[Symbol.hasInstance](obj) {
return Number(obj) % 2 === 0;
}
};
1 instanceof Even // false
2 instanceof Even // true
12345 instanceof Even // false
Set和Map 篇
1.Set
去除数组重复成员的方法。
// 去除数组的重复成员
[...new Set(array)]
这就提供了去除数组重复成员的另一种方法。
function dedupe(array) {
return Array.from(new Set(array));
}
dedupe([1, 1, 2, 3]) // [1, 2, 3]
向 Set 加入值的时候,不会发生类型转换,所以5
和"5"
是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(===
),主要的区别是NaN
等于自身,而精确相等运算符认为NaN
不等于自身。
let set = new Set();
let a = NaN;
let b = NaN;
set.add(a);
set.add(b);
set // Set {NaN}
上面代码向 Set 实例添加了两个NaN
,但是只能加入一个。这表明,在 Set 内部,两个NaN
是相等。
另外,两个对象总是不相等的。
let set = new Set();
set.add({});
set.size // 1
set.add({});
set.size // 2
上面代码表示,由于两个空对象不相等,所以它们被视为两个值。
因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。
let a = new Set([1, 2, 3]);
let b = new Set([4, 3, 2]);
// 并集
let union = new Set([...a, ...b]);
// Set {1, 2, 3, 4}
// 交集
let intersect = new Set([...a].filter(x => b.has(x)));
// set {2, 3}
// 差集
let difference = new Set([...a].filter(x => !b.has(x)));
// Set {1}
WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。
首先,WeakSet 的成员只能是对象,而不能是其他类型的值。
const ws = new WeakSet();
ws.add(1)
// TypeError: Invalid value used in weak set
ws.add(Symbol())
// TypeError: invalid value used in weak set
上面代码试图向 WeakSet 添加一个数值和Symbol
值,结果报错,因为 WeakSet 只能放置对象。
其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。
这是因为垃圾回收机制依赖引用计数,如果一个值的引用次数不为
0
,垃圾回收机制就不会释放这块内存。结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。
由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历。
其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。
WeakSet 不能遍历,是因为成员都是弱引用,随时可能消失,遍历机制无法保证成员的存在,很可能刚刚遍历结束,成员就取不到了。WeakSet 的一个用处,是储存 DOM 节点,而不用担心这些节点从文档移除时,会引发内存泄漏。
下面是 WeakSet 的另一个例子。
const foos = new WeakSet()
class Foo {
constructor() {
foos.add(this)
}
method () {
if (!foos.has(this)) {
throw new TypeError('Foo.prototype.method 只能在Foo的实例上调用!');
}
}
}
上面代码保证了Foo
的实例方法,只能在Foo
的实例上调用。这里使用 WeakSet 的好处是,foos
对实例的引用,不会被计入内存回收机制,所以删除实例的时候,不用考虑foos
,也不会出现内存泄漏。
Map
ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,Map 比 Object 更合适。
该数组的成员是一个个表示键值对的数组。
const map = new Map([
['name', '张三'],
['title', 'Author']
]);
map.size // 2
map.has('name') // true
map.get('name') // "张三"
map.has('title') // true
map.get('title') // "Author"
上面代码在新建 Map 实例时,就指定了两个键name
和title
。
Map
构造函数接受数组作为参数,实际上执行的是下面的算法。
const items = [
['name', '张三'],
['title', 'Author']
];
const map = new Map();
items.forEach(
([key, value]) => map.set(key, value)
);
只有对同一个对象的引用,Map 结构才将其视为同一个键。这一点要非常小心。
const map = new Map();
map.set(['a'], 555);
map.get(['a']) // undefined
上面代码的set
和get
方法,表面是针对同一个键,但实际上这是两个值,内存地址是不一样的,因此get
方法无法读取该键,返回undefined
。
同理,同样的值的两个实例,在 Map 结构中被视为两个键。
const map = new Map();
const k1 = ['a'];
const k2 = ['a'];
map
.set(k1, 111)
.set(k2, 222);
map.get(k1) // 111
map.get(k2) // 222
如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键,比如0
和-0
就是一个键,布尔值true
和字符串true
则是两个不同的键。另外,undefined
和null
也是两个不同的键。虽然NaN
不严格相等于自身,但 Map 将其视为同一个键。
let map = new Map();
map.set(-0, 123);
map.get(+0) // 123
map.set(true, 1);
map.set('true', 2);
map.get(true) // 1
map.set(undefined, 3);
map.set(null, 4);
map.get(undefined) // 3
map.set(NaN, 123);
map.get(NaN) // 123
set
方法返回的是当前的Map
对象,因此可以采用链式写法。
let map = new Map()
.set(1, 'a')
.set(2, 'b')
.set(3, 'c');
数据结构的互相转换
(1)Map 转为数组
前面已经提过,Map 转为数组最方便的方法,就是使用扩展运算符(...
)。
const myMap = new Map()
.set(true, 7)
.set({foo: 3}, ['abc']);
[...myMap]
// [ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]
(2)数组 转为 Map
将数组传入 Map 构造函数,就可以转为 Map。
new Map([
[true, 7],
[{foo: 3}, ['abc']]
])
// Map {
// true => 7,
// Object {foo: 3} => ['abc']
// }
(3)Map 转为对象
如果所有 Map 的键都是字符串,它可以无损地转为对象。
function strMapToObj(strMap) {
let obj = Object.create(null);
for (let [k,v] of strMap) {
obj[k] = v;
}
return obj;
}
const myMap = new Map()
.set('yes', true)
.set('no', false);
strMapToObj(myMap)
// { yes: true, no: false }
如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。
(4)对象转为 Map
function objToStrMap(obj) {
let strMap = new Map();
for (let k of Object.keys(obj)) {
strMap.set(k, obj[k]);
}
return strMap;
}
objToStrMap({yes: true, no: false})
// Map {"yes" => true, "no" => false}
(5)Map 转为 JSON
Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名都是字符串,这时可以选择转为对象 JSON。
function strMapToJson(strMap) {
return JSON.stringify(strMapToObj(strMap));
}
let myMap = new Map().set('yes', true).set('no', false);
strMapToJson(myMap)
// '{"yes":true,"no":false}'
另一种情况是,Map 的键名有非字符串,这时可以选择转为数组 JSON。
function mapToArrayJson(map) {
return JSON.stringify([...map]);
}
let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']);
mapToArrayJson(myMap)
// '[[true,7],[{"foo":3},["abc"]]]'
(6)JSON 转为 Map
JSON 转为 Map,正常情况下,所有键名都是字符串。
function jsonToStrMap(jsonStr) {
return objToStrMap(JSON.parse(jsonStr));
}
jsonToStrMap('{"yes": true, "no": false}')
// Map {'yes' => true, 'no' => false}
但是,有一种特殊情况,整个 JSON 就是一个数组,且每个数组成员本身,又是一个有两个成员的数组。这时,它可以一一对应地转为 Map。这往往是 Map 转为数组 JSON 的逆操作。
function jsonToMap(jsonStr) {
return new Map(JSON.parse(jsonStr));
}
jsonToMap('[[true,7],[{"foo":3},["abc"]]]')
// Map {true => 7, Object {foo: 3} => ['abc']}
WeakMap
与Map
的区别有两点。
首先,WeakMap
只接受对象作为键名(null
除外),不接受其他类型的值作为键名。
其次,WeakMap
的键名所指向的对象,不计入垃圾回收机制。
WeakMap
的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。
const e1 = document.getElementById('foo');
const e2 = document.getElementById('bar');
const arr = [
[e1, 'foo 元素'],
[e2, 'bar 元素'],
];
上面代码中,e1
和e2
是两个对象,我们通过arr
数组对这两个对象添加一些文字说明。这就形成了arr
对e1
和e2
的引用。
一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放e1
和e2
占用的内存。
// 不需要 e1 和 e2 的时候
// 必须手动删除引用
arr [0] = null;
arr [1] = null;
上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露。
WeakMap 就是为了解决这个问题而诞生的,它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用WeakMap
结构。当该 DOM 元素被清除,其所对应的WeakMap
记录就会自动被移除。
const wm = new WeakMap();
const element = document.getElementById('example');
wm.set(element, 'some information');
wm.get(element) // "some information"
上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element
的引用就是弱引用,不会被计入垃圾回收机制。
也就是说,上面的 DOM 节点对象的引用计数是1
,而不是2
。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。
总之,WeakMap
的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap
结构有助于防止内存泄漏。
注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。
const wm = new WeakMap();
let key = {};
let obj = {foo: 1};
wm.set(key, obj);
obj = null;
wm.get(key)
// Object {foo: 1}
上面代码中,键值obj
是正常引用。所以,即使在 WeakMap 外部消除了obj
的引用,WeakMap 内部的引用依然存在。
WeakMap 的用途
前文说过,WeakMap 应用的典型场合就是 DOM 节点作为键名。下面是一个例子。
let myElement = document.getElementById('logo');
let myWeakmap = new WeakMap();
myWeakmap.set(myElement, {timesClicked: 0});
myElement.addEventListener('click', function() {
let logoData = myWeakmap.get(myElement);
logoData.timesClicked++;
}, false);
上面代码中,myElement
是一个 DOM 节点,每当发生click
事件,就更新一下状态。我们将这个状态作为键值放在 WeakMap 里,对应的键名就是myElement
。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。
WeakMap 的另一个用处是部署私有属性。
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
_counter.set(this, counter);
_action.set(this, action);
}
dec() {
let counter = _counter.get(this);
if (counter < 1) return;
counter--;
_counter.set(this, counter);
if (counter === 0) {
_action.get(this)();
}
}
}
const c = new Countdown(2, () => console.log('DONE'));
c.dec()
c.dec()
// DONE
上面代码中,Countdown
类的两个内部属性_counter
和_action
,是实例的弱引用,所以如果删除实例,它们也就随之消失,不会造成内存泄漏。
Proxy 篇
在一定规则下对原始对象进行代理处理某些操作
下面是另一个拦截读取属性行为的例子。
var proxy = new Proxy({}, {
get: function(target, property) {
return 35;
}
});
proxy.time // 35
proxy.name // 35
proxy.title // 35
上面代码中,作为构造函数,Proxy
接受两个参数。第一个参数是所要代理的目标对象(上例是一个空对象),即如果没有Proxy
的介入,操作原来要访问的就是这个对象;第二个参数是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。比如,上面代码中,配置对象有一个get
方法,用来拦截对目标对象属性的访问请求。get
方法的两个参数分别是目标对象和所要访问的属性。可以看到,由于拦截函数总是返回35
,所以访问任何属性都得到35
。
注意,要使得Proxy
起作用,必须针对Proxy
实例(上例是proxy
对象)进行操作,而不是针对目标对象(上例是空对象)进行操作。
如果handler
没有设置任何拦截,那就等同于直接通向原对象。
var target = {};
var handler = {};
var proxy = new Proxy(target, handler);
proxy.a = 'b';
target.a // "b"
上面代码中,handler
是一个空对象,没有任何拦截效果,访问proxy
就等同于访问target
。
一个技巧是将 Proxy 对象,设置到object.proxy
属性,从而可以在object
对象上调用。
var object = { proxy: new Proxy(target, handler) };
Proxy 实例也可以作为其他对象的原型对象。
var proxy = new Proxy({}, {
get: function(target, property) {
return 35;
}
});
let obj = Object.create(proxy);
obj.time // 35
上面代码中,proxy
对象是obj
对象的原型,obj
对象本身并没有time
属性,所以根据原型链,会在proxy
对象上读取该属性,导致被拦截。
1.apply()
apply
方法拦截函数的调用、call
和apply
操作。
apply
方法可以接受三个参数,分别是目标对象、目标对象的上下文对象(this
)和目标对象的参数数组。
var handler = {
apply (target, ctx, args) {
return Reflect.apply(...arguments);
}
};
下面是一个例子。
var target = function () { return 'I am the target'; };
var handler = {
apply: function () {
return 'I am the proxy';
}
};
var p = new Proxy(target, handler);
p()
// "I am the proxy"
上面代码中,变量p
是 Proxy 的实例,当它作为函数调用时(p()
),就会被apply
方法拦截,返回一个字符串。
下面是另外一个例子。
var twice = {
apply (target, ctx, args) {
return Reflect.apply(...arguments) * 2;
}
};
function sum (left, right) {
return left + right;
};
var proxy = new Proxy(sum, twice);
proxy(1, 2) // 6
proxy.call(null, 5, 6) // 22
proxy.apply(null, [7, 8]) // 30
上面代码中,每当执行proxy
函数(直接调用或call
和apply
调用),就会被apply
方法拦截。
另外,直接调用Reflect.apply
方法,也会被拦截。
Reflect.apply(proxy, null, [9, 10]) // 38
2.Proxy.revocable()
Proxy.revocable
方法返回一个可取消的 Proxy 实例。
let target = {};
let handler = {};
let {proxy, revoke} = Proxy.revocable(target, handler);
proxy.foo = 123;
proxy.foo // 123
revoke();
proxy.foo // TypeError: Revoked
Proxy.revocable
方法返回一个对象,该对象的proxy
属性是Proxy
实例,revoke
属性是一个函数,可以取消Proxy
实例。上面代码中,当执行revoke
函数之后,再访问Proxy
实例,就会抛出一个错误。
Proxy.revocable
的一个使用场景是,目标对象不允许直接访问,必须通过代理访问,一旦访问结束,就收回代理权,不允许再次访问。
this 问题
虽然 Proxy 可以代理针对目标对象的访问,但它不是目标对象的透明代理,即不做任何拦截的情况下,也无法保证与目标对象的行为一致。主要原因就是在 Proxy 代理的情况下,目标对象内部的this
关键字会指向 Proxy 代理。
const target = {
m: function () {
console.log(this === proxy);
}
};
const handler = {};
const proxy = new Proxy(target, handler);
target.m() // false
proxy.m() // true
上面代码中,一旦proxy
代理target.m
,后者内部的this
就是指向proxy
,而不是target
。
下面是一个例子,由于this
指向的变化,导致 Proxy 无法代理目标对象。
const _name = new WeakMap();
class Person {
constructor(name) {
_name.set(this, name);
}
get name() {
return _name.get(this);
}
}
const jane = new Person('Jane');
jane.name // 'Jane'
const proxy = new Proxy(jane, {});
proxy.name // undefined
上面代码中,目标对象jane
的name
属性,实际保存在外部WeakMap
对象_name
上面,通过this
键区分。由于通过proxy.name
访问时,this
指向proxy
,导致无法取到值,所以返回undefined
。
此外,有些原生对象的内部属性,只有通过正确的this
才能拿到,所以 Proxy 也无法代理这些原生对象的属性。
const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);
proxy.getDate();
// TypeError: this is not a Date object.
上面代码中,getDate
方法只能在Date
对象实例上面拿到,如果this
不是Date
对象实例就会报错。这时,this
绑定原始对象,就可以解决这个问题。
const target = new Date('2015-01-01');
const handler = {
get(target, prop) {
if (prop === 'getDate') {
return target.getDate.bind(target);
}
return Reflect.get(target, prop);
}
};
const proxy = new Proxy(target, handler);
proxy.getDate() // 1
Proxy实例:Web 服务的客户端
Proxy 对象可以拦截目标对象的任意属性,这使得它很合适用来写 Web 服务的客户端。
const service = createWebService('http://example.com/data');
service.employees().then(json => {
const employees = JSON.parse(json);
// ···
});
上面代码新建了一个 Web 服务的接口,这个接口返回各种数据。Proxy 可以拦截这个对象的任意属性,所以不用为每一种数据写一个适配方法,只要写一个 Proxy 拦截就可以了。
function createWebService(baseUrl) {
return new Proxy({}, {
get(target, propKey, receiver) {
return () => httpGet(baseUrl+'/' + propKey);
}
});
}
同理,Proxy 也可以用来实现数据库的 ORM 层。
Reflect 篇
Reflect
对象的方法与Proxy
对象的方法一一对应,只要是Proxy
对象的方法,就能在Reflect
对象上找到对应的方法。这就让Proxy
对象可以方便地调用对应的Reflect
方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy
怎么修改默认行为,你总可以在Reflect
上获取默认行为。
Proxy(target, {
set: function(target, name, value, receiver) {
var success = Reflect.set(target,name, value, receiver);
if (success) {
console.log('property ' + name + ' on ' + target + ' set to ' + value);
}
return success;
}
});
1.Reflect.get(target, name, receiver)
Reflect.get
方法查找并返回target
对象的name
属性,如果没有该属性,则返回undefined
。
var myObject = {
foo: 1,
bar: 2,
get baz() {
return this.foo + this.bar;
},
}
Reflect.get(myObject, 'foo') // 1
Reflect.get(myObject, 'bar') // 2
Reflect.get(myObject, 'baz') // 3
如果name
属性部署了读取函数(getter),则读取函数的this
绑定receiver
。
var myObject = {
foo: 1,
bar: 2,
get baz() {
return this.foo + this.bar;
},
};
var myReceiverObject = {
foo: 4,
bar: 4,
};
Reflect.get(myObject, 'baz', myReceiverObject) // 8
Reflect实例:使用 Proxy 实现观察者模式
观察者模式(Observer mode)指的是函数自动观察数据对象,一旦对象有变化,函数就会自动执行。
const person = observable({
name: '张三',
age: 20
});
function print() {
console.log(`${person.name}, ${person.age}`)
}
observe(print);
person.name = '李四';
// 输出
// 李四, 20
上面代码中,数据对象person
是观察目标,函数print
是观察者。一旦数据对象发生变化,print
就会自动执行。
下面,使用 Proxy 写一个观察者模式的最简单实现,即实现observable
和observe
这两个函数。思路是observable
函数返回一个原始对象的 Proxy 代理,拦截赋值操作,触发充当观察者的各个函数。
const queuedObservers = new Set();
const observe = fn => queuedObservers.add(fn);
const observable = obj => new Proxy(obj, {set});
function set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
queuedObservers.forEach(observer => observer());
return result;
}
上面代码中,先定义了一个Set
集合,所有观察者函数都放进这个集合。然后,observable
函数返回原始对象的代理,拦截赋值操作。拦截函数set
之中,会自动执行所有观察者。
Promise 篇
注意,调用resolve
或reject
并不会终结 Promise 的参数函数的执行。
new Promise((resolve, reject) => {
resolve(1);
console.log(2);
}).then(r => {
console.log(r);
});
// 2
// 1
上面代码中,调用resolve(1)
以后,后面的console.log(2)
还是会执行,并且会首先打印出来。这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。
一般来说,调用resolve
或reject
以后,Promise 的使命就完成了,后继操作应该放到then
方法里面,而不应该直接写在resolve
或reject
的后面。所以,最好在它们前面加上return
语句,这样就不会有意外。
new Promise((resolve, reject) => {
return resolve(1);
// 后面的语句不会执行
console.log(2);
})
Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch
语句捕获。
getJSON('/post/1.json').then(function(post) {
return getJSON(post.commentURL);
}).then(function(comments) {
// some code
}).catch(function(error) {
// 处理前面三个Promise产生的错误
});
上面代码中,一共有三个 Promise 对象:一个由getJSON
产生,两个由then
产生。它们之中任何一个抛出的错误,都会被最后一个catch
捕获。
一般来说,不要在then
方法里面定义 Reject 状态的回调函数(即then
的第二个参数),总是使用catch
方法。
// bad
promise
.then(function(data) {
// success
}, function(err) {
// error
});
// good
promise
.then(function(data) { //cb
// success
})
.catch(function(err) {
// error
});
上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面then
方法执行中的错误,也更接近同步的写法(try/catch
)。因此,建议总是使用catch
方法,而不使用then
方法的第二个参数。
跟传统的try/catch
代码块不同的是,如果没有使用catch
方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。
const someAsyncThing = function() {
return new Promise(function(resolve, reject) {
// 下面一行会报错,因为x没有声明
resolve(x + 2);
});
};
someAsyncThing().then(function() {
console.log('everything is great');
});
setTimeout(() => { console.log(123) }, 2000);
// Uncaught (in promise) ReferenceError: x is not defined
// 123
上面代码中,someAsyncThing
函数产生的 Promise 对象,内部有语法错误。浏览器运行到这一行,会打印出错误提示ReferenceError: x is not defined
,但是不会退出进程、终止脚本执行,2 秒之后还是会输出123
。这就是说,Promise 内部的错误不会影响到 Promise 外部的代码,通俗的说法就是“Promise 会吃掉错误”。
Iterator 篇
下面的例子是数组的Symbol.iterator
属性。
let arr = ['a', 'b', 'c'];
let iter = arr[Symbol.iterator]();
iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }
上面代码中,变量arr
是一个数组,原生就具有遍历器接口,部署在arr
的Symbol.iterator
属性上面。所以,调用这个属性,就得到遍历器对象。
对于原生部署 Iterator 接口的数据结构,不用自己写遍历器生成函数,for...of
循环会自动遍历它们。除此之外,其他数据结构(主要是对象)的 Iterator 接口,都需要自己在Symbol.iterator
属性上面部署,这样才会被for...of
循环遍历。
调用 Iterator 接口的场合 § ⇧
有一些场合会默认调用 Iterator 接口(即Symbol.iterator
方法),除了下文会介绍的for...of
循环,还有几个别的场合。
(1)解构赋值
对数组和 Set 结构进行解构赋值时,会默认调用Symbol.iterator
方法。
let set = new Set().add('a').add('b').add('c');
let [x,y] = set;
// x='a'; y='b'
let [first, ...rest] = set;
// first='a'; rest=['b','c'];
(2)扩展运算符
扩展运算符(...)也会调用默认的 Iterator 接口。
// 例一
var str = 'hello';
[...str] // ['h','e','l','l','o']
// 例二
let arr = ['b', 'c'];
['a', ...arr, 'd']
// ['a', 'b', 'c', 'd']
上面代码的扩展运算符内部就调用 Iterator 接口。
实际上,这提供了一种简便机制,可以将任何部署了 Iterator 接口的数据结构,转为数组。也就是说,只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组。
let arr = [...iterable];
(3)yield*
yield*
后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口。
let generator = function* () {
yield 1;
yield* [2,3,4];
yield 5;
};
var iterator = generator();
iterator.next() // { value: 1, done: false }
iterator.next() // { value: 2, done: false }
iterator.next() // { value: 3, done: false }
iterator.next() // { value: 4, done: false }
iterator.next() // { value: 5, done: false }
iterator.next() // { value: undefined, done: true }
(4)其他场合
由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。
- for...of
- Array.from()
- Map(), Set(), WeakMap(), WeakSet()(比如
new Map([['a',1],['b',2]])
) - Promise.all()
- Promise.race()
for...of
循环可以代替数组实例的forEach
方法。
const arr = ['red', 'green', 'blue'];
arr.forEach(function (element, index) {
console.log(element); // red green blue
console.log(index); // 0 1 2
});
JavaScript 原有的for...in
循环,只能获得对象的键名,不能直接获取键值。ES6 提供for...of
循环,允许遍历获得键值。
var arr = ['a', 'b', 'c', 'd'];
for (let a in arr) {
console.log(a); // 0 1 2 3
}
for (let a of arr) {
console.log(a); // a b c d
}
上面代码表明,for...in
循环读取键名,for...of
循环读取键值。如果要通过for...of
循环,获取数组的索引,可以借助数组实例的entries
方法和keys
方法
for...of
循环调用遍历器接口,数组的遍历器接口只返回具有数字索引的属性。这一点跟for...in
循环也不一样。
let arr = [3, 5, 7];
arr.foo = 'hello';
for (let i in arr) {
console.log(i); // "0", "1", "2", "foo"
}
for (let i of arr) {
console.log(i); // "3", "5", "7"
}
上面代码中,for...of
循环不会返回数组arr
的foo
属性。
并不是所有类似数组的对象都具有 Iterator 接口,一个简便的解决方法,就是使用Array.from
方法将其转为数组。
let arrayLike = { length: 2, 0: 'a', 1: 'b' };
// 报错
for (let x of arrayLike) {
console.log(x);
}
// 正确
for (let x of Array.from(arrayLike)) {
console.log(x);
}
对象 篇
对于普通的对象,for...of
结构不能直接使用,会报错,必须部署了 Iterator 接口后才能使用。但是,这样情况下,for...in
循环依然可以用来遍历键名。
let es6 = {
edition: 6,
committee: "TC39",
standard: "ECMA-262"
};
for (let e in es6) {
console.log(e);
}
// edition
// committee
// standard
for (let e of es6) {
console.log(e);
}
// TypeError: es6[Symbol.iterator] is not a function
上面代码表示,对于普通的对象,for...in
循环可以遍历键名,for...of
循环会报错。
一种解决方法是,使用Object.keys
方法将对象的键名生成一个数组,然后遍历这个数组。
for (var key of Object.keys(someObject)) {
console.log(key + ': ' + someObject[key]);
}
另一个方法是使用 Generator 函数将对象重新包装一下。
function* entries(obj) {
for (let key of Object.keys(obj)) {
yield [key, obj[key]];
}
}
for (let [key, value] of entries(obj)) {
console.log(key, '->', value);
}
// a -> 1
// b -> 2
// c -> 3
遍历语法的比较 § ⇧
以数组为例,JavaScript 提供多种遍历语法。最原始的写法就是for
循环。
for (var index = 0; index < myArray.length; index++) {
console.log(myArray[index]);
}
这种写法比较麻烦,因此数组提供内置的forEach
方法。
myArray.forEach(function (value) {
console.log(value);
});
这种写法的问题在于,无法中途跳出forEach
循环,break
命令或return
命令都不能奏效。
for...in
循环可以遍历数组的键名。
for (var index in myArray) {
console.log(myArray[index]);
}
for...in
循环有几个缺点。
- 数组的键名是数字,但是
for...in
循环是以字符串作为键名“0”、“1”、“2”等等。 for...in
循环不仅遍历数字键名,还会遍历手动添加的其他键,甚至包括原型链上的键。- 某些情况下,
for...in
循环会以任意顺序遍历键名。
总之,for...in
循环主要是为遍历对象而设计的,不适用于遍历数组。
for...of
循环相比上面几种做法,有一些显著的优点。
for (let value of myArray) {
console.log(value);
}
- 有着同
for...in
一样的简洁语法,但是没有for...in
那些缺点。 - 不同于
forEach
方法,它可以与break
、continue
和return
配合使用。 - 提供了遍历所有数据结构的统一操作接口。
下面是一个使用 break 语句,跳出for...of
循环的例子。
for (var n of fibonacci) {
if (n > 1000)
break;
console.log(n);
}
上面的例子,会输出斐波纳契数列小于等于 1000 的项。如果当前项大于 1000,就会使用break
语句跳出for...of
循环。
Generator 篇
Generator 函数可以不用yield
表达式,这时就变成了一个单纯的暂缓执行函数。
function* f() {
console.log('执行了!')
}
var generator = f();
setTimeout(function () {
generator.next()
}, 2000);
上面代码中,函数f
如果是普通函数,在为变量generator
赋值时就会执行。但是,函数f
是一个 Generator 函数,就变成只有调用next
方法时,函数f
才会执行。
另外需要注意,yield
表达式只能用在 Generator 函数里面,用在其他地方都会报错。
(function (){
yield 1;
})()
// SyntaxError: Unexpected number
下面是另外一个例子。
var arr = [1, [[2, 3], 4], [5, 6]];
var flat = function* (a) {
a.forEach(function (item) {
if (typeof item !== 'number') {
yield* flat(item);
} else {
yield item;
}
});
};
for (var f of flat(arr)){
console.log(f);
}
上面代码也会产生句法错误,因为forEach
方法的参数是一个普通函数,但是在里面使用了yield
表达式(这个函数里面还使用了yield*
表达式,详细介绍见后文)。一种修改方法是改用for
循环。
var arr = [1, [[2, 3], 4], [5, 6]];
var flat = function* (a) {
var length = a.length;
for (var i = 0; i < length; i++) {
var item = a[i];
if (typeof item !== 'number') {
yield* flat(item);
} else {
yield item;
}
}
};
for (var f of flat(arr)) {
console.log(f);
}
// 1, 2, 3, 4, 5, 6
另外,yield
表达式如果用在另一个表达式之中,必须放在圆括号里面。
function* demo() {
console.log('Hello' + yield); // SyntaxError
console.log('Hello' + yield 123); // SyntaxError
console.log('Hello' + (yield)); // OK
console.log('Hello' + (yield 123)); // OK
}
yield
表达式用作函数参数或放在赋值表达式的右边,可以不加括号。
function* demo() {
foo(yield 'a', yield 'b'); // OK
let input = yield; // OK
}
1.for...of 循环
for...of
循环可以自动遍历 Generator 函数时生成的Iterator
对象,且此时不再需要调用next
方法。
function* foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
for (let v of foo()) {
console.log(v);
}
// 1 2 3 4 5
上面代码使用for...of
循环,依次显示 5 个yield
表达式的值。这里需要注意,一旦next
方法的返回对象的done
属性为true
,for...of
循环就会中止,且不包含该返回对象,所以上面代码的return
语句返回的6
,不包括在for...of
循环之中。
下面是一个利用 Generator 函数和for...of
循环,实现斐波那契数列的例子。
function* fibonacci() {
let [prev, curr] = [0, 1];
for (;;) {
yield curr;
[prev, curr] = [curr, prev + curr];
}
}
for (let n of fibonacci()) {
if (n > 1000) break;
console.log(n);
}
从上面代码可见,使用for...of
语句时不需要使用next
方法。
2.Generator.prototype.return()
Generator 函数返回的遍历器对象,还有一个return
方法,可以返回给定的值,并且终结遍历 Generator 函数。
function* gen() {
yield 1;
yield 2;
yield 3;
}
var g = gen();
g.next() // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next() // { value: undefined, done: true }
上面代码中,遍历器对象g
调用return
方法后,返回值的value
属性就是return
方法的参数foo
。并且,Generator 函数的遍历就终止了,返回值的done
属性为true
,以后再调用next
方法,done
属性总是返回true
。
如果return
方法调用时,不提供参数,则返回值的value
属性为undefined
。
function* gen() {
yield 1;
yield 2;
yield 3;
}
var g = gen();
g.next() // { value: 1, done: false }
g.return() // { value: undefined, done: true }
如果 Generator 函数内部有try...finally
代码块,且正在执行try
代码块,那么return
方法会推迟到finally
代码块执行完再执行。
function* numbers () {
yield 1;
try {
yield 2;
yield 3;
} finally {
yield 4;
yield 5;
}
yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }
上面代码中,调用return
方法后,就开始执行finally
代码块,然后等到finally
代码块执行完,再执行return
方法。
next()、throw()、return() 的共同点
next()
、throw()
、return()
这三个方法本质上是同一件事,可以放在一起理解。它们的作用都是让 Generator 函数恢复执行,并且使用不同的语句替换yield
表达式。
个人理解:一次next()其实是从yield前语句执行到一个yield之后一个语句。可以理解为,通过一些工作,产出一些产品。return的产出和yield差不多,只是不能多次生产。
next()
是将yield
表达式替换成一个值。
const g = function* (x, y) {
let result = yield x + y;
return result;
};
const gen = g(1, 2);
gen.next(); // Object {value: 3, done: false}
gen.next(1); // Object {value: 1, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = 1;
上面代码中,第二个next(1)
方法就相当于将yield
表达式替换成一个值1
。如果next
方法没有参数,就相当于替换成undefined
。
throw()
是将yield
表达式替换成一个throw
语句。
gen.throw(new Error('出错了')); // Uncaught Error: 出错了
// 相当于将 let result = yield x + y
// 替换成 let result = throw(new Error('出错了'));
return()
是将yield
表达式替换成一个return
语句。
gen.return(2); // Object {value: 2, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = return 2;
如果被代理的 Generator 函数有return
语句,那么就可以向代理它的 Generator 函数返回数据。
function* foo() {
yield 2;
yield 3;
return "foo";
}
function* bar() {
yield 1;
var v = yield* foo();
console.log("v: " + v);
yield 4;
}
var it = bar();
it.next()
// {value: 1, done: false}
it.next()
// {value: 2, done: false}
it.next()
// {value: 3, done: false}
it.next();
// "v: foo"
// {value: 4, done: false}
it.next()
// {value: undefined, done: true}
上面代码在第四次调用next
方法的时候,屏幕上会有输出,这是因为函数foo
的return
语句,向函数bar
提供了返回值。
使用yield*
语句遍历完全二叉树。
// 下面是二叉树的构造函数,
// 三个参数分别是左树、当前节点和右树
function Tree(left, label, right) {
this.left = left;
this.label = label;
this.right = right;
}
// 下面是中序(inorder)遍历函数。
// 由于返回的是一个遍历器,所以要用generator函数。
// 函数体内采用递归算法,所以左树和右树要用yield*遍历
function* inorder(t) {
if (t) {
yield* inorder(t.left);
yield t.label;
yield* inorder(t.right);
}
}
// 下面生成二叉树
function make(array) {
// 判断是否为叶节点
if (array.length == 1) return new Tree(null, array[0], null);
return new Tree(make(array[0]), array[1], make(array[2]));
}
let tree = make([[['a'], 'b', ['c']], 'd', [['e'], 'f', ['g']]]);
// 遍历二叉树
var result = [];
for (let node of inorder(tree)) {
result.push(node);
}
result
// ['a', 'b', 'c', 'd', 'e', 'f', 'g']
将F
改成构造函数,就可以对它执行new
命令了。
function* gen() {
this.a = 1;
yield this.b = 2;
yield this.c = 3;
}
function F() {
return gen.call(gen.prototype);
}
var f = new F();
f.next(); // Object {value: 2, done: false}
f.next(); // Object {value: 3, done: false}
f.next(); // Object {value: undefined, done: true}
f.a // 1
f.b // 2
f.c // 3
Generator 与状态机
Generator 是实现状态机的最佳结构。比如,下面的clock
函数就是一个状态机。
var ticking = true;
var clock = function() {
if (ticking)
console.log('Tick!');
else
console.log('Tock!');
ticking = !ticking;
}
上面代码的clock
函数一共有两种状态(Tick
和Tock
),每运行一次,就改变一次状态。这个函数如果用 Generator 实现,就是下面这样。
var clock = function* () {
while (true) {
console.log('Tick!');
yield;
console.log('Tock!');
yield;
}
};
上面的 Generator 实现与 ES5 实现对比,可以看到少了用来保存状态的外部变量ticking
,这样就更简洁,更安全(状态不会被非法篡改)、更符合函数式编程的思想,在写法上也更优雅。Generator 之所以可以不用外部变量保存状态,是因为它本身就包含了一个状态信息,即目前是否处于暂停态。
应用 § ⇧
Generator 可以暂停函数执行,返回任意表达式的值。这种特点使得 Generator 有多种应用场景。
(1)异步操作的同步化表达
Generator 函数的暂停执行的效果,意味着可以把异步操作写在yield
表达式里面,等到调用next
方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield
表达式下面,反正要等到调用next
方法时再执行。所以,Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数。
function* loadUI() {
showLoadingScreen();
yield loadUIDataAsynchronously();
hideLoadingScreen();
}
var loader = loadUI();
// 加载UI
loader.next()
// 卸载UI
loader.next()
上面代码中,第一次调用loadUI
函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器调用next
方法,则会显示Loading
界面(showLoadingScreen
),并且异步加载数据(loadUIDataAsynchronously
)。等到数据加载完成,再一次使用next
方法,则会隐藏Loading
界面。可以看到,这种写法的好处是所有Loading
界面的逻辑,都被封装在一个函数,按部就班非常清晰。
Ajax 是典型的异步操作,通过 Generator 函数部署 Ajax 操作,可以用同步的方式表达。
function* main() {
var result = yield request("http://some.url");
var resp = JSON.parse(result);
console.log(resp.value);
}
function request(url) {
makeAjaxCall(url, function(response){
it.next(response);
});
}
var it = main();
it.next();
上面代码的main
函数,就是通过 Ajax 操作获取数据。可以看到,除了多了一个yield
,它几乎与同步操作的写法完全一样。注意,makeAjaxCall
函数中的next
方法,必须加上response
参数,因为yield
表达式,本身是没有值的,总是等于undefined
。
下面是另一个例子,通过 Generator 函数逐行读取文本文件。
function* numbers() {
let file = new FileReader("numbers.txt");
try {
while(!file.eof) {
yield parseInt(file.readLine(), 10);
}
} finally {
file.close();
}
}
上面代码打开文本文件,使用yield
表达式可以手动逐行读取文件。
(2)控制流管理
如果有一个多步操作非常耗时,采用回调函数,可能会写成下面这样。
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// Do something with value4
});
});
});
});
采用 Promise 改写上面的代码。
Promise.resolve(step1)
.then(step2)
.then(step3)
.then(step4)
.then(function (value4) {
// Do something with value4
}, function (error) {
// Handle any error from step1 through step4
})
.done();
上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise 的语法。Generator 函数可以进一步改善代码运行流程。
function* longRunningTask(value1) {
try {
var value2 = yield step1(value1);
var value3 = yield step2(value2);
var value4 = yield step3(value3);
var value5 = yield step4(value4);
// Do something with value4
} catch (e) {
// Handle any error from step1 through step4
}
}
然后,使用一个函数,按次序自动执行所有步骤。
scheduler(longRunningTask(initialValue));
function scheduler(task) {
var taskObj = task.next(task.value);
// 如果Generator函数未结束,就继续调用
if (!taskObj.done) {
task.value = taskObj.value
scheduler(task);
}
}
注意,上面这种做法,只适合同步操作,即所有的task
都必须是同步的,不能有异步操作。因为这里的代码一得到返回值,就继续往下执行,没有判断异步操作何时完成。如果要控制异步的操作流程,详见后面的《异步操作》一章。
下面,利用for...of
循环会自动依次执行yield
命令的特性,提供一种更一般的控制流管理的方法。
let steps = [step1Func, step2Func, step3Func];
function* iterateSteps(steps){
for (var i=0; i< steps.length; i++){
var step = steps[i];
yield step();
}
}
上面代码中,数组steps
封装了一个任务的多个步骤,Generator 函数iterateSteps
则是依次为这些步骤加上yield
命令。
将任务分解成步骤之后,还可以将项目分解成多个依次执行的任务。
let jobs = [job1, job2, job3];
function* iterateJobs(jobs){
for (var i=0; i< jobs.length; i++){
var job = jobs[i];
yield* iterateSteps(job.steps);
}
}
上面代码中,数组jobs
封装了一个项目的多个任务,Generator 函数iterateJobs
则是依次为这些任务加上yield*
命令。
最后,就可以用for...of
循环一次性依次执行所有任务的所有步骤。
for (var step of iterateJobs(jobs)){
console.log(step.id);
}
再次提醒,上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤。如果想要依次执行异步的步骤,必须使用后面的《异步操作》一章介绍的方法。
for...of
的本质是一个while
循环,所以上面的代码实质上执行的是下面的逻辑。
var it = iterateJobs(jobs);
var res = it.next();
while (!res.done){
var result = res.value;
// ...
res = it.next();
}
(3)部署 Iterator 接口
利用 Generator 函数,可以在任意对象上部署 Iterator 接口。
function* iterEntries(obj) {
let keys = Object.keys(obj);
for (let i=0; i < keys.length; i++) {
let key = keys[i];
yield [key, obj[key]];
}
}
let myObj = { foo: 3, bar: 7 };
for (let [key, value] of iterEntries(myObj)) {
console.log(key, value);
}
// foo 3
// bar 7
上述代码中,myObj
是一个普通对象,通过iterEntries
函数,就有了 Iterator 接口。也就是说,可以在任意对象上部署next
方法。
下面是一个对数组部署 Iterator 接口的例子,尽管数组原生具有这个接口。
function* makeSimpleGenerator(array){
var nextIndex = 0;
while(nextIndex < array.length){
yield array[nextIndex++];
}
}
var gen = makeSimpleGenerator(['yo', 'ya']);
gen.next().value // 'yo'
gen.next().value // 'ya'
gen.next().done // true
(4)作为数据结构
Generator 可以看作是数据结构,更确切地说,可以看作是一个数组结构,因为 Generator 函数可以返回一系列的值,这意味着它可以对任意表达式,提供类似数组的接口。
function* doStuff() {
yield fs.readFile.bind(null, 'hello.txt');
yield fs.readFile.bind(null, 'world.txt');
yield fs.readFile.bind(null, 'and-such.txt');
}
上面代码就是依次返回三个函数,但是由于使用了 Generator 函数,导致可以像处理数组那样,处理这三个返回的函数。
for (task of doStuff()) {
// task是一个函数,可以像回调函数那样使用它
}
实际上,如果用 ES5 表达,完全可以用数组模拟 Generator 的这种用法。
function doStuff() {
return [
fs.readFile.bind(null, 'hello.txt'),
fs.readFile.bind(null, 'world.txt'),
fs.readFile.bind(null, 'and-such.txt')
];
}
上面的函数,可以用一模一样的for...of
循环处理!两相一比较,就不难看出 Generator 使得数据或者操作,具备了类似数组的接口
Generator 异步
下面看看如何使用 Generator 函数,执行一个真实的异步任务。
var fetch = require('node-fetch');
function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}
上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield
命令。
执行这段代码的方法如下。
var g = gen();
var result = g.next();
result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});
上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next
方法(第二行),执行异步任务的第一阶段。由于Fetch
模块返回的是一个 Promise 对象,因此要用then
方法调用下一个next
方法。
可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。
JavaScript 语言的 Thunk 函数
JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。
// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);
// Thunk版本的readFile(单参数版本)
var Thunk = function (fileName) {
return function (callback) {
return fs.readFile(fileName, callback);
};
};
var readFileThunk = Thunk(fileName);
readFileThunk(callback);
上面代码中,fs
模块的readFile
方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。
任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。
// ES5版本
var Thunk = function(fn){
return function (){
var args = Array.prototype.slice.call(arguments);
return function (callback){
args.push(callback);
return fn.apply(this, args);
}
};
};
// ES6版本
const Thunk = function(fn) {
return function (...args) {
return function (callback) {
return fn.call(this, ...args, callback);
}
};
};
使用上面的转换器,生成fs.readFile
的 Thunk 函数。
var readFileThunk = Thunk(fs.readFile);
readFileThunk(fileA)(callback);
下面是另一个完整的例子。
function f(a, cb) {
cb(a);
}
const ft = Thunk(f);
ft(1)(console.log) // 1
Thunkify 模块
生产环境的转换器,建议使用 Thunkify 模块。
首先是安装。
$ npm install thunkify
使用方式如下。
var thunkify = require('thunkify');
var fs = require('fs');
var read = thunkify(fs.readFile);
read('package.json')(function(err, str){
// ...
});
它的源码主要多了一个检查机制,变量called
确保回调函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子。
function f(a, b, callback){
var sum = a + b;
callback(sum);
callback(sum);
}
var ft = thunkify(f);
var print = console.log.bind(console);
ft(1, 2)(print);
// 3
上面代码中,由于thunkify
只允许回调函数执行一次,所以只输出一行结果。
Thunk 函数的自动流程管理
Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。
function run(fn) {
var gen = fn();
function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}
next();
}
function* g() {
// ...
}
run(g);
上面代码的run
函数,就是一个 Generator 函数的自动执行器。内部的next
函数就是 Thunk 的回调函数。next
函数先将指针移到 Generator 函数的下一步(gen.next
方法),然后判断 Generator 函数是否结束(result.done
属性),如果没结束,就将next
函数再传入 Thunk 函数(result.value
属性),否则就直接退出。
有了这个执行器,执行 Generator 函数方便多了。不管内部有多少个异步操作,直接把 Generator 函数传入run
函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在yield
命令后面的必须是 Thunk 函数。
var g = function* (){
var f1 = yield readFileThunk('fileA');
var f2 = yield readFileThunk('fileB');
// ...
var fn = yield readFileThunk('fileN');
};
run(g);
上面代码中,函数g
封装了n
个异步的读取文件操作,只要执行run
函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
Async 异步操作 篇
使用注意点
第一点,前面已经说过,await
命令后面的Promise
对象,运行结果可能是rejected
,所以最好把await
命令放在try...catch
代码块中。
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一种写法
async function myFunction() {
await somethingThatReturnsAPromise()
.catch(function (err) {
console.log(err);
});
}
第二点,多个await
命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。
let foo = await getFoo();
let bar = await getBar();
上面代码中,getFoo
和getBar
是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo
完成以后,才会执行getBar
,完全可以让它们同时触发。
// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
上面两种写法,getFoo
和getBar
都是同时触发,这样就会缩短程序的执行时间。
第三点,await
命令只能用在async
函数之中,如果用在普通函数,就会报错。
async function dbFuc(db) {
let docs = [{}, {}, {}];
// 报错
docs.forEach(function (doc) {
await db.post(doc);
});
}
上面代码会报错,因为await
用在普通函数之中了。但是,如果将forEach
方法的参数改成async
函数,也有问题。
function dbFuc(db) { //这里不需要 async
let docs = [{}, {}, {}];
// 可能得到错误结果
docs.forEach(async function (doc) {
await db.post(doc);
});
}
上面代码可能不会正常工作,原因是这时三个db.post
操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用for
循环。
async function dbFuc(db) {
let docs = [{}, {}, {}];
for (let doc of docs) {
await db.post(doc);
}
}
第四点,async 函数可以保留运行堆栈。
const a = () => {
b().then(() => c());
};
上面代码中,函数a
内部运行了一个异步任务b()
。当b()
运行的时候,函数a()
不会中断,而是继续执行。等到b()
运行结束,可能a()
早就运行结束了,b()
所在的上下文环境已经消失了。如果b()
或c()
报错,错误堆栈将不包括a()
。
现在将这个例子改成async
函数。
const a = async () => {
await b();
c();
};
上面代码中,b()
运行的时候,a()
是暂停执行,上下文环境都保存着。一旦b()
或c()
,错误堆栈将包括a()
。
实例:按顺序完成异步操作
实际开发中,经常遇到一组异步操作,需要按照顺序完成。比如,依次远程读取一组 URL,然后按照读取的顺序输出结果。
Promise 的写法如下。
function logInOrder(urls) {
// 远程读取所有URL
const textPromises = urls.map(url => {
return fetch(url).then(response => response.text());
});
// 按次序输出
textPromises.reduce((chain, textPromise) => {
return chain.then(() => textPromise)
.then(text => console.log(text));
}, Promise.resolve());
}
上面代码使用fetch
方法,同时远程读取一组 URL。每个fetch
操作都返回一个 Promise 对象,放入textPromises
数组。然后,reduce
方法依次处理每个 Promise 对象,然后使用then
,将所有 Promise 对象连起来,因此就可以依次输出结果。
这种写法不太直观,可读性比较差。下面是 async 函数实现。
async function logInOrder(urls) {
for (const url of urls) {
const response = await fetch(url);
console.log(await response.text());
}
}
上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个 URL 返回结果,才会去读取下一个 URL,这样做效率很差,非常浪费时间。我们需要的是并发发出远程请求。
async function logInOrder(urls) {
// 并发读取远程URL
const textPromises = urls.map(async url => {
const response = await fetch(url);
return response.text();
});
// 按次序输出
for (const textPromise of textPromises) {
console.log(await textPromise);
}
}
上面代码中,虽然map
方法的参数是async
函数,但它是并发执行的,因为只有async
函数内部是继发执行,外部不受影响。后面的for..of
循环内部使用了await
,因此实现了按顺序输出。
for await...of
前面介绍过,for...of
循环用于遍历同步的 Iterator 接口。新引入的for await...of
循环,则是用于遍历异步的 Iterator 接口。
async function f() {
for await (const x of createAsyncIterable(['a', 'b'])) {
console.log(x);
}
}
// a
// b
上面代码中,createAsyncIterable()
返回一个拥有异步遍历器接口的对象,for...of
循环自动调用这个对象的异步遍历器的next
方法,会得到一个 Promise 对象。await
用来处理这个 Promise 对象,一旦resolve
,就把得到的值(x
)传入for...of
的循环体。
for await...of
循环的一个用途,是部署了 asyncIterable 操作的异步接口,可以直接放入这个循环。
let body = '';
async function f() {
for await(const data of req) body += data;
const parsed = JSON.parse(body);
console.log('got', parsed);
}
上面代码中,req
是一个 asyncIterable 对象,用来异步读取数据。可以看到,使用for await...of
循环以后,代码会非常简洁。
Class 篇
类的属性名,可以采用表达式。
let methodName = 'getArea';
class Square {
constructor(length) {
// ...
}
[methodName]() {
// ...
}
}
上面代码中,Square
类的方法名getArea
,是从表达式得到的。
与 ES5 一样,实例的属性除非显式定义在其本身(即定义在this
对象上),否则都是定义在原型上(即定义在class
上)。
//定义类
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
var point = new Point(2, 3);
point.toString() // (2, 3)
point.hasOwnProperty('x') // true
point.hasOwnProperty('y') // true
point.hasOwnProperty('toString') // false
point.__proto__.hasOwnProperty('toString') // true
上面代码中,x
和y
都是实例对象point
自身的属性(因为定义在this
变量上),所以hasOwnProperty
方法返回true
,而toString
是原型对象的属性(因为定义在Point
类上),所以hasOwnProperty
方法返回false
。这些都与 ES5 的行为保持一致。
与 ES5 一样,类的所有实例共享一个原型对象。
var p1 = new Point(2,3);
var p2 = new Point(3,2);
p1.__proto__ === p2.__proto__
//true
上面代码中,p1
和p2
都是Point
的实例,它们的原型都是Point.prototype
,所以__proto__
属性是相等的。
这也意味着,可以通过实例的__proto__
属性为“类”添加方法。
__proto__
并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。生产环境中,我们可以使用Object.getPrototypeOf
方法来获取实例对象的原型,然后再来为原型添加方法/属性。
var p1 = new Point(2,3);
var p2 = new Point(3,2);
p1.__proto__.printName = function () { return 'Oops' };
p1.printName() // "Oops"
p2.printName() // "Oops"
var p3 = new Point(4,2);
p3.printName() // "Oops"
上面代码在p1
的原型上添加了一个printName
方法,由于p1
的原型就是p2
的原型,因此p2
也可以调用这个方法。而且,此后新建的实例p3
也可以调用这个方法。这意味着,使用实例的__proto__
属性改写原型,必须相当谨慎,不推荐使用,因为这会改变“类”的原始定义,影响到所有实例。
Class 表达式
与函数一样,类也可以使用表达式的形式定义。
const MyClass = class Me {
getClassName() {
return Me.name;
}
};
上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass
而不是Me
,Me
只在 Class 的内部代码可用,指代当前类。
let inst = new MyClass();
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined
上面代码表示,Me
只在 Class 内部有定义。
如果类的内部没用到的话,可以省略Me
,也就是可以写成下面的形式。
const MyClass = class { /* ... */ };
采用 Class 表达式,可以写出立即执行的 Class。
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}('张三');
person.sayName(); // "张三"
上面代码中,person
是一个立即执行的类的实例。
不存在变量提升
类不存在变量提升(hoist),这一点与 ES5 完全不同。
new Foo(); // ReferenceError
class Foo {}
上面代码中,Foo
类使用在前,定义在后,这样会报错,因为 ES6 不会把类的声明提升到代码头部。这种规定的原因与下文要提到的继承有关,必须保证子类在父类之后定义。
{
let Foo = class {};
class Bar extends Foo {
}
}
上面的代码不会报错,因为Bar
继承Foo
的时候,Foo
已经有定义了。但是,如果存在class
的提升,上面代码就会报错,因为class
会被提升到代码头部,而let
命令是不提升的,所以导致Bar
继承Foo
的时候,Foo
还没有定义。
私有方法和私有属性
现有的方法
私有方法是常见需求,但 ES6 不提供,只能通过变通方法模拟实现。
一种做法是在命名上加以区别。
class Widget {
// 公有方法
foo (baz) {
this._bar(baz);
}
// 私有方法
_bar(baz) {
return this.snaf = baz;
}
// ...
}
上面代码中,_bar
方法前面的下划线,表示这是一个只限于内部使用的私有方法。但是,这种命名是不保险的,在类的外部,还是可以调用到这个方法。
另一种方法就是索性将私有方法移出模块,因为模块内部的所有方法都是对外可见的。
class Widget {
foo (baz) {
bar.call(this, baz);
}
// ...
}
function bar(baz) {
return this.snaf = baz;
}
上面代码中,foo
是公有方法,内部调用了bar.call(this, baz)
。这使得bar
实际上成为了当前模块的私有方法。
还有一种方法是利用Symbol
值的唯一性,将私有方法的名字命名为一个Symbol
值。
const bar = Symbol('bar');
const snaf = Symbol('snaf');
export default class myClass{
// 公有方法
foo(baz) {
this[bar](baz);
}
// 私有方法
[bar](baz) {
return this[snaf] = baz;
}
// ...
};
上面代码中,bar
和snaf
都是Symbol
值,导致第三方无法获取到它们,因此达到了私有方法和私有属性的效果。
私有属性的提案
目前,有一个提案,为class
加了私有属性。方法是在属性名之前,使用#
表示。
class Point {
#x;
constructor(x = 0) {
#x = +x; // 写成 this.#x 亦可
}
get x() { return #x }
set x(value) { #x = +value }
}
上面代码中,#x
就是私有属性,在Point
类之外是读取不到这个属性的。由于井号#
是属性名的一部分,使用时必须带有#
一起使用,所以#x
和x
是两个不同的属性。
私有属性可以指定初始值,在构造函数执行时进行初始化。
class Point {
#x = 0;
constructor() {
#x; // 0
}
}
之所以要引入一个新的前缀#
表示私有属性,而没有采用private
关键字,是因为 JavaScript 是一门动态语言,没有类型声明,使用独立的符号似乎是唯一的比较方便可靠的方法,能够准确地区分一种属性是否为私有属性。另外,Ruby 语言使用@
表示私有属性,ES6 没有用这个符号而使用#
,是因为@
已经被留给了 Decorator。
这种写法不仅可以写私有属性,还可以用来写私有方法。
class Foo {
#a;
#b;
#sum() { return #a + #b; }
printSum() { console.log(#sum()); }
constructor(a, b) { #a = a; #b = b; }
}
上面代码中,#sum()
就是一个私有方法。
另外,私有属性也可以设置 getter 和 setter 方法。
class Counter {
#xValue = 0;
get #x() { return #xValue; }
set #x(value) {
this.#xValue = value;
}
constructor() {
super();
// ...
}
}
上面代码中,#x
是一个私有属性,它的读写都通过get #x()
和set #x()
来完成。
私有属性不限于从this
引用,类的实例也可以引用私有属性。
class Foo {
#privateValue = 42;
static getPrivateValue(foo) {
return foo.#privateValue;
}
}
Foo.getPrivateValue(new Foo()); // 42
上面代码允许从实例foo
上面引用私有属性。
但是,直接从实例上引用私有属性是不可以的,只能在类的定义中引用。
class Foo {
#bar;
}
let foo = new Foo();
foo.#bar; // 报错
上面代码直接从实例引用私有属性,导致报错。
this 的指向
类的方法内部如果含有this
,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能报错。
class Logger {
printName(name = 'there') {
this.print(`Hello ${name}`);
}
print(text) {
console.log(text);
}
}
const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined
上面代码中,printName
方法中的this
,默认指向Logger
类的实例。但是,如果将这个方法提取出来单独使用,this
会指向该方法运行时所在的环境,因为找不到print
方法而导致报错。
一个比较简单的解决方法是,在构造方法中绑定this
,这样就不会找不到print
方法了。
class Logger {
constructor() {
this.printName = this.printName.bind(this);
}
// ...
}
另一种解决方法是使用箭头函数。
class Logger {
constructor() {
this.printName = (name = 'there') => {
this.print(`Hello ${name}`);
};
}
// ...
}
还有一种解决方法是使用Proxy
,获取方法的时候,自动绑定this
。
function selfish (target) {
const cache = new WeakMap();
const handler = {
get (target, key) {
const value = Reflect.get(target, key);
if (typeof value !== 'function') {
return value;
}
if (!cache.has(value)) {
cache.set(value, value.bind(target));
}
return cache.get(value);
}
};
const proxy = new Proxy(target, handler);
return proxy;
}
const logger = selfish(new Logger());
Class 的取值函数(getter)和存值函数(setter)
与 ES5 一样,在“类”的内部可以使用get
和set
关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
class MyClass {
constructor() {
// ...
}
get prop() {
return 'getter';
}
set prop(value) {
console.log('setter: '+value);
}
}
let inst = new MyClass();
inst.prop = 123;
// setter: 123
inst.prop
// 'getter'
上面代码中,prop
属性有对应的存值函数和取值函数,因此赋值和读取行为都被自定义了。
Class 的 Generator 方法
如果某个方法之前加上星号(*
),就表示该方法是一个 Generator 函数。
class Foo {
constructor(...args) {
this.args = args;
}
* [Symbol.iterator]() {
for (let arg of this.args) {
yield arg;
}
}
}
for (let x of new Foo('hello', 'world')) {
console.log(x);
}
// hello
// world
上面代码中,Foo
类的Symbol.iterator
方法前有一个星号,表示该方法是一个 Generator 函数。Symbol.iterator
方法返回一个Foo
类的默认遍历器,for...of
循环会自动调用这个遍历器。
Class 的静态方法
类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static
关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。
class Foo {
static classMethod() {
return 'hello';
}
}
Foo.classMethod() // 'hello'
var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function
上面代码中,Foo
类的classMethod
方法前有static
关键字,表明该方法是一个静态方法,可以直接在Foo
类上调用(Foo.classMethod()
),而不是在Foo
类的实例上调用。如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。
注意,如果静态方法包含this
关键字,这个this
指的是类,而不是实例。
class Foo {
static bar () {
this.baz();
}
static baz () {
console.log('hello');
}
baz () {
console.log('world');
}
}
Foo.bar() // hello
上面代码中,静态方法bar
调用了this.baz
,这里的this
指的是Foo
类,而不是Foo
的实例,等同于调用Foo.baz
。另外,从这个例子还可以看出,静态方法可以与非静态方法重名。
父类的静态方法,可以被子类继承。
class Foo {
static classMethod() {
return 'hello';
}
}
class Bar extends Foo {
}
Bar.classMethod() // 'hello'
上面代码中,父类Foo
有一个静态方法,子类Bar
可以调用这个方法。
静态方法也是可以从super
对象上调用的。
class Foo {
static classMethod() {
return 'hello';
}
}
class Bar extends Foo {
static classMethod() {
return super.classMethod() + ', too';
}
}
Bar.classMethod() // "hello, too"
Class 的静态属性和实例属性
静态属性指的是 Class 本身的属性,即Class.propName
,而不是定义在实例对象(this
)上的属性。
class Foo {
}
Foo.prop = 1;
Foo.prop // 1
上面的写法为Foo
类定义了一个静态属性prop
。
目前,只有这种写法可行,因为 ES6 明确规定,Class 内部只有静态方法,没有静态属性。
// 以下两种写法都无效
class Foo {
// 写法一
prop: 2
// 写法二
static prop: 2
}
Foo.prop // undefined
目前有一个静态属性的提案,对实例属性和静态属性都规定了新的写法。
(1)类的实例属性
类的实例属性可以用等式,写入类的定义之中。
class MyClass {
myProp = 42;
constructor() {
console.log(this.myProp); // 42
}
}
上面代码中,myProp
就是MyClass
的实例属性。在MyClass
的实例上,可以读取这个属性。
以前,我们定义实例属性,只能写在类的constructor
方法里面。
class ReactCounter extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
}
上面代码中,构造方法constructor
里面,定义了this.state
属性。
有了新的写法以后,可以不在constructor
方法里面定义。
class ReactCounter extends React.Component {
state = {
count: 0
};
}
这种写法比以前更清晰。
为了可读性的目的,对于那些在constructor
里面已经定义的实例属性,新写法允许直接列出。
class ReactCounter extends React.Component {
state;
constructor(props) {
super(props);
this.state = {
count: 0
};
}
}
(2)类的静态属性
类的静态属性只要在上面的实例属性写法前面,加上static
关键字就可以了。
class MyClass {
static myStaticProp = 42;
constructor() {
console.log(MyClass.myStaticProp); // 42
}
}
同样的,这个新写法大大方便了静态属性的表达。
// 老写法
class Foo {
// ...
}
Foo.prop = 1;
// 新写法
class Foo {
static prop = 1;
}
上面代码中,老写法的静态属性定义在类的外部。整个类生成以后,再生成静态属性。这样让人很容易忽略这个静态属性,也不符合相关代码应该放在一起的代码组织原则。另外,新写法是显式声明(declarative),而不是赋值处理,语义更好。
Class 内部调用new.target
,返回当前 Class。
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
var obj = new Rectangle(3, 4); // 输出 true
需要注意的是,子类继承父类时,new.target
会返回子类。
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
// ...
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}
var obj = new Square(3); // 输出 false
上面代码中,new.target
会返回子类。
利用这个特点,可以写出不能独立使用、必须继承后才能使用的类。
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error('本类不能实例化');
}
}
}
class Rectangle extends Shape {
constructor(length, width) {
super();
// ...
}
}
var x = new Shape(); // 报错
var y = new Rectangle(3, 4); // 正确
上面代码中,Shape
类不能被实例化,只能用于继承。
注意,在函数外部,使用new.target
会报错。
在子类的构造函数中,只有调用super
之后,才可以使用this
关键字,否则会报错。这是因为子类实例的构建,基于父类实例,只有super
方法才能调用父类实例。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
this.color = color; // ReferenceError
super(x, y);
this.color = color; // 正确
}
}
super
虽然代表了父类A
的构造函数,但是返回的是子类B
的实例,即super
内部的this
指的是B
,因此super()
在这里相当于A.prototype.constructor.call(this)
。
class A {
constructor() {
console.log(new.target.name);
}
}
class B extends A {
constructor() {
super();
}
}
new A() // A
new B() // B
上面代码中,new.target
指向当前正在执行的函数。可以看到,在super()
执行时,它指向的是子类B
的构造函数,而不是父类A
的构造函数。也就是说,super()
内部的this
指向的是B
。
由于super
指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super
调用的。
class A {
constructor() {
this.p = 2;
}
}
class B extends A {
get m() {
return super.p;
}
}
let b = new B();
b.m // undefined
上面代码中,p
是父类A
实例的属性,super.p
就引用不到它。
如果属性定义在父类的原型对象上,super
就可以取到。
class A {}
A.prototype.x = 2;
class B extends A {
constructor() {
super();
console.log(super.x) // 2
}
}
let b = new B();
ES6 规定,在子类普通方法中通过super
调用父类的方法时,方法内部的this
指向当前的子类实例。
class A {
constructor() {
this.x = 1;
}
print() {
console.log(this.x);
}
}
class B extends A {
constructor() {
super();
this.x = 2;
}
m() {
super.print();
}
}
let b = new B();
b.m() // 2
上面代码中,super.print()
虽然调用的是A.prototype.print()
,但是A.prototype.print()
内部的this
指向子类B
的实例,导致输出的是2
,而不是1
。也就是说,实际上执行的是super.print.call(this)
。
由于this
指向子类实例,所以如果通过super
对某个属性赋值,这时super
就是this
,赋值的属性会变成子类实例的属性。
class A {
constructor() {
this.x = 1;
}
}
class B extends A {
constructor() {
super();
this.x = 2;
super.x = 3;
console.log(super.x); // undefined
console.log(this.x); // 3
}
}
let b = new B();
上面代码中,super.x
赋值为3
,这时等同于对this.x
赋值为3
。而当读取super.x
的时候,读的是A.prototype.x
,所以返回undefined
。
另外,在子类的静态方法中通过super
调用父类的方法时,方法内部的this
指向当前的子类,而不是子类的实例。
class A {
constructor() {
this.x = 1;
}
static print() {
console.log(this.x);
}
}
class B extends A {
constructor() {
super();
this.x = 2;
}
static m() {
super.print();
}
}
B.x = 3;
B.m() // 3
上面代码中,静态方法B.m
里面,super.print
指向父类的静态方法。这个方法里面的this
指向的是B
,而不是B
的实例。
类的 prototype 属性和__proto__属性
大多数浏览器的 ES5 实现之中,每一个对象都有__proto__
属性,指向对应的构造函数的prototype
属性。Class 作为构造函数的语法糖,同时有prototype
属性和__proto__
属性,因此同时存在两条继承链。
(1)子类的__proto__
属性,表示构造函数的继承,总是指向父类。
(2)子类prototype
属性的__proto__
属性,表示方法的继承,总是指向父类的prototype
属性。
class A {
}
class B extends A {
}
B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true
上面代码中,子类B
的__proto__
属性指向父类A
,子类B
的prototype
属性的__proto__
属性指向父类A
的prototype
属性。
这样的结果是因为,类的继承是按照下面的模式实现的。
class A {
}
class B {
}
// B 的实例继承 A 的实例
Object.setPrototypeOf(B.prototype, A.prototype);
// B 继承 A 的静态属性
Object.setPrototypeOf(B, A);
const b = new B();
《对象的扩展》一章给出过Object.setPrototypeOf
方法的实现。
Object.setPrototypeOf = function (obj, proto) {
obj.__proto__ = proto;
return obj;
}
因此,就得到了上面的结果。
Object.setPrototypeOf(B.prototype, A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;
Object.setPrototypeOf(B, A);
// 等同于
B.__proto__ = A;
这两条继承链,可以这样理解:作为一个对象,子类(B
)的原型(__proto__
属性)是父类(A
);作为一个构造函数,子类(B
)的原型对象(prototype
属性)是父类的原型对象(prototype
属性)的实例。
Object.create(A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;
extends
关键字后面可以跟多种类型的值。
class B extends A {
}
上面代码的A
,只要是一个有prototype
属性的函数,就能被B
继承。由于函数都有prototype
属性(除了Function.prototype
函数),因此A
可以是任意函数。
下面,讨论两种情况。第一种,子类继承Object
类。
class A extends Object {
}
A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true
这种情况下,A
其实就是构造函数Object
的复制,A
的实例就是Object
的实例。
第二种情况,不存在任何继承。
class A {
}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true
这种情况下,A
作为一个基类(即不存在任何继承),就是一个普通函数,所以直接继承Function.prototype
。但是,A
调用后返回一个空对象(即Object
实例),所以A.prototype.__proto__
指向构造函数(Object
)的prototype
属性。
实例的 __proto__ 属性
子类实例的__proto__
属性的__proto__
属性,指向父类实例的__proto__
属性。也就是说,子类的原型的原型,是父类的原型。
var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');
p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true
上面代码中,ColorPoint
继承了Point
,导致前者原型的原型是后者的原型。
因此,通过子类实例的__proto__.__proto__
属性,可以修改父类实例的行为。
p2.__proto__.__proto__.printName = function () {
console.log('Ha');
};
p1.printName() // "Ha"
上面代码在ColorPoint
的实例p2
上向Point
类添加方法,结果影响到了Point
的实例p1
。
Mixin 模式的实现
Mixin 指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。它的最简单实现如下。
const a = {
a: 'a'
};
const b = {
b: 'b'
};
const c = {...a, ...b}; // {a: 'a', b: 'b'}
上面代码中,c
对象是a
对象和b
对象的合成,具有两者的接口。
下面是一个更完备的实现,将多个类的接口“混入”(mix in)另一个类。
function mix(...mixins) {
class Mix {}
for (let mixin of mixins) {
copyProperties(Mix.prototype, mixin); // 拷贝实例属性
copyProperties(Mix.prototype, Reflect.getPrototypeOf(mixin)); // 拷贝原型属性
}
return Mix;
}
function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if ( key !== "constructor"
&& key !== "prototype"
&& key !== "name"
) {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}
上面代码的mix
函数,可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。
class DistributedEdit extends mix(Loggable, Serializable) {
// ...
}
个人测试:
实例方法和原型方法同时存在时,调用实例方法。实例属性屏蔽原型属性,原型属性只能通过[Class].prototype.[PropertyName]修改
class Parent {
constructor() {
this.p = () => {
console.log("实例方法parent");
};//有这个this.p时,log->实例方法parent ;无 log->原型方法parent
}
p = () => {
console.log("原型方法parent");
};
}
export default class ClassPractice extends Parent {
static a = 10;
a = 20;
getA() {
this.p();
return this.a + ClassPractice.a;
}
}
Decorator 修饰器 篇
类的修饰
许多面向对象的语言都有修饰器(Decorator)函数,用来修改类的行为。目前,有一个提案将这项功能,引入了 ECMAScript。
@testable
class MyTestableClass {
// ...
}
function testable(target) {
target.isTestable = true;
}
MyTestableClass.isTestable // true
上面代码中,@testable
就是一个修饰器。它修改了MyTestableClass
这个类的行为,为它加上了静态属性isTestable
。testable
函数的参数target
是MyTestableClass
类本身。
基本上,修饰器的行为就是下面这样。
@decorator
class A {}
// 等同于
class A {}
A = decorator(A) || A;
也就是说,修饰器是一个对类进行处理的函数。修饰器函数的第一个参数,就是所要修饰的目标类。
function testable(target) {
// ...
}
上面代码中,testable
函数的参数target
,就是会被修饰的类。
如果觉得一个参数不够用,可以在修饰器外面再封装一层函数。
function testable(isTestable) {
return function(target) {
target.isTestable = isTestable;
}
}
@testable(true)
class MyTestableClass {}
MyTestableClass.isTestable // true
@testable(false)
class MyClass {}
MyClass.isTestable // false
上面代码中,修饰器testable
可以接受参数,这就等于可以修改修饰器的行为。
注意,修饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,修饰器能在编译阶段运行代码。也就是说,修饰器本质就是编译时执行的函数。
前面的例子是为类添加一个静态属性,如果想添加实例属性,可以通过目标类的prototype
对象操作。
function testable(target) {
target.prototype.isTestable = true;
}
@testable
class MyTestableClass {}
let obj = new MyTestableClass();
obj.isTestable // true
上面代码中,修饰器函数testable
是在目标类的prototype
对象上添加属性,因此就可以在实例上调用。
core-decorators.js
core-decorators.js是一个第三方模块,提供了几个常见的修饰器,通过它可以更好地理解修饰器。
(1)@autobind
autobind
修饰器使得方法中的this
对象,绑定原始对象。
import { autobind } from 'core-decorators';
class Person {
@autobind
getPerson() {
return this;
}
}
let person = new Person();
let getPerson = person.getPerson;
getPerson() === person;
// true
(2)@readonly
readonly
修饰器使得属性或方法不可写。
import { readonly } from 'core-decorators';
class Meal {
@readonly
entree = 'steak';
}
var dinner = new Meal();
dinner.entree = 'salmon';
// Cannot assign to read only property 'entree' of [object Object]
使用修饰器实现自动发布事件
我们可以使用修饰器,使得对象的方法被调用时,自动发出一个事件。
const postal = require("postal/lib/postal.lodash");
export default function publish(topic, channel) {
const channelName = channel || '/';
const msgChannel = postal.channel(channelName);
msgChannel.subscribe(topic, v => {
console.log('频道: ', channelName);
console.log('事件: ', topic);
console.log('数据: ', v);
});
return function(target, name, descriptor) {
const fn = descriptor.value;
descriptor.value = function() {
let value = fn.apply(this, arguments);
msgChannel.publish(topic, value);
};
};
}
上面代码定义了一个名为publish
的修饰器,它通过改写descriptor.value
,使得原方法被调用时,会自动发出一个事件。它使用的事件“发布/订阅”库是Postal.js。
它的用法如下。
// index.js
import publish from './publish';
class FooComponent {
@publish('foo.some.message', 'component')
someMethod() {
return { my: 'data' };
}
@publish('foo.some.other')
anotherMethod() {
// ...
}
}
let foo = new FooComponent();
foo.someMethod();
foo.anotherMethod();
以后,只要调用someMethod
或者anotherMethod
,就会自动发出一个事件。
$ bash-node index.js
频道: component
事件: foo.some.message
数据: { my: 'data' }
频道: /
事件: foo.some.other
数据: undefined
Trait
Trait 也是一种修饰器,效果与 Mixin 类似,但是提供更多功能,比如防止同名方法的冲突、排除混入某些方法、为混入的方法起别名等等。
下面采用traits-decorator这个第三方模块作为例子。这个模块提供的traits
修饰器,不仅可以接受对象,还可以接受 ES6 类作为参数。
import { traits } from 'traits-decorator';
class TFoo {
foo() { console.log('foo') }
}
const TBar = {
bar() { console.log('bar') }
};
@traits(TFoo, TBar)
class MyClass { }
let obj = new MyClass();
obj.foo() // foo
obj.bar() // bar
上面代码中,通过traits
修饰器,在MyClass
类上面“混入”了TFoo
类的foo
方法和TBar
对象的bar
方法。
Trait 不允许“混入”同名方法。
import { traits } from 'traits-decorator';
class TFoo {
foo() { console.log('foo') }
}
const TBar = {
bar() { console.log('bar') },
foo() { console.log('foo') }
};
@traits(TFoo, TBar)
class MyClass { }
// 报错
// throw new Error('Method named: ' + methodName + ' is defined twice.');
// ^
// Error: Method named: foo is defined twice.
上面代码中,TFoo
和TBar
都有foo
方法,结果traits
修饰器报错。
一种解决方法是排除TBar
的foo
方法。
import { traits, excludes } from 'traits-decorator';
class TFoo {
foo() { console.log('foo') }
}
const TBar = {
bar() { console.log('bar') },
foo() { console.log('foo') }
};
@traits(TFoo, TBar::excludes('foo'))
class MyClass { }
let obj = new MyClass();
obj.foo() // foo
obj.bar() // bar
上面代码使用绑定运算符(::)在TBar
上排除foo
方法,混入时就不会报错了。
另一种方法是为TBar
的foo
方法起一个别名。
import { traits, alias } from 'traits-decorator';
class TFoo {
foo() { console.log('foo') }
}
const TBar = {
bar() { console.log('bar') },
foo() { console.log('foo') }
};
@traits(TFoo, TBar::alias({foo: 'aliasFoo'}))
class MyClass { }
let obj = new MyClass();
obj.foo() // foo
obj.aliasFoo() // foo
obj.bar() // bar
上面代码为TBar
的foo
方法起了别名aliasFoo
,于是MyClass
也可以混入TBar
的foo
方法了。
alias
和excludes
方法,可以结合起来使用。
@traits(TExample::excludes('foo','bar')::alias({baz:'exampleBaz'}))
class MyClass {}
上面代码排除了TExample
的foo
方法和bar
方法,为baz
方法起了别名exampleBaz
。
as
方法则为上面的代码提供了另一种写法。
@traits(TExample::as({excludes:['foo', 'bar'], alias: {baz: 'exampleBaz'}}))
class MyClass {}
Module 篇
1.常情况下,export
输出的变量就是本来的名字,但是可以使用as
关键字重命名。
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
上面代码使用as
关键字,重命名了函数v1
和v2
的对外接口。重命名后,v2
可以用不同的名字输出两次。
需要特别注意的是,export
命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;
// 报错
var m = 1;
export m;
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量m
,还是直接输出 1。1
只是一个值,不是接口。正确的写法是下面这样。
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
上面三种写法都是正确的,规定了对外的接口m
。其他脚本可以通过这个接口,取到值1
。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。
同样的,function
和class
的输出,也必须遵守这样的写法。
// 报错
function f() {}
export f;
// 正确
export function f() {};
// 正确
function f() {}
export {f};
2.模块的整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*
)指定一个对象,所有输出值都加载在这个对象上面。
下面是一个circle.js
文件,它输出两个方法area
和circumference
。
// circle.js
export function area(radius) {
return Math.PI * radius * radius;
}
export function circumference(radius) {
return 2 * Math.PI * radius;
}
现在,加载这个模块。
// main.js
import { area, circumference } from './circle';
console.log('圆面积:' + area(4));
console.log('圆周长:' + circumference(14));
上面写法是逐一指定要加载的方法,整体加载的写法如下。
import * as circle from './circle';
console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));
注意,模块整体加载所在的那个对象(上例是circle
),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。
import * as circle from './circle';
// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};
3.default
本质上,export default
就是输出一个叫做default
的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。
// modules.js
function add(x, y) {
return x * y;
}
export {add as default};
// 等同于
// export default add;
// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';
正是因为export default
命令其实只是输出一个叫做default
的变量,所以它后面不能跟变量声明语句。
// 正确
export var a = 1;
// 正确
var a = 1;
export default a;
// 错误
export default var a = 1;
上面代码中,export default a
的含义是将变量a
的值赋给变量default
。所以,最后一种写法会报错。
同样地,因为export default
命令的本质是将后面的值,赋给default
变量,所以可以直接将一个值写在export default
之后。
// 正确
export default 42;
// 报错
export 42;
4.模块的继承
模块之间也可以继承。
假设有一个circleplus
模块,继承了circle
模块。
// circleplus.js
export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
return Math.exp(x);
}
上面代码中的export *
,表示再输出circle
模块的所有属性和方法。注意,export *
命令会忽略circle
模块的default
方法。然后,上面代码又输出了自定义的e
变量和默认方法。
这时,也可以将circle
的属性或方法,改名后再输出。
// circleplus.js
export { area as circleArea } from 'circle';
上面代码表示,只输出circle
模块的area
方法,且将其改名为circleArea
。
加载上面模块的写法如下。
// main.js
import * as math from 'circleplus';
import exp from 'circleplus';
console.log(exp(math.e));
上面代码中的import exp
表示,将circleplus
模块的默认方法加载为exp
方法。
5. 资源统一
如果要使用的常量非常多,可以建一个专门的constants
目录,将各种常量写在不同的文件里面,保存在该目录下。
// constants/db.js
export const db = {
url: 'http://my.couchdbserver.local:5984',
admin_username: 'admin',
admin_password: 'admin password'
};
// constants/user.js
export const users = ['root', 'admin', 'staff', 'ceo', 'chief', 'moderator'];
然后,将这些文件输出的常量,合并在index.js
里面。
// constants/index.js
export {db} from './db';
export {users} from './users';
使用的时候,直接加载index.js
就可以了。
// script.js
import {db, users} from './constants/index';
6.动态加载模块(运行时加载)
因此,有一个提案,建议引入import()
函数,完成动态加载。
import(specifier)
上面代码中,import
函数的参数specifier
,指定所要加载的模块的位置。import
命令能够接受什么参数,import()
函数就能接受什么参数,两者区别主要是后者为动态加载。
import()
返回一个 Promise 对象。下面是一个例子。
const main = document.querySelector('main');
import(`./section-modules/${someVariable}.js`)
.then(module => {
module.loadPageInto(main);
})
.catch(err => {
main.textContent = err.message;
});
import()
函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()
函数与所加载的模块没有静态连接关系,这点也是与import
语句不相同。import()
类似于 Node 的require
方法,区别主要是前者是异步加载,后者是同步加载。
7.import()
加载模块成功以后,这个模块会作为一个对象,当作then
方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。
import('./myModule.js')
.then(({export1, export2}) => {
// ...·
});
上面代码中,export1
和export2
都是myModule.js
的输出接口,可以解构获得。
如果模块有default
输出接口,可以用参数直接获得。
import('./myModule.js')
.then(myModule => {
console.log(myModule.default);
});
上面的代码也可以使用具名输入的形式。
import('./myModule.js')
.then(({default: theDefault}) => {
console.log(theDefault);
});
如果想同时加载多个模块,可以采用下面的写法。
Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
])
.then(([module1, module2, module3]) => {
···
});
import()
也可以用在 async 函数之中。
async function main() {
const myModule = await import('./myModule.js');
const {export1, export2} = await import('./myModule.js');
const [module1, module2, module3] =
await Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
]);
}
main();
由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。
// lib.js
export let obj = {};
// main.js
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError
上面代码中,main.js
从lib.js
输入变量obj
,可以对obj
添加属性,但是重新赋值就会报错。因为变量obj
指向的地址是只读的,不能重新赋值,这就好比main.js
创造了一个名为obj
的const
变量。
如果模块名不含路径,那么import
命令会去node_modules
目录寻找这个模块。
import 'baz';
import 'abc/123';
如果模块名包含路径,那么import
命令会按照路径去寻找这个名字的脚本文件。
import 'file:///etc/config/app.json';
import './foo';
import './foo?search';
import '../bar';
import '/baz';
编程风格 篇
1.const(常量)的使用要多于let的使用
2.字符串
静态字符串一律使用单引号或反引号,不使用双引号。动态字符串使用反引号。
// bad
const a = "foobar";
const b = 'foo' + a + 'bar';
// acceptable
const c = `foobar`;
// good
const a = 'foobar';
const b = `foo${a}bar`;
3.解构赋值
使用数组成员对变量赋值时,优先使用解构赋值。
const arr = [1, 2, 3, 4];
// bad
const first = arr[0];
const second = arr[1];
// good
const [first, second] = arr;
函数的参数如果是对象的成员,优先使用解构赋值。
// bad
function getFullName(user) {
const firstName = user.firstName;
const lastName = user.lastName;
}
// good
function getFullName(obj) {
const { firstName, lastName } = obj;
}
// best
function getFullName({ firstName, lastName }) {
}
如果函数返回多个值,优先使用对象的解构赋值,而不是数组的解构赋值。这样便于以后添加返回值,以及更改返回值的顺序。
// bad
function processInput(input) {
return [left, right, top, bottom];
}
// good
function processInput(input) {
return { left, right, top, bottom };
}
const { left, right } = processInput(input);
4.使用export
取代module.exports
。
如果模块默认输出一个函数,函数名的首字母应该小写。
function makeStyleGuide() {
}
export default makeStyleGuide;
如果模块默认输出一个对象,对象名的首字母应该大写。
const StyleGuide = {
es6: {
}
};
export default StyleGuide;
借鉴: