JavaScript:深入理解原型语言,洞察__proto__与prototype之间的区别

简介: JavaScript:深入理解原型语言,洞察__proto__与prototype之间的区别



image.pngJavaScript现在也有class与extends关键字,也可以像Java那样实现类与继承了,但从本质上讲,JS其实是一门原型语言,每个类型都有一个原型,每个类型在实例化时,都有一个原型属性指向了它的原型。JS用原型的概念保证了所有类型都具有相同的行为,这种朴素的思想特别利于初学者理解,JS是编程新人从自然语言跨向编程语言最容易的起点。

深入理解__proto__与prototype属性

JS是一门原型语言,JS中的一切皆为对象,每个对象都有一个原型(prototype),原型是对象实例在创建时,默认被继承的属性和方法的集合,原型对象中的属性、方法在对象实例中皆可访问。看一个示例代码:

1. let obj = {}
2. obj.toString() // Output:'[object Object]'

obj是一个使用对象字面量创建的对象实例,从字面上看,obj中并没有名为toString的方法,但第2行却可以调用,为什么?就是因为toString是原型对象上的方法。

在谈论原型这个概念时,涉及两个属性,即__proto__与prototype,这两个属性都表示原型,它们有什么分别呢?我们应当如何区分和理解它们呢?

在区分这两个概念之前,我们先理清另外两个概念:类型和实例。

什么是类型?例如Object、Function、Array、RegExp等,这些以大字字母开头(在其它语言中不一定是以大写开头的)的都是类型,包括我们在代码中自定义的class,也是类型,类型不是对象实例,是对象实例的模板。举个例子:

1. class Ball { ... }
2. let ball = new Ball()

其中,Ball便是类型,而ball是实例。

对于任何类型,都有一个prototype属性,例如Object.prototype、Function.prototype、Array.prototype等,prototype属性指向类型模板,模板中预定义了一些属性和方法,实例创建时,它所继承的属性和方法便是从[类型].prototype上复制的。JS作为一名原型语言,靠的便是这种机制实现了原型继承。

对于如下代码:

1. let arr = [1, 2, 3]
3. arr.length  // Output:3
4. arr.push(4)
5. arr.pop()
6. arr.toString() // Output:'1,2,3'

变量arr的类型是Array,arr在创建时,从Array.prototype上复制了一些属性和方法(例如length、push、pop等),但toString方法并不是Array.prototype上的。toString方法在Object.prototype上,Array.prototype继承于Object.prototype,它们的继承关系是这样的:

image.png

JS中一切皆为对象,所有对象的原型最终都指向Object.prototype,这样一来,Object上面就没有对象了,所以Object.prototype的父级原型指向了null。

prototype是作为对象实例 中 继承的 原型对象 类型而存在的。 了解了prototype,再来看__proto_属性。

仍以数组实例arr为例,看一下它的结构:

1. arr:(3) [1, 2, 3]
2.  0: 1
3.  1: 2
4.  2: 3
5.  length: 3
6.  [[Prototype]]: Array(0) // Array.prototype
7.    constructor: ƒ Array()
8.    length: 0
9.    pop: ƒ pop()
10.    push: ƒ push()
11.   ...
12.    [[Prototype]]: Object // Object.prototype
13.      constructor: ƒ Object()
14.      toString: ƒ toString()
15.      get __proto__: ƒ __proto__()
16.      set __proto__: ƒ __proto__()
17.     ...

注意:这个结构列表可以在浏览器或微信开发者工具的调试区查看。

在对象arr上,有一个[[Prototype]]属性(第6行),这个属性是一个对象,是以Array.prototype为模板复制的。在[[Prototype]]属性上,还有一个[[Prototype]]属性(第12行),这也是一个对象,它是以Object.prototype为模板进行复制的。第7行、第13行,每个[[Prototype]]属性上都有一个constructor成员,constructor是构建器函数,是使用new关键字创建实例时被调用的函数,这个constructor成员是在类型模板([类型].prototype)上定义的。

在最后这一级[[Prototype]]对象上,有一个名称为__proto__的getter和setter。 proto 是定义在Object.prototype上的存取器,既然JS中的一切皆为对象,那么一切实例都有__proto__这个存取器属性。__proto__作为存取器属性,在内部指向[[Prototype]],不过[[Prototype]]是不能在代码中直接访问的,只能通过__proto__访问。

综上所述, _proto _是实例的存取器属性,是对私有属性 [[Prototype]] 的封装,它返回实例上从原型模板([类型].prototype)上 复制 的实例属性。 如果基于__proto__描述继承关系,那么链条是这样的:

image.png

总结一下:

❑ __proto__是实例的存取器属性,封装内部对实例属性[[Prototype]]的访问,本质是存取器属性;

❑ prototype是作为对象实例在创建时继承的原型类型而存在的,本质是类型。

改变原型 的 指向便可以改变继承关系。 接下来看一个示例,进一步理解__proto__与prototype的区别,如代码清单6-10所示。

代码清单6-10通过改变原型改变继承

1.// JS:disc\第6章\6.2\6.2.3\change_prototype.js
2.class Being {
3.  run(i) {
4.    console.log(`${i} running..`)
5.  }
6.}
7.// const Being = function () {
8.//   this.run = (i) => {
9.//     console.log(`${i} running..`)
10.//   }
11.// }
12.class Person {
13.  title = "微信小游戏"
14.}
15.// const Person = function () {
16.//   this.title = "微信小游戏"
17.// }
18.
19.const person1 = {
20.  print: function () {
21.    console.log(`title:${this?.title}`)
22.  }
23.}
24.person1.print() // Output:title:undefined
25.person1.__proto__ = new Person()
26.// Object.setPrototypeOf(person1, new Person())
27.person1.print() // Output:title:微信小游戏
28.
29.person1.run?.(1)
30.person1.__proto__.__proto__ = new Being()
31.person1.run?.(2) // Output:2 running..
32.new Person().run?.(3)
33.
34.Person.prototype.__proto__ = new Being()
35.person1.run?.(4) // Output:4 running..
36.new Person().run?.(5) // Output:5 running..

这个文件做了什么事?

❑ 第2行至第6行声明了一个类型Being,声明效果与第7行至第11行相同。

❑ 第12行至第14行声明了另一个类型Person,声明效果与第15行至第17行相同。

❑ 第19行至至第23行,person1是一个对象实例,包含一个print方法成员。

❑ 第24行,person1默认是没有title属性的,所以这一行打印结果是“title:undefined”。

❑ 第25行,使用__proto__存取器将person1实例的原型设置为一个Person实例,必须是实例,不能是类型。第26行,使用静态方法setPrototypeOf与使用__proto__存取器的效果一样。在改变原型后,第27行的print打印后,便能取到title属性了。

❑ 第29行,此时person1实例上并没有run方法,这一行不会打印任何内容。

❑ 第30行,person1.__proto__指向Person实例,将它的__proto__设置为Being,相当于让Person继承于Being,如此一来,person1便拥有了run方法,所以第31行的run方法有输出。但第32行的run没有输出,因为第30行只是实例person1的原型改变了,新实例的原型没有改变。

❑ 第34行,Person.prototype是一个类型模板,它的 proto 本是undefined,将其设置为Being实例,也相当于让Person继承于Being。 改变后,第35行、第36行,无论是旧实例,还是新实例,都有run方法了。

最后总结一下,prototype作用在类型上, proto 作用在实例上,两者的赋值对象都必须是实例。无论使用这两个属性中的哪一个改变原型,继承关系都不是很清晰明朗,在实际开发中最简单明了的继承方法还是使用extends关键字,在类型声明时就确定了继承关系。

如何理解原型及原型链

原型即prototype,当对象存在上下从属关系时,原型便形成了一个链条,这便是原型链。如果理解原型及原型链呢?

看一个示例代码:

1. // JS:disc\第6章\6.2\6.2.2\constructor.js
2. // 构造函数
3. function PersonConstructorFunction(name, age, job) {
4.   this.name = name
5.   this.age = age
6.   this.job = job
7.   this.friends = ["小王", "小李"]
8.   this.say = function () {
9.     return `我的名字是${this.name},我是一名${this.job}。`
10.   }
11. }
12. let p = new PersonConstructorFunction("LY", 18, "程序员")

在这个示例中,第12行如果不使用new关键字,PersonConstructorFunction就是一个普通函数,它返回undefined;但是如果用了new,它就变成了一个构造器函数,this将指向创建后的实例。

对新创建的实例p,它的继承关系是:

image.png

prototype属性是类型属性,没有办法链式访问,但__proto__是实例属性,支持链式访问,对于上面的继承关系链,有如下链条:

p.__proto__.__proto__.__proto__ // 输出:null
复制代码

这个链条便是原型链,最后一个__proto__节点,指向Object.prototype的原型,是null。

如果我们再实例化出p2、p3,那么这些对象的原型继承关系如图6-5所示。

image.png

图6-5 p与p2、p3的原型关系图

用new PersonConstructorFunction()创建的对象还从原型上获得了一个constructor属性,它指向函数PersonConstructorFunction本身,示例代码如下,这些关系判断都会返回true:

1. // JS:disc\第6章\6.2\6.2.4\constructor.js
2. ...
3. console.log(p.constructor === PersonConstructorFunction.prototype.constructor) // Output:true
4. console.log(PersonConstructorFunction.prototype.constructor === PersonConstructorFunction) // Output:true
5. console.log(Object.getPrototypeOf(p) === PersonConstructorFunction.prototype) // Output:true
6. console.log(p instanceof PersonConstructorFunction) // Output:true

第5行,getPrototypeOf方法用于返回一个实例的原型。第6行,instanceof操作符用于判断左值是否为右值的一个实例。

执行如下指令对上面修改后的代码进行测试:

cd disc
node ./第4章/4.2/constructor.js 

输出:

true
true
true
true

从测试可以看出,一个对象类型无论有多少实例,其原型均指向一处,原型是多个实例共享的一块内存区域。原型是类型,一个程序中会有许多实例,虽然每个实例都有原型,但因为原型是共享的,所以并不会因为原型链长而影响程序性能。

基于原型链实现万能的类型检测方法 instanceOf

在了解了原型及原型链的概念后,我们做一个练习:我们知道原生的instanceof操作符可以判断一个对象是否为某类型的实例,那么能否根据原型及原型链的概念自实现一个instanceOf函数,用其代替instanceof进行实例类型的判断呢?

答案是肯定的,示例代码如代码清单6-11所示。

代码清单6-11自定义instanceOf函数

1.  // JS : disc\ 第 6 章 \6.2\6.2.5\instance_of.js
2. function instanceOf(target, kind) {
3.    // basicTypes : "number", "boolean", "string", "undefined", "object"
4.   switch (typeof target) {
5.     case "number": {
6.       return Object.prototype.toString.call(new kind) === "[object Number]"
7.       break
8.     }
9.     case "boolean": {
10.       return Object.prototype.toString.call(new kind) === "[object Boolean]"
11.       break
12.     }
13.     case "string": {
14.       return Object.prototype.toString.call(new kind) === "[object String]"
15.       break
16.     }
17.     case "undefined": {
18.       return Object.prototype.toString.call(kind) === "[object Undefined]"
19.       break
20.     }
21.     case "object":
22.     default: {
23.        // 有 typeof 为 null 的情况, toString 结果为 [object Null]
24.       if (!!!target && Object.prototype.toString.call(kind) === "[object Null]") return true
25.       const left = target.__proto__
26.         , right = kind.prototype
27.       if (left === null) {
28.         return false
29.       } else if (left === right) {
30.         return true
31.       } else {
32.         return instanceOf(left, kind)
33.       }
34.     }
35.   }
36. }
37.  // 测试代码
38. console.log(instanceOf(0, Number)) // Output : true
39. console.log(instanceOf("0", String)) // Output : true
40. console.log(instanceOf(true, Boolean)) // Output : true
41. console.log(instanceOf(null, null)) // Output : true
42. console.log(instanceOf(undefined, undefined)) // Output : true
43. console.log(instanceOf(Symbol("s"), Symbol)) // Output : true
44. console.log(instanceOf({}, Object)) // Output : true
45. console.log(instanceOf(/.{2}/, RegExp)) // Output : true
46. class Class1 { }
47. class Class2 extends Class1 { }
48. class Class3 extends Class2 { }
49. console.log(instanceOf(new Class2, Class1)) // Output : true
50. console.log(instanceOf(new Class3, Class1)) // Output : true
51. console.log(instanceOf(new Class3, RegExp)) // Output : false

这个instanceOf函数是怎么实现的?

❑ 第25行至第33行是关键代码。第25行取出实例原型,第26行取出类型原型,如果它们全等,在第30行返回true;如果不相等,将left作为检测目标,在第32行递归调用instanceOf,这是沿着原型链向上走;如果走到了Object.prototype,此时left为null,代表原型链走到了尽头仍然没有匹配,在第28行返回false。

❑ 第4行至第24行处理的是基本类型检测的特殊情况。加上这些代码,我们自定义的instanceOf方法不仅可以检测对象,还可以检测基本数据类型的变量,包括null、undefined等。

❑ 第24行,当typeof target为object时,有可能是null,这是特殊情况。

❑ 第38行至第51行是测试代码。

从测试结果来看,instanceOf满足要求,支持对象及所有基本类型的测试。

自定义实例化函数,以代替new关键字

JS是一门原型语言,class其实是基于原型的语法糖,当使用class定义了一个类型,并用new关键字实例化一个对象时,其实是先创建一个实例,然后将类型的原型给它拷贝一份,实例化就是这么简单。在深入理解了JS的原型概念以后,我们甚至可以自实现一个实例化函数,以代替new关键字。

第六章第18课有这样一道思考题:

思考与练习6-1(面试题):试尝试实现一个函数,代替new关键字,实现基于构建函数创建对象的逻辑。

下面是这个问题的参考答案(附录思考与练习6-1)

构建函数前面的new关键字主要完成了3件事:

❑ 基于构建函数的prototype原型属性,创建一个新对象;

❑ 将新对象绑定为构建函数中的this,并执行构建函数;

❑ 返回对象,如果构建函数有返回并且是引用对象,返回它;否则返回创建的新对象。

针对这个逻辑,代替new关键字的newOperator函数如代码清单1-6所示:

代码清单1-6实现newOperator函数

1.  // JS : disc\ 第 6 章 \6.2\6.2.2\new_operator.js
1. function newOperator() {
2.   const constructor = Array.prototype.shift.call(arguments)
3. 
4.    // 判断参数是否是一个函数
5.   if (typeof constructor !== "function") throw SyntaxError("第1个参数须是函数")
6. 
7.    // 创建一个空对象
8.   const newObject = Object.create(constructor.prototype)
9.    // 绑定this对象,执行构建函数
10.   const result = constructor.apply(newObject, arguments)
11.    // 看构建函数是否有返回并且是引用类型
12.   const flag = result && (typeof result === "object" || typeof result === "function")
13. 
14.    // 返回结果
15.   return flag ? result : newObject
16. }
17. 
18.  // 构造函数
19. function PersonConstructorFunction(name, age, job) {
20.   this.name = name
21.   this.age = age
22.   this.job = job
23.   this.friends = ["小王", "小李"]
24.   this.say = function () {
25.     return `我的名字是${this.name},我是${this.job}。`
26.   }
27. }
28.  // let p = new PersonConstructorFunction(" LIYI ", 18, "程序员")
29. let p = newOperator(PersonConstructorFunction, "LIYI", 18, "程序员")
30. p.say() // 我的名字是 LIYI ,我是程序员。

在该示例中:

❑ 第2行至第17行是newOperator函数。

❑ 第3行,将第一个参数取出作为构建函数,newOperator的第一个实参必须为函数。

❑ 第9行,使用Object.create基于一个原型类型,创建一个对象,就是以此对象作为新对象实例的[[Prototype]]属性。

❑ 第11行,在调用apply方法时,将新对象实例newObject作为构建函数中的this绑定了。

❑ 第13行,检测构建函数有没有返回,准备返回创建的对象实例。

第20行至第31行是测试代码,从测试结果看,使用new关键字的第29行代码与使用newOperator函数的第30行代码,效果是一样的。

小结

JS作为一门原型语言,所有类型都有一个原型且只有一个原型,当类型实例化时,原型被拷贝一份,实例属性装进实例里,存取器属性和方法映射向原型对象,这便最大程度节省了程序在运行时占用的内存。

如果想通过原型改变对象之间的继承,使用prototype属性作用在类型上,改变的是整个类型的定义,使用__proto__实例作用在实例上,改变的是指定实例继承的属性和行为。

由于JS是一门动态语言,且原型允许在运行时动态修改,所以如果未来JS拥有独立意识的话,它可能是电子计算机世界里最先进化出独立智能的一门语言。它可以通过不断优化自身而越变越强大,由于这个过程不需要重新编译与重新启动,它可以悄悄地不断进行,以指数级的速度迅速蜕变,一夜震惊整个世界。


目录
相关文章
|
6天前
|
存储 JavaScript 索引
js开发:请解释什么是ES6的Map和Set,以及它们与普通对象和数组的区别。
ES6引入了Map和Set数据结构。Map的键可以是任意类型且有序,与对象的字符串或符号键不同;Set存储唯一值,无重复。两者皆可迭代,支持for...of循环。Map有get、set、has、delete等方法,Set有add、delete、has方法。示例展示了Map和Set的基本操作。
17 3
|
19天前
|
JavaScript 前端开发 API
ECMAScript和JavaScript的区别是什么?
【4月更文挑战第11天】ECMAScript和JavaScript的区别是什么?
13 1
|
25天前
|
JavaScript 前端开发 索引
问js的forEach和map的区别
JavaScript中的`forEach`和`map`都是数组迭代方法。`forEach`遍历数组但不修改原数组,无返回值;它接受回调函数处理元素。`map`则遍历数组并返回新数组,新数组元素为回调函数处理后的结果。两者都接收元素、索引和数组作为回调函数参数。
21 7
|
28天前
|
JavaScript
JS中Null和Undefined的区别及用法
JS中Null和Undefined的区别及用法
13 1
|
28天前
|
JavaScript 前端开发
JS require 与 import 的区别
JS require 与 import 的区别
19 1
|
1天前
|
前端开发 JavaScript
【Web 前端】 js中call、apply、bind有什么区别?
【4月更文挑战第22天】【Web 前端】 js中call、apply、bind有什么区别?
【Web 前端】 js中call、apply、bind有什么区别?
|
3天前
|
JavaScript 前端开发
js的let、const、var的区别以及应用案例
【4月更文挑战第27天】ES6 中,`let` 和 `const` 是新增的变量声明关键字,与 `var` 存在显著差异。`let` 允许重新赋值,而 `const` 不可,且两者都具有块级作用域。`var` 拥有函数级作用域,并可在函数内任意位置访问。`let` 和 `const` 声明时必须初始化,而 `var` 不需。根据需求选择使用:局部作用域用 `let`/`const`,全局或函数范围用 `var`,不可变值用 `const`。
11 2
|
5天前
|
JavaScript 前端开发 Oracle
java和JavaScript的区别
java和JavaScript的区别
7 3
|
7天前
|
JavaScript 前端开发
js开发:请解释同步和异步编程的区别。
同步编程按顺序执行,易阻塞;异步编程不阻塞,提高效率。同步适合简单操作,异步适合并发场景。示例展示了JavaScript中同步和异步函数的使用。
16 0
|
8天前
|
存储 缓存 前端开发
< 今日份知识点:Javascript本地存储的方式有哪些?区别及应用场景? >
在前端开发中,偶尔需要存储一些如: 用户信息、登录状态、历史记录等常量数据。用于后续二次调用,并且避免刷新后丢失。这时,就需要用到本地存储了。 在`JavaScript` 中,提供了四种可用的本地存储方式: **`cookie`** ,**`sessionStorage`**, **`localStorage`**, **`indexedDB`** ( 已废除的 `WebSQL` )。四种方式各有千秋,接下来,就由小温带各位卷王了解一下,`Javascript` 中的本地存储吧
< 今日份知识点:Javascript本地存储的方式有哪些?区别及应用场景? >