闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。

也就是说,闭包让你可以在一个内层函数访问到其外层函数的作用域

在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

词法作用域

请看下面的代码:

1
2
3
4
5
6
7
8
function init() {
var name = "Mozilla"; // name 是一个被 init 创建的局部变量
function displayName() { // displayName() 是内部函数,一个闭包
alert(name); // 使用了父函数中声明的变量
}
displayName();
}
init();

init() 创建了一个局部变量 name 和一个名为 displayName() 的函数。displayName() 是定义在 init() 里的内部函数,并且仅在 init() 函数体内可用。请注意,displayName() 没有自己的局部变量。然而,因为它可以访问到外部函数的变量,所以 displayName() 可以使用父函数 init() 中声明的变量 name

运行该代码后发现, displayName() 函数内的 alert() 语句成功显示出了变量 name 的值(该变量在其父函数中声明)。这个词法作用域的例子描述了分析器如何在函数嵌套的情况下解析变量名。词法(lexical)一词指的是,词法作用域根据源代码中声明变量的位置来确定该变量在何处可用。嵌套函数可访问声明于它们外部作用域的变量。

闭包

现在来考虑以下例子 :

1
2
3
4
5
6
7
8
9
10
function makeFunc() {
var name = "Mozilla";
function displayName() {
alert(name);
}
return displayName;
}

var myFunc = makeFunc();
myFunc();

运行这段代码的效果和之前 init() 函数的示例完全一样。其中不同的地方(也是有意思的地方)在于内部函数 displayName() 在执行前,从外部函数返回。

第一眼看上去,也许不能直观地看出这段代码能够正常运行。在一些编程语言中,一个函数中的局部变量仅存在于此函数的执行期间。一旦 makeFunc() 执行完毕,你可能会认为 name 变量将不能再被访问。然而,因为代码仍按预期运行,所以在 JavaScript 中情况显然与此不同。

原因在于,JavaScript中的函数会形成了闭包。

闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。

在本例子中,myFunc 是执行 makeFunc 时创建的 displayName 函数实例的引用。displayName 的实例维持了一个对它的词法环境(变量 name 存在于其中)的引用。因此,当 myFunc 被调用时,变量 name 仍然可用,其值 Mozilla 就被传递到alert中。

闭包原理

本质就是上级作用域内变量的生命周期,因为被下级作用域内引用,而没有被释放。就导致上级作用域内的变量,等到下级作用域执行完以后才正常得到释放。

利用了函数作用域链的特性,一个函数内部定义的函数会将包含外部函数的活动对象添加到它的作用域链中,函数执行完毕,其执行作用域链销毁,但因内部函数的作用域链仍然在引用这个活动对象,所以其活动对象不会被销毁,直到内部函数被烧毁后才被销毁。

优点

  • 可以从内部函数访问外部函数的作用域中的变量,且访问到的变量长期驻扎在内存中,可供之后使用
  • 避免变量污染全局。
  • 把变量存到独立的作用域,作为私有成员存在。

缺点

  • 对内存消耗有负面影响。因内部函数保存了对外部变量的引用,导致无法被垃圾回收,增大内存使用量,所以使用不当会导致内存泄漏
  • 对处理速度具有负面影响。闭包的层级决定了引用的外部变量在查找时经过的作用域链长度
  • 可能获取到意外的值(captured value)。

应用场景

用闭包模拟私有方法

编程语言中,比如 Java,是支持将方法声明为私有的,即它们只能被同一个类中的其它方法所调用。

而 JavaScript 没有这种原生支持,但我们可以使用闭包来模拟私有方法

私有方法不仅仅有利于限制对代码的访问:还提供了管理全局命名空间的强大能力避免非核心的方法弄乱了代码的公共接口部分。

下面的示例展现了如何使用闭包来定义公共函数,并令其可以访问私有函数和变量。这个方式也称为模块模式(module pattern)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var Counter = (function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

在之前的示例中,每个闭包都有它自己的词法环境;而这次我们只创建了一个词法环境,为三个函数所共享:Counter.incrementCounter.decrementCounter.value

该共享环境创建于一个立即执行的匿名函数体内。这个环境中包含两个私有项:名为 privateCounter 的变量和名为 changeBy 的函数。这两项都无法在这个匿名函数外部直接访问。必须通过匿名函数返回的三个公共函数访问。

这三个公共函数是共享同一个环境的闭包。

多亏 JavaScript 的词法作用域,它们都可以访问 privateCounter 变量和 changeBy 函数。

备注:你应该注意到我们定义了一个匿名函数,用于创建一个计数器。我们立即执行了这个匿名函数,并将他的值赋给了变量Counter。我们可以把这个函数储存在另外一个变量makeCounter中,并用他来创建多个计数器。

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
var makeCounter = function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
};

var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */

请注意两个计数器 Counter1Counter2 是如何维护它们各自的独立性的。

每个闭包都是引用自己词法作用域内的变量 privateCounter

每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境。

然而在一个闭包内对变量的修改,不会影响到另外一个闭包中的变量

备注:以这种方式使用闭包,提供了许多与面向对象编程相关的好处 —— 特别是数据隐藏和封装

定义JS模块

  • 具有特定功能的js文件。
  • 将所有的数据和功能都封装在一个函数内部(私有的)。
  • 只向外暴露一个包含n个方法的对象或函数。
  • 模块的使用者, 只需要通过模块暴露的对象调用方法来实现对应的功能。

模块定义:

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
38
//myModule.js
function myModule() {
//私有数据
var msg = 'My atguigu'
//操作数据的函数
function doSomething() {
console.log('doSomething() '+msg.toUpperCase())
}
function doOtherthing () {
console.log('doOtherthing() '+msg.toLowerCase())
}

//向外暴露对象(给外部使用的方法)
return {
doSomething: doSomething,
doOtherthing: doOtherthing
}
}

-----------------------------------------------------------------
// myModule2.js
(function () {
//私有数据
var msg = 'My atguigu'
//操作数据的函数
function doSomething() {
console.log('doSomething() '+msg.toUpperCase())
}
function doOtherthing () {
console.log('doOtherthing() '+msg.toLowerCase())
}

//向外暴露对象(给外部使用的方法)
window.myModule2 = {
doSomething: doSomething,
doOtherthing: doOtherthing
}
})()

模块调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//调用示例
------------ 模块调用1 --------------------------------------------
<script type="text/javascript" src="myModule.js"></script>
<script type="text/javascript">
var module = myModule()
module.doSomething()
module.doOtherthing()
</script>
------------ 模块调用2 --------------------------------------------
<script type="text/javascript" src="myModule2.js"></script>
<script type="text/javascript">
myModule2.doSomething()
myModule2.doOtherthing()
</script>

在循环中创建闭包

在 ECMAScript 2015 引入 let 关键字之前,在循环中有一个常见的闭包创建问题。参考下面的示例:

1
2
3
4
<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>Name: <input type="text" id="name" name="name"></p>
<p>Age: <input type="text" id="age" name="age"></p>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}

function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];

for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}

setupHelp();

数组 helpText 中定义了三个有用的提示信息,每一个都关联于对应的文档中的input 的 ID。通过循环这三项定义,依次为相应input添加了一个 onfocus 事件处理函数,以便显示帮助信息。

运行这段代码后,您会发现它没有达到想要的效果。无论焦点在哪个input上,显示的都是关于年龄的信息。

原因是赋值给 onfocus 的是闭包。这些闭包是由他们的函数定义和在 setupHelp 作用域中捕获的环境所组成的。

这三个闭包在循环中被创建,但他们共享了同一个词法作用域,在这个作用域中存在一个变量item。

这是因为变量item使用var进行声明,由于变量提升,所以具有函数作用域。当onfocus的回调执行时,item.help的值被决定。由于循环在事件触发之前早已执行完毕,变量对象item(被三个闭包所共享)已经指向了helpText的最后一项。

解决这个问题的一种方案是使用更多的闭包:特别是使用前面所述的函数工厂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
return function() {
showHelp(help);
};
}

function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];

for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
}
}

setupHelp();

这段代码可以如我们所期望的那样工作。所有的回调不再共享同一个环境, makeHelpCallback 函数为每一个回调创建一个新的词法环境。在这些环境中,help 指向 helpText 数组中对应的字符串。

另一种方法使用了匿名闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}

function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];

for (var i = 0; i < helpText.length; i++) {
(function() {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
})(); // 马上把当前循环项的item与事件回调相关联起来
}
}

setupHelp();

如果不想使用过多的闭包,你可以用ES2015引入的let关键词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}

function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];

for (var i = 0; i < helpText.length; i++) {
let item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}

setupHelp();

这个例子使用let而不是var,因此每个闭包都绑定了块作用域的变量,这意味着不再需要额外的闭包

另一个可选方案是使用 forEach()来遍历helpText数组并给每一个<p>添加一个监听器,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}

function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];

helpText.forEach(function(text) {
document.getElementById(text.id).onfocus = function() {
showHelp(text.help);
}
});
}

setupHelp();

高阶函数与柯里化

高阶函数是对其他函数进行操作的函数,操作可以是将它们作为参数,或者是返回它们。 简单来说,高阶函数是一个接收函数作为参数或将函数作为输出返回的函数。

下面是一个更有意思的示例 — 一个 makeAdder 函数:

1
2
3
4
5
6
7
8
9
10
11
function makeAdder(x) {
return function(y) {
return x + y;
};
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

在这个示例中,我们定义了 makeAdder(x) 函数,它接受一个参数 x ,并返回一个新的函数。返回的函数接受一个参数 y,并返回x+y的值。

从本质上讲,makeAdder 是一个函数工厂 — 他创建了将指定的值和它的参数相加求和的函数。在上面的示例中,我们使用函数工厂创建了两个新函数 — 一个将其参数和 5 求和,另一个和 10 求和。

add5add10 都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境。

add5 的环境中,x 为 5。而在 add10 中,x 则为 10。

其实可以把它理解成,柯里化后,将第一个参数变量存在函数里面了(闭包),然后本来需要n个参数的函数可以变成只需要剩下的(n - 1个)参数就可以调用,比如

1
2
3
4
5
6
7
8
9
10
11
12
let add = x => y => x + y
let add2 = add(2)
-*----------------------------------
本来完成 add 这个操作,应该是这样调用
let add = (x, y) => x + y
add(2,3)
----------------------------------
1. 而现在 add2 函数完成同样操作只需要一个参数,这在函数式编程中广泛应用。
let add = x => y => x + y
let add2 = add(2)
2.详细解释一下,就是 add2 函数 等价于 有了 x 这个闭包变量的 y => x + y 函数,并且此时 x = 2,所以此时调用
add2(3) === 2 + 3

节流与防抖

throttledebounce 是开发中常用的高阶函数,作用都是为了防止函数被高频调用,换句话说就是,用来控制某个函数在一定时间内执行多少次。

使用场景

比如绑定响应鼠标移动、窗口大小调整、滚屏等事件时,绑定的函数触发的频率会很频繁。若处理函数稍微复杂,需要较多的运算执行时间和资源,往往会出现延迟,甚至导致假死或者卡顿感。为了优化性能,这时就很有必要使用 throttledebounce 了。

throttle与debounce区别

节流(throttle):限制目标函数调用的频率,比如:1s内不能调用2次。

防抖 (debounce) :多次触发,只在最后一次触发时,执行目标函数。

手写一个 throttle

实现方案有以下两种:

  • 第一种是用时间戳来判断是否已到执行时间,记录上次执行的时间戳,然后每次触发事件执行回调,回调中判断当前时间戳距离上次执行时间戳的间隔是否已经达到时间差(Xms) ,如果是则执行,并更新上次执行的时间戳,如此循环。
  • 第二种方法是使用定时器,比如当 scroll 事件刚触发时,打印一个 hello world,然后设置个 1000ms 的定时器,此后每次触发 scroll 事件触发回调,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器。

这里我们采用第一种方案来实现,通过闭包保存一个 previous 变量,每次触发 throttle 函数时判断当前时间和 previous 的时间差,如果这段时间差小于等待时间,那就忽略本次事件触发。如果大于等待时间就把 previous 设置为当前时间并执行函数 fn。

高阶函数,每次触发事件才执行回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait = 50) => {
// 上一次执行 fn 的时间
let previous = 0;
// 将 throttle 处理结果当作函数返回
return function(...args) {
// 获取当前时间,转换成时间戳,单位毫秒
let now = +new Date();
// 将当前时间和上一次执行函数的时间进行对比
// 大于等待时间就把 previous 设置为当前时间并执行函数 fn
if (now - previous > wait) {
previous = now;
fn.apply(this, args);
}
}
}

// DEMO
// 执行 throttle 函数返回新函数
const betterFn = throttle(() => console.log('fn 函数执行了'), 1000)
// 每 10 毫秒执行一次 betterFn 函数,但是只有时间差大于 1000 时才会执行 fn
setInterval(betterFn, 10)

补充

+new Date() 会调用Date.prototype 上面的valueOf方法,根据

下面的例子返回效果等同:

1
2
3
4
+new Date();
new Date().getTime();
new Date().valueOf();
new Date()*1

手写一个 debounce

防抖函数 debounce 指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次

实现原理就是利用定时器,函数第一次执行时设定一个定时器,之后调用时发现已经设定过定时器就清空之前的定时器,并重新设定一个新的定时器,如果存在没有被清空的定时器,当定时器计时结束后触发函数执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fn 是需要防抖处理的函数
// wait 是时间间隔
function debounce(fn, wait = 50) {
// 通过闭包缓存一个定时器 id
let timer = null
// 将 debounce 处理结果当作函数返回
// 触发事件回调时执行这个返回函数
return function(...args) {
// 如果已经设定过定时器就清空上一次的定时器
if (timer) clearTimeout(timer)

// 开始设定一个新的定时器,定时器结束后执行传入的函数 fn
timer = setTimeout(() => {
fn(...args)
}, wait)
}
}

// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000)
// 停止滑动 1 秒后执行函数 () => console.log('fn 防抖执行了')
document.addEventListener('scroll', betterFn)

剩余参数 ...args 允许我们将不确定数量的剩余的元素放到一个数组中。

不过 underscore 中的 debounce 还有第三个参数:immediate 。这个参数是做什么用的呢?

传参 immediate 为 true, debounce会在 wait 时间间隔的开始调用这个函数 。(注:并且在 wait 的时间之内,不会再次调用。)在类似不小心点了提交按钮两下而提交了两次的情况下很有用。

true 传递给 immediate 参数,会让 debouncewait 时间开始计算之前就触发函数(也就是没有任何延时就触发函数),而不是过了 wait 时间才触发函数,而且在 wait 时间内也不会触发(相当于把 fn 的执行锁住)。 如果不小心点了两次提交按钮,第二次提交就会不会执行。

那我们根据 immediate 的值来决定如何执行 fn 。如果是 immediate 的情况下,我们立即执行 fn ,并在 wait 时间内锁住 fn 的执行, wait 时间之后再触发,才会重新执行 fn ,以此类推。

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
// immediate 表示第一次是否立即执行
function debounce(fn, wait = 50, immediate) {
let timer = null;
return function(...args) {
// this保存给context
const context = this;
if (timer) clearTimeout(timer)

// immediate 为 true 表示第一次触发后执行
// timer 为空表示首次触发
if (immediate && !timer) {
fn.apply(context, args);
}

timer = setTimeout(() => {
fn.apply(context, args);
}, wait)
}
}

// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000, true)
// 第一次触发 scroll 执行一次 fn,后续只有在停止滑动 1 秒后才执行函数 fn
document.addEventListener('scroll', betterFn)