在上篇文章中,分析了什么是 JavaScript 中的对象,接下来我们一起来聊聊 V8 是如何实现 JavaScript 中对象继承的。

    简单地理解,继承就是一个对象可以访问另外一个对象中的属性和方法,比如我有一个 B 对象,该对象继承了 A 对象,那么 B 对象便可以直接访问 A 对象中的属性和方法,你可以参考下图:
    image.png
    观察上图,因为 B 继承了 A,那么 B 可以直接使用 A 中的 count 属性,就像这个属性是 B 自带的一样。

    不同的语言实现继承的方式是不同的,其中最典型的两种方式是基于类的设计基于原型继承的设计。

    “基于类” 的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。

    与此相对,”基于原型”的编程通过“复制”的方式来创建新对象。它更提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象。

    原型系统的“复制”操作有两种实现思路:

    • 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;
    • 另一个是切实地复制对象,从此两个对象再无关联。

    历史上的基于原型语言因此产生了两个流派,显然,JavaScript 显然选择了前一种方式。

    如果我们抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说相当简单,我可以用两条概括:

    • 所有对象都有私有字段[[prototype]] (proto),指向此对象的原型对象;
    • 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。

    有了这两个设计我们可以完全抛开类的思维,利用原型来实现抽象和复用。

    原型继承参照下图:
    02- V8怎么实现对象继承 - 图2

    有一个对象 C,它包含了一个属性”type”,怎样让 C 对象像访问自己的属性一样,访问 B 对象呢?
    刚刚我们讲到JavaScript 的每个对象都包含了一个隐藏属性 proto ,我们就把该隐藏属性 proto 称之为该对象的原型 (prototype),proto 指向了内存中的另外一个对象,我们就把 proto 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。

    比如我让 C 对象的原型指向 B 对象,那么便可以利用 C 对象来直接访问 B 对象中的属性或者方法了,最终的效果如下图所示:

    02- V8怎么实现对象继承 - 图3
    当 C 对象将它的 proto 属性指向了 B 对象后,那么通过对象 C 来访问对象 B 中的 name 属性时,V8 会先从对象 C 中查找,但是并没有查找到,接下来 V8 继续在其原型对象 B 中查找,因为对象 B 中包含了 name 属性,那么 V8 就直接返回对象 B 中的 name 属性值。

    关于继承,还有一种情况,如果我有另外一个对象 D,它可以和 C 共同拥有同一个原型对象 B,如下图所示:

    02- V8怎么实现对象继承 - 图4
    因为对象 C 和对象 D 的原型都指向了对象 B,所以它们共同拥有同一个原型对象,当我通过 D 去访问 name 属性或者 color 属性时,返回的值和使用对象 C 访问 name 属性和 color 属性是一样的,因为它们是同一个数据。

    实践:共享原型设计
    了解了 JavaScript 中的原型和原型链继承之后,下面我们就可以通过一个例子,看看共享原型是怎么应用在 jQuery 中的,你可以先看下面这段代码:

    1. (function(window) {
    2. var jQuery = function(selector, context) {
    3. return new jQuery.fn.init(selector, context);
    4. };
    5. jQuery.fn = jQuery.prototype = {
    6. init: function( selector, context) {
    7. //...
    8. },
    9. css:function(){
    10. //....
    11. }
    12. }
    13. //Give the init function the jQuery prototype for later instantiation
    14. jQuery.fn.init.prototype = jQuery.fn;
    15. })(this);

    早期当我们调用$(“..”) 方法只知道是去查询DOM元素,基础稍微好点的同学知道是在创建jQuery的实例对象, 只是在外部调用时jQuery屏蔽了new jQuery() 创建对象的方式。

    可以说这是一种无 new 化创建对象的方式,来看下代码细节:

    1. var jQuery = function(selector, context) {
    2. return new jQuery.fn.init(selector, context);
    3. };

    通过代码发现 $(“…”) 实际上是在创建init实例对象。为什么不new jQuery()?

    1. var jQuery = function(selector, context) {
    2. return new jQuery(selector, context);
    3. };

    原因是直接return new jQuery() 在创建实例的时候会无限的调用 jQuery 构造函数, 导致 Maximum call stack size exceeded 内存溢出。

    为了解决这个问题 jQuery 的做法是关注点分离,每次在调用$(“…”) 构建init实例对象, 而init 的原型对象指向jQuery.prototype, 这跟正常的创建jQuery 实例,实例访问jQuery.prototype原型上封装的方法是一致的。

    内存快照 如图:
    image.png
    简单总结下:$(“…”) 创建的是init的实例,但init的实例的原型对象都指向与jQuery.prototype,所以你只需要去关注j原型对象中封装了什么属性即可,因为不管是jQuery实例 、还是init 实例都可以通过proto访问原型对象中的属性。

    有个地方需要特别说明下:
    内存数据结构

    1. var obj = { init:function(){} };

    上面的代码将一个对象赋值给变量obj。JavaScript 引擎会先在内存里面,生成一个对象{ init: function(){} },然后把这个对象的内存地址赋值给变量obj。

    也就是说,变量 obj 是一个地址(reference)。后面如果要读取obj.init,引擎先从obj拿到内存地址,然后再从该地址读出原始的对象。每一个属性名都对应一个属性描述对象。obj.init的取值就是返回此属性描述对象中的Value。

    举例来说,上面例子的 init 属性,实际上是以下图的形式在内存中存储的。
    image.png
    引擎会将函数单独保存在内存中,然后再将函数的地址赋值给 init 属性的 value 属性。接下来除了共享原型之外,在给大家说个好玩的东西”原型代理”。

    实践:原型代理
    在 Vue官网 响应式原理中有句这样的话:”由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。尽管如此我们还是有一些办法来回避这些限制并保证它们的响应性。” 它是如何做到的呢? 其实很简单我们一起来看。

    如果你要对实现对数组的观测,首先要知道数组是一个特殊的数据结构,它有很多实例方法,并且有些方法会改变数组自身的值,我们称其为变异方法,这些方法有:push、pop、shift、unshift、splice、sort 以及 reverse 等。这个时候我们就要考虑一件事,即当用户调用这些变异方法改变数组结构时需要做一层拦截,执行原生的方法之后再去触发依赖。

    简化版 Vue 源码:

    1. var methods = [
    2. 'push',
    3. 'pop',
    4. 'shift',
    5. 'unshift',
    6. 'splice',
    7. 'sort',
    8. 'reverse'
    9. ];
    10. const arrayMethods = Object.create(Array.prototype);
    11. const arrayProto = Array.prototype;
    12. methods.forEach(function(method){
    13. arrayMethods[method] = function () {
    14. const result = arrayProto[method].apply(this, arguments)
    15. console.log("拦截"+method+"操作")
    16. return result
    17. }
    18. });
    19. const arr = [1,2,3,4];
    20. arr.__proto__ = arrayMethods;
    21. arr.push(5)

    这里我们把数组实例 arr 的 proto 属性指向了 arrayMethods 对象,同时 arrayMethods 对象的 proto 属性指向了真正的数组原型对象。并且 arrayMethods 对象上定义了与数组变异方法同名的函数,这样当通过数组实例调用变异方法时,首先执行的是 arrayMethods 上的同名函数,这样就能够实现对数组变异方法的拦截。

    如图:
    image.png
    本章节完:相信通过这两个例子你对JavaScript中的原型继承有一个更好的理解了。