JS深入理解闭包/作用域(scope)作用域链/执行上下文和执行栈
Posted 傲娇味的草莓
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS深入理解闭包/作用域(scope)作用域链/执行上下文和执行栈相关的知识,希望对你有一定的参考价值。
前言:javascript深入理解scope作用域和闭包,就要先理解什么是执行上下文和执行栈。作用域、作用域链、闭包是JavaScript的难点也是重点,其实理解起来也不难,学习过其他语言,比如C语言就可以很好的类比。
一 知识储备
深入学习JavaScript程序内部的执行机制,就要彻底理解执行上下文和执行栈。
了解一些专业概念
- EC:函数执行环境(或执行上下文),Execution Context
- ECS:执行环境栈,Execution Context Stack
- VO:变量对象,Variable Object
- AO:活动对象,Active Object
- scope chain:作用域链
二 执行上下文和执行栈
1 什么是执行上下文?
执行上下文是评估和执行javascript代码环境的抽象概念。每当控制器转到ECMAScript可执行代码的时候,它都是在执行上下文中运行,即执行环境中的变量,函数声明,参数,作用域链,this等信息。
//组成代码展示
const ExecutionContextObj = {
VO: window, // 变量对象
ScopeChain: {}, // 作用域链
this: window
};
2 执行上下文三种类型
-
全局执行上下文—这是默认上下文或者说基础的上下文,任何不在函数内部的代码都在全局上下文中,浏览器中的全局对象就是window对象。它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置
this
的值等于这个全局对象。一个程序中只会有一个全局执行上下文。 -
函数执行上下文—每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序执行一系列步骤。
-
Eval函数执行上下文—执行在
eval
函数内部的代码也会有它属于自己的执行上下文,JavaScript开发者不经常使用。
3 什么是执行栈?
执行栈,也叫调用栈,被用来存储代码运行时创建的所有执行上下文。
栈是一种数据结构,遵循后进先出的原则。
当 JavaScript 引擎第一次遇到脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。
function fn1() {
console.log('fn1被调用了 -- 创建了fn1的函数执行上下文,压入栈');
fn2();
console.log('fn2执行完成,fn2的执行上下文会从栈中弹出');
}
function fn2() {
console.log('fn2被调用了 -- 创建了fn2的函数执行上下文,压入栈');
}
fn1();
console.log('fn1执行完成,fn2的执行上下文会从栈中弹出');
运行结果
//fn1被调用了 -- 创建了fn1的函数执行上下文,压入栈
//fn2被调用了 -- 创建了fn2的函数执行上下文,压入栈
//fn2执行完成,fn2的执行上下文会从栈中弹出
//fn1执行完成,fn2的执行上下文会从栈中弹出
4 JavaScript引擎创建执行上下文
执行上下文有两个阶段
- 创建阶段
- 执行阶段
在代码执行前是创建阶段,发生三件事情
- this绑定
- 创建(LexicalEnvironment)词法环境组件
- 创建(VariableEnvironment)变量环境组件
ExecutionContext = {
ThisBinding = <this value>,
LexicalEnvironment = { ... },
VariableEnvironment = { ... },
}
(1)创建阶段
- this绑定
在全局执行上下文中,this 的值指向全局对象。(在浏览器中,this引用window对象)
在函数执行上下文中,this的指向取决于函数是如何被调用的。
let obj = {
fn: function() {
console.log(this);
}
}
let win = obj.fn;
obj.fn(); //this指向obj
win(); // this指向window
- LexicalEnvironment词法环境
官方ES6文档概述:词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
attention:标识符指的是变量/函数的名字,而变量是对实际对象[包含函数类型对象]或原始数据的引用。
词法环境有两个组成部分
- 声明式环境记录器:存储变量和函数声明的实际位置
- 对象环境记录器:可以访问其外部词法环境(作用域)
词法环境有两种类型
全局环境:是一个没有外部环境的词法环境,其外部环境引用为 null。拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量,this 的值指向这个全局对象。
函数环境:用户在函数中定义的变量被存储在环境记录中,包含了arguments 对象。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。
attention:
在全局环境中,环境记录器是对象环境记录器
在函数环境中,环境记录器是声明式环境记录器
- VariableEnvironment变量环境
在ES6中,词法环境组件和变量环境组件之间的一个区别是前者用于存储函数声明和变量let和const绑定,而后者仅用于存储变量var绑定。
(2)执行阶段
在此阶段,完成对所有这些变量的分配,最后执行代码。(在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined
三 作用域(Scope)
1 什么是作用域?
作用域指程序中定义变量的区域,它可以决定当前执行代码的变量的可访问权限。按照我自己的理解来说,作用域就是代码中某些特定的变量、函数在特定的独立区域可以被访问到。
举个例子
function OutFun() {
var inVariable = "internal variable";
}
OutFun();
console.log(inVariable);
为什么会报错呢?这就跟变量的作用域有关系了!这里先不解释,看完整篇笔记就懂了。
2 作用域有什么作用呢?
我们给变量起名字的时候,有时候代码量太多,一不小心就重名了。就像全世界这么多人,总有很多人重名一个道理,那如何保证识人的唯一性呢。对的!可以通过区域划分。这些人生活在世界的各个角落,区域是不一样的。所以我们可以通过A住在B城,另外一个A住在C城,从而把A和A区分开来。作用域也是一个道理,不同作用域下的同名变量不会起冲突的原因就是,重名变量各自在不同的区域被访问到,起到了隔离的作用。
3 作用域的分类
在ES6之前,JavaScript只有全局作用域和函数作用域。在ES6之后,通过提供关键字let/const来体现块级作用域。
(1)全局作用域
在代码中的任何地方都可以被访问到的对象就拥有全局作用域,那什么情况下是拥有全局作用域的呢?
- 最外层函数+在最外层函数的外面定义的最外层变量
var outVariable = "I am outermost variable"; //最外层变量
function outFun() { //最外层函数
var innerVariable = "I am internal variable"; //内层变量
function innerFun() { //内层函数
console.log(innerVariable);
}
innerFun();
}
console.log(outVariable);
outFun();
console.log(innerVariable);
innerFun();
为什么控制台会有这样的打印信息呢?
在代码中我们在最外层函数outFun()里面嵌套了一层内层函数innerFun(),变量outVariable在全局作用域有声明,所以没有报错。变量innerVariable在函数内部被声明,而在全局作用域没有声明,所以在全局作用域下取值会报错。outFun()函数是外层函数,在全局作用域下被声明被调用,不会报错。innerFun()函数的声明嵌套在outFun()函数里面,属于内层函数,在全局作用域调用会报错。要深入理解这些,建议结合计算机组成原理,了解全局变量和局部变量在计算机内部存储的分配和使用。
- 未定义直接赋值的变量自动声明且有全局作用域
function outFun() {
variable = "我是没有被定义直接赋值的变量";
var invariable = "我是内部变量";
}
outFun();
console.log(variable);
console.log(invariable);
- 一般情况下,window对象拥有全局作用域
window.name;
window.location;
window.top;
弊端
频繁使用全局变量在全局作用域起作用,会污染内存空间,引起变量重名的冲突,造成资源浪费。全局变量内存的分配一直要等到程序执行结束才会被释放。如果一个变量只需要在代码第一行使用之后就不会再次被使用,但是它命名到全局作用域,那么这一个全局变量就会污染内存空间,一直霸占内存位置,但实际上没有任何的使用价值了。(占着茅坑不拉屎)
解决方案—函数作用域
(2)函数作用域
函数作用域是指声明在函数内部的变量,和全局作用域相反,是局部作用域。在固定的函数代码段里面才可以被访问和使用。函数执行完之后内存就会释放为执行这个函数开辟的内存空间,就解决了全局变量会引起污染的问题。
function Fun() {
var name = "gaby";
function SayHi() {
alert(name);
}
SayHi();
}
alert(name);
SayHi();
SayHi()函数是内层函数,嵌套在Fun()外层函数里面,所以会造成脚本错误。函数作用域顾名思义就是在函数{}括起来的内部可以被访问有作用。
函数的作用域是分层级的,内层作用域的变量可以访问外部作用域的变量,反之则不行。
每一个变量都有生命周期
来看代码例子,加深理解。
function add1(a) {
var b = a + a;
function add2(c) {
console.log(a, b, c);
}
add2(b * 3);
}
add1(2);
根据下图控制台的信息显示,add2()这个最内层的函数可以依次访问到变量a,变量b,变量c。
给上面的代码再增加一行,我们再看一下控制台的信息。
function add1(a) {
var b = a + a;
function add2(c) {
console.log(a, b, c);
}
add2(b * 3);
console.log(a, b, c);
}
add1(2);
根据下图控制台信息显示,控制台报错了,原因就是add1()这个外层函数不能访问add2()这个内层函数的变量。
由上面的例子可以看到,函数变量的访问顺序是按层级划分的,一层一层查找。
需要注意的是,并不是所有用{}大括号括起来的代码都是函数作用域,比如循环if和switch,不会像函数,创建一个新的作用域。
(3)块级作用域
ES6的新特性,通过let和const关键字声明,所声明的变量在指定的块级作用域外无法被访问。
在什么环境下会被创建呢?
- 在一个函数内部
- 在一个代码块{}内部
使用let和const的时候需要注意几个点
(1)声明的变量不会提升到代码块的顶部,需要手动将声明放置到顶部,方便变量在整个代码块内都可以使用
function getName(condition) {
if (condition) {
let name = "gaby";
return name;
} else {//name在此处不可用
return null;
}
//name在此处不可用
}
(2)不能重复声明同一个变量
var name = "gaby";
let name = "gaby";
如果一个标识符在代码块内部已经被声明,那么在这代码块内使用let再次声明会抛出错误。name被声明了两次,一次是var另一次是let,let不能在同一快作用域内重复声明一个已经被声明过的变量。但是如果处理成嵌套的情况,则是可以的。
var name = "gaby";
function fun() {
let name = "gabrielle";
}
(3)实现for循环的块级作用域
计数器变量问题(稍微提一下,这个机理说清楚会需要很大的篇幅)
for (let i = 0; i < 10; i++) {
//...
}
console.log(i);
使用let声明i,计数器变量i只在循环体内能被访问到,循环体外找不到。
我们再来比较var和let声明的区别
- 用var声明
var a = [];
for (var i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
}
}
a[4]();
为什么输出是10?
for循环里面的i用var声明,在全局作用域内都可以被访问,全局变量只有一个i。每执行一次循环体,i的值都会被改变。循环体内的语句console.log(i)里面的i指向的是全局变量。所有a数组里面的成员都是指向同一个i,执行完之后都为10。
- 用let声明
var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
}
}
a[4]();
为什么输出是4?
let和var的作用域不同,let在循环体的每一轮循环中有效,每一轮循环i的值都是不一样,因为每一轮循环的i都需要被重新声明。那每一轮都是新的值,是重复的值还是在上一轮循环的值基础上进行计算。**是上一轮循环的值基础上进行计算!**这是因为JavaScript引擎会记住上一轮循环的值。
(4)创造父作用域和子作用域
for (let i = 0; i < 4; i++) {
let i = 'gaby';
console.log(i);
}
输出四次gaby,循环变量i和函数内部i不是同一个作用域,由各自的单独的作用域。
四 作用域链
1 自由变量
当前作用域没有定义的变量叫做自由变量,这个自由变量会由内而外向父级一层一层寻找。
var a = 1;
function fun() {
var b = 2;
console.log(a);
console.log(b);
}
fun();
2 作用域链
在自由变量向上一层一层寻找,直到找到全局作用域没有找到就结束。自由变量一层一层查找的关系,就叫做作用域链。
var a = 1;
function fun1() {
var b = 2;
function fun2() {
var c = 3;
console.log(a);
console.log(b);
console.log(c);
}
fun2();
}
fun1();
五、闭包
在前面分别整理了要深入理解闭包需要的前缀知识,js原型、原型链、继承+scope作用域、作用域链+执行上下文和执行栈+变量的作用域和变量提升等等。在这些前缀知识的基础上去理解js闭包是不难的。作为一个前端打工人,扎扎实实的js基础是必要条件,地基不稳楼层又能高到哪里去呢?
1 什么是闭包?
在上一篇笔记中整理了js的scope作用域,不懂回去翻看笔记。能够访问其他内部函数变量的函数,成为闭包。
在函数内部定义的函数,被返回出去并在外部进行调用,就是闭包。
function fun1() {
var a = 1;
function fun2() {
console.log(a);
}
return fun2;
}
var fn = fun1();
fn();//形成闭包,从外部获取内部作用域的信息。
我们再一次回顾一下,前面整理的知识,代码究竟是怎么样的一个运行机制?
(1)编译阶段,变量和函数被声明,作用域就确定下来。
(2)运行函数fun1(),创建fun1()函数的执行上下文,内部存储fun1()中所有的变量函数的信息。
(3)函数fun1()执行完之后,把fun2的引用赋值给外部变量fn,要明确的是此时fn的指针指向的是fun2,此时fn位于全局作用域,fun2位于函数作用域,所以可以看见fn位于fun1() 作用域之外,但是访问到了fun1()的内部变量。
(4)fn在全局被执行,内部代码console.log(a)向作用域请求获取a变量,在本级的作用域没有找到,就向上父级作用域一层一层找父亲(找爸爸)。在fun1()找到了a变量,返回给console.log所以打印出来了1。
2 闭包被创造出来有什么用?
还是一样的逻辑,一样方法被创造出来肯定是用来解决问题,那么闭包可以用来干嘛呢?
(1)属性私有化
接触过java语言的都学过java可以通过关键字public
private
设定访问权限。但是JavaScript中没有,对象中的方法和属性均可以访问到,没有隐私空间,可以随意修改属性和方法,造成安全隐患。想象一下,你的房间没有钥匙,任何人都可以进入访问你的房间,随意更改你房间的布置,是不是很可怕的事情。闭包的出现就是来解决这个问题的,模拟属性和方法的私有化。
function getImformation() {
var name = "gaby";
var age = 20;
return function () {
return {
getName: function () {
return name;
},
getAge: function () {
return age;
}
};
};
}
var obj = getImformation()();
obj.getName();
obj.getAge();
obj.age;
(2)避免重复实例化
单一实例化,保证一个类只有一个实例,避免污染内存空间。先判断实例是否存在,存在就返回,不存在创建后返回。
function Gaby() {
this.data = "gaby";
}
Gaby.getInstance = (function () {
var instance;
return function () {
if (instance) {
return instance;
} else {
instance = new Gaby();
return instance;
}
}
})();
var a = Gaby.getInstance();
var b = Gaby.getInstance();
console.log(a === b);
console.log(a.data);
3 闭包会引起什么问题?
JavaScript内部有垃圾回收机制,用计数的方法 。当内存中的一个变量被引用一次,计数+1,垃圾回收机制会在固定的时间间隔内询问这些变量,将计数为0的变量标记为失效变量从而清除释放内存。
再来看第一个闭包代码,fun1()函数隔绝了外部的影响,所有变量在函数内部完成,fun1()执行后,理论上内部的变量就会被销毁,内存被回收。但是我们写了一个闭包,这就导致了全局作用域始终存在一个a变量,一直占用内存,造成内存泄漏。
function fun1() {
var a = 1;
function fun2() {
console.log(a);
}
return fun2;
}
var fn = fun1();
fn();//形成闭包,从外部获取内部作用域的信息。
由于闭包使用过度而导致内存无法释放的情况,就叫做内存泄漏。
正经一点:内存泄露 是指当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者内存池的现象。内存泄漏可能会导致应用程序卡顿或者崩溃。
六、后话
最近也是大三上学期了,也开始到了找实习焦虑的日常。技术岗不缺人才,向上看的人很多,但是愿意向下扎根的人很少。希望每一个coder都能向下扎根也能向上生长。
以上是关于JS深入理解闭包/作用域(scope)作用域链/执行上下文和执行栈的主要内容,如果未能解决你的问题,请参考以下文章
1--面试总结-js深入理解,对象,原型链,构造函数,执行上下文堆栈,执行上下文,变量对象,活动对象,作用域链,闭包,This
前端:如何理解 JS 的作用域和作用域链?说说闭包的两个应用场景
深入理解JavaScript系列(14):作用域链(Scope Chain)