聊一聊 JavaScript 中的作用域和闭包

哈喽大家好,我是归思君~

一、引言

我们知道,作用域(Scope)就是代码中变量和函数的可访问的区域,这个区域中决定了变量和函数的生命周期
在当前的高级程序语言中,主要有词法作用域(静态作用域)和动态作用域两种。其实这两种作用域的区别主要是作用域中的变量和函数,是在编译期还是运行期确定的,从词法分析角度讲,如果是通过静态词法分析而得出的时候,它就被称为词法作用域:

  • 静态作用域:其作用域是在编写代码时就已经确定好,静态作用域是根据变量和函数在代码中的位置来决定。函数寻找变量时,是在函数定义的位置中寻找,而不是调用的位置。现在大多数编程语言都采用的是静态作用域,比如 C, C++, Java, JavaScript, Python 等
  • 动态作用域是在程序运行时根据程序的调用栈来动态确定,而不是在写代码时静态确定。在函数寻找变量时,根据函数调用的位置来寻找。这意味着同一个变量名在不同的调用上下文中可能指向不同的变量,可以用 Js 中的 this 值来进行理解,只有在调用时才知道 this 值的指向,动态作用域类型语言中的所有变量都是以这种形式来确定。动态作用域在现代编程语言中较少见,在某些早期语言中比如 Lisp

JavaScript 中就采用的是词法作用域(静态作用域),下面就来详细看看:

二、全局作用域和函数作用域

从范围上分,JavaScript中的作用域有三种:全局作用域、函数作用域和块级作用域。我们先来聊聊全局作用域和函数作用域:

1.全局作用域

In a programming environment, the global scope is the scope that contains, and is visible in, all other scopes. In client-sideJavaScript, the global scope is generally the web page inside which all the code is being executed.

也就是说在一个程序运行环境中,全局作用域指的是能看见的代码全部及其他的作用域;在内置JS代码中,全局作用域是**指所被执行js代码的全部区域。**其生命周期伴随着页面的生命周期

  • Web浏览器中,全局作用域是window对象,所有的变量和函数是作为其方法和属性创建:
var test = 1000;
console.log(test);//1000
//全局作用域中定义的变量可以通过,全局对象调用属性的方式来获取
console.log(window.test);//1000
  • Node环境中,全局作用域是global对象:
var y = 200;
console.log(y);//200
console.log(global);//global
//在node环境中,全局作用域中的变量不会自动成为global对象的属性
console.log(global.y);//undefined

2.函数作用域

函数作用域是指:声明在函数体内的所有变量和函数都是始终可见的,只能在函数内部访问,其他作用域则无法访问,在函数执行结束后,其内部定义的变量会被销毁。

function test() {
	var num1 = 100;
	var num2 = 1000;
	if(num1 === 100){
		console.log(num2);
	}
	function test2(){
		console.log(num2);
	}
	test2();
}
test();//输出:1000 1000
//全局作用域无法访问函数作用域中的变量
console.log(num2);//Uncaught ReferenceError: num2 is not defined

3.作用域链

关于作用域链,要从调用栈中的执行上下文栈说起,详情可以看我的这篇文章:ECMAScript下的执行上下文。其中 ECMAScript 6 规范用 Lexcical Environment(词法环境)、Environment Record(环境记录项) 来描述词法和运行期环境。一个词法环境由环境记录项和指向外部词法环境的 outer引用值组成
比如对于以下嵌套函数,其作用域链是:foo3->foo2->foo1->global,该作用域链是在代码写好后就确定了,与是否调用函数或者代码执行无关。

function foo1() {
//...
  function foo2() {
  //...
    function foo3() {
    //...
  }
 }  
}

image.png
用 ES6 规范描述如下:

//全局上下文
GlobalExectionContext = {
   //词法环境
  LexicalEnvironment: {
    EnvironmentRecord: {
      ...
    }
    outerEnv: <null>,
	}
}
//foo1函数上下文
foo1ExectionContext = {
	LexicalEnvironment: {
			EnvironmentRecord: {
				...
			},
			outerEnv: <GlobalLexicalEnvironment>,
		},
}
//foo2函数上下文
foo2ExectionContext = {
	LexicalEnvironment: {
			EnvironmentRecord: {
				...
			},
			outerEnv: <foo1ExectionContext>,
		},
}
//foo3函数上下文
foo2ExectionContext = {
	LexicalEnvironment: {
			EnvironmentRecord: {
				...
			},
			outerEnv: <foo2ExectionContext>,
		},
}

我们以这个例子为例讲解

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>作用域</title>
</head>
<body>
    <div>作用域链</div>
    <button onclick="foo1()">点击:</button>
    <script>
        var globalName = "global";
        function foo1() {
            debugger
            var foo1Name = "foo1";
            console.log(globalName);//global
            function foo2() {
                var foo2Name = "foo2";
                debugger
                console.log(globalName);//global
                console.log(foo1Name); //foo1
                function foo3() {
                    debugger
                    console.log(globalName);//global
                    console.log(foo1Name);//foo1
                    console.log(foo2Name);//foo2
                }
                foo3();
            }
            foo2();  
        }
    </script>
</body> 
</html>
  • foo1()函数作用域中没有 globalName 字段时,会顺着作用域链foo3->foo2->foo1->global去寻找其他作用域是否有该 globalName 字段
  • foo2()函数作用域中没有 globalNamefoo1Name 字段 时,则沿着作用链从 foo1()函数和全局作用域中寻找。foo3 函数作用域和 foo2 类似

可以用 Chrome DevTools 中的 Scope 面板来演示上例代码在执行过程中的的作用域变化:
再次强调一下,当前代码的作用域和作用域链在代码写好就已经确定,与是否执行代码和函数调用无关。这里只是为了展示作用域,而采用调用的方式,通过执行上下文查看其对应的作用域:

  • 当代码执行到 foo1() 时,调用栈(Call Stack)的栈顶是 foo1()函数执行上下文,其作用域是 Scope 中的 Local 作用域:

image.png

  • 当代码执行到 foo2()时, 调用栈(Call Stack)的栈顶是 foo2()函数执行上下文,其作用域是 Scope 中的 Local 作用域,Local 下面的 Closure(foo1)实际上就是闭包,此时foo2()中函数的 console.log(foolName)foo1() 中的 foo1Name 字段

image.png

  • 当代码执行到 foo3()时, 调用栈(Call Stack)的栈顶是 foo3()函数执行上下文,其作用域是 Scope 中的 Local 作用域,当前作用域没有变量,其打印的变量需要从其他作用域中获取,同样是沿着作用域链查找:

image.png
除了全局作用域和函数作用域,在 JavaScript 中还存在块级作用域,比如被花括号 { }包围的代码语句

// try-catch语句
try {  
	// 作用域1
}catch (e) { 
	// 表达式e位于作用域2
	// 作用域2
}finally { 
	// 作用域3
}

// with语句
//(注:没有使用大括号)
with (x) /* 作用域1 */; 
// <- 这里存在一个块级作用域

// 块语句
{  
	// 作用域1
}

三、块级作用域

在 JavaScript 早期设计中, 绝大多数语句中是没有块级作用域的,变量所处的作用域只有全局和函数作用域两种,比如下面的 for 语句

//此时变量都处在全局作用域中
for(var x = 4; x < 10; x++) {
	console.log("inner", x);//1~9
}
//在外部作用域中打印出x值
console.log("outer", x);//10

ES6 后增加 letconst关键字,让 JavaScript 语言中拥有了块级作用域。但是有两个特例:

两个拥有块作用域的语句

绝大多数语句中是没有块级作用域,但是有两个语句例外:

  • with 语句:() 后的部分存在一个块级作用域
  • try/catch 语句: catch 分句会创建一个块级作用域

with 语句

已弃用: 不再推荐使用该特性。ECMAScript 5 中该标签已被禁止

语法
//expression: 将给定的表达式添加到在评估语句时使用的作用域链上。
//expression 周围的括号是必需的。expression是对象
with (expression) 
	//任何语句 这里存在一个块级作用域
  statement

JavaScript 查找某个未使用命名空间的变量时,会通过作用域链来查找,作用域链是跟执行代码的上下文或者包含这个变量的函数有关。'with’语句将某个对象添加到作用域链的顶部,如果在 statement 中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出ReferenceError异常。

比如下面的 with语句指定 Math对象作为默认对象:

var a, x, y;
var r = 10;
//PI,cos 和 sin 函数都是 Math 对象内部的函数
//因此不用在前面添加命名空间,后续所有引用都指向 math 对象
with (Math) {
  a = PI * r * r;
  x = r * cos(PI);
  y = r * sin(PI / 2);
}

我们在 Chrome Devtools 中查看 with 块作用域:
image.png

  • 此时代码执行到 with 语句内部,调用栈中是全局执行上下文,当前所在作用域是 with 块作用域
  • 再来看看 with 块作用域中有什么参数和内部函数:

image.png
发现不仅 PI,还有其他参数值和内部函数都在 with 块作用域内部。

缺陷
  • with 中的变量声明会被添加到外层作用域中:
var a = {};
with(a) {
    var x = "name";
}
console.log(x);//name
  • 语义不明,参数查找混乱

当对象名和对象中的参数相同时,会出现变量查找混乱的现象:

var a = {name: "a"};
var obj = { obj: "obj"};
function foo(obj) {
    with(obj) {
        console.log(obj);
    }
}
//打印成功,输出对象
foo(a);//{name: 'a'}
//当对象中的参数和对象名相同时,输出出现混乱
foo(obj);//obj

try/catch 语句

try语句包含了由一个或者多个语句组成的try块,和至少一个catch块或者一个finally块的其中一个,或者两个兼有,下面是三种形式的try声明:

  • try…catch
  • try…finally
  • try…catch…finally

finally子句在try块和catch块之后执行但是在下一个try声明之前执行。无论是否有异常抛出或捕获它总是执行。

ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效

比如:

try {
	throw 2;
} catch(a) {
	console.log(a); //2
}
console.log(a);//Uncaught ReferenceError: a is not defined

在 Devtools 中查看其作用域:
image.png

let/const 关键字 + {}

ES6 之前,JavaScript 的作用域只有全局作用域和函数作用域两种。块级作用域就是用花括号 {} 包裹的代码,比如判断、循环语句。在 JavaScript 中,是通过使用 letconst 关键字+ {} 来实现块级作用域的,比如:

{
	var str = "block scope";
	let str1 = "lexical scope";
}
//没有使用let/const关键字前,相当于全局作用域中一部分,不存在块级作用域
console.log(str);// block scope
//使用let后,全局作用域无法访问块级作用域内容
console.log(str1); // Uncaught ReferenceError: str1 is not defined

在 DevTools 中查看 {} 中变量的作用域:
image.png

  • var 声明的变量仍然在全局作用域中
  • let 声明的变量在块作用域 block 中

那么为何letvar声明变量在不同的作用域之中呢?咱们从执行上下文的角度来分析:
首先在调用函数或者初始化创建全局上下文时(这里忽略变量提升问题):

  • 若有 let/const 声明的变量时,代码一边执行,一边会将这些变量压入 LexicalEnvironment标识符指向的词法环境中
  • 若有 var声明变量时,代码会一边执行,一边会将变量压入 VariableEnvironment标识符指向的词法环境中

image.png
注意:两个标识符指向的都是词法环境,在执行上下文创建时,两个标识符初始值指向的是同一个词法环境

关于执行上下文中的词法环境和词法环境记录项,可以看我这篇文章:从 ECMAScript 6 角度谈谈执行上下文

在词法环境内部,相当于一个栈结构,栈顶元素是该作用域最末尾声明的值。具体查找顺序是:

  • 在当前执行上下文中,先对LexicalEnvironment标识符指向词法环境按照栈顶->栈底顺序查找。
  • 在当前执行上下文中,若LexicalEnvironment环境中找不到,则在 VariableEnvironment标识符指向的词法环境中继续按照栈的顺序查找
  • 如果当前执行上下文中还是找不到,沿着作用域链方向依次向外部作用域中的词法环境中查找,并循环上面两步

对于如下代码:

var a = 1;
let b = 2;
function foo(){
	var c = 3;
	let d = 4;
	{
		var e = 5;
		let f = 6;
		console.log(a);//1
	}
}
foo();

如下图所示,在执行到 console.log(a);这一行时整个寻找路径是:
image.png

  • 首先 foo 函数执行上下文中,按照词法->变量环境后进先出顺序查找变量
  • 如果还查找不到,就沿着作用域链方向到外部执行上下文中寻找

所以块级作用域就是通过词法环境标识符指向的栈结构实现的;而变量提升则是将作用域中的 var 声明变量提前,放在变量环境标识符指向的环境中,并设置成默认值。
讲完块级作用域,咱们来谈谈闭包

四、闭包

1.什么是闭包

先来看看 MDN 中闭包的定义:

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建

再来看看《JavaScript 高级程序设计(第 4 版)》怎么说的:

闭包指的是哪些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的

所以说闭包的组成部分由:

  • 函数:必须是一个函数,普通函数和匿名函数均可
  • 环境:该函数的词法环境引用(包括内部变量和外部函数变量的引用)

比如下面例子

function foo1() {
  var name = "foo1"; 
	//此时该内部函数和引用变量的词法环境共同成为
  function foo2() { //引用外层作用域变量,此时foo2就是一个闭包
    console.log(name); 
  }
  foo2();
}
foo1();

2. 闭包类型

内部函数执行和返回闭包

function foo1() {
  var name = "foo1"; 
	//此时该内部函数和引用变量的词法环境共同成为
  function foo2() { //引用外层作用域变量,此时foo2就是一个闭包
    console.log(name); 
  }
  foo2();
}
foo1();

当内部 foo2 函数引用外部 foo1 函数中的变量时,闭包并没有形成。只有当内部函数 foo2 执行后,才会触发引用外部函数变量操作,形成引用外部作用域的词法环境,最后形成闭包。
image.png
上面的闭包会随着 foo1 函数执行完毕而关闭,那么闭包有可能在外部作用域关闭后继续存在吗?下面再来看看一种变体:

function foo1() {
  var name = "foo1"; 
  function foo2() { 
    console.log(name); 
  }
  return foo2;
}
var closure = foo1();
closure();
var test = "test";

这种变体将内部函数 foo2 作为变量返回,然后用引用变量 closure 指向该闭包,哪怕 foo1 执行完毕,因为引用变量 closure 在全局作用域上,这个闭包会一直存在,直到全局执行上下文销毁。
哪怕执行到 var test = "test",仍然能在全局作用域中找到闭包:
image.png

回调函数闭包

回调函数比较常见,回调函数引用外层作用域的变量时,闭包就产生了,比如:

function delayedExecution() {
  var count = 0;
	
  setTimeout(function() {
    console.log('Count:', count);
  }, 1000);

  count++;
}
//闭包函数在延迟1秒后执行,并输出 Count: 0。
//这是因为在闭包函数执行时,count 的值已经被捕获并保存在闭包中
delayedExecution();

在 JS 引擎的内置函数 setTimeout()函数中,会持有一个函数参数的引用,在经过一定时间后,JS 引擎会自动调用该函数,而该函数因为引用了 delayedExecution()中的 count,所以会形成一个闭包。
这种情况和第一种不同支出在于,其闭包执行实际上是由 JS 引擎来完成的

事件处理函数闭包

当将一个闭包作为事件处理函数绑定到 DOM 元素上时,闭包会在事件触发时执行。闭包可以访问绑定事件函数时所在的作用域中的变量和参数。

function createButton() {
  var message = 'Button clicked';

  var button = document.createElement('button');
  button.innerText = '请点击此处';
	//闭包被添加到DOM上
  button.addEventListener('click', function() {
    console.log(message);
  });

  document.body.appendChild(button);
}

createButton();//点击按钮后会出现 Button clicked

image.png

高阶函数中的闭包

比如在函数柯里化(柯里化是一种将一个接受多个参数的函数转化为一系列只接受单个参数的函数的技术)中,也有闭包的身影:

function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}
//add5 和 add10 都是闭包
//它们共享相同的函数定义,但是保存不同的词法环境:
//在 add5 的环境中,x 为 5。而在 add10 中,x 则为 10
var add5 = makeAdder(5); 
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

2.闭包的回收

闭包的回收就涉及到 JS 引擎中的垃圾回收了,可以下次再开一篇文章讲解 JS 的垃圾回收机制。对于闭包来说,主要有两种情况:

  • 局部变量引用闭包
  • 全局变量引用闭包

如果引用闭包的变量是一个局部变量,等函数执行完毕后,这个局部变量也随之销毁。下次 JS 引擎在执行垃圾回收时,会判断该闭包是否被引用,如果不被引用了,就会将这块闭包的内存进行回收
若是全局变量,则只能等到全局执行上下文关闭后,JS 引擎才能回收这部分闭包占用的内存。所以在全局上引用闭包时,需要注意未来可能造成内存泄露的问题。

小结

下面再来回顾一下文章的整体内容:

  1. JavaScript 采用静态作用域,主要包括全局作用域、函数作用域和块级作用域三种
    • 全局作用域在网页或Node中;
    • 函数作用域内部变量不影响外部;
    • ES6引入letconst后,使用它们与{} 可以产生块级作用域,ES6之前有withtry/catch 语句可以产生块级作用域
  2. 作用域链的顺序在代码写好就已经确定,当JS引擎在访问变量时,
    • 先沿着作用域顺序查找执行上下文,
    • 然后在执行上下文中,按照先词法环境后变量环境的顺序查找
    • 如果当前执行上下文查找不到,则继续按照作用域顺序查找其他执行上下文,直到作用域链末尾
  3. 闭包实际上是函数和其绑定词法环境引用的组合,当外部函数结束,内部函数还在引用外部函数变量时,就形成了闭包,常见的闭包的产生有下列情形:
    • 回调函数、嵌套函数中内部函数引用返回、事件处理函数闭包、高阶函数闭包等等
  4. 闭包的回收涉及到JS的垃圾回收机制,主要分为两种:
    • 全局变量引用闭包时,其生命周期会随全局周期而存亡
    • 局部变量引用闭包时,其生命周期随外部函数执行上下文周期

参考文章

https://262.ecma-international.org/6.0/

with - JavaScript | MDN

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures

深入JavaScript with语句

09 | 块级作用域:var缺陷以及为什么要引入let和const

[从 ECMAScript 6 角度谈谈执行上下文]