【进阶第 2 期】作用域和闭包

350 阅读12分钟

尽管很多开发者每天都在使用 JavaScript,却不知道这背后发生了什么,该篇文章是本系列文章的第二篇,旨在深入探讨 JavaScript 及浏览器工作原理,将前端相关如网络、页面渲染、浏览器安全、javascript执行机制等知识点串联起来,帮助更多开发者找到自己定位(查漏补缺),达到提升自己技能同时,游刃有余解决工作中难题(这些难题往往就是你对某些概念理解不够深刻)。

前言

上一篇文章调用栈与执行上下文,谈到当某个执行环境中的所有代码执行完毕后,该环境的执行上下文也会被销毁,并且执行上下文是存在生命周期的,但这篇文章只是简单介绍了一个执行上下文所发生两个(创建、执行)阶段的场景。

先来思考一个问题:存在多个执行上下文的一段代码中出现相同的变量,JavaScript引擎是如何选择变量的?这个就涉及到javascript引擎是如何界定变量作用域范围以及如何查找变量的(即标识符解析过程)。

今天我们通过下面这个例子来认识什么是作用域作用域链,再通过作用域链来理解闭包的概念

function bar() { 
	console.log(myName)
}
function foo() { 
	var myName = "this is foo" 
	bar()
}
var myName = "this is global"
foo()

什么是作用域?

理解作用域之前,我们需要知道javascript使用的作用域是词法作用域(静态作用域),那什么是词法作用域呢?

  • 词法作用域(静态作用域)是在书写代码或者说定义时确定的,而动态作用域是在运行时确定的。
  • 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用,其作用域链是基于运行时的调用栈的。

动态作用域 vs 静态作用域

类型描述实例
词法作用域(静态作用域)指词法作用域在词法分析阶段就已经确定了,不会改变
动态作用域会根据程序的调用、执行而改变,取决于在什么地方被调用

一般来说,作用域是用来管理程序不同部分中的变量声明或函数声明的可见性和生命周期(即对某一变量或方法具有访问权限的代码空间)。 而在javascript这门语言中,作用域可以理解为一套用来存储变量的规则,用于确定在何处以及如何查找变量(标志符的解析)。在javascript中,变量的查找(标识符解析)只在一个方向上发生:向外。这样,外部作用域就不能“看到”内部作用域。

影响一个标识符(变量)的作用域有三个因素

  • 变量是如何被声明的(var/let/const)
  • 变量声明的位置
  • 是否使用严格模式/ 非严格模式

作用域的分类

在ES6 之前只有全局作用域函数作用域,之后引入了块级作用域(通过let/const实现)

类型描述
全局作用域变量全局可访问
函数作用域函数内声明的所有变量在函数体内始终是可见的,可以在整个函数的范围内使用及复用
块级作用域在变量声明的代码段之外是不可见的

来看个例子,一目了然

var name = 'this is gloabal';
function test(){
    console.log(name); // undefined
    var name = 'this is local';
    console.log(name); // 'this is local'
}
test();
console.log(name) // this is gloabal

上篇文章提到:当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。从执行上下文分为:全局执行上下文,函数执行上下文,eval 执行上下文,利用这个来分步理解代码执行流程:

// 1、进入全局执行上下文,编译阶段,变量提升
var name = undefined
function test(){
    console.log(name); // undefined
    var name = 'this is local';
    console.log(name); // 'this is local'
}

// 2、全局执行上下文,执行阶段
name = 'this is gloabal'; // 赋值
test(); // 执行函数test 入栈

// 3、进入函数test 执行上下文 编译阶段,变量提升
var name = undefined

// 4、进入函数test 执行上下文 执行阶段,当前name作用域仅限于当前执行上下文
 	console.log(name); // 输出 undefined
    name = 'this is local'; // 赋值
    console.log(name); // 输出 'this is local'

// 5、退出test函数执行上下文,回到全局执行上下文 继续执行后续语句
console.log(name) // 输出 "this is gloabal" ,退出

什么是作用域链?

上面提到在ES6之前,就有全局作用域和函数作用域,一开始我们在V8初始化环境的时候,就会创建全局执行上下文,而当某个函数被调用时,就进入一个新的执行环境,创建该作用域对应的函数执行上下文。作用域链的存在是为了标识符的解析,通俗的说就是查找变量。 当函数中代码使用了一个变量时,JavaScript 引擎首先会在当前的函数作用域的执行上下文中查找该变量,继续往外层函数的执行上下文查找,以此类推,以此类推,直到找到,或者到作用域链顶端(全局作用域)还未找到则抛出ReferenceError。而在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部作用域的执行上下文,我们把这个外部引用称为 Outer,把这个查找的顺序称之为作用域链

简单来看个例子,很容易通过静态分析出foo函数的作用域链

let a = 1
function main(){
    let b =2
    function bar(){
        let c =3
    	function foo(){
    	    let d =4
    		console.log(a)
            console.log(b)
            console.log(c)
            console.log(d)	
    	}
    	foo()
    }
    bar()
}
main()

// 执行到foo函数时,作用域查找顺序:foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域。

打开开发者工具,再从浏览器实现它角度来理解下,在foo函数任意位置打断点,然后刷新执行

那么疑问就来了,这个outer指向哪个执行上下文是如何确定的呢?

要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。 javascript的作用域就是采用的词法作用域,也成为静态作用域。变量的作用域是在定义时而非执行时决定,也就是说词法作用域取决于源码,通过静态分析就能确定,(需要特别说明的是,with 和 eval 可以欺骗词法作用域)。因此通过这个则很容易就能够预测代码在执行过程中如何查找标识符。

再来看个例子,很明显,使用词法作用域分析,foo outer 访问的是全局环境的变量value

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}
bar();//1

来分步解析下,代码执行流程

// 1、进入全局环境,编译阶段,变量提升
var value = undefined
function foo() {
    console.log(value);
}
function bar() {
    var value = 2;
    foo();
}

// 2、进入全局环境,执行阶段,执行bar方法
bar()

// 3、进入bar函数执行上下文,创建阶段,变量提升
var value = undefined

// 4、进入bar函数执行上下文,执行阶段:当前执行上下文并没有定义foo,通过变量环境中的outer访问到全局foo并执行
foo()

// 5、进入foo函数执行上下文,创建阶段:不存在变量定义(跳过)

// 6、进入foo函数执行上下文,创建阶段:需要访问value,当前执行上下文未定义value,通过作用域链查找访问到全局value
console.log(value) // 输出 1

所以无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

作用域与执行上下文的关系
如果单独介绍执行上下文或者作用,很多开发会把这两个概念混淆。所以有必要区分这两个概念:

  • 执行上下文是动态创建的,而作用域是在解析时静态确定(词法分析阶段)的。
  • 对于调用栈来说,我们更应该看的时执行上下文。当我们在全局执行一个函数的时候,就会形成一个调用栈。虽然JavaScript使用的是词法作用域,在使用变量和方法调用时,会沿着作用域链去寻找相应的变量和方法,但是在内存中,调用栈的执行上下文顺序,是由函数的执行顺序来决定的,也就是看函数什么时候调用,越早调用的函数对应执行上下文就越早入栈,因此栈底就是全局执行上下文。

什么是闭包?

可以这么说,在JavaScript 世界里闭包无处不在,尽管你天天与它打交道,但是当你不太熟悉javascript这么语言时,很难通过闭包概念及背后的原理彻底理解闭包,也只能似懂非懂。只有理解了作用域链、变量环境、执行上下文这些基础概念之后,再来理解闭包就相对容易多了。这里先不抛出闭包的概念,先从这个例子来理解闭包表现:

function foo() { 
	var myName = "foo" 
    let test1 = 1 
    const test2 = 2 
    var innerBar = { 
    	getName:function(){ 
        	var test3 = 3
        	console.log(test1) 
        	return myName 
    	}, 
    	setName:function(newName){ 
        	myName = newName 
        } 
    } 
    return innerBar
}

var bar = foo()
bar.setName("bar")
console.log(bar.getName())

同样地,分步解析下代码执行流程

// 1、进入全局执行上下文 编译阶段:变量提升
function foo() {
	... // 此处省略
}
var bar = undefined

// 2、进入全局执行上下文,执行阶段
var bar = foo() // 赋值 

// 3、上一步赋值语句获取的foo函数执行结果 ,进入foo函数上下文编译阶段:变量提升 ,let const 不会进行变量提升
var myName = undefined 
var innerBar = undefined

// 4、进入foo函数上下文执行阶段
var myName = "foo" 
let test1 = 1 
const test2 = 2 
var innerBar = {
	... // 省略
}

return innerBar

// 5、foo函数执行完毕,当前执行上下文本应全部被销毁。但内部函数的引用被返回了,其引用外部函数的变量会保存在内存中,在下次调用时可通过作用域链 访问到,执行到bar.setName 可通过作用域链访问到闭包 closure上的 myName 更新为bar
bar.setName("bar") 

// 6、进入bar.getName 执行上下文,编译阶段阶段(跳过),再到执行阶段,可以通过作用链方访问到闭包上的myName
console.log(bar.getName()) // 输出 bar 

再打开开发者工具,从它角度来理解下,在bar函数任意位置打断点,然后刷新执行 从图中可以看出来,当调用 bar.getName 的时候,右边 Scope 项代表了当前作用域链的情况:Local 就是当前的 getName 函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从Local–>Closure(foo)–>Global就是一个完整的作用域链。 所以,我们以后也可以通过 Scope 来查看实际代码作用域链的情况,这样调试代码也会比较方便。

现在来定义闭包概念就容易多了,在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

闭包用途

前面提到,闭包几乎无处不在,那闭包有什么作用呢? 最大用处有两个,一个是函数外部的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。

使用闭包注意事项

1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。

2)闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

结语

本文主要回答了这几个方面的问题:

  • Q:什么是作用域? A:作用域是用于确定在何处以及如何查找变量(标志符的解析)的一套规则。
  • Q:什么是作用域链?A: 了解了作用域链概念,再来重新认识闭包就容易很多了。javascript引擎是通过作用域确定变量作用域范围及并通过outer作为作用域链来查找变量
  • Q: 什么是闭包?A: 内部函数的引用在外部函数执行结束之后被返回,同时内部函数引用到外部函数的变量依然保存在内存中,我们把这些变量的集合称为闭包。

本文其实还有很多概念由于篇幅原因未详细展开,如块级作用域、词法分析、语法分析等,抽空我会重新把这些生涩概念重新梳理下,下篇先来介绍下 this