一文搞定闭包原理

8次阅读

共计 5331 个字符,预计需要花费 14 分钟才能阅读完成。

前言

闭包是 JS 中重要的内容,对大多数人来说都会觉的闭包本身很好理解,不就是一个函数嵌套一个函数吗?但是再深入解释时,好像不知道要说些啥。不用担心,相信看完这篇你对闭包的理解就不仅仅只停留在概念层面上了。

基本概念

1、闭包是什么?

官方对闭包的解释是:一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。

通俗的解释是:闭包就是能够读取其他函数内部变量的函数。

更清晰的讲:闭包就是一个函数,这个函数能够访问其他函数的作用域中的变量。

JS 为什么使用闭包?

因为 JS 中变量的作用域分为全局变量和局部变量。在函数外部无法读取函数内的局部变量。需要闭包来解决。

闭包带来的问题?

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

闭包相关概念

在了解闭包之前,先来了解作用域、执行上下文、变量对象、活动对象、作用域链,这些将有助于对闭包的理解。

作用域(Scope)

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

1、作用域分为

  • 全局作用域
  • 函数作用域(ES6 新增了块级作用域)

PS: 这些相信很多人都知道,就不详细举例了

2、作用域共有两种主要的工作模型

  • 词法(静态)作用域:作用域是在编写代码的时候确定的
  • 动态作用域:作用域是在代码运行的时候确定的

我们知道 Javascript 使用的是 词法(静态)作用域

3、词法(静态)作用域

理解静态作用域之前,首先要先了解 Js 在编译阶段做了些什么事情。

Js 编译阶段分为三个阶段,下面概括一下三个阶段:

1. 分词 / 词法分析(Tokenizing/Lexing)

其实我们写的代码就是字符串,在编译的第一个阶段里,把这些字符串转成词法单元(toekn)。

2. 解析 / 语法分析(Parsing)

在有了词法单元之后,JS还需要继续分解代码中的语法以便为 JS 引擎减轻负担,通过词法单元生成了一个抽象语法树 (Abstract Syntax Tree), 它的作用是为JS 引擎构造出一份程序语法树,我们简称为AST

3. 代码生成(raw code)

这个阶段主要做的就是拿 AST 来生成一份 JS 语言内部认可的代码。

静态作用域是发生在编译阶段的第一个步骤当中,也就是分词 / 词法分析阶段。它有两种可能,分词和词法分析,分词是无状态的,而词法分析是有状态的。

那我们如何判断有无状态呢?以 var a = 1 为例。

  • 如果词法单元生成器在判断 a 是否为一个独立的词法单元时,调用的是有状态的解析规则(生成器不清楚它是否依赖于其他词法单元,所以要进一步解析)。
  • 如果它不用生成器判断,是一条不用被赋予语意的代码(暂时可以理解为不涉及作用域的代码, 因为 js 内部定义什么样的规则我们并不清楚),那就被列入分词中了。

总的来说,如果词法单元生成器拿不准当前词法单元是否为独立的,就进入词法分析,否则就进入分词阶段。

简单的说,词法作用域就是定义在词法阶段的作用域。词法作用域就是你编写代码时,变量和块级作用域写在哪里决定的。当词法解析器处理代码时,会保持作用域不变(除动态作用域)。

执行上下文(Execution Contexts)

1、Javascript中代码的执行上下文分为以下三种

  • 全局级别的代码 – 这个是默认的代码运行环境,一旦代码被载入,引擎最先进入的就是这个环境。
  • 函数级别的代码 – 当执行一个函数时,运行函数体中的代码。
  • Eval的代码 – 在 Eval 函数内运行的代码。

2、一个执行的上下文可以抽象的理解为一个对象。每一个执行的上下文都有一系列的属性:

  • 变量对象(variable object)
  • this 指针(this value)
  • 作用域链(scope chain)

代码表示如下:

Execution Contexts = {
    variable object:变量对象 / 活动对象;
    this value: this 指针;
    scope chain:作用域链;
}

3、如何管理执行上下文

Js的运行采用 的方式对执行上下文进行管理,栈底始终是全局上下文,栈顶始终是正在被调用执行的函数的执行上下文。

实例

var a = 10;
var b = 'hello';

function fun1 () {console.log('i am fun1...');
    fun2();}

function fun2 () {console.log('i am fun2...');
}

fun1();

上面代码具体流程是:

  1. Js 文件开始执行时,创建全局上下文,并 pushcall stack
  2. fun1()被调用时,创建 fun1 上下文,pushcall stack
  3. fun2()被调用时,创建 fun2 上下文,pushcall stack
  4. fun2()执行完毕,fun2上下文 pop 出栈,等待被回收。
  5. fun1()执行完毕,fun1上下文 pop 出栈,等待被回收。
  6. 全局执行环境不会出栈。

4、执行环境的生命周期

执行上下文生命周期分为创建阶段、执行阶段、执行完毕。如下图:


执行上下文是代码执行的一种抽象,而代码执行除了整个 Js 开始执行之外,代码的执行都是通过函数调用执行的,所以执行上下文生命周期的各个阶段其实是可以分别对应函数被调用时的初始化、执行、执行完毕阶段的。下面会详细的解释每个阶段的过程。

变量对象(variable object)

1、变量对象的定义:

如果变量与执行上下文相关,那变量自己应该知道它的数据存储在哪里,并且知道如何访问。这种机制称为变量对象(variable object)。

2、变量对象的作用:

可以说变量对象是与执行上下文相关的数据作用域(scope of data)。它是与执行上下文关联的特殊对象,用于存储被定义在执行上下文中的变量(variables)、函数声明(function declarations)、arguments。

3、变量对象的创建过程:

实例

function add(num){
    var sum = 5;
    return sum + num;
}
var sum = add(4);

根据上面代码,创建变量对象的流程是:

  1. 检查当前执行环境上的参数列表,建立 Arguments 对象,并作为 add VOarguments属性值。
  2. 检查当前执行环境上的 function 函数声明,每检查到一个函数声明,就在变量对象中以函数名建立一个属性,属性指向函数所在的内存地址。
  3. 检查当前执行环境上的所有 var 变量声明。每检查到一个 var 声明,如果 VO 中已存在 function 属性名则跳过,如果没有就在变量对象中以变量名新建一个属性,属性值为undefined

当进入全局上下文时,全局上下文的变量对象可表示为:

VO = {
    add: <reference to function>,
    sum: undefined,
    Math: <...>,
    String: <...>
    ...
    window: global // 引用自身
}

活动对象(Activation Object)

当函数被调用者激活时,这个特殊的活动对象 (activation object) 就被创建了。它包含普通参数(formal parameters) 与特殊参数(arguments) 对象(具有索引属性的参数映射表)。活动对象在函数上下文中作为变量对象使用。


根据上图,简单解释:在没有执行当前环境之前,变量对象中的属性都不能访问!但是进入执行阶段之后,变量对象转变为了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。所以活动对象实际就是变量对象在真正执行时的另一种形式。

根据上面变量对象的实例。当 add 函数被调用时,add函数执行上下文被压入执行上下文堆栈的顶端,add函数执行上下文中活动对象可表示为

AO = {
    num: 4,
    sum: 5,
    arguments:{0:4}
}

作用域链(Scope Chain)

函数上下文的作用域链在函数调用时创建的,包含活动对象 AO 和这个函数内部的 [[scope]] 属性。

实例

var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
  bar();}
foo(); 

在这段代码中我们看到变量 y 在函数 foo 中定义(意味着它在 foo 上下文的 AO 中)z在函数 bar 中定义,但是变量 x 并未在 bar 上下文中定义,相应地,它也不会添加到 barAO中。乍一看,变量 x 相对于函数 bar 根本就不存在。

函数 bar 如何访问到变量 x?理论上函数应该能访问一个更高一层上下文的变量对象。实际上它正是这样,这种机制是通过函数内部的[[scope]] 属性来实现的。
[[scope]]是所有父级变量对象的层级链,处于当前函数上下文之上,在函数创建时存于其中。

根据上面代码我们逐步分析:

  1. 代码初始化时,创建全局上下文的变量对象。
globalContext.VO === Global = {
  x: 10
  foo: <reference to function>
};
  1. foo 创建时,foo[[scope]] 属性是:
foo.[[Scope]] = [globalContext.VO];
  1. foo 激活时(进入上下文),foo上下文的活动对象。
fooContext.AO = {
  y: 20,
  bar: <reference to function>
};
  1. foo上下文的作用域链为:
fooContext.Scope = [
  fooContext.AO,
  globalContext.VO
];
  1. 内部函数 bar 创建时,其 [[scope]] 为:
bar.[[Scope]] = [
  fooContext.AO,
  globalContext.VO
];
  1. bar 激活时,bar上下文的活动对象为:
barContext.AO = {z: 30};
  1. bar上下文的作用域链为:
bar.Scope= [
  barContext.AO,
  fooContext.AO,
  globalContext.VO
];

闭包的原理

了解了上面的相关概念之后,我们通过一个闭包的例子来分析一下闭包的形成原理。

function add(){
    var sum =5;
    var func = function () {console.log(sum);
    }
    return func;
}
var addFunc = add();
addFunc(); //5

根据上面代码我们逐步分析:

  1. Js执行流进入全局执行上下文环境时, 全局执行上下文可表示为:
globalContext = {
    VO: {
        add: <reference to function>,
        addFunc: undefined
    },
    this: window,
    scope chain: window 
}
  1. add 函数被调用时,add函数执行上下文可表示为:
addContext = {
    AO: {
        sum: undefined // 代码进入执行阶段时此处被赋值为 5
        func: undefined // 代码进入执行阶段时此处被赋值为 function (){console.log(sum);}
    },
    this: window,
    scope chain: addContext.AO + globalContext.VO 
}
  1. add函数执行完毕后,Js执行流回到全局上下文环境中,将 add 函数的返回值赋值给addFunc
  2. 由于 addFunc 仍保存着 func 函数的引用,所以 add 函数执行上下文从执行上下文堆栈顶端弹出后并未被销毁而是保存在内存中。
  3. addFunc() 执行时,func函数被调用,此时 func 函数执行上下文可表示为:
funcContext = {
    this: window,
    scope chain: addContext.AO + globalContext.VO 
}

当要访问变量 sum 时,func的活动对象中未能找到,则会沿着作用域链查找,由于 Js 遵循词法作用域,作用域在函数创建阶段就被确定,在 add 函数的活动对象中找到sum = 5;

闭包的用法实战

闭包可以用在许多地方。它的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。本文闭包的用法不是重点内容,如果想了解更多方法,可以自行查阅资料。下面列举几个应用方法。

1、延迟回调

var a = 10;
setTimeout(function () {alert(a); // 10, after one second
}, 1000);

2、回调函数

//...
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
  // 当数据就绪的时候,才会调用;
  // 这里,不论是在哪个上下文中创建
  // 此时变量“x”的值已经存在了
  alert(x); // 10
};
//...

3、创建封装的作用域来隐藏辅助对象

var foo = {};

// 初始化
(function (object) {

  var x = 10;

  object.getX = function _getX() {return x;};

})(foo);

alert(foo.getX()); // 获得闭包 "x" – 10

总结

本文介绍了关于闭包以及闭包相关的知识,如果对你有用,欢迎点赞收藏!!!♥♥

相关文章

  • 大前端之路
  • 深入理解 JavaScript 系列(16):闭包(Closures)
正文完
 0