原型与原型链

2024/09/12 JavaScript 共 4966 字,约 15 分钟

什么是原型

原型在开发中无处不在,在深入介绍原型之前,我们先了解一下对象的基本概念。 这里先将对象理解为数据和行为的集合。开发中,很多时候会希望对象能够“共享”“复用”某些行为,这就引入了“原型”的概念。

为了更直观地理解原型和原型链,我们暂时抛开 JavaScript 的具体语法,使用伪代码来描述对象的定义、继承和复用的过程。

定义结构与行为

Element 结构:
    - child: null
    - parent: null


ElementFunctions 方法结构:
    - append(方法)


Text 结构:
    - child: null
    - parent: null
    - classList: []
    - 更多属性...

上面伪代码定义了三个个结构对象

  • Element 结构 包含子元素和父元素
  • ElementFunctions 方法结构(描述 Element 结构 可以产生的行为)
    • 比如 “追加元素”,“修改属性” 等方法。
  • Text 结构,在 Element 结构上又增加了一些属性。

关联对象与方法

为了让 Element 能够调用 ElementFunctions 中的方法。 这里把 ElementFunctionsElement 对象建立关联,使其能够访问这些方法: JavaScript 中通过(__proto__)属性用来实现这一目的的。

Element 关联 ElementFunctions

element = Element { child: ..., parent: ... };
element.append(...);

“继承扩展”

为了让 Test 可以访问 Element 的方法, 我们需要实现一种继承机制。 可以将 ElementFunctions 关联到 Text 对象 如示例1。

示例 1:

Text 内联 ElementFunctions
text = Text { ... };
text.append(...);

同样的也可以将 ElementFunctions 关联到 Element 结构。 Element 结构再关联 Text结构,从而访问方法。

示例 2:

Element 内联 ElementFunctions

Text 内联 Element
text = Text { classList };
text.append(...);

这些结构之间的关系可以用树状图来表示:


    示例 1
       ElementFunctions 结构
        |         |
       Text     Element 

    示例 2:
        ElementFunctions 结构
                |
              Element
                |
               Text 

你有看出示例1和示例2的差异吗?

回看上面的伪代码示例:

  • 上面 结构 都可以理解为 js 中的对象。
  • 关联操作对应 js 原型对象指向。
    • 示例2 来讲:Text 的原型对象 就是 Element 对象。
    • 关联等价 “继承”
  • 每个结构都能被当做一个 “基类”,可以被其他结构继承, 或者继承其他结构。

下面会进行验证。

对象原型

MDN 解释

  • 原型是 JavaScript 对象相互继承特性的一种机制
  • JavaScript 中所有的对象都有一个内置属性,称为它的原型(原型)。
  • 原型对象也有它自己的原型,逐渐构成了原型链路。原型链终止于拥有 null 作为其原型的对象上。

再回头看看伪代码的示例2

  • Text 对象, 通过内置属性指向 Element 对象。
    • Text 对象的原型是 Element 对象。
  • Element 对象, 通过内置属性指向 ElementFunctions 对象。
    • Element 对象的原型是 ElementFunctions 对象。
  • 他们通过 __proto__(原型)链接成了一条链表。
  • Text 对象调用 ElementFunctions 对象方法就是链表查找过程。

举例说明:


    let ElementFunctions = {
        append: function() {
            console.log("print!!")
        }
    };

    let Element = { child: null, parent: null };

    Object.setPrototypeOf(Element, ElementFunctions);

    let Text = {};

    Object.setPrototypeOf(Text, Element);

    Text.append(); // print!!

    console.log(Object.getPrototypeOf(Text) === Element);             // true
    console.log(Object.getPrototypeOf(Element) === ElementFunctions); // true

注意Object.setPrototypeOf 官方并不建议项目中使用动态修改原型链性能很差, 后面会说明。 Object.getPrototypeOf: 获取原型 等价于 target.__proto__

函数原型

  1. 原型对象 (prototype) 函数 prototype 属性通常是一个对象,它被用作该函数创建的实例的原型。 使用 new 运算符调用一个函数新的对象时,该对象的原型被设置为该函数的 prototype 属性。

  2. 构造函数 prototype 对象上通常会有一个 constructor 属性,这个属性指向函数对象本身。 例如:Test.prototype.constructor 会指向 Test 函数本身。

  3. 特殊情况 大部分函数都拥有 prototype 属性,下面函数不拥有 prototype 属性。

    • 箭头函数, 不能被 new 实例化 (执行上下文中展开)
    • bind 函数, 但是可能是可构造的。当它被构造的时候,目标函数将会被构造,如果目标函数是可构造的,将会返回一个普通的实例。

举个例子

function Test() {
    this.data = null;
}

// 为 Test 构造函数的原型对象添加一个方法
Test.prototype.fn = function() { console.log("Hello, world!"); }

// 创建一个 Test 实例
let test = new Test();

console.log(test.__proto__ === Test.prototype);   // true
console.log(Test.constructor === Test);           // true
console.log(Test.constructor === Test.prototype.constructor);// true
console.log(test instanceof Test);                // true

test.fn(); // 输出: "Hello, world!"

在上面的例子中:

  • Test.prototype 的原型上挂载 fn 方法。
  • test 实例原型指向 Test.prototype
    • 因此 test 实例可以调用 fn
    • 自然 Test.constructor 等于 Test.prototype.constructor
  • Test 等价 Class Test{ fn } 的语法糖
    • class 只是一个语法糖,本质上仍然是使用原型机制

再观察下面的示例:

let obj = {};
console.log(obj.constructor); // Object

const o2 = new Object();
console.log(o2.constructor);  // Object

之前说过 prototype 对象上通常会有一个 constructor 属性

除了null原型对象之外,任何对象都会在其[[Prototype]]上有一个constructor属性。

  • obj字面量 也存在 constructor,并且 constructor 指向 Object方法;
  • 换句话说,obj字面量new Object 构造函数的实例
  • 字面量对象等价于 new Object 的语法糖

函数原型的继承

因为 函数prototype 大多数也是对象,对象可以依靠__proto__继承。 实例指向 函数prototype, 变相实现构造函数的继承。


function Test() {}
let data = Object.create(ElementFunctions);
data.fn = function() { console.log("Hello, world!"); }
Test.prototype = data;

let t = new Test();
t.append(); 

// 或者
function testfn() {}
testfn.prototype = Object.create(ElementFunctions);
class Test extends testfn {
    fn() { console.log("Hello, world!"); }
}

let t = new Test();
t.append();

原型链

原型链是 JavaScript 中对象属性查找的机制。当访问一个对象的属性时,如果该属性不存在于当前对象上,JavaScript 会沿着该对象的原型链向上查找,直到找到该属性或者到达 null

下面的示例演示了如何查看 Text 对象的原型链:


// 查看 Text 的原型链
let next = Text;
while (next) {
    next = Object.getPrototypeOf(next);
    console.log(next);
}

打印:

  • {child: null, parent: null}
  • {append: ƒ}
  • {defineGetter: ƒ, defineSetter: ƒ, hasOwnProperty: ƒ, lookupGetter: ƒ, lookupSetter: ƒ, …}
  • null

注意:原型链的终点是 null,表示达到了 JavaScript 的根对象。

性能问题

再了解原型链的查找过程后,可能意识到某些场景下查找对象属性可能会带来性能开销。

使用Typescript 辅助开发

对于原型链过长导致性能问题。来看一个示例:

let o100 = {}
...98
let o1 = {};
Object.setPrototypeOf(o1, o100);

console.log(o1.test); // null

在上述代码中,o1 的原型链长度为 100。当我们访问 o1.test 时,由于 o1 本身没有 test 属性,JavaScript 会沿着它的原型链向上查找直到终点 null

因而使用类型机制,可以在编译时规避无效属性,进而抹去无效属性查找开销。

减少不必要的原型链层级

尽可能减少原型链的层级,可以提高查找性能。 以下示例通过优化 ElementText 对象的原型关系来减少层级:


let Text = { ClasssList, ... };
Object.assign(
    Object.create(ElementFunctions),
    Text
);

Text.append(...);

在上面的代码中,Text 继承 ElementFunctions,而非 Element 对象,减少了原型链的层级数。

注意:

  • 这种操作需要确保 Text 对象的属性能够满足 ElementFunctions 中方法的依赖。例如,append 方法可能需要 parent 属性。
  • 如果你希望通过原型链调用 append 方法,确保对象本身或原型链上的某个对象存在 parent 属性(如继承自 Element)。

规避动态更改原型

动态更改对象的原型(如使用 Object.setPrototypeOf)会导致性能问题,详细可了解 v8对于prototype优化

为了加快后续原型加载的速度,V8 引擎使用了内联缓存(Inline Cache)。该缓存有四个字段:

  • 属性在原型中找到的偏移量。
  • 找到该属性的原型。
  • 实例的形状。
  • 从实例形状链接到的直接原型的 ValidityCell

当内联缓存首次命中时,V8 会记住这些信息。下次访问时,如果形状和 ValidityCell 仍然有效,V8 可以直接访问缓存中的属性,跳过额外的查找。

但如果动态更改了原型(如 Object.setPrototypeOf),会分配一个新的形状,这样旧的 ValidityCell 失效,内联缓存也会失效,导致性能下降。因此,避免动态更改原型可以显著提升性能。

结语

不知道你有没有察觉,前面说过的原型意味着“继承”对象属性,实际上,继承是一个十分具有迷惑性的说法,引用《你不知道的JavaScript》中的话,就是:

继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。

参考

文档信息

Search

    Table of Contents