作用域和闭包
1.什么是作用域
1.1 几个概念
A. 编译器:语法分析及代码生成
B. 引擎:负责JS程序的编译和执行
C. 作用域:收集并维护由声明的变量组成的查询,确定当前执行代码的访问权限
D. LHS:赋值操作的目标是谁(要给谁赋值)
E. RHS :谁是赋值操作的源头(获取变量的值)
var a = 2复制代码
我们看下这几个概念是如何连在一起的
(1) 首先编译器会在当前作用域中声明一个变量(如果之前没有声明过);
分词 / 词法分析:编译器会先将一连串字符打断成(对于语言来说)有意义的片段,称为 token(记号),例如 var a = 2;。这段程序很可能会被打断成如下 token:var,a,=,2。
解析 / 语法分析:编译器将一个 token 的流(数组)转换为一个“抽象语法树”(AST —— Abstract Syntax Tree),它表示了程序的语法结构。
代码生成:编译器将上一步中生成的抽象语法树转换为机器指令,等待引擎执行。
(2) 然后在运行时引擎会在作用域中==查找==该变量,如果能够找到就会对它==赋值== ;
LHS (Left-hand Side) 和 RHS (Right-hand Side) ,是在代码执行阶段 JS 引擎操作变量的两种方式,二者区别就是对变量的查询目的是 变量赋值 还是 查询 。
LHS 可以理解为变量在赋值操作符(=)的左侧,例如 a = 2,当前引擎对变量 a ==查找的目的是变量赋值==。这种情况下,引擎不关心变量 a 原始值是什么,只管将值 2 赋给 a 变量。
RHS 可以理解为变量在赋值操作符(=)的右侧,例如:console.log(a),其中引擎==对变量a的查找目的就是查询==,它需要找到变量 a 对应的实际值是什么,然后才能将它打印出来。
通过以上两大步才完成一个变量的赋值。
1.2 什么是作用域
几乎所有语言的最基础模型之一就是在变量中存储值,并且在稍后取出或修改这些值。在变量中存储值和取出值的能力,给程序赋予了状态。这就引伸出两个问题:这些变量被存储在哪里?程序如何在需要的时候找到它们?回答这些问题需要一组明确定义的规则,它定义了如何存储变量,以及如何找到这些变量。我们称这组规则为:作用域。
简洁:==指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。==
javascript 中大部分情况下,只有两种作用域类型(ES5标准):
全局作用域:全局作用域为程序的最外层作用域,一直存在。
函数作用域:函数作用域只有函数被定义时才会创建,包含在父级函数作用域 / 全局作用域内。
/* 全局作用域开始 */ var a = 1; function func () { /* func 函数作用域开始 */ var a = 2; console.log(a); } /* func 函数作用域结束 */ func(); // => 2 console.log(a); // => 1 /* 全局作用域结束 */复制代码
由于作用域的限制,每段独立的执行代码块只能访问自己作用域和外层作用域中的变量,无法访问到内层作用域的变量。
当出现函数嵌套的时候,就产生了作用域链;
1.3 作用域链
function foo(a) { var b = a * 2; function bar(c) { console.log( a, b, c ); } bar(b * 3); } foo(2); // 2 4 12复制代码
结合前面的知识我们知道,在 bar 函数内部,会做三次 RHS 查询从而分别获取到 a b c 三个变量的值。bar 内部作用域中只能获取到变量 c 的值,a 和 b 都是从外部 foo 函数的作用域中获取到的。
当可执行代码内部访问变量时,会先查找本地作用域,如果找到目标变量即返回,否则会去父级作用域继续查找...一直找到全局作用域。我们把这种作用域的嵌套机制,称为 作用域链。
1.4.1 词法作用域
词法作用域(Lexical Scopes)是 javascript 中使用的作用域类型,词法作用域 也可以被叫做 静态作用域,与之相对的还有 动态作用域。那么 javascript 使用的 词法作用域 和 动态作用域 的区别是什么呢?看下面这段代码:
var value = 1; function foo() { console.log(value); } function bar() { var value = 2; foo(); } bar(); // 1复制代码
根据前面说的,引擎为了拿到这个变量就要去 foo 的上层作用域查询,那么 foo 的上层作用域是什么呢?是它 调用时 所在的 bar 作用域?还是它 定义时 所在的全局作用域?
这个关键的问题就是 javascript 中的作用域类型——词法作用域。
词法作用域,就意味着函数被定义的时候,它的作用域就已经确定了,和拿到哪里执行没有关系,因此词法作用域也被称为 “静态作用域”。
1.4.2 动态作用域
事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域。
// 老活新整 var obj = { m: function m(){ console.log(this.num) }, msunh : 97 } var num = 1997; var n = obj.m; obj.m() // 97 n() //1997复制代码
现在应该很熟悉作用域的概念,以及根据声明的位置和方式将变量分配给 作用域的相关原理了。但是作用域其中的变量声明出现的位置有某种微妙的联系,这就是我们要讨论的提升。
1.5 提升
1.5.1 变量提升
a = 2; var a; console.log( a );复制代码
你觉得在 console.log(..) 语句中会打印出什么?
许多开发者会期望 undefined,因为语句 var a 出现在 a = 2 之后,这很自然地看起来像是这个变量被重定义了,并因此被赋予了默认的 undefined。然而,输出将是 2。
console.log( a ); var a = 2;复制代码
你可能会被诱导而这样认为:因为上一个代码段展示了一种看起来不是从上到下的行为,也许在这个代码段中,也会打印 2。另一些人认为,因为变量 a 在它被声明之前就被使用了,所以这一定会导致一个 ReferenceError 被抛出。
不幸的是,两种猜测都不正确。输出是 undefined
编译器再次袭来
当你看到 var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a; 和 a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处。
于是我们的第一个代码段应当被认为是这样被处理的:
var a;复制代码
a = 2; console.log( a );复制代码
相似地,我们的第二个代码段实际上被处理为:
var a;复制代码
console.log( a ); a = 2;复制代码
所以,关于这种处理的一个有些隐喻的考虑方式是,变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。
注意: ==只有声明本身被提升了,而任何赋值或者其他的执行逻辑都被留在 原处==。如果提升会重新安排我们代码的可执行逻辑,那就会是一场灾难了。
1.5.2 函数提升
foo(); function foo() { console.log( a ); // undefined var a = 2; }复制代码
函数 foo 的声明(在这个例子中它还 包含 一个隐含的、实际为函数的值)被提升了,因此第一行的调用是可以执行的。
还需要注意的是,提升是 以作用域为单位的。所以虽然我们的前一个代码段被简化为仅含有全局作用域,但是我们现在检视的函数foo(..)本身展示了,var a被提升至foo(..)的顶端(很明显,不是程序的顶端)。所以这个程序也许可以更准确地解释为:
function foo() { var a; console.log( a ); // undefined a = 2; } foo();复制代码
注:函数声明会被提升,就像我们看到的。但是函数表达式不会。
当出现重复声明的时候,函数优先
foo(); // 1 var foo; function foo() { console.log( 1 ); } foo = function() { console.log( 2 ); };复制代码
1 被打印了,而不是 2!这个代码段被 引擎 解释执行为
function foo() { console.log( 1 ); } foo(); // 1 foo = function() { console.log( 2 ); };复制代码
由于存在提升行为,所以ES6提出了块级作用域。
1.6 ES6的块级作用域
简单来说,花括号内 {...} 的区域就是块级作用域区域。
if (true) { var a = 1; } console.log(a); // 结果???复制代码
运行后会发现,结果还是 1,花括号内定义并赋值的 a 变量跑到全局了。这足以说明,javascript 不是原生支持块级作用域的。
但是 ES6 标准提出了使用 let 和 const 代替 var 关键字,来“创建块级作用域”。也就是说,上述代码改成如下方式,块级作用域是有效的
if (true) { let a = 1; } console.log(a); // ReferenceError复制代码
只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
var tmp = 123; if (true) { tmp = 'abc'; // ReferenceError let tmp; }复制代码
==ES6 明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。==
总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。
1.7 如何创建作用域
在 javascript 中,我们有几种创建 / 改变作用域的手段:
定义函数,创建函数作用(推荐):
function foo () { // 创建了一个 foo 的函数作用域 }复制代码
使用 let 和 const 创建块级作用域(推荐):
for (let i = 0; i < 5; i++) { console.log(i); } console.log(i); // ReferenceError复制代码
try catch 创建作用域(不推荐),err 仅存在于 catch 子句中:
try { undefined(); // 强制产生异常 } catch (err) { console.log( err ); // TypeError: undefined is not a function } console.log( err ); // ReferenceError: `err` not found复制代码
使用 eval “欺骗” 词法作用域(不推荐)
使用 with 欺骗词法作用域(不推荐):
不推荐eval是因为在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。 另外一个不推荐使用 eval(..) 和 with 的原因是会被严格模式所影响(限 制)。 with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..) 也被禁止了。复制代码
总结下来,能够使用的创建作用域的方式就两种:定义函数创建 和 let const 创建。
2.闭包
2.1 什么是闭包
对于闭包有各种解释和说明
闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。
个人觉得好理解的说法:
能够访问其他函数内部变量的函数,被称为闭包。
根据第一节我们学习了作用域我们知道以下几点
Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。
var n=999; function f1(){ alert(n); } f1(); // 999复制代码
在函数外部自然无法读取函数内的局部变量。
function f1(){ var n=999; } alert(n); // error复制代码
==那如何从外部读取局部变量?==
出于种种原因,我们有时候需要得到函数内的局部变量。但是,前面已经说过了,正常情况下,这是办不到的,只有通过变通方法才能实现。
那就是在函数的内部,再定义一个函数。
function f1(){ var n=999; function f2(){ alert(n); // 999 } }复制代码
在上面的代码中,函数f2就被包括在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这就是Javascript语言特有的"链式作用域"结构,子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。
==既然f2可以读取f1中的局部变量,那么只要把f2作为返回值,我们不就可以在f1外部读取它的内部变量了吗!==
function f1(){ var n=999; function f2(){ alert(n); } return f2; } var result=f1(); result(); // 999复制代码
f2函数,就是闭包!!!
所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
2.2 闭包的作用
保护函数的私有变量不受外部的干扰,实现方法和属性的私有化。
变量的值始终保持在内存中。
怎么来理解这两点?请看下面的代码。
function f1(){ var n = 999; nAdd = function(){n+=1} function f2(){ alert(n); } return f2; } var result = f1(); result(); // 999 nAdd(); result(); // 1000复制代码
在这段代码中,result实际上就是闭包f2函数。它一共运行了两次,第一次的值是999,第二次的值是1000。这证明了,函数f1中的局部变量n一直保存在内存中,并没有在f1调用后被自动清除。
为什么会这样呢?原因就在于f1是f2的父函数,而f2被赋给了一个全局变量,这导致f2始终在内存中,而f2的存在依赖于f1,因此f1也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。
2.3 闭包的应用
闭包可以使代码组织方式的自由度大大提升,在日常使用中有非常广泛的用途。
简单的有:
ajax请求的成功回调
事件绑定的回调方法
setTimeout的延时回调
函数内部返回另一个匿名函数
我们以下几个应用场景来帮助我们理解闭包:
构造函数的私有属性
函数节流、防抖
(1)由于javascript中天然没有类的实现,某些不希望被外部修改的私有属性可以通过闭包的方式实现。
function Person(param) { var name = param.name; // 私有属性 this.age = 18; // 共有属性 this.sayName = function () { console.log(name); } this.getAge = functin(){ console.log(this.age) } } const tom = new Person({name: 'tom'}); tom.age += 1; // 共有属性,外部可以更改 tom.getAge(); // 19 tom.name = 'jerry';// 私有属性,外部不可更改 tom.sayName(); // tom复制代码
(2)函数节流,防抖(以防抖为例)
function debounce(fn, delay) { var timer = null; return function () { var that = this; var args = arguments; clearTimeout(timer);// 清除重新计时 timer = setTimeout(function () { fn.apply(that, args); }, delay || 500); }; }复制代码
在防抖后的回调函数用 timer 记录计时,每次执行回调的时候会先清空之前的计时。注意这里的timer是闭包变量,始终保持着上一个计时。
作者:Even7
链接:https://juejin.cn/post/7018840939821006878