JavaScript学习笔记

JavaScript学习笔记

定义变量

let 与块级作用域

  • (1)let声明的变量,只在let命令所在的代码块内有效。var声明的变量在全局作用域和函数作用域内有效。
{
  let a = 10;
  var b = 1;
}

a // ReferenceError: a is not defined.
b // 1
  • (2)for循环计数器很适合let命令
var elements = [{},{},{}]
for(var i = 0; i<elements.length;i++){
    elements[i].onclick = function(){
        console.log(i)
    }
}

elements[0].onclick() //打印结果为3

上面变量i是var命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生变化,而循环内被赋值给数组a的函数内部的console.log(i)里面的i指向的是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的最后一轮的i值,也就是3。

这也是立即执行函数的应用场景。

for(var i = 0; i<elements.length;i++){
    (function(i){
        elements[i].onclick = function(){
            console.log(i)
        }
    })(i)
}
elements[1].onclick() //打印结果为1

利用立即执行函数构造一个函数作用域,console.log(i)中的 i是函数作用域的i。

如果使用let,声明的变量仅在块级作用域内有效。

for(let i = 0; i<elements.length;i++){
    elements[i].onclick = function(){
        console.log(i)
    }
}

elements[0].onclick() //打印结果为0

变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量。

  • (3)不存在变量提升
    var命令会发生变量提升现象,即变量可以在声明之前使用,值为undefined。let声明的变量不存在变量提升的现象。
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;
  • (4)内层变量可能会覆盖外层变量。
var tmp = new Date();

function f() {
  console.log(tmp);
  if (false) {
    var tmp = 'hello world';
  }
}

f(); // undefined

函数f执行后,输出结果为undefined,原因在于变量提升,函数作用域发生变量提升。

  • (5)暂时性死区
    只要块级作用域内存在let命令,它所声明的变量就绑定在这个区域,不在受到外部的影响。
var tmp = 123;

if (true) {
  tmp = 'abc'; // ReferenceError
  let tmp;
}

上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。

  • (6)不允许重复声明
    let不允许在相同作用域内,重复声明同一个变量。var是可以在相同作用域内声明同一个变量。
// 报错
function func() {
  let a = 10;
  var a = 1;
}

// 报错
function func() {
  let a = 10;
  let a = 1;
}

块级作用域

ES5只有全局作用域和函数作用域,没有块级作用域。ES6 let为 JavaScript 新增了块级作用域。

function f1() {
  let n = 5; //外层的块级作用域
  if (true) { //块级作用域
    let n = 10;
  }
  console.log(n); // 5
}

const命令

const声明一个只读的常量。一旦声明,常量的值就不能改变。

const PI = 3.1415;

const其他的特性与let保持一致。

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

const foo = {};

// 为 foo 添加一个属性,可以成功
foo.prop = 123;
foo.prop // 123

// 将 foo 指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only

上面代码中,常量foo储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。

JS中的函数

函数是一等公民

  • 函数可以存储在变量中
  • 函数可以作为参数
  • 函数可以作为返回值。

JavaScript中函数就是一个普通的对象(可以通过new Function()来创建),我们可以把函数存储到变量或者数组中,它还可以作为另外一个函数的参数和返回值,甚至可以在程序运行的时候通过 new Function('alert(1)')来构造一个新的函数。

高阶函数

什么是高阶函数

  • 高阶函数
    • 可以把函数作为参数传递给另外一个参数
    • 可以把函数作为另一函数的返回结果。
  • 函数作为参数
//模拟forEach
function forEach(array,fn){
    for(let i = 0; i<array.length; i++){
        fn(array[i])
    }
}

//模拟filter 
function filter(array,fn){
    let results = []
    for(let i = 0; i<array.length; i++){
        if(fn(array[i])){
            results.push(array[i])
        }
    }

    return results
}
  • 函数作为返回值
function makeFn(){
    let msg ='Hello function'
    return function(){
        console.log(msg)
    }
}
const fn = makeFn()
fn();
//模拟once
function once(fn){
    let done = false 
    return function(){
        if(!done){
            done = true
            fn.apply(this,arguments)
        }
    }
}

let pay = once(function(money){
    console.log(`支付:${money}RMB`);
})
//只会支付一次
pay(5)
pay(5)
pay(5)

常用高阶函数

map、filter、reduce、forEatch、every、some、find/findIndex、sort…

1、map
map 是映射的意思。
原数组被映射成一个新的数组,返回值是一个新数组,不改变原来的数组。新的数组与原数组的长度是不会改变的。

//item:数组当前项的值
//index:数组当前项的索引
//self:数组对象本身 
let array = [1, 2, 3, 4, 5]
array.map((item, index, self) => {
	console.log(item)
	console.log(index)
	console.log(self)
})

示例:

  let arr = [1,2,3,4,5]
    let newArr = arr.map((item)=>{
        return item*item;
    })
    console.log(newArr) //[1, 4, 9, 16, 25]

上述实例,map接收的参数是一个函数,该函数依次作用于每个元素,对元素求平方,也可以对其进行任意的复杂操作。

2、filter
filter 是过滤数组,返回满足条件的数据,组成一个新的数组返回,不满足条件的被丢弃。

	//item:数组当前项的值
	//index:数组当前项的索引
	//self:数组对象本身 
   let arr = [1,2,3,4,5]
   let newArr = arr.filter((item,index,self)=>{
       console.log(item)
       console.log(index)
       console.log(array)
   })

示例:

   let arr = [1,2,3,4,5]
   let newArr = arr.filter(item=>{
        return item % 2 == 0
   })
   console.log(newArr) //[2,4]

上述实例中,filter传入的参数是一个函数,传入的函数依次作用于每个元素,然后根据返回值是 true 或 false 决定保留还是丢弃元素。因为只有 2和 4 两个满足条件,所以新的数组中只有这两个元素。

利用filter给数组去重

  //利用filter给数组去重
  let arr = [1,2,3,4,5,5,4,3,2,1]
  let newArr = arr.filter((item,index,arr)=>{
      //array.indexOf()返回的是数组的此元素的第一个索引。
      return arr.indexOf(item) === index
  })
  console.log(newArr) //[1,2,3,4,5]

3、reduce
reduce 是对数组进行汇总的,往往进去一个数组,出来是一个数据。经常用于求和和计算平均值。

	//tmp: 临时值,暂存
	//item:数组当前项的值
	//index:数组当前项的索引
    let arr = [1,2,3,4,5]
    let result = arr.reduce((tmp,item,index)=>{
        console.log('index:' + index+','+'item:'+item+'tmp:'+tmp)
       return tmp + item
    })
    /*
        打印结果:
        index:1,item:2tmp:1
        index:2,item:3tmp:3
        index:3,item:4tmp:6
        index:4,item:5tmp:10
    */
    //result=15

4、forEach
遍历数组。

	//item:数组当前项的值
	//index:数组当前项的索引
	//self:数组对象本身 
    let arr = [1,2,3,4,5]
    arr.forEach((item,index,self)=>{
        console.log(item)
        console.log(index)
        console.log(array)
    })

5、every
every()是对数组中每一项运行给指定函数,如果该函数对每一项返回true,则返回true。

  • 1.如果数组中检测到有一个元素不满足,则整个表达式返回 false ,且剩余的元素不会再进行检测。
  • 2.如果所有元素都满足条件,则返回 true。
	//item:数组当前项的值
	//index:数组当前项的索引
	//self:数组对象本身 
    let arr = [3,4,5]
    let result = arr.every((item,index,self)=>{
        return item > 2
    })
    console.log(result) //true

6、some()
some()是对数组中每一项运行给定函数,如果该函数对任一项返回true,则返回true。

  • 1.如果有一个元素满足条件,则表达式返回true , 剩余的元素不会再执行检测。
  • 2.如果没有满足条件的元素,则返回false。
	//item:数组当前项的值
	//index:数组当前项的索引
	//self:数组对象本身 
	let arr = [3,4,5]
    let result = arr.some((item,index,self)=>{
        return item > 4
    })
    console.log(result) //true

7、find()
find() 方法返回通过测试(函数内判断)的数组的第一个元素的值。

  • 如果没有符合条件的元素返回 undefined
  • find() 对于空数组,函数是不会执行的。
  • find() 并没有改变数组的原始值。
	//item:数组当前项的值
	//index:数组当前项的索引
	//self:数组对象本身 
    let arr = [3,4,5,6]
    let result = arr.find((item,index,self)=>{
        return item % 2 == 0 
    })
    console.log(result) //4

8、findIndex()
返回满足回调函数中指定的测试条件的第一个数组元素的索引值。

  • 对于数组中的每个元素,findIndex 方法都会调用一次回调函数(采用升序索引顺序),直到有元素返回 true。
  • 只要有一个元素返回 true,findIndex 立即返回该返回 true 的元素的索引值。
  • 如果数组中没有任何元素返回 true,则 findIndex 返回 -1。
	//item:数组当前项的值
	//index:数组当前项的索引
	//self:数组对象本身 
    let arr = [3,4,5,6]
    let firstIndex = arr.findIndex((item,index,self)=>{
         return item % 2 == 0 
    })
    console.log(firstIndex) //1

9、sort()
sort()方法按升序排列数组。

    let arr = [4,3,5,6]
    let newArr = arr.sort()
    console.log(newArr) //[3,4,5,6]

这时发现数据按照从小到大排列,没问题;于是再把数组改成:let arr=[101,1,3,5,9,4,11];,再调用sort()方法打印排序结果。

var arr=[101,1,3,5,9,4,11];
console.log(arr.sort());
// 输出: [1, 101, 11, 3, 4, 5, 9]

这个时候发现数组101,11都排在3前面,是因为 sort() 方法会调用数组的toString()转型方法,然后比较得到的字符串,确定如何排序,即使数组中的每一项都是数值,sort()方法比较的也是字符串。
那么字符串又是怎么排序的呢,是根据字符串的unicode编码从小到大排序的。下面我们尝试打印出数组每一项的unicode编码看一下。

...
// 转码方法
function getUnicode (charCode) {
    return charCode.charCodeAt(0).toString(16);
}
// 打印转码
arr.forEach((n)=>{
  console.log(getUnicode(String(n)))
});

// 输出: 31 31 31 33 34 35 39

惊奇地发现,1,101,11的字符串unicode编码都是31
以上发现sort()方法不是按照我们想要的顺序排序的,那么,怎么解决呢,sort()方法可以接收一个比较函数作为参数,以便指定哪个值位于哪个值前面。

比较函数(compare)接收两个参数,如果第一个参数位于第二个之前则返回一个负数,如果两个参数相等则返回0,如果第一个参数位于第二个之后则返回一个整数。
function compare(value1,value2){
  if (value1 < value2){
    return -1;
  } else if (value1 > value2){
    return 1;
  } else{
    return 0;
  }
}

我们把比较函数传递给sort()方法,在对arr数组进行排列,打印结果如下:
var arr=[101,1,3,5,9,4,11];
console.log(arr.sort(compare));
// 输出: [1, 3, 4, 5, 9, 11, 101];
  • 对象数组的排序
     sort() 方法通过传入一个比较函数来排序数字数组,但是在开发中,我们会对一个对象数组的某个属性进行排序,例如id,年龄等等,那么怎么解决呢?
    要解决这个问题:我们可以定义一个函数,让它接收一个属性名,然后根据这个属性名来创建一个比较函数并作为返回值返回来,代码如下。
function compareFunc(prop){
  return function (obj1,obj2){
    var value1=obj1[prop];
    var value2=obj2[prop];
    if (value1 < value2){
        return -1;
    } else if (value1 > value2){
        return 1;
    } else{
        return 0;
    }
  }
}

定义一个数组users,调用sort()方法传入compareFunc(prop)打印输出结果:

var users=[
    {name:'tom',age:18},
	{name:'lucy',age:24},
    {name:'jhon',age:17},
];
console.log(users.sort(compareFunc('age')));
// 输出结果
[{name: "jhon", age: 17},
{name: "tom", age: 18},
{name: "lucy", age: 24}]

在默认情况下,调用sort()方法不传入比较函数时,sort()方法会调用每个对象的toString()方法来确定他们的次序,当我们调用compareFunc('age')方法创建一个比较函数,排序是按照对象的age属性排序的。

闭包

闭包的最大用处有两个,一个是可以读取外层函数内部的变量,另一个就是让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。

//模拟once
function once(fn){
    let done = false 
    return function(){
        if(!done){
            done = true
            fn.apply(this,arguments)
        }
    }
}

闭包的另一个用处,是封装对象的私有属性和私有方法。

function Person(name) {
  var _age;
  function setAge(n) {
    _age = n;
  }
  function getAge() {
    return _age;
  }

  return {
    name: name,
    getAge: getAge,
    setAge: setAge
  };
}

var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25

注意,外层函数每次运行,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。

立即执行函数

(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();

上面两种写法都是以圆括号开头,引擎就会认为后面跟的是一个表达式,而不是函数定义语句,所以就避免了错误。这就叫做“立即调用的函数表达式”(Immediately-Invoked Function Expression),简称 IIFE。

箭头函数

  • 1、ES6 允许使用“箭头”(=>)定义函数。
var f = v => v;

// 等同于
var f = function (v) {
  return v;
};
  • 2、如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
var f = () => 5;
// 等同于
var f = function () { return 5 };

var sum = (num1, num2) => num1 + num2;
// 等同于
var sum = function(num1, num2) {
  return num1 + num2;
};
  • 3、如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return语句返回。
var sum = (num1, num2) => { return num1 + num2; }

参数默认值

  • ES 5设置参数默认值
function foo(enable) {
    enable = enable === undefined ? true : enable
}
  • ES 6设置参数默认值
function foo(bar,enable = true) {
}

剩余参数

  • ES5剩余参数是通过arguments对象来获取的。
//模拟once
function once(fn){
    let done = false 
    return function(){
        if(!done){
            done = true
            fn.apply(this,arguments)
        }
    }
}

let pay = once(function(money){
    console.log(`支付:${money}RMB`);
})
//只会支付一次
pay(5)
pay(5)
pay(5)
  • ES6 引入 rest 参数(形式为...变量名),用于获取函数的多余参数,rest 参数是一个数组,该变量将多余的参数放入数组中。
function add(...values) {
  let sum = 0;

  for (var val of values) {
    sum += val;
  }

  return sum;
}

add(2, 5, 3) // 10

展开数组

let arr = [1,2,3]
console.log(...arr) //1,2,3

解构

数组解构

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

  • 解构数组
let [a, b, c] = [1, 2, 3];
  • 提取从当前位置开始的所有成员。
const arr = [100,200,300]
const [foo,...rest] = arr
console.log(rest) //打印结果[200,300]

这种三个点的用法只能在解构位置的最后的位置使用。

  • 如果解构不成功,变量的值就等于undefined。
let [foo] = [];
let [bar, foo] = [1];
  • 不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。
let [x, y] = [1, 2, 3];
x // 1
y // 2

let [a, [b], d] = [1, [2, 3], 4];
a // 1
b // 2
d // 4
  • 提取指定位置的成员
const arr = [100,200,300]
const [,,last] = arr
console.log(last)
  • 解构赋值允许指定默认值。
let [foo = true] = [];
foo // true

本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。

对象的解构赋值

  • 解构属性
let { foo, bar } = { foo: 'aaa', bar: 'bbb' };
foo // "aaa"
bar // "bbb"

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

  • 解构方法
let {log} = console
log("HelloWorld!")

字符串的解构

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

模板字符串

传统字符串不支持换行,但是模板字符串支持多行。

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

// 多行字符串
$('#list').html(`
<ul>
  <li>first</li>
  <li>second</li>
</ul>
`);

支持通过插值表达式嵌入内容

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

标签模板

它可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串。这被称为“标签模板”功能

alert`hello`
等同于
alert(['hello']) //是一个数组

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

字符串

String.prototype.concat

concat方法用于连接两个字符串,返回一个新字符串,不改变原字符串。

var s1 = 'abc';
var s2 = 'def';

s1.concat(s2) // "abcdef"
s1 // "abc"

String.prototype.split()

split方法按照给定规则分割字符串,返回一个由分割出来的子字符串组成的数组。

'a|b|c'.split('|') // ["a", "b", "c"]

includes()

字符串中间是否包含。

message.includes('foo') //字符串中间是否包含

startsWith()

const message = ` Error: foo is not defined.`
message.startsWith('Error') //是否以Error开头

endsWidth()

message.endsWith('.')//是否以点结尾

对象字面量增强

  • ES5对象字面量定义属性。
const bar = "HelloWorld"
const obj = {
    foo:123,
    bar:bar,
}
  • ES6对象字面量定义属性:
const bar = "HelloWorld"
const obj = {
    foo:123,
    bar
}
  • ES5对象字面量定义方法
const obj = {
    foo:123,
    method:function(){
        console.log("methods")
    }
}
  • ES6对象字面量定义方法
const obj = {
    foo:123,
    method(){
        console.log("methods")
    }
}

Symbol

概述

ES5 的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的方法(mixin 模式),新方法的名字就有可能与现有方法产生冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是 ES6 引入Symbol的原因。

ES6 引入了一种新的原始数据类型Symbol表示独一无二的值。它是 JavaScript 语言的第七种数据类型,前六种是:undefinednull、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。

Symbol 值通过Symbol函数生成。这就是说,对象的属性名现在可以有两种类型:字符串和Symbol类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。

注意,Symbol函数前不能使用new命令,否则会报错。这是因为生成的 Symbol 是一个原始类型的值,不是对象。也就是说,由于 Symbol 值不是对象,所以不能添加属性。基本上,它是一种类似于字符串的数据类型。

Symbol函数可以接受一个字符串作为参数,表示对 Symbol 实例的描述,主要是为了在控制台显示,或者转为字符串时,比较容易区分。

// 没有参数的情况
let s1 = Symbol();
let s2 = Symbol();

s1 === s2 // false

// 有参数的情况
let s1 = Symbol('foo');
let s2 = Symbol('foo');

s1 === s2 // false

Symbol.prototype.description

ES2019 提供了一个实例属性description,直接返回 Symbol 的描述。

const sym = Symbol('foo');

String(sym) // "Symbol(foo)"
sym.toString() // "Symbol(foo)"
sym.description // "foo"

作为属性名的 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 shapeType = {
  triangle: 'Triangle'
};

function getArea(shape, options) {
  let area = 0;
  switch (shape) {
    case shapeType.triangle:
      area = .5 * options.width * options.height;
      break;
  }
  return area;
}

getArea(shapeType.triangle, { width: 100, height: 100 });

上面代码中,我们把Triangle写成shapeType对象的triangle属性,这样就消除了强耦合。

如果仔细分析,可以发现shapeType.triangle等于哪个值并不重要,只要确保不会跟其他shapeType属性的值冲突即可。因此,这里就很适合改用 Symbol 值。

const shapeType = {
  triangle: Symbol()
};

上面代码中,除了将shapeType.triangle的值设为一个 Symbol,其他地方都不用修改。

属性名的遍历

Symbol 作为属性名,遍历对象的时候,该属性不会出现在for...infor...of循环中,也不会被Object.keys()Object.getOwnPropertyNames()JSON.stringify()返回

但是,它也不是私有属性,有一个Object.getOwnPropertySymbols()方法,可以获取指定对象的所有 Symbol 属性名。该方法返回一个数组,成员是当前对象的所有用作属性名的 Symbol 值。

const obj = {};
let a = Symbol('a');
let b = Symbol('b');

obj[a] = 'Hello';
obj[b] = 'World';

const objectSymbols = Object.getOwnPropertySymbols(obj);

objectSymbols
// [Symbol(a), Symbol(b)]

由于以 Symbol 值作为键名,不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有的、但又希望只用于内部的方法。

Symbol.for(),Symbol.keyFor()

有时,我们希望重新使用同一个 Symbol 值,Symbol.for()方法可以做到这一点。它接受一个字符串作为参数,然后搜索有没有以该参数作为名称的 Symbol 值。如果有,就返回这个 Symbol 值,否则就新建一个以该字符串为名称的 Symbol 值,并将其注册到全局。

let s1 = Symbol.for('foo');
let s2 = Symbol.for('foo');

s1 === s2 // true

Symbol.for()Symbol()这两种写法,都会生成新的 Symbol它们的区别是,前者会被登记在全局环境中供搜索,后者不会Symbol.for()不会每次调用就返回一个新的 Symbol 类型的值,而是会先检查给定的key是否已经存在,如果不存在才会新建一个值。

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 值

Set

基本用法

ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。
Set本身是一个构造函数,用来生成 Set 数据结构。

Set函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。可以使用Set的特性实现数组去重。

    let arr = [1,2,3,4,6,6,6]
    let set = new Set(arr) //去掉重复的数值
    let newArr = [...set] //展开set形成一个新的数组

	// 去除数组的重复成员
	[...new Set(arr)]

上面的方法也可以用于,去除字符串里面的重复字符。

[...new Set('ababbc')].join('')
// "abc"

向 Set 加入值的时候,不会发生类型转换,所以5和"5"是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(===),主要的区别是向 Set 加入值时认为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 实例的属性和方法

  • Set.prototype.constructor:构造函数,默认就是Set函数。
  • Set.prototype.size:返回Set实例的成员总数。
  • Set.prototype.add(value):添加某个值,返回 Set 结构本身。
  • Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
  • Set.prototype.has(value):返回一个布尔值,表示该值是否为Set的成员。
  • Set.prototype.clear():清除所有成员,没有返回值。

遍历操作

  • Set.prototype.keys():返回键名的遍历器
  • Set.prototype.values():返回键值的遍历器
  • Set.prototype.entries():返回键值对的遍历器
  • Set.prototype.forEach():使用回调函数遍历每个成员
    需要特别指出的是,Set的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用 Set 保存一个回调函数列表,调用时就能保证按照添加顺序调用。

WeakSet

WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。

首先,WeakSet 的成员只能是对象,而不能是其他类型的值。

其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。

是因为垃圾回收机制根据对象的可达性(reachability)来判断回收,如果对象还能被访问到,垃圾回收机制就不会释放这块内存。结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。

由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历。

Map

Object与Map用法对比

(1)对于Object而言,它键的类型只能是字符串、数字或者Symbol;而对于Map而言,它的键可以是任何类型。(包括Date、Map或者自定义对象)
(2)Map中的元素会保持其插入时的顺序;而Object则不会完全保持插入时的顺序,而是根据如下规则进行排序:首先,非负整数会最先被列出,排序是从小到大的数字顺序;然后,所有字符串、负整数、浮点数会被列出,顺序是根据插入的顺序;最后,才会列出Symbol,Symbol也是根据插入的顺序进行的排序的。
(3)读取Map的长度只需要调用其size属性即可;而读取Object长度则需要额外的计算: Object.keys(obj).length
(4)Map是可迭代对象,所以其中的键值对是可以通过for…of循环或者.forEatch()方法来迭代的。而普通的对象键值对则默认是不可迭代的,只能通过for…in 循环来访问(或者使用Object.keys(o)、Object.values(o)、Object.entries(o)来取得表示键或者值的数字)迭代时的顺序就是上面提到的顺序
(5)在Map中新增键时,不会覆盖其原型上的键;而在Object中新增键时,则有可能覆盖其原型上的键。
(6)JSON默认支持Object而不支持Map。若想要通过JSON传输Map,则需要使用到.toJSON()方法,然后在JSON.parse()中传入复原函数来将其复原。

基本用法

//调用构造函数
const m = new Map(); 
//新增/修改
m.set('x',1)
//读取
m.get('x')
//删除
map.delete('b')

作为构造函数,Map 也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。

const m = new Map([['x',1]])
console.log(m.get('x')) //1

Map实例的属性和操作方法

size属性

size属性返回Map结构的成员总数

Map.prototype.set(key, value)

set方法设置键名key对应的键值为value,然后返回整个 Map 结构。如果key已经有值,则键值会被更新,否则就新生成该键。

const m = new Map();

m.set('edition', 6)        // 键是字符串
m.set(262, 'standard')     // 键是数值
m.set(undefined, 'nah')    // 键是 undefined

set方法返回的是当前的Map对象,因此可以采用链式写法。

let map = new Map()
  .set(1, 'a')
  .set(2, 'b')
  .set(3, 'c');

Map.prototype.get(key)

get方法读取key对应的键值,如果找不到key,返回undefined。

const m = new Map();

const hello = function() {console.log('hello');};
m.set(hello, 'Hello ES6!') // 键是函数

m.get(hello)  // Hello ES6!

Map.prototype.has(key)

has方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。

Map.prototype.delete(key)

delete方法删除某个键,返回true。如果删除失败,返回false。

Map.prototype.clear()

clear方法清除所有成员,没有返回值。

遍历方法

  • Map.prototype.keys():返回键名的遍历器。

  • Map.prototype.values():返回键值的遍历器。

  • Map.prototype.entries():返回所有成员的遍历器。

  • Map.prototype.forEach():遍历 Map 的所有成员。

与其他数据类型的相互转换

Map转数组

const myMap = new Map()
  .set(true, 7)
  .set({foo: 3}, ['abc']);
[...myMap] //[ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]

数组转Map

new Map([
  [true, 7],
  [{foo: 3}, ['abc']]
])
// Map {
//   true => 7,
//   Object {foo: 3} => ['abc']
// }

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 }

如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。

对象转Map

对象转为 Map 可以通过Object.entries()。

let obj = {"a":1, "b":2};
let map = new Map(Object.entries(obj));

此外,也可以自己实现一个转换函数。

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}

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"]]]'

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

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的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。

WeakMap 应用的典型场合就是 DOM 节点作为键名。下面是一个例子。

let myWeakmap = new WeakMap();

myWeakmap.set(
  document.getElementById('logo'),
  {timesClicked: 0})
;

document.getElementById('logo').addEventListener('click', function() {
  let logoData = myWeakmap.get(document.getElementById('logo'));
  logoData.timesClicked++;
}, false);

上面代码中,document.getElementById(‘logo’)是一个 DOM 节点,每当发生click事件,就更新一下状态。我们将这个状态作为键值放在 WeakMap 里,对应的键名就是这个节点对象。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。

WeakRef

WeakSet 和 WeakMap 是基于弱引用的数据结构,ES2021 更进一步,提供了 WeakRef 对象,用于直接创建对象的弱引用。

let target = {};
let wr = new WeakRef(target);

let obj = wr.deref();
if (obj) { // target 未被垃圾回收机制清除
  // ...
}

上面示例中,target是原始对象,构造函数WeakRef()创建了一个基于target的新对象wr。这里,wr就是一个 WeakRef 的实例,属于对target的弱引用,垃圾回收机制不会计入这个引用,也就是说,wr的引用不会妨碍原始对象target被垃圾回收机制清除。
WeakRef 实例对象有一个deref()方法,如果原始对象存在,该方法返回原始对象;如果原始对象已经被垃圾回收机制清除,该方法返回undefined。

注意,标准规定,一旦使用WeakRef()创建了原始对象的弱引用,那么在本轮事件循环(event loop),原始对象肯定不会被清除,只会在后面的事件循环才会被清除。

Iterator 和 for…of 循环

JavaScript 原有的表示“集合”的数据结构,主要是数组(Array)和对象(Object),ES6 又添加了Map和Set。这样就有了四种数据集合,用户还可以组合使用它们,定义自己的数据结构,比如数组的成员是Map,Map的成员是对象。这样就需要一种统一的接口机制,来处理所有不同的数据结构。

Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即for…of循环。当使用for…of循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。

Iterator 的遍历过程是这样的。
(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
(2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
(3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
(4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。
每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value和done两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。

默认 Iterator 接口

Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即for…of循环。当使用for…of循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。

一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是“可遍历的”(iterable)。

ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内。

const obj = {
  [Symbol.iterator] : function () {
    return {
      next: function () {
        return {
          value: 1,
          done: true
        };
      }
    };
  }
};

let iterator = obj[Symbol.iterator]()
console.log(iterator.next())

上面代码中,对象obj是可遍历的(iterable),因为具有Symbol.iterator属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有next方法。每次调用next方法,都会返回一个代表当前成员的信息对象,具有value和done两个属性。

ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被for…of循环遍历。原因在于,这些数据结构原生部署了Symbol.iterator属性,另外一些数据结构没有(比如对象)。凡是部署了Symbol.iterator属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。
原生具备 Iterator 接口的数据结构如下。
Array
Map
Set
String
TypedArray
函数的 arguments 对象
NodeList 对象
下面的例子是数组的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 }

for( let value of arr) {
    console.log(value)
}

上面代码中,变量arr是一个数组,原生就具有遍历器接口,部署在arr的Symbol.iterator属性上面。所以,调用这个属性,就得到遍历器对象。

对于原生部署 Iterator 接口的数据结构,不用自己写遍历器生成函数,for…of循环会自动遍历它们。除此之外,其他数据结构(主要是对象)的 Iterator 接口,都需要自己在Symbol.iterator属性上面部署,这样才会被for…of循环遍历。

对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作 Map 结构使用,ES5 没有 Map 结构,而 ES6 原生提供了。

下面是另一个为对象添加 Iterator 接口的例子。

let obj = {
  data: [ 'hello', 'world' ],
  [Symbol.iterator]() {
    const self = this;
    let index = 0;
    return {
      next() {
        if (index < self.data.length) {
          return {
            value: self.data[index++],
            done: false
          };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

for(let value of obj) {
    console.log(value)
}

对于类似数组的对象(存在数值键名和length属性),部署 Iterator 接口,有一个简便方法,就是Symbol.iterator方法直接引用数组的 Iterator 接口。

下面是另一个类似数组的对象调用数组的Symbol.iterator方法的例子。

let iterable = {
  0: 'a',
  1: 'b',
  2: 'c',
  length: 3,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
};
for (let item of iterable) {
  console.log(item); // 'a', 'b', 'c'
}

注意,普通对象部署数组的Symbol.iterator方法,并无效果。

let iterable = {
  a: 'a',
  b: 'b',
  c: 'c',
  length: 3,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
};
for (let item of iterable) {
  console.log(item); // undefined, undefined, undefined
}

如果Symbol.iterator方法对应的不是遍历器生成函数(即会返回一个遍历器对象),解释引擎将会报错。

var obj = {};

obj[Symbol.iterator] = () => 1;

[...obj] // TypeError: [] is not a function

上面代码中,变量obj的Symbol.iterator方法对应的不是遍历器生成函数,因此报错。

有了遍历器接口,数据结构就可以用for…of循环遍历,也可以使用while循环遍历

var $iterator = ITERABLE[Symbol.iterator]();
var $result = $iterator.next();
while (!$result.done) {
  var x = $result.value;
  // ...
  $result = $iterator.next();
}

上面代码中,ITERABLE代表某种可遍历的数据结构,$iterator是它的遍历器对象。遍历器对象每次移动指针(next方法),都检查一下返回值的done属性,如果遍历还没结束,就移动遍历器对象的指针到下一步(next方法),不断循环。

for…of 循环

一个数据结构只要部署了Symbol.iterator属性,就被视为具有 iterator 接口,就可以用for…of循环遍历它的成员。也就是说,for…of循环内部调用的是数据结构的Symbol.iterator方法。

for…of循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如arguments对象、DOM NodeList 对象)、后文的 Generator 对象,以及字符串。

数组

数组原生具备iterator接口(即默认部署了Symbol.iterator属性),for…of循环本质上就是调用这个接口产生的遍历器,可以用下面的代码证明。

const arr = ['red', 'green', 'blue'];

for(let v of arr) {
  console.log(v); // red green blue
}

const obj = {};
obj[Symbol.iterator] = arr[Symbol.iterator].bind(arr);

for(let v of obj) {
  console.log(v); // red green blue
}

上面代码中,空对象obj部署了数组arr的Symbol.iterator属性,结果obj的for…of循环,产生了与arr完全一样的结果。

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…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"
}

Set 和 Map 结构

Set 和 Map 结构也原生具有 Iterator 接口,可以直接使用for…of循环。

计算生成的数据结构

有些数据结构是在现有数据结构的基础上,计算生成的。比如,ES6 的数组、Set、Map 都部署了以下三个方法,调用后都返回遍历器对象。

entries() 返回一个遍历器对象,用来遍历[键名, 键值]组成的数组。对于数组,键名就是索引值;对于 Set,键名与键值相同。Map 结构的 Iterator 接口,默认就是调用entries方法。
keys() 返回一个遍历器对象,用来遍历所有的键名。
values() 返回一个遍历器对象,用来遍历所有的键值。
这三个方法调用后生成的遍历器对象,所遍历的都是计算生成的数据结构。

let arr = ['a', 'b', 'c'];
for (let pair of arr.entries()) {
  console.log(pair);
}
// [0, 'a']
// [1, 'b']
// [2, 'c']

类似数组的对象

// 字符串
let str = "hello";

for (let s of str) {
  console.log(s); // h e l l o
}

// DOM NodeList对象
let paras = document.querySelectorAll("p");

for (let p of paras) {
  p.classList.add("test");
}

// arguments对象
function printArgs() {
  for (let x of arguments) {
    console.log(x);
  }
}
printArgs('a', 'b');
// 'a'
// 'b'

对象

对于普通的对象,for…of结构不能直接使用,会报错,必须部署了 Iterator 接口后才能使用。但是,这样情况下,for…in循环依然可以用来遍历键名。
一种解决方法是,使用Object.keys方法将对象的键名生成一个数组,然后遍历这个数组。

for (var key of Object.keys(someObject)) {
  console.log(key + ': ' + someObject[key]);
}

另一个方法是使用 Generator 函数将对象重新包装一下。

const obj = { a: 1, b: 2, c: 3 }

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

Reflect

属于一个静态类,统一的操作对象的API。

Promise

概述

Promise 对象是 JavaScript 的异步操作解决方案,为异步操作提供统一接口。它起到代理作用(proxy),充当异步操作与回调函数之间的中介,使得异步操作具备同步操作的接口。

Promise 的设计思想是,所有异步任务都返回一个 Promise 实例。Promise 实例有一个then方法,用来指定下一步的回调函数。

Promise对象的状态

Promise 对象通过自身的状态,来控制异步操作。Promise 实例具有三种状态。

  • 异步操作未完成(pending)
  • 异步操作成功(fulfilled)
  • 异步操作失败(rejected)

Promise构造函数

JavaScript 提供原生的Promise构造函数,用来生成 Promise 实例。

const promise = new Promise(function (resolve, reject) {
  // ...

  if (/* 异步操作成功 */){
    resolve(value);
  } else { /* 异步操作失败 */
    reject(new Error());
  }
});

上面代码中,Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject。它们是两个函数,由 JavaScript 引擎提供,不用自己实现。

resolve函数的作用是,将Promise实例的状态从“未完成”变为“成功”(即从pending变为fulfilled),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。reject函数的作用是,将Promise实例的状态从“未完成”变为“失败”(即从pending变为rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

Promise.prototype.then()

Promise 实例具有then方法,也就是说,then方法是定义在原型对象Promise.prototype上的。它的作用是为 Promise 实例添加状态改变时的回调函数。then方法的第一个参数是resolved状态的回调函数,第二个参数是rejected状态的回调函数,它们都是可选的。

promise.then(function(value){
    console.log("异步操作成功的回调")
},function(error){
    console.log("异步操作失败的回调")
})

then方法可以链式使用。

p1
  .then(step1)
  .then(step2)
  .then(step3)
  .then(
    console.log,
    console.error
  );

上面代码中,p1后面有四个then,意味依次有四个回调函数。只要前一步的状态变为fulfilled,就会依次执行紧跟在后面的回调函数。

console.log只显示step3的返回值,而console.error可以显示p1step1step2step3之中任意一个发生的错误。举例来说,如果step1的状态变为rejected,那么step2step3都不会执行了(因为它们是resolved的回调函数)。Promise 开始寻找,接下来第一个为rejected的回调函数,在上面代码中是console.error。这就是说,Promise 对象的报错具有传递性。

Promise.prototype.catch()

Promise.prototype.catch()方法是.then(null, rejection).then(undefined, rejection)的别名,用于指定发生错误时的回调函数。

getJSON('/posts.json').then(function(posts) {
  // ...
}).catch(function(error) {
  // 处理 getJSON 和 前一个回调函数运行时发生的错误
  console.log('发生错误!', error);
});

上面代码中,getJSON()方法返回一个 Promise 对象,如果该对象状态变为resolved,则会调用then()方法指定的回调函数;如果异步操作抛出错误,状态就会变为rejected,就会调用catch()方法指定的回调函数,处理这个错误。另外,then()方法指定的回调函数,如果运行中抛出错误,也会被catch()方法捕获。

// 写法一
const promise = new Promise(function(resolve, reject) {
  try {
    throw new Error('test');
  } catch(e) {
    reject(e);
  }
});
promise.catch(function(error) {
  console.log(error);
});

// 写法二
const promise = new Promise(function(resolve, reject) {
  reject(new Error('test'));
});
promise.catch(function(error) {
  console.log(error);
});
// Error: test

Promise.prototype.finally()

finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。该方法是 ES2018 引入标准的。

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

上面代码中,不管promise最后的状态,在执行完then或catch指定的回调函数以后,都会执行finally方法指定的回调函数。

Promise.all()

Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

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

(1)只有p1、p2、p3的状态都变成fulfilledp的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
(2)只要p1、p2、p3之中有一个被rejectedp的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。

// 生成一个Promise对象的数组
const promises = [2, 3, 5, 7, 11, 13].map(function (id) {
  return getJSON('/post/' + id + ".json");
});

Promise.all(promises).then(function (posts) {
  // ...
}).catch(function(reason){
  // ...
});

Promise.allSettled()

有时候,我们希望等到一组异步操作都结束了,不管每一个操作是成功还是失败,再进行下一步操作。但是,现有的 Promise 方法很难实现这个要求。

Promise.all()方法只适合所有异步操作都成功的情况,如果有一个操作失败,就无法满足要求。

为了解决这个问题,ES2020 引入了Promise.allSettled()方法,用来确定一组异步操作是否都结束了(不管成功或失败)。所以,它的名字叫做”Settled“,包含了”fulfilled“和”rejected“两种情况。

Promise.allSettled()方法接受一个数组作为参数,数组的每个成员都是一个 Promise 对象,并返回一个新的 Promise 对象。只有等到参数数组的所有 Promise 对象都发生状态变更(不管是fulfilled还是rejected),返回的 Promise 对象才会发生状态变更。

const promises = [
  fetch('/api-1'),
  fetch('/api-2'),
  fetch('/api-3'),
];

await Promise.allSettled(promises);
removeLoadingIndicator();

上面示例中,数组promises包含了三个请求,只有等到这三个请求都结束了(不管请求成功还是失败),removeLoadingIndicator()才会执行。
该方法返回的新的 Promise 实例,一旦发生状态变更,状态总是fulfilled,不会变成rejected。状态变成fulfilled后,它的回调函数会接收到一个数组作为参数,该数组的每个成员对应前面数组的每个 Promise 对象。
results的每个成员是一个对象,对象的格式是固定的,对应异步操作的结果。

// 异步操作成功时
{status: 'fulfilled', value: value}

// 异步操作失败时
{status: 'rejected', reason: reason}

成员对象的status属性的值只可能是字符串fulfilled或字符串rejected,用来区分异步操作是成功还是失败。如果是成功(fulfilled),对象会有value属性,如果是失败(rejected),会有reason属性,对应两种状态时前面异步操作的返回值。

下面是返回值的用法例子。

const promises = [ fetch('index.html'), fetch('https://does-not-exist/') ];
const results = await Promise.allSettled(promises);

// 过滤出成功的请求
const successfulPromises = results.filter(p => p.status === 'fulfilled');

// 过滤出失败的请求,并输出原因
const errors = results
  .filter(p => p.status === 'rejected')
  .map(p => p.reason);

Promise.race()

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.race([p1, p2, p3]);

上面代码中,只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。

Promise.race()方法的参数与Promise.all()方法一样,如果不是 Promise 实例,就会先调用下面讲到的Promise.resolve()方法,将参数转为 Promise 实例,再进一步处理。
下面是一个例子,如果指定时间内没有获得结果,就将 Promise 的状态变为reject,否则变为resolve。

const p = Promise.race([
  fetch('/resource-that-may-take-a-while'),
  new Promise(function (resolve, reject) {
    setTimeout(() => reject(new Error('request timeout')), 5000)
  })
]);

p
.then(console.log)
.catch(console.error);

上面代码中,如果 5 秒之内fetch方法无法返回结果,变量p的状态就会变为rejected,从而触发catch方法指定的回调函数。

Promise.any()

ES2021 引入了Promise.any()方法。该方法接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例返回。

Promise.any([
  fetch('https://v8.dev/').then(() => 'home'),
  fetch('https://v8.dev/blog').then(() => 'blog'),
  fetch('https://v8.dev/docs').then(() => 'docs')
]).then((first) => {  // 只要有一个 fetch() 请求成功
  console.log(first);
}).catch((error) => { // 所有三个 fetch() 全部请求失败
  console.log(error);
});

只要参数实例有一个变成fulfilled状态,包装实例就会变成fulfilled状态;如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。

Promise.any()跟Promise.race()方法很像,只有一点不同,就是Promise.any()不会因为某个 Promise 变成rejected状态而结束,必须等到所有参数 Promise 变成rejected状态才会结束。

const promises = [
  fetch('/endpoint-a').then(() => 'a'),
  fetch('/endpoint-b').then(() => 'b'),
  fetch('/endpoint-c').then(() => 'c'),
];

try {
  const first = await Promise.any(promises);
  console.log(first);
} catch (error) {
  console.log(error);
}

上面代码中,Promise.any()方法的参数数组包含三个 Promise 操作。其中只要有一个变成fulfilled,Promise.any()返回的 Promise 对象就变成fulfilled。如果所有三个操作都变成rejected,那么await命令就会抛出错误。

Promise.any()抛出的错误,不是一个一般的 Error 错误对象,而是一个 AggregateError 实例。它相当于一个数组,每个成员对应一个被rejected的操作所抛出的错误。

Promise.resolve()

有时需要将现有对象转为 Promise 对象,Promise.resolve()方法就起到这个作用。

const jsPromise = Promise.resolve($.ajax('/whatever.json'));

上面代码将 jQuery 生成的deferred对象,转为一个新的 Promise 对象。

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

Promise.reject()

Promise.reject(reason)方法也会返回一个新的 Promise 实例,该实例的状态为rejected。

const p = Promise.reject('出错了');
// 等同于
const p = new Promise((resolve, reject) => reject('出错了'))

p.then(null, function (s) {
  console.log(s)
});
// 出错了

上面代码生成一个 Promise 对象的实例p,状态为rejected,回调函数会立即执行。

宏任务和微任务

javascript中的异步任务包含两种:宏任务和微任务。

宏任务:DOM事件回调、AJAX事件回调、定时器回调。
微任务:Promise、MutationObserver、(node环境中还包括process.nextTick。)

注意:new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
async/await底层是基于Promise封装的,所以await前面的代码相当于new Promise,是同步进行的,await后面的代码相当于then,才是异步进行的。

js运行程序代码是同步的,执行完所有同步代码后就执行异步代码;异步代码中先执行微任务再执行宏任务。

异步任务执行顺序:微任务→宏任务→微任务→宏任务……

运行机制

在这里插入图片描述
事件循环的过程如下:
1、JS引擎(唯一主线程)按顺序解析代码,遇到函数声明,直接跳过,遇到函数调用,入栈。
2、如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用。
3、如果是异步函数调用,分发给Web API(多个辅助线程),异步函数弹出栈,继续下一个函数调用。
4、Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件(比如setTimeout设置的10s后),如果异步函数是宏任务,则入宏任务消息队列,如果是微任务,则入微任务消息队列。
5、Event Loop不停地检查主线程的调用栈与回调队列,当调用栈为空时,就把微任务消息队列的第一个任务推入栈中执行,执行完成之后,再取出第二个微任务,直到微任务消息队列为空。然后去宏任务消息队列中取第一个宏任务推入栈中执行,当该宏任务执行完毕之后,在下一个宏任务执行前,再依次取出微任务消息队列中的所有微任务入栈执行。
6、上述过程不断循环,每当微任务队列清空,可作为本轮事件循环的结束。
有几个关键点如下:
1、所有微任务总会在下一个宏任务之前全部执行完毕,宏任务必然是在微任务之后才执行的(因为微任务实际上是宏任务的其中一个步骤)。
2、宏任务按顺序执行,且浏览器在每个宏任务之间渲染页面
3、所有微任务也按顺序执行,且在以下场景会立即执行所有微任务

  • 每个回调之后且js执行栈中为空。
  • 每个宏任务结束后。

我们通过几个示例来加深一下理解:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2)

流程如下:
1、整体script作为第一个宏任务进入主线程,遇到setTimeout入栈处理,发现是异步函数(宏任务),出栈,移交给Web API处理,0秒等待后,将回调函数加到宏任务队列尾部。
2、遇到new Promise,入栈处理,发现是同步任务,直接执行,console输出1;
3、遇到then,入栈处理,发现是异步函数(微任务),出栈,移交给Web API处理,将回调函数加入微任务队列尾部;
4、遇到console.log(2),入栈处理,同步任务,直接console输出2,出栈。
5、栈已清空,检查微任务队列。
6、取出第一个回调函数,入栈处理,发现是同步任务,直接console输出3,出栈;
7、继续从微任务队列中取出下一个微任务,发现微任务队列已经清空,结束第一轮事件循环。
8、从宏任务队列中取出第一个宏任务,入栈处理,发现是同步任务,直接console输出4。
所以,最终输出结果为:1、2、3、4
我们先稍微改变一下:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)

最终输出结果为:1 > 2 > 3 > before timeout > also before timeout > 4。

before timeout与also before timeout在4之前输出的原因是,在微任务执行的过程中,新产生的微任务会被直接添加到微任务队列尾部,并在下一宏任务执行之前,全部执行掉。
而如果在微任务执行的过程中,新产生了宏任务,则会进入到宏任务队列尾部,按照宏任务顺序在后面的事件循环中执行。
再来看一个嵌套的示例:

Promise.resolve().then(()=>{
  console.log('Promise1')  
  setTimeout(()=>{
    console.log('setTimeout2')
  },0)
})

setTimeout(()=>{
  console.log('setTimeout1')
  Promise.resolve().then(()=>{
    console.log('Promise2')    
  })
},0)

最后输出结果是Promise1 > setTimeout1 > Promise2 > setTimeout2

1、一开始执行栈的同步任务执行完毕,会去microtasks queues中找,清空microtasks queues,输出Promise1,同时会生成一个异步任务setTimeout1。
2、去宏任务队列查看此时队列时setTimeout1在setTimeout2之前,因为setTimeout1执行栈一开始的时候就开始异步执行,所以输出setTimeout1。
3、在执行setTimeout1时会生成Promise2的一个microtasks,放入 microtasks queues 中,接着又是一个循环,去清空 microtasks queues ,输出 Promise2
4、清空完 microtasks queues ,就又会去宏任务队列取一个,这回取的是 setTimeout2

最后来一个复杂的示例,检测一下是否真正掌握了事件循环的机制。

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

最终的输出结果为1,7,6,8,2,4,3,5,9,11,10,12。

setTimeout和setTimeInterval

setTimeout()

setTimeout函数用来指定某个函数或者某个段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。

var timerId = setTimeout(func|code, delay);

上面代码中,setTimeout函数接受两个参数,第一个参数func|code是将要推迟执行的函数名或者一段代码,第二个参数delay是推迟执行的毫秒数。

console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
// 1
// 3
// 2

上面代码会先输出1和3,然后等待1000毫秒再输出2。注意,console.log(2)必须以字符串的形式,作为setTimeout的参数。
如果推迟执行的是函数,就直接将函数名,作为setTimeout的参数。

function f() {
  console.log(2);
}

setTimeout(f, 1000);

setTimeout的第二个参数如果省略,则默认为0。

setTimeout(f)
// 等同于
setTimeout(f, 0)

还有一个需要注意的地方,如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象。

var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(obj.y, 1000) // 1

为了防止出现这个问题,一种解决方法是将obj.y放入一个函数。

var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(function () {
  obj.y();
}, 1000);
// 2

上面代码中,obj.y放在一个匿名函数之中,这使得obj.y在obj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。

另一种解决方法是,使用bind方法,将obj.y这个方法绑定在obj上面。

var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(obj.y.bind(obj), 1000)

setInterval()

setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。

var i = 1
var timer = setInterval(function() {
  console.log(2);
}, 1000)

上面代码中,每隔1000毫秒就输出一个2,会无限运行下去,直到关闭当前窗口。

clearTimeout(),clearInterval()

setTimeoutsetInterval函数,都返回一个整数值,表示计数器编号。将该整数传入clearTimeoutclearInterval函数,就可以取消对应的定时器。

var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);

clearTimeout(id1);
clearInterval(id2);

setTimeout和setInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。
利用这一点,可以写一个函数,取消当前所有的setTimeout定时器。

(function() {
  // 每轮事件循环检查一次
  var gid = setInterval(clearAllTimeouts, 0);

  function clearAllTimeouts() {
    var id = setTimeout(function() {}, 0);
    while (id > 0) {
      if (id !== gid) {
        clearTimeout(id);
      }
      id--;
    }
  }
})();

上面代码中,先调用setTimeout,得到一个计算器编号,然后把编号比它小的计数器全部取消。

debounce函数

防抖动函数。
有时,我们不希望回调函数被频繁调用。比如,用户填入网页输入框的内容,希望通过 Ajax 方法传回服务器,jQuery 的写法如下。

$('textarea').on('keydown', ajaxAction);

这样写有一个很大的缺点,就是如果用户连续击键,就会连续触发keydown事件,造成大量的 Ajax 通信。
正确的做法应该是,设置一个门槛值,表示两次 Ajax 通信的最小间隔时间。如果在间隔时间内,发生新的keydown事件,则不触发 Ajax 通信,并且重新开始计时。如果过了指定时间,没有发生新的keydown事件,再将数据发送出去。
这种做法叫做 debounce(防抖动)。假定两次 Ajax 通信的间隔不得小于2500毫秒,上面的代码可以改写成下面这样。

$('textarea').on('keydown', debounce(ajaxAction, 2500));

function debounce(fn, delay){
  var timer = null; // 声明计时器
  return function() {
    var context = this;
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(context, args);
    }, delay);
  };
}

上面代码中,只要在2500毫秒之内,用户再次击键,就会取消上一次的定时器,然后再新建一个定时器。这样就保证了回调函数之间的调用间隔,至少是2500毫秒。

运行机制

setTimeout和setInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。
这意味着,setTimeout和setInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout和setInterval指定的任务,一定会按照预定时间执行。

setTimeout(someTask, 100);
veryLongTask();

上面代码的setTimeout,指定100毫秒以后运行一个任务。但是,如果后面的veryLongTask函数(同步任务)运行时间非常长,过了100毫秒还无法结束,那么被推迟运行的someTask就只有等着,等到veryLongTask运行结束,才轮到它执行。
再看一个setInterval的例子。

setInterval(function () {
  console.log(2);
}, 1000);

sleep(3000);

function sleep(ms) {
  var start = Date.now();
  while ((Date.now() - start) < ms) {
  }
}

上面代码中,setInterval要求每隔1000毫秒,就输出一个2。但是,紧接着的sleep语句需要3000毫秒才能完成,那么setInterval就必须推迟到3000毫秒之后才开始生效。注意,生效后setInterval不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。

setTimeout(f, 0)

含义

setTimeout的作用是将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),setTimeout(f, 0)会在下一轮事件循环执行。,必须要等到当前脚本的同步任务,全部处理完以后,才会执行setTimeout指定的回调函数f。

setTimeout(function () {
  console.log(1);
}, 0);
console.log(2);

上面代码先输出2,再输出1。因为2是同步任务,在本轮事件循环执行,而1是下一轮事件循环执行。
总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f。
实际上,setTimeout(f, 0)不会真的在0毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到4毫秒之后运行。如果电脑正在使用电池供电,会等到16毫秒之后运行;如果网页不在当前 Tab 页,会推迟到1000毫秒(1秒)之后运行。这样是为了节省系统资源。

应用

setTimeout(f, 0)有几个非常重要的用途。它的一大应用是,可以调整事件的发生顺序。比如,网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,想让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)。

// HTML 代码如下
// <input type="button" id="myButton" value="click">

var input = document.getElementById('myButton');

input.onclick = function A() {
  setTimeout(function B() {
    input.value +=' input';
  }, 0)
};

document.body.onclick = function C() {
  input.value += ' body'
};

上面代码在点击按钮后,先触发回调函数A,然后触发函数C。函数A中,setTimeout将函数B推迟到下一轮事件循环执行,这样就起到了,先触发父元素的回调函数C的目的了。

另一个应用是,用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。

// HTML 代码如下
// <input type="text" id="input-box">

document.getElementById('input-box').onkeypress = function (event) {
  this.value = this.value.toUpperCase();
}

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转为大写,因为浏览器此时还没接收到新的文本,所以this.value取不到最新输入的那个字符。只有用setTimeout改写,上面的代码才能发挥作用。

document.getElementById('input-box').onkeypress = function() {
  var self = this;
  setTimeout(function() {
    self.value = self.value.toUpperCase();
  }, 0);
}

上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。

由于setTimeout(f, 0)实际上意味着,将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到setTimeout(f, 0)里面执行。

var div = document.getElementsByTagName('div')[0];

// 写法一
for (var i = 0xA00000; i < 0xFFFFFF; i++) {
  div.style.backgroundColor = '#' + i.toString(16);
}

// 写法二
var timer;
var i=0x100000;

function func() {
  timer = setTimeout(func, 0);
  div.style.backgroundColor = '#' + i.toString(16);
  if (i++ == 0xFFFFFF) clearTimeout(timer);
}

timer = setTimeout(func, 0);

上面代码有两种写法,都是改变一个网页元素的背景色。写法一会造成浏览器“堵塞”,因为 JavaScript 执行速度远高于 DOM,会造成大量 DOM 操作“堆积”,而写法二就不会,这就是setTimeout(f, 0)的好处。

另一个使用这种技巧的例子是代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成setTimeout(highlightNext, 50)的样子,性能压力就会减轻。

Generator函数

基本用法

Generator函数是ES6提供的一种异步编程解决方案。从语法层面上,可以把Generator函数理解成一个状态机,内部封装了多个内部状态。执行Generator函数会返回一个遍历器对象(iterator),可以依次遍历Generator函数内部的每一个状态。

形式上Generator函数是一个普通函数,但是有两个特征:一是function关键字与函数名之间有一个星号;二是函数体内部使用yield表达式,定义不同的内部状态。

   function* helloGenerator(){
        yield 'hello'
        yield 'generator'
        return 'ending'
    }
    let g = helloGenerator()
hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'generator', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }

上面代码定义了一个Generator函数,它内部有两个yield表达式,即该函数有三个状态: hello、generator和return语句。
调用Generator函数后,该函数不执行,返回的也不是函数的运行结果,而是一个执行内部状态的指针对象iterator。
下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。

yield表达式

由于 Generator 函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。
遍历器对象的next方法的运行逻辑如下。
(1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。
(2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。
(3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。
(4)如果该函数没有return语句,则返回的对象的value属性值为undefined。

需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

function* gen() {
  yield  123 + 456;
}

上面代码中,yield后面的表达式123 + 456,不会立即求值,只会在next方法将指针移到这一句时,才会求值。

yield表达式与return语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到yield,函数暂停执行,下一次再从该位置继续向后执行,而return语句不具备位置记忆的功能。一个函数里面,只能执行一次(或者说一个)return语句,但是可以执行多次(或者说多个)yield表达式。正常函数只能返回一个值,因为只能执行一次return;Generator 函数可以返回一系列的值,因为可以有任意多个yield。

Generator 函数可以不用yield表达式,这时就变成了一个单纯的暂缓执行函数。

function* f() {
  console.log('执行了!')
}

var generator = f();

setTimeout(function () {
  generator.next()
}, 2000);

另外,yield表达式如果用在另一个表达式之中,必须放在圆括号里面。

 console.log('Hello' + (yield 123)); // OK

yield表达式用作函数参数或放在赋值表达式的右边,可以不加括号。

function* demo() {
  foo(yield 'a', yield 'b'); // OK
  let input = yield; // OK
}

与Iterator接口的关系

由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。

var myIterable = {};
myIterable[Symbol.iterator] = function* () {
  yield 1;
  yield 2;
  yield 3;
};

[...myIterable] // [1, 2, 3]

next方法的参数

function* f() {
  for(var i = 0; true; i++) {
    var reset = yield i;
    if(reset) { i = -1; }
  }
}

var g = f();

g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

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

Generator.prototype.throw()

Generator 函数返回的遍历器对象,都有一个throw方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。

var g = function* () {
  try {
    yield;
  } catch (e) {
    console.log('内部捕获', e);
  }
};
var i = g();
i.next();

try {
  i.throw('a');
  i.throw('b');
} catch (e) {
  console.log('外部捕获', e);
}
// 内部捕获 a
// 外部捕获 b

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 }

协程

意思是多个线程互相协作,完成异步任务。
举例来说,读取文件的协程写法如下。

function* asyncJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。

异步任务的封装

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

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方法。

async/await

async函数

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。
async函数就是Generator函数的语法糖。
只要使用了async关键字,函数就会返回一个Promise对象

    function sayHello(){
        return "Hello"
    }
    //返回结果: Hello
    async function sayHello() {
        return "Hello"
    }
    //返回结果:Promise{<fulfilled>:'Hello'}
    function sayHello(){
        return Promise.resolve('Hello')
    }
    //返回结果: Promise {<fulfilled>: 'Hello'}

相似地,会抛出错误的async函数等效于返回将失败的promise的函数:

function f() {
    return Promise.reject('Error');
}

// asyncF is equivalent to f!
async function asyncF() {
    throw 'Error';
}

await

以前,当我们产生一个promise,我们无法同步等待它完成,我们只能通过then注册一个回调函数。不允许直接等待一个promise是为了鼓励开发者写非阻塞代码,不然开发者会更乐意写阻塞的代码,因为这样比promise和回调更加简单。

await关键字只能在async函数内部使用,让我们可以等待一个promise。

   function delay(){
       let promise = new Promise(function(resolve,reject){
            setTimeout(()=>{
                console.log('promise执行完毕')
                resolve('成功回调的参数')
            },3000)
       })

       return promise
   }

   async function f(){
       const response = await delay()
       console.log(response) //response:promise执行成功或者失败的返回值。
   }
   f()

如果在async函数外使用promise,我们依然需要then和回调函数。

   async function f(){
       const response = await delay()
       return response
   }
   f().then(function(value){
       console.log(value)
   })

多个Promise继发执行

   async function f(){
       console.log('开始执行')
       /*start:promise继发执行*/
       /*先执行promise1,在执行promise2,最后执行promise3*/
       const response1 = await delay1()
       const response2 = await delay2()
       const response3 = await delay3()
       /*end:promise继发执行*/
       console.log('执行结束')
   }
   f()
   //打印结果: 开始执行、promise1执行完毕、promise2执行完毕、promise3执行完毕、执行结束

多个promise并发执行

如果异步操作之间没有依赖关系,我们应该尽量使用并发执行。

  async function f(){
       console.log('开始执行')
       /*start:promise并发执行*/
       const promise1 = delay1()
       const promise2 = delay2()
       const promise3 = delay3()
       /*end:promise并发执行*/
       const response1 = await promise1
       const response2 = await promise2
       const response3 = await promise3
       console.log('执行结束')
   }
   f()

错误处理

在之前的例子里,我们大多假定promise会成功,然后await一个promise的返回值。如果我们等待的promise失败了,会在async函数里产生一个异常,我们可以使用标准的try/catch来处理它:

async function f() {
    try {
        const promiseResult = await Promise.reject('Error');
    } catch (e){
        console.log(e);
    }
}

如果async函数不处理这个异常,不管是这异常是因为promise是被reject了还是其他的bug,这个函数都会返回一个被reject掉的promise:

async function f() {
    // Throws an exception
    const promiseResult = await Promise.reject('Error');
}

// Will print "Error"
f().
    then(() => console.log('Success')).
    catch(err => console.log(err))

async function g() {
    throw "Error";
}

// Will print "Error"
g().
    then(() => console.log('Success')).
    catch(err => console.log(err))

这就让我们可以使用熟悉的方式来处理错误。

对象的方法

Object.create

生成实例对象的常用方法是,使用new命令让构造函数返回一个实例。但是很多时候,只能拿到一个实例对象,那么能不能从一个实例对象,生成另一个实例对象呢?

JavaScript 提供了Object.create()方法,用来满足从一个实例对象生成另外一个实例对象。该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性和方法。

function A(){
    this.test = function() { //实例对象的方法
        console.log('test')
    }
}
A.prototype.print = function() { //原型对象的方法
    console.log('print')
}

var a = new A()

var b = Object.create(a)
b.test() //test
b.print() //print

上面代码中,Object.create()方法以A对象为原型,生成了B对象。B继承了A的所有属性和方法。
实际上,Object.create()方法可以用下面的代码代替。

if (typeof Object.create !== 'function') {
  Object.create = function (obj) {
    function F() {}
    F.prototype = obj;
    return new F();
  };
}

上面代码表明,Object.create()方法的实质是新建一个空的构造函数F,然后让F.prototype属性指向参数对象obj,最后返回一个F的实例,从而实现让该实例继承obj的属性。
如果想要生成一个不继承任何属性(比如没有toString()和valueOf()方法)的对象,可以将Object.create()的参数设为null

var obj = Object.create(null);

obj.valueOf()
// TypeError: Object [object Object] has no method 'valueOf'

Object.create()方法生成的新对象,动态继承了原型。在原型上添加或修改任何方法,会立刻反映在新对象之上。

var obj1 = { p: 1 };
var obj2 = Object.create(obj1);

obj1.p = 2;
obj2.p // 2

Object.create()方法生成的对象,继承了它的原型对象的构造函数

function A() {}
var a = new A();
var b = Object.create(a);

b.constructor === A // true
b instanceof A // true
let a = {
    name:'lichangan',
    age:18,
}

let b = {
    name:'xiaowu',
    book: {
        title:'js',
        price:50
    }
}

let c = Object.create(b)
console.log(c.book.price) //50
b.book.price = 18
console.log(c.book.price) //18

Object.assign

将多个源对象的属性复制到一个目标对象中。如果有相同属性,源对象会覆盖目标对象。

const source1 = {
    a:10,
    b:20
}

const target = {
    a:40,
    c:80
}

Object.assign(target,source1)
console.log(target.a) //10 
console.log(target.b) // 20 

const Source2 = {
	d:d
}
Object.assign(target,source1,source2)

Object.assign()的拷贝行为是一个浅拷贝行为。

let a = {
    name:'lichangan',
    age:18,
}

let b = {
    name:'xiaowu',
    book: {
        title:'js',
        price:50
    }
}
let c = {}
Object.assign(c,a,b)
console.log(c)

a.age = 19;
b.book.price = 40
console.log(c.age) //18
console.log(c.book.price) // 40

JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。

function M1() {
  this.hello = 'hello';
}

function M2() {
  this.world = 'world';
}

function S() {
  //this.hello和this.world都是实例对象的属性,不是原型对象的属性
  //通过显示调用M1和M2的构造函数来绑定到当前this上面。
  M1.call(this); 
  M2.call(this);
}

// 继承 M1,根据实例创建一个对象。
S.prototype = Object.create(M1.prototype);
//S.prototype = M1.prototype 这种写法是引用,当我们修改S的原型对象时,M1也会受到影响。
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype);

// 指定构造函数
S.prototype.constructor = S;

var s = new S();
s.hello // 'hello'
s.world // 'world'

上面代码中,子类S同时继承了父类M1和M2。这种模式又称为 Mixin(混入)。

Object.is

Object.is():是ES6新增的用来比较两个值是否严格相等的方法,与===的行为基本一致。

==:比较运算符,两边值类型不同的时候,先进行类型转换,再比较;
===:严格比较运算符,不做类型转换,类型不同就是不等;

而在ES6中,Object.is()类似于===,但在三等号判等的基础上特别处理了 NaN-0+0 ,保证 -0+0 不再相同,但 Object.is(NaN, NaN) 会返回 true。

下面这些情况Object.is()会认为两个值是相同的:

两个值都是 undefined
两个值都是 null
两个值都是 true 或者都是 false
两个值是由相同个数的字符按照相同的顺序组成的字符串
两个值指向同一个对象
两个值都是数字并且
都是正零 +0
都是负零 -0
都是 NaN
都是除零和 NaN 外的其它同一个数字

Object.defineProperty

Object.defineProperty()方法会直接在一个对象上定义一个新属性或者修改一个对象的现有属性,并且返回此对象。可以用来监视某个对象的读写。

 //模拟Vue中的data选项
      let data = {
        msg: "hello",
      };
      //模拟Vue实例
      let vm = {};

      //数据劫持:当访问或者设置vm中的成员的时候,做一些干预操作。
      //给vm对象新建一个属性msg,当访问或者设置vm中的msg的时候,做一些干预操作。
      Object.defineProperty(vm, "msg", {
        //可枚举(可遍历)
        enumerable: true,
        //可配置(可以使用delete删除,可以通过defineProperty重新定义)
        configurable: true,
        //当获取值的时候执行
        get() {
          console.log("get:" + data.msg);
          return data.msg;
        },
        //当设置值的时候执行
        set(newValue) {
          console.log("set:" + newValue);
          if (newValue === data.msg) {
            return;
          }
          data.msg = newValue;
          //数据修改,更新DOM值
          document.querySelector("#app").textContent = data.msg;
        },
      });
      
      vm.msg = "HelloWorld!"

Object.prototype.isPrototypeOf()

实例对象的isPrototypeOf方法,用来判断该对象是否为参数对象的原型

var o1 = {};
var o2 = Object.create(o1);
var o3 = Object.create(o2);

o2.isPrototypeOf(o3) // true
o1.isPrototypeOf(o3) // true

Object.prototype.proto

实例对象的__proto__属性(前后各两个下划线),返回该对象的原型。该属性可读写。

var obj = {};
var p = {};

obj.__proto__ = p;
Object.getPrototypeOf(obj) === p // true

根据语言标准,__proto__属性只有浏览器才需要部署,其他环境可以没有这个属性。它前后的两根下划线,表明它本质是一个内部属性,不应该对使用者暴露。因此,应该尽量少用这个属性,而是用Object.getPrototypeOf()和Object.setPrototypeOf(),进行原型对象的读写操作。

Proxy

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
基本用法:

let proxy = new Proxy(target,handler);

target参数表示要拦截的目标对象
handle参数也是一个对象,用来制定拦截行为。
Proxy支持13中拦截行为,这里只用到两种。

get(target,propKey,receiver);

用来拦截某个属性的读取操作,可以接受三个参数:
target:目标对象
propKey:属性名
receiver(可选):proxy实例本身

set(target,propKey,value,receiver)

用于拦截某个属性的赋值操作,可以接受四个参数:
target:目标对象
propKey:属性名
value:属性值
receiver(可选):Proxy实例本身。

  //模拟Vue中的data选项
      let data = {
        msg: "hello",
        count:10,
      };
      //模拟Vue实例
      let vm = new Proxy(data,{
          //当访问vm的成员会执行
          get(target,key) {
              console.log("get,key:",key,target[key])
              return target[key]
          },
          //当设置vm的成员时会执行
          set(target,key,newValue){
              console.log('set,key:',key,newValue)
              if(target[key] === newValue){
                  return
              }
              target[key] = newValue
              document.querySelector('#app').textContent = target[key]
          }
      })
    
      vm.msg = "HelloWorld!"

Proxy与Object.defineProperty

(1)
Object.defineProperty()只能监视属性的读写。
Object.defineProperty无法监听数组变化。

Proxy监听的是整个对象。
Proxy可以监视数组更加方便。
Proxy有多达13种拦截方法。

const listPropery = new Proxy(list,{
    set(target,property,value){
        target[property] = value
        return true
    }
})
listPropery.push(100)

面向对象

this指针

使用场合

在普通的函数中,包含this的函数,谁调用了它,它就指向谁
也就是说,包含 this 的函数 只在乎是谁调用了它,跟在哪里进行的函数声明没有关系。
箭头函数体内的this对象,就是定义该函数时所在的作用域指向的对象,而不是使用时所在的作用域指向的对象。

(1)全局环境

全局环境使用this,它指的就是顶层对象window

this === window // true
(2)函数内部

非严格模式下,this默认指向全局对象window
严格模式,thisundefined

function test(){
    var a = 1;
    console.log(this.a);
}

test(); //undefined
(3)构造函数

构造函数中的this,指的是实例对象。

var Obj = function (p) {
  this.p = p;
};

上面代码定义了一个构造函数Obj。由于this指向实例对象,所以在构造函数内部定义this.p,就相当于定义实例对象有一个p属性。

(4)对象的方法

如果对象的方法里面包含thisthis的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。

var o = {
  prop: 37,
  f: function() {
    return this.prop;
  }
};
console.log(o.f());  //37
//this指向调用方法的实例对象o
var a = o.f;
console.log(a()):  //undefined
//var a实际上是window.a,把o.f赋值给window.a。
//a()实际上是window.a(),a方法内部的指针指向调用它的window对象,window对象没有prop属性,所以undefined。
原型链中的this

原型链中的this仍然指向调用它的对象。

setTimeout和setInterval

对于延时函数内部的回调函数的this指向全局对象window(当然我们可以通过bind方法改变其内部函数的this指向)

function Person() {  
    this.age = 0;  
    setTimeout(function() {
        console.log(this); //打印window对象
    }, 3000);
}

var p = new Person();//3秒后返回 window 对象
//通过bind绑定this,返回一个新方法
function Person() {  
    this.age = 0;  
    setTimeout((function() {
        console.log(this);
    }).bind(this), 3000);
}

箭头函数中的this

箭头函数体内的this对象,就是定义该函数时所在的作用域指向的对象,而不是使用时所在的作用域指向的对象。

下面是普通函数的列子:

var name = 'window'; // 其实是window.name = 'window'

var A = {
   name: 'A',
   sayHello: function(){
      console.log(this.name)
   }
}

A.sayHello();// 输出A

var B = {
  name: 'B'
}

A.sayHello.call(B);//输出B

A.sayHello.call();//不传参数指向全局window对象,输出window.name也就是window

改造一下:

var name = 'window'; 
var A = {
   name: 'A',
   sayHello: () => {
      console.log(this.name)
   }
}

A.sayHello();// 输出的是window

这里的箭头函数,也就是sayHello(),所在的作用域其实是最外层的js环境,因为没有其他函数包裹;然后最外层的js环境指向的对象是winodw对象,所以这里的this指向的是window对象。

继续改造一下:

var name = 'window'; 

var A = {
   name: 'A',
   sayHello: function(){
      var s = () => console.log(this.name)
      return s//返回箭头函数s
   }
}

var sayHello = A.sayHello();
sayHello();// 输出A 

var B = {
   name: 'B';
}

sayHello.call(B); //还是A
sayHello.call(); //还是A

这样就做到了永远指向A对象了

function Person() {  
    this.age = 0;  
    setInterval(() => {
        // 回调里面的 `this` 变量就指向了期望的那个对象了
        this.age++;
    }, 3000);
}
DOM事件处理函数

当函数被当做监听事件处理函数时, 其 this 指向触发该事件的元素 (针对于addEventListener事件)。

内联事件

内联事件中的this指向分两种情况:
(1)当代码被内联处理函数调用时,它的this指向监听器所在的DOM元素
(2)当代码被包括在函数内部执行时,其this指向等同于 函数直接调用的情况,即在非严格模式指向全局对象window, 在严格模式指向undefined

绑定this的方法

call,apply,bind三者的区别
  • 三者都是用来改变this指向。
  • 三者第一个参数都是this要指向的对象,如果如果没有这个参数或参数为undefinednull,则默认指向全局window
  • callapply改变this指向后会立即执行,bind改变this指向后不会立即执行,而是返回一个新的函数。
  • 三者都可以传参,call是传入参数列表,apply是传入数组,且callapply是一次性传入参数,bind也是通过参数列表传参,而bind可以分为多次传入,第一次在使用bind绑定时传入参数,如果需要可以在调用新的函数的时候再次传入参数。
Function.prototype.call()

函数实例的call方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。

call方法的参数,应该是一个对象。如果参数为空、nullundefined,则默认传入全局对象。

var n = 123;
var obj = { n: 456 };

function print() {
  console.log(this.n);
}

print.call() // 123
print.call(null) // 123
print.call(undefined) // 123
print.call(window) // 123
print.call(obj) // 456

call方法还可以接受多个参数。

func.call(thisValue, arg1, arg2, ...)

call的第一个参数就是this所要指向的那个对象,后面的参数则是函数调用时所需的参数。

function add(a, b) {
  return a + b;
}

add.call(this, 1, 2) // 3
  • 应用场景: 调用对象的原生方法。
var obj = {};
obj.hasOwnProperty('toString') // false

// 覆盖掉继承的 hasOwnProperty 方法
obj.hasOwnProperty = function () {
  return true;
};
obj.hasOwnProperty('toString') // true

Object.prototype.hasOwnProperty.call(obj, 'toString') // false

上面代码中,hasOwnProperty是obj对象继承的方法,如果这个方法一旦被覆盖,就不会得到正确结果。call方法可以解决这个问题,它将hasOwnProperty方法的原始定义放到obj对象上执行,这样无论obj上有没有同名方法,都不会影响结果。

Function.prototype.apply()

apply方法的作用与call方法类似,也是改变this指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。

func.apply(thisValue, [arg1, arg2, ...])

apply方法的第一个参数也是this所要指向的那个对象,如果设为nullundefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在call方法中必须一个个添加,但是在apply方法中,必须以数组形式添加

function f(x, y){
  console.log(x + y);
}

f.call(null, 1, 1) // 2
f.apply(null, [1, 1]) // 2
  • 一些有趣的应用
    (1)找出数组的最大元素
    JavaScript 不提供找出数组最大元素的函数。结合使用apply方法和Math.max方法,就可以返回数组的最大元素。
Math.max(1,2,3,4,5) //本来需要传入的是参数列表
var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15 借助apply的特性传入数组,实现数组的求最大值。

(2)将数组的空元素变为undefined
通过apply方法,利用Array构造函数将数组的空元素变成undefined

Array.apply(null, ['a', ,'b'])
// [ 'a', undefined, 'b' ]

空元素与undefined的差别在于,数组的forEach方法会跳过空元素,但是不会跳过undefined。因此,遍历内部元素的时候,会得到不同的结果。

(3) 转换类似数组的对象
另外,利用数组对象的slice方法,可以将一个类似数组的对象(比如arguments对象)转为真正的数组。

Array.prototype.slice.apply({0: 1, length: 1}) // [1]
Array.prototype.slice.apply({0: 1}) // []
Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined]
Array.prototype.slice.apply({length: 1}) // [undefined]

上面代码的apply方法的参数都是对象,但是返回结果都是数组,这就起到了将对象转成数组的目的。从上面代码可以看到,这个方法起作用的前提是,被处理的对象必须有length属性,以及相对应的数字键。

(4) 绑定回调函数的对象

var o = new Object();

o.f = function () {
  console.log(this === o);
}

var f = function (){
  o.f.apply(o);
  // 或者 o.f.call(o);
};

// jQuery 的写法
$('#button').on('click', f);

上面代码中,点击按钮以后,控制台将会显示true。由于apply()方法(或者call()方法)不仅绑定函数执行时所在的对象,还会立即执行函数,因此不得不把绑定语句写在一个函数体内。更简洁的写法是采用下面介绍的bind()方法。

Function.prototype.bind()

bind()方法用于将函数体内的this绑定到某个对象,然后返回一个新函数。

var d = new Date();
d.getTime() // 1481869925657

var print = d.getTime;
print() // Uncaught TypeError: this is not a Date object.

上面代码中,我们将d.getTime()方法赋给变量print,然后调用print()就报错了。这是因为getTime()方法内部的this,绑定Date对象的实例,赋给变量print以后,内部的this已经不指向Date对象的实例了。

bind()方法可以解决这个问题。

var print = d.getTime.bind(d);
print() // 1481869925657

上面代码中,bind()方法将getTime()方法内部的this绑定到d对象,这时就可以安全地将这个方法赋值给其他变量了。

bind()还可以接受更多的参数,将这些参数绑定原函数的参数。

function add(x, y) {
  return x + y;
}

var plus5 = add.bind(null, 5);
plus5(10) // 15

上面代码中,函数add()内部并没有this,使用bind()方法的主要目的是绑定参数x,以后每次运行新函数plus5(),就只需要提供另一个参数y就够了。而且因为add()内部没有this,所以bind()的第一个参数是null,不过这里如果是其他对象,也没有影响。

  • 使用注意点
    (1)每一次返回一个新函数
    bind()方法每运行一次,就返回一个新函数,这会产生一些问题。比如,监听事件的时候,不能写成下面这样。
element.addEventListener('click', o.m.bind(o));

上面代码中,click事件绑定bind()方法生成的一个匿名函数。这样会导致无法取消绑定,所以下面的代码是无效的。

element.removeEventListener('click', o.m.bind(o));

正确的方法是写成下面这样:

var listener = o.m.bind(o);
element.addEventListener('click', listener);
//  ...
element.removeEventListener('click', listener);

(2) 结合回调函数使用
**回调函数是 JavaScript 最常用的模式之一,但是一个常见的错误是,将包含this的方法直接当作回调函数。**解决方法就是使用bind()方法,将counter.inc()绑定counter

var counter = {
  count: 0,
  inc: function () {
    'use strict';
    this.count++;
  }
};

function callIt(callback) {
  callback();
}

callIt(counter.inc.bind(counter));
counter.count // 1

上面代码中,callIt()方法会调用回调函数。这时如果直接把counter.inc传入,调用时counter.inc()内部的this就会指向全局对象。使用bind()方法将counter.inc绑定counter以后,就不会有这个问题,this总是指向counter

还有一种情况比较隐蔽,就是某些数组方法可以接受一个函数当作参数。这些函数内部的this指向,很可能也会出错。

var obj = {
  name: '张三',
  times: [1, 2, 3],
  print: function () {
    this.times.forEach(function (n) {
      console.log(this.name);
    });
  }
};

obj.print()
// 没有任何输出

上面代码中,obj.print内部this.times的this是指向obj的,这个没有问题。但是,forEach()方法的回调函数内部的this.name却是指向全局对象,导致没有办法取到值。稍微改动一下,就可以看得更清楚。

obj.print = function () {
  this.times.forEach(function (n) {
    console.log(this === window);
  });
};

obj.print()
// true
// true
// true

解决这个问题,也是通过bind()方法绑定this

obj.print = function () {
  this.times.forEach(function (n) {
    console.log(this.name);
  }.bind(this));
};

obj.print()
// 张三
// 张三
// 张三

(3)结合call()方法使用
利用bind()方法,可以改写一些 JavaScript 原生方法的使用形式,以数组的slice()方法为例。

[1, 2, 3].slice(0, 1) // [1]
// 等同于
Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]

上面的代码中,数组的slice方法从[1, 2, 3]里面,按照指定的开始位置和结束位置,切分出另一个数组。这样做的本质是在[1, 2, 3]上面调用Array.prototype.slice()方法,因此可以用call方法表达这个过程,得到同样的结果。

call()方法实质上是调用Function.prototype.call()方法,因此上面的表达式可以用bind()方法改写。

var slice = Function.prototype.call.bind(Array.prototype.slice);
slice([1, 2, 3], 0, 1) // [1]

上面代码的含义就是,将Array.prototype.slice变成Function.prototype.call方法所在的对象,调用时就变成了Array.prototype.slice.call。类似的写法还可以用于其他数组方法。

var push = Function.prototype.call.bind(Array.prototype.push);
var pop = Function.prototype.call.bind(Array.prototype.pop);

var a = [1 ,2 ,3];
push(a, 4)
a // [1, 2, 3, 4]

pop(a)
a // [1, 2, 3]

如果再进一步,将Function.prototype.call方法绑定到Function.prototype.bind对象,就意味着bind的调用形式也可以被改写。

function f() {
  console.log(this.v);
}

var o = { v: 123 };
var bind = Function.prototype.call.bind(Function.prototype.bind);
bind(f, o)() // 123

上面代码的含义就是,将Function.prototype.bind方法绑定在Function.prototype.call上面,所以bind方法就可以直接使用,不需要在函数实例上使用。

对象的继承

原型对象概述

构造函数的缺点

JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法,可以定义在构造函数内部。

同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。

function Cat(name, color) {
  this.name = name;
  this.color = color;
  this.miao = function () {
    console.log('喵喵');
  };
}

var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');

cat1.miao === cat2.miao// false
//这两个方法的内容地址不一样,都是各自独立的内存地址。

面代码中,cat1和cat2是同一个构造函数的两个实例,它们都具有miao方法。由于miao方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个miao方法。这既没有必要,又浪费系统资源,因为所有miao方法都是同样的行为,完全应该共享。

prototype 属性的作用

JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。

JavaScript 规定,每个函数都有一个prototype属性,指向一个对象,这个对象就是原型对象。

function Animal(name) {
  this.name = name;
}
Animal.prototype.color = 'white';

var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');

cat1.color // 'white'
cat2.color // 'white'

原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。

Animal.prototype.color = 'yellow';

cat1.color // "yellow"
cat2.color // "yellow"

上面代码中,原型对象的color属性的值变为yellow,两个实例对象的color属性立刻跟着变了。这是因为实例对象其实没有color属性,都是读取原型对象的color属性。

当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。
如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。

原型链

JavaScript 规定,所有对象都有自己的原型对象(prototype)。由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”。
如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOftoString方法的原因,因为这是从Object.prototype继承的。
Object.prototype的原型是nullnull没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null

读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。

注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。

constructor 属性

prototype对象有一个constructor属性,默认指向prototype所在对象的构造函数。

function P() {}
P.prototype.constructor === P // true

由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。
constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。
constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性,防止引用的时候出错。

function Person(name) {
  this.name = name;
}

Person.prototype.constructor === Person // true

Person.prototype = {
  method: function () {}
};

Person.prototype.constructor === Person // false
Person.prototype.constructor === Object // true

上面代码中,构造函数Person的原型对象改掉了,但是没有修改constructor属性,导致这个属性不再指向Person。由于Person的新原型是一个普通对象,而普通对象的constructor属性指向Object构造函数,导致Person.prototype.constructor变成了Object

所以,修改原型对象时,一般要同时修改constructor属性的指向。

// 坏的写法
C.prototype = {
  method1: function (...) { ... },
  // ...
};

// 好的写法
C.prototype = {
  constructor: C,
  method1: function (...) { ... },
  // ...
};

// 更好的写法
C.prototype.method1 = function (...) { ... };

如果不能确定constructor属性是什么函数,还有一个办法:通过name属性,从实例得到构造函数的名称。

function Foo() {}
var f = new Foo();
f.constructor.name // "Foo"

instanceof运算符

instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例

var v = new Vehicle();
v instanceof Vehicle // true

上面代码中,对象v是构造函数Vehicle的实例,所以返回true。
instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构造函数的原型对象(prototype),是否在左边对象的原型链上。因此,下面两种写法是等价的。

v instanceof Vehicle
// 等同于
Vehicle.prototype.isPrototypeOf(v)

上面代码中,Vehicle是对象v的构造函数,它的原型对象是Vehicle.prototypeisPrototypeOf()方法是 JavaScript 提供的原生方法,用于检查某个对象是否为另一个对象的原型。

由于instanceof检查整个原型链,因此同一个实例对象,可能会对多个构造函数都返回true。

var d = new Date();
d instanceof Date // true
d instanceof Object // true

由于任意对象(除了null)都是Object的实例,所以instanceof运算符可以判断一个值是否为非null的对象。

var obj = { foo: 123 };
obj instanceof Object // true

null instanceof Object // false

instanceof的原理是检查右边构造函数的prototype属性,是否在左边对象的原型链上有一种特殊情况,就是左边对象的原型链上,只有null对象。这时,instanceof判断会失真。

var obj = Object.create(null);
typeof obj // "object"
obj instanceof Object // false

上面代码中,Object.create(null)返回一个新对象obj,它的原型是null。右边的构造函数Objectprototype属性,不在左边的原型链上,因此instanceof就认为obj不是Object的实例。这是唯一的instanceof运算符判断会失真的情况(一个对象的原型是null)。

对于undefinednullinstanceof运算符总是返回false

利用instanceof运算符,还可以巧妙地解决,调用构造函数时,忘了加new命令的问题。

function Fubar (foo, bar) {
  if (this instanceof Fubar) {
    this._foo = foo;
    this._bar = bar;
  } else {
    return new Fubar(foo, bar);
  }
}

构造函数的继承

让一个构造函数继承另一个构造函数,分两步实现:
第一步是在子类的构造函数中,调用父类的构造函数。

function Sub(value) {
  Super.call(this); //继承父类实例对象上面的属性和方法
  this.prop = value;
}

上面代码中,Sub是子类的构造函数,this是子类的实例。在实例上调用父类的构造函数Super,就会让子类实例具有父类实例的属性。

第二步,是让子类的原型指向父类的原型,这样子类就可以继承父类原型。

Sub.prototype = Object.create(Super.prototype);
//获取的父类的原型对象,constructor指向父类,需要重置。
Sub.prototype.constructor = Sub; 
Sub.prototype.method = '...';

上面代码中,Sub.prototype是子类的原型,要将它赋值为Object.create(Super.prototype),而不是直接等于Super.prototype。否则后面两行对Sub.prototype的操作,会连父类的原型Super.prototype一起修改掉。

另外一种写法是Sub.prototype等于一个父类实例。

Sub.prototype = new Super();

上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。

function Shape() {
  this.x = 0;
  this.y = 0;
}

Shape.prototype.move = function (x, y) {
  this.x += x;
  this.y += y;
  console.info('Shape moved.');
};
// 第一步,子类继承父类的实例
function Rectangle() {
  Shape.call(this); // 调用父类构造函数
}
// 另一种写法
function Rectangle() {
  this.base = Shape;
  this.base();
}

// 第二步,子类继承父类的原型
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

采用这样的写法以后,instanceof运算符会对子类和父类的构造函数,都返回true
上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法。

ClassB.prototype.print = function() {
  ClassA.prototype.print.call(this);
  // some code
}

多重继承

JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。

function M1() {
  this.hello = 'hello';
}

function M2() {
  this.world = 'world';
}

function S() {
 //this.hello和this.world都是实例对象的属性,不是原型对象的属性
  //通过显示调用M1和M2的构造函数来绑定到当前this上面。
  M1.call(this);
  M2.call(this);
}

// 继承 M1,根据实例创建一个对象。
S.prototype = Object.create(M1.prototype);
//S.prototype = M1.prototype 这种写法是引用,当我们修改S的原型对象时,M1也会受到影响。
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype);

// 指定构造函数
S.prototype.constructor = S;

var s = new S();
s.hello // 'hello'
s.world // 'world'

上面代码中,子类S同时继承了父类M1和M2。这种模式又称为 Mixin(混入)。

构造函数内的方法与原型上的方法的对比

  • 定义在构造函数内部的方法,会在它的每一个实例上,都克隆这个方法。
  • 定义在prototype原型上的方法,会让它的所有的实例都共享这个方法,但是不会再每个实例内部,重新定义这个方法。因为方法是共享的,所以修改方法会影响到其他的实例对象。
  • 如果我们的应用需要创建很多的对象,并且这些对象还有许多的方法,为了节省内存,我们建议把这些方法都定义在构造函数的prototype属性上。
    在这里插入图片描述

ES6中的class

class的定义

JavaScript 语言中,生成实例对象的传统方法是通过构造函数。

//通过构造函数定义实例对象不可共享(内存层面)的属性和方法
function Point(x,y) {
    this.x = x 
    this.y = y 
}
//通过prototype定义实例对象可以共享(内存层面)的方法
Point.prototype.toString  = function(){
    return '(' + this.x + ',' + this.y + ')'
}

ES6中引入了class,可以把class看成是一个语法糖。

class Point {
    constructor(x,y) {
   		//定义实例属性
        this.x = x 
        this.y = y 
    }
    //实例方法
     toString() {
        return "(" + this.x + "," + this.y + ")"
    }
}

ES 6中的类完全可以看做构造函数的另一种写法。

class Point {
}
typeof Point // "function"
Point === Point.prototype.constructor // true

上面代码表明,类的数据类型就是函数,类本身就指向构造函数。

构造函数的prototype属性,在 ES6 的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。

class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

// 等同于

Point.prototype = {
  constructor() {},
  toString() {},
  toValue() {},
};

因此,在类的实例上面调用方法,其实就是调用原型上的方法。

由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign()方法可以很方便地一次向类添加多个方法。

class Point {
  constructor(){
    // ...
  }
}

Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
});

取值函数和存值函数

与 ES5 一样,在“类”的内部可以使用get和set关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

class CustomHTMLElement {
  constructor(element) {
    this.element = element;
  }

  get html() {
    return this.element.innerHTML;
  }

  set html(value) {
    this.element.innerHTML = value;
  }
}

静态方法

如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

注意,如果静态方法包含this关键字,这个this指的是类,而不是实例。

实例属性的新写法

实例属性除了可以定义在construct()方法里面的this上面,还可以定义在类的顶部。

class foo {
  bar = 'hello';
  baz = 'world';

  constructor() {
    // ...
    this.num = 10
  }
}

类的继承

Class 可以通过extends关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。

class Point {
}

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y); // 调用父类的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + ' ' + super.toString(); // 调用父类的toString()
  }
}

子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。

Object.getPrototypeOf()

Object.getPrototypeOf方法可以用来从子类上获取父类。

Object.getPrototypeOf(ColorPoint) === Point
// true

因此,可以使用这个方法判断,一个类是否继承了另一个类。

super 关键字

super这个关键字,既可以当作函数使用,也可以当作对象使用。
第一种情况,super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。

class A {}

class B extends A {
  constructor() {
    super();
  }
}

第二种情况,super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

class A {
  p() {
    return 2;
  }
}

class B extends A {
  constructor() {
    super();
    console.log(super.p()); // 2
  }
}

let b = new B();

上面代码中,子类B当中的super.p(),就是将super当作一个对象使用。这时,super在普通方法之中,指向A.prototype,所以super.p()就相当于A.prototype.p()

这里需要注意,由于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();

上面代码中,属性x是定义在A.prototype上面的,所以super.x可以取到它的值。

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作为对象,用在静态方法之中,这时super将指向父类,而不是父类的原型对象。

class Parent {
  static myMethod(msg) {
    console.log('static', msg);
  }

  myMethod(msg) {
    console.log('instance', msg);
  }
}

class Child extends Parent {
  static myMethod(msg) {
    super.myMethod(msg);
  }

  myMethod(msg) {
    super.myMethod(msg);
  }
}

Child.myMethod(1); // static 1

var child = new Child();
child.myMethod(2); // instance 2

上面代码中,super在静态方法之中指向父类,在普通方法之中指向父类的原型对象。

另外,在子类的静态方法中通过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的实例。

Module模块

Module 的加载实现

浏览器加载

HTML 网页中,浏览器通过<script>标签加载 JavaScript 脚本。
默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。

如果脚本体积很大,下载和执行的时间就会很长,因此造成浏览器堵塞,用户会感觉到浏览器“卡死”了,没有任何响应。这显然是很不好的体验,所以浏览器允许脚本异步加载,下面就是两种异步加载的语法。

<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

上面代码中,<script>标签打开defer或async属性,脚本就会异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令。

defer与async的区别是:defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

加载规则

浏览器加载 ES6 模块,也使用<script>标签,但是要加入type="module"属性。

<script type="module" src="./foo.js"></script>

浏览器对于带有type="module"<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>

如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行。
<script>标签的async属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。

<script type="module" src="./foo.js" async></script>

一旦使用了async属性,<script type="module">就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块。

ES6 模块与Common.js模块的差异

它们有三个重大差异。

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
  • CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。

第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

错误处理机制

在 JavaScript 中可以使用 throw 抛出异常,使用 try … catch 捕获错误。

throw

throw 用于抛出一个异常,这种异常通常是程序出现了不符合预期的错误。

alert('出错前');

throw '发生了一个错误!';

alert('出错后');

当出现 throw 时,程序将会中断执行。

如果 throw 发生在 try … catch 中,则会执行 catch 中的代码块,同时将异常信息带给 catch。

try … catch

try…catch 语句标记要尝试的语句块,并指定一个出现异常时抛出的响应。
try … catch 可以用于捕获异常,当出现 throw 时,会结束 try 内的代码执行,直接进入到 catch,执行 catch 内的代码块。

try {
  alert('出错前');

  throw '发生了一个错误!';

  alert('出错后');
} catch (e) { // e 是错误信息,名字随意,符合变量命名规范就行
  alert('出错了!错误是:' + e);
}

需要注意的是,以前 catch 后面的错误参数是必须接收的,否则会报错。
但 ES2019 中有一个提案,可以选择性的提供给 catch 参数,所以最新的 chrome 不传递错误参数也是可以的。

try {
  alert('出错前');

  throw '发生了一个错误!';

  alert('出错后');
} catch {
  alert('出错了!');
}

在 try 后面还可以跟 finally 部分,即无论 try 中的代码块是否抛出错误,都会执行 finally 代码块。

try {
  alert('开始请求数据,loading 显示');

  throw '没有网络';

  alert('请求结果是:..编不下去了,反正到不了这里');
} catch (e) {
  alert('出现错误:' + e);
} finally {
  alert('关闭 loading');
}

可以写条件的 catch 语句

try {
  throw 'error';
} catch (e if e instanceof TypeError) {
  console.log('TypeError');
} catch (e if e instanceof ReferenceError) {
  console.log('ReferenceError');
} catch (e) {
  console.log(e);
}

但目前主流浏览器基本都无法正常运行这种语法的 try … catch 语句,所以不要使用。
如果有类似的需求,可以使用 if 来代替。

try {
  throw 'error';
} catch (e) {
  if (e instanceof TypeError) {
    console.log('TypeError');
  } else if (e instanceof ReferenceError) {
    console.log('ReferenceError');
  } else {
    console.log(e);
  }
}

Error 对象

通过 Error 的构造器可以创建一个错误对象。当运行时错误产生时,Error的实例对象会被抛出。Error 对象也可用于用户自定义的异常的基础对象。

try {
  throw new Error('主动抛出一个错误');
} catch (e) {
  console.error(e);
}

和大部分内置对象一样,Error 实例也可以不使用 new 关键字创建。

try {
  throw Error('主动抛出一个错误');
} catch (e) {
  console.error(e);
}

其他异常对象

  • URIError 表示以一种错误的方式使用全局URI处理函数而产生的错误;
  • TypeError 值的类型非预期类型时发生的错误;
  • SyntaxError 尝试解析语法上不合法的代码的错误;
  • ReferenceError 当一个不存在的变量被引用时发生的错误;
  • RangeError 当一个值不在其所允许的范围或者集合中抛出的异常;
  • InternalError 表示出现在 JavaScript 引擎内部的错误。非标准对象,不建议使用;
  • EvalError 本对象代表了一个关于 eval 函数的错误.此异常不再会被 JavaScript 抛出,但是EvalError对象仍然保持兼容性。
    这些异常对象的使用和 Error 几乎一致。

JavaScript内存管理

引用计数

引用计数的基本原理是,保存每个对象的引用计数,当引用发生增减时对计数进行更新。引用计数的增减,一般发生在变量赋值、对象内容更新、函数结束(局部变量不再被引用)等时间节点。当一个对象的引用计数变为 0 时,则说明它将来不会再被引用,因此可以释放相应的内存空间。

引用计数最大的缺点,就是无法释放循环引用的对象。以下 marry 函数执行完毕后,bob 和 alice 两个对象由于互相引用,因此引用计数都为 1,即使后续不会再被使用到,也会持续占用内存,不被释放。当我们不使用它们的时候,需要手动切断引用才能回收内存。

function marry(){
  let bob = {};
  let alice = {};
  bob.wife = alice; 		// bob 引用 alice
  alice.husband = bob;	// alice 引用 bob

  return "They are married!";
}

marry();

标记清除

标记清除算法是最早开发出来的GC算法。Node.js的global对象和JavaScript的window对象,被称作根,标记清除首先从根开始,将可能被引用的对象用递归的方式进行标记,标记阶段完成时,被标记的对象就被视为存活对象。然后将没有被标记到的对象作为垃圾进行回收。

零引用的对象肯定是需要被回收的,反过来,需要被回收的对象却不一定是零引用的(循环引用)。因此标记清除可以有效解决循环引用的问题。在上面的循环引用示例中,marry 函数调用返回之后,两个对象从全局对象出发无法获取。因此,它们将会被垃圾回收器回收。

从 2012 年起,所有现代浏览器都使用了标记清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记清除算法的改进(如 V8 引擎的垃圾回收机制)。

垃圾回收的基本算法称为"标记-清除",定期执行以下垃圾回收步骤:

  • (1)垃圾回收器获取根并标记(记住)它们。
  • (2)然后它访问并标记所有来自它们的引用
  • (3)然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
  • (4)以此类推,直到有未访问的引用(可以从根访问)为止
  • (5)除标记的对象外,所有对象都被删除。
    在这里插入图片描述
    我们可以清楚地看到右边有一个“不可到达的块”。现在让我们看看“标记并清除”垃圾回收器如何处理它。
    第一步标记根
    在这里插入图片描述
    然后标记他们的引用
    在这里插入图片描述
    以及子孙代的引用:
    在这里插入图片描述
    现在进程中不能访问的对象被认为是不可访问的,将被删除:
    在这里插入图片描述
    这就是垃圾收集的工作原理。JavaScript引擎应用了许多优化,使其运行得更快,并且不影响执行。

自动 GC 的问题

尽管自动 GC 很方便, 但是我们不知道GC 什么时候会进行. 这意味着如果我们在使用过程中使用了大量的内存, 而 GC 没有运行的情况下, 或者 GC 无法回收这些内存的情况下, 程序就有可能假死, 这个就需要我们在程序中手动做一些操作来触发内存回收.

什么是内存泄露?

本质上讲, 内存泄露就是不再被需要的内存, 由于某种原因, 无法被释放.

常见的内存泄露案例

全局变量

function foo(arg) {
    bar = "some text";
}

在 JS 中处理未被声明的变量, 上述范例中的 bar 时, 会把 bar , 定义到全局对象中, 在浏览器中就是 window 上. 在页面中的全局变量, 只有当页面被关闭后才会被销毁. 所以这种写法就会造成内存泄露, 当然在这个例子中泄露的只是一个简单的字符串, 但是在实际的代码中, 往往情况会更加糟糕.

另外一种意外创建全局变量的情况.

function foo() {
    this.var1 = "potential accidental global";
}
// Foo 被调用时, this 指向全局变量(window)
foo();

在这种情况下调用 foo, this被指向了全局变量 window, 意外的创建了全局变量.
我们谈到了一些意外情况下定义的全局变量, 代码中也有一些我们明确定义的全局变量. 如果使用这些全局变量用来暂存大量的数据, 记得在使用后, 对其重新赋值为 null.

未销毁的定时器和回调函数

在很多库中, 如果使用了观察着模式, 都会提供回调方法, 来调用一些回调函数. 要记得回收这些回调函数. 举一个 setInterval的例子.

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // 每 5 秒调用一次

如果后续 renderer 元素被移除, 整个定时器实际上没有任何作用. 但如果你没有回收定时器, 整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收. 在这个案例中的 serverData 也无法被回收.

闭包

在 JS 开发中, 我们会经常用到闭包, 一个内部函数, 有权访问包含其的外部函数中的变量. 下面这种情况下, 闭包也会造成内存泄露.

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 对于 'originalThing'的引用
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);

这段代码, 每次调用 replaceThing 时, theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象. 同时 unused 是一个引用了 originalThing 的闭包.
这个范例的关键在于, 闭包之间是共享作用域的, 尽管 unused 可能一直没有被调用, 但是someMethod 可能会被调用, 就会导致内存无法对其进行回收. 当这段代码被反复执行时, 内存会持续增长.

DOM 引用

很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中.

var elements = {
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    document.body.removeChild(document.getElementById('image'));
    // 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收. 
}

上述案例中, 即使我们对于 image 元素进行了移除, 但是仍然有对 image 元素的引用, 依然无法对齐进行内存回收.

另外需要注意的一个点是, 对于一个 Dom 树的叶子节点的引用. 举个例子: 如果我们引用了一个表格中的 td 元素, 一旦在 Dom 中删除了整个表格, 我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素. 但是事实上, 这个 td 元素是整个表格的一个子元素, 并保留对于其父元素的引用. 这就会导致对于整个表格, 都无法进行内存回收. 所以我们要小心处理对于 Dom 元素的引用.

精读

ES6中引入 WeakSet 和 WeakMap 两个新的概念, 来解决引用造成的内存回收问题. WeakSet 和 WeakMap 对于值的引用可以忽略不计, 他们对于值的引用是弱引用,内存回收机制, 不会考虑这种引用. 当其他引用被消除后, 引用就会从内存中被释放.、WeakSet 和 WeakMap 是基于弱引用的数据结构,ES2021 更进一步,提供了 WeakRef 对象,用于直接创建对象的弱引用。

let target = {};
let wr = new WeakRef(target);

let obj = wr.deref();
if (obj) { // target 未被垃圾回收机制清除
  // ...
}
  • 5
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值