跟着月影学Javascript之闭包| 青训营笔记

114 阅读7分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天

闭包专题

在介绍闭包之前需要铺垫一些前置概念,这些知识是理解闭包的关键。

变量对象

变量对象就是我们定义了一个函数,在Javascript后台执行中会用一个对象把我们的变量和函数都存在这个对象中,我们一般称为AO。

我们用一个简单的例子来理解一下,比如我们创建了一个函数。

function getName(name) {
    var b = 1;
    function foo() {};
    var bar = function() {};

}
getName('lyy')

而Javascript在后台会用一个对象进行保存,这个创建过程的顺序是规定好的:

1.初始化函数的参数arguments

2.函数声明

3.变量声明

与之对应的,我们上面的例子创建的对象AO即是

AO = {
    arguments: {
        0: 'lyy',
        length: 1
    },
    name: 'lyy',
    b: undefined,
    foo: reference to function foo(){},
    bar: undefined
}

执行上下文栈

什么是执行上下文?

执行上下文就是Javascript在开始要执行一个函数之前开始进行的准备工作,就比如我们上面所说的创建一个变量对象就是执行上下文的过程之一。

什么是执行上下文栈?

我们的Javascript可能不止一个函数,因此就需要用一个栈来规定执行顺序。

在我们的执行上下文栈中,最底层永远有一个全局执行上下文在其中,这个全局执行上下文的作用在后面作用域就明白它的作用了,在这里先不赘述。

我们来个例子理解一下:

let a = 'javascript';

function foo() {
    console.log('foo');
    bar();
}
function bar() {
    console.log('bar');
}
foo();

image

1.上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入到当前执行栈。

2.当遇到 foo() 函数调用时, JavaScript 引擎创建了一个 foo 函数执行上下文并把它压入到当前执行栈的顶部。

3.当从 foo() 函数内部调用 bar() 函数时,JavaScript 引擎创建了一个 bar 函数执行上下文并把它压入到当前执行栈的顶部。

4.当函数 bar 执行完毕,它的执行上下文会从当前栈中弹出,控制流程到达下一个执行上下文,即 foo() 函数的执行上下文。

5.当 foo() 执行完成,它的执行上下文从栈弹出,控制流程到达全局执行上下文,一旦所有代码执行完成,javaScript 引擎就从当前栈中移除全局执行上下文。

作用域链

还记得我们说的变量对象吗?

在一个函数中查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。

而这样由每一个变量对象都有一个自己的作用域,而由多个执行上下文的变量对象构成的链表就叫做作用域链。

举个例子:

function foo() {
  function bar() {
    ...
  }
}

在上面这个例子中,我们创建的作用域链就是这样

foo.[[scope]] = [
  globalContext.VO   // 全局作用域
];

bar.[[scope]] = [
    fooContext.AO,   // foo的作用域
    globalContext.VO
];

开始闭包

前置知识铺垫完毕,现在正片开始

根据MDN的解释:

闭包是指那些能够访问自由变量的函数。

光看这句话,没有实际例子是没办法理解它的意思的,但是我们依然可以获取一些信息,闭包与 函数和变量 有关。

实际上,闭包是变量用来沟通函数和外界的桥梁。

我们先看一个例子:

function f1() {
  var n = 999;
}

console.log(n) // Uncaught ReferenceError: n is not defined(

在这个例子中,我们是没办法去访问到f1中的n,但是现实情况下,出于各种各样的原因,我们需要能访问到函数中的变量,因此我们需要通过一些变通的手段。

function f1() {
  var n = 999;
  function f2() {
    console.log(n);
  }
  return f2;
}

我们通过在函数中返回一个函数的办法,成功获取到了n的值,这个操作其实就是闭包。

这个操作之所以能实现,其实归功于Javascript的链式作用域,也就是我们之前所说的作用域链。

我们用之前的铺垫的知识,来解析一下上面这个例子。

首先在执行上下文栈中会push一个全局上下文

ecstack = [globalContext]

//初始化globalContext
globalContext = {
    VO: [global],
    Scope: [globalContext.VO],
}

紧接着会创建出f1函数,并把f1的执行上下文丢进栈中

f1.[[scope]] = [
  globalContext.VO
];

ecstack = [f1,globalContext]

初始化f1的变量对象,创建作用域链,并把初始化的f1变量对象压入作用域链的最顶端。

AO = {
    arguments: {
        length: 0
    },
    n = undefinded
    Scope: [AO, globalContext.VO],
}

然后在按上面的流程创建f2

f1.[[scope]] = [
  globalContext.VO
];

ecstack = [f2,f1,globalContext]

AO = {
    arguments: {
        length: 0
    },
    Scope: [AO,f1.AO, globalContext.VO],
}

到这里创建过程就结束了,开始执行,逐步在执行上下文栈中弹出。后面不是重点就不细说了

注意到了吗?上面的流程中有一步是实现了闭包的关键步骤,就是初始化f2时,将f1的变量对象丢到了f2的作用域中,因此哪怕后面pop掉f1上下文后,f2依然能取得f1中的n。

这也是闭包的真正含义:

维护了一个作用域链,即使创建它的上下文已经销毁,它仍然存在,只要我们引用了其它自由变量

经典例题:

接下来,看这道刷题必刷,面试必考的闭包题:

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0](); // 3
data[1](); // 3
data[2](); // 3

上面的执行结果都是3,不知道有没有出乎你意料,这是因为在执行上下文时,循环已经执行完成,此时的全局VO就是:

VO: {
    data: [...],
    i: 3
}

当执行data时,它的作用域了链就是这样

data[0]Context = {
    Scope: [AO, globalContext.VO]
}

data[0]中的AO并没有i,因此在作用域链它会往上面找,结果在VO中找到了i,但是此时循环已经执行完成i为3,因此就打印3。

因此为了保存好这个i的变量,就需要用闭包了解决

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
        return function(){
            console.log(i);
        }
  })(i);
}

data[0]();  // 0
data[1]();  // 1
data[2]();  // 2

此时VO和没改之前一样

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}

但是data的作用域链内容就发送了改变:

data[0]Context = {
    Scope: [AO, 闭包保存的函数.AO globalContext.VO]
}

通过闭包保存了一个AO中的i

闭包保存的函数.AO:{
    arguments: {
        0: 0,
        length: 1
    },
    i: 0
}

闭包经典案例

防抖

防抖节流应该是非常常见的闭包使用案例了。防抖和节流是用来优化Javascript代码的经典操作,主要针对一些浏览器事件,都是防止一个事件频发触发。

防抖:当你触发完事件后,设定在n秒后执行,如果在这期间你再次触发了,就以新的触发事件的时间为准。

function getUserAction() {
    container.innerHTML = count++;
};

function debounce(func, wait) {
    var timeout;
    return function () {
        var _this = this;    // 获取到调用时间的this,因为在setTimeout中this是全局window
        var args = arguments;
        clearTimeout(timeout)
        timeout = setTimeout(function(){
            func.apply(_this,args)
        }, wait);
    }
}

container.onmousemove = debounce(getUserAction,100);

执行流程:当我们保存代码后,会先运行同步代码deounce,在deounce中会返回一个匿名函数并且注册到事件上。因为事件是异步的,所以只有当我们触发事件后,会开始调用deounce返回的匿名函数。

问题1:为什么要保存this?

这里保存this是为了在getUserAction函数中可以获取到this,因为在定时器中的this是指向window

问题2:args是干嘛用的?

同理,args是为了保存事件函数的参数,因此我们采用调用apply把this和args传进去

问题3:为什么要用闭包保存timeout呢?

这里的timeout主要是保存定时器返回的id,让我们在下次触发时可以删掉上一个定时器,来重新触发定时器生效。因此就需要有一个长周期的变量来维护id

节流

节流:当你持续触发一个事件,每隔一段事件只执行一次你触发的事件。

function getUserAction() {
    container.innerHTML = count++;
};
function throttle(func, wait) {
    var timeout;
    return function() {
        var _this = this;
        var args = arguments;
        if (!timeout) {
            timeout = setTimeout(function(){
                timeout = null;
                func.apply(_this, args)
            }, wait)
        }
    }
}
container.onmousemove = throttle(getUserAction,100);