JavaScript 高级程序设计(第3版)学习笔记

笔记2:面向对象部分

这是阅读《JavaScript 高级程序设计》书籍的学习笔记,整理和归纳,方便自己今后复习和查阅,这里总结的基本上都是一些比较特殊的知识点,和 Java 等其他高级语言重复的地方不在归纳范围内。建议先阅读第一部分 非面向对象部分。此部分主要是理解 JavaScript 中的面向对象的实现思路以及如何实现继承。

理解对象

ECMAScript 中有两种属性:数据属性和访问器属性。

数据属性

数据属性有 4 个描述其行为的特征。

[[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true.

[[Enumerable]]:表示能否通过 for-in 循环返回属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true.

[[Writable]]:表示能否修改属性的值。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true.

[[Value]]:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为 undefined.

var person = {
    name : "XiaoMing"
}

上面例子中的 [[Configurable]][[Enumerable]][[Writable]] 属性默认都是 true, 而 [[Value]] 属性被设定为特定的值 XiaoMing.

要修改这些数据属性就需要使用 ECMAScript 5 的 Object.defineProperty() 方法,这个方法接收三个参数:

var person = {};

Object.defineProperty(person, "name", {
    writable: false,
    value: "LiXiaoQiang"
});

console.log(person.name);  //"LiXiaoQiang"

person.name = "MingMing";

console.log(person.name);  //"LiXiaoQiang"

可以看到上面我们使 [[Writable]] 属性变为了 false 则不可以更改属性值。类似的可以设置 [[Configurable]] 属性也为 false.

var person = {};

Object.defineProperty(person, "name", {
    configurable: false,
    value: "LiXiaoQiang"
});

console.log(person.name);  //"LiXiaoQiang"

delete person.name;  //该属性删除不掉

console.log(person.name);  //"LiXiaoQiang"

访问器属性

访问器属性不包含数据值 [[Value]], 它们包含一对儿 getter 和 setter 函数(不过,这两个函数都不是必需的)。

[[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。对于直接在对象上定义的属性,这个特性的默认值为 true.

[[Enumerable]]:表示能否通过 for-in 循环返回属性。对于直接在对象上定义的属性,这个特性的默认值为 true.

[[Get]]:在读取属性时调用的函数。默认值为 undefined.

[[Set]]:在写入属性时调用的函数。默认值为 undefined.

访问器属性不能直接定义,必须使用 Object.defineProperty() 来定义:

var person = {
    _name: "LiXiaoQiang"
};

Object.defineProperty(person, "name", {
    value: "ZhangQiang",
    get: function(){
        return this._name;
    },
    set: function(name){
        if(name !== this._name){
            this._name = "It's bad!";
        }
    }
});

person.name = "HaHa";
console.log(person.name);  //"It's bad!"

上面的 _name 下划线是一种常用记号,用于表示只能通过对象的方法访问属性。注意上面如果使用了 [[Get]][[Set]] 就不能使用 [[Value]] 不然会报如下错误:

定义多个属性

由于为对象定义多个属性的可能性很大,ECMAScript 5 又定义了一个 Object.defineProperties() 方法。利用这个方法可以通过描述符一次定义多个属性。

var book = {};

Object.defineProperties(book, {
    _year: {
        value: 2020
    },

    edition: {
        value : 1
    },

    year: {
        get: function(){
            return this._year;
        },
        set: function(newValue){
            if(newValue > 2019){
                this._year = newValue;
                this.edition = newValue - 2019;
            }
        }
    }
});

读取属性

使用 ECMAScript 5 的 Object.getOwnPropertyDescriptor() 方法,可以取得给定属性的描述符。

var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
console.log(descriptor.value);        //2020
console.log(descriptor.configurable);  //false
console.log(typeof descriptor.get);  //undefined

var descriptor = Object.getOwnPropertyDescriptor(book, "year");
console.log(descriptor.value);         //undefined
console.log(descriptor.configurable);  //false
console.log(typeof descriptor.get);  //function

创建对象

我们已经知道创建对象的两个方法,一种是 new Object(), 另一种是字面量的方式,但是这两个方式有一个明显的缺点,使用同一个接口创建多个对象会产生大量的重复代码。

工厂模式

function createPerson(name, age, job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        console.log(this.name);
    };
    return o;
}

var person1 = createPerson("XiaoQiang", 28, "Android Engineer");
var person2 = createPerson("ZhangQiang", 29, "Web Engineer");

构造函数模式

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function(){
        console.log(this.name);
    };
}

var person1 = new Person("XiaoQiang", 28, "Android Engineer");
var person2 = new Person("ZhangQiang", 29, "Web Engineer");

person1 和 person2 分别保存着 Person 类的一个不同的实例,这两个对象都有一个 constructor (构造函数)属性,该属性指向 Person.

console.log(person1.constructor == Person); //true
console.log(person2.constructor == Person); //true

任何函数,只要通过 new 操作符来调用,它就可以作为构造函数,不通过 new 操作符调用就和普通函数没有区别

//当做构造函数使用
var person1 = new Person("XiaoQiang", 28, "Android Engineer");

//作为普通函数使用
Person("ZhangQiang", 29, "Web Engineer");  //添加到 window
window.sayName();

原型模式

上面的构造函数模式有一个特点就是内部方法不是指向同一个地址:

console.log(person1.sayName == person2.sayName);  //false

事实上,我们创建的每个函数都有一个 prototype (原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。

function Person(){

}

Person.prototype.name = "XiaoQiang";
Person.prototype.age = 28;
Person.prototype.job = "Android Engineer";
Person.prototype.sayName = function(){
    console.log(this.name);
}

var person1 = new Person();
person1.sayName();
var person2 = new Person();
console.log(person1.sayName == person2.sayName); //true

只要我们定义了这个函数(原型对象) Person 就会自动创建一个 prototype 属性,这个属性同时也指向函数原型对象,默认情况下原型对象会获得一个 constructor 属性。

Person.prototype.constructor 指向 Person 原型对象。

当我们创建新实例后(例如 person1 )实例内部将包含一个指针,指向构造函数的原型对象

console.log(Object.getPrototypeOf(person1) == Person.prototype);  //true
console.log(Object.getPrototypeOf(person2) == Person.prototype);  //true

当我们读取某个对象实例的属性时,会进行两次搜索,第一次从对象实例本身开始,如果找到则返回该属性值,如果找不到则会去原型对象中查找,例如:

function Person(){

}

Person.prototype.name = "XiaoQiang";

var person1 = new Person();
person1.name = "ZhangQiang";

var person2 = new Person();
var person3 = new Person();

console.log(person1.name);  //"ZhangQiang"
console.log(person2.name);  //"XiaoQiang"
console.log(person3.name);  //"XiaoQiang"

使用 delete 操作符可以完全删除实例属性,从而让我们能够重新访问原型中的属性:

function Person(){

}

Person.prototype.name = "XiaoQiang";

var person1 = new Person();
person1.name = "ZhangQiang";

var person2 = new Person();

console.log(person1.hasOwnPropertye("name"));  //true
console.log(person1.name);  //"ZhangQiang"

delete person1.name;

console.log(person1.hasOwnPropertye("name"));  //false
console.log(person1.name);  //"XiaoQiang" (来自原型对象)
console.log(person2.name);  //"XiaoQiang" (来自原型对象)

原型 in 操作符

除了在 for-in 循环中能使用 in 操作符之外,还可以单独使用,它能够判断是否该对象可以返回某个属性(属性存在实例或者原型中):

console.log("name" in person1);

所以我们在使用 for-in 循环遍历的时候,返回的是所有能够通过对象访问的、可枚举([[Enumerable]]标记)的实例和原型的属性。

简化原型语法

function Person(){

}

Person.prototype = {
    name: "XiaoQiang",
    age: 28,
    job: "Android Engineer",
    sayName: function(){
        console.log(this.name);
    }
}

var person1 = new Person();
person1.sayName();

上面代码虽说是原型语法的简化,但是却有着一个本质区别,这里 Person.prototype 属性不在指向 Person 对象原型,而是指向了一个对象字面量创建的新对象,所以这里就有些不同了 constructor 属性不再指向 Person 对象原型了,而这里的字面量新对象自动获得 constructor 属性指向了 Object 对象原型

console.log(person1.constructor == Person);  //false
console.log(person1.constructor == Object);  //true

当然,我们如果必须要让 Person.prototype 属性指回原来的 Person 对象原型,也可以主动添加 constructor 属性指向 Person.

Person.prototype = {
    constructor: Person,   //注意这一行
    name: "XiaoQiang",
    age: 28,
    job: "Android Engineer",
    sayName: function(){
        console.log(this.name);
    }
}

搞清楚这层关系,我们再来看一个例子加深理解:

function Person(){

}

var person1 = new Person();

Person.prototype = {
    name: "XiaoQiang",
    age: 28,
    job: "Android Engineer",
    sayName: function(){
        console.log(this.name);
    }
}

var person2 = new Person();

console.log(person1.sayName());  //Error
console.log(person2.sayName());  //"XiaoQiang"

上面例子中 person1 创建的时候 Person.prototype 指向的是默认的,此时没有定义任何属性和方法,所以导致了 Error.

这种原型模式看似已经接近 Java 等其他高级语言的对象定义方式,但是存在着一个潜在的问题,由于原型模式的默认对象原型 prototype 是共享的,所以会导致一个问题:

function Person(){

}

Person.prototype = {
    name: "XiaoQiang",
    age: 28,
    job: "Android Engineer",
    friends: ["WangWei", "XiaoXian"],
    sayName: function(){
        console.log(this.name);
    }
}

var person1 = new Person();
var person2 = new Person();

person1.friends.push("ZhangQiang");

console.log(person1.friends);   //["WangWei", "XiaoXian", "ZhangQiang"]
console.log(person2.friends);   //["WangWei", "XiaoXian", "ZhangQiang"]

如果我们不想共享 friends 属性,则会存在问题,所以很少看到单独使用原型模式,而是结合构造函数模式一起使用。

组合使用构造函数模式和原型模式

基于上面我们对构造函数模式和原型模式创建对象的理解,也认识到了两者的特点和各自的不足,所以通常我们会将两者结合起来使用。

构造函数模式用来定义实例属性(不同实例会各自创建一份),而原型模式用于定义方法和共享的属性。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["WangWei", "XiaoXian"];
}

Person.prototype = {
    constructor: Person,
    sayName: function(){
        console.log(this.name);
    }
}

var person1 = new Person("XiaoQiang", 28, "Android Enginner");
var person2 = new Person("ZhouChong", 29, "Web Enginner");

person1.friends.push("ZhangQiang");

console.log(person1.friends);   //["WangWei", "XiaoXian", "ZhangQiang"]
console.log(person2.friends);   //["WangWei", "XiaoXian"]
console.log(person1.sayName());    //XiaoQiang
console.log(person2.sayName());    //ZhouChong

这种构造函数与原型的混合模式是目前使用最广泛、认同度最高的一种创建自定义类型的方式,可以说这是定义引用类型的一种默认方式。

动态原型模式

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["WangWei", "XiaoXian"];

    if(typeof this.sayName != "function"){
        Person.prototype.sayName = function(){
            console.log(this.name);
        }
    }
}

这里加了一个判断,只有在 sayName 方法不存在的时候才会将它添加到原型中,这里的 if 判断只需要是任意属性或方法,不必用一大堆 if 判断。

寄生构造函数模式

有时候我们在不能修改源码的前提下想给对象添加额外的方法就必须使用此模式,例如我们想给 Array 添加方法。

function MyArray(){

    var values = new Array();

    values.push.apply(values, arguments);

    values.toPipedString = function(){
        return this.join("|");
    }

    return values;
}

var colors = new MyArray("red", "blue", "green");
console.log(colors.toPipedString());  // red|blue|green

继承

//定义人类
function Person(){
    this.name = "XiaoQiang";
}

Person.prototype.sayName = function(){
    console.log(this.name);
}

//定义程序员类
function Programmer(){
    this.language = "Android";
}

//实现继承关系
Programmer.prototype = new Person();

//重写sayName方法
Programmer.prototype.sayName = function(){
    console.log(this.name + " is a " + this.language + " Programmer Enginner");
}

var person = new Programmer();
console.log(person.sayName());  //XiaoQiang is a Android Programmer Enginner
console.log(person.constructor == Person);   // true
console.log(person.constructor == Programmer);  // false

上面的继承我们先来理解一下,可能一下子不好理解,但是如果我改一下写法,改成字面量的方式:

Programmer.prototype = {
    this.name = "XiaoQiang";
}

此时,Programmer 的 constructor 指向的是 Object 对象,而我们直接使用 new Person() 相当于创建了一个基于 Person 模板的字面量对象, 而这样 Programmer 的 constructor 和 Person 的 constructor 指向的是同一个地方。

图中虚线框内是实例对象,接下来我们稍微修改一下代码来验证一下。

//定义人类
function Person(){
    this.name = "XiaoQiang";
}

Person.prototype.sayName = function(){
    console.log(this.name);
}

//定义程序员类
function Programmer(){
    this.language = "Android";
}

//实现继承关系
Programmer.prototype = new Person();

//验证代码【1】
Person.prototype.sayHello = function(){
    console.log("Hello!!");
}

//重写sayName方法
Programmer.prototype.sayName = function(){
    console.log(this.name + " is a " + this.language + " Programmer Enginner");
}

//验证代码【2】

function IT(){

}

IT.prototype = new Programmer();


var person = new IT();
console.log(person.sayName());  //XiaoQiang is a Android Programmer Enginner
console.log(person.sayHello());  // Hello!!

多添加了一个 IT 类,实现了多级继承,这样实质上是实现了一种链式查找,也就是前面我们提到过的原型链,如下图(红色线条)。

借用构造函数

我们可以通过 apply()call() 方法,借用父类的构造函数。

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){
    SuperType.call(this);
}

var instance1 = new SubType(); 
instance1.colors.push("blank");
console.log(instance1.colors);  //["red", "blue", "green", "blank"]

var instance2 = new SubType();
console.log(instance2.colors);  //["red", "blue", "green"]

借用构造函数可以向父类里面传递参数,类似于其他语言的 super() 方法。

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

function SubType(){
    SuperType.call(this, "XiaoQiang");
}

var instance1 = new SubType(); 
instance1.colors.push("blank");
console.log(instance1.colors);  //["red", "blue", "green", "blank"]
console.log(instance1.name);    //XiaoQiang

组合继承

组合继承又叫伪经典继承,是将原型链和借用构造函数的技术结合,发挥二者之长的一种继承模式。

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    console.log(this.name);
}

function SubType(name, age){
    SuperType.call(this, name);
    this.age = age;
}

SubType.prototype = new SuperType();

SubType.prototype.sayAge = function(){
    console.log(this.age);
}

var instance = new SubType("XiaoQiang", 28);
instance.colors.push("black");

console.log(instance.colors);  //["red", "blue", "green", "black"]
instance.sayName();  //XiaoQiang
instance.sayAge();   //28

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继承模式。

另外还有一些其他继承实现思路,例如拷贝继承,更多可以参考我的另一篇文章《JavaScript面向对象(1)》