我所认识的 JavaScript 作用域链和原型链
发布于 7 年前 作者 MrErHu 6452 次浏览 来自 分享

毕业也整整一年了,看着很多学弟都毕业了,忽然心中颇有感慨,时间一去不复还呀。记得从去年这个时候接触到JavaScript,从一开始就很喜欢这门语言,当时迷迷糊糊看完了《JavaScript高级程序设计》这本书,似懂非懂。这几天又再次回顾了这本书,之前很多不理解的内容似乎开始有些豁然开朗了。为了防止之后自己又开始模糊,所以自己来总结一下JavaScript中关于 作用域链和原型链的知识,并将二者相比较看待进一步加深理解。以下内容都纯属于自己的理解,有不对的地方欢迎指正。

作用域链

作用域

首先我们需要了解的是作用域做什么的?当JavaScript引擎在某一作用域中遇见变量函数的时候,需要能够明确变量和函数所对应的值是什么,所以就需要作用域来对变量和函数进行查找,并且还需要确定当前代码是否对该变量具有访问权限。也就是说作用域主要有以下的任务:

  • 收集并维护所有声明的标识符(变量和函数)
  • 依照特定的规则对标识符进行查找
  • 确定当前的代码对标识符的访问权限

举一个例子:

function foo(a) {
    console.log( a ); // 2
}

foo( 2 );

对于上述代码,JavaScript引擎需要对作用域发出以下的命令

  • 查询标识符foo,得到变量后执行该变量
  • 查询标识符a,得到变量后对其赋值为2
  • 查询标识符console,得到变量后准备执行属性log
  • 查询标识符a,得到变量后,作为参数传入console.log执行

我们省略了函数console.log内部的执行过程,我们可以看到对JavaScript引擎来说,作用域最重要的功能就是查询标识符。从上面的例子来看,引擎对变量的使用其实不是都一样的。比如第一步引擎得到标识符foo的目的是执行它(或者说是为了拿到标识符里存储的值)。 但第二步中引擎查找标识符a的目的是为了对其赋值(也就是改变存储的值)。所以查找也分为两种:LHSRHS

我在之前的一篇文章中从LHS与RHS角度浅谈Js变量声明与赋值曾经介绍过LHSRHS,这两个看起来很高大上的名词其实非常简单。LHS指的是Left-hand Side,而RHS指的是Right-hand Side。分别对应于两种不同目的的词法查询。LHS所查询的目的是为了赋值(类似于该变量会位于赋值符号=的左边),例如第二步查找变量a的过程。而RHS所查询的目的是为了引用(类似于变量会位于赋值符号=的右边),例如第一步查找变量foo的过程。

作用域链

我们知道代码不仅仅可以访问当前的作用域的变量,对于嵌套的父级作用域中的变量也可以访问。我们先只在ES5中表述,我们知道JavaScript在ES5中是没有块级作用域的,只有函数可以创建作用域。举个例子:

function Outer(){
    var outer = 'outer';
    Inner();
    function Inner(){
        var inner = 'inner';
        console.log(outer,inner) // outer inner
    }
}

当引擎执行到函数Inner内部的时候,不仅可以访问当前作用域而且可以访问到Outer的作用域,从而可以访问到标识符outer。因此我们发现当多个作用域相互嵌套的时候,就形成了作用域链。词法作用域在查找标识符的时候,优先在本作用域中查找。如果在本作用域没有找到标识符,会继续向上一级查找,当抵达最外层的全局作用域仍然没有找到,则会停止对标识符的搜索。如果没有查找到标识符,会根据不同的查找方式作出不同的反应。如果是RHS,则会抛出Uncaught ReferenceError的错误,如果是LHS,则会在查找最外层的作用域声明该变量,这就解释了为什么对未声明的变量赋值后该变量会成为全局变量。所以上面的代码执行

console.log(outer,inner)

的时候,引擎会首先要求Inner函数的词法作用域查找(RHS)标识符outer,被告知该词法作用域不存在该标识符,然后引擎会要求嵌套的上一级Outer词法作用域查找(RHS)标识符outer,Outer词法作用域的查找成功并将结果返回给引擎。

换个角度理解作用域链

上面我们理解作用域链都是从作用域链查找变量的角度去考虑的,其实这已经足够了,大部分作用域链的场景都是查找标识符。但是我们可以换一个角度去理解作用域链。其实JavaScript的每个函数都有对应的执行环境(execution context)。当执行流进入进入一个函数时,该函数的执行环境就会被推入环境栈,当函数执行结束之后,该函数的执行环境就会被弹出环境栈,执行环境被变更为之前的执行环境。而每创建一个执行环境时,会同时生成一个变量对象(variable object)(函数生成的是活动变量(activation object)),用来存储当前执行环境中定义的变量和函数,当执行环境结束时,当前的变量(活动)对象就会被销毁(全局的变量对象是一直存在的,不会被销毁)。虽然我们无法访问到变量(活动)对象,但词法作用域查找标识符会使用它。   当对于函数的执行环境生成的活动对象,初始化就会存在两个变量:thisarguments,因此我们在函数中就直接可以使用这两个变量。对于作用域链存储都是变量(活动)对象,而当前执行环境的变量对象就存储在作用域链的最前端,优先被查找。从这个角度看,标识符解析是沿着作用域链一级一级地在变量(活动)对象中搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止。

闭包

这年头出去面试JavaScript的岗位,各个都要问你闭包的问题,开始的时候觉得闭包的概念蛮高级的,后来觉得这个也没啥东西可讲的。老早的之前就写过一篇关于闭包的文章浅谈JavaScript闭包,讲到现在我觉得把闭包放到作用域链一起将会更好。还是继续讲个例子:

function fn(){
    var a = 'JavaScript';
    function func(){
        console.log(a);
    }
    return func;
}

var func = fn();
func(); //JavaScript

首先明确一下什么是闭包?我认为闭包最好的概念解释就是:

函数在定义的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义时的词法作用域。

func函数执行的位置和定义的位置是不相同的,func是在函数fn中定义的,但执行却是在全局环境中,虽然是在全局函数中执行的,但函数仍然可以访问当定义时的词法作用域。如下图所示:

我们之前说过,当函数执行结束后其活动变量就会被销毁,但是在上面的例子中却不是这个样子。但函数fn执行结束之后,fn对象的活动变量并没有被销毁,这是因为fn返回的函数func的作用域链还保持着fn的活动变量,因此JavaScript的垃圾回收机制不会回收fn活动变量。虽然返回的函数func是在全局环境下执行的,但是其作用域链的存储的活动(变量)对象的顺序分别是:func的活动变量、fn的活动变量、全局变量对象。因此在func函数执行时,会顺着作用域链查找标识符,也就能访问到fn所定义的词法作用域(即fn函数的活动变量)也就不足为奇了。这样看起来是不是觉得闭包也是非常的简单。

原型链

原型

说完了作用域链,我们来讲讲原型链。首先也是要明确什么是原型?所有的函数都有一个特殊的属性: prototype(原型)prototype属性是一个指针,指向的是一个对象(原型对象),原型对象中的方法和属性都可以被函数的实例所共享。所谓的函数实例是指以函数作为构造函数创建的对象,这些对象实例都可以共享构造函数的原型的方法。举个例子:

var Person = function(name){
    this.name = name;
}
Person.prototype.sayName = function(){
    console.log('name: ', this.name)
};

var person = new Person('JavaScript');
person.sayName(); //JavaScript

在上面的例子中,对象person是构造函数Person创建的实例。所谓的构造函数也只不过是普通的函数通过操作符new来调用。在使用new操作符调用函数时主要执行以下几个步骤:

  • 创建新的对象,并将函数的this指向新创建的对象
  • 执行函数
  • 返回新创建的对象

通过构造函数返回的对象,其中含有一个内部指针[[Prototype]]指向构造函数的原型对象,当然我们是无法访问到这个标准的内部指针[[Prototype]],但是在Firefox、Safari和Chrome在上都支持一个属性**__proto__**,用来指向构造函数的原型对象。下图就解释了上面的结构:

我们可以看到,构造函数Personprototype属性指向Prototype的原型对象。而person作为构造函数Person创建的实例,其中存在内部指针也指向Person的原型对象。需要注意的是,在Person的原型对象中存在一个特殊的属性constructor,指向构造函数Person。在我们的例子中,执行到:

person.sayName(); //JavaScript

当执行personsayName属性时,首先会在对象实例中查找sayName属性,当发现对象实例中不存在sayName时,会转而去搜索person内部指针[[Prototpe]]所指向的原型对象,当发现原型对象中存在sayName属性时,执行该属性。关于函数sayNamethis的指向,有兴趣可以戳这篇文章一个小小的JavaScript题目

原型链

讲完了原型,再讲讲原型链,其实我们上面的图并不完整,因为所有函数的默认原型都是Object的实例,所以函数原型实例的内部指针[[Prototype]]指向的是Object.prototype,让我们继续来完善一下:         这就是完整的原型链,假如我们执行下面代码:

person.toString()

执行上面代码时,首先会在对象实例person中查找属性toString方法,我们发现实例中不存在toString属性。然后我们转到person内部指针[[Prototype]]指向的Person原型对象去寻找toString属性,结果是仍然不存在。这找不到我们就放弃了?开玩笑,我们这么有毅力。我们会再接着到Person原型对象的内部指针[[Prototype]]指向的Object原型对象中查找,这次我们发现其中确实存在toString属性,然后我们执行toString方法。发现了没有,这一连串的原型形成了一条链,这就是原型链。      其实我们上面例子中对属性toString查找属于RHS,以RHS方式寻找属性时,会在原型链中依次查找,如果在当前的原型中已经查找到所需要的属性,那么就会停止搜索,否则会一直向后查找原型链,直到原型链的结尾(这一点有点类似于作用域链),如果直到原型链结尾仍未找到,那么该属性就是undefined。但执行LHS方式的查找却截然不同,当发现对象实例本身不存在该属性,直接在该对象实例中声明变量,而不会去查找原型链。例如:

person.toString = function(){
    console.log('person')
}
person.toString(); //person

当对person执行LHS的方式查找toString属性时,我们发现person中并不存在toString,这时会直接在person中声明属性,而不会去查找原型链,接着我们执行person.toString()时,我们在实例中找到了toString属性并将其执行,这样实例中的toString就屏蔽了原型链中的toString属性。

作用域链和原型链的比较

讲完了作用域链和原型链,我们可以比较一下。作用域链的作用主要用于查找标识符,当作用域需要查询变量的时候会沿着作用域链依次查找,如果找到标识符就会停止搜索,否则将会沿着作用域链依次向后查找,直到作用域链的结尾。而原型链是用于查找引用类型的属性,查找属性会沿着原型链依次进行,如果找到该属性会停止搜索并做相应的操作,否则将会沿着原型链依次查找直到结尾。        如果觉得阅读完了本篇文章对你有些许帮助,欢迎大家我关注我的掘金账号或者star我的Github的blog项目,也算是对我的鼓励啦!

4 回复

能通俗简单的说出来,也需要一定技巧和理解。

[[prototype]] ->也就是 _proto_

另外,图上 对象person [[prototype]] 不是指向 constructor ,是指向 Person prototype

function Person (){}
const person =  new Person ()
console.log(person.__proto__ === Person.prototype);//true
console.log(person.__proto__ === Person.constructor);//false
回到顶部