你不知道的闭包

1,673 阅读12分钟

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

什么是闭包

  • 话不多说,先来品鉴一段朴实无华的闭包代码
const name = "hello";

const makeFunc = () => {
  const name = "Closure Func";

  const dispalyName = () => {
    alert(name);
  };

  return dispalyName;
};

const myFunc = makeFunc();

myFunc();
  • 如果不出意外,代码执行后,alert 输出的是Closure Func
  • 为什么myFunc执行后,输出的是makeFunc内部变量 name的值?
  • 我们先来看一下较为权威的资料怎么说的:

红宝书:闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

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

  • 【红宝书】与【MDN】在描述闭包的定义时,所用的语句不尽相同,但其实是一个意思

  • 闭包就是函数本身函数被申明时所在的词法作用域绑定组合

  • 所以函数执行时,可以自由访问该词法作用域中的任意变量

  • 以上面第一段代码为例,来剖析闭包

    • 函数本身指的是 dispalyName 函数自身
    • 而函数 dispalyName 被声明时所在的词法作用域,指的就是 makeFunc 函数所创建的函数作用域
    • 所以函数 dispalyName,理所当然可以访问函数 makeFunc 中的任意变量
    • 通常情况下,函数执行完之后
      • js 引擎的垃圾回收机制,会认为这个函数执行时产生的变量已经是闲置资源,可以进行释放回收

      • 但是由于函数 dispalyName 引用了函数 makeFunc 中的变量 name

      • 所以在函数 makeFunc 执行完之后,垃圾回收机制无法释放,仍然被函数 dispalyName 所引用的变量 name

      • 以至于,函数 makeFunc 执行完成之后,其内部的函数 dispalyName 可以在任何地方,继续执行,并使用函数 makeFunc 内部的变量 name

  • 为了能跟进一步理解闭包,下面简单介绍一下,上面的分析中提到了词法作用域

    • 词法作用域就是在词法分析阶段,它就被确定了且不会再改变,所以它也叫静态作用域
    • 与之相对应的还有动态作用域,它可以根据程序运行时的流程信息来动态变化,js 中使用 function 关键字申明的函数内部的 this 就是典型代表
  • 词法作用域与动态作用域对比:

    • 两者被确定的时机不同
      • 前者是在源代码中定义变量时就确定了的,而后者是在程序运行时被确定的
    • 两者关注函数生命周期的阶段不同
      • 前者关注的是函数在何处被声明,而后者关注的是函数在何处被调用
  • 说了这么多,闭包到底有什么 🐂🍺 的应用?

闭包的应用

  • 不同的编程语言中,闭包的应用都是非常广泛的
  • 下面介绍几个常见的闭包应用

惰性函数

  • 使用惰性函数是一个可以有效提升程序运行效率的技巧,其本质就是将函数执行的结果缓存起来,以供函数下次执行时使用
  • 下面是一个典型的惰性函数,输入一个数字,输出这个数字的平方数
const calcFn = ((cache) => {
  const realFn = (n) => {
    if (!cache[n]) {
      cache[n] = n * n;
    }

    return cache[n];
  };

  return realFn;
})({});

calcFn(2);
  • 上面的例子中用到了闭包的好朋友👬 即时函数,也叫立即执行函数表达式 IIFE (Immediately-Invoked Function Expressions)
  • 例子中声明了一个变量calcFn,其值为即时函数的执行结果函数realFn
  • 函数 realFn 与即时函数内部变量 cache 形成了闭包
  • 每次调用 calcFn 时,其实调用的都是 realFn
  • realFn 执行时,会先判断有没有当前输入数字计算结果的缓存,有则直接返回,没有则先将计算结果存入 cache 中,然后再返回计算结果
  • 下面是另一个惰性函数的例子
const checkType = (value) => {
  return (obj) => {
    const map = {
      "[object Boolean]": "boolean",
      "[object Number]": "number",
      "[object String]": "string",
      "[object Function]": "function",
      "[object Array]": "array",
      "[object Date]": "date",
      "[object RegExp]": "regExp",
      "[object Undefined]": "undefined",
      "[object Null]": "null",
      "[object Object]": "object",
    };

    const toString = Object.prototype.toString;

    return map[toString.call(obj)] === value;
  };
};

const isNumber = checkType("number");
const isFunction = checkType("function");
const isRegExp = checkType("regExp");

isNumber(888); // => true
isFunction(function () {}); // true
isFunction(() => {}); // true
isRegExp({}); // => false
isRegExp(/\w/); // => true
  • 函数 checkType 接收一个数据类型的字符串作为参数,并生成一个类型判断函数
  • 由于闭包的特性,新生成的类型判断函数缓存了一个独有数据类型字符串
  • 当新函数执行是,传入的对象的字符串与之前缓存的类型字符串相同时返回 true,否则返回 false

函数柯里化

  • 柯里化也是函数式编程的一个重要概念,它的特性之一就是参数复用
  • 函数柯里化是指,将一个接受多个参数的函数,分解成多个一系列嵌套的接收单个参数的函数的技术,且只有当接收的参数数量,满足了原函数所需的参数数量后,才执行
  • 概念有点绕口,需要细品
  • 下面是一个函数柯里化的例子 🌰,看懂后就能慢慢理解上面的定义了
// 传统多元写法
let foo = (x, y) => x + y;

// 柯里化写法:low 版
const curry = (fn) => {
  return (x) => {
    return (y) => {
      return fn(x, y);
    };
  };
};
let myfn = curry(foo);

myfn(1)(2)
  • 当然细心的小伙伴可能看到,上面的柯里化写法是 low 版,只适用于当原函数只有两个参数的情况,那当函数的参数不固定时,该如何进行柯里化呢?

偏应用函数

  • 通常我们提到函数柯里化,一定会想到偏应用函数。偏应用函数也是函数式编程中的另一个重要概念,它的一个重要特性也是参数复用,但与函数柯里化的参数复用不尽相同
  • 偏应用函数指的是:
    • 使用一个函数,并将其应用一部分(一个或多个)参数,但不是全部的参数
    • 这个过程会创建一个新的函数,然后由新函数来接收原函数剩余的那些参数
  • 概念依然绕口,依然需要细品
  • 通过上面的概念,很明显的我们能知道,偏应用函数不再向函数柯里化那样,一个参数一个参数的缓存,而是根据我们的需要来,缓存适当数量的参数
  • 下面是偏应用函数的例子,附加彩蛋是第二个通用转换偏应用函数的工具函数
// 原函数
function sum(a, b, c) {
  return a + b + c;
}



// 1、使用偏函数的写法
function fn(b, c) {
  return (a) => {
    return a + b + c;
  };
}

// 缓存原函数后两位参数,用 foo_1 接收返回的新函数
const foo_1 = fn(2, 3);
// 调用 foo 时传入剩余的参数
foo_1(1); // 6



// 2、通用转换偏函数的方法,看起来确实很偏 😛
const partialFn =
    // 接收一部分参数,返回新函数
    (f, ...args) =>
        // 接收剩余的参数,返回新函数
        (...moreArgs) =>
            // 将全部参数传给原函数执行
            f(...args, ...moreArgs);

// 缓存原函数后两位参数,用 foo_2 接收返回的新函数
const foo_2 = partialFn(sum, 2, 3);
// 调用 foo 时传入剩余的参数
foo_2(1); // 6
  • 让我们来总结一下,偏应用函数与函数柯里化的异同点:
    • 柯里化和偏函数都是用于将多个参数的函数 转化 为接收更少参数的函数的方法技巧,都能够复用参数
    • 但是其中不同之处在于:
      • 转换后的函数接收参数个数不同
      • 柯里化是将函数转化为多个嵌套的一元函数,也就是每个函数只接受一个参数
      • 偏函数可以接受不只一个参数,它预设了部分参数被固定复用,然后接收原函数余下的参数
    • 缓存参数的位置要求不同
      • 函数柯里化,只能从左至右,一个一个的缓存原函数的参数
      • 偏函数则没有限制,可以根据我们的需要来缓存原函数任意位置的参数

实现单例模式

  • 单例模式是一中常见的,且被广泛应用的设计模式,它保证了一个类只会有一个实例
  • 不同的编程语言中,也都有着不同的实现方式。实现的方法一般是先判断类的实例是否存在,如果已经存在就可以直接返回,否则就创建实例之后再返回该实例
  • 单例模式最大的好处就是避免了类被重复实例化带来的内存开销
  • 下面使用闭包来实现单例模式
function Singleton() {
  this.data = "this is singleton";
}

Singleton.getInstance = (function () {
  const instance = null;

  return () => {
    if (instance) {
      return instance;
    } else {
      instance = new Singleton();
      return instance;
    }
  };
})();

let a = Singleton.getInstance();
let b = Singleton.getInstance();

console.log(a === b); // true
console.log(a.data); // 'this is singleton'
  • 同样的,上面的例子中也使用了闭包的好朋友 👬 即时函数
  • 在构造函数 Singleton 上面挂载一个getInstance 方法,该方法其实是即时函数执行后,返回的一个新函数
  • 新函数执行时,会先判断构造函数 Singleton 的实例是否存在,存在则直接返回,不存在则先实例化一个对象,且将该对象缓存在 getInstance 方法内部的变量 instance 中,再返回该对象实例

模拟私有方法

  • 阮一峰老师的 ECMAScript 6入门 一书中有提到:
    • 私有属性和私有方法是只能在类的内部访问使用,在类的外部无法访问的属性和方法
    • 私有属性和私有方法的使用,有利于代码的封装,但遗憾的是一直到 ES6,JavaScript 的各版本都没有提供
    • 只是近年来,有一个提案为 class 增加了私有属性,就是在 class 内部方法或者属性前面加上#以表示私有属性或私有方法
  • 此处不过多解释私有属性方法,回到我们的主题闭包上面:如何利用闭包,给构造函数增加私有属性和私有方法?
  • 下面是一个小例子 🌰
const Counter = (function () {
  let privateVal = 0;

  const setPrivateVal = (val) => {
    privateVal += val;
  };

  return {
    increment() {
      setPrivateVal(1);
    },
    decrement() {
      setPrivateVal(-1);
    },
    value() {
      return privateVal;
    },
  };
})();

console.log(Counter.value()); // 0

Counter.increment();
Counter.increment();
console.log(Counter.value()); // 2

Counter.decrement();
console.log(Counter.value()); // 1
  • 仔细的小伙伴已经发现,又出现了闭包的好朋友 👬 即时函数
  • 上面的例子给构造函数 Counter 定义了一个私有变量 privateVal 和一个私有方法 setPrivateVal
  • Counter 函数返回的对象中的三个方法都产生了闭包,且三个闭包使用的是同一个词法环境,不记得词法环境是什么的小伙伴,可以看文章最上面的概念介绍部分

实现类库封装

  • 类库的封装最重要的要求,就是不能让类库中的变量污染全局
  • 为了致敬推动了前端发展巨大变革的伟大的划时代 JS 库 jQuery
  • 下面实现一个最简版 jQuery
// 写法 1
(function () {
  const a = "hello IIFE + jQuery";
  
  const jq = window.$ = function() {
    // Intialize jQuery function
		console.log(a)
  }
  
})()


// 写法 2
let $ = (function () {
  const a = "hello IIFE + jQuery";
  
  function jq() {
    // Intialize jQuery function
    console.log(a);
  }
  
  return jq
})()
  • 在实现 mini jQuery 的过程中免不了会定义一些变量,但是又不希望这些变量污染全局环境,所以可以使用即时函数 搭配 闭包来实现
    • 写法 1
      • 给 window 添加一个 $ 属性,其值为具体的 jquery 函数,是为了在外部调用 jQuery 方法
      • 而将window.$再次赋值给局部变量 jq是为了方便在 即时函数 的内部,通过局部变量 jq 访问 jQuery 函数
    • 写法2
      • 实现更加直观,用一个全局变量来接收,即时函数自执行返回的 jQuery 函数

解决循环陷阱

  • 这是一道经典面试题
  • 在那个没有块级作用域的年代,使用闭包与即时函数的组合就能解决这道题了
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i));
}


for (var i = 0; i < 5; i++) {
  ((i) => setTimeout(() => console.log(i)))(i);
}
  • 其实闭包的应用远不止本文所描述的这些,在 vue 和 react 源码中也有大量使用闭包的例子。本文先抛砖引玉,后续再分享框架源码中的闭包应用。
  • 细心的小伙伴会发现,上面的例子中出现了几次即时函数,那即时函数还有什么作用呢,难道只能陪着闭包玩,能不能自己也玩点花活?由于篇幅限制,下面先暂时简单介绍一个即时函数的典型应用,后面再分享它的其他应用。

即时函数的应用之一:模块化打包

  • Rollup 是一个 JS 的模块打包器,它的打包方式之一就是使用即时函数 iife
  • 不了解 Rollup 的小伙伴可以看下它的官网,写的还挺详细的
  • 下面使用 rollup 打包生成一个即时函数
    • 执行打包命令:npx rollup -f iife -n $ -o ./dist.js ./jquery.js
      • -f指定以 iife 方式输出
      • -n指定输出的变量名
      • -o指定输出文件
  • 源文件
// jquery.js
export default function () {
  console.log("hello jQuery");
}
  • 打包后的文件
// 输出文件 dist.js
var $ = (function () {
  "use strict";

  function jquery() {
    console.log("hello jQuery");
  }

  return jquery;
})();
  • 验证打包后的文件
$(); // hello jQuery

小结

  • 闭包这么牛,经典应用这么多,那我们可以在程序代码中任意使用闭包吗?

    • 答案肯定是不行的,闭包的存在会阻碍垃圾回收机制的运行,因此会导致内存消耗变大,影响程序运行性能

    • 所以我们需要根据程序功能的需要,来衡量是否需要使用闭包

    • 用的恰到好处的闭包会让程序的更优雅,反之,你的代码会被维护者嗤之以鼻👀

  • 闭包的分享就到这里了,欢迎大家在评论区里面讨论自己对闭包的理解 👏。

  • 最后,如果觉得文章写的不错的话,希望大家不要吝惜点赞,大家的鼓励是我分享的最大动力🥰