JavaScript原型及原型链

2,390 阅读16分钟

1. 什么是原型

1.1. 题外话

理解对象(或者说函数的)的原型(可以通过 Object.getPrototypeOf(obj) 或者已被弃用的 proto 属性获得)与构造函数的prototype属性之间的区别是很重要的。前者是每个实例上都有的属性,后者是构造函数的属性。也就是说,Object.getPrototypeOf(new Foobar())和Foobar.prototype指向着同一个对象。

这个 __proto__[[Prototype]] 的因历史原因而留下来的 getter/setter(一个getter函数和一个setter函数), 暴露了通过它访问的对象的内部[[Prototype]]__proto__这个东西已经从web标准中废弃了,尽管在Chrome中还可以使用,可以通过它修改一个对象的[[Prototype]]属性,但是这是非常耗性能的。

同时,原型链中的方法和属性没有被复制到其他对象——它们被访问需要通过前面所说的“原型链”的方式。没有官方的方法用于直接访问一个对象的原型对象——原型链中的“连接”被定义在一个内部属性中,在 JavaScript 语言标准中用 [[prototype]] 表示(参见 ECMAScript)。然而,大多数现代浏览器还是提供了一个名为 __proto__ (前后各有2个下划线)的属性,其包含了对象的原型。

每次讲到原型,我都要回顾下下面这张图:

1.2. 定义

原型既指构造函数的prototype属性指向的对象

其实也指实例的[[Prototype]]属性(通过__proto__访问),它们指向的是同一个东西。

参考:JavaScript学习笔记(十二) 原型 文中的图片不错,很形象。

原型是 function 对象的一个属性,见上图Person.prototype,原型定义了构造函数制造出的对象的公共祖先。通过该构造函数产生的对象,可以继承该原型的属性和方法,原型也是对象。

自然而然我联想到了Java中的继承,进而Java有了重写,同样JS我们可以在对象中对原型的方法和属性进行重写,但是不能通过修改对象的属性修改(此处指增删改查)原型,想要修改原型只有把原型调出来才能修改,如下代码:

        Person.prototype.age = 22; //直接调出来修改
        Person.prototype.name = "田";

        function Person(age, name) {
            this.age = age;
            this.name = name;
        }
        var per = new Person(23, "挥动");
        console.log(per);

这里题外话解释一下JavaScript构造函数内部原理

  1. 在函数体最前面隐式地加上this = {}(这一步存在疑问,下面解释)
  2. 执行this.XXX = xxx
  3. 隐式地返回this

如下

        Person.prototype.age = 22;

        function Person() {}
        var per = new Person();
        console.log(per.age); //22

Person()是一个构造函数,而.prototype是系统在 Person 出生时添加的属性,prototype 译为原型,原型是构造函数构造出的对象的公共祖先,如果不对它做什么修改,那么原型值 = {},此处我们加了age = 22

此处附加一个小知识,图中浅粉色代表系统给你写的,紫色代表你自己写的,见代码,构造函数是系统写的,而age是你自己在原型中加的,系统给定的构造函数我们也可以自己在代码中调出原型进行修改,如上图

现在回到上面存疑的步骤,开篇讲过构造函数的原理第一步this = {},值得怀疑的是他到底传入的是不是空的{}

在控制台查看它并不是空的,里面有一个__proto__属性,那么第一步实际上应该是下面这样

        // 注意这是伪代码
        var this = {
            __proto__:Person.prototype
        };
        // 可以在控制台中试一试:per.__proto__===Person.prototype返回true

当我们在查找对象的属性或方法时会首先在自己里面找,如果找不到会找__proto__指向的原型,这样就把对象和原型连接到了一起。__proto__存的是对象的原型,或者换句话说每个对象都有一个__proto__指向构造它的原型,由此我们可以发现 __proto__指向的构造函数是可以修改的,因此per的构造函数也就未必是Person()了,也就是说Person()这个构造函数构造出来的对象的原型不一定是Person.prototype,如下图

我们再来看下面的代码结果是这样的,为什么?

        function Person() {}
        var per1, per2, per3;
        per1 = new Person();
        console.log(per1); //Person {}
        Person.prototype.name = "sunny"; //通过结果发现,影响到了per1
        per2 = new Person();
        Person.prototype = { //这个却没有影响到per1和per2,为什么?
            name: "cherry"
        }
        per3 = new Person();
        console.log("per1.name:" + per1.name); //per1.name:sunny
        console.log(per1.__proto__); //{name: "sunny", constructor: ƒ}
        console.log("per2.name:" + per2.name); //per2.name:sunny
        console.log(per2.__proto__); //{name: "sunny", constructor: ƒ}
        console.log("per3.name:" + per3.name); //per3.name:cherry
        console.log(per3.__proto__); //{name: "cherry"}


        console.log(per1.__proto__ === per2.__proto__); //true
        console.log(per2.__proto__ === per3.__proto__); //false
        console.log(per3.__proto__.__proto__ === Object.prototype); //true

我们讲到构造函数第一步相当于var this = {__proto__:Person.prototype},在这里__proto__Person.prototype都相当于指向了同一个空间,per1创建的时候构造函数里面什么都没有写,尽管per1的__proto__并没有修改,但是它和per2指向的是同一个空间,我们在后面修改了Person.prototype的值,所以后面per2、per1的输出值都是sunny。

对于per3我们发现它没有构造函数,因为我们后面修改原型的方式不是修改Person.prototype所指向的对象中的一个属性,而是给Person.prototype直接赋值一个新的对象,所以per3输出cherry,并且这个新的对象是以对象字面量的方式创建的,默认这种对象的原型就是Object,所以per3.__proto__.__proto__ ===Object.prototype //true

        let obj = {
            name: "HUI"
        };
        console.log(obj.__proto__ == Object.prototype); //true

关于预编译可以参考:JavaScript预编译、作用域

2. 原型链

        function Grand() {
            this.word = "我是爷";
        }
        var grand = new Grand();
        Father.prototype = grand;
        //grand是用Grand()创建的一个对象实例,它是个对象
        function Father() {
            this.name = "我是爹";
        }
        var father = new Father();
        Son.prototype = father;
        // 同上,直接给原型赋值一个对象
        function Son() {
            this.hobbit = "smoke";
        }
        var son = new Son();

像这样在原型上面再加一个原型对象的方法叫做原型链,原型链使用__proto__来连接各个原型,控制台尝试输出如图

在其中可以看出,Grand的__proto__指向Object,而再点开Object发现已经没有__proto__属性(有prototype),说明Object()就是原型链的终端(实际上Object.prototype.__proto__指向null,也可以说null是终端)。

3. 原型链的增删改查

3.1. 删除

只能在自己身上通过原型删除(delete father.name),不能通过父代或者后代来删除

3.2. 增加

可以自己增,后代一般不能增。但是不排除下面这种情况:在后代中 增加 了父级对象的属性。通过子代调用了引用 修改或者说增加了父代的属性,这是一种调用的修改而不是赋值的修改,并且这种修改也仅限于引用值,比如原始值你就只能覆盖,不能修改。

        function Father() {
            this.name = "我是爹";
            this.for = {
                sd1: "哈哈哈",
            }
        }
        var father = new Father();
        Son.prototype = father;
        // 同上,直接给原型赋值一个对象
        function Son() {}
        var son = new Son();
        console.log(son);
        son.for.sd2 = "操";
        son.name = "改名"; //最终father没有改名,而是在son新增了名字
        console.log(father);
        son.father.name = "改名"; //Uncaught TypeError: Cannot set property 'name' of undefined

3.3. 修改

3.4. 查询

        Father.prototype = {
            num: 100
        }
        function Father() {
            this.eat = function () {
                this.num++; //把值拿过来+1再赋给自己
            }
        }
        var son = new Father();
        son.eat();
        console.log(son.num); //101
        console.log(Father.prototype.num); //100

4. Object.create(原型)方法

还需注意原型是隐式的内部属性,只有系统给我们的才能用,假如我们自己在一个没有原型的对象中添加了__proto__属性,系统是不能识别的。

        var obj = Object.create(null);
        obj.__proto__ = {
            name: "sunny"
        }
        console.log(obj); //{__proto__:{name: "sunny",__proto__: Object}}
        console.log(obj.name); //undefined 此时发现从原型链上找是找不到的

注意图中,obj.__proto__是可以访问的,因为我们赋值的时候是一个对象{name: "sunny"},但是Chrome并不能识别obj.name,此时的obj并不是继承自Object.prototype,由此总结绝大多数对象最终都会继承自Object.prototype,但不是全部,还需注意Object.create(原型)方法必须传入object或者null!不能为空。

注意:我们发现toString()方法在Object的原型中,那么应该说很多经过包装类的值都可以使用这个方法,不过 undefined、null 以及自己构造的没有原型的对象是没有这个方法的。

值得注意的是许多东西都可以调用toString()方法,按我所想调用的都是Object原型中的方法,但是事实并非如此,举个例子

        Object.prototype.toString = function () {
            return "人为重写";
            //在此重写Object的toString方法  
        }
        var nun = 123;
        console.log(nun.toString()); //123,明显nun调用了重写的方法(如果有的话)
        console.log(Number.prototype.toString.call(nun)); //123
        console.log(Object.prototype.toString.call(nun)); //人为重写

方法的重写不但出现于工程师与机器之间,也存在于机器和自己之间,nun.toString()实际上是调用了重写的方法,为什么?因为Object的toString方法实际上不完善,输出的信息没什么用,假如调用Object的方法就会输出"[object Number]",所以需要重写方法,诸如BooleanArray等等都会调用重写的方法,实际情况就是它们都有自己重写的toString。

5. call/apply/bind

三者都可用于重定义this对象,或者说重定义this指向。

5.1. call

上面看到了call:Object.prototype.toString.call(nun),call的作用是改变this的指向,这里nun是调用者,调用前面的Object.prototype.toString方法,举个例子

        function Person(name, age) {
            this.name = name;
            this.age = age;
            this.say = function () {
                console.log("myName is:" + this.name);
            }
        }

        var obj1 = {};
        Person.call(obj1, "Tian", 21); //借用构造函数
        console.log(obj1); //{name: "Tian", age: 21, say: ƒ}

        var obj2 = {};
        Person.apply(obj2, ["Hui", 22])
        console.log(obj2); //{name: "Hui", age: 22, say: ƒ}

        var fun = obj1.say;
        fun.bind(obj1)(); //fun.bind(obj1)返回的是一个函数

此处使用了Persn()来构造obj,在开发中往往在某一构造函数完全覆盖另一构造函数时使用这种方法,如下,Student的需求完全覆盖了Person,就可以在Student中使用call使用Person的代码,而不用自己再写一遍,call时企业级开发组装函数的一个方法之一

        function Person(name, age, sex) {
            this.name = name;
            this.age = age;
            this.sex = sex;
        }

        function Student(name, age, sex, grade, tel) {
            Person.call(this, name, age, sex);
            this.grade = grade;
            this.tel = tel;
        }
        var nun = new Student("Tian", 21, "Male", 9, 188);
        console.log(nun); //Student {name: "Tian", age: 21, sex: "Male", grade: 9, tel: 188}

为什么call()第一个参数传入this值得思考

5.2. apply

apply的区别在于传参数不一样,call是一个一个把参数传进去,而apply是一个arguments,也就是一个数组(不包括this),我们只需要把call()除开this的其他参数用[]括起来就可以了,如下

        Person.apply(this, [name, age, sex]);

5.3. bind

bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

        function Person(name, age) {
            this.name = name;
            this.age = age;
            this.say = function (ming) {
                console.log("myName is:" + ming);
            }
        }
        let obj1 = new Person();
        var fun = obj1.say;
        fun.bind(obj1, "Hang")(); //myName is:Hang

6. JS的数字操作

        console.log(0.14 * 100); //输出14.000000000000002,JS的精度不高
        console.log(Math.ceil(123.33)); //输出124,向上取整
        console.log(Math.floor(123.33)); //输出123,向下取整
        console.log(Math.random()); //产生0~1之间的随机数
        var nun = Number(123.654);
        console.log(nun.toFixed(2)); //123.65
        // toFixed把Number四舍五入为指定小数位数的数字,此处指定2位

        // 产生100以内随机数
        for (var i = 0; i < 10; i++) {
            var num = Math.random();
            console.log("随机数:" + num);
            num = num.toFixed(2);
            console.log("小数位:" + num);
            num = num * 100;
            console.log("乘一百:" + num);
        }

按道理来说在for循环中最后输出的数字应该都是0~100的两位数,但是结果并非如此,比如

        let num = 0.5458565720681452;
        num = num.toFixed(2);
        num = num * 100;
        console.log(num); //55.00000000000001

同样这还是因为精度不高,此处有一个解决办法就是先乘100再取整。

        for (var i = 0; i < 10; i++) {
            var num = Math.random();
            num = num * 100;
            num = num.toFixed(0);
            console.log(num);
        }

可计算范围:小数点前16位与后16位,如果超出请使用BigInt。

7. 基本类型和引用类型的值 P69

7.1. 数据类型

关于JavaScript的数据类型可参考 YAMA:JavaScript数据类型及变量

以下Pxx表示《JavaScript高级程序设计》的第几页

ECMAScript变量可能包含两种不同数据类型的值:基本类型值和引用类型值,前者指简单的数据段,后者指可能由多个值构成的对象

  1. 基本类型值:undefined、null、boolean、number、string、新增(Symbol、BigInt)
  2. 引用类型:Object

参考:YAMA:JavaScript数据类型

定义基本类型值和引用类型值的方式是类似的:创建一个变量并为其赋值。但是当值保存到变量中以后

  • 对于引用类型值我们可以对其添加属性(增删改查)。
  • 我们不能给基本类型值添加属性,但是这样做了并不会报错。

7.2. 赋值

把一个变量赋值给另一个变量时:

若是基本数据类型,则会新建一个值,把该值复制到新变量的内存上,此时两个变量的值各自独立,互不干扰。

此时如果是引用类型值,则会复制指针的值,该指针指向存储在堆中的对象,赋值后两个变量将引用同一个对象,改变其中一个变量会影响另一个变量 。

        function Person() {
            this.name = "hui";
        }
        var per1 = new Person();
        var per2 = per1;
        console.log(per1.name); //输出hui
        per2.name = "tian";
        console.log(per1.name); //输出tian

引用类型值复制示意图如下

7.3. 在传递参数时P70

JavaScript中,函数都是按照值来传递的,也就是说把函数外部的值赋值给函数内部的参数,就像把值从一个变量复制到另一个变量一样(这里面分为基本类型值的传递和引用类型值的传递),对此我感到困惑,因为参数只能按值传递,而访问变量却有两种方式,那么在向函数传递引用类型值时到底时怎么传值的?

图片源自 浅析 JavaScript Clone:堆内存、栈内存

当变量复制引用类型值的时候,它是一个指针,指向存储在堆内存中的对象(堆内存中的对象无法直接访问,要通过这个对象在堆内存中的地址访问,再通过地址去查值(RHS查询,试图获取变量的源值),所以引用类型的值是按引用访问)

所谓的传值是因为这个在栈内存中的变量,也就是这个指针(我的意思是这个指针是原始值)是存储在栈上的一个指针,指向一个存储在堆内存中的对象,所以说JS函数传参一定是按值传递的,但是访问变量确实有两种方式。

传递基本数据类型的值好理解,其实传递引用类型值时传递的仍然是值,传递的时候会把这个值在内存中的地址复制给一个局部变量,也就是形参,因此形参的变化会反映在外部,如下代码就说明了这一点

        function Person() {
            this.name = "hui";
        }
        var per1 = new Person();

        function setname(per) {
            per.name = "tong";
            console.log("局部" + per.name); //输出 局部tong
            per = new Person();
            console.log("局部" + per.name); //输出 局部hui
            per.name = "tian";
            console.log("局部" + per.name); //输出 局部tian
        }
        console.log(per1.name); //输出hui
        setname(per1);
        console.log(per1.name); //输出tong
        // 最后一行输出为tong,说明在setname函数内部per = new Person();并没有起作用,
        // 因为函数按值来传递

注意此处还在函数内部新建了一个同名的对象per,如果per1是按引用传递的,那么per就会自动修改为指向其name属性为tian的新对象。但当接下来在外部访问per1.name是dong,这说明在函数内部修改了参数的值但原始的引用仍然保持不变。实际上在函数内部重写per时,这个变量引用的就是一个局部对象了。而这个局部对象会在函数执行完毕后立即被销毁。

这里重申:对于JS中的内存,到底是怎么存的,文中只是给出了一种想得通的说法,真要追究到底,恐怕并非如此。参考:juejin.cn/post/684490…

8. 执行环境及作用域

关于作用域可以参考:JavaScript预编译、作用域

8.1. 执行环境

执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。 每个执行环境都有一个 与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。虽然我们 编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

根据宿主环境不同,表示的执行环境也不一样,在Chrome中全局执行环境就是window对象,在node中是Global 对象。

8.2. 作用域

参考:

  1. 掘金 JavaScript执行环境及作用域
  2. www.xiaohuochai.site/JS/ECMA/sco…

作用域是根据名称查找变量的一套规则。负责收集并维护有所有声明的标识符(变量)组成的一系列查询,并实施一套严格规则,确定当前执行的代码对这些标识符的访问权限,以及保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象(AO)作为变量对象。

活动对象在最开始时只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

还需注意在JS中if语句等等使用的大括号不算是块作用域。如下

        if (true) {
            var color = "blue";
        }
        alert(color); //"blue"

使用let或const会创建块作用域。

9. 垃圾收集

参考:MDN 内存管理

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()free()。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。

在P180闭包就涉及到了垃圾回收,由于闭包导致匿名函数的作用域一直引用着这个活动对象,换句话说活动对象还留在内存中不能被销毁,这会导致内存泄漏。我猜想这里的垃圾回收方式就是引用技术垃圾收集。

9.1. 内存声明周期

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放、归还

所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像JavaScript这些高级语言中,大部分都是隐含的。

9.2. 当内存不再需要使用时释放

大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

如上所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。

9.3. 垃圾回收实现方式

  1. 标记-清除算法
  2. 引用计数垃圾收集

参考:

  1. developer.mozilla.org/zh-CN/docs/…
  2. juejin.cn/post/684490…
  3. juejin.cn/post/684490…