作用域与提升
一、理解作用域
JS引擎在代码执行前会对其进行编译,这个过程中需要收集并维护代码中存在的所有变量,并实施一套规则来确定当前执行的代码对变量的访问权限,这套规则就是作用域,用于确定在何处以及如何查找变量
声明
变量声明:以
var a = 2
为例,会分为两个步骤:var a
:编译阶段,在作用域中声明一个变量并命名为a
a = 2
:执行阶段,在作用域内查找变量a
,找到的话对其赋值
函数声明:以
function foo(){...}
为例,并不会有线程专门用来将一个函数值分配给foo
,编译器会在代码生成的同时处理声明和值的定义
作用域嵌套
实际情况中需要同时顾及多个作用域。当一个块(或函数)嵌套在另一个块(或函数)中时,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎会在外层嵌套的作用域中继续查找,直到找到该变量或者抵达最外层(也就是全局)作用域为止
父级作用域的同名变量无法访问,但全局变量会自动成为全局对象(例如浏览器中的
window
)的属性,因此可以通过window.a
这种形式访问同名全局变量
异常情况
在作用域内未找到变量
- 查找变量的目的是为了使用,例如
console.log(a)
,会报错ReferenceError
- 查找变量的目的是为了赋值,例如
a = 2
,会自动隐式地创建一个全局变量,然后赋值(严格模式下依旧会报错)
- 查找变量的目的是为了使用,例如
在作用域内找到变量,但未正确使用,例如对字符串进行函数调用,则报错
TypeError
二、词法作用域
作用域主要有两种工作模型,一种是大多数编程语言使用的最普遍的词法作用域,另一种叫动态作用域
什么是词法作用域
词法作用域这个概念的来源是编译器的第一个工作阶段词法化(检查和解析源代码的字符),作用域是由书写代码时变量声明的位置来决定的
修改词法作用域
eval
、setTimeout
、setInterval
与new Function
可以根据传入的字符串执行代码,若其中包含有变量声明,则会修改词法作用域环境,严格模式下这种形式的调用会生成一个独立的作用域,无法影响其他作用域with
可以将一个对象处理为一个完全隔离的词法作用域,但是这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到with
所处的作用域中,严格模式禁用with
引擎在对编译阶段的性能优化依赖于作用域的静态分析,预先确定变量的位置才能更快地查找和使用。但出现
eval
等可能会影响作用域的代码后,由于参数是动态的,引擎无法判断最终的作用域状态,所以会影响性能
三、函数作用域与块作用域
函数作用域
函数是 JavaScript 中最常见的作用域单元,声明在一个函数内部的变量无法在外部访问。
首先我们要区分函数声明和表达式,最简单的方法是看 function
关键字出现在声明中的位置,如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
两者最重要的区别是它们的名称标识符将会绑定在何处,函数表达式的变量名会隐藏在自身,外部无法访问,如下几个示例都是函数表达式
let foo = function () {};
(function bar() {...})(); // bar无法在全局中访问到
setTimeout(function baz() {}, 1000);
判断
function
的位置看的不仅仅是一行代码,而是整个声明范围,所以回调函数也是函数表达式
块作用域
代码块(通常指{...}
)内的作用域被称为块作用域,块作用域的存在可以更好地避免变量污染以及利于垃圾回收,然而表面上看 JavaScript 并没有块作用域的相关功能,在其中通过 var
声明变量会泄漏到外层,除非我们深入了解
if (true) {
var a = 1;
}
console.log(a); // 1
with()
该作用域中如果对正确的对象属性进行访问,是和外部作用域分隔开的
try/catch
catch
分句会创建一个块作用域,其中的变量 err
只在 catch
内有效
try {
undefined();
} catch (err) {
console.log('err:' + err); // 此处可以正常访问到 err
}
console.log(err); // ReferenceError: err is not defined
虽然这种形式的代码十分丑陋,但事实上这是很多工具都在使用的代码转换方式,使得块作用域能在更早的环境中兼容
ES6
ES6之后的版本中,引入的 let
和 const
关键字,真正实现了块作用域
if (true) {
let a = 1;
}
console.log(a); // ReferenceError: a is not defined
四、提升
变量声明提升
引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来,也就是说,包括变量和函数在内的所有声明都会在代码执行前进行处理
console.log(a); // undefined
var a = 2;
上述代码之所以没有报错,是因为它在编译阶段被处理为如下逻辑
var a;
console.log(a); // undefined
a = 2;
声明和赋值被分开,并且声明被移动到了最上面,这个过程就叫提升
函数声明提升
函数声明是不会被拆分为声明和赋值两个过程的,会一起提升,所以函数在声明前就可以调用
foo(); // foo
function foo() {
console.log('foo');
}
函数表达式不会被提升,即使具名也不提升
bar(); // ReferenceError: bar is not defined
foo(); // TypeError: foo is not a function
var foo = function bar() {
console.log('foo');
};
提升的优先级
对同一个变量名重复声明时,函数声明优先级更高,会忽略变量声明
foo(); // 2 第一行代码运行时,忽略了 var foo 的声明,foo 被优先提升为第二个函数声明
var foo = function () {
console.log('1');
};
function foo() {
console.log('2');
}
foo(); // 1 第二行代码中 foo 被重新赋值为第一个函数表达式,因此此处输出 1
同样都是函数声明的话,后面的优先级更高,会覆盖之前的声明
foo(); // 2
function foo() {
console.log('1');
}
function foo() {
console.log('2');
}
foo(); // 2
声明提升的终点
使用
var
的声明会无视块级作用域提升到当前作用域jsconsole.log(a); // undefined if (true) { console.log(a); // undefined var a = 1; } console.log(a); // 1
没有关键字的默认声明不会在编译阶段提升,而是在执行代码时提升,提升的终点与
var
相同jsconsole.log(a); // ReferenceError: a is not defined if (true) { console.log(a); // ReferenceError: a is not defined a = 1; } console.log(a); // 1
函数声明在块级作用域内的表现比较怪异,个人理解可以还原为如下过程:
编译阶段在块级作用域内外分别提升一次,块级作用域外的声明初始值是
undefined
,块内变量初始值为一个函数无论在块内对变量做任何更改都不会影响到块外的同名变量,只有在执行函数声明那一行代码后,会复制当前块内作用域的变量值给块外变量
jsconsole.log(window.a, a); // undefined,undefined if (true) { console.log(window.a, a); // undefined [Function: a] a = 50; console.log(window.a, a); // undefined 50 function a() {} console.log(window.a, a); // 50 50 a = 100; console.log(window.a, a); // 50 100 } console.log(window.a, a); // 50 50
应当尽量避免在块级作用域内进行函数声明
五、闭包
什么是闭包
一般情况下函数内部声明的变量是无法在外部被访问到的,首先查看一段经典的示例代码
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2
上例中,函数 baz
被定义在函数 foo
的作用域外,但却能访问到 foo
内部的变量,这就是闭包。无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
一般情况下函数被执行后,垃圾回收机制就会将该函数内部的作用域销毁以释放内存空间,而闭包的存在会阻止垃圾回收,使得上例中的 foo
函数的内部作用域依旧存在。
闭包的场景
闭包是基于词法作用域书写代码产生的自然结果,不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在代码中随处可见
回调函数
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 100);
}
wait('Hello, closure!');
在函数中使用 setTimeout
传入一个回调函数是很常见的场景,这也是闭包的一种,内部函数 timer
在执行的时候,已经不处于定义它的 wait
函数内了,但 timer
依旧保持了对 wait
内部作用域的引用,可以访问到变量 message
,由此可知,只要使用了回调函数,实际上就是在使用闭包。
循环
for
循环是最常见的闭包示例,对于以下代码,我们期望的结果是输出 1-5 ,但实际上会输出五次 6
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, 0);
}
仔细想想这个结果是显而易见的,回调函数是在循环结束后才执行,所以输出的结果是变量 i
最终的值
之所以会期望输出 1-5 是因为我们会错误地假设:回调函数每次循环迭代在运行的时候都会捕获一次 i
的副本,让后续 i
的变化影响不到回调函数中的副本变量
但实际情况是回调函数引用的 i
始终是同一个变量,循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有
那到底如何才能达到我们假设的情况呢,这要具备两个条件:独立作用域、副本变量
for (var i = 1; i <= 5; i++) {
(function () { // 使用一个函数包裹起来形成独立作用域
var j = i; // 拷贝当前循环中 i 的值
setTimeout(function timer() {
console.log(j);
}, 0);
})();
}
上述代码还可以进行优化,使用形参来拷贝变量
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j);
}, 0);
})(i);
}
let
声明的变量天然就具备我们设想的这两点,每次循环 let
都会创建独立作用域并且在其中声明变量
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, 0);
}
模块
例如 jQuery 中的 $
标识符,可以通过 $
调用模块的函数方法,这也是闭包的应用方式
模块具有以下特点:
- 为创建内部作用域而调用了一个包装函数
- 包装函数会将内部函数返回,这样就形成了一个闭包
六、动态作用域
首先查看一段示例代码:
var a = 1;
function foo() {
console.log(a);
}
function bar() {
var a = 2;
foo();
}
bar();
JS只有词法作用域,词法作用域在代码的书写阶段就已经决定,因此无论在何处调用,foo
函数的调用结果都是输出 1
假设JS具有动态作用域,理论上来说上面的例子就该输出 2
根本区别在于词法作用域只关注函数在何处声明,而动态作用域关注函数在何处调用