深入理解JavaScript的面向对象机制和原型链

  • 〇、前言
  • 一、JavaScript和Java在面向对象机制上的区别
    • 1、面向对象编程的特征
    • 2、机制差异简述
  • 二、面向对象机制差异举例
    • 1、Java版的继承机制例子
    • 2、JavaScript版的派生例子
  • 三、JavaScript的面向对象的基础知识点
  • 四、结合代码和对象图解读理解原型链
  • 五、总结

〇、前言

JavaScript是一种面向对象的语言,但它并没有采用Java那种基于类(class-based)的方式来实现,而是采用基于原型(prototype-based)方式----这种方式能提供更灵活的机制,但没有基于类方式那么简洁,因而苦涩难懂。

本文尝试通过一段例子代码和其对应的UML对象图来阐述JavaScript的面向对象机制,以让读者能快速、深入地理解。

本文前面部分有一些理论知识,略显枯燥,你可以先浏览一下文中的图、代码例子,以便先有个感性认识,这里先提前贴一下下文中会出现的图:

注1:本文假定读者有一定的JavaScript基础,以及对Java的语法有初步的了解。

注2:ES6(ES2015)引入了class,但只是语法糖(Syntactic sugar),
  所以还是有必要理解JavaScript的基于原型的机制。

注3:本文有时会用JS代替JavaScript,OO代替Object Oriented(面向对象)。

一、JavaScript和Java在面向对象机制上的区别

ES6(ES2015)引入了class,但只是语法糖(Syntactic sugar),即是说,核心机制还是基于原型的,所以还是有必要理解JavaScript的基于原型的OO机制。

下文主要以ES5为基准,所以以“JS中没有class这样东西”为论调。

一.1、面向对象编程的特征

JS也是一门面向对象编程的语言,所以也具备这有3个OO特征,这里简单概述一下。(跟本文关系最密切的是继承Inheritance)

  1. 封装 Encapsulation:把跟一种对象(一个class)相关的属性、操作放在一起,便于理解、编程,同时可以指定这些属性、操作的访问权限;
  2. 继承 Inheritance:允许一个类继承另外一个类的属性、操作,必要时改写这些属性和行为----被继承的类被称之为父类(superclass),继承父类的类被称为子类(subclass);
  3. 多态 Polymorphism:允许在某个API或某段代码中指定使用某个类的instances,而在运行态时,可以传递这个类的子类的instances给这个API或这段代码。

一.2、机制差异简述

要快速、准确地理解原型链,最好的办法是通过Java的OO机制对比着来理解。两者主要差别有:

  1. Java的OO机制是基于类(class-based)的,而JS是基于原型(prototype-based)的;
  2. 具体来说,就是Java中分别有(class)和实例(instance)的区分;而JS中没有class这个概念,与class类似的是prototype(原型),而与instance类似的是object(对象);
  3. Java通过class与class之间的继承来实现继承(Inheritance),比较简洁、直观;而JS是通过构造函数(constructor)和其对应的原型(prototype),以及原型链(prototype chain)来实现的,比较复杂。(下文有图为证)

二、面向对象机制差异举例

这部分先给出Java的例子,然后用JS实现同样的功能,以此展示JS的OO机制的特点、复杂性。

Java的OO机制在语法上太简单了,以至于哪怕你不懂Java,但只要你对JavaScript的OO机制有初步的了解,也能读懂下面的Java例子。

二.1、Java版的继承机制例子

直接上代码吧,请留意代码中的注释,以及注释中通过“->”标识的输出结果。

class InheritanceDemo {
    class Clock {
        protected String mTimezone; // 实例属性
        
        public Clock(String timezone) { // 构造函数
            mTimezone = timezone;
        }
        
        public void greeting() { // 实例方法
            System.out.println("A clock - " + mTimezone);
        }
    }
    
    class PrettyClock extends Clock { // 类的派生
        protected String mColor;
        
        public PrettyClock(String timezone, String color) {
            super(timezone); // 调用父类的构造函数
            
            mColor = color;
        }
        
        @Override
        public void greeting() { // 重写(override)父类的一个方法
            System.out.println("A clock - " + mTimezone + " + "+ mColor);
        }
        
    }
    
    public void demo() {
        Clock clock1 = new Clock("London");
        Clock clock2 = new Clock("Shanghai");
        Clock clock3 = new PrettyClock("Chongqing", "RED");
        
        clock1.greeting(); // -> "A clock - London"
        clock2.greeting(); // -> "A clock - Shanghai"
        clock3.greeting(); // -> "A clock - Chongqing + RED"
    }
    
    public static void main(String[] args) {
        (new InheritanceDemo()).demo();
    }
}

上述的Clock和PrettyClock的继承关系,用UML类图方式表达如下:

Java继承的UML类图表达

注:Clock类没有指明父类,默认派生于Object这个class。

二.2、JavaScript版的派生例子

在ES6/ES2015添加class这个语法糖之前,要实现继承则比较复杂,下面是其中一种方法(这段JS代码实现跟上面的Java代码段一样的功能).

注:这段代码在Node.js或者Chrome的console中运行通过。

function Clock(timezone) { // 构造函数
    this.timezone = timezone; // 添加实例属性
}

// JS默认就为构造函数添加了一个prototype属性

// 为Clock类(通过其prototype属性)添加实例方法
Clock.prototype.greeting = function() {
    console.log("A clock - " + this.timezone);
}

// 定义子类 - 此为子类的构造函数
function PrettyClock(timezone, color) {
    Clock.call(this, timezone); // 调用父类的constructor
    
    this.color = color; // 添加实例属性
}

// 通过操作子类构造函数的prototype来使其成为子类
PrettyClock.prototype = Object.create(Clock.prototype);
PrettyClock.prototype.constructor = PrettyClock; // 手工添加
PrettyClock.prototype.greeting = function() {
    console.log("A clock - " + this.timezone + " + " + this.color);
}

function demo() {
    var clock1 = new Clock("London");
    var clock2 = new Clock("Shanghai");
    var clock3 = new PrettyClock("Chongqing", "RED");

    clock1.greeting();  // -> "A clock - London"
    clock2.greeting();  // -> "A clock - Shanghai"
    clock3.greeting();  // -> "A clock - Chongqing + RED"
}

demo();

对比Java和JS两个版本的继承例子代码,可以看出JS的OO语法远没有Java的那么简洁、直观 ---- 这点差异其实不算什么了,难点在于后面要介绍的原型链机制,这难点可以在这个例子中的JS的OO机制(原型链)的对象图(object diagram)中看出来:

JavaScript原型链

一时看不懂这张图?没关系,因为不是理解能力的问题,而是JS的OO机制实在太不直观了。下面需要先补充一些JS知识,然后解释这幅图。

三、JavaScript的面向对象的基础知识点

这里的几点知识点很重要,建议先好好理解一下,必要时多看几遍再读下一部分。

因为它们之间关系比较紧密,所以就按这种方式来排版了,请理解一下。

  1. JS中,对象(object)是指一组属性(property,每个属性都是一对key-value)的集合,例如{x: 11, y: 22};实际上,
    • 一个函数也是一个object,所以
      • (function() {}) instanceof Object // -> true
    • 连Object、Function、String等这些关键字对应的东西都是objects:
      • Object instanceof Object // -> true
      • Function instanceof Object // -> true
      • String instanceof Object // -> true
    • 除了Boolean, Number, String, Null, Undefined, Symbol(ES6/ES2015中加入)类型的值外,其它的都是Object类型的值(即都是objects);所以:
      • 123 instanceof Object // -> false -- 数值123是原始值(Primitive value)而不是对象;
      • "hello" instanceof Object // -> false -- 字符串也是原始值(Primitive value);
      • "hello".length // -> 5 -- 字符串用起来像对象,原因是JS隐性地使用了包裹对象(Wrapper objects,详情)来封装;
      • "hello".toUpperCase() // -> "HELLO" -- 同上;
  2. JS的objects可以被看作是Java中的Map的instances,并且JS支持用对象直接量(Object literal)来创建对象。这一点需要记住,因为下文很多例子用到这个语法:
    • var o1 = {}; o1.age = 18; - 创建一个object,并添加一个名为age的属性;
    • var o2 = {name: "Alan", age: 18} - 创建了一个object,里面有两个属性;
    • ({name:"Alan", age:18}).age === 18 // -> true - 创建了一个object并访问其中一个属性;
  3. JS的OO机制中没有Java的class这东西(ES6/ES2015的class只是语法糖),JS的OO机制是通过原型(Prototype)来实现的,而你看到的Object, Function, String等,其实并不是class(虽然它们是大写字母开头的,而Java要求类名以大写字母开头),而是构造函数(Constructor),因为函数也是对象;
    • typeof Object // -> "function"
    • typeof String // -> "function"
  4. JS为每个function都默认添加了一个prototype属性;
    • 这个属性的值是个对象:
      • typeof (function() {}).prototype // -> "object"
    • 默认情况下,每个function的prototype值都是不一样的:
      • (function() {}).prototype == (function() {}).prototype // -> false
    • 所以,语法上每个function都可以被当作构造函数来使用,例如:
      • new (function(x) {this.v = x*2})(3) // -> {v: 6}
        • 上一句定义了一个匿名函数,用它作为构造函数,参数是3,创建了一个object,里面有一个属性(key="v", value=6);
      • new (function(){}) // -> {} -- 创建了一个空object
      • new (function(x) {x = 2})(3) // -> {} -- 空object,因为没有通过this往object里添加属性;
  5. 除了Object这个object等少数几个objects外,JS里每个object都有它的原型,你可以用Object.getPrototypeOf()这种标准方式来获取,或者使用__proto__这个非标准属性来获取(IE的JS引擎中的objects就没有__proto__这个属性):
    • Object.getPrototypeOf({}) === Object.prototype // -> true
    • ({}).__proto__ === Object.prototype // -> true
    • Object.getPrototypeOf(function (){}) === Function.prototype // -> true
  6. 当一个function跟着new操作符时,它就被用作构造函数来使用了,例如:
    • var clock3 = new PrettyClock("Chongqing", "RED")
    • 上面一句的new操作符实际上做了4件事情:
      • A. 创建一个空的临时object,类似于 var tmp = {};
      • B. 记录临时object与PrettyClock的关系(原型关系),类似于 tmp.__proto__ = PrettyClock.prototype
        • 注:这一点是浏览器、JS引擎的内部机制,Chrome和Node.js中有__proto__,但IE中没有;
      • C. 以"Chongqing", "RED"为参数,调用PrettyClock这个构造函数(Constructor),在PrettyClock的body内部时this指向那个新创建的临时空对象(那个tmp);
        • 这里值得强调的是:构造函数中的this.timezone = timezone;一类赋值语句,其实是在那个临时object中添加或者修改实例属性 -- 这跟Java的机制不一样,Java是在class内部定义了实例属性(必要时可以同时赋初始值),而在(Java的)构造函数中,最多是修改实例属性值,无法添加属性或者修改属性的名字或者类型。
      • D. PrettyClock函数结束执行后,返回临时object,上述语句中就赋给了clock3。
  7. 当访问一个object的属性(普通属性或者方法)时,步骤如下:
    • 如果本object中有这个属性,就返回它的值,访问结束;
    • 如果本object中没有这个属性,就往该object的__proto__属性指向的object(prototype object)里找,如果找到,就返回它的值,访问结束;
    • 如果还没找到,就不断重复上一步骤的动作,直到没有上级prototype,此时返回undefined这个值;
      • 实际运行中,是一直找到Object.prototype这个object的,因为它的__proto__等于null,所以不会再继续找了。相关信息如下:
        • typeof Object.prototype // -> "object"
        • Object.prototype.__proto__ === null // -> true
  8. 上一点提到的寻找object的某个属性的过程,就是原型链的工作机制,它的运行效果跟Java中“在类继承树上一直往上找,直到Object类中都找不到为止”差不多。

四、结合代码和对象图解读理解原型链

要理解JS的OO机制,需要先理解原型链(prototype chain);要理解原型链,则需要理解构造函数(constructor)、原型对象(prototype object)、普通对象(object)之间的关系,说明如下。

为了避免你来回滚动网页,这里把上面的对象图再贴一次:

JavaScript原型链
  • 图中每个“大框”(2到4个小方框的集合)都是一个object,大框中的每个小框(除了最上面的一个)都是一个属性(Property),图中列举的这些属性,它们的值就是箭头指向的那个object。其中,
    • 左边一列(浅蓝色)的objects都是构造函数(Constructor),它们都是functions(概念上,可以认为Function是Object的子类),从图中它们的__proto__属性都指向Function.prototype(红色的线)就可以看出来;
    • 中间一列(浅绿色)的objects充当原型(Prototype)的角色,它们本身是objects,这从它们的__proto__属性指向Object.prototype(青色的线)可以看得出来;
      • 充当prototype的objects都需要有一个constructor属性(留意绿色的线),指向其对应的构造函数,这个属性在你手工构造prototype时,则需要自行显式添加;
    • 右边一列(浅橙色)的objects就是普通对象了,它们本身没有constructor或者prototype属性,但有JS引擎内部为它们添加的__proto__属性来指向对应的prototype对象;
      • var clock3 = new PrettyClock("Chongqing", "RED")时, new操作符先创建一个临时对象,然后把PrettyClock的prototype属性的值(就是图中浅绿色的PrettyClock.prototype这个object的引用)添加到这个临时对象中(作为一个内部属性,名为__proto__),最后将临时对象赋给变量clock3。
  • JS的原型链就是由图中青色的线+蓝色的线所构成的,其运作机制需要用几个例子来说明:
    • clock3.greeting(); // -> "A clock - Chongqing + RED"
      • clock3对象本身没有名为"greeting"的属性,所以到其__proto__指向的object即PrettyClock.prototype里找,结果有,所以就调用;
        • clock3.hasOwnProperty("greeting") // -> false
        • clock3.__proto__.hasOwnProperty("greeting") // -> true
      • 调用时这个greeting函数时,关键字this指向的是clock3这个对象,这个对象有timezonecolor两个属性值,所以打印结果"A clock - Chongqing + RED";
        • clock3的这两个属性值,是在用new调用PrettyClock这个构造函数(图中浅蓝色的PrettyClock)时被添加进去的,其中timezone这个属性是在(PrettyClock直接地)调用Clock这个构造函数添加的 ---- 这一点很重要,请好好理解一下;
    • clock3.toString(); // -> "[object Object]"
      • clock3本身没有"toString"这个属性(例子中没有添加这个属性),估往PrettyClock.prototype找(蓝线),也没,再往Clock.prototype找(青色虚线),还是没有,再往Object.prototype找(青色线),就找到了。
      • 这里需要指出的是,你Chrome的console中能打印出一个object的一个属性值,并不代表这个这个属性值在这个object中,这需要用hasOwnProperty()来检查,例子如下:
        • clock3.toString != null // -> true - 能读到这个属性
        • clock3.hasOwnProperty("toString") // -> false - 不直接拥有
        • clock3.__proto__.__proto__.__proto__.hasOwnProperty('toString') // -> true
          • 终于找到了
        • clock3.__proto__.__proto__.__proto__ === Object.prototype // -> true
          • 这一点,结合图中的线来理解一下吧;
        • clock3.__proto__.__proto__ === Clock.prototype // -> true
          • 如果上面一点理解了,这一点就不在话下了。
    • Object.prototype.__proto__ === null // -> true
      • Object.prototype这个object是原型链的最顶端了(就如Java中Object这个class是最顶端的class一样),所以它没有原型了,所以它的__proto__这个属性的值为null(注意:不是undefined,至于null与undefined的区别,请自行搜索一下);
      • 这一点的严谨写法是:
        • Object.getPrototypeOf(Object.prototype) === null // -> true
        • 再重复一遍:Chrome、Node.js中每个object都有__proto__这个属性,但IE中就没有,因为它不是JavaScript的规范的中定义的,Object.getPrototypeOf()才是标准用法。

写到这里就差不多了。😊

五、总结

JS的OO机制、原型链不容易理解,原因有这几点:

  1. JS的OO机制本来就复杂(没有Java的那么简洁);
  2. “对象”、“属性”、“原型”这些词的含义比较模糊,带来了理解上的困难;
  3. Object、Function、String等关键字其实是函数,它们充当着“某个class的门面”的角色,但它们并不直接是原型链的一部分,因为那些实例方法(instance method)不是放在它们里面,而是放在它们对应的原型对象里面。

推荐阅读更多精彩内容