【JS深入】对象,原型与继承

530 阅读16分钟

1. 对象

1.1 对象概述

JS 中的对象有两种形式: 字面量形式与构造形式.

注意:对象的键名只能是字符串和 Symbol 类型

//字面量形式
var myObj = {
    key: value
    // ...
};

//构造形式
var myObj = new Object();
myObj.key = value;

对象是Javascript中的主要类型之一, 简单基本类型不是object, 数组和函数都是对象.

JavaScript中有一些内置的对象, 叫做内建对象,主要是: String, Number, Boolean, Function等. 在js中他们实际上仅仅是内建函数. 通过new操作符可以构建相应的子类型.

ES6 中对象的 key 可以通过表达式计算获得:

var prefix = 'foo';

var myObject = {
    [prefix + 'bar']: 'hello',
    [prefix + 'baz']: 'world'
};

myObject['foobar']; // hello
myObject['foobaz']; // world

对象的拷贝赋值可以参见'函数技巧'中的深拷贝和浅拷贝相关技巧.

1.2 属性描述符

Object.defineProperty()可以用来添加或者修改对象的值的属性, 主要的属性描述符有四个:

名称描述
writable可写性 false 时不可修改属性的值, strict 下会抛出一个异常.
configureable可配置性 只要当前是可配置的, 我们就可以使用 defineProperty()修改器描述符定义, 将这个属性设置为 false 是一个单项操作, 是不可撤销的. 不过 wirteable 还是可以从 true 改成 false, 反向的操作则不行, 并且 delete 会失效
enumerable可枚举性 该描述符决定了属性能否在枚举操作中是否出现, 例如for...in等.
immutability不可变性(浅的)

结合不同的属性描述符, 我们可以实现不同的对对象属性的操作

名称描述
常量writeable: false+configurable:false
不可扩展Object.preventExtension(obj)
封印Object.seal(obj), 本质上是调用不可扩展并且配置configurable:false
冻结Object.freeze(obj), 本质上调用封印, 并且设置writeable:false.

1.3 访问描述符

在访问对象的属性时, 实际上是在执行对象的[[GET]]操作, 它会在对象本身以及原型链上访问属性, 如果没有访问到, 就返回undefined.

相对于[[GET]], 当然也存在[[SET]].

ES5 中有一个方法来覆盖这些默认操作的一部分, 但是不是针对对象级别的, 而是针对每个属性, 通过getterssetters, 调用一个隐藏函数来取得值和属性. 这就是"访问描述符"(类似于"数据描述符"). 对于访问描述符, 其valuewritable是没有意义的. 取而代之的是set,get以及原本的configurableenumerable.

let myObject = {
    get a() {
        return 2;
    }
};

Object.defineProperty(
    myObject, // 目标对象
    'b', // 属性名
    {
        // 描述符
        // 为 `b` 定义 getter
        get: function() {
            return this.a * 2;
        },

        // 确保 `b` 作为对象属性出现
        enumerable: true
    }
);

myObject.a; // 2

myObject.b; // 4

myObject.a = 3;

myObject.a; // 2

1.4 存在性判断

因为不存在的对象会返回一个undefined, 所以我们有时会无法准确的判断属性的存在性. 比如下面这种情况:

var myObject = {
    a: undefined
};

myObject.a; // undefined

myObject.b; // undefined

判断属性的存在性的几种方法:

//in
'a' in myObject; // true
'b' in myObject; // false

//hasOwnProperty
myObject.hasOwnProperty('a'); // true
myObject.hasOwnProperty('b'); // false

//更好的写法: 因为你无法判断给定对象是否存在该方法
Object.prototype.hasOwnProperty.call(myObject, 'a');

inhasOwnProperty的区别在于in会查询原型链, 而hasOwnProperty只会在属性存在于实例中时才返回true.

Objecy.keys()Object.getOwnPropertyName()都不会去查询原型链.

如果需要区分枚举和不可枚举, 可以使用myOject.propertyIsEnumerable("..").

1.5 对象迭代

for...in循环迭代一个对象上(包含原型链)所有可迭代属性.

ES5加入了forEach(),every()some(). 这些方法的每一个都接受一个回调函数, 将用于数组中的每一个元素, 仅仅如何响应回调的返回值上有所不同. forEach()会迭代数组中所有的值,并且忽略回调的返回值. every()会一直迭代到最后,或者当回调返回一个值为false,而some()是等待返回一个 true 值.

ES6 引入了for...of来迭代数据或者对象, 要求迭代对象内部有一个iterator接口, 每次循环都调用一次这个迭代器对象的next()方法, 循环迭代的内容就是这些连续的返回值.

Iterator 是一种接口, 为各种不同的数据结构提供统一的访问机制, 任何数据只要部署 Iterator 接口, 就可以完成遍历操作(即一次处理改数据结构的所有成员). 并主要为for...of提供消费.

Iterator 的遍历过程是这样的:

  1. 创建一个指针对象, 指向当前数据结构的起始位置, 也就是说, Iterator 本质上就是一个指针对象.
  2. 第一次调用 next 方法, 将指针指向第一个成员
  3. 第二次调用 next 方法, 指针就只想数据结构的第二个成员
  4. 不断调用, 直到遍历的结束

这是一个模拟next返回的方法:

var it = makeIterator(['a', 'b']);

it.next(); // { value: "a", done: false }
it.next(); // { value: "b", done: false }
it.next(); // { value: undefined, done: true }

function makeIterator(array) {
    var nextIndex = 0;
    return {
        next: function() {
            return nextIndex < array.length
                ? { value: array[nextIndex++], done: false }
                : { value: undefined, done: true };
        }
    };
}

遍历返回的结果是一个包含valuedone两个属性的对象, 其中value是当前成员的值, done属性是一个布尔值, 表示遍历是否结束.

正常我们可以直接使用for...of来遍历我们想要的对象, 我们可以通过Symbol.iterator来获取内部的iterator接口:

var myArray = [1, 2, 3];
var it = myArray[Symbol.iterator]();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }

1.6 手动部署 Iterator

var myObject = {
    a: 2,
    b: 3
};

Object.defineProperty(myObject, Symbol.iterator, {
    enumerable: false,
    writable: false,
    configurable: true,
    value: function() {
        var o = this;
        var idx = 0;
        var ks = Object.keys(o);
        return {
            next: function() {
                return {
                    value: o[ks[idx++]],
                    done: idx > ks.length
                };
            }
        };
    }
});

// 手动迭代 `myObject`
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }

// 用 `for..of` 迭代 `myObject`
for (var v of myObject) {
    console.log(v);
}
// 2
// 3

2. 原型

什么是原型呢? 可以这理解: 在 js 中的所有对象几乎都有一个内部属性[[Prototype]], 它是一个其他对象的引用.

var anotherObject = {
    a: 2
};

// 创建一个链接到 `anotherObject` 的对象
var myObject = Object.create(anotherObject);

myObject.a; // 2

其中, create主要的内部原理大致:

function _create(obj) {
    function F() {}
    F.prototype = obj;
    return new F()
}

2.1 prototype

每个函数都有一个 prototype 属性, prototype 属性指向了一个对象,这个对象正是调用该构造函数而创建的实例的原型:

function Person() {}
// 虽然写在注释里,但是你要注意:
// prototype是函数才会有的属性
Person.prototype.name = 'Xiaoming';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name); // Xiaoming
console.log(person2.name); // Xiaoming

2.2 __proto__

这是每一个 js 对象(除了 null )都具有的一个属性,叫__proto__,这个属性会指向该对象的原型:

function Person() {}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

2.3 constructor

原型拥有一个属性constructor, 能够指向构造函数:

function Person() {}
console.log(Person === Person.prototype.constructor); // true

这三者(构造函数, 实例原型, 实例)的关系就像是下图所示:

alt

2.4 原型链

在上面的代码中,Person.prototype作为person的原型, 它本身也是一个对象, 因此也应当存在自己的原型, 那就是Object.prototype. 原型的原型会一直这样链接上去, 到尽头就是内置类型Object. 期间由__proto__形成的链接, 就是原型链:

alt

2.5 小结

  1. __proto__constructor属性是对象所独有的;
  2. prototype属性则是函数所独有的
  3. 函数也是一种对象,所以函数也拥有__proto__constructor属性

__proto__是对象独有的, 它指向一个对象的原型(原型链). prototype是函数独有的, 从一个函数指向一个对象原型, 含义为函数的原型对象, 也就是这个函数所创建的实例的原型对象. prototype的作用在于包含可以由特定类型的所有实例共享的属性和方法, 也就是可以让该函数所实例化的对象们都可以找到公用的属性和方法. 任何函数在创建的时候, 都会默认创建该函数的prototype对象.

contrructor也是对象才拥有的, 从一个对象指向一个函数, 含义就是指向该对象的构造函数.

关系图:

3. 类的继承

类在传统面向对象编程中是最常见的概念, 有一些很重要的概念, 比如构造器, 继承, 多态, 多继承等等在程序构建中是非常重要的概念.

因为 js 语言的灵活性, 有许多中实现和模拟继承的写法. 现在一一介绍.

3.1 原型链继承

原型继承:把父类的私有+公有的属性和方法,都作为子类公有的属性。

核心:不是把父类私有+公有的属性克隆一份一模一样的给子类的公有属性;他是通过__proto__建立和子类之间的原型链,当子类的实例需要使用父类的属性和方法的时候,可以通过__proto__一级级找上去使用;

//父类
function Parent() {
    this.x = 199;
    this.y = 299;
}
Parent.prototype.say = function() {
    console.log('say');
};

//子类
function Child() {
    this.g = 90;
}

Child.prototype = new Parent(); //继承

var p = new Parent();
var c = new Child();
console.dir(c);

但是,需要我们注意一点的是,有的时候我们需要在子类中添加新的方法或者是重写父类的方法时候,切记一定要放到替换原型的语句之后。

function Parent() {
    this.x = 199;
    this.y = 299;
}
Parent.prototype.say = function() {
    console.log('say Person');
};
function Child() {
    this.z = 90;
}
/*Child.prototype.Bs = function(){
	console.log('Bs')
}*/
//在这里写子类的原型方法和属性是没用的因为会改变原型的指向,所以应该放到重新指定之后
Child.prototype = new Parent();
Child.prototype.constructor = Child; //由于重新修改了Child的原型导致默认原型上的constructor丢失,我们需要自己添加上,其实没啥用,加不加都一样
Child.prototype.sayB = function() {
    console.log('say B');
};
Child.prototype.say = function() {
    console.log('say Child');
};
var p = new Parent();
var c = new Child();
console.dir(c);
c.sayB(); // say B
c.say(); // say Child
p.say(); //say Person 不影响父类实例访问父类的方法

原型继承的问题:

1、子类继承父类的属性和方法是将父类的私有属性和公有方法都作为自己的公有属性和方法,我们要清楚一件事情就是我们操作基本数据类型的时候操作的是值,在操作应用数据类型的时候操作的是地址,如果说父类的私有属性中引用类型的属性,那他被子类继承的时候会作为公有属性,这样子类一操作这个属性的时候,会影响到子类二。

2、在创建子类的实例时,不能向父类型的构造函数中传递参数。应该说是没有办法在不影响所有对象实例的情况下,给父类的构造函数传递参数

所以在实际中很少单独使用原型继承。

3.2 Call 继承

使用父类的构造函数来增强子类实例,等同于复制父类的实例给子类(不使用原型)

function Parent() {
    this.x = 100;
    this.y = 199;
}
Parent.prototype.fn = function() {};

function Child() {
    this.d = 100;
    Parent.call(this); //构造函数中的this就是当前实例
}
var p = new Parent();
var c = new Child();
console.log(p); //Parent {x: 100, y: 199}
console.log(c); //Child {d: 100, x: 100, y: 199}

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性/方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能

3.3 冒充对象继承

冒充对象继承的原理是循环遍历父类实例,然后父类实例的方法全部拿过来添加给子类实例

function Parent() {
    this.x = 100;
}
Parent.prototype.getX = function() {
    console.log('getX');
};
function Child() {
    var p = new Parent();
    for (var attr in p) {
        //for in 可以遍历到原型上的公有自定义属性
        this[attr] = p[attr];
    }
    //以下代码是只获得到私有方法和属性,如果不加这个的话就可以遍历到所有方法和属性
    /*if(e.hasOwnProperty(attr)){
		this[attr] = e[attr]
	}
	e.propertyIsEnumerable()*/ //可枚举属性==>  可以拿出来一一列举的属性
}
var p = new Parent();
var c = new Child();
console.dir(c);

3.4 混合继承

用原型链实现对原型方法的继承,利用构造函数继承属性. 本质上就是call集成和原型链继承的混合:

function Parent() {
    this.x = 100;
}
Parent.prototype.getX = function() {};

function Child() {
    Parent.call(this);
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;

var p = new Parent();
var c = new Child();
console.log(c); //Child {x: 100}

混合继承有多重方式,这种是 call 和原型混合的,你也可以 call 和冒充对象继承混合,等等

这种混合继承的最大问题就是无论在什么情况下,都会调用两次构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数的内部,没错,子类型最终会包含父类型对象的全部实例属性,但我们不得不在调用子类构造函数时重写这些属性。

下面是另一种混合继承: call继承 + 拷贝继承

//混合继承:call继承+拷贝继承
function extend(newEle, oldEle) {
    for (var attr in oldEle) {
        newEle[attr] = oldEle[attr];
    }
}

function F() {
    this.x = 100;
    this.showX = function() {};
}
F.prototype.getX = function() {};
F.prototype.getX1 = function() {};

var f1 = new F();
console.dir(f1);

function S() {
    F.call(this); //call继承
}
extend(S.prototype, F.prototype); //拷贝继承
S.prototype.cc = function() {};

var p1 = new S();
console.dir(p1);

3.5 寄生式继承

核心:在原型式继承的基础上,增强对象,返回构造函数

// “传统的 JS 类” `Vehicle`
function Vehicle() {
    this.engines = 1;
}
Vehicle.prototype.ignition = function() {
    console.log('Turning on my engine.');
};
Vehicle.prototype.drive = function() {
    this.ignition();
    console.log('Steering and moving forward!');
};

// “寄生类” `Car`
function Car() {
    // 首先, `car` 是一个 `Vehicle`
    var car = new Vehicle();

    // 现在, 我们修改 `car` 使它特化
    car.wheels = 4;

    // 保存一个 `Vehicle::drive()` 的引用
    var vehDrive = car.drive;

    // 覆盖 `Vehicle::drive()`
    car.drive = function() {
        vehDrive.call(this);
        console.log('Rolling on all ' + this.wheels + ' wheels!');
    };

    return car;
}

var myCar = new Car();

myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!

3.6 寄生组合式继承

寄生式组合: call继承 + Object.create();

所谓寄生组合式继承就是通过借用构造函数来继承属性,通过原型链的混合形式来继承方法。 基本思路是不必为了指定子类的原型而调用父类的构造函数,我们所需要的无非就是父类型原型的一个副本而已。 本质上,就是使用寄生式继承父类的原型,然后再将结果指定给子类的原型。

// 继承原型链
function inheritPrototype(subType, superType) {
    var prototype = Object.create(superType.prototype); // 创建对象,创建父类原型的一个副本
    prototype.constructor = subType; // 增强对象,弥补因重写原型而失去的默认的constructor 属性
    subType.prototype = prototype; // 指定对象,将新创建的对象赋值给子类的原型
}

// 父类初始化实例属性和原型属性
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function() {
    alert(this.name);
};

// 借用构造函数传递增强子类实例属性(支持传参和避免篡改)
function SubType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}

// 将父类原型指向子类
inheritPrototype(SubType, SuperType);

// 新增子类原型属性
SubType.prototype.sayAge = function() {
    alert(this.age);
};

var instance1 = new SubType('xyc', 23);
var instance2 = new SubType('lxy', 23);

instance1.colors.push('2'); // ["red", "blue", "green", "2"]
instance2.colors.push('3'); // ["red", "blue", "green", "3"]

3.7 经典继承(道格拉斯继承)

//功能封装
function create(o) {
    function F(){}
    F.prototype=o;
    return new F();
}

var o={name:"张三",age:18};
var o2=create(o);//这样o2就继承自o了

3.8 ES6 extends

extends 关键字主要用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。其中 constructor 表示构造函数,一个类中只能有一个构造函数,有多个会报出 SyntaxError 错误,如果没有显式指定构造方法,则会添加默认的 constructor 方法,使用例子如下。

class Rectangle {
    // constructor
    constructor(height, width) {
        this.height = height;
        this.width = width;
    }

    // Getter
    get area() {
        return this.calcArea();
    }

    // Method
    calcArea() {
        return this.height * this.width;
    }
}

const rectangle = new Rectangle(10, 20);
console.log(rectangle.area);
// 输出 200

// 继承
class Square extends Rectangle {
    constructor(length) {
        super(length, length);

        // 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
        this.name = 'Square';
    }

    get area() {
        return this.height * this.width;
    }
}

const square = new Square(10);
console.log(square.area);
// 输出 100

extends 继承的核心代码如下,其实和上述的寄生组合式继承方式一样:

function _inherits(subType, superType) {
    // 创建对象,创建父类原型的一个副本
    // 增强对象,弥补因重写原型而失去的默认的constructor 属性
    // 指定对象,将新创建的对象赋值给子类的原型
    subType.prototype = Object.create(superType && superType.prototype, {
        constructor: {
            value: subType,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });

    if (superType) {
        Object.setPrototypeOf ? Object.setPrototypeOf(subType, superType) : (subType.__proto__ = superType);
    }
}

4. Class 关键字

ES6 提出了 Class 关键字来实现 js 中的类的写法.

  1. class 声明会提升, 但是不会进行赋值, 存在 TDZ(暂时性死区), 类似于let,const:
const bar = new Bar(); // it's ok
function Bar() {
    this.bar = 42;
}

const foo = new Foo(); // ReferenceError: Foo is not defined
class Foo {
    constructor() {
        this.foo = 42;
    }
}
  1. class 内部会启用严格模式
// 引用一个未声明的变量
function Bar() {
    baz = 42; // it's ok
}
const bar = new Bar();

class Foo {
    constructor() {
        fol = 42; // ReferenceError: fol is not defined
    }
}
const foo = new Foo();
  1. class 的所有方法(包括静态方法和实例方法)都是不可枚举的。
// 引用一个未声明的变量
function Bar() {
    this.bar = 42;
}
Bar.answer = function() {
    return 42;
};
Bar.prototype.print = function() {
    console.log(this.bar);
};
const barKeys = Object.keys(Bar); // ['answer']
const barProtoKeys = Object.keys(Bar.prototype); // ['print']

class Foo {
    constructor() {
        this.foo = 42;
    }
    static answer() {
        return 42;
    }
    print() {
        console.log(this.foo);
    }
}
const fooKeys = Object.keys(Foo); // []
const fooProtoKeys = Object.keys(Foo.prototype); // []
  1. class 的所有方法(包括静态方法和实例方法)都没有原型对象 prototype,所以也没有[[construct]],不能使用 new 来调用:
function Bar() {
    this.bar = 42;
}
Bar.prototype.print = function() {
    console.log(this.bar);
};

const bar = new Bar();
const barPrint = new bar.print(); // it's ok

class Foo {
    constructor() {
        this.foo = 42;
    }
    print() {
        console.log(this.foo);
    }
}
const foo = new Foo();
const fooPrint = new foo.print(); // TypeError: foo.print is not a constructor
  1. 必须使用 new 调用class
function Bar() {
    this.bar = 42;
}
const bar = Bar(); // it's ok

class Foo {
    constructor() {
        this.foo = 42;
    }
}
const foo = Foo(); // TypeError: Class constructor Foo cannot be invoked without 'new'
  1. class 内部无法重写类名。
function Bar() {
    Bar = 'Baz'; // it's ok
    this.bar = 42;
}
const bar = new Bar();
// Bar: 'Baz'
// bar: Bar {bar: 42}

class Foo {
    constructor() {
        this.foo = 42;
        Foo = 'Fol'; // TypeError: Assignment to constant variable
    }
}
const foo = new Foo();
Foo = 'Fol'; // it's ok

5. 关于JS中的类和对象

思考下原型链的意义: 为什么一个对象需要链到另一个对象? 在理解这个问题之前, 我们先要理解[[Prototypr]]不是什么.

在JavaScript中, 对于对象来说没有抽象模式, 也就是灭有面向类的语言中那样被称为类的东西. JS只有对象.

实际上, 在所有语言中, JS的这个特定几乎是独一无二的. 因为可以根本没有类而直接创建对象的语言很少. JS就是其中之一. 在JS中, 类不能描述对象可以做什么. 对象直接定义它自己的行为. 这里只有 对象

"类" 函数

在上面的代码中, 我们创建了很多看起来像类的东西.

"某种程度的类"这种奇特的行为取决于了函数的一个奇怪性质: 所有函数都会得到一个公有的, 不可枚举的属性. 称为prototype. 它可以指向任意的对象.

function Foo() {
	// ...
}

Foo.prototype; // { }

这个对象就被称为"Foo 的原型", 因为我们通过一个Foo.prototype去访问它.

解释这个东西的最直接的方式是: 每个由调用new Foo()而创建的对象将最终被[[Prototype]]链接到这个Foo.prototype对象.

function Foo() {
	// ...
}

var a = new Foo();

Object.getPrototypeOf( a ) === Foo.prototype; // true

在面向类的语言中, 可以制造一个类的多个实例, 就像从模具中冲压出某些东西一样. 这是因为初始化类的处理意味着: 将行为从这个类拷贝到物理对象中.

但是在JS中, 没有这样的拷贝处理发生. 你不会创建类的多个实例. 你可以创建多个对象. 他们的[[Proptotype]]连接到一个共同对象. 但默认的, 没有拷贝发生. 这些对象彼此间不是完全分离和切断关系的, 而是链接在一起.

实际上, new Foo()函数调用和建立链接的处理没有任何直接关系. 它是某种欧安的副作用. new Foo()是一个间接的, 迂回的方法来得到我们想要的: 一个被链接到另一个对象的对象.

参考链接