JavaScript 教程(二)

面向对象编程

实例对象与 new 命令

JavaScript 语言具有很强的面向对象编程能力,这里介绍 JavaScript 面向对象编程的基础知识

对象是什么

面向对象编程(Object Oriented Programming,缩写为 OOP)是目前主流的编程范式。它将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。对象可以复用,通过继承机制还可以定制。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,容易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。那么,“对象”(object)到底是什么?我们从两个层次来理解

对象是单个实物的抽象

一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程

对象是一个容器,封装了属性(property)和方法(method)

属性是对象的状态,方法是对象的行为(完成某种任务)。比如,我们可以把动物抽象为animal对象,使用“属性”记录具体是那一种动物,使用“方法”表示动物的某种行为(奔跑、捕猎、休息等等)

构造函数

面向对象编程的第一步,就是要生成对象。前面说过,对象是单个实物的抽象。通常需要一个模板,表示某一类实物的共同特征,然后对象根据这个模板生成。典型的面向对象编程语言(比如 C++ 和 Java),都有“类”(class)这个概念。所谓“类”就是对象的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于构造函数(constructor)和原型链(prototype)。JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来生成实例对象的函数。它就是对象的模板,描述实例对象的基本结构。一个构造函数,可以生成多个实例对象,这些实例对象都有相同的结构。构造函数就是一个普通的函数,但是有自己的特征和用法

var Vehicle = function () {
  this.price = 1000;
};

上面代码中,Vehicle就是构造函数。为了与普通函数区别,构造函数名字的第一个字母通常大写

构造函数的特点有两个。

1.函数体内部使用了this关键字,代表了所要生成的对象实例

2.生成对象的时候,必须使用new命令

new 命令

基本用法

new命令的作用,就是执行构造函数,返回一个实例对象

var Vehicle = function () {
  this.price = 1000;
};
var v = new Vehicle();
v.price // 1000

上面代码通过new命令,让构造函数Vehicle生成一个实例对象,保存在变量v中。这个新生成的实例对象,从构造函数Vehicle得到了price属性。new命令执行时,构造函数内部的this,就代表了新生成的实例对象,this.price表示实例对象有一个price属性,值是1000。使用new命令时,根据需要构造函数也可以接受参数

var Vehicle = function (p) {
  this.price = p;
};
var v = new Vehicle(500);

new命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。下面两行代码是等价的,但是为了表示这里是函数调用,推荐使用括号

var v = new Vehicle(); // 推荐的写法
var v = new Vehicle; // 不推荐的写法

一个很自然的问题是,如果忘了使用new命令,直接调用构造函数会发生什么事?这种情况下,构造函数就变成了普通函数,并不会生成实例对象。而且由于后面会说到的原因,this这时代表全局对象,将造成一些意想不到的结果

var Vehicle = function (){
  this.price = 1000;
};
var v = Vehicle();
v // undefined
price // 1000

上面代码中,调用Vehicle构造函数时,忘了加上new命令。结果,变量v变成了undefined,而price属性变成了全局变量。因此,应该非常小心,避免不使用new命令、直接调用构造函数。为了保证构造函数必须与new命令一起使用,一个解决办法是,构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错

function Fubar(foo, bar){
  'use strict';
  this._foo = foo;
  this._bar = bar;
}
Fubar() // TypeError: Cannot set property '_foo' of undefined

上面代码的Fubar为构造函数,use strict命令保证了该函数在严格模式下运行。由于严格模式中,函数内部的this不能指向全局对象,默认等于undefined,导致不加new调用会报错(JavaScript 不允许对undefined添加属性)。另一个解决办法,构造函数内部判断是否使用new命令,如果发现没有使用,则直接返回一个实例对象

function Fubar(foo, bar) {
  if (!(this instanceof Fubar)) {
    return new Fubar(foo, bar);
  }
  this._foo = foo;
  this._bar = bar;
}
Fubar(1, 2)._foo // 1
(new Fubar(1, 2))._foo // 1

上面代码中的构造函数,不管加不加new命令,都会得到同样的结果

new 命令的原理

使用new命令时,它后面的函数依次执行下面的步骤

1.创建一个空对象,作为将要返回的对象实例

2.将这个空对象的原型,指向构造函数的prototype属性

3.将这个空对象赋值给函数内部的this关键字

4.开始执行构造函数内部的代码

也就是说,构造函数内部,this指的是一个新生成的空对象,所有针对this的操作,都会发生在这个空对象上。构造函数之所以叫“构造函数”,就是说这个函数的目的,就是操作一个空对象(即this对象),将其“构造”为需要的样子。如果构造函数内部有return语句,而且return后面跟着一个对象,new命令会返回return语句指定的对象;否则,就会不管return语句,返回this对象

var Vehicle = function () {
  this.price = 1000;
  return 1000;
};
(new Vehicle()) === 1000 // false

上面代码中,构造函数Vehicle的return语句返回一个数值。这时,new命令就会忽略这个return语句,返回“构造”后的this对象。但是,如果return语句返回的是一个跟this无关的新对象,new命令会返回这个新对象,而不是this对象。这一点需要特别引起注意

var Vehicle = function (){
  this.price = 1000;
  return { price: 2000 };
};
(new Vehicle()).price // 2000

上面代码中,构造函数Vehicle的return语句,返回的是一个新对象。new命令会返回这个对象,而不是this对象。另一方面,如果对普通函数(内部没有this关键字的函数)使用new命令,则会返回一个空对象

function getMessage() {
  return 'this is a message';
}
var msg = new getMessage();
msg // {}
typeof msg // "object"

上面代码中,getMessage是一个普通函数,返回一个字符串。对它使用new命令,会得到一个空对象。这是因为new命令总是返回一个对象,要么是实例对象,要么是return语句指定的对象。本例中,return语句返回的是字符串,所以new命令就忽略了该语句。new命令简化的内部流程,可以用下面的代码表示

function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ params) {  
  var args = [].slice.call(arguments); // 将 arguments 对象转为数组  
  var constructor = args.shift(); // 取出构造函数  
  var context = Object.create(constructor.prototype); // 创建一个空对象,继承构造函数的 prototype 属性  
  var result = constructor.apply(context, args); // 执行构造函数  
  return (typeof result === 'object' && result != null) ? result : context; // 如果返回结果是对象,就直接返回,否则返回 context 对象
}
var actor = _new(Person, '张三', 28); // 实例
new.target

函数内部可以使用new.target属性。如果当前函数是new命令调用,new.target指向当前函数,否则为undefined

function f() {
  console.log(new.target === f);
}
f() // false
new f() // true

使用这个属性,可以判断函数调用的时候,是否使用new命令

function f() {
  if (!new.target) {
    throw new Error('请使用 new 命令调用!');
  }
  // ...
}
f() // Uncaught Error: 请使用 new 命令调用!

Object.create() 创建实例对象

构造函数作为模板,可以生成实例对象。但是,有时拿不到构造函数,只能拿到一个现有的对象。我们希望以这个现有的对象作为模板,生成新的实例对象,这时就可以使用Object.create()方法

var person1 = {
  name: '张三',
  age: 38,
  greeting: function() {
    console.log('Hi! I\'m ' + this.name + '.');
  }
};
var person2 = Object.create(person1);
person2.name // 张三
person2.greeting() // Hi! I'm 张三.

上面代码中,对象person1是person2的模板,后者继承了前者的属性和方法

this 关键字

涵义

this关键字是一个非常重要的语法点。毫不夸张地说,不理解它的含义大部分开发任务都无法完成。this可以用在构造函数之中,表示实例对象;除此之外,this还可以用在别的场合。但不管是什么场合,this都有一个共同点:它总是返回一个对象;简单说,this就是属性或方法“当前”所在的对象

var person = {
  name: '张三',
  describe: function () { return '姓名:'+ this.name; }
};
person.describe() // "姓名:张三"

上面代码中,this.name表示name属性所在的那个对象。由于this.name是在describe方法中调用,而describe方法所在的当前对象是person,因此this指向person,this.name就是person.name。由于对象的属性可以赋给另一个对象,所以属性所在的当前对象是可变的,即this的指向是可变的

var A = {
  name: '张三',
  describe: function () { return '姓名:'+ this.name; }
};
var B = { name: '李四' };
B.describe = A.describe;
B.describe() // "姓名:李四"

上面代码中,A.describe属性被赋给B,于是B.describe就表示describe方法所在的当前对象是B,所以this.name就指向B.name。稍稍重构这个例子,this的动态指向就能看得更清楚

function f() { return '姓名:'+ this.name; }
var A = { name: '张三', describe: f };
var B = { name: '李四', describe: f };
A.describe() // "姓名:张三"
B.describe() // "姓名:李四"

只要函数被赋给另一个变量,this的指向就会变

var A = {
  name: '张三',
  describe: function () { return '姓名:'+ this.name; }
};
var name = '李四';
var f = A.describe;
f() // "姓名:李四"

上面代码中,A.describe被赋值给变量f,内部的this就会指向f运行时所在的对象(本例是顶层对象)。再看一个网页编程的例子

<input type="text" name="age" size=3 onChange="validate(this, 18, 99);">
<script>
function validate(obj, lowval, hival){
  if ((obj.value < lowval) || (obj.value > hival))
    console.log('Invalid Value!');
}
</script>

上面代码是一个文本输入框,每当用户输入一个值,就会调用onChange回调函数,验证这个值是否在指定范围。浏览器会向回调函数传入当前对象,因此this就代表传入当前对象(即文本框),然后就可以从this.value上面读到用户的输入值

总结一下,JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是函数运行时所在的对象(环境)。这本来并不会让用户糊涂,但是 JavaScript 支持运行环境动态切换,也就是说,this的指向是动态的,没有办法事先确定到底指向哪个对象,这才是最让初学者感到困惑的地方

实质

JavaScript 语言之所以有 this 的设计,跟内存里面的数据结构有关系

var obj = { foo:  5 };

上面的代码将一个对象赋值给变量obj。JavaScript 引擎会先在内存里面,生成一个对象{ foo: 5 },然后把这个对象的内存地址赋值给变量obj。也就是说,变量obj是一个地址(reference)。后面如果要读取obj.foo,引擎先从obj拿到内存地址,然后再从该地址读出原始的对象,返回它的foo属性。原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的foo属性,实际上是以下面的形式保存的

{
  foo: {
    [[value]]: 5
    [[writable]]: true
    [[enumerable]]: true
    [[configurable]]: true
  }
}

注意,foo属性的值保存在属性描述对象的value属性里面。这样的结构是很清晰的,问题在于属性的值可能是一个函数;这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给foo属性的value属性

var obj = { foo: function () {} };

由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行

var f = function () {};
var obj = { f: f };
f() // 单独执行
obj.f() // obj 环境执行

JavaScript 允许在函数体内部,引用当前环境的其他变量

var f = function () {
  console.log(x);
};

上面代码中,函数体里面使用了变量x,该变量由运行环境提供。现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。所以,this就出现了,它的设计目的就是在函数体内部,指代函数当前的运行环境

var f = function () { console.log(this.x);}
var x = 1;
var obj = { f: f,  x: 2};
// 单独执行
f() // 1
// obj 环境执行
obj.f() // 2

上面代码中,函数f在全局环境执行,this.x指向全局环境的x;在obj环境执行,this.x指向obj.x

使用场合

this主要有以下几个使用场合

全局环境

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

this === window // true
function f() {
  console.log(this === window);
}
f() // true

上面代码说明,不管是不是在函数内部,只要是在全局环境下运行,this就是指顶层对象window

构造函数

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

var Obj = function (p) {
  this.p = p;
};
var o = new Obj('Hello World!');
o.p // "Hello World!"

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

对象的方法

如果对象的方法里面包含this,this的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。但是,这条规则很不容易把握。请看下面的代码

var obj ={
  foo: function () { console.log(this); }
};
obj.foo() // obj

上面代码中,obj.foo方法执行时,它内部的this指向obj。但是,下面这几种用法,都会改变this的指向

// 情况一
(obj.foo = obj.foo)() // window
// 情况二
(false || obj.foo)() // window
// 情况三
(1, obj.foo)() // window

上面代码中,obj.foo就是一个值。这个值真正调用的时候,运行环境已经不是obj了,而是全局环境,所以this不再指向obj。可以这样理解,JavaScript 引擎内部,obj和obj.foo储存在两个内存地址,称为地址一和地址二。obj.foo()这样调用时,是从地址一调用地址二,因此地址二的运行环境是地址一,this指向obj。但是,上面三种情况,都是直接取出地址二进行调用,这样的话,运行环境就是全局环境,因此this指向全局环境。上面三种情况等同于下面的代码

// 情况一
(obj.foo = function () { console.log(this);})()
// 等同于
(function () { console.log(this);})()
// 情况二
(false || function () { console.log(this);})()
// 情况三
(1, function () { console.log(this);})()

如果this所在的方法不在对象的第一层,这时this只是指向当前一层的对象,而不会继承更上面的层

var a = {
  p: 'Hello',
  b: {
    m: function() { console.log(this.p); }
  }
};
a.b.m() // undefined

上面代码中,a.b.m方法在a对象的第二层,该方法内部的this不是指向a,而是指向a.b,因为实际执行的是下面的代码

var b = {
  m: function() { console.log(this.p); }
};
var a = { p: 'Hello',  b: b };
(a.b).m() // 等同于 b.m()

如果要达到预期效果,只有写成下面这样

var a = {
  b: {
    m: function() { console.log(this.p); },
    p: 'Hello'
  }
};

如果这时将嵌套对象内部的方法赋值给一个变量,this依然会指向全局对象

var a = {
  b: {
    m: function() { console.log(this.p); },
    p: 'Hello'
  }
};
var hello = a.b.m;
hello() // undefined

上面代码中,m是多层对象内部的一个方法。为求简便,将其赋值给hello变量,结果调用时,this指向了顶层对象。为了避免这个问题,可以只将m所在的对象赋值给hello,这样调用时,this的指向就不会变

var hello = a.b;
hello.m() // Hello

使用注意点

避免多层 this

由于this的指向是不确定的,所以切勿在函数中包含多层的this

var o = {
  f1: function () {
    console.log(this);
    var f2 = function () { console.log(this); }();
  }
}
o.f1()
// Object
// Window

上面代码包含两层this,结果运行后,第一层指向对象o,第二层指向全局对象,因为实际执行的是下面的代码

var temp = function () { console.log(this); };
var o = {
  f1: function () { console.log(this); var f2 = temp(); }
}

一个解决方法是在第二层改用一个指向外层this的变量

var o = {
  f1: function() {
    console.log(this);
    var that = this;
    var f2 = function() { console.log(that); }();
  }
}
o.f1()
// Object
// Object

上面代码定义了变量that,固定指向外层的this,然后在内层使用that,就不会发生this指向的改变。事实上,使用一个变量固定this的值,然后内层函数调用这个变量,是非常常见的做法。JavaScript 提供了严格模式,也可以硬性避免这种问题。严格模式下,如果函数内部的this指向顶层对象,就会报错

var counter = { count: 0 };
counter.inc = function () {
  'use strict';
  this.count++
};
var f = counter.inc;
f() // TypeError: Cannot read property 'count' of undefined

上面代码中,inc方法通过'use strict'声明采用严格模式,这时内部的this一旦指向顶层对象,就会报错

避免数组处理方法中的 this

数组的map和foreach方法,允许提供一个函数作为参数。这个函数内部不应该使用this

var o = {
  v: 'hello',
  p: [ 'a1', 'a2' ],
  f: function f() {
    this.p.forEach(function (item) { console.log(this.v + ' ' + item); });
  }
}
o.f()
// undefined a1
// undefined a2

上面代码中,foreach方法的回调函数中的this,其实是指向window对象,因此取不到o.v的值。原因跟上一段的多层this是一样的,就是内层的this不指向外部,而指向顶层对象。解决这个问题的一种方法,就是前面提到的,使用中间变量固定this

var o = {
  v: 'hello',
  p: [ 'a1', 'a2' ],
  f: function f() {
    var that = this;
    this.p.forEach(function (item) { console.log(that.v+' '+item); });
  }
}
o.f()
// hello a1
// hello a2

另一种方法是将this当作foreach方法的第二个参数,固定它的运行环境

var o = {
  v: 'hello',
  p: [ 'a1', 'a2' ],
  f: function f() {
    this.p.forEach(function (item) { console.log(this.v + ' ' + item); }, this);
  }
}
o.f()
// hello a1
// hello a2
避免回调函数中的 this

回调函数中的this往往会改变指向,最好避免使用

var o = new Object();
o.f = function () { console.log(this === o);}
$('#button').on('click', o.f); // jQuery 的写法

上面代码中,点击按钮以后,控制台会显示false。原因是此时this不再指向o对象,而是指向按钮的 DOM 对象,因为f方法是在按钮对象的环境中被调用的。这种细微的差别,很容易在编程中忽视,导致难以察觉的错误。为了解决这个问题,可以采用下面的一些方法对this进行绑定,也就是使得this固定指向某个对象,减少不确定性

绑定 this 的方法

this的动态切换,固然为 JavaScript 创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把this固定下来,避免出现意想不到的情况。JavaScript 提供了call、apply、bind这三个方法,来切换/固定this的指向

Function.prototype.call()

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

var obj = {};
var f = function () { return this; };
f() === window // true
f.call(obj) === obj // true

上面代码中,全局环境运行函数f时,this指向全局环境(浏览器为window对象);call方法可以改变this的指向,指定this指向对象obj,然后在对象obj的作用域中运行函数f。call方法的参数,应该是一个对象。如果参数为空、null和undefined,则默认传入全局对象

var n = 123;
var obj = { n: 456 };
function a() { console.log(this.n);}
a.call() // 123
a.call(null) // 123
a.call(undefined) // 123
a.call(window) // 123
a.call(obj) // 456

上面代码中,a函数中的this关键字,如果指向全局对象,返回结果为123。如果使用call方法将this关键字指向obj对象,返回结果为456。可以看到,如果call方法没有参数,或者参数为null或undefined,则等同于指向全局对象。如果call方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入call方法

var f = function () {
  return this;
};
f.call(5) // Number {[[PrimitiveValue]]: 5}

上面代码中,call的参数为5,不是对象,会被自动转成包装对象(Number的实例),绑定f内部的this。call方法还可以接受多个参数

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

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

function add(a, b) { return a + b;}
add.call(this, 1, 2) // 3

上面代码中,call方法指定函数add内部的this绑定当前环境(对象),并且参数为1和2,因此函数add运行后得到3。call方法的一个应用是调用对象的原生方法

var obj = {};
// object的hasOwnProperty()方法返回一个布尔值,判断对象是否包含特定的自身(非继承)属性
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所要指向的那个对象,如果设为null或undefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在call方法中必须一个个添加,但是在apply方法中,必须以数组形式添加

function f(x, y){ console.log(x + y);}
f.call(null, 1, 1) // 2
f.apply(null, [1, 1]) // 2

上面代码中,f函数本来接受两个参数,使用apply方法以后,就变成可以接受一个数组作为参数。利用这一点,可以做一些有趣的应用

找出数组最大元素

JavaScript 不提供找出数组最大元素的函数。结合使用apply方法和Math.max方法,就可以返回数组的最大元素

var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15
将数组的空元素变为undefined

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

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

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

var a = ['a', , 'b'];
function print(i) { console.log(i);}
a.forEach(print)
// a
// b
Array.apply(null, a).forEach(print)
// a
// undefined
// b
转换类似数组的对象

另外,利用数组对象的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属性,以及相对应的数字键

绑定回调函数的对象

前面的按钮点击事件的例子,可以改写如下:

var o = new Object();
o.f = function () { console.log(this === o);}
var f = function (){
  o.f.apply(o); // 或者 o.f.call(o);
};
$('#button').on('click', f); // jQuery 的写法

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

Function.prototype.bind()

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

var d = new Date();
d.getTime() // 1553063951536
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() // 1553063951536

上面代码中,bind方法将getTime方法内部的this绑定到d对象,这时就可以安全地将这个方法赋值给其他变量了。bind方法的参数就是所要绑定this的对象,下面是一个更清晰的例子

var counter = {
  count: 0,
  inc: function () { this.count++; }
};
var func = counter.inc.bind(counter);
func();
counter.count // 1

上面代码中,counter.inc方法被赋值给变量func。这时必须用bind方法将inc内部的this,绑定到counter,否则就会出错。this绑定到其他对象也是可以的

var counter = {
  count: 0,
  inc: function () { this.count++; }
};
var obj = { count: 100 };
var func = counter.inc.bind(obj);
func();
obj.count // 101

上面代码中,bind方法将inc方法内部的this,绑定到obj对象。结果调用func函数以后,递增的就是obj内部的count属性。bind还可以接受更多的参数,将这些参数绑定原函数的参数

var add = function (x, y) { return x * this.m + y * this.n;}
var obj = { m: 2, n: 3};
var newAdd = add.bind(obj, 5);
newAdd(6) // 28

上面代码中,bind方法除了绑定this对象,还将add函数的第一个参数x绑定成5,然后返回一个新函数newAdd,这个函数只要再接受一个参数y就能运行了。如果bind方法的第一个参数是null或undefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(浏览器为window)

function add(x, y) { return x + y;}
var plus = add.bind(null, 5);
plus(10) // 15

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

每一次返回一个新函数

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);
结合回调函数使用

回调函数是 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()
// 张三
// 张三
// 张三
结合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方法就可以直接使用,不需要在函数实例上使用

对象的继承

面向对象编程很重要的一个方面,就是对象的继承。A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法。这对于代码的复用是非常有用的。大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现,这里主要介绍 JavaScript 的原型链继承。ES6 引入了 class 语法,基于 class 的继承暂不在这里介绍

原型对象概述

构造函数的缺点

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

function Cat (name, color) {  this.name = name;  this.color = color;}
var cat1 = new Cat('大毛', '白色');
cat1.name // '大毛'
cat1.color // '白色'

上面代码中,Cat函数是一个构造函数,函数内部定义了name属性和color属性,所有实例对象(上例是cat1)都会生成这两个属性,即这两个属性会定义在实例对象上面。通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费

function Cat(name, color) {
  this.name = name;
  this.color = color;
  this.meow = function () { console.log('喵喵'); };
}
var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');
cat1.meow === cat2.meow // false

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

prototype 属性的作用

JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。下面,先看怎么为对象指定原型。JavaScript 规定,每个函数都有一个prototype属性,指向一个对象

function f() {}
typeof f.prototype // "object"

上面代码中,函数f默认具有prototype属性,指向一个对象。对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型

function f() {}
typeof f.prototype // "object"

上面代码中,函数f默认具有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属性,就是实例对象cat1和cat2的原型对象。原型对象上添加一个color属性,结果,实例对象都共享了该属性。原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。也就是说,当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法

Animal.prototype.color = 'yellow';
cat1.color = 'black';
cat1.color // 'black'
cat2.color // 'yellow'
Animal.prototype.color // 'yellow';

总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象

Animal.prototype.walk = function () {
  console.log(this.name + ' is walking');
};

上面代码中,Animal.prototype对象上面定义了一个walk方法,这个方法将可以在所有Animal实例对象上面调用

原型链

JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……。如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOf和toString方法的原因,因为这是从Object.prototype继承的。那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是null。null没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null

Object.getPrototypeOf(Object.prototype) // null

上面代码表示,Object.prototype对象的原型是null,由于null没有任何属性,所以原型链到此为止。Object.getPrototypeOf方法返回参数对象的原型。读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)

注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。举例来说,如果让构造函数的prototype属性指向一个数组,就意味着实例对象可以调用数组方法

var MyArray = function () {};
MyArray.prototype = new Array();
MyArray.prototype.constructor = MyArray;
var mine = new MyArray();
mine.push(1, 2, 3);
mine.length // 3
mine instanceof Array // true

上面代码中,mine是构造函数MyArray的实例对象,由于MyArray.prototype指向一个数组实例,使得mine可以调用数组方法(这些方法定义在数组实例的prototype对象上面)。最后那行instanceof表达式,用来比较一个对象是否为某个构造函数的实例,结果就是证明mine为Array的实例。上面代码还出现了原型对象的constructor属性

constructor 属性

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

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

由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承

function P() {}
var p = new P();
p.constructor === P // true
p.constructor === P.prototype.constructor // true
p.hasOwnProperty('constructor') // false
P.prototype.hasOwnProperty('constructor') // true

上面代码中,p是构造函数P的实例对象,但是p自身没有constructor属性,该属性其实是读取原型链上面的P.prototype.constructor属性。constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的

function F() {};
var f = new F();
f.constructor === F // true
f.constructor === RegExp // false

上面代码中,constructor属性确定了实例对象f的构造函数是F,而不是RegExp。另一方面,有了constructor属性,就可以从一个实例对象新建另一个实例

function Constr() {}
var x = new Constr();
var y = new x.constructor();
y instanceof Constr // true

上面代码中,x是构造函数Constr的实例,可以从x.constructor间接调用构造函数。这使得在实例方法中,调用自身的构造函数成为可能

Constr.prototype.createCopy = function () {
  return new this.constructor();
};

上面代码中,createCopy方法调用构造函数,新建另一个实例。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属性重新指向原来的构造函数,要么只在原型对象上添加方法,这样可以保证instanceof运算符不会失真。如果不能确定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)

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

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

上面代码中,d同时是Date和Object的实例,因此对这两个构造函数都返回true。instanceof的原理是检查右边构造函数的prototype属性,是否在左边对象的原型链上。有一种特殊情况,就是左边对象的原型链上,只有null对象。这时,instanceof判断会失真

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

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

var x = [1, 2, 3];
var y = {};
x instanceof Array // true
y instanceof Object // true

注意,instanceof运算符只能用于对象,不适用原始类型的值。此外,对于undefined和null,instanceOf运算符总是返回false

var s = 'hello';
s instanceof String // false
undefined instanceof Object // false
null instanceof Object // false

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

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

上面代码使用instanceof运算符,在函数体内部判断this关键字是否为构造函数Fubar的实例。如果不是,就表明忘了加new命令

构造函数的继承

让一个构造函数继承另一个构造函数,是非常常见的需求。这可以分成两步实现

第一步:在子类的构造函数中,调用父类的构造函数

function Sub(value) {
  Super.call(this);
  this.prop = value;
}

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

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

Sub.prototype = Object.create(Super.prototype);
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();

上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。举例来说,下面是一个Shape构造函数

function Shape() { this.x = 0;  this.y = 0;}
Shape.prototype.move = function (x, y) {
  this.x += x;
  this.y += y;
  console.info('Shape moved.');
};

我们需要让Rectangle构造函数继承Shape

// 第一步,子类继承父类的实例
function Rectangle() {
  Shape.call(this); // 调用父类构造函数
}
// 另一种写法
function Rectangle() {
  this.base = Shape;
  this.base();
}
// 第二步,子类继承父类的原型
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

采用这样的写法以后,instanceof运算符会对子类和父类的构造函数,都返回true

var rect = new Rectangle();
rect instanceof Rectangle  // true
rect instanceof Shape  // true

上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法

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

上面代码中,子类B的print方法先调用父类A的print方法,再部署自己的代码。这就等于继承了父类A的print方法

多重继承

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

function M1() { this.hello = 'hello';}
function M2() { this.world = 'world';}
function S() { M1.call(this);  M2.call(this);}
S.prototype = Object.create(M1.prototype); // 继承 M1
Object.assign(S.prototype, M2.prototype); // 继承链上加入 M2
S.prototype.constructor = S; // 指定构造函数
var s = new S();
s.hello // 'hello'
s.world // 'world'

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

模块

随着网站逐渐变成“互联网应用程序”,嵌入网页的 JavaScript 代码越来越庞大,越来越复杂。网页越来越像桌面程序,需要一个团队分工协作、进度管理、单元测试等等……开发者必须使用软件工程的方法,管理网页的业务逻辑。JavaScript 模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。但是,JavaScript 不是一种模块化编程语言,ES6 才开始支持“类”和“模块”。下面介绍传统的做法,如何利用对象实现模块的效果

基本的实现方法

模块是实现特定功能的一组属性和方法的封装。简单的做法是把模块写成一个对象,所有的模块成员都放到这个对象里面

var module1 = new Object({
 _count : 0,
 m1 : function (){
  //...
 },
 m2 : function (){
   //...
 }
});

上面的函数m1和m2,都封装在module1对象里。使用的时候,就是调用这个对象的属性

module1.m1();

但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值

module1._count = 5;
封装私有变量:构造函数的写法

我们可以利用构造函数,封装私有变量

function StringBuilder() {
  var buffer = [];
  this.add = function (str) {
     buffer.push(str);
  };
  this.toString = function () {
    return buffer.join('');
  };
}

上面代码中,buffer是模块的私有变量。一旦生成实例对象,外部是无法直接访问buffer的。但是,这种方法将私有变量封装在构造函数中,导致构造函数与实例对象是一体的,总是存在于内存之中,无法在使用完成后清除。这意味着,构造函数有双重作用,既用来塑造实例对象,又用来保存实例对象的数据,违背了构造函数与实例对象在数据上相分离的原则(即实例对象的数据,不应该保存在实例对象以外)。同时,非常耗费内存

function StringBuilder() {
  this._buffer = [];
}
StringBuilder.prototype = {
  constructor: StringBuilder,
  add: function (str) {
    this._buffer.push(str);
  },
  toString: function () {
    return this._buffer.join('');
  }
};

这种方法将私有变量放入实例对象中,好处是看上去更自然,但是它的私有变量可以从外部读写,不是很安全

封装私有变量:立即执行函数的写法

另一种做法是使用“立即执行函数”(Immediately-Invoked Function Expression,IIFE),将相关的属性和方法封装在一个函数作用域里面,可以达到不暴露私有成员的目的

var module1 = (function () {
 var _count = 0;
 var m1 = function () {
   //...
 };
 var m2 = function () {
  //...
 };
 return {m1 : m1, m2 : m2};
})();

使用上面的写法,外部代码无法读取内部的_count变量

console.info(module1._count); //undefined

上面的module1就是 JavaScript 模块的基本写法。下面,再对这种写法进行加工

模块的放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(augmentation)

var module1 = (function (mod){
 mod.m3 = function () {
  //...
 };
 return mod;
})(module1);

上面的代码为module1模块添加了一个新方法m3(),然后返回新的module1模块。在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上面的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用"宽放大模式"(Loose augmentation)

var module1 = (function (mod) {
 //...
 return mod;
})(window.module1 || {});

与"放大模式"相比,“宽放大模式”就是“立即执行函数”的参数可以是空对象

输入全局变量

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。为了在模块内部调用全局变量,必须显式地将其他变量输入模块

var module1 = (function ($, YAHOO) {
 //...
})(jQuery, YAHOO);

上面的module1模块需要使用 jQuery 库和 YUI 库,就把这两个库(其实是两个模块)当作参数输入module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。立即执行函数还可以起到命名空间的作用

(function($, window, document) {
  function go(num) {}
  function handleEvents() {}
  function initialize() {}
  function dieCarouselDie() {}
  //attach to the global scope
  window.finalCarousel = {
    init : initialize,
    destroy : dieCarouselDie
  }
})( jQuery, window, document );

上面代码中,finalCarousel对象输出到全局,对外暴露init和destroy接口,内部方法go、handleEvents、initialize、dieCarouselDie都是外部无法调用的

Object 对象的相关方法

JavaScript 在Object对象上面,提供了很多相关方法,处理面向对象编程的相关操作

Object.getPrototypeOf()

Object.getPrototypeOf方法返回参数对象的原型。这是获取原型对象的标准方法

var F = function () {};
var f = new F();
Object.getPrototypeOf(f) === F.prototype // true

上面代码中,实例对象f的原型是F.prototype。下面是几种特殊对象的原型

// 空对象的原型是 Object.prototype
Object.getPrototypeOf({}) === Object.prototype // true
// Object.prototype 的原型是 null
Object.getPrototypeOf(Object.prototype) === null // true
// 函数的原型是 Function.prototype
function f() {}
Object.getPrototypeOf(f) === Function.prototype // true

Object.setPrototypeOf()

Object.setPrototypeOf方法为参数对象设置原型,返回该参数对象。它接受两个参数,第一个是现有对象,第二个是原型对象

var a = {};
var b = {x: 1};
Object.setPrototypeOf(a, b);
Object.getPrototypeOf(a) === b // true
a.x // 1

上面代码中,Object.setPrototypeOf方法将对象a的原型,设置为对象b,因此a可以共享b的属性。new命令可以使用Object.setPrototypeOf方法模拟

var F = function () {
  this.foo = 'bar';
};
var f = new F();
// 等同于
var f = Object.setPrototypeOf({}, F.prototype);
F.call(f);

上面代码中,new命令新建实例对象,其实可以分成两步。第一步,将一个空对象的原型设为构造函数的prototype属性(上例是F.prototype);第二步,将构造函数内部的this绑定这个空对象,然后执行构造函数,使得定义在this上面的方法和属性(上例是this.foo),都转移到这个空对象上

Object.create()

生成实例对象的常用方法是使用new命令让构造函数返回一个实例。但是很多时候只能拿到一个实例对象,它可能根本不是由构建函数生成的,那么能不能从一个实例对象生成另一个实例对象呢?JavaScript 提供了Object.create方法,用来满足这种需求。该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性

// 原型对象
var A = {
  print: function () { console.log('hello'); }
};
// 实例对象
var B = Object.create(A);
Object.getPrototypeOf(B) === A // true
B.print() // hello
B.print === A.print // true

上面代码中,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的属性。下面三种方式生成的新对象是等价的

var obj1 = Object.create({});
var obj2 = Object.create(Object.prototype);
var obj3 = new Object();

如果想要生成一个不继承任何属性(比如没有toString和valueOf方法)的对象,可以将Object.create的参数设为null

var obj = Object.create(null);
obj.valueOf() // TypeError: Object [object Object] has no method 'valueOf'

上面代码中,对象obj的原型是null,它就不具备一些定义在Object.prototype对象上面的属性,比如valueOf方法。使用Object.create方法的时候,必须提供对象原型,即参数不能为空,或者不是对象,否则会报错

Object.create() // TypeError: Object prototype may only be an Object or null
Object.create(123) // TypeError: Object prototype may only be an Object or null

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

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

上面代码中,修改对象原型obj1会影响到实例对象obj2。除了对象的原型,Object.create方法还可以接受第二个参数。该参数是一个属性描述对象,它所描述的对象属性,会添加到实例对象,作为该对象自身的属性

var obj = Object.create({}, {
  p1: { value: 123, enumerable: true, configurable: true, writable: true,},
  p2: { value: 'abc', enumerable: true, configurable: true, writable: true,}
});
// 等同于
var obj = Object.create({});
obj.p1 = 123;
obj.p2 = 'abc';

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

function A() {}
var a = new A();
var b = Object.create(a);
b.constructor === A // true
b instanceof A // true

上面代码中,b对象的原型是a对象,因此继承了a对象的构造函数A

Object.prototype.isPrototypeOf()

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

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

上面代码中,o1和o2都是o3的原型。这表明只要实例对象处在参数对象的原型链上,isPrototypeOf方法都返回true

Object.prototype.isPrototypeOf({}) // true
Object.prototype.isPrototypeOf([]) // true
Object.prototype.isPrototypeOf(/xyz/) // true
Object.prototype.isPrototypeOf(Object.create(null)) // false

上面代码中,由于Object.prototype处于原型链的最顶端,所以对各种实例都返回true,只有直接继承自null的对象除外

Object.prototype.__proto__

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

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

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

var A = { name: '张三'};
var B = { name: '李四'};
var proto = {
  print: function () { console.log(this.name); }
};
A.__proto__ = proto;
B.__proto__ = proto;
A.print() // 张三
B.print() // 李四
A.print === B.print // true
A.print === proto.print // true
B.print === proto.print // true

上面代码中,A对象和B对象的原型都是proto对象,它们都共享proto对象的print方法。也就是说,A和B的print方法,都是在调用proto对象的print方法

获取原型对象方法的比较

如前所述,__proto__属性指向当前对象的原型对象,即构造函数的prototype属性

var obj = new Object();
obj.__proto__ === Object.prototype // true
obj.__proto__ === obj.constructor.prototype // true

上面代码首先新建了一个对象obj,它的__proto__属性,指向构造函数(Object或obj.constructor)的prototype属性;因此,获取实例对象obj的原型对象,有三种方法

obj.__proto__
obj.constructor.prototype
Object.getPrototypeOf(obj)

上面三种方法之中,前两种都不是很可靠。__proto__属性只有浏览器才需要部署,其他环境可以不部署。而obj.constructor.prototype在手动改变原型对象时,可能会失效

var P = function () {};
var p = new P();
var C = function () {};
C.prototype = p;
var c = new C();
c.constructor.prototype === p // false

上面代码中,构造函数C的原型对象被改成了p,但是实例对象的c.constructor.prototype却没有指向p。所以,在改变原型对象时,一般要同时设置constructor属性

C.prototype = p;
C.prototype.constructor = C;
var c = new C();
c.constructor.prototype === p // true

因此,推荐使用第三种Object.getPrototypeOf方法,获取原型对象

Object.getOwnPropertyNames()

Object.getOwnPropertyNames方法返回一个数组,成员是参数对象本身的所有属性的键名,不包含继承的属性键名

Object.getOwnPropertyNames(Date) // ["length", "name", "prototype", "now", "parse", "UTC"]

上面代码中,Object.getOwnPropertyNames方法返回Date所有自身的属性名。对象本身的属性之中,有的是可以遍历的(enumerable)有的是不可以遍历的。Object.getOwnPropertyNames方法返回所有键名,不管是否可以遍历。只获取那些可以遍历的属性,使用Object.keys方法

Object.keys(Date) // []

上面代码表明,Date对象所有自身的属性,都是不可以遍历的

Object.prototype.hasOwnProperty()

对象实例的hasOwnProperty方法返回一个布尔值,用于判断某个属性定义在对象自身,还是定义在原型链上

Date.hasOwnProperty('length') // true
Date.hasOwnProperty('toString') // false

上面代码表明,Date.length(构造函数Date可以接受多少个参数)是Date自身的属性,Date.toString是继承的属性。另外,hasOwnProperty方法是 JavaScript 之中唯一一个处理对象属性时,不会遍历原型链的方法

in 运算符和 for...in 循环

in运算符返回一个布尔值,表示一个对象是否具有某个属性。它不区分该属性是对象自身的属性,还是继承的属性

'length' in Date // true
'toString' in Date // true

in运算符常用于检查一个属性是否存在。获得对象的所有可遍历属性(不管是自身的还是继承的),可以使用for...in循环

var o1 = { p1: 123 };
var o2 = Object.create(o1, {
  p2: { value: "abc", enumerable: true }
});
for (p in o2) { console.info(p);}
// p2
// p1

上面代码中,对象o2的p2属性是自身的,p1属性是继承的。这两个属性都会被for...in循环遍历。为了在for...in循环中获得对象自身的属性,可以采用hasOwnProperty方法判断一下

for ( var name in object ) {
  if ( object.hasOwnProperty(name) ) {
    /* loop code */
  }
}

获得对象的所有属性(不管是自身的还是继承的,也不管是否可枚举),可以使用下面的函数

function inheritedPropertyNames(obj) {
  var props = {};
  while(obj) {
    Object.getOwnPropertyNames(obj).forEach(function(p) { props[p] = true;});
    obj = Object.getPrototypeOf(obj);
  }
  return Object.getOwnPropertyNames(props);
}

上面代码依次获取obj对象的每一级原型对象“自身”的属性,从而获取obj对象的“所有”属性,不管是否可遍历。下面是一个例子,列出Date对象的所有属性

inheritedPropertyNames(Date)
// ["caller","constructor","toString","UTC",...]

对象的拷贝

如果要拷贝一个对象,需要做到下面两件事情。

1.确保拷贝后的对象,与原对象具有同样的原型。

2.确保拷贝后的对象,与原对象具有同样的实例属性。

下面就是根据上面两点,实现的对象拷贝函数

function copyObject(orig) {
  var copy = Object.create(Object.getPrototypeOf(orig));
  copyOwnPropertiesFrom(copy, orig);
  return copy;
}
function copyOwnPropertiesFrom(target, source) {
  Object.getOwnPropertyNames(source).forEach(function (propKey) {
      var desc = Object.getOwnPropertyDescriptor(source, propKey);
      Object.defineProperty(target, propKey, desc);
    });
  return target;
}

另一种更简单的写法,是利用 ES2017 才引入标准的Object.getOwnPropertyDescriptors方法

function copyObject(orig) {
  return Object.create(
    Object.getPrototypeOf(orig),
    Object.getOwnPropertyDescriptors(orig)
  );
}

严格模式

除了正常的运行模式,JavaScript 还有第二种运行模式:严格模式(strict mode)。顾名思义,这种模式采用更加严格的 JavaScript 语法。同样的代码,在正常模式和严格模式中,可能会有不一样的运行结果。一些在正常模式下可以运行的语句,在严格模式下将不能运行

设计目的

早期的 JavaScript 语言有很多设计不合理的地方,但是为了兼容以前的代码,又不能改变老的语法,只能不断添加新的语法,引导程序员使用新语法。

严格模式是从 ES5 进入标准的,主要目的有以下几个:

1.明确禁止一些不合理、不严谨的语法,减少 JavaScript 语言的一些怪异行为

2.增加更多报错的场合,消除代码运行的一些不安全之处,保证代码运行的安全

3.提高编译器效率,增加运行速度

4.为未来新版本的 JavaScript 语法做好铺垫

总之,严格模式体现了 JavaScript 更合理、更安全、更严谨的发展方向

启用方法

进入严格模式的标志,是一行字符串'use strict'

老版本的引擎会把它当作一行普通字符串,加以忽略;新版本的引擎就会进入严格模式。严格模式可以用于整个脚本,也可以只用于单个函数

整个脚本文件

use strict放在脚本文件的第一行,整个脚本都将以严格模式运行;如果这行语句不在第一行就无效,整个脚本会以正常模式运行。(严格地说,只要前面不是产生实际运行结果的语句,use strict可以不在第一行,比如直接跟在一个空的分号后面,或者跟在注释后面)

<script>
  'use strict';
  console.log('这是严格模式');
</script>

<script>
  console.log('这是正常模式');
</script>

上面代码中,一个网页文件依次有两段 JavaScript 代码。前一个

<script>
  console.log('这是正常模式');
  'use strict';
</script>
单个函数

use strict放在函数体的第一行,则整个函数以严格模式运行

function strict() {
  'use strict';
  return '这是严格模式';
}

function strict2() {
  'use strict';
  function f() {
    return '这也是严格模式';
  }
  return f();
}

function notStrict() {
  return '这是正常模式';
}

有时需要把不同脚本合并在一个文件里面。如果一个脚本是严格模式,另一个脚本不是,它们的合并就可能出错。严格模式的脚本在前,则合并后的脚本都是严格模式;如果正常模式的脚本在前,则合并后的脚本都是正常模式。这两种情况下,合并后的结果都是不正确的。这时可以考虑把整个脚本文件放在一个立即执行的匿名函数之中

(function () {
  'use strict';
  // some code here
})();

显式报错

严格模式使得 JavaScript 的语法变得更严格,更多的操作会显式报错。其中有些操作,在正常模式下只会默默地失败,不会报错

只读属性不可写

严格模式下,设置字符串的length属性,会报错

'use strict';
'abc'.length = 5; // TypeError: Cannot assign to read only property 'length' of string 'abc'

上面代码报错,因为length是只读属性,严格模式下不可写。正常模式下,改变length属性是无效的,但不会报错。严格模式下,对只读属性赋值,或者删除不可配置(non-configurable)属性都会报错

// 对只读属性赋值会报错
'use strict';
Object.defineProperty({}, 'a', {
  value: 37,
  writable: false
});
obj.a = 123; // TypeError: Cannot assign to read only property 'a' of object #<Object>

// 删除不可配置的属性会报错
'use strict';
var obj = Object.defineProperty({}, 'p', {
  value: 1,
  configurable: false
});
delete obj.p // TypeError: Cannot delete property 'p' of #<Object>
只设置了取值器的属性不可写

严格模式下,对一个只有取值器(getter)、没有存值器(setter)的属性赋值,会报错

'use strict';
var obj = {
  get v() { return 1; }
};
obj.v = 2; // Uncaught TypeError: Cannot set property v of #<Object> which has only a getter
禁止扩展的对象不可扩展

严格模式下,对禁止扩展的对象添加新属性,会报错

'use strict';
var obj = {};
Object.preventExtensions(obj);
obj.v = 1; // Uncaught TypeError: Cannot add property v, object is not extensible

上面代码中,obj对象禁止扩展,添加属性就会报错

eval、arguments 不可用作标识名

严格模式下,使用eval或者arguments作为标识名,将会报错。下面的语句都会报错

'use strict';
var eval = 17;
var arguments = 17;
var obj = { set p(arguments) { } };
try { } catch (arguments) { }
function x(eval) { }
function arguments() { }
var y = function eval() { };
var f = new Function('arguments', "'use strict'; return 17;"); // SyntaxError: Unexpected eval or arguments in strict mode
函数不能有重名的参数

正常模式下,如果函数有多个重名的参数,可以用arguments[i]读取。严格模式下,这属于语法错误

function f(a, a, b) {
  'use strict';
  return a + b;
} // Uncaught SyntaxError: Duplicate parameter name not allowed in this context
禁止八进制的前缀0表示法

正常模式下,整数的第一位如果是0,表示这是八进制数,比如0100等于十进制的64。严格模式禁止这种表示法,整数第一位为0,将报错

增强的安全措施

严格模式增强了安全保护,从语法上防止了一些不小心会出现的错误

全局变量显式声明

正常模式中,如果一个变量没有声明就赋值,默认是全局变量。严格模式禁止这种用法,全局变量必须显式声明

'use strict';
v = 1; // 报错,v未声明
for (i = 0; i < 2; i++) { // 报错,i 未声明
  // ...
}
function f() {
  x = 123;
}
f() // 报错,未声明就创建一个全局变量

因此,严格模式下,变量都必须先声明,然后再使用

禁止 this 关键字指向全局对象

正常模式下,函数内部的this可能会指向全局对象,严格模式禁止这种用法,避免无意间创造全局变量

// 正常模式
function f() {
  console.log(this === window);
}
f() // true

// 严格模式
function f() {
  'use strict';
  console.log(this === undefined);
}
f() // true

上面代码中,严格模式的函数体内部this是undefined,这种限制对于构造函数尤其有用。使用构造函数时,有时忘了加new,这时this不再指向全局对象,而是报错

function f() {
  'use strict';
  this.a = 1;
};
f();// 报错,this 未定义

严格模式下,函数直接调用时(不使用new调用),函数内部的this表示undefined(未定义),因此可以用call、apply和bind方法,将任意值绑定在this上面。正常模式下,this指向全局对象,如果绑定的值是非对象,将被自动转为对象再绑定上去,而null和undefined这两个无法转成对象的值,将被忽略

// 正常模式
function fun() { return this; }
fun() // window
fun.call(2) // Number {2}
fun.call(true) // Boolean {true}
fun.call(null) // window
fun.call(undefined) // window

// 严格模式
'use strict';
function fun() { return this; }
fun() //undefined
fun.call(2) // 2
fun.call(true) // true
fun.call(null) // null
fun.call(undefined) // undefined

上面代码中,可以把任意类型的值,绑定在this上面

禁止使用 fn.callee、fn.caller

函数内部不得使用fn.caller、fn.arguments,否则会报错。这意味着不能在函数内部得到调用栈了

function f1() {
  'use strict';
  f1.caller;    // 报错
  f1.arguments; // 报错
}
f1();
禁止使用 arguments.callee、arguments.caller

arguments.callee和arguments.caller是两个历史遗留的变量,从来没有标准化过,现在已经取消了。正常模式下调用它们没有什么作用,但是不会报错。严格模式明确规定,函数内部使用arguments.callee、arguments.caller将会报错

'use strict';
var f = function () { return arguments.callee; };
f(); // 报错
禁止删除变量

严格模式下无法删除变量,如果使用delete命令删除一个变量,会报错。只有对象的属性,且属性的描述对象的configurable属性设置为true,才能被delete命令删除

'use strict';
var x;
delete x; // 语法错误

var obj = Object.create(null, {
  x: { value: 1, configurable: true }
});
delete obj.x; // 删除成功

静态绑定

JavaScript 语言的一个特点,就是允许“动态绑定”,即某些属性和方法到底属于哪一个对象,不是在编译时确定的,而是在运行时(runtime)确定的。严格模式对动态绑定做了一些限制;某些情况下,只允许静态绑定;也就是说,属性和方法到底归属哪个对象,必须在编译阶段就确定。这样做有利于编译效率的提高,也使得代码更容易阅读,更少出现意外。具体来说,涉及以下几个方面:

禁止使用 with 语句

严格模式下,使用with语句将报错;因为with语句无法在编译时就确定,某个属性到底归属哪个对象,从而影响了编译效果

'use strict';
var v  = 1;
var obj = {};
with (obj) { v = 2; } // Uncaught SyntaxError: Strict mode code may not include a with statement
创设 eval 作用域

正常模式下,JavaScript 语言有两种变量作用域(scope):全局作用域和函数作用域。严格模式创设了第三种作用域:eval作用域。正常模式下,eval语句的作用域取决于它处于全局作用域还是函数作用域。严格模式下,eval语句本身就是一个作用域,不再能够在其所运行的作用域创设新的变量了,也就是说,eval所生成的变量只能用于eval内部

(function () {
  'use strict';
  var x = 2;
  console.log(eval('var x = 5; x')) // 5
  console.log(x) // 2
})()

上面代码中,由于eval语句内部是一个独立作用域,所以内部的变量x不会泄露到外部。注意,如果希望eval语句也使用严格模式,有两种方式

// 方式一
function f1(str){
  'use strict';
  return eval(str);
}
f1('undeclared_variable = 1'); // 报错

// 方式二
function f2(str){ return eval(str); }
f2('"use strict";undeclared_variable = 1')  // 报错

上面两种写法,eval内部使用的都是严格模式

arguments 不再追踪参数的变化

变量arguments代表函数的参数。严格模式下,函数内部改变参数与arguments的联系被切断了,两者不再存在联动关系

function f(a) {
  a = 2;
  return [a, arguments[0]];
}
f(1); // 正常模式为[2, 2]

function f(a) {
  'use strict';
  a = 2;
  return [a, arguments[0]];
}
f(1); // 严格模式为[2, 1]

上面代码中,改变函数的参数,不会反应到arguments对象上来

向下一个版本的 JavaScript 过渡

JavaScript 语言的下一个版本是 ECMAScript 6,为了平稳过渡,严格模式引入了一些 ES6 语法

非函数代码块不得声明函数

ES6 会引入块级作用域。为了与新版本接轨,ES5 的严格模式只允许在全局作用域或函数作用域声明函数。也就是说,不允许在非函数的代码块内声明函数

'use strict';
if (true) {
  function f1() { } // 语法错误
}
for (var i = 0; i < 5; i++) {
  function f2() { } // 语法错误
}

上面代码在if代码块和for代码块中声明了函数,ES5 环境会报错

注意,如果是 ES6 环境,上面的代码不会报错,因为 ES6 允许在代码块之中声明函数

保留字

为了向将来 JavaScript 的新版本过渡,严格模式新增了一些保留字(implements、interface、let、package、private、protected、public、static、yield等)。使用这些词作为变量名将会报错

function package(protected) { // 语法错误
  'use strict';
  var implements; // 语法错误
}

异步操作

异步操作概述

单线程模型

单线程模型指的是,JavaScript 只在一个线程上运行;也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待;注意,JavaScript 只在一个线程上运行,不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合

JavaScript 之所以采用单线程,而不是多线程,跟历史有关系;JavaScript 从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说就太复杂了。如果 JavaScript 同时有两个线程,一个线程在网页 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?是不是还要有锁机制?所以,为了避免复杂性,JavaScript 一开始就是单线程,这已经成了这门语言的核心特征,将来也不会改变

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 JavaScript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。JavaScript 语言本身并不慢,慢的是读写外部数据,比如等待 Ajax 请求返回结果;这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 操作(输入输出)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript 语言的设计者意识到,这时 CPU 完全可以不管 IO 操作,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是 JavaScript 内部采用的“事件循环”机制(Event Loop)

单线程模型虽然对 JavaScript 构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果用得好,JavaScript 程序是不会出现堵塞的,这就是为什么 Node 可以用很少的资源,应付大流量访问的原因。为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质

同步任务和异步任务

程序里面所有的任务,可以分成两类:同步任务(synchronous)和异步任务(asynchronous)。

同步任务是那些没有被引擎挂起、在主线程上排队执行的任务;只有前一个任务执行完毕,才能执行后一个任务。异步任务是那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。

举例来说,Ajax 操作可以当作同步任务处理,也可以当作异步任务处理,由开发者决定。如果是同步任务,主线程就等着 Ajax 操作返回结果,再往下执行;如果是异步任务,主线程在发出 Ajax 请求以后,就直接往下执行,等到 Ajax 操作有了结果,主线程再执行对应的回调函数

任务队列和事件循环

JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列)

首先,主线程会去执行所有的同步任务;等到同步任务全部执行完,就会去看任务队列里面的异步任务;如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。

异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。

JavaScript 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环(Event Loop)。维基百科的定义是:“事件循环是一个程序结构,用于等待和发送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”

异步操作的模式

下面总结一下异步操作的几种模式

回调函数

回调函数是异步操作最基本的方法。下面是两个函数f1和f2,编程的意图是f2必须等到f1执行完成,才能执行

function f1() {
  // ...
}
function f2() {
  // ...
}
f1();
f2();

上面代码的问题在于,如果f1是异步操作,f2会立即执行,不会等到f1结束再执行。这时,可以考虑改写f1,把f2写成f1的回调函数

function f1(callback) {
  // ...
  callback();
}
function f2() {
  // ...
}
f1(f2);

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数

事件监听

另一种思路是采用事件驱动模式。异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。还是以f1和f2为例;首先,为f1绑定一个事件

f1.on('done', f2);

上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:

function f1() {
  setTimeout(function () {
    // ...
    f1.trigger('done');
  }, 1000);
}

上面代码中,f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2。这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去耦合”(decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程

发布/订阅

事件完全可以理解成“信号”,如果存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行;这就叫做”发布/订阅模式”(publish-subscribe pattern),又称“观察者模式”(observer pattern)。

这个模式有多种实现,下面采用的是 Ben Alman 的 Tiny Pub/Sub,这是 jQuery 的一个插件。

首先,f2向信号中心jQuery订阅done信号

jQuery.subscribe('done', f2);

然后,f1进行如下改写

function f1() {
  setTimeout(function () {
    // ...
    jQuery.publish('done');
  }, 1000);
}

上面代码中,jQuery.publish('done')的意思是,f1执行完成后,向信号中心jQuery发布done信号,从而引发f2的执行;f2完成执行后,可以取消订阅(unsubscribe)

jQuery.unsubscribe('done', f2);

这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行

异步操作的流程控制

如果有多个异步操作,就存在一个流程控制的问题:如何确定异步操作执行的顺序,以及如何保证遵守这种顺序

function async(arg, callback) {
  console.log('参数为 ' + arg +' , 1秒后返回结果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}

上面代码的async函数是一个异步任务,非常耗时,每次执行需要1秒才能完成,然后再调用回调函数;如果有六个这样的异步任务,需要全部完成后,才能执行最后的final函数。请问应该如何安排操作流程?

function final(value) {
  console.log('完成: ', value);
}
async(1, function (value) {
  async(2, function (value) {
    async(3, function (value) {
      async(4, function (value) {
        async(5, function (value) {
          async(6, final);
        });
      });
    });
  });
});
// 参数为 1 , 1秒后返回结果
// 参数为 2 , 1秒后返回结果
// 参数为 3 , 1秒后返回结果
// 参数为 4 , 1秒后返回结果
// 参数为 5 , 1秒后返回结果
// 参数为 6 , 1秒后返回结果
// 完成:  12

上面代码中,六个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护

串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串行执行

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
function async(arg, callback) {
  console.log('参数为 ' + arg +' , 1秒后返回结果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}
function final(value) {
  console.log('完成: ', value);
}
function series(item) {
  if(item) {
    async( item, function(result) {
      results.push(result);
      return series(items.shift());
    });
  } else {
    return final(results[results.length - 1]);
  }
}
series(items.shift());

上面代码中,函数series就是串行函数,它会依次执行异步任务,所有任务都完成后,才会执行final函数。items数组保存每一个异步任务的参数,results数组保存每一个异步任务的运行结果。注意,上面的写法需要六秒,才能完成整个脚本

并行执行

流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行final函数

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
function async(arg, callback) {
  console.log('参数为 ' + arg +' , 1秒后返回结果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}
function final(value) {
  console.log('完成: ', value);
}
items.forEach(function(item) {
  async(item, function(result){
    results.push(result);
    if(results.length === items.length) {
      final(results[results.length - 1]);
    }
  })
});

上面代码中,forEach方法会同时发起六个异步任务,等到它们全部完成以后,才会执行final函数。相比而言,上面的写法只要一秒就能完成整个脚本;这就是说,并行执行的效率较高,比起串行执行一次只能执行一个任务,较为节约时间;但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式

并行与串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行n个异步任务,这样就避免了过分占用系统资源

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;
function async(arg, callback) {
  console.log('参数为 ' + arg +' , 1秒后返回结果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}
function final(value) {
  console.log('完成: ', value);
}
function launcher() {
  while(running < limit && items.length > 0) {
    var item = items.shift();
    async(item, function(result) {
      results.push(result);
      running--;
      if(items.length > 0) {
        launcher();
      } else if(running == 0) {
        final(results);
      }
    });
    running++;
  }
}
launcher();

上面代码中,最多只能同时运行两个异步任务。变量running记录当前正在运行的任务数,只要低于门槛值,就再启动一个新的任务,如果等于0,就表示所有任务都执行完了,这时就执行final函数。

这段代码需要三秒完成整个脚本,处在串行执行和并行执行之间。通过调节limit变量,达到效率和资源的最佳平衡

定时器

JavaScript 提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()和setInterval()这两个函数来完成。它们向任务队列添加定时任务

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还允许更多的参数。它们将依次传入推迟执行的函数(回调函数)

setTimeout(function (a,b) {
  console.log(a + b);
}, 1000, 1, 1);

上面代码中,setTimeout共有4个参数。最后两个参数,将在1000毫秒之后回调函数执行时作为回调函数的参数。还有一个需要注意的地方,如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象

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

上面代码输出的是1,而不是2。因为当obj.y在1000毫秒后运行时,this所指向的已经不是obj了,而是全局环境。为了防止出现这个问题,一种解决方法是将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) // 2

setInterval()

setInterval函数的用法与setTimeout完全一致,区别仅仅在于setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行

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

上面代码中,每隔1000毫秒就输出一个2,会无限运行下去,直到关闭当前窗口;与setTimeout一样,除了前两个参数,setInterval方法还可以接受更多的参数,它们会传入回调函数。下面是一个通过setInterval方法实现网页动画的例子

var div = document.getElementById('someDiv');
var opacity = 1;
var fader = setInterval(function() {
  opacity -= 0.1;
  if (opacity >= 0) {
    div.style.opacity = opacity;
  } else {
    clearInterval(fader);
  }
}, 100);

上面代码每隔100毫秒,设置一次div元素的透明度,直至其完全透明为止。setInterval的一个常见用途是实现轮询。下面是一个轮询 URL 的 Hash 值是否发生变化的例子

var hash = window.location.hash;
var hashWatcher = setInterval(function() {
  if (window.location.hash != hash) {
    updatePage();
  }
}, 1000);

setInterval指定的是“开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。比如,setInterval指定每 100ms 执行一次,每次执行需要 5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。为了确保两次执行之间有固定的间隔,可以不用setInterval,而是每次执行结束后,使用setTimeout指定下一次执行的具体时间

var i = 1;
var timer = setTimeout(function f() {
  // ...
  timer = setTimeout(f, 2000);
}, 2000);

上面代码可以确保,下一次执行总是在本次执行结束之后的2000毫秒开始

clearTimeout(),clearInterval()

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

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

上面代码中,回调函数f不会再执行了,因为两个定时器都被取消了。setTimeout和setInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1

function f() {}
setTimeout(f, 1000) // 10
setTimeout(f, 1000) // 11
setTimeout(f, 1000) // 12

上面代码中,连续调用三次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) {}
}

setTimeout(f, 0)

含义

setTimeout的作用是将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),那么会立刻执行吗?

答案是不会。因为上一节说过,必须要等到当前脚本的同步任务,全部处理完以后,才会执行setTimeout指定的回调函数f。也就是说,setTimeout(f, 0)会在下一轮事件循环一开始就执行

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

上面代码先输出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)

<input type="button" id="myButton" value="click">
<script>
  var input = document.getElementById('myButton');
  input.onclick = function A() {
    setTimeout(function B() { input.value +=' input'; }, 0)
  };
  document.body.onclick = function C() { input.value += ' body' };
</script>

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

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

<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(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)的样子,性能压力就会减轻。

Promise 对象

概述

Promise 对象是 JavaScript 的异步操作解决方案,为异步操作提供统一接口。它起到代理作用(proxy),充当异步操作与回调函数之间的中介,使得异步操作具备同步操作的接口。Promise 可以让异步操作写起来,就像在写同步操作的流程,而不必一层层地嵌套回调函数。

首先,Promise 是一个对象,也是一个构造函数

function f1(resolve, reject) {
  // 异步代码...
}
var p1 = new Promise(f1);

上面代码中,Promise构造函数接受一个回调函数f1作为参数,f1里面是异步操作的代码。然后,返回的p1就是一个 Promise 实例。Promise 的设计思想是,所有异步任务都返回一个 Promise 实例。Promise 实例有一个then方法,用来指定下一步的回调函数

var p1 = new Promise(f1);
p1.then(f2);

上面代码中,f1的异步操作执行完成,就会执行f2。传统的写法可能需要把f2作为回调函数传入f1,比如写成f1(f2),异步操作完成后,在f1内部调用f2。Promise 使得f1和f2变成了链式写法。不仅改善了可读性,而且对于多层嵌套的回调函数尤其方便

// 传统写法
step1(function (value1) {
  step2(value1, function(value2) {
    step3(value2, function(value3) {
      step4(value3, function(value4) {
        // ...
      });
    });
  });
});
// Promise 的写法
(new Promise(step1)).then(step2).then(step3).then(step4);

从上面代码可以看到,采用 Promise 以后,程序流程变得非常清楚,十分易读。总的来说,传统的回调函数写法使得代码混成一团,变得横向发展而不是向下发展。Promise 就是解决这个问题,使得异步流程可以写成同步流程。Promise 原本只是社区提出的一个构想,一些函数库率先实现了这个功能。ECMAScript 6 将其写入语言标准,目前 JavaScript 原生支持 Promise 对象

Promise 对象的状态

Promise 对象通过自身的状态,来控制异步操作。Promise 实例具有三种状态:异步操作未完成(pending)/异步操作成功(fulfilled)/异步操作失败(rejected);上面三种状态里面,fulfilled和rejected合在一起称为resolved(已定型)。

这三种的状态的变化途径只有两种:从“未完成”到“成功” / 从“未完成”到“失败”。一旦状态发生变化就凝固了,不会再有新的状态变化。这也是 Promise 这个名字的由来,它的英语意思是“承诺”,一旦承诺成效,就不能再改变了。这也意味着,Promise 实例的状态变化只可能发生一次。因此,Promise 的最终结果只有两种:

异步操作成功,Promise 实例传回一个值(value),状态变为fulfilled

异步操作失败,Promise 实例抛出一个错误(error),状态变为rejected

Promise 构造函数

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

var promise = new Promise(function (resolve, reject) {
  // ...
  if (/* 异步操作成功 */){
    resolve(value);
  } else { /* 异步操作失败 */
    reject(new Error());
  }
});

上面代码中,Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由 JavaScript 引擎提供,不用自己实现。resolve函数的作用是,将Promise实例的状态从“未完成”变为“成功”(即从pending变为fulfilled),在异步操作成功时调用,并将异步操作的结果作为参数传递出去。reject函数的作用是,将Promise实例的状态从“未完成”变为“失败”(即从pending变为rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去

function timeout(ms) {
  return new Promise((resolve, reject) => { setTimeout(resolve, ms, 'done'); });
}
timeout(100)

上面代码中,timeout(100)返回一个 Promise 实例。100毫秒以后,该实例的状态会变为fulfilled

Promise.prototype.then()

Promise 实例的then方法,用来添加回调函数。then方法可以接受两个回调函数,第一个是异步操作成功时(变为fulfilled状态)的回调函数,第二个是异步操作失败(变为rejected)时的回调函数(该参数可以省略)。一旦状态改变,就调用相应的回调函数

var p1 = new Promise(function (resolve, reject) { resolve('成功'); });
p1.then(console.log, console.error); // "成功"
var p2 = new Promise(function (resolve, reject) { reject(new Error('失败')); });
p2.then(console.log, console.error); // Error: 失败

上面代码中,p1和p2都是Promise 实例,它们的then方法绑定两个回调函数:成功时的回调函数console.log,失败时的回调函数console.error(可以省略)。p1的状态变为成功,p2的状态变为失败,对应的回调函数会收到异步操作传回的值,然后在控制台输出。then方法可以链式使用

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

上面代码中,p1后面有四个then,意味着依次有四个回调函数。只要前一步的状态变为fulfilled,就会依次执行紧跟在后面的回调函数。最后一个then方法,回调函数是console.log和console.error,用法上有一点重要的区别;console.log只显示step3的返回值,而console.error可以显示p1、step1、step2、step3之中任意一个发生的错误。举例来说,如果step1的状态变为rejected,那么step2和step3都不会执行了(因为它们是resolved的回调函数)。Promise 开始寻找,接下来第一个为rejected的回调函数,在上面代码中是console.error。这就是说,Promise 对象的报错具有传递性

then() 用法辨析

Promise 的用法,简单说就是一句话:使用then方法添加回调函数。但是,不同的写法有一些细微的差别,请看下面四种写法:

// 写法一
f1().then(function () { return f2(); });
// 写法二
f1().then(function () { f2(); });
// 写法三
f1().then(f2());
// 写法四
f1().then(f2);

为了便于讲解,下面这四种写法都再用then方法接一个回调函数f3。写法一的f3回调函数的参数,是f2函数的运行结果

f1().then(function () { return f2(); }).then(f3);

写法二的f3回调函数的参数是undefined

f1().then(function () { f2(); return; }).then(f3);

写法三的f3回调函数的参数,是f2函数返回的函数的运行结果

f1().then(f2()).then(f3);

写法四与写法一只有一个差别,那就是f2会接收到f1()返回的结果

f1().then(f2).then(f3);

实例:图片加载

下面是使用 Promise 完成图片的加载

var preloadImage = function (path) {
  return new Promise(function (resolve, reject) {
    var image = new Image();
    image.onload  = resolve;
    image.onerror = reject;
    image.src = path;
  });
};

上面的preloadImage函数用法如下

preloadImage('https://example.com/my.jpg').then(function (e) { document.body.append(e.target) }).then(function () { console.log('加载成功') })

小结

Promise 的优点在于,让回调函数变成了规范的链式写法,程序流程可以看得很清楚。它有一整套接口,可以实现许多强大的功能,比如同时执行多个异步操作,等到它们的状态都改变以后,再执行一个回调函数;再比如,为多个回调函数中抛出的错误,统一指定处理方法等等。而且,Promise 还有一个传统写法没有的好处:它的状态一旦改变,无论何时查询,都能得到这个状态。这意味着,无论何时为 Promise 实例添加回调函数,该函数都能正确执行。所以,你不用担心是否错过了某个事件或信号。如果是传统写法,通过监听事件来执行回调函数,一旦错过了事件,再添加回调函数是不会执行的。

Promise 的缺点是,编写的难度比传统写法高,而且阅读代码也不是一眼可以看懂。你只会看到一堆then,必须自己在then的回调函数里面理清逻辑

微任务

Promise 的回调函数属于异步任务,会在同步任务之后执行

new Promise(function (resolve, reject) { resolve(1); }).then(console.log);
console.log(2);
// 2
// 1

上面代码会先输出2,再输出1。因为console.log(2)是同步任务,而then的回调函数属于异步任务,一定晚于同步任务执行;但是,Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。它们的区别在于,正常任务追加到下一轮事件循环,微任务追加到本轮事件循环。这意味着,微任务的执行时间一定早于正常任务

setTimeout(function() { console.log(1); }, 0);
new Promise(function (resolve, reject) { resolve(2); }).then(console.log);
console.log(3);
// 3
// 2
// 1

上面代码的输出结果是321。这说明then的回调函数的执行时间,早于setTimeout(fn, 0)。因为then是本轮事件循环执行,setTimeout(fn, 0)在下一轮事件循环开始时执行

DOM

DOM 概述

DOM

DOM 是 JavaScript 操作网页的接口,全称为“文档对象模型”(Document Object Model)。它的作用是将网页转为一个 JavaScript 对象,从而可以用脚本进行各种操作(比如增删内容)。浏览器会根据 DOM 模型,将结构化文档(比如 HTML 和 XML)解析成一系列的节点,再由这些节点组成一个树状结构(DOM Tree)。所有的节点和最终的树状结构,都有规范的对外接口

DOM 只是一个接口规范,可以用各种语言实现。所以严格地说,DOM 不是 JavaScript 语法的一部分,但是 DOM 操作是 JavaScript 最常见的任务,离开了 DOM,JavaScript 就无法控制网页。另一方面,JavaScript 也是最常用于 DOM 操作的语言

节点

DOM 的最小组成单位叫做节点(node)。文档的树形结构(DOM 树),就是由各种不同类型的节点组成。每个节点可以看作是文档树的一片叶子

节点的类型有七种。

1.Document:整个文档树的顶层节点

2.DocumentType:doctype标签(比如)

3.Element:网页的各种HTML标签(比如

、 等)

4.Attribute:网页元素的属性(比如class="right")

5.Text:标签之间或标签包含的文本

6.Comment:注释

7.DocumentFragment:文档的片段

浏览器提供一个原生的节点对象Node,上面这七种节点都继承了Node,因此具有一些共同的属性和方法

节点树

一个文档的所有节点,按照所在的层级,可以抽象成一种树状结构;这种树状结构就是 DOM 树。它有一个顶层节点,下一层都是顶层节点的子节点,然后子节点又有自己的子节点,就这样层层衍生出一个金字塔结构,倒过来就像一棵树。浏览器原生提供document节点,代表整个文档

document // 整个文档树

文档的第一层只有一个节点,就是 HTML 网页的第一个标签,它构成了树结构的根节点(root node),其他 HTML 标签节点都是它的下级节点;除了根节点,其他节点都有三种层级关系:

1.父节点关系(parentNode):直接的那个上级节点

2.子节点关系(childNodes):直接的下级节点
3.同级节点关系(sibling):拥有同一个父节点的节点

DOM 提供操作接口,用来获取这三种关系的节点。比如,子节点接口包括firstChild(第一个子节点)和lastChild(最后一个子节点)等属性,同级节点接口包括nextSibling(紧邻在后的那个同级节点)和previousSibling(紧邻在前的那个同级节点)属性

Node 接口

所有 DOM 节点对象都继承了 Node 接口,拥有一些共同的属性和方法。这是 DOM 操作的基础

属性

Node.prototype.nodeType

nodeType属性返回一个整数值,表示节点的类型

document.nodeType // 9

上面代码中,文档节点的类型值为9。Node 对象定义了几个常量,对应这些类型值

document.nodeType === Node.DOCUMENT_NODE // true

上面代码中,文档节点的nodeType属性等于常量Node.DOCUMENT_NODE;不同节点的nodeType属性值和对应的常量如下:

1.文档节点(document):9,对应常量Node.DOCUMENT_NODE

2.元素节点(element):1,对应常量Node.ELEMENT_NODE

3.属性节点(attr):2,对应常量Node.ATTRIBUTE_NODE

4.文本节点(text):3,对应常量Node.TEXT_NODE

5.文档片断节点(DocumentFragment):11,对应常量Node.DOCUMENT_FRAGMENT_NODE

6.文档类型节点(DocumentType):10,对应常量Node.DOCUMENT_TYPE_NODE

7.注释节点(Comment):8,对应常量Node.COMMENT_NODE

确定节点类型时,使用nodeType属性是常用方法

var node = document.documentElement.firstChild;
if (node.nodeType === Node.ELEMENT_NODE) { console.log('该节点是元素节点'); }
Node.prototype.nodeName

nodeName属性返回节点的名称

<div id="d1">hello world</div>
<script>
  var div = document.getElementById('d1');
  div.nodeName // "DIV"
</script>

上面代码中,元素节点

的nodeName属性就是大写的标签名DIV;不同节点的nodeName属性值如下:

1.文档节点(document):#document

2.元素节点(element):大写的标签名

3.属性节点(attr):属性的名称

4.文本节点(text):#text

5.文档片断节点(DocumentFragment):#document-fragment

6.文档类型节点(DocumentType):文档的类型

7.注释节点(Comment):#comment

Node.prototype.nodeValue

nodeValue属性返回一个字符串,表示当前节点本身的文本值,该属性可读写;只有文本节点(text)、注释节点(comment)和属性节点(attr)有文本值,因此这三类节点的nodeValue可以返回结果,其他类型的节点一律返回null。同样的,也只有这三类节点可以设置nodeValue属性的值,其他类型的节点设置无效

<div id="d1">hello world</div>
<script>
  var div = document.getElementById('d1');
  div.nodeValue // null
  div.firstChild.nodeValue // "hello world"
</script>

上面代码中,div是元素节点,nodeValue属性返回null。div.firstChild是文本节点,所以可以返回文本值

Node.prototype.textContent

textContent属性返回当前节点和它的所有后代节点的文本内容

<div id="divA">This is <span>some</span> text</div>
document.getElementById('divA').textContent // This is some text

textContent属性自动忽略当前节点内部的 HTML 标签,返回所有文本内容;该属性是可读写的,设置该属性的值,会用一个新的文本节点,替换所有原来的子节点。它还有一个好处,就是自动对 HTML 标签转义。这很适合用于用户提供的内容

document.getElementById('foo').textContent = '<p>GoodBye!</p>'

上面代码在插入文本时,会将

标签解释为文本,而不会当作标签处理。

对于文本节点(text)、注释节点(comment)和属性节点(attr),textContent属性的值与nodeValue属性相同。对于其他类型的节点,该属性会将每个子节点(不包括注释节点)的内容连接在一起返回。如果一个节点没有子节点,则返回空字符串。文档节点(document)和文档类型节点(doctype)的textContent属性为null。如果要读取整个文档的内容,可以使用document.documentElement.textContent

Node.prototype.baseURI

baseURI属性返回一个字符串,表示当前网页的绝对路径。浏览器根据这个属性,计算网页上的相对路径的 URL。该属性为只读

document.baseURI

如果无法读到网页的 URL,baseURI属性返回null;该属性的值一般由当前网址的 URL(即window.location属性)决定,但是可以使用 HTML 的标签,改变该属性的值

<base href="http://www.example.com/page.html">

设置了以后,baseURI属性就返回标签设置的值

Node.prototype.ownerDocument

Node.ownerDocument属性返回当前节点所在的顶层文档对象,即document对象

var d = p.ownerDocument;
d === document // true

document对象本身的ownerDocument属性,返回null

Node.prototype.nextSibling

Node.nextSibling属性返回紧跟在当前节点后面的第一个同级节点;如果当前节点后面没有同级节点,则返回null

<div id="d1">hello</div><div id="d2">world</div>
var d1 = document.getElementById('d1');
var d2 = document.getElementById('d2');
d1.nextSibling === d2 // true

注意,该属性还包括文本节点和注释节点()。因此如果当前节点后面有空格,该属性会返回一个文本节点,内容为空格;nextSibling属性可以用来遍历所有子节点

var el = document.getElementById('div1').firstChild;
while (el !== null) {
  console.log(el.nodeName);
  el = el.nextSibling;
}

上面代码遍历div1节点的所有子节点

Node.prototype.previousSibling

previousSibling属性返回当前节点前面的、距离最近的一个同级节点。如果当前节点前面没有同级节点,则返回null

<div id="d1">hello</div><div id="d2">world</div>
var d1 = document.getElementById('d1');
var d2 = document.getElementById('d2');
d2.previousSibling === d1 // true

上面代码中,d2.previousSibling就是d2前面的同级节点d1。注意,该属性还包括文本节点和注释节点。因此如果当前节点前面有空格,该属性会返回一个文本节点,内容为空格

Node.prototype.parentNode

parentNode属性返回当前节点的父节点。对于一个节点来说,它的父节点只可能是三种类型:元素节点(element)、文档节点(document)和文档片段节点(documentfragment)

if (node.parentNode) { node.parentNode.removeChild(node); }

上面代码中,通过node.parentNode属性将node节点从文档里面移除;文档节点(document)和文档片段节点(documentfragment)的父节点都是null。另外,对于那些生成后还没插入 DOM 树的节点,父节点也是null

Node.prototype.parentElement

parentElement属性返回当前节点的父元素节点;如果当前节点没有父节点,或者父节点类型不是元素节点,则返回null

if (node.parentElement) { node.parentElement.style.color = 'red'; }

上面代码中,父元素节点的样式设定了红色;由于父节点只可能是三种类型:元素节点、文档节点(document)和文档片段节点(documentfragment);parentElement属性相当于把后两种父节点都排除了

Node.prototype.firstChild,Node.prototype.lastChild

firstChild属性返回当前节点的第一个子节点,如果当前节点没有子节点,则返回null

<p id="p1"><span>First span</span></p>
var p1 = document.getElementById('p1');
p1.firstChild.nodeName // "SPAN"

注意,firstChild返回的除了元素节点,还可能是文本节点或注释节点

<p id="p1"><span>First span</span></p>
var p1 = document.getElementById('p1');
p1.firstChild.nodeName // "#text"

lastChild属性返回当前节点的最后一个子节点,如果当前节点没有子节点,则返回null。用法与firstChild属性相同

Node.prototype.childNodes

childNodes属性返回一个类似数组的对象(NodeList集合),成员包括当前节点的所有子节点

var children = document.querySelector('ul').childNodes;

上面代码中,children就是ul元素的所有子节点;使用该属性,可以遍历某个节点的所有子节点

var div = document.getElementById('div1');
var children = div.childNodes;
for (var i = 0; i < children.length; i++) {
  // ...
}

文档节点(document)就有两个子节点:文档类型节点(docType)和 HTML 根元素节点

var children = document.childNodes;
for (var i = 0; i < children.length; i++) { console.log(children[i].nodeType); }
// 10
// 1

上面代码中,文档节点的第一个子节点的类型是10(即文档类型节点),第二个子节点的类型是1(即元素节点)

注意,除了元素节点,childNodes属性的返回值还包括文本节点和注释节点;如果当前节点不包括任何子节点,则返回一个空的NodeList集合。由于NodeList对象是一个动态集合,一旦子节点发生变化,立刻会反映在返回结果之中

Node.prototype.isConnected

isConnected属性返回一个布尔值,表示当前节点是否在文档之中

var test = document.createElement('p');
test.isConnected // false
document.body.appendChild(test);
test.isConnected // true

上面代码中,test节点是脚本生成的节点,没有插入文档之前,isConnected属性返回false,插入之后返回true

方法

Node.prototype.appendChild()

appendChild()方法接受一个节点对象作为参数,将其作为最后一个子节点,插入当前节点。该方法的返回值就是插入文档的子节点

var p = document.createElement('p');
document.body.appendChild(p);

上面代码新建一个

节点,将其插入document.body的尾部;如果参数节点是 DOM 已经存在的节点,appendChild()方法会将其从原来的位置,移动到新位置

var div = document.getElementById('myDiv');
document.body.appendChild(div);

上面代码中,插入的是一个已经存在的节点myDiv,结果就是该节点会从原来的位置,移动到document.body的尾部;如果appendChild()方法的参数是DocumentFragment节点,那么插入的是DocumentFragment的所有子节点,而不是DocumentFragment节点本身。返回值是一个空的DocumentFragment节点

Node.prototype.hasChildNodes()

hasChildNodes方法返回一个布尔值,表示当前节点是否有子节点

var foo = document.getElementById('foo');
if (foo.hasChildNodes()) { foo.removeChild(foo.childNodes[0]); }

上面代码表示,如果foo节点有子节点,就移除第一个子节点;注意,子节点包括所有类型的节点,并不仅仅是元素节点。哪怕节点只包含一个空格,hasChildNodes方法也会返回true。判断一个节点有没有子节点,有许多种方法,下面是其中的三种:

1.node.hasChildNodes()

2.node.firstChild !== null

3.node.childNodes && node.childNodes.length > 0

hasChildNodes方法结合firstChild属性和nextSibling属性,可以遍历当前节点的所有后代节点

function DOMComb(parent, callback) {
  if (parent.hasChildNodes()) {
    for (var node = parent.firstChild; node; node = node.nextSibling) { DOMComb(node, callback); }
  }
  callback(parent);
}
DOMComb(document.body, console.log) // 用法

上面代码中,DOMComb函数的第一个参数是某个指定的节点,第二个参数是回调函数。这个回调函数会依次作用于指定节点,以及指定节点的所有后代节点

Node.prototype.cloneNode()

cloneNode方法用于克隆一个节点。它接受一个布尔值作为参数,表示是否同时克隆子节点。它的返回值是一个克隆出来的新节点

var cloneUL = document.querySelector('ul').cloneNode(true);

该方法有一些使用注意点:

1.克隆一个节点,会拷贝该节点的所有属性,但是会丧失addEventListener方法和on-属性(即node.onclick = fn),添加在这个节点上的事件回调函数

2.该方法返回的节点不在文档之中,即没有任何父节点,必须使用诸如Node.appendChild这样的方法添加到文档之中。

3.克隆一个节点之后,DOM 有可能出现两个有相同id属性(即id="xxx")的网页元素,这时应该修改其中一个元素的id属性。如果原节点有name属性,可能也需要修改

Node.prototype.insertBefore()

insertBefore方法用于将某个节点插入父节点内部的指定位置

var insertedNode = parentNode.insertBefore(newNode, referenceNode);

insertBefore方法接受两个参数,第一个参数是所要插入的节点newNode,第二个参数是父节点parentNode内部的一个子节点referenceNode。newNode将插在referenceNode这个子节点的前面。返回值是插入的新节点newNode

var p = document.createElement('p');
document.body.insertBefore(p, document.body.firstChild);

上面代码中,新建一个

节点,插在document.body.firstChild的前面,也就是成为document.body的第一个子节点。如果insertBefore方法的第二个参数为null,则新节点将插在当前节点内部的最后位置,即变成最后一个子节点

var p = document.createElement('p');
document.body.insertBefore(p, null);

上面代码中,p将成为document.body的最后一个子节点。这也说明insertBefore的第二个参数不能省略。

注意,如果所要插入的节点是当前 DOM 现有的节点,则该节点将从原有的位置移除,插入新的位置。

由于不存在insertAfter方法,如果新节点要插在父节点的某个子节点后面,可以用insertBefore方法结合nextSibling属性模拟

parent.insertBefore(s1, s2.nextSibling);

上面代码中,parent是父节点,s1是一个全新的节点,s2是可以将s1节点,插在s2节点的后面。如果s2是当前节点的最后一个子节点,则s2.nextSibling返回null,这时s1节点会插在当前节点的最后,变成当前节点的最后一个子节点,等于紧跟在s2的后面。如果要插入的节点是DocumentFragment类型,那么插入的将是DocumentFragment的所有子节点,而不是DocumentFragment节点本身。返回值将是一个空的DocumentFragment节点

Node.prototype.removeChild()

removeChild方法接受一个子节点作为参数,用于从当前节点移除该子节点。返回值是移除的子节点

var divA = document.getElementById('A');
divA.parentNode.removeChild(divA);

上面代码移除了divA节点。注意,这个方法是在divA的父节点上调用的,不是在divA上调用的。下面是如何移除当前节点的所有子节点

var element = document.getElementById('top');
while (element.firstChild) { element.removeChild(element.firstChild); }

被移除的节点依然存在于内存之中,但不再是 DOM 的一部分。所以,一个节点移除以后,依然可以使用它,比如插入到另一个节点下面。如果参数节点不是当前节点的子节点,removeChild方法将报错

Node.prototype.replaceChild()

replaceChild方法用于将一个新的节点,替换当前节点的某一个子节点

var replacedNode = parentNode.replaceChild(newChild, oldChild);

上面代码中,replaceChild方法接受两个参数,第一个参数newChild是用来替换的新节点,第二个参数oldChild是将要替换走的子节点。返回值是替换走的那个节点oldChild

var divA = document.getElementById('divA');
var newSpan = document.createElement('span');
newSpan.textContent = 'Hello World!';
divA.parentNode.replaceChild(newSpan, divA);
Node.prototype.contains()

contains方法返回一个布尔值,表示参数节点是否满足以下三个条件之一:

1.参数节点为当前节点

2.参数节点为当前节点的子节点

3.参数节点为当前节点的后代节点

document.body.contains(node)

上面代码检查参数节点node,是否包含在当前文档之中

注意,当前节点传入contains方法,返回true

nodeA.contains(nodeA) // true
Node.prototype.compareDocumentPosition()

compareDocumentPosition方法的用法,与contains方法完全一致,返回一个六个比特位的二进制值,表示参数节点与当前节点的关系

二进制值十进制值含义0000000两个节点相同0000011两个节点不在同一个文档(即有一个节点不在当前文档)0000102参数节点在当前节点的前面0001004参数节点在当前节点的后面0010008参数节点包含当前节点01000016当前节点包含参数节点10000032浏览器内部使用
<div id="mydiv">
    <form><input id="test" /></form>
</div>
<script>
var div = document.getElementById('mydiv');
var input = document.getElementById('test');
div.compareDocumentPosition(input) // 20
input.compareDocumentPosition(div) // 10
</script>

上面代码中,节点div包含节点input(二进制010000),而且节点input在节点div的后面(二进制000100),所以第一个compareDocumentPosition方法返回20(二进制010100,即010000 + 000100),第二个compareDocumentPosition方法返回10(二进制001010)。由于compareDocumentPosition返回值的含义,定义在每一个比特位上,所以如果要检查某一种特定的含义,就需要使用比特位运算符

var head = document.head;
var body = document.body;
if (head.compareDocumentPosition(body) & 4) {
  console.log('文档结构正确');
} else {
  console.log('<body> 不能在 <head> 前面');
}

上面代码中,compareDocumentPosition的返回值与4(又称掩码)进行与运算(&),得到一个布尔值,表示

是否在前面
Node.prototype.isEqualNode(),Node.prototype.isSameNode()

isEqualNode方法返回一个布尔值,用于检查两个节点是否相等。所谓相等的节点,指的是两个节点的类型相同、属性相同、子节点相同

var p1 = document.createElement('p');
var p2 = document.createElement('p');
p1.isEqualNode(p2) // true

isSameNode方法返回一个布尔值,表示两个节点是否为同一个节点

var p1 = document.createElement('p');
var p2 = document.createElement('p');
p1.isSameNode(p2) // false
p1.isSameNode(p1) // true
Node.prototype.normalize()

normalize方法用于清理当前节点内部的所有文本节点(text)。它会去除空的文本节点,并且将毗邻的文本节点合并成一个,也就是说不存在空的文本节点,以及毗邻的文本节点

var wrapper = document.createElement('div');
wrapper.appendChild(document.createTextNode('Part 1 '));
wrapper.appendChild(document.createTextNode('Part 2 '));
wrapper.childNodes.length // 2
wrapper.normalize();
wrapper.childNodes.length // 1

上面代码使用normalize方法之前,wrapper节点有两个毗邻的文本子节点。使用normalize方法之后,两个文本子节点被合并成一个;该方法是Text.splitText的逆方法

Node.prototype.getRootNode()

getRootNode()方法返回当前节点所在文档的根节点document,与ownerDocument属性的作用相同

document.body.firstChild.getRootNode() === document // true
document.body.firstChild.getRootNode() === document.body.firstChild.ownerDocument // true

该方法可用于document节点自身,这一点与document.ownerDocument不同

document.getRootNode() // document
document.ownerDocument // null

NodeList 接口,HTMLCollection 接口

节点都是单个对象,有时需要一种数据结构,能够容纳多个节点。DOM 提供两种节点集合,用于容纳多个节点:NodeList和HTMLCollection。这两种集合都属于接口规范;许多 DOM 属性和方法,返回的结果是NodeList实例或HTMLCollection实例。主要区别是,NodeList可以包含各种类型的节点,HTMLCollection只能包含 HTML 元素节点

NodeList 接口

概述

NodeList实例是一个类似数组的对象,它的成员是节点对象。通过以下方法可以得到NodeList实例:

1.Node.childNodes

2.document.querySelectorAll()等节点搜索方法

document.body.childNodes instanceof NodeList // true

NodeList实例很像数组,可以使用length属性和forEach方法。但是,它不是数组,不能使用pop或push之类数组特有的方法

var children = document.body.childNodes;
Array.isArray(children) // false
children.length // 34
children.forEach(console.log)

上面代码中,NodeList 实例children不是数组,但是具有length属性和forEach方法。如果NodeList实例要使用数组方法,可以将其转为真正的数组

var children = document.body.childNodes;
var nodeArr = Array.prototype.slice.call(children);

除了使用forEach方法遍历 NodeList 实例,还可以使用for循环

var children = document.body.childNodes;
for (var i = 0; i < children.length; i++) { var item = children[i]; }

注意,NodeList 实例可能是动态集合,也可能是静态集合。所谓动态集合就是一个活的集合,DOM 删除或新增一个相关节点,都会立刻反映在 NodeList 实例。目前,只有Node.childNodes返回的是一个动态集合,其他的 NodeList 都是静态集合

var children = document.body.childNodes;
children.length // 18
document.body.appendChild(document.createElement('p'));
children.length // 19

上面代码中,文档增加一个子节点,NodeList 实例children的length属性就增加了1

NodeList.prototype.length

length属性返回 NodeList 实例包含的节点数量

document.querySelectorAll('xxx').length // 0

上面代码中,document.querySelectorAll返回一个 NodeList 集合。对于那些不存在的 HTML 标签,length属性返回0

NodeList.prototype.forEach()

forEach方法用于遍历 NodeList 的所有成员。它接受一个回调函数作为参数,每一轮遍历就执行一次这个回调函数,用法与数组实例的forEach方法完全一致

var children = document.body.childNodes;
children.forEach(function f(item, i, list) {
  // ...
}, this);

上面代码中,回调函数f的三个参数依次是当前成员、位置和当前 NodeList 实例。forEach方法的第二个参数,用于绑定回调函数内部的this,该参数可省略

NodeList.prototype.item()

item方法接受一个整数值作为参数,表示成员的位置,返回该位置上的成员

document.body.childNodes.item(0)

上面代码中,item(0)返回第一个成员;如果参数值大于实际长度,或者索引不合法(比如负数),item方法返回null;如果省略参数,item方法会报错;所有类似数组的对象,都可以使用方括号运算符取出成员;一般情况下,都是使用方括号运算符,而不使用item方法

document.body.childNodes[0]
NodeList.prototype.keys(),NodeList.prototype.values(),NodeList.prototype.entries()

这三个方法都返回一个 ES6 的遍历器对象,可以通过for...of循环遍历获取每一个成员的信息。区别在于,keys()返回键名的遍历器,values()返回键值的遍历器,entries()返回的遍历器同时包含键名和键值的信息

var children = document.body.childNodes;
for (var key of children.keys()) { console.log(key); }
// 0
// 1
// 2
// ...
for (var value of children.values()) { console.log(value); }
// #text
// <script>
// ...
for (var entry of children.entries()) { console.log(entry); }
// Array [ 0, #text ]
// Array [ 1, <script> ]
// ...

HTMLCollection 接口

概述

HTMLCollection是一个节点对象的集合,只能包含元素节点(element),不能包含其他类型的节点。它的返回值是一个类似数组的对象,但是与NodeList接口不同,HTMLCollection没有forEach方法,只能使用for循环遍历。返回HTMLCollection实例的,主要是一些Document对象的集合属性,比如document.links、document.forms、document.images等

document.links instanceof HTMLCollection // true

HTMLCollection实例都是动态集合,节点的变化会实时反映在集合中;如果元素节点有id或name属性,那么HTMLCollection实例上面,可以使用id属性或name属性引用该节点元素。如果没有对应的节点,则返回null

<img id="pic" src="http://example.com/foo.jpg">
var pic = document.getElementById('pic');
document.images.pic === pic // true

上面代码中,document.images是一个HTMLCollection实例,可以通过元素的id属性值,从HTMLCollection实例上取到这个元素

HTMLCollection.prototype.length

length属性返回HTMLCollection实例包含的成员数量

document.links.length // 18
HTMLCollection.prototype.item()

item方法接受一个整数值作为参数,表示成员的位置,返回该位置上的成员

var c = document.images;
var img0 = c.item(0);

上面代码中,item(0)表示返回0号位置的成员。由于方括号运算符也具有同样作用,而且使用更方便,所以一般情况下,总是使用方括号运算符;如果参数值超出成员数量或者不合法(比如小于0),那么item方法返回null

HTMLCollection.prototype.namedItem()

namedItem方法的参数是一个字符串,表示id属性或name属性的值,返回对应的元素节点。如果没有对应的节点,则返回null

<img id="pic" src="http://example.com/foo.jpg">
var pic = document.getElementById('pic');
document.images.namedItem('pic') === pic // true

ParentNode 接口,ChildNode 接口

节点对象除了继承 Node 接口以外,还会继承其他接口。ParentNode接口表示当前节点是一个父节点,提供一些处理子节点的方法;ChildNode接口表示当前节点是一个子节点,提供一些相关方法

ParentNode 接口

如果当前节点是父节点,就会继承ParentNode接口。由于只有元素节点(element)、文档节点(document)和文档片段节点(documentFragment)拥有子节点,因此只有这三类节点会继承ParentNode接口

ParentNode.children

children属性返回一个HTMLCollection实例,成员是当前节点的所有元素子节点,该属性只读;下面是遍历某个节点的所有元素子节点的示例

for (var i = 0; i < el.children.length; i++) {
  // ...
}

注意,children属性只包括元素子节点,不包括其他类型的子节点(比如文本子节点);如果没有元素类型的子节点,返回值HTMLCollection实例的length属性为0。另外,HTMLCollection是动态集合,会实时反映 DOM 的任何变化

ParentNode.firstElementChild

firstElementChild属性返回当前节点的第一个元素子节点;如果没有任何元素子节点,则返回null

document.firstElementChild.nodeName // "HTML"

上面代码中,document节点的第一个元素子节点是

ParentNode.lastElementChild

lastElementChild属性返回当前节点的最后一个元素子节点,如果不存在任何元素子节点,则返回null

document.lastElementChild.nodeName // "HTML"

上面代码中,document节点的最后一个元素子节点是(因为document只包含这一个元素子节点)

ParentNode.childElementCount

childElementCount属性返回一个整数,表示当前节点的所有元素子节点的数目。如果不包含任何元素子节点,则返回0

document.body.childElementCount // 13
ParentNode.append(),ParentNode.prepend()

append方法为当前节点追加一个或多个子节点,位置是最后一个元素子节点的后面;该方法不仅可以添加元素子节点,还可以添加文本子节点

var parent = document.body;
// 添加元素子节点
var p = document.createElement('p');
parent.append(p);
// 添加文本子节点
parent.append('Hello');
// 添加多个元素子节点
var p1 = document.createElement('p');
var p2 = document.createElement('p');
parent.append(p1, p2);
// 添加元素子节点和文本子节点
var p = document.createElement('p');
parent.append('Hello', p);

注意,该方法没有返回值;prepend方法为当前节点追加一个或多个子节点,位置是第一个元素子节点的前面;它的用法与append方法完全一致,也是没有返回值

ChildNode 接口

如果一个节点有父节点,那么该节点就继承了ChildNode接口

ChildNode.remove()

remove方法用于从父节点移除当前节点

el.remove()

上面代码在 DOM 里面移除了el节点

ChildNode.before(),ChildNode.after()

before方法用于在当前节点的前面,插入一个或多个同级节点,两者拥有相同的父节点;注意,该方法不仅可以插入元素节点,还可以插入文本节点

var p = document.createElement('p');
var p1 = document.createElement('p');
el.before(p); // 插入元素节点
el.before('Hello'); // 插入文本节点
el.before(p, p1); // 插入多个元素节点
el.before(p, 'Hello'); // 插入元素节点和文本节点

after方法用于在当前节点的后面,插入一个或多个同级节点,两者拥有相同的父节点。用法与before方法完全相同

ChildNode.replaceWith()

replaceWith方法使用参数节点,替换当前节点。参数可以是元素节点,也可以是文本节点

var span = document.createElement('span');
el.replaceWith(span);

上面代码中,el节点将被span节点替换

Document 节点

概述

document节点对象代表整个文档,每张网页都有自己的document对象,window.document属性就指向这个对象;只要浏览器开始载入 HTML 文档,该对象就存在了,可以直接使用。document对象有不同的办法可以获取:

1.正常的网页,直接使用document或window.document

2.iframe框架里面的网页,使用iframe节点的contentDocument属性

3.Ajax 操作返回的文档,使用XMLHttpRequest对象的responseXML属性

4.内部节点的ownerDocument属性

document对象继承了EventTarget接口、Node接口、ParentNode接口;这意味着这些接口的方法都可以在document对象上调用;除此之外,document对象还有很多自己的属性和方法

属性

快捷方式属性

以下属性是指向文档内部的某个节点的快捷方式

document.defaultView

document.defaultView属性返回document对象所属的window对象。如果当前文档不属于window对象,该属性返回null

document.defaultView === window // true
document.doctype

对于 HTML 文档来说,document对象一般有两个子节点;第一个子节点是document.doctype,指向节点,即文档类型(Document Type Declaration,简写DTD)节点;HTML 的文档类型节点一般写成,如果网页没有声明 DTD,该属性返回null

var doctype = document.doctype;
doctype // "<!DOCTYPE html>"
doctype.name // "html"

document.firstChild通常就返回这个节点

document.documentElement

document.documentElement属性返回当前文档的根元素节点(root);它通常是document节点的第二个子节点,紧跟在document.doctype节点后面;HTML网页的该属性,一般是节点

document.body,document.head

document.body属性指向

节点,document.head属性指向节点;这两个属性总是存在的,如果网页源码里面省略了或,浏览器会自动创建;另外,这两个属性是可写的,如果改写它们的值,相当于移除所有子节点
document.scrollingElement

document.scrollingElement属性返回文档的滚动元素。也就是说,当文档整体滚动时,到底是哪个元素在滚动;标准模式下,这个属性返回的文档的根元素document.documentElement(即)。兼容(quirk)模式下,返回的是

元素,如果该元素不存在,返回null
document.scrollingElement.scrollTop = 0; // 页面滚动到浏览器顶部
document.activeElement

document.activeElement属性返回获得当前焦点(focus)的 DOM 元素。通常,这个属性返回的是、、等表单元素;如果当前没有焦点元素,返回元素或null

document.fullscreenElement

document.fullscreenElement属性返回当前以全屏状态展示的 DOM 元素。如果不是全屏状态,该属性返回null

if (document.fullscreenElement.nodeName == 'VIDEO') { console.log('全屏播放视频'); }

上面代码中,通过document.fullscreenElement可以知道

元素有没有处在全屏状态,从而判断用户行为
节点集合属性

以下属性返回一个HTMLCollection实例,表示文档内部特定元素的集合;这些集合都是动态的,原节点有任何变化,立刻会反映在集合中

document.links

document.links属性返回当前文档所有设定了href属性的

// 打印文档所有的链接
var links = document.links;
for(var i = 0; i < links.length; i++) {
  console.log(links[i]);
}
document.forms

document.forms属性返回所有

表单节点
var selectForm = document.forms[0];

上面代码获取文档第一个表单;除了使用位置序号,id属性和name属性也可以用来引用表单

<form name="foo" id="bar"></form>
document.forms[0] === document.forms.foo // true
document.forms.bar === document.forms.foo // true
document.images

document.images属性返回页面所有图片节点

var imglist = document.images;
for(var i = 0; i < imglist.length; i++) {
  if (imglist[i].src === 'banner.gif') {
    // ...
  }
}

上面代码在所有img标签中,寻找某张图片

document.embeds,document.plugins

document.embeds属性和document.plugins属性,都返回所有节点

document.scripts

document.scripts属性返回所有

var scripts = document.scripts;
if (scripts.length !== 0 ) { console.log('当前网页有脚本'); }
document.styleSheets

document.styleSheets属性返回文档内嵌或引入的样式表集合

小结

除了document.styleSheets,以上的集合属性返回的都是HTMLCollection实例

document.links instanceof HTMLCollection // true
document.images instanceof HTMLCollection // true
document.forms instanceof HTMLCollection // true
document.embeds instanceof HTMLCollection // true
document.scripts instanceof HTMLCollection // true

HTMLCollection实例是类似数组的对象,所以这些属性都有length属性,都可以使用方括号运算符引用成员。如果成员有id或name属性,还可以用这两个属性的值,在HTMLCollection实例上引用到这个成员

<form name="myForm">
document.myForm === document.forms.myForm // true
文档静态信息属性

以下属性返回文档信息

document.documentURI,document.URL

document.documentURI属性和document.URL属性都返回一个字符串,表示当前文档的网址;不同之处是它们继承自不同的接口,documentURI继承自Document接口,可用于所有文档;URL继承自HTMLDocument接口,只能用于 HTML 文档

document.URL // http://www.example.com/about
document.documentURI === document.URL // true

如果文档的锚点(#anchor)变化,这两个属性都会跟着变化

document.domain

document.domain属性返回当前文档的域名,不包含协议和接口;比如,网页的网址是,那么domain属性就等于www.example.com。如果无法获取域名,该属性返回null;document.domain基本上是一个只读属性,只有一种情况除外;次级域名的网页,可以把document.domain设为对应的上级域名。比如,当前域名是a.sub.example.com,则document.domain属性可以设置为sub.example.com,也可以设为example.com。修改后,document.domain相同的两个网页,可以读取对方的资源,比如设置的 Cookie。

另外,设置document.domain会导致端口被改成null。因此,如果通过设置document.domain来进行通信,双方网页都必须设置这个值,才能保证端口相同

document.location

Location对象是浏览器提供的原生对象,提供 URL 相关的信息和操作方法。通过window.location和document.location属性,可以拿到这个对象

document.lastModified

document.lastModified属性返回一个字符串,表示当前文档最后修改的时间。不同浏览器的返回值,日期格式是不一样的

document.lastModified // "04/10/2019 12:17:01"

注意:document.lastModified属性的值是字符串,所以不能直接用来比较;Date.parse方法将其转为Date实例,才能比较两个网页

var lastVisitedDate = Date.parse('01/01/2018');
if (Date.parse(document.lastModified) > lastVisitedDate) { console.log('网页已经变更'); }
document.characterSet

document.characterSet属性返回当前文档的编码,比如UTF-8、ISO-8859-1等

document.referrer

document.referrer属性返回一个字符串,表示当前文档的访问者来自哪里

document.referrer // "https://example.com/path"

如果无法获取来源,或者用户直接键入网址而不是从其他网页点击进入,document.referrer返回一个空字符串;document.referrer的值,总是与 HTTP 头信息的Referer字段保持一致。但是,document.referrer的拼写有两个r,而头信息的Referer字段只有一个r

document.dir

document.dir返回一个字符串,表示文字方向。它只有两个可能的值:rtl表示文字从右到左,阿拉伯文是这种方式;ltr表示文字从左到右,包括英语和汉语在内的大多数文字采用这种方式

document.compatMode

compatMode属性返回浏览器处理文档的模式,可能的值为BackCompat(向后兼容模式)和CSS1Compat(严格模式)。一般来说,如果网页代码的第一行设置了明确的DOCTYPE(比如),document.compatMode的值都为CSS1Compat

文档状态属性
document.hidden

document.hidden属性返回一个布尔值,表示当前页面是否可见;如果窗口最小化、浏览器切换了 Tab,都会导致页面不可见,使得document.hidden返回true。这个属性是 Page Visibility API 引入的,一般都是配合这个 API 使用

document.visibilityState

document.visibilityState返回文档的可见状态;它的值有四种可能:

1.visible:页面可见。注意,页面可能是部分可见,即不是焦点窗口,前面被其他窗口部分挡住了

2.hidden:页面不可见,有可能窗口最小化,或者浏览器切换到了另一个 Tab

3.prerender:页面处于正在渲染状态,对于用户来说,该页面不可见

4.unloaded:页面从内存里面卸载了

这个属性可以用在页面加载时,防止加载某些资源;或者页面不可见时停掉一些页面功能

document.readyState

document.readyState属性返回当前文档的状态,共有三种可能的值:

1.loading:加载 HTML 代码阶段(尚未完成解析)

2.interactive:加载外部资源阶段

3.complete:加载完成

这个属性变化的过程如下:

1.浏览器开始解析 HTML 文档,document.readyState属性等于loading

2.浏览器遇到 HTML 文档中的

3.HTML 文档解析完成,document.readyState属性变成interactive

4.浏览器等待图片、样式表、字体文件等外部资源加载完成;一旦全部加载完成,document.readyState属性变成complete

下面的代码用来检查网页是否加载成功

if (document.readyState === 'complete') { // 基本检查
  // ...
}
var interval = setInterval(function() { // 轮询检查
  if (document.readyState === 'complete') {
    clearInterval(interval);
    // ...
  }
}, 100);

另外,每次状态变化都会触发一个readystatechange事件

document.cookie

document.cookie属性用来操作浏览器 Cookie

document.designMode

document.designMode属性控制当前文档是否可编辑。该属性只有两个值on和off,默认值为off。一旦设为on,用户就可以编辑整个文档的内容;

document.implementation

document.implementation属性返回一个DOMImplementation对象。该对象有三个方法,主要用于创建独立于当前文档的新的 Document 对象;

1.DOMImplementation.createDocument():创建一个 XML 文档

2.DOMImplementation.createHTMLDocument():创建一个 HTML 文档

3.DOMImplementation.createDocumentType():创建一个 DocumentType 对象

下面是创建 HTML 文档的例子

var doc = document.implementation.createHTMLDocument('Title');
var p = doc.createElement('p');
p.innerHTML = 'hello world';
doc.body.appendChild(p);
document.replaceChild(
  doc.documentElement,
  document.documentElement
);

上面代码中,第一步生成一个新的 HTML 文档doc,然后用它的根元素document.documentElement替换掉document.documentElement;这会使得当前文档的内容全部消失,变成hello world

方法

document.open(),document.close()

document.open方法清除当前文档所有内容,使得文档处于可写状态,供document.write方法写入内容;document.close方法用来关闭document.open()打开的文档

document.open();
document.write('hello world');
document.close();
document.write(),document.writeln()

document.write方法用于向当前文档写入内容;在网页的首次渲染阶段,只要页面没有关闭写入(即没有执行document.close()),document.write写入的内容就会追加在已有内容的后面

// 页面显示“helloworld”
document.open();
document.write('hello');
document.write('world');
document.close();

注意,document.write会当作 HTML 代码解析,不会转义

document.write('<p>hello world</p>');

上面代码中,document.write会将

当作 HTML 标签解释;如果页面已经解析完成(DOMContentLoaded事件发生之后),再调用write方法,它会先调用open方法,擦除当前文档所有内容,然后再写入

document.addEventListener('DOMContentLoaded', function (event) { document.write('<p>Hello World!</p>'); });
// 等同于
document.addEventListener('DOMContentLoaded', function (event) {
  document.open();
  document.write('<p>Hello World!</p>');
  document.close();
});

如果在页面渲染过程中调用write方法,并不会自动调用open方法。(可以理解成,open方法已调用,但close方法还未调用。)

<html><body>
hello
<script type="text/javascript">
  document.write("world")
</script>
</body></html>

在浏览器打开上面网页,将会显示hello world;document.write是 JavaScript 语言标准化之前就存在的方法,现在完全有更符合标准的方法向文档写入内容(比如对innerHTML属性赋值);所以,除了某些特殊情况,应该尽量避免使用document.write这个方法;document.writeln方法与write方法完全一致,除了会在输出内容的尾部添加换行符

document.write(1);
document.write(2);
// 12
document.writeln(1);
document.writeln(2);
// 1
// 2

注意,writeln方法添加的是 ASCII 码的换行符,渲染成 HTML 网页时不起作用,即在网页上显示不出换行。网页上的换行,必须显式写入

document.querySelector(),document.querySelectorAll()

document.querySelector方法接受一个 CSS 选择器作为参数,返回匹配该选择器的元素节点;如果有多个节点满足匹配条件,则返回第一个匹配的节点;如果没有发现匹配的节点,则返回null

var el1 = document.querySelector('.myclass');
var el2 = document.querySelector('#myParent > [ng-click]');

document.querySelectorAll方法与querySelector用法类似,区别是返回一个NodeList对象,包含所有匹配给定选择器的节点

elementList = document.querySelectorAll('.myclass');

这两个方法的参数,可以是逗号分隔的多个 CSS 选择器,返回匹配其中一个选择器的元素节点,这与 CSS 选择器的规则是一致的

var matches = document.querySelectorAll('div.note, div.alert');

上面代码返回class属性是note或alert的div元素;这两个方法都支持复杂的 CSS 选择器

document.querySelectorAll('[data-foo-bar="someval"]'); // 选中 data-foo-bar 属性等于 someval 的元素
document.querySelectorAll('#myForm :invalid'); // 选中 myForm 表单中所有不通过验证的元素
document.querySelectorAll('DIV:not(.ignore)'); // 选中div元素,那些 class 含 ignore 的除外
document.querySelectorAll('DIV, A, SCRIPT'); // 同时选中 div,a,script 三类元素

但是,它们不支持 CSS 伪元素的选择器(比如:first-line和:first-letter)和伪类的选择器(比如:link和:visited),即无法选中伪元素和伪类;如果querySelectorAll方法的参数是字符串*,则会返回文档中的所有元素节点;另外,querySelectorAll的返回结果不是动态集合,不会实时反映元素节点的变化;最后,这两个方法除了定义在document对象上,还定义在元素节点上,即在元素节点上也可以调用

document.getElementsByTagName()

document.getElementsByTagName方法搜索 HTML 标签名,返回符合条件的元素。它的返回值是一个类似数组对象(HTMLCollection实例),可以实时反映 HTML 文档的变化。如果没有任何匹配的元素,就返回一个空集

var paras = document.getElementsByTagName('p');
paras instanceof HTMLCollection // true

上面代码返回当前文档的所有p元素节点;HTML 标签名是大小写不敏感的,因此getElementsByTagName方法也是大小写不敏感的。另外,返回结果中,各个成员的顺序就是它们在文档中出现的顺序;如果传入*,就可以返回文档中所有 HTML 元素

var allElements = document.getElementsByTagName('*');

注意,元素节点本身也定义了getElementsByTagName方法,返回该元素的后代元素中符合条件的元素。也就是说,这个方法不仅可以在document对象上调用,也可以在任何元素节点上调用

var firstPara = document.getElementsByTagName('p')[0];
var spans = firstPara.getElementsByTagName('span');

上面代码选中第一个p元素内部的所有span元素

document.getElementsByClassName()

document.getElementsByClassName方法返回一个类似数组的对象(HTMLCollection实例),包括了所有class名字符合指定条件的元素,元素的变化实时反映在返回结果中

var elements = document.getElementsByClassName(names);

由于class是保留字,所以 JavaScript 一律使用className表示 CSS 的class;参数可以是多个class,它们之间使用空格分隔

var elements = document.getElementsByClassName('foo bar');

上面代码返回同时具有foo和bar两个class的元素,foo和bar的顺序不重要。;注意,正常模式下,CSS 的class是大小写敏感的;(quirks mode下,大小写不敏感。)

与getElementsByTagName方法一样,getElementsByClassName方法不仅可以在document对象上调用,也可以在任何元素节点上调用

// 非document对象上调用
var elements = rootElement.getElementsByClassName(names);
document.getElementsByName()

document.getElementsByName方法用于选择拥有name属性的 HTML 元素(比如

、、、、和
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值