作用域和闭包
最近读了 You don’t know js 的Scope & Closures
章节,记录一下学到的。
编译简介
先简单介绍一下一般语言的编译过程:
标记解释/词法分析
把一段字符串的程序解析成几个有意义的部分。比如
var a = 2;
在这一步中会被解释成var
,a
,=
,2
和;
。这两个词的具体意思是什么呢?或者说区别是什么,举个例子,这里决定a
是单独的一个标记,还是作为另外一个标记中的一部分比如说var a
这是个单独的标记。词法分析就是在做这件事情。解析
把上一步做的一系列的标记解析成一个抽象语法树(简称AST)。比如说
var a = 2
,可以被解析为:生成代码
这一步是把 AST 变成可执行代码 。这里先不做详细介绍了,只知道
var a = 2
最后的结果是会创建一个变量a
(申请一个内存空间等等的操作),然后把2这个值存进变量a
。
但是 JS 不同于传统的预编译语言如(C, Java 等),它是一门边运行边编译的语言。比如说这里的var a = 2;
是在它被即可执行前才编译的,而不是在整个程序开始之前。这里是先编译分析var a
的变量声明,再执行a = 2
。
作用域( scope )介绍:
scope 是指一系列规则来决定一个变量在哪里怎么被找到。这个查找过程涉及这两种 LHS ( setter 查询,left-hand-side)、RHS ( getter 查询, right-hand-side)。
先解释下 LHS 和 RHS,对于var a = 2
:
- 首先做 RHS 查询(RHS),
var a
,查询a
的变量,一个 getter 操作。 - 然后
a = 2
,对变量 a 做一个赋值,属于一个 setter 操作。
LHS 和 RHS 的差别在哪里?
比如这段代码:
|
|
执行到console.log( a + b )
的时候,compiler对这里做两个RHS查询,一个a
和一个b
。a
在这里被赋值为 2 了,而b
没有在当前作用域也就是function foo
的{}
里被声明。这时候,JS 会抛出一个b的ReferenceError
错误。
改一下代码(如果在浏览器环境做测试,请刷新下当前 console,下面例子请都这样操作:
|
|
执行到b = a;
的时候,compiler 对这里的 b 做 LHS 操作,同样b也没有在foo
中声明,可是没有报错。继续在 console 里输出 b,会得到 2 的结果。
如果不在 JS 的严格模式下,compiler 会自动对 LHS 查询的变量做创建工作。
在改一下代码:
|
|
输出结果是5;
可以看到,LHS 和 RHS,是会 scope 向上查询的,直到找到了var b
的声明。
继续改:- -!
|
|
输出结果是 NaN!
引出下一个话题Hoisting
(变量提升)。
回到之前聊的 JS 编译过程,这段程序也是先编译var b
, 再执行b = 3
;整个程序编译后是这样的:
|
|
看上面的程序,一个是声明方法,另一个是声明变量。既然编译器不是程序的书写顺序来编译,那么如果我同时有一个变量名和方法名声明都叫 foo,这时候哪个优先级更高呢?
|
|
可以看到变量 foo 是先声明的,可是方法 foo 是被优先编译的。编译后的代码如下:
|
|
顺便提一句,JS里的方法声明和方法变量声明是不一样的
方法变量声明:
|
|
这里编译为:
|
|
方法声明:
|
|
看下一段程序:
|
|
输出结果是 b! 这里涉及到两个问题:
- JS 的块作用域(
{...}
)并没有什么卵用。还是会用 else 里面的声明。 - 方法声明编译的时候会被覆盖。
继续聊下一个话题—块作用域。
|
|
如果 JS 有像 C 语言一样的块级作用域,那么这里的 i 应该会抛出ReferenceError
的错误。可是这里输出了 10,也就是说 JS 的块级作用域是无效的。
那么 JS 是怎么来实现块级作用域呢?答案是 function。比如:
|
|
这里的变量b, c
和function bar
都是对外隐藏的,只在foo
function 的作用域里有效。
既然 function 可以来隔离作用域,那么我们可以轻松实现作用域隔离。
|
|
但是这里又有foo
这个函数名把全局的命名空间给污染了。JS 通常的解决方式是:
|
|
这样既做到了作用域里面的变量没有污染全局,foo
方法也没有污染全局。这种方式叫做立即执行函数表达式(IIFE–Invoking Function Expressions Immediately)
ES6 的 let 与 const
let 与 const 提供了 JS 的块级作用域。比如回到之前的:
|
|
这里的let i
就只在 for 循环里块级作用域中起效。
闭包
闭包的定义:
Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.
简而言之,是指一个方法在它词法作用域外被调用的时候,还能引用到它词法作用域上下文的变量的方法。
举个例子:
|
|
这里的 baz 就是闭包,它在外部被调用的时候还能”认识”变量var a
,而变量a
是在foo
函数里声明的,并且只在foo
函数里有效。
看一个经典的理解闭包的案例:
|
|
这里的输出结果是,每一秒打印一个 6,一共打印了 5 次。
如果是一个刚接触 JS 的人,可能会认为这里应该是1,2,3,4,5。每隔一秒打印一个。
首先来看,为什么是输出 6。setTimeout
函数是在 for 循环结束后执行的(即使setTimeout(.., 0)
),for 循环结束的时候i的值是 6。所以打印了 5 次 6.
回想之前说到的块级作用域,这里的var i
不仅仅在 for 循环块级作用域中有作用,在全局作用域中也是有作用的,如果想要输出 1,2,3,4,5的话,那就让循环里的每个 i 都只在一个单独的作用域里。之前也说到,只有 function 可以有独立的作用域。
所以这里尝试改成这样:
|
|
很遗憾,结果还是一样的,打印了 5 个 6。为什么呢?虽然这里用 IIFE 创建分割了循环的作用域,可是它什么都没有做,因为这是作用域啥都没干,i还是外部作用域变量的那个 i,i 的值没有在这个作用域中被捕获,还是循环后的6。
所以这里尝试捕获一下 i 的在 IIFE 的作用域中:
|
|
成功了!1,2,3,4,5 每隔一秒被打印了。
更优雅些:
|
|
但是说到底,我们的需求很简单,只是想要 for 循环的块级作用域起效果就行了,用闭包来实现作用域显得有些啰嗦。
所以回想一下之前说到的ES6的let
:
|
|
这里每次,用 j 来创建一个有效的块级作用域就可以了。
当然 ES6 对 for 循环里的 let 也做了特殊的行为操作—每次循环的时候都会自动创建一个新的 let i,所以可以像下面这么优雅的写了。
|
|
模块化开发
如果不是很了解前端模块化开发的价值,可以参考一下玉伯的这篇文章—前端模块化开发的价值。
Revealing Module
下面这段是典型的一个 Revealing 模块。
|
|
上面这段模块化的代码做到了什么:
- 如果外部不调用
CoolModule()
方法的话,方法体里面的代码是不会被编译执行的(前面说到过 JS 是边编译边执行的) CoolModule
返回了一个新对象,只有新对象里的属性(闭包方法)能访问CoolModule
这个作用域里的变量(有点像私有属性了)
Modern Modules
现在已经有很多库在这方面都做的不错了,这里介绍个最简单的概念实现:
|
|
这段代码同时包含了模块的导出,和模块的引入功能,看下如何使用它:
|
|
定义bar模块,并且在这个模块里不引入其他模块。
|
|
定义另外 foo 模块,并且在 foo 模块中引入 bar 模块。
|
|
JS 里的模块化,有两点必须要满足:
- 通过一个外部的方法来创建作用域。
- 外部方法返回的对象至少要有一个闭包来捕获方法作用域里的其他内部对象。
一道微博流行的面试题
|
|
.
.
.
答案是:
- 2 — Foo对象的属性方法
4 — 编译后的代码如下
1234567891011121314function Foo() {getName = function () { alert(1); };return this;}function getName() { alert(5);} // 函数声明高于变量声明var getName;getName = function () { alert(4);};Foo.getName = function () { alert(2);};Foo.prototype.getName = function () { alert(3);};getName(); // 41 — Foo()调用过程中 1. 更改了 getName 变量的实现 2. Foo() 返回的对象是全局对象 this。所以 getName() 还是全局对象的调用只不过实现改了。
4、5、6 首先考验的是对 JS 运算优先级的认识,不熟悉可以先参考MDN的资料再考虑答案是什么。
IV. 2 — 点操作优先级高于new
操作符,所以这里new (Foo.getName)()
,也就是 2
V. 3 — 根据操作符优先级,首先翻译为(new Foo()).getName()
,new Foo()
返回一个与 Foo 原型链链接的新对象而不是Foo()
返回的全局对象。这里这个新对象就去找Foo.prototype
上的getName
属性。
VI. 3 — 和上一个一样,只不过是又多了个 new 操作。
动态作用域
之前介绍都是词法作用域,解释了JS在编译期间是如何查找变量的规则。现在看看 JS 运行时候的作用域是怎么定义的,JS 里的 this 就是动态作用域来决定的(之前一篇介绍this的)。
举个例子:
|
|
这里会输出 2 还是 3 呢?
从词法作用域角度来看,编译的时候,是先去找foo
里的 a 变量,找不到,然后去找全局的 a,全局的是2。所以foo()
稳稳的输出 2。
可是,在bar
运行的时候,foo
同样也会去找 a 的值,但是它首先会在bar
的作用域里查找,所以bar
稳稳的输出 3。
所以除了考虑词法作用域(编译时查找),动态作用域也是要考虑的(运行时查找)。