js 系列第二篇---闭包

77 阅读7分钟

闭包

闭包的概念

大多数文章甚至以前的 MDN 对 JavaScript 闭包的定义为:

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

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

再来看下权威书籍和现在的 MDN 对 JS 闭包的定义:

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 --JavaScript高级程序设计(第四版)

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。 --MDN

个人认为现在的 MDN 的定义是更准确的,也建议使用这个定义来理解 JS 闭包,即闭包是函数以及函数的词法环境的引用的绑定。这里词法环境的引用就是上一篇文章中提到的词法环境对外部环境的引用。

从执行上下文和调用栈的角度去理解闭包,先看这段代码:

function f() {
    var name = "ly_qu"
    let test1 = 1
    const test2 = 2
    var inner = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return name
        }
    }
    return inner
}
var g = f()
g.setName("page_not_found")
g.getName()
console.log(g.getName())

根据上一篇文章中我们所讲的内容,当执行到函数 f 内部时,调用栈如下图所示:

image.png

可以看到 inner 对象中定义的两个方法 getNamesetName 都是定义在函数 f 中的。其实在上一篇文章中,为了方便描述,上图省略了一些细节,比如inner存储的是对象,而对象是存储在堆中的,但在上图中我们做了简化处理,同样被简化处理的还有闭包(closure),当执行到foo函数中的return inner时,调用栈状态如下图所示:

image.png

当 JavaScript 引擎执行到 f 函数时,在编译过程中遇到内部函数setName,还会对内部函数setName做一次快速的词法扫描,发现该内部函数引用了函数 f 中的 name 变量,判断其为闭包,于是在堆空间创建了一个closure(f)对象,保存 name 变量。同理,扫描到getName函数时,将test1添加到consule(f)对象中。函数 f执行结束后,调用栈会弹出函数f的执行上下文:

image.png

如图,即使f执行结束,其执行上下文从调用栈中弹出,getNamesetName仍然可以通过保存在堆内存中的closure(f)正确地获取到函数 f 中定义的变量。

总的来说,产生闭包的核心有两步:

  1. 预扫描内部函数。
  2. 把内部函数引用的外部变量保存到堆中。

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在堆内存中,我们就把这些变量的集合称为闭包。比如外部函数是 f,那么这些变量的集合就称为函数 f 的闭包。

闭包的两种表现形式

  1. 函数作为返回值被返回
  2. 函数作为参数被传递

简单例子:

//函数作为返回值
function create(){
    let a = 100;
    return function(){
        console.log(a);
    }
}
const fn = create();
const a = 200;
fn(); // 100//函数作为参数被传递
function print(fn){
    let a = 200;
    fn();
}
const a = 100;
function fn(){
    console.log(a);
}
print(fn); // 100

上一篇文章中提到过,由于 JavaScript 是词法作用域,故闭包中所谓的自由变量的查找,是在函数定义的地方向上级作用域查找,不是在执行的地方。

image-20220819112721178

上图是第一个例子在 chrome 的执行,闭包中是a:100,没问题。

image-20220819113018141

上图是第二个例子在 chrome 中的执行,发现chrome 并没有标记出闭包,原因是,我们的代码是在全局,而不是在函数中,上面我们提到了,只有在遇到函数中存在内部函数时,才会扫描闭包。我们只需要将上述代码放入到一个函数中,chrome 就能标记闭包:

image-20220819113407409

闭包的应用

  1. 隐藏数据/模拟私有变量的实现

//闭包隐藏数据,只提供API
function createCache(){
    const data = {}; // 闭包中的数据,被隐藏,不被外界访问
    return {
        set:function(key,val){
            data[key] = val;
        },
        get:function(key){
            return data[key];
        }
    }
}
​
const c = createCache();
c.set("a",100);
console.log(c.get("a"));
// 利用闭包生成IIFE,返回 User 类
const User = (function() {
    // 定义私有变量_password
    let _password
​
    class User {
        constructor (username, password) {
            // 初始化私有变量_password
            _password = password
            this.username = username
        }
​
       login() {
           // 这里我们增加一行 console,为了验证 login 里仍可以顺利拿到密码
           console.log(this.username, _password)
           // 使用 fetch 进行登录请求,同上,此处省略
       }
    }
​
    return User
})()
​
let user = new User('ly', 'pageNotFound')
​
console.log(user.username);//ly
console.log(user.password);//undefined
console.log(user._password);//undefined
user.login();//ly pageNotFound
  1. 在函数调用之间共享状态

如防抖节流等等,例子太多这里不举例子了。

  1. 偏函数和柯里化

柯里化:是把接受多个参数的函数变换成接受 一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

偏函数:固定你函数的某一个或几个参数,然后返回一个新的函数(这 个函数用于接收剩下的参数)。

柯里化封装函数:

//args.length是实参长度,func.length是形参长度
//函数柯里化
function curry(func) {
  return function curried(...args) {
    if (args.length >= func.length) {
      return func.apply(this, args);
    } else {
      return function (...args2) {
        return curried.apply(this, args.concat(args2));
      };
    }
  };
}
​
function sum(a, b, c) {
  return a + b + c;
}
​
const currysum = curry(sum);
​
console.log(currysum(1)(2)(3)); //6
console.log(currysum(1)(2, 3)); //6
console.log(currysum(1, 2, 3)); //6

闭包的错误使用

在循环体内使用闭包

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}
console.log(i);

上面代码执行会先输出最后一行的5,1 秒后输出55555

分析过程:代码从上到下顺序执行,先执行for循环,每次遇到循环体内的setTimeout都会把setTimeout内部的函数推迟 1秒执行。for 循环结束,然后执行到最后一行,此时,由于for 循环已经将i的值加到了 5,故输出了 5。一秒后,setTimeout内的函数被执行,依次输出了 55(setTimeout 的执行涉及到事件循环机制,这里不深入介绍,后面再说)。

解决方式

思路 1

setTimeout 的第三个参数可以作为回调函数的附加参数传入。

for (var i = 0; i < 5; i++) {
    setTimeout(function(j) {
        console.log(j);
    }, 1000, i);
}

思路 2

setTimeout外面再套一层立即执行函数,利用立即执行函数的入参来缓存每一个循环中的i的值。

for (var i = 0; i < 5; i++) {
    // 这里的 i 被赋值给了立即执行函数作用域内的变量 j
    (function(j) {  
        setTimeout(function() {
            console.log(j);
        }, 1000);
    })(i);
}

思路 3

使用let替换掉var,由于let具有块作用域的特性,故多个循环体内i 的值互相不影响。

for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}
​
console.log(i);

总结

在 js 中,根据词法作用域模型规则,内部函数总是可以访问其外部函数中声明的变量,即使其外部函数已经弹出调用栈,其引用的变量被保存在堆内存中。

产生闭包的核心步骤有两个:一是预扫描内部函数,二是把内部函数引用的外部变量保存到堆内存中。

闭包就是函数及其引用词法环境的绑定。

闭包有两种表现形式:一是函数作为参数传入;二是函数作为返回值被返回。

闭包有模拟私有变量的实现、共享变量、函数柯里化等应用。

在循环体内使用闭包要小心各个迭代之间的变量被污染。