一套设计好的规则来存储变量, 并在之后能够方便的找到这些变量, 这套规则被称之为作用域
JavaScript
在处理代码时, 会与以下三个角色进行协同工作:
- JavaScript 引擎
从头到尾负责整个
JavaScript
程序的编译以及执行过程
- 编译器
负责语法分析及代码生成, 每个程序源代码在 (引擎) 执行之前, 都会经历以下三个步骤:
- 分词/词法分析 (Tokenizing/Lexing)
这个过程将由字符组成的字符串分解为(对编程语言来说)有意义的代码块, 这些代码块被称为词法单元. 如,
var a = 2;
, 将被分解为var、a、=、2、;
- 语法分析 (Parsing) 这个过程将 词法单元流(数组) 转换成一个由元素逐级嵌套组成的抽象语法树 (AST, Abstract Syntax Tree)
- 代码生成
将
AST
转换为可执行的代码, 简单的说就是有某种方法可以将var a = 2
的AST
转换为一组机器指令, 用来创建一个叫做a
的变量(包括分配内存), 并将一个值储存在a
中.
- 分词/词法分析 (Tokenizing/Lexing)
这个过程将由字符组成的字符串分解为(对编程语言来说)有意义的代码块, 这些代码块被称为词法单元. 如,
- 作用域 负责收集并维护所有声明的标识符 (变量) 组成的一系列查询.
我们来看这段程序,
JavaScript
是如何处理的:
var a = 2;
引擎会认为这是两个完全不同的声明, 一个 var a
由编译器在编译时处理, 一个 a = 2
由引擎在运行(执行)时处理:
- 编译器首先会将这段程序分解为
词法单元
, 然后将词法单元解析成抽象语法树(AST)
; - 编译过程中(三个步骤), 遇到
var a
, 编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中, 有, 则忽略声明, 继续编译, 没有, 则声明一个新的变量 (此时变量在执行代码之前已经存在, 这也是接下来要讲的变量提升的原理); - 到编译的第三部(代码生成), 编译器为引擎生成了运行时所需的代码, 这些代码用来处理
a = 2
这个赋值操作; 引擎会询问作用域是否存在a
变量, 如果存在, 则将值赋值给它, 否则抛出异常.
我们知道, 当编译器完成了编译, 并返回给引擎代码后, 引擎需要协同作用域, 对生成的变量进行查询和赋值;
如果目的是对变量进行赋值, 引擎使用 LHS
查询;
如果目的是获取变量的值, 引擎使用 RHS
查询;
如, var a = 2
, var a
会在编译过程中声明, a = 2
是赋值操作, 我们需要为 = 2
找到一个目标, 所以使用了 LHS
查询;
如, console.log(a)
, 在这里我们向作用域询问 a
的值, 所以使用了 RHS
查询.
作用域是根据名称查找变量的一套规则, 实际情况中, 通常需要同时顾及几个作用域
function foo(a) {
console.log(a + b)
}
var b = 2
foo(2) // 4
foo(2)
传参的操作, 实际上为参数a
进行了隐式的赋值操作a = 2
- 变量
a
存在于foo
的函数作用域中, 外部无法访问 foo
函数中引用了b
变量, 引擎会先尝试在foo
函数作用域中查找(RHS)该变量, 如果找不到, 就会往上一级作用域查找, 以此类推, 直到找到为止, 而这里的b
变量, 存在于 全局作用域 中.
根据以上示例, 我们可以把作用域比作一个建筑, 这个建筑代表程序中的嵌套作用域链, 第一层代表当前的执行作用域, 建筑的顶层代表全局作用域
LHS 和 RHS 引用都会在当前楼层进行查找, 如果没找到, 就会坐电梯上一层, 以此类推, 最后到达顶楼(全局作用域), 无论程序是否已经找到你所需的变量, 都为到此为止.
ReferenceError
同作用域判别失败相关
> const a = 1
> b
< Uncaught ReferenceError: b is not defined
TypeError
则代表作用域判别成功了, 但是对结果的操作是非法或不合理的:
- 引用 null 或 undefined 类型值中的属性
> null.a
< Uncaught TypeError: Cannot read property 'a' of null
- 对一个非函数类型的值进行函数调用
> const a = 1
> a()
< Uncaught TypeError: a is not a function
词法, 指的书写代码的阶段, 就是说, 你写代码的时候将变量和块作用域写在哪里, 将决定 词法作用域
function foo(a) {
var b = a * 2
function bar(c) {
console.log(a, b, c)
}
bar(b * 3)
}
foo(2) // 2, 4, 12
在多层的嵌套作用域中可以定义同名的标识符, 这叫"遮蔽效应"(内部的标识符"遮蔽"了外部的标识符)
var a = 1
function foo() {
var a = 2
console.log(a)
}
foo() // 2
console.log(a) // 1
我们再来看一个容易混淆的例子:
var a = 1
function foo() {
console.log(a)
}
function bar() {
var a = 2
foo()
}
bar()
这个例子, 你可能以为最终会输出 2
, 因为在 bar
函数内部我们又声明了一次 var a = 2
, 但事实上输出的是 1
;
我们要牢牢记住, 词法作用域只关注函数在何处被声明, 而不是在何处被调用, 以上例子, foo
函数位于全局作用域被声明, 所以它对 a
变量的 RHS 查找当然是在 全局作用域 中.
理解欺骗词法很简单, 即我们定义作用域时, 并不是通过书写代码的阶段定义的, 而是在运行的阶段定义, 如下示例:
function foo(str, a) {
eval(str)
console.log(a + b)
}
foo('var b = 2', 3) // 5
该示例, foo
函数内部作用域中在书写代码时没有定义变量 b
, 而是在运行过程中, 执行了 eval
函数, 通过解析传入字符串 var b = 2
得到了这一变量
函数内部的全部变量都可以在整个函数范围内使用和复用, 函数外部无法访问到这些变量
function bar() {
var a = 1
function foo(b) {
console.log(a + b)
}
foo(3)
}
bar() // 4
// 外部访问这些变量, 会抛出异常
> console.log(a)
< ReferenceError: a is not defined
> foo(3)
< ReferenceError: b is not defined
ES6 以前, Javascript 并不存在块作用域
for (var i = 0; i < 10; i ++) {
console.log(i)
}
> window.i
< 10
我们预期只想在循环中使用变量 i
, 但它却被声明为一个全局变量
if (true) {
var a = 1
}
> window.a
< 1
同样的情况也出现在 if
代码块中
ES5 以下如何实现块作用域?
我们知道, ES3 的 try/catch
的 catch
语句中会形成一个天然的 块作用域, 但这样的写法不仅丑陋, 而且让人难以理解:
try { throw undefined } catch(a) {
a = 2
console.log(a) // 2
}
为了解决这个问题, ES6 引入了
let/const
关键字, 他们可以将变量绑定到所在的任意作用域中, 通常是{...}
内部, 换句话说,let
为其声明的变量隐式劫持了所在的块作用域
// for 循环头部的 let 不仅将 i 绑定到了 for 循环中, 事实上它将其重新绑定到了循环的每一个迭代中, 确保使用上一个循环迭代结束时的值重新进行赋值
for (let i = 0; i < 10; i ++) {
console.log(i)
}
> window.i
< ReferenceError: i is not defined
if (true) {
const a = 1
}
> window.a
< ReferenceError: a is not defined
{
const a = 1
const b = 2
}
let c = a + b
< ReferenceError: a is not defined
我们都认为,
Javascript
会从上到下一行一行地执行, 但实际上并不完全正确
看以下两个示例:
a = 1
console.log(a)
var a
< 1
console.log(a)
var a = 1
< undefined
为何第一个示例会出现如此匪夷所思的结果?
当我们在代码中看到 var a = 1
时, Javascript
实际上会将其看成两个声明 var a
; a = 1
;
-
var a
是在编译阶段进行的; -
a = 1
赋值声明会在原地等待执行阶段, 才进行处理
所以, 就算 var a
放在了代码最后, 它在浏览器解析过程中, 仍会比赋值或其他运行逻辑快一步, 被放在了代码的最前面, 也就是变量提升, 所以第一个示例输出了正确的值;(无论作用域中的声明出现在什么地方, 都将在代码本身被执行前首先进行处理)
而第二个示例, 虽然 var a
被提升了, 但查询 a
比赋值 a
早了一步, 所以输出是 undefined
.
同样的, 函数声明也会被提升.
foo()
function foo() {
console.log(1)
}
< 1
结合前面变量提升的知识, 我们可以看到, 通过 var
声明的函数, 同样适用变量提升的原则:
foo()
var foo = function() {
console.log(1)
}
< TypeError
但为什么是
TypeError
而不是ReferenceError
?
var foo
因为变量提升, 已经被分配到了其所在的全局作用域, 作用域中已经存在foo
变量了, 所以不会导致ReferenceError
;- 但执行
foo()
的时候, 赋值操作并未执行, 实际上我们执行的是一个undefined
, 浏览器当然会抛出TypeError
函数声明和变量声明都会被提升, 但函数会首先被提升, 然后才是变量
foo()
var foo
foo = function() {
console.log('Var')
}
function foo() {
console.log('Function')
}
< Function
当然, 后面的函数声明是可以覆盖前面的:
foo()
function foo() {
console.log(1)
}
function foo() {
console.log(2)
}
< 2
当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行(你不知道的 JavaScript 上)
闭包就是能够读取其他函数内部变量的函数(百度百科)
我们知道, 函数作用域中的变量, 我们在外部是无法访问到的, 如以下示例, 常规手段, 我们永远无法在外部访问 foo
函数内部的计算结果(res 变量)
function foo() {
const res = 1 + 1
}
console.log(res)
< ReferenceError
但我们知道, 通过回调函数可以做到这一点:
function foo(callback) {
const res = 1 + 1
callback(res)
}
foo(function(res) {
result = res
})
console.log(result)
// 还记得变量提升么?
var result
< 2
当执行函数 foo
时, 实际上我们隐式为 callback
参数赋值了一个方法, 该方法不属于 foo
函数的作用域, 但它却能访问到 foo
函数内部的变量, 所以这就是一个闭包!
我们知道 Javascript
存在垃圾回收机制, 原本 foo
函数执行完毕后, 整个内部作用域应该被销毁掉, 但由于 callback
函数拥有涵盖 foo
函数内部作用域的闭包, 所以内部作用域被神奇的保存了下来, 并为 callback
所用
- 分配给全局变量
var fn
function foo() {
var a = 1
function bar() {
console.log(a)
}
fn = bar
}
foo()
fn() // 1
- 通过内部返回函数方法
function foo() {
var a = 2
function bar() {
console.log(a)
}
return bar
}
var baz = foo()
baz() // 2
- 回调函数
function foo(callback) {
var a = 3
callback(a)
}
foo(function (p) {
console.log(p)
}) // 3
看如下示例代码:
for (var i = 0; i <= 5; i++) {
setTimeout(function() {
console.log(i)
}, i * 1000)
}
正常情况下, 我们对这段代码行为的预期是分别输出 1~5, 每秒一次, 每次一个;
但实际上这段代码在运行时会以每秒一次的频率输出五次6;
是什么导致了如此它的行为同语义暗示的不一致呢?
- 首先, 我们知道,
var
的声明下,for
循环没有自己的块作用域, 也就是说,i
位于全局作用域中, 整段程序只有唯一一个i
; setTimeout
作为异步函数, 在程序执行过程中, 会被推到任务队列中, 等待所有同步函数执行完毕后再执行, 所以, 当for
完成循环后, 全局变量i
已经变成了 6, 自然在执行异步函数时输出的都是6了.
如何使用闭包解决这个问题?
LIFE
函数会通过声明立即执行一个函数来创建函数作用域, 通过这个特性, 我们可以在每次循环时, 将当前状态的 i
传递到 LIFE
函数中, 在内部为 setTimeout
创建一个新的作用域;
以下代码能够获得我们预期的结果:
for (var i = 0; i <= 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j)
}, j * 1000)
})(i)
}
有更简单的解决方案么?
答案是肯定的! 前面我们说到过 let
声明, 它可以用来劫持块作用域;
for 循环头部的 let 不仅将 i 绑定到了 for 循环中, 事实上它将其重新绑定到了循环的每一个迭代中, 确保使用上一个循环迭代结束时的值重新进行赋值;
以下代码能够获得我们预期的结果:
for (var i = 0; i <= 5; i++) {
// i 被劫持了, 现在每次循环都形成一个封闭的块作用域
let j = i
setTimeout(function() {
console.log(j)
}, j * 1000)
}
我们平时所使用的第三方框架, 为了使自身的变量不会污染到全局中, 都会将所有方法封闭到一个内部私有的作用域中,
LIFE
函数就能提供这样的一个作用域, 来实现框架的模块化
var _ = (function() {
var a = 1
var b = 2
function add() {
console.log(a + b)
}
function decrease() {
console.log(a - b)
}
return {
add: add,
decrease: decrease
}
}())
_.add() // 3
_.decrease() // -1
这也是一个典型的闭包, _.add()
在全局作用域下执行, 但它却能够访问到 LIFE
函数内部的变量.
作用域嵌套
我们可以把作用域比作一个建筑, 这个建筑代表程序中的嵌套作用域链, 第一层代表当前的执行作用域, 建筑的顶层代表全局作用域;
LHS 和 RHS 引用都会在当前楼层进行查找, 如果没找到, 就会坐电梯上一层, 以此类推, 最后到达顶楼(全局作用域), 无论程序是否已经找到你所需的变量, 都为到此为止.
作用域异常
ReferenceError
: 作用域判别失败时抛出, 如查询一个不存在的变量
TypeError
: 作用域判别成功了, 但对结果的操作非法, 如将一个字符串变量当做函数来执行
词法作用域
当我们书写代码的时候, 就已经决定了函数的作用域在哪里, 而不是在我们执行函数之后
函数作用域
函数内部的作用域是私有的, 外部无法访问到它们
块作用域
ES6 之前, 并不存在生成 块作用域 的直接方法, 我们使用 try/catch
来代替;
ES6 之后, 有了 let/const
, 这两个关键字事实上并没有生成真正的块作用域, 而是隐式的劫持了关键字所在的快作用域
变量提升
var a = 1
, 会被引擎解析为 var a;a = 1
两条语句, 而 var a
会在编译时执行, 而 a = 1
则会在执行阶段才被处理, 这就造成了变量的提升;
同样, 函数声明也会被提升, 它的优先级甚至还高于变量;
闭包
我们知道, 函数作用域 是不能被外界所访问到的, 但我们可以通过回调函数 或 全局变量 等方法, 在函数外部访问到函数内部的变量, 这就是闭包;
我们经常使用的 $.ajax
, 就是典型的闭包, 甚至整个 jQuery
包或其他第三方包的编写, 为了避免污染全局变量, 都会使用 LIFE
函数创建一个闭包, 并返回包含插件所有方法的 $
(每个框架变量名不同) 对象.