JS原型链、作用域链、执行环境、闭包、this指向透析

665 阅读9分钟

写在前面

首先,原型链≠作用域链,他们是两个不同的概念,经常有人会把他们弄混。

闭包是作用域链的一种应用,想要弄清楚其中的细节,需要理解函数调用时,执行环境、作用域链的细节和关系。

我们都知道,this对象是在运行时基于函数执行环境确定的,它指向调用该方法的对象。但是多数人并不知道为什么要有this。

实际上,因为函数在js中既可以当做值传递和返回,也可当做对象和构造函数,所有函数在运行时需要确定其当前的运行环境,因此this出现了。this会根据运行环境的改变而改变,同时,函数中的this也只能在运行时才能最终确定运行环境。

原型链

理解原型链之前首先需要理解JS中原型的概念。

在JS中,我们创建的每一个函数都有一个prototype(原型)属性,该属性本质是一个指针,指向一个对象(原型对象)。该对象用于包含可以被特定类型的所有实例共享的属性和方法。默认情况下,所有的原型对象都会自动获得一个constructor属性,该属性是一个指向该原型对象所在的函数的指针。

前面所说的函数,实际上也就是常说的构造函数。当使用new语法调用构造函数创建该构造函数的一个新的实例后,该实例的内部便会自动的包含一个指针属性:__ptoto__。它指向构造函数的原型对象。

由于我们创建的自定义构造函数的原型对象,本质上也是一个对象,因此他也具有__proto__属性,它指向Object构造函数的原型对象:Object.prototype。其次,JS中,函数也是一种对象,因此Object构造函数也有__proto__属性,指向Function构造函数的原型对象。而Function构造函数的__proto__和prototype均指向它的原型对象,即Function.prototype。Function的原型对象的__proto__指向Object构造函数的原型对象。由此可见,所有对象的__proto__属性最终都会汇聚到Object构造函数的原型对象。最后,Object构造函数的原型对象的__proto__属性指向null,它表示原型链的顶端。下面这张图简单画出了部分关键的__proto__和prototype指向。

理解了原型链的构成之后,下面说说原型链的作用。当我们访问一个对象的属性时,如果该对象自身包含这个属性,自然可以直接取到。但是如果该对象不包含,是不是就获取不到呢?不一定。因为如果对象自身不包含该属性,则会沿着原型链查找,直到在某个原型对象中找到该属性来返回或者找到原型链顶端也没有找到,返回报错。

基于原型链和对象属性访问的机制,可以实现原型链继承。它的原理是:

如果将父类的一个实例对象作为子类构造函数的原型对象。则子类构造函数创建子类实例后,子类实例的__proto__也就指向了父类的一个实例对象,而该父类实例对象的__proto__属性指向父类的原型对象。因此,当通过子类实例访问一个属性时,会依次检查子类实例自身->子类原型对象->父类原型对象->Object构造函数的原型对象,从而实现原型继承。

//组合继承(包含了原型继承)
function father(name){
    this.name = name;
}
function child(name,age){
    father.call(this,name);
    this.age = age;
}
father.prototype.getName = function(){
    return this.name;
}
child.prototype = new father('father1');
child.prototype.constructor = child;
const obj_child1 = new child('child1',13)

作用域链和执行环境

之所以把作用域链和执行环境放在一起,是因为作用域链和执行环境实际上密不可分。

我们都知道,JS的作用域是静态的,而执行环境是动态的。这句话是什么意思?

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。简单来说就是在执行之前就确定了它可以应用哪些地方的作用域(变量)。

和静态作用域不同,JS的执行环境是动态变化的。

执行环境定义了变量或者函数有权访问的其他数据,决定了他们各自的行为。

每个执行环境都有一个与之相关的变量对象,它保存着环境中定义的所有变量和函数。在JS中,全局执行环境是最外层的执行环境,在web浏览器中,通常被认为是window对象。

除了全局执行环境,每个函数也有自己的局部执行环境,当执行流进入到一个函数时,便会将函数的执行环境压入环境栈中,函数执行结束后再弹出,将控制权交给之前的执行环境。

当代码在一个环境中执行时,会创建变量对象的一个作用域链,作用域链的作用,是保证对执行环境内有权访问的所有变量和函数的有序访问。作用域链的前端,始终是当前执行环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象。活动对象最初时只包含arguments一个变量。作用域链中的下一个变量对象来自其包含(外部)环境,再下一个变量对象则来自下一个包含的环境。这样,一直延续到全局执行环境。全局执行环境的变量对象始终是作用域链的最后一个对象。

上述一段话摘自《Javascript 高级程序设计》第三版第73页。这里面需要补充解释两点:

  1. 作用域链的生成是“动态的”,并且每个执行环境都有对应的自己的作用域链,而不是所有的执行环境共享一个作用域链。
  2. “作用域链中下一个变量对象来自其包含环境”,这里的包含关系是静态的包含关系,以函数为例,指的是**函数声明时他的外层包含函数,而不是函数调用时的外层函数。**这一点非常重要,也非常容易出错!

标识符的解析是沿着作用域链一级一级地搜索的过程。搜索的过程始终从作用域的前端开始,然后逐级地向后回溯,直到找到为止或者找不到报错。

由此可见,作用域链的本质是一个指向变量对象的指针列表,他只是引用而不是实际包含变量对象。这一概念非常重要,它是闭包的基础。

那么作用域链具体是怎么生成的呢?

举个例子:

var a = 10
function fn() {
    var b = 20
    function bar() {
        console.log(a + b) //30
    }
    return bar
}
var x = fn(),b = 200
x() //bar()

上述代码中,函数fn在全局作用域下创建,在创建fn时,会创建一个预先包含全局变量对象的的作用域链,并且这个作用域链保存在函数内部[[scope]]属性中,当调用fn函数时,会为该函数创建一个执行环境,然后通过复制函数[[scope]]属性中的对象构建起执行环境的作用域链,然后创建自身的活动对象(在此作为变量对象使用)并推入到执行环境作用域链的前端。因此执行fn的过程中,其作用域链包含两个变量对象:本地活动对象和全局变量对象。

在fn中,定义了函数bar,同理,创建一个包含全局变量和fn活动变量的作用域链保存在bar内部[[scope]]属性中,之后,在执行x()时(相当于执行了bar),同样复制函数[[scope]]属性中的对象构建起执行环境的作用域链,然后创建自身的活动对象(在此作为变量对象使用)并推入到执行环境作用域链的前端。因此执行x的过程中,其作用域链包含三个变量对象:本地活动对象、fn函数变量对象、全局变量对象。

闭包

闭包是指有权访问另一个函数作用域中的变量的函数

因此创建闭包的常用方式就是在一个函数的内部创建另一个函数。

还以前面的例子为例,前面说了,在fn函数内部创建函数bar时,内部函数bar会将包含函数(即外部函数)的活动对象加入到他的作用域链中,因此内部函数的作用域链中包含外部函数的变量对象。将内部函数返回后,他的作用域链被初始化为包含fn函数的变量对象和全局变量对象。因此内部函数可以访问外部函数定义的所有变量。不仅如此,即便在外部函数fn执行结束之后,它的活动对象也不会被销毁,因为它还被内部函数的作用域链引用着。换句话说,当外部函数fn返回后,它的执行环境的作用域链会被销毁,但是它的变量对象仍然留在内存中,除非内部函数bar被销毁。(通过bar=null,即x=null销毁)。

this指向

说到this指针,熟悉面向对象语言的同学不会陌生。例如在Java中,this指针在只能在实例化对象里使用,this指针就等于这个被实例化好的对象,而this后面加上点操作符,点操作符后面的东西就是this所拥有的属性。

但是JS中的this比java中要复杂多变很多,主要原因在于:

  • javascript里的函数既可以作为对象传递的,也可以作为构造函数,创建实例化对象,结果导致方法执行时候this指针的指向会不断发生变化,很难控制。

  • javascript里的this在没有进行new操作也会生效,非严格模式下,默认this会指向全局对象window。

  • javascript里call和apply操作符可以随意改变this指向

JS中this指向主要可以分为四类:

  1. 默认情况下指向window
  2. 隐式绑定:通过对象调用函数时,函数内部this指向该对象
  3. 显式绑定:call、apply绑定
  4. 硬绑定:bind绑定,绑定后不可更改
  5. new绑定:指向构造出来的实例对象。

优先级:

显式绑定 > 隐式绑定 > 默认绑定

new绑定 > 隐式绑定 > 默认绑定