原型链是如何贯穿js的

原型链是js的大动脉。

导读

js的原型链难以避免要牵扯到面向对象,这里我们先简单说说原型还有原型链。之后我们说到面向对象的演变过程,会再次涉及到原型链,还有更多的东西。相信看完的读者会对JavaScript会有更深的认识。

原型对象

本小节意在介绍js中几位朋友,读者只需要记住有它们的存在就行了,毕竟这几位朋友性格有点隐匿。

首先,我们要明白,声明一个对象,哪怕是空属性,js也生成一些内置的属性和方法。

/* 两种方法声明对象 */
// 对象直接量
var obj_1 = {};
// new关键字声明对象
var obj_2 = new Object();

// 在Object的原型对象添加属性
Object.prototype.attr = 'myarr'

console.log(obj_1); // {}
console.log(obj_2); // {}

// js中的恒等符号对函数来说只比较引用
// obj_1.valuOf函数来源于Object.valueOf
// 更准确来说是Object.protoype.valueOf
console.log(obj_1.valueOf === Object.valueOf); // true

// obj_1并未声明attr属性,通过Object.prototype继承得到attr属性
console.log(obj_1.attr); // myarr

空对象属性的prototype

*firefox控制台中空对象仍然有`prototype`属性*

误区:每个浏览器的控制台输出都不太一样,Chrome和Edge并不显示prototype属性,因为我们并没有给obj_1的prototype属性定义任何属性和方法。
由于历代浏览器的更新和ECMAScript的修正,有时难以体现prototype__proto__的存在,但我们的js代码能体现出它们的确是真实存在的。

prototype在这里称之为obj_1的原型对象,通过对象直接量和new关键字声明的对象都具有原型对象,继承自Object.prototype;几乎每个对象都有其原型对象,null是特例。

双对象实现原型继承

需要原型对象是为了实现继承,但有了原型对象我们还无法把obj_1Object.prototype链接起来。
我们还需要另一个对象:__proto__,该属性能指向构造函数的原形属性constructor
一些老版本浏览器不识别,有些无法识别其内部信息,但不影响程序的正常运行。

obj_1的__proto__

*`obj_1`的`__proto__`对象, 该属性下又有`__proto__`和`constructor`属性*

obj_1.__proto__ === Object.prototype  // true
obj_1.__proto__.constructor === Object // true

这里有三个概念先行抛出

  • 继承:继承使子类(超类)可拥有父类的属性和方法,子类也可添加属性和方法
  • 父类:提供属性和方法被子类继承
  • 子类:被父类继承的对象,可调用父类的属性和方法,也能定义属性和方法(父类无法调用)

通过Object.prototype.attrobj_1.attr,我们可以看出 obj_1 (子类) 继承了 Object (父类)的原型对象的attr属性。
正是因为obj_1__proto__指向Object.prototype,obj_1继承了父类原型对象,使之拥有了attr属性。
而子类的__proto__.constructor直接指向父类。

原型继承:每声明一个对象,其本身拥有用两个对象:原型对象(prototype),与__proto__对象,原型对象即可供自身使用,子类继承后也可调用;自身的__proto__对象指向父类的原型对象,其constructor属性指向父类的构造函数。通过原型对象的方法实现继承,叫原型继承。

双对象与原型链

综合以上,我们知道了使用原型对象prototype__proto__对象可以实现继承的功能。那么我们是不是可以一直继承下去呢?

function People(name) {
  this.name = name;
}

function Engineer(type) {
  this.type = type;
}

Engineer.prototype = new People('Chris Chen'); // Engineer (子类)继承 People (父类)

function Programmer(skill) {
  this.skill = skill;
  this.showMsg = function () {
    return 'Hi, my name is ' + this.name + ', I am a ' + this.type + ' engineer, I can write ' + this.skill + ' code!';
  }
}

Programmer.prototype = new Engineer('front-end'); // Programmer (子类) 继承 Engineer (父类)

var me = new Programmer('js');

console.log(me); // Object { skill: "js", showMsg: showMsg() }
console.log(me.showMsg()); // Hi, my name is Chris Chen, I am a front-end engineer, I can write js code!

代码看完,我们从子类开始解释,也就是从下往上的顺序:

  1. meProgrammer的实例化对象
  2. Programmer的原型指向Engineer的实例对象
  3. Engineer的原型指向People的实例对象

我们再来一张图说明其关系

proto_egg
这个.. 一盘煎蛋??

好伐,煎蛋就煎蛋,来,我们继续。

请注意重点:**Programmer并无定义type, name属性,ProgrammershowMsg中能显示this.name this.type分别来源于EngineerProgrammer的原型对象。**
很巧妙的一种属性搜索机制,自身的构造函数没有该属性,就从自身的原型对象中找,如果父类的原型对象没有,那么继续往父类的父类原型对象找,找到了就赋值;或直到没有父类,返回undefined属性如此,方法也是同样的赋值机制。

说到底属性搜索机制就是原型链的一种具体体现,我们再上一张图。

proto_link

所以原型链的关键字是继承原型对象!!

原型链:使用prototype_proto_两个对象实现继承,由于是基于原型对象实现调用链,又称之为原型链。

关于原型链的第一步介绍就到这里,接下来我们从头开始,说说面向对象。

面向对象

首先我们先来概述面向过程编程(opp)与面向对象(oop)。这是JS的两种编程范式,也可以理解为编程思想。
顾名思义,两者的重心不同。下面我们使用两种方法创建dom并挂载于页面。

/* 面向过程 */
// 1. 定义dom
var dom = document.createElement('div');
// 2. 设置dom属性
dom.innerHTML = '面向过程';
dom.id = 'opp';
dom.style = 'color: skyblue';
// 3. 挂载dmo
var container = document.getElementById('container');
container.appendChild(dom);

/* 面向对象 */
// 1. 定义构造函数
function CreateElement(tagName, id, innerText, style) {
  var dom = document.createElement(tagName);
  dom.innerHTML = innerText;
  dom.id = id;
  dom.style = style;
  this.dom = dom;
}
// 2. 定义原型对象上的方法
CreateElement.prototype = {
  render: function (dom) {
    var container = document.getElementById(dom);
    container.appendChild(this.dom);
  }
}

// 实例化对象
var innerBox = new CreateElement('div', 'oop', '面向对象', 'color: pink;');
// 调用原型方法
innerBox.render('container');

面向过程比较流水线,更注重程序的实现过程,面向对象的程序由一个又一个的单位————对象组成,不关心对象的内部属性和方法,只需实例化,调用方法即可使用。

或许上面的例子,还不是很有力得体现出两者的区别,那么如果现在,需要挂载多个元素呢?

/* 面向过程 */
// var dom_1 = document.createElement('div');
// dom_1.innerHTML = '面向过程_1';
// dom_1.id = 'opp-1';
// dom_1.style = 'color: skyblue';

// var dom_2 = document.createElement('div');
// dom_2.innerHTML = '面向过程_2';
// dom_2.id = 'opp-2';
// dom_2.style = 'color: skyblue;';

// var container = document.getElementById('container');
// container.appendChild(dom_1);
// container.appendChild(dom_2);

/* 这种方法傻的可爱,我们包装成函数吧 */

function createElement(tagName, id, innerText, style) {
  var dom = document.createElement(tagName);
  dom.innerHTML = innerText;
  dom.id = id;
  dom.style = style;
  return dom;
}

var container = document.getElementById('container');
var box_1 = createElement('div', 'oop-1', '面向过程_1', 'color: skyblue;');
var box_2 = createElement('div', 'oop-2', '面向过程_2', 'color: skyblue;');
container.appendChild(box_1);
container.appendChild(box_2);

/* 面向对象 */
function CreateElement(tagName, id, innerText, style) {
  var dom = document.createElement(tagName);
  dom.innerHTML = innerText;
  dom.id = id;
  dom.style = style;
  this.dom = dom;
}
CreateElement.prototype = {
  render: function (dom) {
    var container = document.getElementById(dom);
    container.appendChild(this.dom);
  }
}

var innerBox_1 = new CreateElement('div', 'oop-1', '面向对象_1', 'color: pink;');
innerBox_1.render('container');

// 这里只需再实例化一个对象调用render方法即可
var innerBox_2 = new CreateElement('div', 'oop-2', '面向对象_2', 'color: pink;');
innerBox_2.render('container');

重复调用同样的方法,面向过程如果不包装一个函数,显得代码很冗余且愚蠢,而面向对象只需再次实例化即可。
这里也提醒我们平时写代码的时候要考虑复用性。

好的,那我们现在需要给dom元素添加一些交互功能,又要怎么做?

/* 面向过程 */
function createElement(tagName, id, innerText, style, event, fn) {
  var dom = document.createElement(tagName);
  dom.innerHTML = innerText;
  dom.id = id;
  dom.style = style;
  // 直接修改内部函数
  dom.addEventListener(event, fn);
  return dom;
}

var container = document.getElementById('container');
var box_1 = createElement('div', 'oop-1', '面向过程_1', 'color: skyblue;', 'click', function (e) {
  alert(e.target.innerHTML);
});
// 过于死板,就算没有传参dom.addEventListener也会调用两次
var box_2 = createElement('div', 'oop-2', '面向过程_2', 'color: skyblue;');
container.appendChild(box_1);
container.appendChild(box_2);

/* 面向对象 */
function CreateElement(tagName, id, innerText, style) {
  var dom = document.createElement(tagName);
  dom.innerHTML = innerText;
  dom.id = id;
  dom.style = style;
  this.dom = dom;
}
CreateElement.prototype = {
  render: function (dom) {
    var container = document.getElementById(dom);
    container.appendChild(this.dom);
  },
  // 在原型对象上添加方法
  addMethod: function (event, fn) {
    this.dom.addEventListener(event, fn);
  }
}

var innerBox_1 = new CreateElement('div', 'oop-1', '面向对象_1', 'color: pink;', 'click');
innerBox_1.render('container');

var innerBox_2 = new CreateElement('div', 'oop-2', '面向对象_2', 'color: pink;', 'click');
innerBox_2.render('container');
// 根据场景需求决定是否调用addMethod方法
innerBox_2.addMethod('click', function (e) {
  alert(e.target.innerHTML);
})

从这里可以我们看出两者的扩展方法截然不同,面向过程模式需要直接在函数中修改,而面向对像在原型对象上直接追加方法。

面向对象比面向过程有更高的复用性和扩展性。

PS:面向过程也并非一无是处,比面向对象更直观化,也更理解。若不需要考虑太多的因素,使用面向过程开发反而效率会更快。

创建对象

把大象关进冰箱需要几步在下并不清楚。不过要想进行面向对象开发,第一步是先创建一个对象,js中有6种方法可创建对象:

  1. new 操作符
  2. 字面量
  3. 工厂模式
  4. 构造函数
  5. 原型模式
  6. 混合模式(构造+原型)

工厂模式

前两种方法在开头已使用,这里不再复述。如果要创建多个相同的对象,使用前两种方法,会产生大量重复的代码,而工厂模式解决了这个问题..

function factoryMode(name, age) {
  var obj = new Object();
  obj.name = name;
  obj.age = age;
  obj.say = function () {
    return this.name + ' has ' + this.age + ' years old!';
  }
  return obj;
}

var guest = factoryMode('Gentleman', 25);
var Chris = factoryMode('Chris', 20);
console.log(guest.say()) // Gentleman has 25 years old!
console.log(Chris.say()) // Chris has 20 years old!

console.log(guest instanceof Object);  // true
console.log(Chris instanceof Object);  // ture
...

有点赞哦,这样重复实例化多个对象也不怕了,对象识别问题仍然没解决

PS:new Object()已决定了工厂模式的实例是由Object实例化而来的,其对象类型是ObjectDate Array有对应的对象类型,这里读者可以试试new Array instanceof Array等原生数据类型。

工厂模式是面向对象中常见的一种设计模式,是一个可以重复实例化多个对象的函数,但识别对象无能为力。

构造函数

我们可以把工厂模式修改一下,就可以写出一个构造函数..

function ConstructorMode(name, age) {
  this.name = name;
  this.age = age;
  this.say = function () {
    return this.name + ' has ' + this.age + ' years old!';
  }
}

var guest = new ConstructorMode('Gentleman', 25);
var Chris = new ConstructorMode('Chris', 20);
console.log(guest.say()) // Gentleman has 25 years old!
console.log(Chris.say()) // Chris has 20 years old!

console.log(guest instanceof Object); // true
console.log(guest instanceof ConstructorMode);  // true
console.log(ConstructorMode instanceof Object); // true

有几个地方不太一样:

  1. 没有显示创建对象
  2. 属性/方法赋值给this
  3. 使用new关键字调用
  4. return

可以看出实现了跟工厂模式一样的功能,那么什么是构造函数呢?

  • 构造函数也是一个函数,跟工厂模式一样可重复实例化对象。为了跟普通函数区分,函数名首字母一般是大写的。
  • 使用该函数时需要使用new关键字实例化;不使用new实例化,该构造函数表现如同普通的函数。
  • 虽然没有显示创建对象,但在new实例化时,后台执行了new Object()
  • 使用this是因为,构造函数的作用域指向实例化对象,即:两次实例化,ConstructorMode中的this分别指向Guest, Chris

通过上面的instanceof判断,我们能识别出guest是由ConstructoreMode实例化的,与此同时 guest 也是 Object 的实例对象。
构造函数也有其弊端,声明在构造函数内的属性叫“构造属性”,问题就在于:构造属性若是引用类型(以函数为例),实例化后的函数执行的动作虽然是相同的,但引用地址不同,我们并不需要两份同样的函数。

console.log(Chris.say == guest.say); // false

构造函数模式:构造函数是一个需要实例化调用的函数,内部作用域指向实例对象,无须return。构造函数模式,也可实例化大量重复对象,也可识别实例化后的对象是由哪个构造函数实例化而来。其缺点是:若在构造属性中声明函数,实例化后的各个对象引用地址保持独立。

原型模式

原型模式靠原型对象发挥作用,原型对象开头已有介绍。

function PrototypeMdoe() {

}
// 直接在原型对象声明,直面量形式
PrototypeMdoe.prototype.mode = 'prototype';
PrototypeMdoe.prototype.do = function (name) {
  return 'we do the something same, ' + name + '.';
}

var guest = new PrototypeMdoe();
var Chris = new PrototypeMdoe();

console.log(guest.do('guest')) // we do the something same, guest.
console.log(Chris.do('Chris')) // we do the something same, Chris.
console.log(guest.do === Chris.do) // true,相同的引用指针
console.log(guest.do('guest') === Chris.do('Chirs')) // false, 返回值不相等

console.log(guest.prototype === Chris.prototype) // 指向相同的原型对象

实例化对象do方法引用指针是相同的,所以如果是需要给所有实例化对象共享的方法,可在原型上直接声明。guestChris都由同一个构造函数的实例化原型对象的指针地址相同

也可以使用对象字面量的方法,两者有点的区别:对象字面量声明的原型constructor会指向Object,我们也可以手动设置。

function PrototypeMdoe() {

}
// 对象字面量,原型赋值为对象
PrototypeMdoe.prototype = {
  // 手动设置构造函数指针
  // constructor: PrototypeMdoe,
  run: function () {
    return 'I;m running!'
  }
}

var proto = new PrototypeMdoe()

// 打开constructor的注释对比运行结果
console.log(
  proto.constructor === PrototypeMdoe,
  proto.constructor === Object
)

原型模式:共享是原型对象的特点,所有声明在原型上的属性和方法都会被所有实例化对象继承,且指向同一个引用地址。

原型属性是基本类型的数据,共享很方便;如果是引用类型的数据,共享将带来麻烦。由于引用地址相同更改其中一个实例的原型属性,其他实例的原型也随之改变

function PrototypeMdoe() {

}
PrototypeMdoe.prototype.arr = [1, 2, 3, 4, 5];

var proto_1 = new PrototypeMdoe();
var proto_2 = new PrototypeMdoe();

console.log(proto_1.arr)  // [1,2,3,4,5]
proto_1.arr.splice(1, 2)  // [2,3,4]
console.log(proto_2.arr)  // [1,5]

Object.definedPeroperty:ES5语法,可定义新属性或修改现有属性并返回改对象;第三个参数为属性描述符,能精确添加或修改对象的属性:枚举性、属性值、可写性、存取设置。

var Obj = {
  attr: 'obj'
}

Obj.prototype = {
  run: function (name) {
    return name + ' run!';
  }
}

// 使用Object.definedPeroperty设置constructor的特性
Object.defineProperty(Obj.prototype, 'constructor', {
  configurable: true,     // 设置为ture下面的设置才能生效
  // enumerable: false,   // 枚举性
  // writable: false,     // 可写性
  // get: undefined,      // 取值器
  // set: undefined,      // 设置器
  value: Obj              // 属性值
})

isPrototypeOf函数可以判断原型对象是否为某个实例的原型对象。

console.log(
  PrototypeMdoe.prototype.isPrototypeOf(proto_1), // true
  Array.prototype.isPrototypeOf(proto_1)          // false
)

混合模式

混合模式是组合构造函数和原型模式使用,这是最常用的一种设计模式了。

构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。
所以每个实例都会有自己的一份实例属性的副本,但同时共享着对方法的引用。
最大限度的节省了内存。同时支持向构造函数传递参数。

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

CreateObject.prototype.say = function () {
  return this.name + ' has ' + this.age + ' years old!';
}

var guest = new CreateObject('Gentleman', 25);
var Chris = new CreateObject('Chris', 20);
console.log(guest.say()) // Gentleman has 25 years old!
console.log(Chris.say()) // Chris has 20 years old!

hasOwnProperty可检测一个属性是否为实例属性。
in可判断属性是否存在本对象中,包括实例属性或者原型属性。


console.log(guest.hasOwnProperty('name')) // true
console.log(guest.hasOwnProperty('say'))  // false

console.log('name' in guest)  // true
console.log('say' in guest)   // true

// 判断是否为原型属性
function isProperty(object, property) {
  debugger
  return !object.hasOwnProperty(property) &&  property in object;
}

console.log(isProperty(guest, 'name'))
console.log(isProperty(guest, 'say'))

创建对象的六种方法就到这里了,另外还有动态原型寄生构造稳妥构造函数。 这三种模式都是基于混合模式的改良,感兴趣的可以随便看看:点我查看

动态原型

寄生构造

未完待续