深入理解混合对象“类”和原型

Posted by csy on 2019-08-26

1. 混合对象“类”

javascript中类和其他语言中的类完全不同。传统面向类的语言中父类和子类、子类和实例之间其实是复制操作,但是在javascript中并没有复制,对象之间是通过内部的 [[Prototype]] 链委托关联的,这样一个对象就可以通过委托访问另一个对象的属性和函数。

多态看起来像是从子类引用父类,但是本质上就是引用。继承的本质是重写原型对象,代之以一个新类型的实例。

混入模式(显式混入、隐式混入)可以用来模拟类的复制行为,但是很丑陋。而且显式混入无法完全模拟类的复制行为,因为对象只能复制对共享函数对象的引用。

1.1 类式继承(构造函数继承)

即在子类构造函数的内部调用父类构造函数,使得自身获得父类的方法和属性。

  • 优点:
    • 可以定义私有属性方法
  • 缺点:
    • 因为继承的是对象本身,每次实例化都保存内存中,有性能问题
    • 不能定义共享属性方法/或写在外面失去了封装性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Father() {
  this.name = name
  this.friends = friends // 👍 可以定义私有 引用类型不会被共享
  this.share = share // ❌ 可以定义公有 但需要放在外部
  this.log = log // ❌ 避免重复声明,为了复用需要放在外面。
}
// ❌ 公有属性和方法定义在外面失去了封装性
let share = [1, 2, 3]
function log() {
    return this.name
}

function Child(name) {
  Father.call(this, name)// 👍 可以在子类传递参数给父类
}

var a = new Child('a')

1.2 原型继承

即子类型从超类型的原型对象里继承方法

  • 优点:
    • 父类的方法得到了复用,可以定义公有属性方法
  • 缺点:
    • 不能定义私有属性方法
    • 如果父类包含引用类型的属性,那么所有子类的实例都会共享该属性
    • 在创建子类实例时,不能向父类的构造函数传递参数
    • 封装性一般
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Father() {}
Father.prototype = {
    constructor: Father, 
    name: 'csy', // ❌ 不能定义私有属性,全部都是公有
    friends: ['alice', 'troy'], // 👍 可以定义公有属性 所有实例都引用这个
    log: function() { // 👍 方法被共享了
        return this.name
    }
}

function Child() {}

//ES6之前的写法
Child.prototype = Object.create(Father.prototype) //原型继承
Child.prototype.constructor = Child
//ES6写法 不过ES6就直接用class语法糖了,一般不用写原型继承
//Object.setPrototypeOf(Child.prototype, Father.prototype)

var a = new Child('a')
console.log(a.myName());

网上很多博客原型继承的方式是 Child.prototype = new Father()。«你不知道的javascirpt»书中说道这种用法有副作用。虽然会创建一个关联到 Child.prototype 的新对象,但是它使用了 Father(..) 的“构造函数调用”,如果函数 Father 有一些副作用(比如写日志、修改状态、注册到其他对象、给 this 添加数据属性,等等)的话,就会影响到 Child() 的“后代”。

1.3 组合继承

结合类式继承和组合继承,用类式继承属性,而原型继承方法。

  • 优点:
    • 可以定义私有属性,引用属性不会被共享。私有的写在构造函数,公有的写在原型
    • 可以向父类传递参数
  • 缺点:
    • 调用两次父类,性能损耗
    • 封装性一般
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Father(name, friends) {
  // 😀 私有的写这里
  this.name = name // 👍 可以定义私有属性
  this.friends = friends // 👍 定义公有引用属性不会被共享
}

Father.prototype = {
  // 😀 公有的写这里
  share: [1, 2, 3], // 👍 这里定义的公有属性会被共享
  myName: function() { // 👍 方法被共享了
    return this.name
  }
}

function Child(name, friends) {
  Father.call(this, name, friends) // 👍 可以向父类传递参数 ⚡ 这里又调用了一次 Father
}

Child.prototype = new Father() //使用 new 操作符创建并重写 prototype ⚡ 这里调用了一次 Father
Child.prototype.constructor = Child

var a = new Child('a')
console.log(a.myName());

1.4 寄生组合继承

  • 优点:
    • 可以定义私有属性,引用属性不会被共享。私有的写在构造函数,公有的写在原型
    • 可以向父类传递参数
    • 不会重复调用父类
  • 缺点:
    • 封装性一般
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Father(name, friends) {
  this.name = name
  this.friends = friends
}

Father.prototype = {
  share: [1, 2, 3],
  myName: function () {
    return this.name
  }
}

function Child(name, friends, gender) {
  Father.call(this, name, friends)
  this.gender = gender
}

// 上半部分和组合继承一样
Child.prototype = Object.create(Father.prototype) //原型继承
Child.prototype.constructor = Child

1.5 ES6 class

看完前面的,再看es6的语法糖真是太甜了。之前有个需求是要兼容低版本安卓设备,vue不兼容安卓4.0.3和4.0.4版本所以没用框架,需求是判断客户端版本,低于某版本号显示A页面,高版本显示B页面,A、B页面都由后台配置生成,两个有相同的模块也有不同的模块,而且A、B页面有个模块有三套主题可供后台配置,三套主题的交互逻辑完全不同,产品说后续还会加主题。所以用到了继承、多态,尽量优化代码的可读性、可维护性,也得益于es6,代码简洁多了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Father {
    constructor(name, friends) { // 该属性在构造函数上,不共享
        this.name = name
        this.friends = friends
    }
    log() { // 该方法在原型上,共享
        return this
    }
}
Father.prototype.share = [1, 2, 3] // 原型上的属性,共享

class Child extends Father {
    constructor(name, friends, gender) {
        super(name, friends)
        this.gender = gender
    }
}

2. 原型链

[[Prototype]]机制就是对象中的一个内部链接引用另外一个对象。如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找,以此类推,这一系列对象的链接被称为”原型链”。

对于默认的 [[Get]] 操作来说,如果无法在对象本身找到需要的属性,就会通过 __proto__ 作为桥梁连接起来的一系列原型、原型的原型、原型的原型的原型直到 Object 构造函数为止。这个搜索的过程形成的链状关系就是原型链。

for..in 遍历,in 操作符来检查属性在对象中是否存在时,会查找对象的整条原型链(无论属性是否可枚举)。

1
2
3
4
5
6
var anotherObject = { a:2 };
var myObject = Object.create(anotherObject)
for(key in myObject) {
  console.log(key);
}
console.log("a" in myObject);

属性设置和屏蔽

应尽量避免使用屏蔽,以及注意隐式屏蔽的情况

  • 原型链上的已有xxx非只读属性,会直接在myObject中添加一个新的属性xxx,它是屏蔽属性。
  • 原型链上的已有xxx只读属性,则无法创建同名的xxx屏蔽属性。
  • 原型链上的已有xxx但它是一个setter,则会调用这个setter,xxx不会被添加到myObject,也不会重新定义xxx。
1
2
3
4
5
6
7
8
var anotherObject = { a:2 };
var myObject = Object.create(anotherObject)
console.log(anotherObject.hasOwnProperty("a")) //true
console.log(myObject.hasOwnProperty("a")) //false
myObject.a++ //隐式屏蔽
console.log(anotherObject.a) //2
console.log(myObject.a) //3
console.log(myObject.hasOwnProperty("a")) //true

误解

1
2
3
4
5
6
7
8
9
10
function Foo(name) {
  this.name = name;
}
Foo.prototype.myName = function () {
  return this.name;
};
var a = new Foo("a");
var b = new Foo("b");
a.myName(); // "a" 
b.myName(); // "b"

这段代码,看起来好像创建 a 和 b 时会把 Foo.prototype 对象复制到这两个对象中,然而事实并不是这样。 在创建的过程中,a 和 b 的内部 [[Prototype]] 都会关联到 Foo.prototype 上。 当 a 和 b 中无法找到 myName 时,它会通过委托,在 Foo.prototype 上找到。

1
2
3
4
5
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo();
a1.constructor === Foo; // false! 
a1.constructor === Object; // true! 

构造函数只是通过默认的 [[prototype]] 委托指向Foo Foo.prototype的 .constructor 属性只是Foo函数在声明时的默认属性 但是这个对象也没有.constructor 属性(不过默认的 Foo.prototype 对象有这个属性!),所以它会继续委托,这次会委托给委托链顶端的 Object.prototype。这个对象有 .constructor 属性,指向内置的 Object(..) 函数。

关联原型

1
2
3
4
// ES6 之前需要抛弃默认的 Bar.prototype 
Bar.ptototype = Object.create( Foo.prototype ); 
// ES6 开始可以直接修改现有的 
Bar.prototype Object.setPrototypeOf( Bar.prototype, Foo.prototype );

找到继承祖先

a instanceof Foo
a在整条[[prototype]]链中是否有指向 Foo.prototype 的对象。instanceof只能判断对象和函数之间的关系。无法判断两个对象间的关系

a.isPrototypeOf(b)
a是否出现在b的[[prototype]]链中

参考

«你不知道的javascirpt»