作用域和闭包

128 阅读8分钟

一、作用域

作用域是一套规则,用于确定在何处、如何查找变量。

在理解作用域前,我们要先理解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