阅读 1672

原型与原型链

产生背景

JavaScript 的作者 Brendan Eich 在设计这门编程语言时,只是为了让这门语言作为浏览器与网页互动的工具。他觉得这门语言只需要能完成一些简单操作就够了,比如判断用户是否填写了表单。

基于简易语言的设计初衷,Brendan Eich 觉得 JavaScript 不需要有类似 java 等面向对象语言所拥有的“继承”机制。但是考虑到 JavaScript 中一切皆对象(所有的数据类型都可以用对象来表示),必须有一种机制,把所有的对象联系起来,实现类似的“继承”机制。

不同于大部分面向对象语言,ES6 之前并没有引入类(class)的概念,JavaScript 并非通过类而是通过构造函数来创建实例,使用 prototype 原型模型来实现“继承”。

构造函数

在 JavaScript 里,构造函数通常是用来实现实例的,JavaScript 没有类的概念,但是有特殊的构造函数。构造函数本质上是个普通函数,充当类的角色,主要用来创建实例,并初始化实例,即为实例成员变量赋初始值。

构造函数和普通函数的区别在于,构造函数应该遵循以下几点规范:

  1. 在命名上,构造函数首字母需要大写;
  2. 调用方式不同,普通函数是直接调用,而构造函数需要使用 new 关键字来进行调用;
  3. 在构造函数内部,this 指向的是新创建的实例;
  4. 构造函数中没有显示的 return 表达式,一般情况下,会隐式地返回 this,也就是新创建的对象,如果想要使用显示的返回值,则显示返回值必须是对象,否则依然返回实例。

原型

构造函数是用来创建实例的,那和原型又有什么关系呢?

什么是原型

我们通过下面的代码来进行理解:

// 步骤1:新建构造函数
function Person(name) {
    this.name = name;
    this.sayName = function() {
        console.log(this.name);
    }
}

// 步骤2:创建实例
var person = new Person('harry');
复制代码

此时,如下图所示,针对步骤1,当构造函数被创建时,会在内存空间新建一个对象,构造函数内有一个属性 prototype 会指向这个对象的存储空间,这个对象称为构造函数的原型对象。

针对步骤2,如下图所示,person 是通过 Person 构造函数创建的实例,在 person 内部将包含一个指针(内部属性),指向构造函数的原型对象,这个指针称为 [[prototype]]。

目前,大部分浏览器都支持 __proto__ 这个属性来访问构造函数的原型对象,就像这里,person.__proto__ 指向 Person.prototype 的对象存储空间。

由上面示例图我们知道,实例 person 如果访问原型对象,需要使用 __proto__ 这个属性。

事实上,__proto__ 是一个访问器属性(由一个 getter 函数和一个 setter 函数构成),但作为访问 [[prototype]] 的属性,它是一个不被推荐的属性, JavaScript 规范中规定,这个属性仅在浏览器环境下才能使用。

[[prototype]] 是内部的而且是隐藏的,当需要访问内部 [[prototype]] 时,可以使用以下现代方法:

// 返回对象 obj 的 [[prototype]]。
Object.getPrototypeOf(obj);

// 将对象 obj 的 [[prototype]] 设置为 proto。
Object.setPrototypeOf(obj, proto) 

// 利用给定的 proto 作为 [[prototype]] 和属性描述符(可选)来创建一个空对象。
Object.create(proto[, descriptors])
复制代码

原型的属性分析

在默认情况下,所有的原型对象都会自动获得一个 constructor 的属性,这个属性包含一个指向 prototype 所在函数的指针,即 constructor 属性会指向构造函数本身。

此外,Person.prototype 指向的位置是一个对象,也包含有内部 [[prototype]] 指针,这个指针指向的是 Object.prototype,是一个对象。这个关系表示,Person.prototype 是由 Object 作为构造函数创建的。

需要注意的是,原型是可以被改写的。但是 JavaScript 中对其做了规定,只可以被改写成对象,如果改写成其他值(空值 null 也不行),会自动被忽略,会让原型链下一级来替换这个被改写的原型,原型链下面会讲。

原型的作用

原型的作用有以下几点:

  1. 属性公用化:原型可以存储一些默认属性和方法,并且在各个不同的实例中可以共享使用;
  2. 继承:在子类构造函数中借用父类构造函数,再通过原型来继承父类的原型属性和方法,模拟继承的效果;
  3. 节省存储空间:结合第1点,公用的属性和方法多了,对应需要的存储空间也减少了。

下面是结合原型的作用写的验证代码:

// 第一步 新建构造函数
function Person(name) {
    this.name = name;
    this.age = 18;
    this.sayName = function() {
        console.log(this.name);
    }
}
// 第二步 创建实例 1
var person1 = new Person('1号');

// 第三步 创建实例2
var person2 = new Person('2号');

// 结果均为 true
person1.__proto__ === Person.prototype;
person2.__proto__ === Person.prototype; 

// 1号 2号
console.log(person1.name, person2.name);

// 18 18
console.log(person1.age, person2.age);
复制代码

原型链

了解完原型,我们进一步来了解继承的核心-原型链。

什么是原型链

JavaScript 中,万物皆对象(所有的数据类型都可以用对象来表示),对象与对象之间存在关系,并不是孤立存在的,对象之间的继承关系,在 JavaScript 中实例对象通过内部属性 [[prototype]] 指向父类对象的原型空间,直到指向浏览器实现的内部对象 Object 为止,Object 的内部属性 [[prototype]] 为 null,这样就形成了一个原型指向的链条,这个链条称为原型链。

原型链中的属性查找

当访问对象的属性时,会先在对象自身属性中查找,如果有则直接返回使用,如果没有则会顺着原型链指向继续寻找(不断查找内部属性 [[prototype]]),直到寻找浏览器内置对象的原型,如果依然没有找到,则返回 undefined。

需要注意的是,原型链中访问器属性和数据属性在读写上是有区别的(点击了解访问器属性和数据属性)。如果在原型链上某一级设置了访问器属性(假设为 age),则读取 age 时,直接按访问器属性设置的值返回;写入时也是以访问器属性为最优先级。在数据属性的读写上,读取时,会按照原型链属性查找进行查找;写入时,直接写入当前对象,若原型链中有相同属性,会被覆盖。

原型链的剖析

我们可以结合以下代码来对原型链进行分析:

// 第一步 新建构造函数
function Person(name) {
    this.name = name;
    this.age = 18;
    this.sayName = function() {
        console.log(this.name);
    }
}
// 第二步 创建实例
var person = new Person('person');
复制代码

根据以上代码,我们可以得到下面的图示:

第一步中,新建 Person 的构造函数,此时原型空间被创建;第二步中,通过 new 构造函数生成实例 person,person 的 [[prototype]] 会指向原型空间。

很多人容易忽视的是浏览器对于下面的处理,这里 Person.prototype.__proto__ 指向内置对象,因为 Person.prototype 是个对象,默认是由 Object 函数作为类创建的,而 Object.prototype 为内置对象。

而 Person.__proto__ 指向内置匿名函数 anonymous,因为 Person 是个函数对象,默认由 Function 作为类创建,而 Function.prototype 为内置匿名函数 anonymous。

这里还需要注意一个点,Function.prototype 和 Function.__proto__ 同时指向内置匿名函数 anonymous,这样原型链的终点就是 null,而不用担心原型链查找会陷入死循环中。

最后

原型与原型链的应用是 JavaScript 的精髓,很多框架和库内部都广泛使用到了,学好这方面的知识,是作为前端er的首要任务,也是前端职业发展必经的一个台阶。

加油,打工人!

文章分类
前端
文章标签