一、作用域
作用域是一套规则,用于确定在何处、如何查找变量。
在理解作用域前,我们要先理解js对变量进行编译处理的过程。
1、变量声明的编译过程
var a = 2;
在这行代码中,js会做以下2件事。
-
声明变量a:查找当前作用域中是否已有同名变量,如有则忽略该声明。同一个作用域内多次用var声明同一个变量名不会报错(let/const声明会)。
-
赋值:如果当前作用域找不到,会顺着作用域链往上层找,找到a后赋值。如果都没找到,就会在最上层的作用域声明变量a后赋值。
function fn() {
// var a = 3 // 声明fn内的局部变量a
a = 3 // 声明全局变量a
}
2、LHS查询和RHS查询
在作用域中查找变量的方式,可分为两种类型:LHS查询和RHS查询。
LHS查询:查找以赋值
RHS查询:查找以使用
function foo(a) {
var b = a
return a + b
}
var c = foo(2)
如以上代码,实际发生了:
-
定义foo函数,还没有执行,不发生查询
-
到第5行,开始执行代码,声明全局变量c,查找c以赋值,1次LHS
-
查找foo函数以执行,1次RHS
-
进入foo内部的函数作用域,对形参a赋值(隐式),1次LHS(容易被忽略)
-
声明局部变量b,查找b以赋值,1次LHS;查找a以使用,1次RHS
-
执行+,查找a和b以使用,2次RHS
3处LHS:c、a(隐式赋值)、b
4处RHS:foo、a、a、b
3、为什么要区分LHS查询和RHS查询?
在变量未声明(所有作用域都无法找到变量)的情况下,两种查询方式进行的行为不同。
var a = 2
console.log(a + b); // 对b的RHS查询,会报错
b = a // 对b的LHS查询,不会报错
如以上代码,对b进行了一次RHS一次LHS,进行RHS查询时,找不到变量,会抛出ReferenceError异常;进行LHS查询时,找不到变量,会创建变量后返回。(非严格模式)
(严格模式下禁止隐式创建变量,因此在LHS查询失败时也会抛出ReferenceError异常)
一般来说,ReferenceError异常表示寻找变量过程中的相关异常,TypeError异常表示找到变量了、但对变量的操作错误(如对非函数类型进行函数调用)
二、词法作用域
1、词法作用域的定义
作用域有2种主要的工作模型,词法作用域(也称为静态作用域)和动态作用域。大部分语言都采用的词法(js也是),也有一些语言使用动态。
词法作用域指定义在词法阶段的作用域。
function foo() {
console.log(a);
}
var a = 2
function foo2() {
var a = 3
foo()
}
foo2() // 2
如以上代码。foo函数在定义时作用域就已经确定了,无论函数在何处、被如何调用,作用域都不会发生改变。因此它执行时作用域链=全局→foo,foo内找不到就会去全局找。除非foo在foo2内定义,否则它不会找到foo2内的a变量。
相对地,动态作用域指的就是运行时才确定的作用域,类似js中的this机制。
2、修改词法作用域
(1)eval()函数
接收一个字符串作为参数,会将其中的内容视为代码执行(不支持es6语法),可用于动态插入代码
function foo(str, a) {
eval(str);
console.log(a, b); // 1. 3
}
foo("var b = 3", 1);
如以上代码,var b = 3会直接在eval的位置执行。没有eval时,b会向上查找全局变量,找不到后返回undefined。但增加了eval代码,foo就会有一个函数作用域内的局部变量b。
在严格模式下,eval会有自己单独的作用域,b会是eval作用域内的变量,eval就不会对作用域产生影响。
(2)with
with是用于快速重复引用同一个对象属性的,它可以将传入的对象处理为隔离的词法作用域。
var obj = {
a: 1,
b: 2,
c: 3,
};
// 会创建一个obj内的词法作用域
with (obj) {
a = 3;
b = 4;
c = 5;
d = 6; // 当对象不存在时,变量会被泄漏到with所处的上级作用域中
}
如以上代码,使用with,可以快速更改obj.a、obj.b、obj.c的值。但由于with创建了一个作用域,在其中修改d时,由于没有obj.d属性,它就会往上层作用域找,导致创建了一个全局变量d。
在严格模式下,是不允许使用with的。
(3)修改作用域的坏处
以上方式,虽然可以更改已确定了的作用域,但会造成性能损耗。
因为在编译阶段,js引擎会对代码做一个简单分析,提前确定所有变量和函数的定义位置,以便在执行时快速查找。修改作用域,这个分析也就不起作用了,会拖慢代码的运行效率。
三、函数作用域和块作用域
1、函数作用域
函数会创建自身的作用域,而作用域是一层一层向上访问的。因此,可以说函数作用域达到了隐藏代码的效果(它能访问外部,外部访问不到它)。这个特点也符合软件设计中的最小暴露原则(最小限度地暴露必要内容)。
除了函数声明以外,也可以使用函数表达式(IIFE)的方式:
// 两种方式都可以
(function foo(){})()
(function foo(){}())
函数表达式的好处在于foo在外部作用域也是访问不到的,同时foo这个名称也是可以省略的。函数声明需要具名,函数表达式可以不具名。
2、块级作用域
块级作用域指用{}包裹起来的代码内的作用域,常见的有if else和for循环内的代码块作用域。使用块级作用域的好处有:
(1)利于垃圾回收机制
function process(data) {}
// let data = { a: 1 };
// process(data);
{
let data = { a: 1 };
process(data);
}
var btn = document.getElementById("btn");
btn.addEventListener("click", function (evt) {
console.log("clicked");
});
如上,执行到事件绑定时,如果不显式地声明块级作用域,js引擎会认为上面声明的变量可能仍会在绑定事件中使用,不会回收。而显式使用块级作用域,往下执行时不能再访问到作用域的内容,引擎就会知道这段代码不需要保留、可以进行回收。
(2)循环时重新绑定
在循环中,当使用let声明变量时,每次循环的i值都是独立的
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // 01234
}, 0);
}
这是因为它的内部执行如下,在每次循环的块级作用域内,都会重新声明一个变量绑定赋值,重新声明的 j 仅在当前次循环的块级作用域内生效,不会对之前或之后的循环产生影响。
{
let i
for (i = 0; i < 5; i++) {
let j = i
setTimeout(() => {
console.log(j); // 01234
}, 0);
}
}
四、声明提升
变量和函数的声明会被提升到当前作用域的最前方最先执行,函数声明又优先于变量声明。let/const和函数表达式不会被提升。
foo(); // 1
var foo; // 重复声明被忽略,用let会报错
function foo() { // 会被提升到最前最先声明
console.log(1);
}
foo = function () { // 赋值操作,foo()执行完才会执行
console.log(2);
};
foo(); // 3
function foo() {
console.log(1);
}
var foo = function () {
console.log(2);
};
function foo() {
console.log(3);
}
// 两个函数声明都会被提升,后面的覆盖前面的,然后才是变量重复声明、赋值
五、作用域闭包
1、什么是闭包
在函数a内声明函数b,将函数b作为返回值,使得b在a以外的作用域被执行时,仍然能够访问a作用域内的定义。闭包的特点是函数的定义和执行不在同一个作用域。
function f1() {
let n = 999;
function f2() {
console.log(n);
}
return f2;
}
const f3 = f1()
f3()
如上代码,可以看到,f2虽然是f1作用域内定义的,却在全局作用域执行了,并访问到了f1作用域内的变量n。
闭包的特征
-
函数的返回值至少包含一个对内部函数的引用;
-
函数被调用(当f1未被调用时,不会创建f1作用域,也就不存在闭包)
闭包的缺点
一般来说,f1执行后,js的垃圾回收机制会认为f1作用域用不到了并进行回收。但由于闭包的影响,虽然f1已经执行了,但f2还有可能被执行,所以f1作用域不会被回收。因此,闭包过多时会占用太多内存,造成内存泄漏。
闭包的应用
闭包在实际开发中应用很多,比如常见的模块,在模块中创建私有函数,模块外创建实例以调用私有函数,就是典型的闭包。还有如对象的私有方法、定时器传参函数,都是闭包的应用。
2、函数柯里化
函数柯里化也是闭包的一个应用,它指只传递给函数一部分参数以调用,函数内部返回另一个函数去处理剩下的参数。
如以下代码,add是一个累加函数,累加数值会以单次传参的形式传入。
function add(a) {
let ret = a;
return function add2(b) {
if (b) {
ret = ret + b;
return add(ret);
} else {
return ret;
}
};
add(1)(2)() // 3
add(1)(2)(3)(4)() // 10
add(1)(1)(1)(1)(1)(1)(1)(1)(1)(1)() // 10