JavaScript 原型精髓,读完这篇就够了

栏目: JavaScript · 发布时间: 6年前

内容简介:一篇文章让你搞清楚 JavaScript 继承的本质、很多小伙伴表示不明白 JavaScript 的继承,说是原型链,看起来又像类,究竟是原型还是类?各种原文链接:

一篇文章让你搞清楚 JavaScript 继承的本质、 prototype__proto__constructor 都是什么。

很多小伙伴表示不明白 JavaScript 的继承,说是原型链,看起来又像类,究竟是原型还是类?各种 prototype__proto__constructor 内部变量更是傻傻搞不清楚。其实,只要明白继承的本质就很能理解,继承是为了代码复用。复用并不一定得通过类,JS 就采用了一种轻量简明的原型方案来实现。Java/C++ 等强类型语言中有类和对象的区别,但 JS 只有对象。它的原型也是对象。只要你完全抛开面向对象的继承思路来看 JS 的原型继承,你会发现它轻便但强大。

原文链接: blog.linesh.tw/#/post/2018…

Github: github.com/linesh-simp…

目录

prototype
class
__proto__

继承方案的设计要求

前面我们讲,继承的本质是为了更好地实现代码复用。再仔细思考,可以发现,这里的「代码」指的一定是「数据+行为」的复用,也就是把一组数据和数据相关的行为进行封装。为什么呢?因为,如果只是复用行为,那么使用函数就足够了;而如果只是复用数据,这使用 JavaScript 对象就可以了:

const parent = {
  some: 'data',
}
const child = {
  ...parent,
  uniq: 'data',
}
复制代码

因此,只有数据+行为(已经类似于一个「对象」的概念)的封装,才是继承技术所必须出现的地方。为了满足这样的代码复用,一个继承体系的设计需要支持什么需求呢?

  • 存储公用的数据和函数
  • 覆盖被继承对象数据或函数的能力
  • 向上查找/调用被继承对象函数的数据或函数的能力
  • 优雅的语法(API)
  • 增加新成员的能力
  • 支持私有数据

「支持私有数据」,这个基本所有方案都没实现,此阶段我们可以不用纠结;而「增加新成员的能力」,基本所有的方案都能做到,也不再赘述,主要来看前四点。

被复用的对象: prototype

JavaScript 的继承有多种实现方式,具体有哪些,推荐读者可阅读:JavaScript 语言精粹一书 和 这篇文章 。这里,我们直接看一版比较优秀的实现:

function Animal(name) {
  this.name = name
  this.getName = function() {
    return this.name
  }
}

function Cat(name, age) {
  Animal.call(this, name)
  this.age = age || 1
  this.meow = function() {
    return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
  }
}

const cat = new Cat('Lily', 2)
console.log(cat.meow()) // 'Lilyeowww~~~~~, I'm 2 year(s) old'
复制代码

这个方案,具备增添新成员的能力、调用被继承对象函数的能力等。一个比较重大的缺陷是:对象的所有方法 getName meow ,都会随每个实例生成一份新的拷贝。这显然不是优秀的设计方案,我们期望的结果是,继承自同一对象的子对象,其所有的方法都共享自同一个函数实例。

怎么办呢?想法也很简单,就是把它们放到同一个地方去,并且还要跟这个「对象」关联起来。如此一想,用来生成这个「对象」的函数本身就是很好的地方。我们可以把它放在函数的任一一个变量上,比如:

Animal.functions.getName = function() {
  return this.name
}
Cat.functions.meow = function() {
  return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}
复制代码

但这样调用起来,你就要写 animal.functions.getName() ,并不方便。不要怕,JavaScript 这门语言本身已经帮你内置了这样的支持。它内部所用来存储公共函数的变量,就是你熟知的 prototype 。当你调用对象上的方法时(如 cat.getName() ),它会自动去 Cat.prototype 上去帮你找 getName 函数,而你只需要写 cat.getName() 即可。兼具了功能的实现和语法的优雅。

最后写出来的代码会是这样:

function Animal(name) {
  this.name = name
}
Animal.prototype.getName = function() {
  return this.name
}

function Cat(name, age) {
  Animal.call(this, name)
  this.age = age || 1
}
Cat.prototype.meow = function() {
  return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}
复制代码

请注意, 只有函数才有 prototype 属性 ,它是用来做原型继承的必需品。

优雅的 API:ES6 class

然鹅,上面这个写法仍然并不优雅。在何处呢?一个是 prototype 这种暴露语言实现机制的关键词;一个是要命的是,这个函数内部的 this ,依靠的是作为使用者的你记得使用 new 操作符去调用它才能得到正确的初始化。但是这里没有任何线索告诉你,应该使用 new 去调用这个函数,一旦你忘记了,也不会有任何编译期和运行期的错误信息。这样的语言特性,与其说是一个「继承方案」,不如说是一个 bug,一个不应出现的设计失误。

而这两个问题,在 ES6 提供的 class 关键词下,已经得到了非常妥善的解决,尽管它叫一个 class,但本质上其实是通过 prototype 实现的:

class Animal {
  constructor(name) {
    this.name = name
  }

  getName() {
    return this.name
  }
}

class Cat extends Animal {
  constructor(name, age) {
    super(name)
    this.age = age || 1
  }

  meow() {
    return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
  }
}
复制代码
  • 如果你没有使用 new 操作符,编译器和运行时都会直接报错。为什么呢,我们将在下一篇文章讲解
  • extends 关键字,会使解释器直接在底下完成基于原型的继承功能

现在,我们已经看到了一套比较完美的继承 API,也看到其底下使用 prototype 存储公共变量的地点和原理。接下来,我们要解决另外一个问题: prototype 有了,实例对象应该如何访问到它呢?这就关系到 JavaScript 的向上查找机制了。

简明的向上查找机制: __proto__

function Animal(name) {
  this.name = name
}
Animal.prototype.say = function() {
  return this.name
}
const cat = new Animal('kitty')

console.log(cat) // Animal { name: 'kitty' }
cat.hasOwnProperty('say') // false
复制代码

看上面 :point_up_2: 一个最简单的例子。打出来的 cat 对象本身并没有 say 方法。那么,被实例化的 cat 对象本身,是怎样向上查找到 Animal.prototype 上的 say 方法的呢?如果你是 JavaScript 引擎的设计者,你会怎样来实现呢?

我拍脑袋这么一想,有几种方案:

  • Animal 中初始化实例对象 cat 时,顺便存取一个指向 Animal.prototype 的引用
  • Animal 中初始化实例对象时,记录其「类型」(也即是 Animal
// 方案1
function Animal(name) {
  this.name = name
  // 以下代码由引擎自动加入
  this.__prototype__ = Animal.prototype
}

const cat = new Animal('kitty')
cat.say() // -> cat.__prototype__.say()

// 方案2
function Animal(name) {
  this.name = name
  // 以下代码由引擎自动加入
  this.__type__ = Animal
}

const cat = new Animal('kitty')
cat.say() // -> cat.__type__.prototype.say()
复制代码

究其实质,其实就是: 实例对象需要一个指向其函数的引用(变量) ,以拿到这个公共原型 prototype 来实现继承方案的向上查找能力。读者如果有其他方案,不妨留言讨论。

无独有偶,这两种方案,在 JavaScript 中都有实现,只不过变量的命名与我们的取法有所差异:第一种方案中,实际的变量名叫 __proto__ 而不是 __prototype__ ;第二种方案中,实际的变量名叫 constructor ,不叫 俗气的 __type__ 。实际上,用来实现继承、做向上查找的这个引用,正是 __proto__ ;至于 constructor,则另有他用。不过要注意的是,[尽管基本所有浏览器都支持 __proto__ ][mdn __proto__ ],它并不是规范的一部分,因此并不推荐在你的业务代码中直接使用 __proto__ 这个变量。

JavaScript 原型精髓,读完这篇就够了

从上图可以清楚看到, prototype 是用来存储类型公共方法的一个对象(正因此每个类型有它基本的方法),而 __proto__ 是用来实现向上查找的一个引用。任何对象都会有 __proto__Object.prototype__proto__ 是 null,也即是原型链的终点。

构造函数又是个啥玩意儿?

再加入 constructor 这个东西,它与 prototype__proto__ 是什么关系?这个地方,说复杂就很复杂了,让我们尽量把它说简单一些。开始之前,我们需要查阅一下[语言规范][ECMAScript 2015(ES6) Specification],看一些基本的定义:

这里说明了什么呢?说明了构造函数是函数,它比普通函数多一个 prototype 属性;而函数是对象,对象都有一个原型对象 __proto__ 。这个东西有什么作用呢?

上节我们深挖了用于继承的原型链,它链接的是原型对象。而对象是通过构造函数生成的,也就是说,普通对象、原型对象、函数对象都将有它们的构造函数,这将为我们引出另一条链——

JavaScript 原型精髓,读完这篇就够了

在 JavaScript 中,谁是谁的构造函数,是通过 constructor 来标识的。正常来讲,普通对象(如图中的 cat{ name: 'Lin' } 对象)是没有 constructor 属性的,它是从原型上继承而来;而图中粉红色的部分即是函数对象(如 Cat Animal Object 等),它们的原型对象是 Function.prototype ,这没毛病。关键是,它们是函数对象,对象就有构造函数,那么函数的构造函数是啥呢?是 Function 。那么问题又来了, Function 也是函数,它的构造函数是谁呢?是它自己。由此, Function 即是构造函数链的终结。

上面我们提到, constructor 也可以用来实现原型链的向上查找,然后它却别有他用。有个啥用呢?一般认为,它是用以支撑 instanceof 关键字实现的数据结构。

双链合璧:终极全图

好了,是时候进入最烧脑的部分了。前面我们讲了两条链:

  • 原型链。它用来实现原型继承,最上层是 Object.prototype ,终结于 null ,没有循环
  • 构造函数链。它用来表明构造关系,最上层循环终结于 Function

把这两条链结合到一起,你就会看到 一条双螺旋 DNA 这几张你经常看到却又看不懂的图:

JavaScript 原型精髓,读完这篇就够了
JavaScript 原型精髓,读完这篇就够了

图都是引用自其它文章,点击图片可跳转到原文。其中,第一篇文章 [一张图理解 JS 的原型][] 是我见过解析得最详细的,本文的很多灵感也来自这篇文章。

总结

讲到这里,我想关于 JavaScript 继承中的一些基本问题可以解释清楚了:

JavaScript 继承是类继承还是原型继承?不是使用了 new 关键字么,应该跟类有关系吧?

是完全的原型继承。尽管用了 new 关键字,但其实只是个语法糖,跟类没有关系。JavaScript 没有类。它与类继承完全不同,只是长得像。好比雷锋和雷峰塔的关系。

prototype 是什么东西?用来干啥?

prototype 是个对象,只有函数上有。它是用来存储对象的属性(数据和方法)的地方,是实现 JavaScript 原型继承的基础。

__proto__ 是什么东西?用来干啥?

__proto__ 是个指向 prototype 的引用。用以辅助原型继承中向上查找的实现。虽然它得到了所有浏览器的支持,但并不是规范所推荐的做法。严谨地说,它是一个指向 [[Prototype]] 的引用。

constructor 是什么东西?用来干啥?

是对象上一个指向构造函数的引用。用来辅助 instanceof 等关键字的实现。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

数论概论

数论概论

希尔弗曼 / 孙智伟 / 机械工业出版社 / 2008-5 / 42.00元

《数论概论(原书第3版)》讲述了有关数论大量有趣的知识,以及数论的一般方法和应用,循序渐进地启发读者用数学方法思考问题,此外还介绍了目前数论研究的某些前沿课题。《数论概论(原书第3版)》采用轻松的写作风格,引领读者进入美妙的数论世界,不断激发读者的好奇心,并通过一些精心设计的练习来培养读者的探索精神与创新能力。一起来看看 《数论概论》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

SHA 加密
SHA 加密

SHA 加密工具