JS学习之原型和原型链中要知道的事情

80 阅读8分钟

看到了一句话,“原型链的尽头是null”。让我回想起了本科课堂上JS老师讲的一些东西,那时候还是半知半解。并且,我的固式思维会以为原型和原型链这个概念就是出现在引用数据类型上的。但最近翻了好多材料,发现并非如此。基础数据类型也会有原型链

于是做了以下总结。字数不少,但是如果仔细看完,相信一定会有收获。

从一个面试题目说起。

1. var a = {}; a.__proto__ === ?
2. var a = 1; a.__proto__ === ?

第1题,很清楚a是一个对象,有原型链的概念,往上查找,可以找到Object。但是通过浏览器的运行,得到第2题的答案是

`a.__proto__=== Number.prototype`

继续运行,得到

`a.__proto__.__proto__=== Object.prototype`

为什么一个数字(a)的原型链顶端是 Object 的原型?两者是怎么联系的?

一、JS表面意义上的两种数据类型

ECMASciprt包括两个不同类型的值:基本数据类型引用数据类型
基本数据类型指的是简单的数据段,引用数据类型指的是有多个值构成的对象。

当我们把变量赋值给一个变量时,解析器首先要确认的就是这个值是基本类型值还是引用类型值。

基本数据类型

1. 基本数据类型不可以添加属性和方法

var p = "change";
p.age = 29;
p.method = function(){console.log(name)};
console.log(p.age)    //undefined
console.log(p.method)    //undefined

2. 基本数据类型的赋值是简单赋值

var a = 10;
var b = a;
a++;
console.log(a)   //11
console.log(b)   //10

3. 基本数据类型的比较是值的比较

var person1 = '{}';
var person2 = '{}';
console.log(person1 == person2); // true

4. 基本数据类型是存放在栈区的,栈区包括了变量的标识符和变量的值。

引用数据类型

1. 引用类型可以添加属性和方法

var person = {};
person.name = "change";
person.say = function(){
alert("hello");
}
console.log(person.name)   //change
console.log(person.say)   //function(){alert("hello");}

2. 引用类型的赋值是对象引用

var a = {};
var b= a;
a.name = "change";
console.log(a.name)  //change;
console.log(b.name)  //change
b.age = 29;
console.log(a.age)  //29
console.log(b.age)  //29

引用类型的赋值其实是对象保存在栈区地址指针的赋值,所以两个变量指向同一个对象,任何的操作都会互相影响。

3. 引用类型的比较是引用的比较

var person1 = {};
var person2 = {};
console.log(person1 == person2)//false

4. 引用类型是同时保存在栈区和堆区中的

引用类型的存储需要在内存的栈区和堆区共同完成,栈区保存变量标识符和指向堆内存的地址。

二、包装对象(基本包装类型)

这个时候,我发现了一个问题。

var s1 = "helloworld";
var s2 = s1.substr(4);

上面我们说到字符串是基本数据类型,不应该有方法,那为什么这里s1可以调用substr()呢?还有,刚才最上面的那个面试题,a为什么会有__proto__呢?

通过翻阅资料得知,ECMAScript还提供了三个特殊的引用类型Boolean,String,Number。我们称这三个特殊的引用类型为基本包装类型,也叫包装对象.

之所以说,“基础数据类型也会有原型链”,就是因为当读取stringbooleannumber这三个基本数据类型的时候,后台就会创建一个对应的基本包装类型对象,从而让我们能够调用一些方法来操作这些数据。

所以当第二行代码访问s1的时候,后台会自动完成下列操作:

var s1 = new String("helloworld");//创建String类型的一个实例;
var s2 = s1.substr(4);//在实例上调用指定方法;
s1 = null;//销毁这个实例;

正因为有第三步这个销毁的动作,所以基本数据类型不可以添加属性和方法,这也正是基本包装类型和引用数据类型主要区别:对象的生存期

使用new操作符创建的引用数据类型的实例,在执行流离开当前作用域之前都是一直保存在内存中。 而自动创建的基本包装类型的对象,则只存在于一行代码的执行瞬间,然后立即被销毁。

举个例子

var a = "abc";
console.info(a.length);  ===   console.info(new String(a).length);

var b = 1;
console.info(b.toString());  ===  console.info(new Number(b).toString());

三、原型和原型链

JavaScript 中除了基础类型外的数据类型,都是对象(引用类型)。但是由于其没有类(class,ES6 引入了 class,但其只是语法糖)的概念,如何将所有对象联系起来就成立一个问题,于是就有了原型和原型链的概念。

虽然上面讲的很清楚,但是我还不敢说引用类型包含基本包装类型

方便起见,我下面就用“对象”来指代上面引用类型基本包装类型两种类型吧。(因为在JS的世界里,一切皆为对象(逃))。

原型

对象有隐式原型和显式原型。

1. 都有一个隐式原型 __proto__ 属性,属性值是一个普通的对象。

const obj = {}; 
const arr = []; 
const fn = function() {} 

console.log('obj.__proto__', obj.__proto__);
console.log('arr.__proto__', arr.__proto__); 
console.log('fn.__proto__', fn.__proto__);

答案是。

SCR-20221026-tpk.png

2. 隐式原型 __proto__ 的属性值指向它的构造函数的显式原型 prototype 属性值。

const obj = {};
const arr = [];
const fn = function() {}

obj.__proto__ == Object.prototype // true
arr.__proto__ === Array.prototype // true
fn.__proto__ == Function.prototype // true

3. 当你试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型 __proto__(也就是它的构造函数的显式原型 prototype)中寻找。

const obj = { a:1 }
obj.toString
// ƒ toString() { [native code] }

首先, obj 对象并没有 toString 属性,之所以能获取到 toString 属性,是遵循上述规则,从它的构造函数 Object 的 prototype 里去获取。

原型链

举个例子

function Person(name) {
   this.name = name 
   return this // 其实这行可以不写,默认返回 this 对象 
} 

var nick = new Person("nick") 
nick.toString 
// ƒ toString() { [native code] }

按理说, nick 是 Person 构造函数生成的实例,而 Person 的 prototype 并没有 toString 方法,那么为什么, nick 能获取到 toString 方法?

这里就引出 原型链 的概念了, nick 实例先从自身出发检讨自己,发现并没有 toString 方法。找不到,就往上走,找 Person 构造函数的 prototype 属性,还是没找到。构造函数的 prototype 也是一个对象,那对象的构造函数是 Object ,所以就找到了 Object.prototype 下的 toString 方法。

至此,一条从下而上的链路就清晰展示出来了,至于有没有尽头呢?我猜应该是null。

从而这也回答了文章一开始的那个面试题。a.__proto__.__proto__=== Object.prototype

四、原型对象的意义

原型对象的用途是为每个实例对象存储共享的方法和属性,它仅仅是一个普通对象而已

我们经常会这么写

function Person() {
  this.name = "John";
}
var person = new Person();
Person.prototype.say = function() {
  console.log("Hello," + this.name);
};

上述,Person 原型对象定义了公共的 say 方法,虽然此举在构造实例之后出现,但因为原型方法在调用之前已经声明,因此之后的每个实例将都拥有该方法。
并且所有的实例是共享同一个原型对象,因此有别于实例方法或属性,原型对象仅有一份。

所以,会有如下等式成立

person.say == new Person().say;

可能我们也会这么写

function Person() {
  this.name = "John";
}
var person = new Person();
Person.prototype = {
  say: function() {
    console.log("Hello," + this.name);
  },
};
person.say(); //Uncaught TypeError: person.say is not a function

很不幸,person.say 方法没有找到,所以报错了。
其实这样写的初衷是好的:因为如果想在原型对象上添加更多的属性和方法,我们不得不每次都要写一行 Person.prototype,还不如提炼成一个 Object 来的直接。
但是此例子巧就巧在构造实例对象操作是在添加原型方法之前,这样就会造成一个问题:
var person = new Person()时,Person.prototype 为:Person {}(当然了,内部还有 constructor 属性),即 Person.prototype 指向一个空的对象{}
而对于实例 person 而言,其内部有一个原型链指针 proto,该指针指向了 Person.prototype 指向的对象,即{}
接下来重置了 Person 的原型对象,使其指向了另外一个对象,即 Object {say: function}, 这时 person.proto 的指向还是没有变,它指向的{}对象里面是没有 say 方法的,因为报错。
从这个现象我们可以得出: 在 js 中,对象在调用一个方法时会首先在自身里寻找是否有该方法,若没有,则去原型链上去寻找,依次层层递进,这里的原型链就是实例对象的__proto__属性。

若想让上述例子成功运行,最简单有效的方法就是交换构造对象和重置原型对象的顺序,即:

function Person() {
  this.name = "John";
}
Person.prototype = {
  say: function() {
    console.log("Hello," + this.name);
  },
};
var person = new Person();
person.say();   //Hello,John

综上所述,理解了原型对象的结构就可以。

 Function.prototype = {
        constructor : Function,
        __proto__ : parent prototype,
        some prototype properties: ...
    };

参考文章:

基本数据类型和引用类型的区别详解
JS的基本数据类型和引用数据类型
你不知道的 js 类型转化和原型链
你不知道的 javascript 之 JS 原型对象和原型链
面不面试的,你都得懂原型和原型链
看完,你会发现,原型、原型链原来如此简单! JS原型和原型链

有不对的地方,谢谢指教。