什么是原型
原型在开发中无处不在,在深入介绍原型之前,我们先了解一下对象的基本概念。 这里先将对象理解为数据和行为的集合。开发中,很多时候会希望对象能够“共享”“复用”某些行为,这就引入了“原型”的概念。
为了更直观地理解原型和原型链,我们暂时抛开 JavaScript
的具体语法,使用伪代码来描述对象的定义、继承和复用的过程。
定义结构与行为
Element 结构:
- child: null
- parent: null
ElementFunctions 方法结构:
- append(方法)
Text 结构:
- child: null
- parent: null
- classList: []
- 更多属性...
上面伪代码定义了三个个结构对象
Element
结构 包含子元素和父元素ElementFunctions
方法结构(描述Element
结构 可以产生的行为)- 比如 “追加元素”,“修改属性” 等方法。
Text
结构,在Element
结构上又增加了一些属性。
关联对象与方法
为了让 Element
能够调用 ElementFunctions
中的方法。 这里把 ElementFunctions
与 Element
对象建立关联,使其能够访问这些方法: 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
对象。 - 关联等价
“继承”
- 示例2 来讲:
- 每个结构都能被当做一个 “基类”,可以被其他结构继承, 或者继承其他结构。
下面会进行验证。
对象原型
- 原型是
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__
函数原型
原型对象 (prototype) 函数
prototype
属性通常是一个对象,它被用作该函数创建的实例的原型。 使用new
运算符调用一个函数新的对象时,该对象的原型被设置为该函数的prototype
属性。构造函数
prototype
对象上通常会有一个constructor
属性,这个属性指向函数对象本身。 例如:Test.prototype.constructor
会指向Test
函数本身。特殊情况 大部分函数都拥有
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
。
因而使用类型机制,可以在编译时规避无效属性,进而抹去无效属性查找开销。
减少不必要的原型链层级
尽可能减少原型链的层级,可以提高查找性能。 以下示例通过优化 Element
和 Text
对象的原型关系来减少层级:
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 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。
参考
文档信息
- 本文作者:scriptoverture
- 本文链接:https://scriptoverture.github.io/blog/2024/09/12/%E5%8E%9F%E5%9E%8B%E4%B8%8E%E5%8E%9F%E5%9E%8B%E9%93%BE/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)