本节思维导图:

再谈闭包

什么是闭包?

函数执行形成私有的上下文,当前上下文中的某些东西(如变量等)被外部占用,形成不被释放的上下文,这个就是闭包。通常是大函数内包含小函数,如:

1
2
3
4
5
function outer(i){
return function inner(){
console.log(i)
}
}

这种类型的好处是,小函数可以公国作用域链机制查找到大函数中的变量i,使用变量i,并且不会对全局变量造成污染,之后我们会通过计数器这个例子来展示闭包的好处。但闭包也有坏处,像这样不被释放的上下文需要占据浏览器内存,比如这里的outer函数就会一直在内存中了。

还有一点人常以为的误区是,并非大函数包含小函数就代表了闭包,只要形成不被释放的上下文,就可以是闭包,比如这样一段代码:

1
2
3
4
5
6
7
8
9
function A(i){
A = function(){
console.log(i)
}
alert(++i);
}

A(1);
A();

这段代码在执行的时候,由于A函数在A执行的上下文中被重构,所以原来的A也不会被释放。

[ 1 ] 例 计数器 > 当前页面有btn按钮和一个显示数字的h3标签

1
2
3
4
5
6
7
8
9
10
11
let num = document.querySelector("#num");
let btn = document.querySelector("#btn");

function outer(count) {
return _ => count++;
}
let counter = outer(1);

btn.onclick = function (e) {
num.innerHTML = counter();
}

局部代码执行完成之后,形成的私有上下文仍然被外面的代码引用, 没有被浏览器释放,从而起到对变量的 [ 保存/保护 ] 作用。这段代码,每点击一次button,则触发counter函数。outer函数中的count仍然被全局下的couter函数引用,所以该变量不会被释放。

浏览器垃圾回收机制

Chrome浏览器会把当前上下文中,没有被占用的堆内存释放,这就是浏览器的垃圾回收机制。当我们形成闭包的时候,全局上下文中就会占用函数形成私有上下文中的堆内存,函数内部的东西被外部占用,导致函数堆内存不能被浏览器回收,于是函数的私有变量就一直在内存中了。

补充垃圾回收机制相关内容。实现方式:标记清楚和引用计数。

释放内存分为两方面,一个是当前页面的栈内存,一个是当前变量开辟的堆内存。

栈内存的释放: 加载页面会形成一个全局的上下文,只有页面关闭后,全局上下文才会被释放,函数执行会形成一个私有的上下文,进栈执行,当函数中代码执行完成,大部分情况,形成的上下文都会被出栈释放掉,以此优化栈内存的大小

堆内存的释放:常用的内存管理机制:关联引用(chrome),引用计数(IE)

​ 关联引用:浏览器在空闲或者指定时间内,查找所有的堆内存,并把没有被任何东西占用的堆内存释放掉

​ 引用计数:创建堆内存,被占用一次,则计数器+1,取消占用则计数器-1,当计数器为0时,内存释放

如果要想内存直接释放可以选择:variable = null;

下面介绍几种闭包的高级应用:

1. 循环处理

我们先来看这样一段代码:

1
2
3
4
5
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
}

每循环一次,就设置一个定时器,在第i秒后打印。看似没什么问题,但是结果却是将console.log(5) 输出5遍。这是因为SetTimeout执行是异步的,不是循环一次就执行一次,而是遇到SetTimeout之后主线程直接往下走,也就是走到下一个i++,这个过程是同步的。当循环结束的时候,setTimeout中的代码开始执行,控制台输出i,注意:此时循环结束,i为5,所以我们看到的是五次5.

如果我们要输出0~5,则需要把每次循环的i值保存下来,形成一个私有的上下文,这样最后输出的时候,就不调用全局下的i,而是调用每个循环里私有上下文的i。下面介绍三种形成私有上下文的写法,分别为:自执行函数、自定义函数和基于let声明的变量,原理图也如下:

闭包实现循环事件绑定的原理图

1
2
3
4
5
6
7
8
9
/* 自执行函数形成闭包 ,每个循环都会创建一个单独的上下文,外层函数形成的上下文不会被释放,然后当循环结束后,从私有上下文中取变量*/
/* 这里的私有上下文被window.setTimeout占用了 */
for (var i = 1; i < 5; i++) {
(function(num){
setTimeout(()=>{
console.log(num)
},1000*num)
})(i)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
/* 自定义函数实现闭包,其原理和自执行函数相同,都是在外层函数形成一个不被释放的上下文,然后当循环结束后,从私有上下文中取变量i */
let fn = function fn(i) {
return function () {
setTimeout(() => {
console.log(i);
}, 1000 * i)
}
}

for (var i = 1; i < 5; i++) {
let f = fn(i)
f()
}
1
2
3
4
5
6
/* 基于let实现,原理和上面一样,也是每个循环都会创建一个单独的上下文 */
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
}

2. 单例设计模式

基于闭包实现模块化思想
1. 单例设计模式
2. AMD -> request.js
3. CMD -> sea.js
4. CommonJS -> Node本身就是基于这种规范实现的
5. ES6Module
单例模式设计的目的:解决全局变量污染

1
2
3
4
5
6
// 这样的方式无法定义几个类似的变量
let name = 'zen';
let age = 18;

let name = 'neo';
let age = 19;
1
2
3
4
5
6
7
8
9
10
 // 我们可以用闭包来对变量进行保存和保护
(function () {
let name = 'zen';
let age = 18;
})();

(function () {
let name = 'neo';
let age = 19;
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
// 但是上面那种情况,我们无法从name=neo的上下文中得知name=zen的信息,于是我们想到,可以把想让别人访问的变量通过`window`暴露到全局
// 但是这样如果暴露过多的变量,也会产生变量冲突
(function () {
let name = 'zen';
let age = 18;
window.hobby = "play";
})();

(function () {
let name = 'neo';
let age = 19;
console.log(window.hobby);
})();
1
2
3
4
5
6
7
8
9
10
11
// 如何解决变量冲突呢?
// 我们可以把描述当前事务特征的内容汇总到一个对象中,存储到同一个堆内存中
// person1 / person2 称为命名空间;每一个对象都是Object这个类的一个实例,person1和person2是两个完全不同的实例,所以我们也可以把这种方式称之为“单例设计模式”
let person1 = {
name: 'zen',
age: 18
}
let person2 = {
name: 'neo',
age: 19
}
1
2
1. 高级单例设计模式:JS中最早期的模块化开发思想,实现模块之间的独立性和互通性
2. 每个模块之间是独立的,相互之间的信息互不干扰;但是公共方法可以相互调用

单例模式具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 公共方法
let utils = (function () {
let isWindow = true,
num = 0;
const queryElement = function queryElement() { };
const formatTime = function formatTime() { };

// 把需要供外界调用的方法存储到一个命名空间中
return {
queryElement,
formatTime
}
})();

// 其他模块使用公共方法
(function () {
let num = 10;
const checkValue = function checkValue() {
utils.formatTime();
}
})();

3. 惰性函数

有些操作我们只想要操作一次,比如判断浏览器某个API是否兼容,或者是绑定URL,利用闭包实现的惰性函数给我们提供了这种可能,我们可以让判断的结果或者是用户输入的变量保存起来,下一次就不需要重复执行了。下面我们写一个获取浏览器元素样式的方法,这个方法最好用getComputedStyle来获取样式,但是getComputedStyle不兼容IE6~8,我们需要在外层函数先做一次判断,之后的调用就不需要再进行判断了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取元素样式
// getComputedStyle使用: window.getComputedStyle(el)['fontSize']

function getStyle() {
if ('getComputedStyle' in window) {
getCss = function (el, attr) {
return window.getComputedStyle(el)[attr]
}
} else {
getCss = function (el, attr) {
return el.currentStyle[attr]
}
}
return getCss;
}

var h = document.querySelector("h1");
var getCss = getStyle();
var h1_size = getCss(h, 'fontSize');
console.log(h1_size) // => 22px

html样式:

1
2
3
4
5
6
7
8
9
10
11
/* 文字渐变色 */ 
h1 {
background: -webkit-linear-gradient(-160deg, #FAB74C, #5F3484);
-webkit-text-fill-color: transparent;
background-clip: text;
-webkit-background-clip: text;
}

<h1 style="font-size: 22px;">
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Soluta sint quia corporis
</h1>

4. 柯里化函数

currying 又称为部分求值。一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值

(预处理)执行函数形成闭包,把一些变量和值保存起来,以后其下级上下文中如果需要用到这些值,直接基于作用域链查找机制,拿来直接用即可。

面试题 - 解法一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 
创建一个函数,让表达式
let res = fn(1,2)(3);
console.log(res) // =>6
成立
*/
// fn函数的返回值需要是一个函数地址,不然没有办法调用函数方法
function fn() {
let outerArr = Array.from(arguments);

return function () {
let innerArr = Array.from(arguments);
let arr = outerArr.concat(innerArr);
let total = 0;
arr.forEach((item, index) => {
total += item;
})
return total;
}
}
let res = fn(1, 2)(3);
console.log(res) // =>6

面试题 - 解法二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 数组中的reduce:依次遍历数组中的每一项,可以把上一轮遍历得到的结果传递给下一轮,以此实现结果的累计
// - arr.reduce(function): 会把数组第一项作为初始结果,从数组第二项开始遍历
// - arr.reduce(function,value): 第二个传递参数作为初始结果,从数据第一项开始遍历

// 从数组第二项开始遍历
let arr = [10, 20, 30, 40];
let result = arr.reduce((result, item, index) => {
return result + item;
})

// 从数组第一项开始遍历
let arr = [10, 20, 30, 40];
let result = arr.reduce((result, item, index) => {
return result + item;
}, 0)

重构reduce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// reduce实现了把数组的上一轮遍历的结果传递给下一轮
// myReduce需要实现reduce的基本功能:
function myReduce(arr, callback, initValue) {
let result = initValue;
let index = 0;
if (result === undefined) {
// 如果用户没有传递初始值,那么初始值就设置为数组第一号元素,并且之后遍历的时候,下标从1开始
result = arr[0];
index = 1;
} else {
// 如果用户设置了初始值,那么result就设置为初始值,遍历的时候,下标从0开始
result = initValue;
}
for (; index < arr.length; index++) {
// 需要向callback中传入当前数组每一项和当前的result
result = callback(result, arr[index]);
}
return result;
}

let arr = [1, 2, 3, 4, 5, 6],
initValue = 0;

let result = myReduce(arr, function (result, item) {
return result + item;
}, initValue); // 参数:数组、处理函数、初始值

console.log(result)



ES6新方法

const fn = (...outerArgs) => (...innerArgs) => outerArgs.concat(innerArgs).reduce((result, item) => result + item);

let arr = [1, 2, 3, 4, 5];
let result = fn(1, 2)(3);

5. Compose组合函数

函数式编程中有一个很重要的概念就是函数组合,实际上就是把处理数据的函数像管道一样连接起来,然后让数据穿过管道得到最终结果,例如:

1
2
3
4
5
6
7
8
9
const add1 = (x) => x + 1;
const mul3 = (x) => x * 3;
const div2 = (x) => x / 2;

// 原生嵌套
div2(mul3(div2(add1(mul3(2)))); // => 1.5
// 组合函数串联
let operator = compose(mul3, div2, add1, mul3);
console.log(operator(2)); // => 1.5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const add1 = (x) => x + 1;
const mul3 = (x) => x * 3;
const div2 = (x) => x / 2;

// compose函数需要接受不定个函数作为参数
// compose函数需要返回一个可执行函数给operator调用
// 返回的可执行函数需要能够接受一个参数,用来传递初始值
function compose(...args) { // 这里接收函数

return function (initValue) {
// 判断传递的函数数组长度是否为0|1
let len = args.length;
if (len === 0) return initValue => initValue;
if (len === 1) return initValue => args[0](initValue);

// 如果长度满足,则考虑如何让上一个函数执行的结果作为下一个函数的参数
// reduce:遍历数组时,将上一次执行的结果传递到下一次遍历,执行顺序从左到右
// reduce: 如果没有初始值,则从数组第1项开始遍历,result结果为第一项的值
// reduce:如果有初始值,则从数组第0项开始遍历,result结果为传递的初始值,这里使用用户传递的initValue作为初始值
args.reverse()
let finalValue = args.reduce(function (result, item) {
// reduce回调函数接受参数:result/item/index ,分别为:上一轮处理的结果(这里是initValue)/每一轮循环的数组项/数组下标
return item(result);
}, initValue)
return finalValue;
}
}

// 实现compose函数 ===> mul3(div2(add1(mul3(value)))) 上一个函数执行的返回值作为下一个函数的参数
let operator = compose(mul3, div2, add1, mul3);
console.log(operator(2));

6. 函数的防抖和节流

防抖和节流的应用:例如百度的搜索框:当停止连续输入后就会帮你展示一些联想词,也就用到了防抖:连续触发的一串事件之间是有关联的,并且直到这一串事件连续触发完成才有反馈;比如页面上元素的拖拽,商城秒杀(用户会疯狂点击),都用到了节流:既要保证用户的行为立即有所反馈,又不要事件被过于频繁触发。

debounce.js 创建一个 debounced(防抖动)函数,该函数会从上一次被调用后,延迟 wait 毫秒后调用 func 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 设置一个空定时器,用户点击时,清除定时器,之后再设置定时器,这样当用户不再点击,最后设置的定时器就会执行完
// 防抖函数: 连续触发完才有反馈
function debounce(func, waits) {
var timer = null;
return function (...args) {
window.clearTimeout(timer);
timer = setTimeout(() => {
func(args);
timer = null;
}, waits || 300)
}
}

// 测试用例
function showmsg(...args) {
var msg = null;
args.forEach(item=>{
item.forEach(innerItem=>{
typeof innerItem === 'string' ? msg = innerItem : null;
})
})
console.log(`this is msg : ${msg}`);
}

var btn = document.querySelector("#btn");
var f = debounce(showmsg, 1000)
btn.onclick = function (e) {
f(e,'hi')
}

throttle 创建一个节流函数,在 wait 秒内最多执行 func 一次的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 节流:限制事件触发的频率
// 设置一个空定时器,用户点击时,判断定时器是否为空,如果不为空,则直接返回;如果为空,则创建新定时器
function throttle(func, waits) {
var timer = null;
return function (...args) {
if (timer) return; // 如果存在timer,则直接返回
timer = setTimeout(() => {
func(args)
timer = null;
}, waits | 300)
}
}



function showmsg() {
console.log("rolling");
}

let f = throttle(showmsg, 100)

window.onscroll = f;

Reference

[1] https://juejin.cn/post/6844903898214301709

[2] https://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.html

[3] https://www.lodashjs.com/docs/lodash.debounce

[4] https://www.lodashjs.com/docs/lodash.throttle/