【读】JavaScript之面向对象

962 阅读16分钟

本篇是JavaScript高级程序设计第三版第六章《面向对象的程序设计》阅读记录。如有疑问可以联系我

理解对象

根据ECMA-262,对象为无序属性的集合,其属性可以包含基本值、对象、或函数。下面是个例子:

var person = new Object();
person.name = 'GY';
person.age = 18;
person.sayHi = function() {
    alert('Hi!')
};

// 或者可以这样

var person = {
    name: 'GY',
    age: 18,
    sayHi: function() {
        alert('Hi!');
    }
};

这里完美的展现了JavaScript对象无序属性的集合的定义。

属性类型

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

用来描述属性(property)各种特征的,称之为特性(attribute)。对比属性描述对象。这些特性不能直接访问,常用[[name]]来描述,比如[[configurable]]

  • 数据属性

    可以写入和读取值。该种属性有4个特性:

    • [[value]],写入和读取时,都操作的是这里。默认值undefined
    • [[configurable]],表示是否能通过delete从对象中删除该属性、能否修改该属性的特性、能否把属性修改为访问器属性。默认值true
    • [[enumerable]],表示是否能通过for-in遍历该属性。默认值true
    • [[writable]],表示是否可以修改该属性的值。默认值true

    比如上面定义的person,其中的属性值,都存储在其[[value]]特性中。

    那么如何操作这些特征值呢?先来看看如何修改,使用Object.defineProperty方法。

    /// 参数依次为:
    /// 需要操作的对象(这里为person),
    /// 需要操作的属性(这里为name),
    /// 特征值(类型为对象)
    Object.defineProperty(object, 'property', attributes);
    

    比如对对于上面的person对象,我们写出如下代码

    Object.defineProperty(person, 'name', {
        writable: false,
        value: 'OtherName'
    });
    
    console.log(person.name); // OtherName
    person.name = '任意值';
    console.log(person.name); // OtherName
    

    这里通过Object.definePropertyname属性的writable特性定义为false,那么name属性将为只读属性。无法再次赋值。对writable特性为false的属性赋值,非严格模式下会忽略,严格模式下会抛出错误Cannot assign to read only property 'name' of object

    相应的,可以通过该方法修改configurableenumerable特性。值得注意的是,对configurable设置为false后,将导致configurableenumerable不能再次更改。

    Object.defineProperty(person, 'name', {
        configurable: false
    });
    
    console.log(person.name); // GY
    // delete person.name; // 严格模式会报错
    
    // 在configurable: false这里将不能再次修改
    // Cannot redefine property: name at Function.defineProperty
    Object.defineProperty(person, 'name', {
        configurable: true,
        enumerable: true,
        
    });
    
  • 访问器属性

    该类型属性不存储值,没有[[value]]。包含getset函数(这两者都是非必须的)。在读取和写入时将调用对应函数。访问器属性有4个特性:[[configurable]][[enumerable]][[get]][[set]].

    访问器属性不能直接定义,需要使用Object.defineProperty,比如:

    var person = {
        // 下划线通常表示需要通过对象方法访问。规范!
        _age: 0
    };
    
    Object.defineProperty(person, 'age', {
        get: function() {
            return this._age;
        },
        set: function(v) {
            this._age = v >= 0 ? v : 0;
        }
    });
    

    如只提供get,意味着该属性只读;只提供set,读取会返回undefined

    若你想一次定义多个属性及其特性,可以使用Object.defineProperties,向下面这样:

    var person = {
        // 下划线通常表示需要通过对象方法访问。规范!
        _age: 10
    };
    
    Object.defineProperties(person, {
        age: {
            get: function() {
                return this._age;
            },
            set: function(v) {
                this._age = v >= 0 ? v : 0;
            }
        },
        name: {
            value: 'unnamed',
            writable: true,
            enumerable: true
        }
    });
    
  • 如何获取属性特征

    使用Object.getOwnPropertyDescriptor方法

    var descriptor = Object.getOwnPropertyDescriptor(person, 'name');
    console.log(descriptor.value + ' ' + descriptor.writable + ' ' + descriptor.enumerable);
    

创建对象

这一节,将会介绍多种创建对象的方法。

工厂模式

/// 工厂模式
/// 这种模式减少了创建多个相似对象的重复代码,
/// 但无法解决对象识别问题(即怎样知道对象的类型)
function createPerson(name, age) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.sayHi = function() {
        alert('Hi! I\'m ' + this.name);
    }
    return o;
}

var instance = createPerson('GY', 18);
person.sayHi();

构造函数模式

/// 构造函数模式
/// 这种方式需要显示的使用 new 关键字
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHi = function() {
        alert('Hi! I\'m ' + this.name);
    };
}

var instance = new Person('GY1', 18);
person.sayHi();

这里不像工厂模式那样直接创建对象,但最终结果相同。主要因为使用new关键字会经历下面几个步骤:

  • 创建对象
  • 将构造函数的作用域赋值给新对象(因此this就指向了该新对象)
  • 执行构造函数中的代码
  • 返回新对象

这里可以通过person.constructor === Person明确知道其类型。使用instanceof检测也是通过的。

alert(instance.constructor === Person); // true
alert(instance instanceof Person); // true
  • 构造函数也是函数,可以不通过new直接使用

    任何函数通过new来调用,都可以作为构造函数;任何函数,不通过new调用,那它跟普通函数也没什么两样。

    由于构造函数中使用了this,不通过new来使用,this将指向global对象(浏览器中就是window)。

    // 这样直接调用会在window对象上定义name和age属性
    Person('Temp', 10);
    
  • 构造函数也存在问题

    使用构造函数的主要问题是,每一个方法都会在每个对象上重新定义一遍。每个对象上的方法都是不相等的。这样会造成内存浪费以及不同的作用域链和标识符解析。很明显,这样是没必要的。

    我们可以使用下面的方法来避免:

    function sayHi() {
        alert(this.name);
    }
    
    function Person(name, age) {
        this.name = name;
        this.age = age;
        this.sayHi = sayHi;
    }
    
    var instance1 = new Person('instance1', 18);
    var instance2 = new Person('instance2', 20);
    

    就是把每个方法都单独定义,在构造函数内部引用。这样又引发了新的问题:全局域上定义的函数实际为某些对象而服务,这样全局域有点名不副实。其次,如果对象上需要有很多方法,那么这些方法都需要在全局域上定义。

    再来看看下面生成对象的方法。

原型模式

每个函数都有一个prototype(原型)属性,这是一个指针,指向一个对象,该对象是用来包含特定类型所有实例共享的属性和方法的。 这样,之前在构造函数中定义的实例信息就可以写在原型对象中了。如下:

function Person() {
}

Person.prototype.name = 'unnamed';
Person.prototype.age = 18;
Person.prototype.sayHi = function() {
    alert(this.name);
}

var instance1 = new Person();
alert(instance1.name);
var instance2 = new Person();
alert(instance2.name);

继续往下之前,先来了解下原型对象:

无论什么时候,只要创建了一个新函数,就会根据特定规则为该函数创建prototype属性,这个属性指向函数的原型对象。默认情况下,该原型对象还会拥有constructor(构造函数)属性,指向该函数。当然,也包含从Object对象继承来的属性(这个我们后面再讲)。

在通过构造函数创建新实例对象后,每个实例对象可以通过__proto__来访问构造函数的原型对象。

下面是他们的关系图:

我们可以使用Person.prototype.isPrototypeOf(instance1)来检测一个对象(这里为Person的prototype对象)是否为指定对象(这里为instance1)的原型。

推荐使用Object.getPrototypeOf(instance1)获取指定对象的原型对象,而不是使用__proto__

上面的例子中,实例对象中均没有name属性,却能够访问到。也正是因为原型对象的原因:当代码获取某个对象属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始,如果找到对应属性,返回该值;如果没有找到,继续搜索原型对象。

从搜索过程可以发现,实例对象和原型对象都有的属性,实例中的会覆盖原型中的。使用delete删除时,只是删除了实例中的属性。

使用hasOwnProperty方法可以检测一个属性是否在实例中。只有给定属性存在于实例中,才会返回true。

使用in操作符时(property in object),不管是在实例中,还是原型中都会返回true。

使用for-in操作时,返回的是所有能够通过对象访问的、可枚举的属性,不管是在实例中,还是原型中;

既然原型对象也是对象,那我们可以手动赋值原型对象,从而减少不必要的输入。向下面这样:

function Person() {
}

Person.prototype = {
    constructor: Person, // constructor记得要声明
    name: 'unnamed',
    age: 0,
    sayHi: function() {
        alert('Hi! this is ' + this.name);
    }
};

注意:原生的constructor是不可枚举的,这样定义后,导致constructor也可以枚举。

由于在原型中查找值是一次搜索过程,这就导致了原型的动态性。也就是说我们对原型对象的操作都会立刻反应在实例对象上。但是,如果我们重新赋值了构造函数的原型对象,那在赋值之前创建的对象将不受影响,原因是之前的实例对象指向的原型和现在的原型是完全不同的两个对象了。

到这里,你也许就能理解原生对象的方法都存储在其原型对象上了吧。

那么使用原型创建对象的方法是不是没有问题了呢?答案是否定的。首先,其省略了构造函数传参的环节,结果就是所有实例默认会有相同的属性值;其次,由于共享属性,在属性值为引用类型时,一个实例修改属性会影响另一个实例。

function Person() {
}

Person.prototype = {
    constructor: Person, // constructor记得要声明
    name: 'unnamed',
    age: 0,
    sayHi: function() {
        alert('Hi! this is ' + this.name);
    },
    friends: ['A', 'B'] // 增加了friends属性
};

var instance1 = new Person();
var instance2 = new Person();

instance1.friends.push('C'); // 修改instance1的friends属性
alert(instance2.friends); // 但instance2的friends属性同样也改变成了A, B, C

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

该模式使用构造函数定义实例属性,使用原型定义共享的方法和属性。

/// 定义实例属性
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.friends = [];
}

/// 定义公共方法
Person.prototype = {
    constructor: Person, // constructor记得要声明
    sayHi: function() {
        alert('Hi! this is ' + this.name);
    }
};

var instance1 = new Person('instance1', 18);
var instance2 = new Person('instance2', 20);

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

动态原型

也许你对上面的组合模式将属性和方法分开来写的形式感到别扭。那么动态原型模式可以来拯救你。

动态原型模式,将组合模式中分开的代码合并在一起,通过判断动态的添加共享的方法或属性。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.friends = [];

    // 这里就是动态原型模式和混合模式的区别
    // 对于共享的属性或方法,你不必每一个都去判断
    // 找到一个标准就行
    if (typeof this.sayHi != 'function') {
        Person.prototype.sayHi = function() {
            alert('Hi! I\'m ' + this.name);
        };
    }
}

var instance1 = new Person('instance1', 18);
instance1.sayHi();

注意:这里不能使用字面量语法重写原型属性,这样会切断之前创建的实例和现有原型的联系。

寄生构造函数模式

寄生构造函数模式提供一个函数用于封装创建对象的代码.这和工厂模式极为相似。

function Person(name, age) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.sayHi = function() {
        alert('Hi! I\'m ' + this.name);
    };
    return o; 
}

var instance = new Person('instance', 18);
instance.sayHi();

既然函数内部并没有使用new操作生成的实例对象,为啥还要生成?这个点暂时没搞懂。

可以看到,和工厂模式相比,除了使用new操作符以为,其他的没啥两样。但是在一些特定场合,还是有它的用武之地的。比如ES6之前原生类型是无法继承的,可以使用这种方法生成继承原生类型的实例

看下这个例子:

function SpecialArray() {
    var values = new Array();
    values.push.apply(values, arguments);
    // 提供新方法
    values.toPipedString = function() {
        return this.join('|');
    };
    
    return values;
}

var colors = new SpecialArray('red', 'blue', 'green');
alert(colors.toPipedString());

这里提供了构造函数,内部使用Array对象,并添加了特有方法。

之所以叫做寄生,原因大概因为新的功能依托于原有对象吧。

稳妥构造函数模式

所谓稳妥,指没有公共属性,而且其方法不引用this。在该模式中不适用new来调用构造函数。下面是个例子:

function Person(name, age) {
    var o = new Object();
    o.sayHi = function() {
        alert('Hi! I\'m ' + name);
    }
    return o;
}

var instance = Person('instance', 18);
instance.sayHi();

也许你会纳闷,这里的name没有显示的存储,到底如何能访问到?请看下面的断点截图。说明了其存储在闭包中,或者说被闭包捕获了。(若理解有误请告知。谢谢!)

继承

ECMAScript中依靠原型链来实现继承。

原型链

简单回顾下构造函数、原型、和实例之间的关系:构造函数也是对象,该对象拥有prototype属性,指向了其原型对象,原型对象存在一个constructor属性,指向了该构造函数;实例对象拥有__proto__属性,也指向了构造函数的原型对象(再次说明下,__proto__不推荐使用哈,)。

现在,如果让构造函数的prototype属性指向另一个类型的实例对象呢?上面的情况会层层递进。让我们看下面的例子:

// 父类型
function SuperType() {
    this.property = true;
}

SuperType.prototype.getSuperValue = function() {
    return this.property;
}

// 子类型
function SubType() {
    this.subproperty = false;
}

// 子类型的prototype指向父类型的实例对象
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function() {
    return this.subproperty;
}

var instance = new SubType();
alert(instance.getSuperValue()); // true

对应关系图:

值得注意的是,instance.constructor将得到的是SuperType。

  • 默认的原型

    所有引用类型都继承了Object,也就是说所有函数的prototype指向了Object实例。让我们更新下上面的关系图:

  • 确定原型和实例的关系 可以使用instanceofisPrototypeOf方法

    alert(instance instanceof Object);
    alert(instance instanceof SuperType);
    alert(instance instanceof SubType);
    alert(Object.prototype.isPrototypeOf(instance));
    alert(SuperType.prototype.isPrototypeOf(instance));
    alert(SubType.prototype.isPrototypeOf(instance));
    

    这里都会返回true。判断规则为:实例的原型在原型链中。我们可以使用下面的方法进行模拟。

    /// 对象是否是指定类型的实例,会查找原型链
    Object.prototype.isKindsOf = function(func) {
        for (
            let proto = Object.getPrototypeOf(this); 
            proto !== null; 
            proto = Object.getPrototypeOf(proto)
        ) {
            if (proto === func.prototype) {
                return true
            }
        }
        return false
    }
    
    /// 对象是否是指定类型的实例,不进行原型链判断
    Object.prototype.isMemberOf = function(func) {
        return Object.getPrototypeOf(this) === func.prototype
    }
    
  • 原型链存在的问题

    通过原型实现继承,是将子类的原型对象赋值为父类(这里暂时使用子类和父类来表述)的实例,这样,原先父类的实例属性成了子类原型属性,会被子类的所有实例共享,这也包含引用类型的属性。

    再者,创建子类类型实例时,无法向父类的构造函数中传递参数。

    下面我们一起看看如何解决这些问题。

借用构造函数

// 父类型
function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

// 子类型
function SubType() {
    // 使父类的构造函数在子类实例对象上初始化
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push('gray');
var instance2 = new SubType();
alert(instance2.colors); // red, blue, green

这里在构造子类实例时,调用父类构造函数,完成父类特定的初始化。 像下面这样,还可以完成参数的传递。

// 父类型
function SuperType(name) {
    this.name = name;
}

// 子类型
function SubType() {
    // 使父类的构造函数在子类实例对象上初始化
    // 这样子类的属性就会覆盖其原型属性
    SuperType.call(this, 'unnamed');
}

var instance = new SubType();
alert(instance.name); // unnamed

借用构造函数也存在一些问题。比如,方法无法实现复用;父类原型中定义的方法对子类不可见(因为这种情形子类和父类并没有原型链上的关系,只是子类在构造过程中借用了父类的构造过程)。

组合继承

将原型链和借用构造函数组合一起,使用原型链实现对原型属性和方法的继承,借用构造函数实现实例属性的继承。这成为最常用的继承模式。下面是一个例子:

// 父类型
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayHi = function() {
    alert('Hi! I\'m ' + this.name);
};

// 子类型
function SubType(name, age) {
    // 使父类的构造函数在子类实例对象上初始化, 完成实例属性的继承
    SuperType.call(this, name);
    // 子类特有的属性
    this.age = age;
}

// 子类型的prototype指向父类型的实例对象,完成继承
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function(){ 
    alert(this.name + ' is ' + this.age + ' years old!');
}

var instance1 = new SubType('instance1', 18);
instance1.colors.push('black');
alert(instance1.colors); // red,blue,green,black
instance1.sayHi(); // Hi! I'm instance1
instance1.sayAge(); // instance1 is 18 years old!

var instance2 = new SubType('instance2', 20);
alert(instance2.colors); // red,blue,green
instance2.sayHi(); // Hi! I'm instance2
instance2.sayAge(); // instance2 is 20 years old!

原型式继承

这是一种借助已有对象创建新对象,同时不必创建自定义类型的方法。先看下面的例子:

function object(o) {
    /// 创建临时构造函数
    function F(){}
    /// 将构造函数的原型赋值为传入的对象
    F.prototype = o;

    /// 返回实例
    return new F();
}

var person = {
    name: 'instance1',
    friends: ['gouzi', 'maozi']
};

var anotherPerson = object(person);
anotherPerson.name = 'cuihua';
anotherPerson.friends.push('xiaofeng');

var yetAnotherPerson = object(person);
yetAnotherPerson.name = 'daha';
yetAnotherPerson.friends.push('bob');

alert(person.friends);

可以看到,这里相当于复制了person的两个副本。在ECMAScript5中,新增了Object.create方法来规范了原型继承模式。

/// 可以只传入一个参数
var anotherPerson = Object.create(person);

// 也可多传入属性及其特性
var yetAnotherPerson = Object.create(person, {
    name: {
        value: 'dab'
    }
});

寄生式继承

相比原型继承,寄生式继承仅是提供一个函数,用来封装对象的继承过程。如下:

function createAnother(original) {
    /// 向原型继承一样,创建新对象
    var clone = Object.create(original)
    /// 自定义的增强过程
    clone.sayHi = function() {
        alert('Hi!');
    }
    return clone;
}

可以发现,该模式,无法对函数进行复用。

寄生组合模式

回顾下之前的组合继承方式,这会导致两次父类构造函数调用:一次在创建子类原型,另一次在创建子类实例。这将导致,子类的实例和原型中都存在父类的属性。如下:

// 父类型
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayHi = function() {
    alert('Hi! I\'m ' + this.name);
};

// 子类型
function SubType(name, age) {
    // 使父类的构造函数在子类实例对象上初始化, 完成实例属性的继承
    SuperType.call(this, name); // 又一次父类构造函数调用
    // 子类特有的属性
    this.age = age;
}

// 子类型的prototype指向父类型的实例对象,完成继承
SubType.prototype = new SuperType(); // 一次父类构造函数调用
SubType.prototype.sayAge = function(){ 
    alert(this.name + ' is ' + this.age + ' years old!');
}

var instance = new SubType();
instance.colors.push('gray');
alert(instance.colors); // red,blue,green,gray
alert(instance.__proto__.colors); // red,blue,green

为了解决这个问题,可以考虑使用构造函数继承属性,使用原型链来继承方法;不必为了指定子类的原型而调用父类的构造函数(这样就避免了生成父类的实例,因为父类的原型对象已经存在了),我们需要的就是原型对象的一个副本而已。看看下面的例子:

// 父类型
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayHi = function() {
    alert('Hi! I\'m ' + this.name);
};

// 子类型
function SubType(name, age) {
    // 使父类的构造函数在子类实例对象上初始化, 完成实例属性的继承
    SuperType.call(this, name); // 又一次父类构造函数调用
    // 子类特有的属性
    this.age = age;
}

/// 使用寄生的方式完成继承
inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function(){ 
    alert(this.name + ' is ' + this.age + ' years old!');
}

function inheritPrototype(subType, superType) {
    // 获得父类原型副本,作为子类的原型
    var prototype = Object.create(superType.prototype);
    // 配置子类原型
    prototype.constructor = subType;
    // 指定子类原型
    subType.prototype = prototype;
}

var instance = new SubType();
instance.colors.push('gray');
alert(instance.colors); // red,blue,green,gray
alert(instance.__proto__.colors); // undefined

至此,我们找到了一种最理想的继承模式。

总结

该篇从对象的含义,到对象的创建方式,再到继承的多种实现。由浅入深的介绍了ECMAScript中面向对象相关知识。也许你在阅读过程中感到疑惑,那么就动手实现一遍...那时,就不必多说什么了!

参考