毕业也整整一年了,看着很多学弟都毕业了,忽然心中颇有感慨,时间一去不复还呀。记得从去年这个时候接触到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
的目的是为了对其赋值(也就是改变存储的值)。所以查找也分为两种:LHS
和RHS
。
我在之前的一篇文章中从LHS与RHS角度浅谈Js变量声明与赋值曾经介绍过LHS
与RHS
,这两个看起来很高大上的名词其实非常简单。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)),用来存储当前执行环境中定义的变量和函数,当执行环境结束时,当前的变量(活动)对象就会被销毁(全局的变量对象是一直存在的,不会被销毁)。虽然我们无法访问到变量(活动)对象,但词法作用域查找标识符会使用它。
当对于函数的执行环境生成的活动对象,初始化就会存在两个变量:this
和arguments
,因此我们在函数中就直接可以使用这两个变量。对于作用域链存储都是变量(活动)对象,而当前执行环境的变量对象就存储在作用域链的最前端,优先被查找。从这个角度看,标识符解析是沿着作用域链一级一级地在变量(活动)对象中搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止。
闭包
这年头出去面试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__
**,用来指向构造函数的原型对象。下图就解释了上面的结构:
我们可以看到,构造函数Person
的prototype
属性指向Prototype
的原型对象。而person
作为构造函数Person
创建的实例,其中存在内部指针也指向Person
的原型对象。需要注意的是,在Person
的原型对象中存在一个特殊的属性constructor
,指向构造函数Person
。在我们的例子中,执行到:
person.sayName(); //JavaScript
当执行person
的sayName
属性时,首先会在对象实例中查找sayName
属性,当发现对象实例中不存在sayName
时,会转而去搜索person
内部指针[[Prototpe]]
所指向的原型对象,当发现原型对象中存在sayName
属性时,执行该属性。关于函数sayName
中this
的指向,有兴趣可以戳这篇文章一个小小的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项目,也算是对我的鼓励啦!
赞👍
能通俗简单的说出来,也需要一定技巧和理解。
[[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