阅读 126

作用域和闭包

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 函数的作用域中获取到的。

image

当可执行代码内部访问变量时,会先查找本地作用域,如果找到目标变量即返回,否则会去父级作用域继续查找...一直找到全局作用域。我们把这种作用域的嵌套机制,称为 作用域链

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


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐