Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

我所认识的JavaScript作用域链和原型链 #16

Open
MrErHu opened this issue Jun 28, 2017 · 1 comment
Open

我所认识的JavaScript作用域链和原型链 #16

MrErHu opened this issue Jun 28, 2017 · 1 comment

Comments

@MrErHu
Copy link
Owner

MrErHu commented Jun 28, 2017

  毕业也整整一年了,看着很多学弟都毕业了,忽然心中颇有感慨,时间一去不复还呀。记得从去年这个时候接触到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项目,也算是对我的鼓励啦!

@ped-Yc
Copy link

ped-Yc commented Mar 17, 2021

讲得很清楚

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants