「这是我参与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 函数
- 写法 1
解决循环陷阱
- 这是一道经典面试题
- 在那个没有
块级作用域的年代,使用闭包与即时函数的组合就能解决这道题了
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
小结
-
闭包这么牛,经典应用这么多,那我们可以在程序代码中任意使用闭包吗?
-
答案肯定是不行的,闭包的存在会阻碍
垃圾回收机制的运行,因此会导致内存消耗变大,影响程序运行性能 -
所以我们需要根据程序功能的需要,来衡量是否需要使用闭包
-
用的恰到好处的闭包会让程序的更优雅,反之,你的代码会被维护者嗤之以鼻👀
-
-
闭包的分享就到这里了,欢迎大家在评论区里面讨论自己对闭包的理解 👏。
-
最后,如果觉得文章写的不错的话,希望大家不要吝惜点赞,大家的鼓励是我分享的最大动力🥰