Javascript学习笔记(四)

导论

Javascript是一门面向对象的编程语言,面向对象的思想贯穿整个Javascript语言,所以了解对象是什么是很重要的。

面向对象编程(Object Oriented Programming,缩写为OOP)是将真实世界中的各种复杂的关系,抽象为一个个的对象,然后由对象之间的分工合作完成对真实世界的模拟。

每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。对象可以复用,通过继承机制还可以定制。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,容易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。

对象是单个实物的抽象

可以将对象想象为一个人、一辆车、一个网页、一个数据库等等,当实物被抽象为对象后,实物之间的关系就变成了对象和对象之间的关系,从而就可以模拟现实的情况,针对对象进行编程。

而对象也是一个容器,封装了各种属性、方法用于处理不同的事情,设置不同的属性,可以将这些属性想象为人的身高、体重,皮肤颜色,而方法则可以想象人的技能,如开车、打篮球、绘画等。

而继承,你可以想象为孩子,孩子会遗传一部分父亲母亲的颜色、单眼皮等,而在对象中也是如此,继承对象会继承一些父对象的方法、属性。

构造函数

面向对象的第一步就是生成对象,对象是一个个实物的单个抽象,而生成一个对象实例则需要一个模板来表示一类对象,比如人、猪、猫等都是单独的模版,在Javascript,这个模板就是构造函数,而继承则是原型。

典型的面相对象编程的语言C++和Java等,都有类的概念,所谓的类就是对象一个模版,对象就是类的实例,但是在Javascript中的对象不是基于类,而是基于构造函数(constrcutor)和原形(prototype)。

Javascript语言中的构造函数则是对象的模板,而构造函数则是专门用来生成实例对象的函数,而每个构造函数可以生成多个对象实例,而这个构造函数则定义了这一类对象的基础属性和方法。

构造函数和普通函数定义没有什么区别,包括函数名也同样遵守表示符的定义,所以为了区分构造函数与普通函数的区别,所以一般构造函数的一个字母都是大写,即普通函数user,而构造函数为User

构造函数还有两个特征:

  • 构造函数内部使用了this关键字,用来代表将要生成的对象实例
  • 生成对象的时候,必须使用new操作符,如果没有new操作符就是普通的函数调用

下面定义了一个很简单的构造函数,名为Js

function Js () {
    this.name = 'Javascript';
}

new 操作符

new操作符的基本作用是执行一个构造函数,并返回一个对象实例,构造函数同时也可以像普通函数一样,接受参数。

function User (name, age) {
    this.name = name;
    this.age = age;
}

var tom = new User('tom', 18);

tom.name    //'tom'
tom.age     //18

new操作符本身带有执行构造函数的功能,所以在生成实例的构造函数如果没有参数也可以不带括号,但是建议构造函数带有括号,因为构造函数本身也可以当一个普通函数使用,而根据作用来区分则可以用new操作符

function User () {
    this.name = 'js';
}

var tom = new User();
//等同
var tom = new User;

构造函数不使用new操作符也是可以执行的,而内部的this是默认指向全局对象的,在浏览器中则指向的是window对象,所以一定要避免这样的情况,因为很有可能覆盖全局对象中其他变量

window.name = 'tom';

function User () {
    this.name = 'js';
}

User();

window.name //'js'

为了避免上面的情况发生,一般有两种办法来解决,第一种是在内部使用严格模式,即在函数代码块的最开始加上use strict,因为在严格模式中,函数内部的this不能指向全局对象,所以当没有使用new操作符来让this指向新的实例的话,将会报错

function User () {
    'use strict';
    this.name = 'js';
}

User()
//Uncaught TypeError: Cannot set property 'name' of undefined

还有一种办法就是在内部检查是否使用了new操作符,可以通过检查this对象是否指向构造函数自身,如果没有则添加new操作符返回一个实例,如果使用了则直接返回实例,因为根据new操作符的原理,新的实例的原型是指向构造函数的prototype对象,所以我们可以通过下面的方法来实现。

function User () {
    if (!(this instanceof arguments.callee)) {
        return new arguments.callee();
    }
    this.name = 'js';
}

User();
//等同
new User();

new操作符原理

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

  1. 创建一个空对象,作为将要返回的对象实例。
  2. 将这个空对象的原型,指向构造函数的prototype属性。
  3. 将这个空对象赋值给函数内部的this关键字。
  4. 开始执行构造函数内部的代码。

从上面能够看出来,实际上构造函数内部this操作都是操作的空对象,而构造函数之所以叫构造函数,是因为构造函数通过操作一个空对象,来构造为想要的对象。

在构造函数中return语句会引发构造函数的行为变化,如果return语句后面跟着的是任何的基本类型值,构造函数将会忽略return语句

function User () {
    this.name = 'js';
    return 1;
}

(new User()) === 1  //false

但如果return语句后面跟着的是任何的对象类型的值,那么构造函数会直接返回return语句后面这个对象类型的值

function User () {
    this.name = 'js';
    return {name: 'rust'};
}

(new User()).name === 'rust'    //true

//包括其他对象类型的值

function User () {
    this.name = 'js';
    return ['rust', 'c++'];
}

(new User())[0] //"rust"
(new User())[1] //"c++"

普通函数(即没有this对象的操作)同样可以使用new操作符,因为根据new操作符的步骤,普通函数和构造函数都是一样的处理方式,所以普通函数的操作步骤是:

  1. 创建一个空对象,作为将要返回的对象实例。
  2. 将这个空对象的原型,指向构造函数的prototype属性。
  3. 将这个空对象赋值给函数内部的this关键字,因为普通函数没有this对象,所以这一步没有任何效果
  4. 开始执行构造函数内部的代码。
  5. 如果普通函数内部的return语句后面是基本值,则直接返回空对象,如果return语句后面是一个对象则会返回这个对象
function user () {
    console.log(1);
    return undefined;
}

var a = new user(); //{}
a.__proto__ === user.prototype  //true

从上面看到其实new操作符将普通函数和构造函数一视同仁,而关键的在于内部去怎么操作这个新实例。

new.target

函数内部可以使用new.target属性,该方式主要用于如果使用了new操作符,那么这属性将会指向当前函数,但是如果没有使用new操作符,那么将会返回undefined

function User () {
    console.log(new.target === User);
}

User()  //false
new User()  //true

Object.create方法

Object.create方法将生成一个新的对象,并将传入的参数对象设置为新对象的原型,当设置了过后,实际上参数对象的静态属性方法就变为新对象的继承属性和方法

var a = {
    b: 1,
    c: 2
}

a.prototype.language = 'js';

var b = Object.create(a);
b.b //1
b.c //2

this关键字

this关键字除了在上面所说的构造函数中指向新的空对象之外,this关键字还可以在很多地方,但不管在什么地方使用,this关键字返回的总是一个对象,并且这个对象是当前方法或属性所在的对象。

var a = {
    name: 'javascript',
    getName: function getName() {
        return this.name;
    }
}

a.getName() //javascript

比如上面代码中的getName方法,该方法中的this对象即表示了getName方法所在的对象,即a对象,所以this即等于a,而this.name等于a.name

从上面看来this对象总是可变的,因为this总是指向当前方法、属性所在的对象

var a = {
    name: 'javascript',
    getName: function getName() {
        return this.name;
    }
}

var b = {
    name: 'rust',
    getName: a.getName
}

b.getName() //"rust"

上面的代码中a.getName方法赋值给b.getName,所以当在用b调用getName方法的时候,this指向的是调用getName方法所在的对象,即对象b

稍稍重构这个例子,this的动态指向就能看得更清楚。

function f() {
  return '姓名:'+ this.name;
}

var A = {
  name: '张三',
  describe: f
};

var B = {
  name: '李四',
  describe: f
};

A.describe() // "姓名:张三"
B.describe() // "姓名:李四"

只要this所在的调用对象不同,而this指向的对象也不同,比如下面的代码,创建了一个全局对象上的name属性,那么调用这个函数也是在全局对象上调用的,所以能够取到这个值

function getName () {
    return this.name;
}

var name = 'javascript';

getName()   //'javascript'

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

this的原理

Javascript中之所以有的this设计,是因为跟内存里面存放的数据结构有关系

var a = {a: 1}

比如上面的代码,首先Javascript引擎会生成一个对象,并将放在内存中的一个地方,内存地址比如为0x0001,然后会将这个内存地址赋值给变量a,所以实际上a变量存放的是一个内存地址,即0x0001,而当调用这个对象的时候,实际上是从a变量中拿出内存地址,然后再去访问这个内存地址中的数据。

而对象中每一个属性和方法内部都存储一个描述对象,而所有的属性和方法的值都是保存在描述对象的[[value]]属性中的,所以当调用一个属性的时候,实际上返回的是该属性的描述对象中的[[value]]属性的值。

{
    a: {
        [[value]]: 1,
        [[writable]]: true,
        [[enumerable]]: true,
        [[configurable]]: true
    }
}

但是我们知道函数也是一个对象,那么利用上面的思维方式,如果一个属性是一个函数的话(即方法),那么描述对象中的[[value]]存储的也是一个内存地址,比如0x0020

{
    a: {
        [[value]]: 0x0020,
        [[writable]]: true,
        [[enumerable]]: true,
        [[configurable]]: true
    }
}

那么当我去调用这个方法的时候,实际步骤是先从变量中取出该对象的内存地址0x0001,然后在找到描述对象的[[value]]属性的值,但是该值同样为一个内存地址0x0020,然后javascript引擎又会去这个0x0020内存地址中取到这个函数,并返回。

由于这个函数是单独存放的,所以如果知道这个内存地址,谁都可以执行它的

var a = {
    get: function () {
        return '1';
    }
}

var b = {
    get: a.get
}

a.get();    //1
b.get();    //1

那么这里就出来了一个问题,由于函数可以在不同的运行环境中执行,那么执行该函数的是谁?该函数的上下文(context)是什么?所以需要一种机制能够让函数体内部获取当前的运行环境上下文(context)。所以就设计了this,而this代表的就是指向当前运行环境的对象。

比如下面的代码,将一个带有this对象的函数,赋值给不同的对象,回返回不同的值,因为执行该函数的上下文不同,那么this指向的也是不同的对象

function a () {
    return this.name;
}

var b = {
    name: 'c++',
    get: a
}

var c = {
    name: 'rust',
    get: a
}

var name = 'javascript';    //创建一个全局对象变量

//全局对象下执行函数,this指向全局对象
a() //javascript

//在对象b下执行函数,this指向对象b
b.get() //c++

//在对象c下执行函数,this指向对象c
c.get() //rust

this的使用场景

this的使用场景有以下的三种场景:

(1)全局环境

全局环境下使用this,那么就是全局环境中,浏览器中的全局环境指的就是window,所以不管是不是函数内部,只要是在全局环境下运行、调用,this都是指向的全局环境

function a () {
    return this.name;
}

var name = 'js'

a() //js

(2)构造函数

构造函数中的this对象指向的是新的空实例

function User () {
    this.name = 123;
}

var a = new User();
a.name  //123

(3)对象的方法

对象中的方法里面包含了this,那么这个this则是指向当时运行这个方法的对象,而如果将方法赋值给另外的变量、属性就会改变this的指向

function a () {
    return this.name;
}

var b = {
    name: 'c++',
    get: a
}

var c = {
    name: 'rust',
    get: a
}

var name = 'javascript';    //创建一个全局对象变量

//全局对象下执行函数,this指向全局对象
a() //javascript

//在对象b下执行函数,this指向对象b
b.get() //c++

//在对象c下执行函数,this指向对象c
c.get() //rust

但是需要注意的是,下面的调用可能会引起this对象的变化

var a = {
    name: 'rust',
    getName: function () {
        return this.name
    }
};

var name = 'js';

(a.getName = a.getName)();  //js
(false || a.getName)(); //js
(1, a.getName)();   //js

上面三种方法调用都是导致了this指向的全局变量,这是因为a.getName是存放的一个内存地址,而如果不调用只是读取,则会将函数放回,然后调用时上下文就成了全局环境。

比如第一个将a.getName赋值为a.getName,实际上是将内存地址中的这个函数赋值,赋值后会将赋值的值返回,然后返回的是一个函数,所以后面的一对括号就立即在全局环境中执行了,所以指向全局环境的name

第二个是因为或运算当第一个值为false的时候,会直接返回第二个的值,而第二个的值是一个函数,所以当这个函数返回就在全局环境中执行

第三个是通过逗号运算符,逗号运算符的规则是对多个值求值,并返回最后一个值,然后返回后在全局环境中立即执行。

还需要注意的是多层对象可能导致this指向的对象不同,因为this对象指向的总是调用它的对象,所以如果对象a里面嵌套了一个对象b,然后b里面的getName方法里调用了this对象的name属性,那么此时的this指向的是b,因为是b这个对象在调用。

var a = {
    name: 'rust',
    b: {
        name: 'js',
        getName: function () {
            return this.name;
        }
    }
};

a.b.getName()   //js

避免多层this

由于this指向的对象是不固定的,所以不要过多的在多层中包含this

var a = {
    a1 : function () {
        console.log(this);
        var b = function () {
            console.log(this);
        }()
    }
};

a.a1()
//a
//window

上面的第二层的this指向的全局环境,这是因为第二个是普通的函数定义,所以该函数会先得到函数提升,则会被先定义,然后定义的环境中是全局环境,所以此时的this指向的是全局环境,然后当执行到调用该函数的位置,此时的环境还是一个函数,所以就没有对象调用,也就不会改变该函数内部的this指向,所以最后得到的就是全局环境window

而上面的代码实际上等同下面的代码

var temp = function () {
    console.log(this);
};

var a = {
    a1 : function () {
        console.log(this);
        var b = temp();
    }
};

将上面的代码改成下面这样,就可以避免上面的情况,下面的代码中采用了将this对象赋值给that局部变量,然后第二层直接引用的that变量,这样的情况是非常常见的。

var a = {
    a1 : function () {
        console.log(this);
        var that = this;
        var b = function () {
            console.log(that);
        }()
    }
};

a.a1()
//a
//a

避免在数组处理方法中使用this

数组的mapforEach方法中应当避免使用this

var a = {
    name: ['rust', 'javascript'],
    version: '1.0',
    print: function () {
        this.name.forEach( function (item) {
            console.log(item + ':' + this.version);
        })
    }
};
//rust:undefined
//javascript:undefined

上面的代码出现的问题和上一小节的问题一致,这是因为第二个是普通的函数定义,所以该函数会先得到函数提升,则会被先定义,然后定义的环境中是全局环境,所以此时的this指向的是全局环境,然后当执行到调用该函数的位置,此时的环境还是一个函数,所以就没有对象调用,也就不会改变该函数内部的this指向,所以最后得到的就是全局环境window

解决这样的问题有两个,第一个是绑定this,而第二个则是将this传入forEach方法

print: function () {
    var that = this;
    this.name.forEach( function (item) {
        console.log(item + ':' + that.version);
    })
}


print: function () {
    this.name.forEach( function (item) {
        console.log(item + ':' + this.version);
    }, this)
}

避免在回调函数中使用this

在回调函数中使用this是一个非常危险的操作,因为this总是再变化的

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

$('#button').on('click', o.f);

上面用o.f方法当作按钮事件的回调函数,但最后实际上是这个按钮对象是这个o.f方法中的this对象

绑定this的方法

this的动态变化,给这门语言带来了很大的灵活性,但是也增加了很多麻烦,所以Javascript提供了三个用户绑定this的方法,这三个方法可以来绑定this的指向,它们分别为callapplybind,这三个方法都是属于实例方法,位于Function.prototype对象上面,下面来分别介绍它们。

call方法

call方法可以指定一个函数在一个指定的环境中运行该函数

var a = {
    name : 'javascript'
};

var name = 'rust';  //全局环境定义了一个name属性

function getName () {
    return this.name;
}

getName()   //rust 默认为全局环境
getName.call(a) //javascript 指定对象a为执行环境

call方法如果接受的对象为空或者nullundefined,那么将会默认指向全局环境。

var a = {
    name : 'javascript'
};

var name = 'rust';  //全局环境定义了一个name属性

function getName () {
    return this.name;
}

getName.call()  //rust
getName.call(undefined) //rust
getName.call(null)  //rust

如果传入的是一个原始值,那么原始值将会被包装成对象传入

function getName () {
    return this;
}

getName.call(123)
//Number {123}

call方法除了第一个参数之外,后面所有的参数都为函数传入的参数,即

a.call(obj, 参数1, 参数2, 参数3, 参数4, ....)

apply方法

apply方法和call方法的行为一致,除了传入的参数不同之外,没有任何的区别,apply传入的参数是为一个数组,数组中的每个成员则为需要传入函数的参数,如果apply方法的第一个参数是nullundefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(浏览器为window)。

a.call(obj, [参数1, 参数2, 参数3, 参数4, ....])

利用这一点,可以做一些有趣的应用。

(1)找出数组最大元素

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

var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15

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

bind方法

bind方法用于将函数的执行环境绑定为指定的执行环境,并且后面的参数为需要传入给函数的参数,并且该方法返回一个新的函数,这里需要特别注意的是,bind方法返回的是一个新函数,而不是callapply方法那样改变了执行环境后立即就执行了。

var a = {
    name : 'javascript'
};

function b () {
    return this.name;
}

var c = b.bind(a);  //只绑定,不会立即执行

c() //"javascript"

上面的代码中很能够清楚的看出来bind方法返回的一个已经绑定指定this的函数,而何时执行这个函数是自由决定。

除此第一个为this的绑定对象之外,后面的每个值都是该函数的参数,即

a.bind(obj, 参数1, 参数2, 参数3, 参数4, ....)

并且该方法还有一个用处,如固定参数,比如一个函数需要两个参数,而在某个情景下,你只需要用户输入其中第二个参数,那么就可以使用bind方法,再使用bind的时候先输入一个参数,然后再调用的时候输入的参数会依次添加。

比如下面的代码bind的时候已经固定了第一个参数,而第二个参数则是调用这个bind方法返回的函数中继续输入。

function getName (age, name) {
    return name + ':' + age;
}

var a = getName.bind({}, 18);
a('javascript') //"javascript:18"

如果bind方法的第一个参数是nullundefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(浏览器为window)。

使用bind方法需要有几点注意的情况

**(1)每一个返回的都是一个新函数 **

bind方法每次返回的都是一个匿名函数,如果没有将匿名函数赋值给变量,然后又去监听事件,那么会导致每次发生事件的时候都产生一个新的处理函数,并且也无法取消绑定

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

正确的做法应该是

var clickListener = o.m.bind(o);
element.addEventListener('click', clickListener);

**(2)结合回调函数使用 **

回调函数是Javascript语言很常见的模式,但有一个常见的错误就是将this的方法直接当中回调函数使用,比如下面的错误用法:

var a = {
    count : 1,
    inc : function () {
        this.count++;
    }
}

function callInc (callback) {
    callback();
}

callInc(a.inc)
a.count //1 自增失败

上面的代码因为把a.inc方法直接传递给callInc函数,那么此时该函数的运行环境为全局环境,把上面的方式用bind改变一样,就可以正常的使用了

var a = {
    count : 1,
    inc : function () {
        this.count++;
    }
}

function callInc (callback) {
    callback();
}

callInc(a.inc.bind(a))
a.count //2

参考链接

Object.create》 – MDN

实例对象与new命令》- 阮一峰

导论

Javascript引擎在运行时或者解析时,如果发生了错误,就会抛出一个错误对象,而这个对象就是Error对象,该对象是Javascript原生对象,同时也是一个构造函数,Javascript的所有错误都是该对象的实例

var a = new Error('发生错误');
a.message
//"发生错误"

而ECMAScript语言只定义了该对象可以接受一个参数,为错误消息,并且必须具备message属性,该属性也等于参入的错误消息,对于其他没有做过多的规定。

而其他大多数的Javascript引擎,对于该对象的实现基本都包括了下面的三种:

  • message:错误消息
  • name:错误名称(非标准)
  • stack:错误的堆栈(非标准)

而使用namemessage可以对错误有一个大概的了解

var a = new Error('发生错误');

a.name + '   ' + a.stack
//Error   Error: 发生错误

原生的错误类型

Error对象是所有错误类型的基础,在Error基础上Javascript还定义了其他6种错误对象,包含Error一共为7种错误对象,每个错误类型与Error对象使用方法都是一致的

SyntaxError对象

SyntaxError错误对象主要用于解析Javascript代码的时候发生的错误,比如变量名不规范、语法错误等

var 1a = 123;
//SyntaxError: Invalid or unexpected token

console.log 123)
// 缺少括号
//Uncaught SyntaxError: Unexpected number

ReferenceError对象

ReferenceError错误对象用于当调用一个不存在的变量,或者对函数的运行结果进行赋值以及this赋值这类的错误

console.log() = 1
//Uncaught ReferenceError: Invalid left-hand side in assignment

this = 1
//Uncaught ReferenceError: Invalid left-hand side in assignment

RangeError对象

RangeError错误对象用于一个值超出了有效的范围时发生的错误,一般有定义的数组长度为负数、Number对象的方法参数超出的范围、函数堆栈超过最大值

new Array(-1)
//Uncaught RangeError: Invalid array length

TypeError对象

TypeError错误对象用于变量或者参数不是预期的类型导致的错误,比如原始值不能用new操作符,因为new操作符必须是针对构造函数使用或者调用一个不存在的函数等情况

new 1
//Uncaught TypeError: 1 is not a constructor

document.body()
//Uncaught TypeError: document.body is not a function

URIError对象

URIError对象是 URI 相关函数的参数不正确时抛出的错误,主要涉及encodeURI()decodeURI()encodeURIComponent()decodeURIComponent()escape()unescape()这六个函数。

decodeURI('%2')
// URIError: URI malformed

EvalError对象

eval函数没有被正确执行时,会抛出EvalError错误。该错误类型已经不再使用了,只是为了保证与以前代码兼容,才继续保留。

自定义错误对象

除了定义的七种错误类型之外,还可以自行定义错误对象,即创建一个构造函数,然后将该构造函数的原型指向Error的实例,然后再改变这个构造函数的原型的构造函数为自身,即创建了一个基于Error错误对象的新错误类型

function UserError (message) {
    this.message = message;
    this.name = 'UserError';
}

UserError.prototype = new Error();
UserError.prototype.constructor = UserError;

throw

throw语句的用于中断程序执行,抛出一个错误,比如下面的函数,接受一个错误对象,并中断程序执行,并抛出这个错误

function a (Err) {
    throw new Err('中断执行');
}

a(TypeError)
//Uncaught TypeError: 中断执行

throw实际上可以接受任何类型的值作为错误抛出

throw 1;
//Uncaught 1

throw true;
//Uncaught true

throw '123';
//Uncaught '123'

try…catch结构

一旦Javascript程序执行错误,就会中断执行,而try...catch结构则是用于捕获这样的错误,并进行处理错误的结构,相当于进行了一次容错处理,而处理过后try...catch结构处理了过后Javascript引擎将不会报错。

try代码块中主要放置容易引发错误的代码,而catch代码块中代码是try代码块中发生了错误而要进行处理的代码,并catch接受一个参数,则为抛出的错误实例对象

try {
    throw new Error('发生错误');
} catch (e) {
    console.log(e.name);    //Error
    console.log(e.message); //发生错误
}

try...catch结构捕获了错误过后,也不会抛出错误,会进行向下执行

try {
    throw new Error('发生错误');
} catch (e) {
    console.log(e.name);    //Error
    console.log(e.message); //发生错误
}

console.log('继续执行');    //继续执行

try…catch结构的catch代码块可以进行嵌套抛出错误

try {
    throw new Error('发生错误1');
} catch (e) {
    console.log(e.name);    //Error
    console.log(e.message); //发生错误1
    try {
        throw new Error('发生错误2');
    } catch (e) {
        console.log(e.name);    //Error
        console.log(e.message); //发生错误2
    }
}

finally

finally代码块的作用不管try代码块是否发生了错误,都会执行finally代码块中的代码,但是再中断程序之前,会先执行中断程序之前会先执行finally代码块的内容,然后再执行中断,finally代码块可以替代catch代码块。

try {
    throw new Error(1);
} finally {
    console.log('抛出错误之前执行');
}

//抛出错误之前执行
//Uncaught Error: 1

也可以与try…catch代码块一同使用

try {
    throw new Error('错误');
} catch (e) {
    console.log(e.message);
    console.log(e.name);
} finally {
    console.log(123);
}
//错误
//Error
//123

需要注意的是整个过程为先执行try代码块,如果try代码块有错误,则遇见错误就停止执行,即抛出错误后的代码就不会再执行了,然后再执行catch中的代码块,然后再执行finally代码块。

下面是finally代码块用法的典型场景。

try {
  writeFile(Data);
} catch(e) {
  handleError(e);
} finally {
  closeFile();
}

上面代码首先打开一个文件,然后在try代码块中写入文件,如果没有发生错误,则运行finally代码块关闭文件;一旦发生错误,则先使用catch代码块处理错误,再使用finally代码块关闭文件。

影响try...atch...finally结构执行顺序的还有return语句

function a () {
    try {
    throw new Error('错误');
    } catch (e) {
        console.log(e.message);
        return 1;   //这段代码会延迟到finally代码块执行完毕后再执行
        console.log(e.name);    //这段代码不会执行,因为已经返回了
    } finally {
        console.log(123);
    } 
}

a()
//错误
//123
//1

上面的代码是当try代码块捕获到错误后,执行catch结构,然后该结构输出了错误实例的错误消息后,执行return语句,但是在函数中是直接返回,不符合try...catch…finally结构,所以return语句等待finally执行完成后,再返回到return语句处执行,所以后面的console.log(e.name)没有执行,因为此时函数已经返回。

除了return之外,还有throw也会影响到这个结构的执行顺序

function a () {
    try {
    throw new Error('错误');
    } catch (e) {
        console.log(e.message);
        throw 1;    //这段代码会延迟到finally代码块执行完了后再执行
        console.log(e.name);    //而这段代码将不会得到执行
    } finally {
        console.log(123);
    } 
}

上面的代码是当try代码块捕获到错误后,执行catch结构,然后该结果输出了错误实例的错误消息后,又得到了一个抛出的错误,即throw new Error('错误'),此时按照抛出错误的机制是直接抛出错误中断程序,但是不符合try...catch…finally结构,所以throw语句等待finally代码块执行完毕再抛出错误,然后中断执行,所以没有再执行console.log(e.name)代码。

参考链接

错误处理机制 》- 阮一峰

try…catch》- MDN

导论

Javascript提供定时执行代码的功能,而这个功能称为定时器(timer),而定时器的功能主要由setTimeousetInterval两个方法来完成,它们会想任务队列添加定时任务。

setTimeout

setTimeout方法来主要用来将一个代码或者函数通过指定多少时间后再执行,该方法接受两个参数,第一个参数为func|code,即代码或者函数,第二个为delay,即推迟多少毫秒执行。

比如下面的代码将推迟3秒向控制台打印字符串js,或者直接用引号包裹语句

setTimeout(function () {
    console.log('js');
}, 3000);
//js
//效果与下面一样
setTimeout('console.log("js")', 3000);

该方法还会返回一个整数,该整数表示定时器的编号,可以用来取消该定时器

var timer_id = setTimeout('console.log("js")', 3000);

timer_id    //5484

如果第二个参数没有,则默认为0,相当于立即将代码加入到下轮的异步任务队列中。

setTimeout('console.log("js")');
//等同
setTimeout('console.log("js")', 0);

除此之外,setTimeout方法还接受多个参数,而除了第一个和第二个参数之外,其他参数都将作为第一个参数为函数时传入的参数。

setTimeout(function (name, colour) {
    console.log(name);
    console.log(colour);
}, 3000, 'js', 'blue');
//js
//blue

还需要注意的是如果第一个参数为对象的方法,那么内部的this指向的为全局环境

var a = {
    name: 'js',
    f: function () {
        console.log(this === window);
    }
}

setTimeout(a.f, 1000)
//true

可以将第一个参数设为函数,而在函数内部去调用这个对象的方法,那么因为是a对象去调用并执行f方法,那么相当于执行环境还是为a

var a = {
    name: 'js',
    f: function () {
        console.log(this.name);
    }
}

setTimeout( function () {
    a.f();
}, 1000)
//js

还可以使用bind方法,将该方法的this绑定并返回一个新的函数给setTimeout方法

var a = {
    name: 'js',
    f: function () {
        console.log(this.name);
    }
}

setTimeout(a.f.bind(a), 1000)
//js

setInterval

setInterval方法与setTimeout方法的用法完全一样,但是唯一的区别在于setInterval方法第二个参数的意思是每个多少毫秒就重复执行一次,即setInterval方法是重复执行代码或者函数所使用的

setInterval('console.log(1)', 3000);

上面的代码是每隔三秒就会向控制台输出数值1,而一直重复循环,直到关闭当前窗口。

setInterval方法执行间隔是两次开始之间的时间,意思是设置一个间隔时间为一秒,而执行代码只要0.5秒,那么执行完后的0.5秒才会开始执行第二次。

但是如果设置的间隔为一秒,但是执行了两秒,那么第一次执行完了后,马上就开始执行第二次。

如果需要确保两次执行的间隔,以及想定时第一函数结束后与第二次开始执行之间的时间,那么可以通过setTimeout来实现

var i = 0;
setTimeout( function () {
    i++;
    console.log(i)
    if ( i !== 5 ) {
        setTimeout(arguments.callee, 2000);
    }
}, 2000)

clearTimeout和clearInterval

setTimeoutsetInterval两个返回都会返回一个整数,而这个整数就是定时器编号,可以分别通过clearTImeoutclearInterval两个方法来取消这个定时器。

var a = setTimeout('console.log(1)', 3000);
var b = setInterval('console.log(1)', 3000);

clearTimeout(a);
clearInterval(b);

上面代码不会有任何输出,因为紧跟着下面就取消了这两个定时器。

实例: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毫秒。

运行机制

setTimeoutsetInterval的运行机制是将指定的代码或者函数移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间,如果还是没有到则继续移出事件循环,一直重复直到设置的延迟时间到了,就把对应的代码或者函数加入到事件循环,等待执行。

而既然定时器是基于事件循环的,所以事件循环执行异步任务之前,就会将本轮的所有同步事件都执行完,才会执行异步任务,但是由于需要将本轮的同步事件都执行完了才会去执行异步任务的任务队列,所以具体前面有多少的同步任务、占用多少时间,都是无法估计的。

比如下面的代码中a函数是一个同步函数,而b函数作为setTimeout的第一个参数(回调函数),并设置延迟3秒执行b函数,那么如果a函数的同步任务超过了三秒就会导致执行b函数的时间再去推迟,直到a函数执行完了才会去执行b函数

a();
setTimeout(b, 3000);

再看一个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的作用是将代码推迟到指定时间执行,如果指定时间为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)

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

参考链接

定时器 》- 阮一峰

Javascript模块模式》- January

导论

对象的继承指的就是对象A通过继承对象B,然后能够直接拥有对象B的属性和方法,而这才是继承最大的优处,大部分面向对象的语言通过“类”来实现对象的继承,而Javascript是通过prototype原型对象来实现的继承。

构造函数的缺点

Javascript通过构造函数来定义一类对象,然后通过构造函数生成对象实例,而构造函数也算是对象的模板,这一类的实例具备什么样的属性、方法都可以在构造函数内定义。

function User () {
    this.name = 'Tom';
    this.age = '18';
    this.getName = function () {
        return this.name;
    }
}

上面代码中每个实例都会生成独立nameage属性和一个独立的getName方法,而这个getName方法每个实例都是一样的,都是用于返回实例对象的name属性,那么每个实例都生成了一个这样的函数,岂不是很浪费系统资源?并且如该我需要所有的这一类实例都有一个或多个共享的属性,目前按照构造函数来看,是没有办法做到的。

原型对象

Javascript继承的机制的设计思想就是原型对象,而原型对象所有的方法和属性都被实例所拥有,也就是说构造函数是定义的是实例属性、方法,而原型对象定义的是继承属性、方法。

而在Javascript中,设置原型是通过prototype,每个函数都会有一个prototype属性,并且默认都指向为一个对象。

function a () {}
typeof a.prototype  //object

对于普通函数来说,这个属性基本上没有什么太大的作用,而对于构造函数来说,这个属性上设置的方法属性,就可以让所有基于这个构造函数生成的实例对象共享

function User () {}
User.prototype.name = 'javascript';

var a = new User();
var b = new User();

a.name  //javascript
b.name  //javascript

从上面的代码可以看出来所有的实例都会共享prototype属性指向的对象里面的方法和属性,那么既然是共享,只要构造函数上通过这个属性进行了修改,这个属性都将改变

function User () {}
User.prototype.name = 'javascript';

var a = new User();
var b = new User();

User.prototype.name = 'rust';

a.name  //rust
b.name  //rust

从上面的代码可以看出来ab都是没有这个属性的,这是因为ab对象都是在读取原型对象上的方法和属性,这是因为当一个当实例对象本身没有这个属性的时候,会去寻找原型对象上面是否存在这个属性,如果有则返回该属性的值,没有则返回undefined

所以如该实例对象本身有这个属性,那么Javascript就不会去寻找原型对象是否存在这个属性

function User () {}
User.prototype.name = 'javascript';

var a = new User();
var b = new User();

a.name = 'rust'

a.name  //rust
b.name  //javascript

实例对象不能更改原型的属性,因为当实例对象对一个本身不存在的属性进行赋值的时候,实际上是对本身新建了一个属性

function User () {}
User.prototype.name = 'javascript';

var a = new User();
var b = new User();

a.name = 'rust'

a   //{name: "rust"}
b   //{}

原型链

Javascript规定每个对象都有一个自己的原型对象,而构造函数的prototype属性指向的也是一个对象,所以每个对象也可以当作其他对象的原型,因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型。

function User () {}
User.prototype.name = 'javascript';

var a = new User();
a.colour = 'blue';
var b = Object.create(a);

b.colour    //blue
b.name  //javascript

上面的代码通过构造函数生成实例对象a,然后对象a当作对象b的原型,这时候当我去访问对象bcolour属性的时候,对象b本来没有colour这个属性,所以就会查找原型对象,而原型对象指向对象a,所以能够获取到对象acolour属性。

当访问对象bname属性的时候,Javascript引擎会先查找对象b本身,本身没有这个属性,然后查找原型对象a,原型对象a还是没有,那么Javascript引擎会继续查找原型对象a的原型,即构造函数Userprototype属性指向的对象上面是否包含,所以能够找到name属性。

如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOftoString方法的原因,因为这是从Object.prototype继承的。

那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是nullnull没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null,而原型链的查找也到此为止。

Object.getPrototypeOf(Object.prototype)
// null

读取对象的某个属性时,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表达式,用来比较一个对象是否为某个构造函数的实例,结果就是证明mineArray的实例,instanceof运算符的详细解释详见后文。

constructor属性

每个函数的prototype对象上有一个constructor属性,该属性默认指向prototype对象所在的函数

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

由于constructor属性定义在prototype对象上,所有这个属性是被所有的实例所继承的。

function User () {}
var b = new User();
b.constructor === User  //true
b.constructor === User.prototype.constructor    //true

constructor属性一般有两个作用,第一个是表示这个实例是由哪一个构造函数创建的,比如上面的代码,而第二个就是可以通过实例的constructor属性来找到构造函数生成一个新的实例,比如下面的代码

function User () {}
var b = new User();
var c = new b.constructor();
c.constructor === User  //true

constructor属性是用于表示原型对象和构造函数之间的关联关系,如该修改了原型,一般会自动修改constructor属性,防止引用的时候出错,比如下面的代码修改了User的原型,随之而变的还有constructor属性,该属性将变为prototype属性指向的原型的函数,即普通对象默认指向的是Object

function User () {}

User.prototype = {};
// 将User的prototype属性改为空对象

User.prototype.constructor === User
User.prototype.constructor === Object

所以在修改原型的时候,一般会修改constructor属性的指向,以免后期不知道该实例的构造函数是谁,并且修改prototype对象的时候不要对整个对象进行修改,而是针对于单个属性进行修改,因为对prototype修改会修改整个prototype对象的指向

User.prototype = {
    getName: function () {return 1}
};  
// 注意!!
//该方式是直接给prototype属性赋值了一个新对象,对象中有一个getName方法

User.prototype.getName = function () {return 1}
//该方式是直接对prototype对象上的getName属性进行赋值一个函数
//所以不会影响prototype

instanceof 运算符

instanceof运算符用于返回一个布尔值,表示右边的函数是否为左边实例的构造函数

function User () {}
var b = new User();

b instanceof User   //true

因为instanceof检查的是整个原型链,所以如该存在多重继承的话,将可能多个构造函数都返回true,并且所有的对象类型的值都是基于Object的实例,所以所有对象类型的值通过instanceof运算符都返回true

function User () {}
var b = new User();

b instanceof User   //true
b instanceof Object //true

instanceof运算符的原理是检查右边的构造函数的prototype属性是否在左边对象的原型链上,而如该左边对象的原型链上是null,这时候会导致instanceof运算符失真。

function User () {}
var b = Object.create(null);
b instanceof User //false

instanceof运算符的一个用处,是判断值的类型。

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

上面代码中,instanceof运算符判断,变量x是数组,变量y是对象。

注意,instanceof运算符只能用于对象,不适用原始类型的值。

var s = 'hello';
s instanceof String // false

上面代码中,字符串不是String对象的实例(因为字符串不是对象),所以返回false

此外,对于undefinednullinstanceOf运算符总是返回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命令。

构造函数的继承

构造函数之间的继承也是非常常见的一个场景,而这样的继承Javascript是没有提供的,但是可以变通来实现,一般继承构造函数分为两步骤实现,第一个步骤则在子构造函数中调用父构造函数,让子构造函数能够生成父构造函数的实例属性和方法

function Super () {
    this.name = 'js';
}

function Sub () {
    Super.call(this);
    this.age = 18;
}

上面的代码通过函数的call方法将Super构造函数的this指针指向Sub的实例对象,然后执行该函数,所以改变了过后Super函数中的thisSub中的this都指向了同一个实例,然后父构造函数内部将对实例对象新建一个name属性,然后子构造函数会对实例对象新建一个age属性,然后就返回这个实例对象,这就相当于变相的实现了构造函数继承另外一个构造函数的实例方法和属性。

然后第二步需要做的是修改子构造函数的prototype属性,让这个属性指向父构造函数的原型实例,需要注意的是这里是父构造函数的原型实例,而不是父构造函数的原型,这是因为后面我们还会对子构造函数的constructor属性进行恢复为自身,这是为了让基于子构造函数的实例能够区分实例是属于这个子构造函数的。

而如果用父构造函数的原型赋值给子构造函数的prototype属性,那么修改constructor属性的时候将是直接修改父构造函数的constructor属性

function Super () {
    this.name = 'js';
}

function Sub () {
    Super.call(this);
    this.age = 18;
}

Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;

虽然可以直接将父构造函数的实例赋值给原型,但是这样会导致子构造函数的实例会继承父构造函数的实例属性和方法,比如下面的代码,父构造函数的实例新建一个name属性,而子构造函数同样建立了一个name属性,所以不建议这样使用。

function Super (str) {
    this.name = str || 'js';
}

function Sub () {
    Super.call(this, 'rust');
    this.age = 18;
}

Sub.prototype = new Super();

Object对象与面向对象编程的相关方法和属性

getPrototypeOf

getPrototypeOf方法是Object对象的静态方法,该方法是获取原型对象的标准方法

function User () {}
var b = new User();

Object.getPrototypeOf(b) === User.prototype //true

而还有几种特殊的对象的原型:

  • 空对象{}的原型是Object.prototype
  • Object.prototype的原型是null
  • 函数function的原型是Function.prototype

setPrototypeOf

setPrototypeOf方法是Object对象的静态方法,用于设置一个对象的原型,该方法接受两个参数,一个为现有对象,一个为原型对象,并将修改后的现有对象返回。

var a = {};
var b = { name: 'js' };

Object.setPrototypeOf(a, b);

需要注意该方法会修改现有对象的原型,即将现有对象的原型指向原型对象

var a = {};
var b = { name: 'js' };

Object.setPrototypeOf(a, b);
Object.getPrototypeOf(a) === b

create

create方法是Object对象的静态方法,用于将一个实例对象当作新对象的原型,并返回这个新对象

function User () {
    this.name = 'js'
}

User.prototype.colour = 'blue';

var a = new User();

var b = Object.create(a);

b.name  //js
b.colour    //blue

上面的代码首先建立了一个构造函数User,然后用这个构造函数生成了一个实例a,然后通过Object.create方法将实例a作为新实例对象b的原型,然后返回,此时实例b能够继承实例a的静态属性方法和原型对象上的实例属性方法。

那么既然实例对象b的原型对象为实例对象a,那么此时这两个对象都指向的是同一个原型对象,包括在原型对象上面的constructor属性

function User () {
    this.name = 'js'
}

User.prototype.colour = 'blue';

var a = new User();

var b = Object.create(a);

b.contructor === a.contructor   //true
b.constructor === User  //true

如该实例对象修改了一个不是属于自身的属性,那么将会在本身创建该属性,所以原型链上只用于查询,而不用于修改,通过这样的方式创建自身的属性叫做“覆盖”

function User () {
    this.name = 'js'
}

User.prototype.colour = 'blue';

var a = new User();

var b = Object.create(a);

b.colour = 'yellow';

a.colour    //blue
User.prototype.colour   //blue
b.colour    //yellow

如果想要生成一个不继承任何属性(比如没有toStringvalueOf方法)的对象,可以将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方法还可以接受第二个参数,该参数是一个属性描述对象,它所描述的对象属性,会添加到实例对象,作为该对象自身的属性。

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';

isPrototypeOf

isPrototypeOfObject的实例对象,即定义在Object.prototype对象上的,能够被实例直接继承,该方法是用于判断使用对象是否为参数对象的原型

var a = {};
var a1 = Object.create(a);

a.isPrototypeOf(a1)

上面代码在原型对象a上面调用了isPrototypeOf方法,判断a1的原型是否为自己

__proto__属性

__proto__(前后各两个下划线)是Object的实例属性,即定义在Object.prototype对象上的,该属性会返回该对象的原型,该属性可读写

function User () {}
var a = new User();

a.__proto__ === User.prototype  //true

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

获取原型对象方法的对比

根据上面的内容来说,获取实例对象的原型对象,有三种方法。

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

但是按照上面章节的内容来说,前两种都不是很可靠,第一种是因为__proto__实例属性因为标准只定义了浏览器才需要部署,如果在其他环境使用不一定会部署这个属性。

第二种是因为手动改变原型对象的时候,可能会造成constrctor的失真,不会指向原始的构造函数。

第三种Object.getPrototypeOf方法可以很准确的返回实例对象的原型,因为该方法总是返回对象的原型。

getOwnPropertyNames

getOwnPropertyNames方法是Object的静态方法,该方法返回一个数组,成员是对象本身所有的属性名,即不包含继承的属性名,该方法会忽略属性是否可以遍历。

var a = {
    name: 'js',
    colour: 'blue'
}

Object.defineProperty(a, 'name',{
    enumerable: false
})

Object.defineProperty(a, 'colour',{
    enumerable: true
})

for (var i in a) {
    console.log(i)
}
//colour

Object.getOwnPropertyNames(a)
//["name", "colour"]

如果要获取支持遍历的属性可以使用Object.keys方法

Object.keys(a)
//["colour"]

hasOwnProperty

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

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

上面代码表明,Date.length(构造函数Date可以接受多少个参数)是Date自身的属性,Date.toString是继承的属性。

另外,hasOwnProperty方法是 JavaScript 之中唯一一个处理对象属性时,不会遍历原型链的方法。

in运算符

in运算符返回一个布尔值,用于表示一个对象是否具有某个属性,in运算符不区分该属性是否为静态属性还是实例属性。该方法常用于检查一个属性是否存在

`length` in String  //true

for…in循环

for...in可以遍历对象所有可遍历的属性,包括继承属性,但是需要注意的是可遍历的属性。

var a = { name: 'js' };
var b = Object.create(a, {
    colour: {value: 'blue', enumberable: true} 
});

for (var i in b) {
    console.log(i);
}
//name

对象拷贝

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

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

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

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;
}

参考链接

对象的继承 》- 阮一峰

Object》- MDN

Object.prototype.__proto__》- MDN

导论

随着网站逐渐变成“互联网应用程序”,嵌入网页的 JavaScript 代码越来越庞大,越来越复杂。网页越来越像桌面程序,需要一个团队分工协作、进度管理、单元测试等等……开发者必须使用软件工程的方法,管理网页的业务逻辑。

JavaScript 模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。

简单的实现方法

模块其实就是将一组方法和属性进封装,而这个正好符合对象,所以最简单的就是将属性和方法放在对象当中进行封装

var module_1 = {
    count : 0,
    inc : function () {
        this.count++;
    }
}

module_1.inc()
module_1.count  //1

上面的代码就是一个简单的模块封装,但是这样的封装会将模块所有的属性和方法全部暴露在外面,这样使得任何人都可以进行修改属性和方法

var module_1 = {
    count : 0,
    inc : function () {
        this.count++;
    }
}

module_1.count  = 5;
module_1.count  //5

私有变量封装:构造函数

可以通过构造函数来进行封装私有变量,让用户只能使用方法来对此变量进行改变

function User () {
    var count = 0;
    this.inc = function () {
        count++;
        return count;
    }
}

var b = new User();

b.count //undefined
b.inc() //1
b.inc() //2
b.inc() //3

上面的代码通过在构造函数内建立了一个私有的变量,一旦实例生成,那么将无法从任何地方进行访问,包括实例对象,唯一能操作的只能通过构造函数提供的方法进行操作该私有变量。

但是这样的方法有一个很大的问题就是在于构造函数与实例是一体的,总是存在内存当中,并且无法消除,这是因为Javascript引擎使用的是引用标记来清除内存中的那些不用的对象和值,意思就是如果代码有一个地方引用了该对象,该对象会被标记为1,而垃圾回收器就不会清除该对象,因为该对象引用为1表示在使用,如果为0则垃圾清理器将会将该对象清除掉。

所以上面的问题就是在于当生成了一个实例对象过后,此时实例对象的函数会引用构造函数的count私有变量,所以实例这里也会有一个引用标志,表示引用count,这时垃圾回收器就一直不会清理掉该实例对象和构造函数,这是一个非常消耗内存的操作,所以建立不要这样使用。

私有变量封装:立即执行函数(IIFE)

另外一种做法是将私有变量放在一个立即执行函数当中,然后仅返回对该变量操作的方法,也同样可以达到不暴露私有变量的目的

var a = (function () {
    var count = 0;

    var inc = function () {
        count++;
        return count;
    }
    return {
        inc: inc
    };
})();

a.count //undefined
a.inc() //1
a.inc() //2

而上面的代码相对于之前的会减少很多内存消耗,因为此时内存中保存的是一个匿名的执行环境,而这个执行环境当中只有count这个变量,对比起对象来说,这会少消耗很多内存。

模块的放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(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对象输出到全局,对外暴露initdestroy接口,内部方法gohandleEventsinitializedieCarouselDie都是外部无法调用的。

参考链接

对象的继承 》- 阮一峰

Javascript模块模式》- January

严格模式

早期的Javascript有很多设计不合理的地方,但又考虑到了需要兼容以前的代码,而又不能去改变老的语法,所以TC39委员会只能不断的去添加更合理的新语法,来引导开发人员使用新语言,但即使这样,以前的代码始终是存在的,而Javascript本身语言会有很多令人费解的地方,所以从ES5严格模式就进入了标准,而引入了严格模式,也代表Javascript正在朝更合理更安全更严谨的方法发展。

而严格模式主要的目的有以下的几个:

  • 明确禁止一些不合理、不严谨的语法,减少 JavaScript 语言的一些怪异行为。
  • 增加更多报错的场合,消除代码运行的一些不安全之处,保证代码运行的安全。
  • 提高编译器效率,增加运行速度。
  • 为未来新版本的 JavaScript 语法做好铺垫。

启动严格模式

启动严格模式的是一串字符串,当老版本的引擎看见这串字符串的时候会忽略,这样正好兼容了以前的引擎,而新版本的引擎看见了这串字符串,会进入严格模式。

'use strict';

严格模式可以针对两个范围:

  • 整个脚本
  • 单个函数

整个脚本启动严格模式

将这串字符串放在整个代码或者脚本的第一行或者前面可以是不产生实际运行结果的语句,如分号、注释,如果没有在上面所说的位置范围内,Javascript引擎将忽略这段字符串,将以正常模式运行,如果在HTML标签的<script>标签中使用,那么整个<script>标签都会采用严格模式

<script>
'use strict';
...
</script>

还有一种就是在Javascript文件中的最开始声明使用严格模式,那么整个Javascript文件都会启用严格模式

'use strict';
...

单个函数启动严格模式

单个函数启动严格模式也是同样需要将use strict字符串放在整个函数的最前面,那么这个函数以及定义的所有对象、值、子函数都将启动严格模式,即所有包含在严格模式函数内的代码都将启动严格模式。

function a () {
    'use strict';
    ...
}

有些时候如果将不同的脚本合并在一起,如果其中一个脚本采用了严格模式,而另一个没有采用,就可能导致结果不同,如果严格模式的脚本在前面,那么合并后整体都将采用严格模式。而如果严格模式的脚本在后面,那么整体都将采用正常模式。

显式报错

在正常模式下有些操作会默默的失败,而在严格模式中是直接显式的报错,这就会使得严格模式下的代码更加严格、严谨。

只读属性不可写

严格模式下,如果属性的描述对象中的writable属性设置为不可写的时候,再去对该属性进行修改,那么将会报错

'use strict';
var a = Object.defineProperty({}, 'a', {
  value: 37,
  writable: false
});
a.a = 123;
//Uncaught TypeError: Cannot assign to read only property 'a' of object '#<Object>'

删除不可配置属性

严格模式下,如果属性的描述对象中的configurable属性设置为不可配置的的时候,再去对该属性进行修改、删除等操作,那么将会报错

'use strict';
var a = Object.defineProperty({}, 'a', {
  value: 37,
  configurable: false
});
delete a.a;
//Uncaught TypeError: Cannot delete property 'a' 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

上面代码中,obj.v只有取值器,没有存值器,对它进行赋值就会报错。

禁止扩展的对象不可扩展

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

'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作为标识名,将会报错,即变量名、属性名、参数名等都不能使用这两个关键词

函数不能有重名参数

正常模式下,如果函数有多个重名参数不会报错,并且重名参数可以arguments对象获取到,而在严格模式下将会报错

function a (b, b) {
    'use strict';
} 
//Uncaught SyntaxError: Duplicate parameter name not allowed in this context

禁止八进制的前缀0表示法

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

'use strict';
var n = 0100;
// Uncaught SyntaxError: Octal literals are not allowed in strict mode.

增强的安全

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

全局变量的显式声明

正常模式下,如果在全局环境下没有声明一个变量,而直接赋值的话,将会默认为全局变量(即全局对象的属性),但是在严格模式下必须显式的声明全局变量,否则会报错,而这个报错的地方存在于函数内部没有声明、循环结构没有声明、全局环境没有声明

'use strict';

a = 1;
//报错

for (i in 'javascript') {
    console.log(i);
    //报错,变量i未声明
}

function a () {
    b = 1;
}

a()
//报错,b未声明

禁止this指向全局对象

正常模式下,this可以指向全局对象,严格模式下则是禁止的,因为this指向全局对象,会无意间创建很多全局变量,比如下面的代码,严格模式下thisundefined

function a () {
    'use strict';
    console.log(this.name);
}
a()
//Uncaught TypeError: Cannot read property 'name' of undefined

禁止使用 fn.callee、fn.caller

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

function f1() {
  'use strict';
  f1.caller;    // 报错
  f1.arguments; // 报错
}

f1();

禁止使用 arguments.callee、arguments.caller

arguments.calleearguments.caller是两个历史遗留的变量,从来没有标准化过,现在已经取消了。正常模式下调用它们没有什么作用,但是不会报错。严格模式明确规定,函数内部使用arguments.calleearguments.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 a (a1) {
    console.log(a1);
    console.log(arguments[0]);
    a1 = 2;
    console.log(a1);
    console.log(arguments[0]);

}

a()
//  1
//  1
//  2
//  2

而严格模式下,arguments对象从函数接受到参数的那刻就已经固定了,意味着如果直接修改参数,将不会影响arguments对象的值

function a (a1) {
    'use strict';
    console.log(a1);
    console.log(arguments[0]);
    a1 = 2;
    console.log(a1);
    console.log(arguments[0]);

}

a()
//  1
//  1
//  2
//  1

向下一个版本过渡

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; // 语法错误
}

参考链接

《[严格模式](http://wangdoc.com/javascript/oop/strict.html》- 阮一峰

严格模式》- MDN

异步编程

单线程模型指的是,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)”。

异步操作模式

回调函数

因为Javascript是单线程的,所以如果第一个函数是异步函数,那么此时执行到异步函数的时候,发现是一个异步操作,比如网络请求等IO操作,那么将该任务放到任务队列中,然后继续执行下面的任务,就会造成第一个函数还没有执行完就执行了第二个函数。

function a () {}    //异步函数
function b () {}    //同步函数

a()
b() //可能在a之前就执行

而回调函数的作用是一个函数执行,然后这个函数执行完了过后才执行第二个函数。

function a (callback) {
    //...
    callback();
}

function b () {}

a()

回调函数的优点是容易理解,使用简单,实现也简单,但是缺点是不容易维护和阅读,都知道函数一般按照所理解的来说是包含了一个大致的功能,而尽量避免高耦合,而回调函数就会造成高度耦合,多个函数之间相互关联。

事件监听

事件监听的异步任务执行顺序不取决于代码的顺序,而是取决于某个事件是否发生,如该这个事件发生,那么将会立马进入主线程执行,比如网页上的点击按钮的事件,当我点击的时候立马就弹出什么提示等,都是属于事件监听。

比如下面的代码就可以用延迟执行来模拟一下事件监听,下面的代码有两个函数,一个函数为事件,一个函数为监听函数,事件函数中延迟3秒执行监听函数

function a () {
    setTimeout( function () {
        b();
    }, 3000)
}

function b () {
    console.log('javascript');
}

a()
//3秒左右返回javascript

上面的代码相当于模拟了一次事件发生,函数a里面定义了一个事件,这个事件是等待3秒后执行b函数,随后输出javascript

发布/订阅(观察者模式)

事件完全可以理解成”信号“,如果存在一个”信号中心“,某个任务执行完成,就向信号中心”发布“(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);

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

异步操作的流程

异步操作最难的地方在于如何控制流程,在实际的编程中,有大量的异步函数是需要确定执行顺序的,那么在Javascript这样的异步函数该怎么保证执行顺序呢?

比如有五个异步函数都需要确定前一个异步函数的是否执行完,那么这个时候如果按照正常的异步函数是不可能实现的,因为比如异步IO的时候,哪个先返回都说不定一定,就可能会导致顺序的错乱。

串行执行

串行执行指的是一个任务完成后再执行另外一个,而通过编写一个流程控制函数就能够达到这样的需求,而这个函数大概的中心思想是通过加载参数数组,然后通过回调函数来每次依次弹出参数数组的元素,直到没有元素为止,这样就相当于通过每次上次一个函数执行完了过后再调用函数继续执行

var items = [1, 2, 3, 4, 5, 6];
var results = [];

function a (arg, callback) {
    console.log('参数为:' + arg + ' , 1秒后执行完本次函数')
    setTimeout( function () {
        callback(arg * 2)
    } , 1000);
}

function b (value) {
    console.log('完成:' + value);
}

function series (item) {
    var that = arguments;
    if (item) {
        a( item, function (result) {
            results.push(result);
            return that.callee(items.shift());
        });
    } else {
       b(results); 
    }
}

series(items.shift());
//完成:2,4,6,8,10,12

并行执行

串行执行指的是同时发起多个异步任务,然后当所有的异步任务都执行完毕了,然后才执行最后一个函数,基本上思想和串行差不多,但是区别在于这个是同时一起发起多个异步任务,然后每个回调函数都检查是否返回的值达到了预期,如果达到了预期则表示都执行完毕了。

而相比之前的串行执行,这个执行速度只需要1秒左右,但是这样的并行执行如果任务太多,会导致拖慢运行速度。

var items = [1, 2, 3, 4, 5, 6];
var results = [];

function a (arg, callback) {
    console.log('参数为:' + arg + ' , 1秒后执行完本次函数')
    setTimeout( function () {
        callback(arg * 2)
    } , 1000);
}

function b (value) {
    console.log('完成:' + value);
}

function series (items) {
    items.forEach( function (item) {
        a( item, function (result) {
            results.push(result);
            if ( results.length === items.length ) {
                b(results);
            }
        })
    })
}

series(items, b);
//完成:2,4,6,8,10,12

并行与串行的结合

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

var items = [1, 2, 3, 4, 5, 6];
var results = [];
var running = 0;
var limit = 2;

function a (arg, callback) {
    console.log('参数为:' + arg + ' , 1秒后执行完本次函数')
    setTimeout( function () {
        callback(arg * 2)
    } , 1000);
}

function b (value) {
    console.log('完成:' + value);
}

function series (items) {
    while ( running < limit && items.length > 0 ) {
        a(items.shift(), function (result) {
            results.push(result);
            running--;
            if (items.length > 0) {
                series(items);
            } else if (running === 0) {
                b(results);
            }
        });
        running++;
    }
}

series(items, b);
//完成:2,4,6,8,10,12

参考链接

异步操作概述 》- 阮一峰

Javascript模块模式》- January

Leave a Reply

Your email address will not be published. Required fields are marked *