关于 JS 闭包看这一篇就够了
今天看完了《你不知道的Javascript 上卷》的闭包,来总结一下。
1. LHS 和 RHS 查询
LHS (Left-hand Side)
和 RHS (Right-hand Side)
,是在代码执行阶段 JS 引擎操作变量的两种方式,字面理解就是当变量出现在赋值操作左侧时进行LHS
查询,出现在右侧时进行RHS
查询。更准确的来说,LHS
是为了找到变量的容器本身从而可以进行赋值,而RHS
则是获取某个变量的值。
例如:
console.log(a);
其中对a
的引用就是一个RHS
引用,因为这里没有给a
赋任何值,而是获取它的值从而将它传递给console.log
。
a = 2;
显然这里对a
的引用是LHS
引用,因为这里并不需要获取值,只是为了将2
赋值给a
这个变量。
现在我们已经知道在代码执行阶段 JS 引擎操作变量这两种方式,那么这两种方式会如何去找到变量呢?
2. 作用域
❝简单来说,「作用域」 指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。
❞
2.1 作用域分类
作用域包括:
- 「全局作用域」:程序的最外层作用域
- 「函数作用域」:函数定义时会被创建
- 「块级作用域」:
ES6
新增的let
、const
特性
例如:
var name = '夏安'; // 全局作用域 function func() { // var name = '..夏安..'; // 函数作用域 console.log(name); } if (true) { let name = '夏安...'; // 块级作用域 console.log(name); }
2.2 作用域链
但几个作用域进行了嵌套,这边现成了作用域链。
LHS
和RHS
查询都会在当前执行作用域中开始,如果它们没有找到所对应的标识符,就会沿作用域向外层作用域查找,直到抵达全局作用域再停止。
不成功的RHs
引用会导致抛出ReferenceError
。不成功的LHS
引用会导致自动隐式地创建一个全局变量(非严格模式下),或者抛出ReferenceError
异常(严格模式下)。
例如:
function func(b) { console.log(a + b); // 3 console.log(c); // ReferenceError: c is not defined } var a = 1; func(2);
上述栗子中,对b
进行RHS
引用,在func
函数内部作用域中无法找到,但可以在上级作用域(全局作用域)中找到,而c
在整个作用域链中都没有找到,所以抛出了ReferenceError
异常。
2.3 词法作用域
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的「词法作用域」,也可以被叫做 「静态作用域」,另一种则称为「动态作用域」(如Bash
脚本)。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
我们来看下面这个栗子:
function func() { console.log(a); } function func2() { var a = 1; } var a = 2; func(); // 2
在函数func
作用域李没有找到变量a
,向外层全局作用域找,而不会在函数func2
作用域里找。
词法作用域查找只会查找一级标识符,比如a
,b
等,如果代码中引用了obj.name
,词法作用域查找只会试图查找obj
标识符,找到这个变量后,对象属性访问规则会接管对name
属性的访问。
2.4 欺骗词法作用域
Javascript
中有两种机制可以欺骗词法作用域,,分别是eval
和with
,但「欺骗词法作用域会导致性能下降」,所以不建议使用。
下面我们以eval
为例简单介绍一下:
function func(str) { eval(str); console.log(a); } var a = 1; func('var a = 2;'); // 2
eval
的参数var a = 2;
被当作本来就在那里的代码执行,在函数func
作用域里创建了一个变量a
,从而遮蔽了外层全局作用域里的变量a
2.5 块级作用域
什么是块级作用域呢?简单来说,花括号内 {...}
的区域就是块级作用域区域。
很多语言本身都是支持块级作用域的。Javascript
中大部分情况下,只有两种作用域类型:「全局作用域」 和 「函数作用域」。
if (true) { var a = 1; } console.log(a); // 1
运行后会发现,结果还是 1
,花括号内定义并赋值的 a
变量跑到全局了。这足以说明,Javascript
不是原生支持块级作用域的。
但是 ES6
标准提出了使用 let
和 const
代替 var
关键字,来“创建块级作用域”。也就是说,上述代码改成如下方式,块级作用域是有效的:
if (true) { let a = 1; } console.log(a); // ReferenceError
2.6 模块化
作用域的一个常见运用场景之一,就是 「模块化」。由于原生Javascript
不支持模块化,在正式的模块化方案出来之前,开发者为了解决这类问题想到了使用函数作用域来创建模块的方法。
// module1.js (function () { var a = 1; console.log(a); })(); // module2.js (function () { var a = 2; console.log(a); })();
上面的代码中,构建了 module1
和 module2
两个代表模块的不同文件,「立即调用函数表达式(Immediately Invoked Function Expression
简写 IIFE
」),两个函数内分别定义了一个同名变量 a
,由于函数作用域的隔离性质,这两个变量被保存在不同的作用域中(不嵌套),JS 引擎在执行这两个函数时会去不同的作用域中读取,并且外部作用域无法访问到函数内部的 a
变量。这样一来就巧妙地解决了 「全局作用域污染」 和 「变量名冲突」 的问题。并且,由于函数的包裹写法,这种方式看起来封装性好多了。
3. 闭包
3.1 什么是闭包
关于什么是闭包,说法很多:
❝在 JS 忍者秘籍(P90)中对闭包的定义:闭包允许函数访问并操作函数外部的变量。
红宝书上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数。
MDN 对闭包的定义为:一个函数和对其周围状态(「lexical environment,词法环境」)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是「闭包」(「closure」)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
❞
function foo() { var a = 2; function bar() { console.log(a); } return bar; } var baz = foo(); baz(); // 2
函数bar()
的词法作用域能够访问foo()
的内部作用域。然后我们将bar()
函数本身当作一个值类型进行传递。在这个例子中,我们将 bar
所引用的函数对象本身当作返回值。
在foo()
执行后,其返回值(也就是内部的 bar()
函数)赋值给变量baz
并调用 baz()
,实际上只是通过不同的标识符引用调用了内部的函数bar()
。
bar()
显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。在 foo()
执行后,通常会期待foo()
的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo()
的内容不会再被使用,所以很自然地会考虑对其进行回收。而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是bar()
本身在使用。
拜bar()
所声明的位置所赐,它拥有涵盖foo()
内部作用域的闭包,使得该作用域能够一直存活,以供 bar()
在之后任何时间进行引用。
bar()
依然持有对该作用域的引用,而这个引用就叫作闭包。
3.2 闭包的作用
- 保护函数的私有变量不受外部的干扰。形成不销毁的栈内存。
- 保存,把一些函数内的值保存下来。闭包可以实现方法和属性的私有化
3.3 闭包经典使用场景
下面举例一些典型的闭包场景:
3.3.1 return 回一个函数
function foo() { var a = 2; function bar() { console.log(a); } return bar; } var baz = foo(); baz(); // 2
3.3.2 IIFE(自执行函数)
(function (a) { console.log(a); })(1)
3.3.3 循环赋值
for(var i = 0; i<10; i++){ (function(j){ setTimeout(function(){ console.log(j) }, 1000) })(i) }
❝因为存在闭包的原因上面能依次输出1~10,闭包形成了10个互不干扰的私有作用域。将外层的自执行函数去掉后就不存在外部作用域的引用了,输出的结果就是连续的 10。为什么会连续输出10,因为 JS 是单线程的遇到异步的代码不会先执行(会入栈),等到同步的代码执行完
❞i++
到 10时,异步代码才开始执行此时的i=10
输出的都是 10。
3.3.4 回调函数
setTimeout(function(){ console.log(j) }, 1000)
3.3.5 节流防抖
// 节流 function throttle(fn, timeout) { let timer = null return function (...arg) { if(timer) return timer = setTimeout(() => { fn.apply(this, arg) timer = null }, timeout) } } // 防抖 function debounce(fn, timeout){ let timer = null return function(...arg){ clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, arg) }, timeout) } }
3.3.6 柯里化实现
function curry(fn, len = fn.length) { return _curry(fn, len) } function _curry(fn, len, ...arg) { return function (...params) { let _arg = [...arg, ...params] if (_arg.length >= len) { return fn.apply(this, _arg) } else { return _curry.call(this, fn, len, ..._arg) } } } let fn = curry(function (a, b, c, d, e) { console.log(a + b + c + d + e) }) fn(1, 2, 3, 4, 5) // 15 fn(1, 2)(3, 4, 5) fn(1, 2)(3)(4)(5) fn(1)(2)(3)(4)(5)
最后,看下面这道题检验一下自己吧:
var result = []; var a = 3; var total = 0; function foo(a) { for (var i = 0; i < 3; i++) { result[i] = function () { total += i * a; console.log(total); } } } foo(1); result[0](); // 3 result[1](); // 6 result[2](); // 9
参考
- JS 闭包经典使用场景和含闭包必刷题 - 掘金 (juejin.cn)
- 闭包 - JavaScript | MDN (mozilla.org)