理清JS作用域 🏍🛩 🚗

1,571 阅读10分钟

词法作用域 和 动态作用域

我们常说js中作用域(大部分情况)在定义的时候就确定了,而this指向在运行的时候才能确定,理解这句话需要搞清楚编译阶段和运行阶段到底在干啥。

词法分析:这个过程浏览器会把我们写好的代码处理成对计算机来说有意义的词法单元,例如 var a = 1 通常会被分解成以下词法单元 var、a、=、1;

语法分析:这个过程浏览器将词法单元集合转换成语法结构树,也就是抽象语法树AST;

代码生成:这个过程浏览器将AST转换成可执行代码;

通过上面三个阶段,浏览器已经可以运行我们得到的可执行代码了,这整个过程就是编译阶段,后面对可执行代码的运行就是运行阶段

在编程语言中作用域分为两种词法作用域动态作用域,来看这两种作用域有啥子区别。

词法作用域:词法作用域根据编码结构确定作用域,作用域在编译的词法分析阶段就确定了,在运行阶段不再改变。

动态作用域:动态作用域在运行阶段确定,也就是说动态作用域会根据不同的代码运行上下文发生变化。

js中作用域采用了词法作用域机制,但同时提供了一些方法可以动态改变作用域,这也是前文说 “js中作用域(大部分)情况在定义的时候就确定” 的原因,虽然js有动态改变作用域的能力,但这并不代表js具有动态作用域的机制,在语言特性上js就是静态作用域。

有些文章中提到js可以动态改变作用域就认为拥有动态作用域机制,笔者认为这是不严谨的。

通过一段DEMO理解静态作用域和动态作用域到底有什么区别

var a = 2
function foo() {
  console.log(a)
}
function bar() {
  var a = 3
  foo()
}
bar() // 2

deom.js
bar 调用,bar 里面 foo 被调用,foo 函数需要查找变量 a,由于js是词法作用域(即静态作用域),在编译阶段 foo 中没有找到变量 a,根据作用域查找规则找到外层的 a=2,整个过程和运行无关。

deom.bash
常见的动态作用域语言比如bash,在bash中 bar 调用,bar 里面 foo 被调用,foo 函数需要查找变量 a,由于bash是动态作用域,在运行阶段查找到了 bar() 函数中的 a=3,整个过程和编译无关。

js中的词法作用域

function作用域

js中的作用域首先会想到function,一个function就是一个作用域空间也叫局部作用域,局部作用域内的变量不允许在外部访问,function很容易形成嵌套结构当嵌套之后在当前作用域中无法找到某个变量时,编译引擎就会在外层嵌套的作用域中继续查找(也就是父级作用域特别注意是定义时的父级作用域并非运行时),直到找到该变量,或抵达最外层的作用域也就是全局作用域为止。这个逐层向上的查找机制就是作用域链的查找规则。

当抵达最外层作用域且没有找到该变量时,如果是赋值操作(LHS)严格模式会抛出异常,非严格模式会在最外层(全局)定义该变量,这也是为什么在js中不建议使用未声明变量的原因;如果是取值操作(RHS)不管是不是严格模式都会直接抛出异常。

赋值操作(LHS Left-hand Side):函数定义、函数传参、变量赋值 取值操作(RHS Right-hand Side):函数调用、变量取值

var a = 0, b = 0, c = 0, d = 0;
function fun1(){
  var a = 1, b = 1, c = 1;
  function fun2(){
    var a = 2, b =2
    function fun3(){
      var a = 3
      console.log(a, b, c, d) // 3 2 1 0
    }
    fun3()
  }
  fun2()
}
fun1()

看一下最里面的 console 对 a, b, c, d 标识符是如何进行查找的
a:fun3
b:fun3 -> fun2
c:fun3 -> fun2 -> fun1
d:fun3 -> fun2 -> fun1 -> global

块级作用域

ES6 提供的 let、const 关键字声明的变量都会固定于块(作用域空间)中,一对花括号 {} 可以形成一个作用域空间,在一个作用域空间中通过 let、const 声明的变量在该作用越外是不可见的,我们称之为块级作用域。块级作用域对 var function 关键字声明的标识符并不生效。

常见的 function、if、for 、try/catch 都会生成一个作用域空间,特别要注意的是 for 的小括号也是一个独立的作用域空间。

for(var a = 0; a < 2; a++){}
console.log(a)  // 2

for(let b = 0; b < 2; b++){}
console.log(b)  // err: b is not defined

if(true){
  function fun(){}
  var c = 1
  let d = 1
  const e = 1
}
console.log(fun) // function fun(){}
console.log(c)   // 1
console.log(d)   // err: d is not defined
console.log(e)   // err: e is not defined

特别要注意的是 let、const 不允许重复声明,如果在同一个作用域空间声明重复的标识符会抛出异常,var 和 function 则不受此规则限制。

var a = 1; let a = 1      // err
let a = 1; var a = 1      // err
const a = 1; var a = 1    // err
const a = 1; let a = 1    // err
let a = 1; functino a(){} // err
var a = 1; var a = 2      // ok
var a = 1; function a(){} // ok

既然 var 和 function 可以重复声明,那么 let a = 1; functino a(){}let a = 1; var a = 1 不应该报错呀,let 在前 var 在后 let 运行的时候 a 还没有被定义,真的没有被定义吗?当然不是,var 关节字是会提升的,具体细节后面讲。

动态改变作用域

一般说来词法作用域在代码编译阶段就已经确定,这种机制的好处是在代码运行的过程中能够预知变量查找规则,提高代码运行效率提升性能。但是js也允许动态改变作用域,比如 eval() 和 with 关键字。

eval():eval() 函数计算 JavaScript 字符串,并把它作为脚本代码来执行。

var a = 0
function foo(str, b){
  eval(str)
  console.log(a, b)
}
foo('var a = 1', 2) // 1, 2
foo('var a = 6', 7) // 6, 7

// 严格模式下 `eval()` 会产生自己的作用域无法修改作用域
function foo(str){
  'use strict';
  eval(str);
  console.log(a);
}
foo('var a =2'); // err: a is not defined

with:with 语句用于设置代码在特定对象中的作用域。

var obj = {a: 1, b: 2}
with (obj) {
  a = 'is_a',
  b = 'is_b'
  c = 'is_c'
}
console.log(obj, c)  // {a: 'is_a', b: 'is_b'}, 'is_c'

with 常用于对一个object的属性以及方法快速引用,当在对象中没有找到对应方法时会泄露到全局比如上文的 c 。

with 和 eval 实际上根据运行逻辑在运行阶段临时创建了一个全新的词法作用域。

变量提升

在编译阶段要确定作用域首先要做的就是找到所有变量的声明,并利用相关机制将它们关联起来(词法作用域确定的本质),这样就需要把相关变量提前声明,js 中 var、let、const、function 都可以声明关键字他们各自的提升逻辑各有差异,这是变量提升的原因。下面来看变量提升的特点:

  • 通过var定义的变量提升,而let和const进行的声明不会提升。
  • 通过function关键字定义的函数会被提升,而函数表达式则不会提升。
  • var声明本身会被提升,但包括函数表达式在内的赋值操作并不会提升
  • 每个作用域都会进行提升操作,声明会被提升到所在作用域的顶部
  • 函数function关键字提升的优先级要高于变量var关键字。
  • 如果变量或函数有重复声明会以最后一次声明为主。
console.log(a) // undefined
console.log(b) // err: b is not defined
console.log(c) // err: Cannot access 'c' before initialization
var a = 1
let b = 2
const c = 3
/*
 通过var定义的变量会提升,而let和const进行的声明不会提升。
 var声明本身会被提升,但包括函数表达式在内的赋值操作并不会提升。
*/

console.log(d) // undefined 
console.log(e) // ƒ e(){}
var d = function(){} 
function e(){}
/* 
 通过function关键字定义的函数会被提升,而函数表达式则不会提升。
*/

var f = 1
function fun(){
  console.log(f) // undefined
  var f = 2
}
fun()
console.log(f) // 1
/* 
  每个作用域都会进行提升操作,声明会被提升到所在作用域的顶部。
*/

console.log(g) // function g(){}
var g = 1
function g(){}
console.log(g) // 1

console.log(g) // function g(){}
function g(){}
var g = 1
console.log(g) // 1
/* 
 函数function关键字提升的优先级要高于变量var关键字。   
 所以第一个console.log(g)打印出了g()方法,当代码继续向下运行遇到function g(){},g()函数已经声明过了不做任何处理,而是被 g=1 的赋值操作给覆盖,所以后面一个console.log(g)打印出了1。
*/

function h(){'1'}
function h(){'2'}
var i = 1
var i = 2
console.log(h, i)  // function h(){'2'}, 2
/* 
  如果变量或函数有重复声明会以最后一次声明为主。
*/

上面说过 let、const 不允许重复声明,var、function 则允许重复声明,现在明白了变量提升回过头看一下var的重复声明到底是怎么事儿。

var a = 1
var a = 2

等价于

var a;
a = 1
a = 2

var 之所以可以重复声明是因为在编译阶段做了变量提升,到了运行阶段只是对a的重复赋值操作 ~

当提升遇到标识符重复的情况会依照以下逻辑处理:

  • 编译阶段 var 关节字提升发现标识符重名不做任何操作,放弃本次提升。
  • 编译阶段 function 关节字提升发现标识符重名会覆盖已有的标识符(function提升优先级高于var)。

作用域链

上面已经说过作用域链就是逐层向外的查找机制,直到全局,通过就近原则拿到标识符。但是一堆函数嵌套浏览器是怎么把他们的嵌套关系梳理清楚的?这个工作又是在哪个阶段完成的?

js的函数可以理解是一个对象,是 Function 对象的一个实例,Function 对象和其他对象一样拥有属性,比如 fun.name、fun.length 分别表示函数的名字和参数长度,name 和 length 是可访问属性,除此之外还有不可访问属性比如 [[Scope]],不可访问属性是给 JavaScript 引擎解析读取的。其中 [[Scope]] 由ECMA-262标准第三版定义,包含了函数的作用域集合,这个集合也被叫做函数的作用域链。作用域链以当前作用域为起点,逐渐向外最终在全局作用域结束。

那么是不是说作用域链能被我们看到了?以上面的demo为例。

var a = 0, b = 0, c = 0, d = 0;
function fun1(){
  var a = 1, b = 1, c = 1;
  function fun2(){
    var a = 2, b =2
    function fun3(){
      var a = 3
      console.log(a, b, c, d)
    }
    fun3()
    console.log(fun3.prototype)
  }
  fun2()
}
fun1()

[[Scope]] 在词法解析阶段(编译阶段)就已经确定了,以后作用域链屡不清楚的时候可以直接打印出来看看,这样对作用域链的理解就七七八八了。

作用域的应用

遵循最小暴露原则

在项目编码中应该遵循最小暴露原则,这个原则是说我们应该最小限度的暴露必要内容,将私有变量都定义在局部作用域避免污染全局。函数可以产生自己的作用域,在jq年代经常可以看到一些插件的源码写在一个立即执行的回调函数中(IIFE),就是为了避免污染全局。

IIFE是js早期的模块化实现方案详细可移步文档 最详细的前端模块化解决方案梳理

闭包

什么是闭包:当一个函数内部返回另一个函数,返回的这个函数访问了其父函数内部的变量,返回的函数在最外层被执行就是一个闭包。

当函数在定义时的词法作用域外被访问时,闭包可以让函数继续访问定义时的词法作用域。

function foo() {
  var a = 2
  function bar() {
    console.log(a)
  }
  return bar
}
var fun = foo()
fun() // 2

bar是在函数foo中定义,执行却在最外层的全局,但仍可以访问定义时foo的词法作用域。

由于js中垃圾回收机制的作用,函数在执行完后会被销毁,释放内存空间,上面例子当 foo() 执行完成后由于闭包的存在会阻止垃圾回收机制对foo()函数的释放,因为闭包需要访问foo()函数内部的a变量(依然存在引用)。大量使用闭包可能会造成内存泄露的问题,但是以现在浏览器的性能来看不必太纠结这个。

执行上下文栈

执行全局代码时,会产生一个执行上下文环境,每次调用函数时又会产生执行上下文环境;当函数调用完成时,这个上下文环境以及其中的数据都会被释放,再重新回到全局上下文环境;处于活动状态的执行上下文环境只有一个,但是执行上下文栈中可能会有多个执行上下文环境,上面讲的函数调用完成时重新回到全局上下文环境是相对的,如果是函数套函数的机制那么里层函数执行完会回到上层函数的执行上下文环境。函数执行完被释放的提前是没有闭包机制这和前面的内容是一致的。

var a = 1
var bar = function(o){
    var b = 2
    fun(o + b)
}
var fun = function(i){
  var c = 3
  console.log(i + c)
}
bar(5)

以这段代码为例看一下执行上下文栈是怎么进行入栈出栈操作的

总结

现在总结一下上文的内容

作用域:作用域可以被全局或者局部定义,用来确定当前代码对标识符(变量以及函数)的访问权限,起到标识符隔离避免冲突的目的。
作用域链:按照一定查找规则逐层查找标识符。
闭包:当函数在定义时的词法作用域外被访问时,闭包可以让函数继续访问定义时的词法作用域。
变量提升:编译阶段要确定作用域首先要找到所有变量的声明,并利用相关机制将它们关联起来(词法作用域确定的本质),这样就需要把相关变量提前声明。
未声明变量:所有末定义直接赋值的变量自动声明到全局作用域下。

几个DEMO

demo1

for(var i = 0; i < 3; i++){
  setTimeout(e => {
    console.log(i) // 3 3 3
  }, 0)
}

// 解法1
for(var i = 0; i < 3; i++){
  (k => {
    setTimeout(e => {
      console.log(k) // 0 1 2
    }, 0)
  })(i)
}

// 解法2
for(var i = 0; i < 3; i++){
  let k = i
  setTimeout(e => {
    console.log(k) // 0 1 2
  }, 0)
}

// 解法3
for(let i = 0; i < 3; i++){
  setTimeout(e => {
    console.log(i) // 0 1 2
  }, 0)
}

因为i变量是用var声明的,var不具备块级作用域机制,会暴露到全局当执行console操作的时候当前全局的i已经是3了。

解法1:通过一个立即执行函数(IIFE)每次循环都会创建一个独立的作用域,取值的时候在自己独立的作用域取值互不影响。

解法2:let 声明的 k 有局部作用域的机制,每次循环都会生成一个新的块级作用域并把k固定到里面互不影响。

解法3:直接用 let 声明 i,每次循环都会生成一个作用域固定i,只不过这个作用域并不是在for的花括号中,而是在for的小括号中,for循环小括号的作用域是花括号的上一级作用域,这样就生成了三个独立的作用域嵌套,根据作用域向上查找机制依然可以得到我们预期的结果。

demo2

var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f();
}
checkscope() // local scope
 
var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
    return scope;
  }
  return f;
}
checkscope()(); // local scope

词法作用域(静态作用域)和运行上下文无关,作用域在编译阶段确定。

demo3

var a = 1
function fn() {
  console.log(a) // err 不允许在定义前使用
  let a = 2
  bar()
  console.log(a) // 2
}
function bar() {
  console.log(a) // 1
}
fn() // err 1 2

虽然全局声明了a,但是在fn()作用域中也声明了let a,在当前作用域已经查找到了 a,就不会触发作用域链的向上查找机制,并且let声明的变量不允许重复,不允许提前调用。
如果把 let a = 2 改成 var a = 2 经过变量提升输出内容是 undefined 1 2 如下面代码。

var a = 1
function fn() {
  console.log(a)
  var a = 2
  bar()
  console.log(a)
}
function bar() {
  console.log(a)
}
fn() // undefined 1 2

demo4

var a = 1
function fn() {
  console.log(a) // 3
  a = 2 // bar -> global LHS
}
a = 3
function bar() {
  console.log(a) // 2
}
fn()  // 3
bar() // 2

demo5

console.log(a()) // 2
var a = function b(){
  console.log(1)
}
console.log(a()) // 1
function a(){
  console.log(2)
}
console.log(a()) // 1
console.log(b()) // err 

经过变量提升和下面代码等价,要注意function提升优先级高于var;var a = function b(){} 这种写法属于函数表达式,该作用域下并没有b()函数。

function a(){
  console.log(2)
}
console.log(a()) // 2
var a = undefined
var a = function b(){
  console.log(1)
}
console.log(a()) // 1
// function a(){
//   console.log(2)
// }
console.log(a()) // 1
console.log(b()) // err 

demo6

function test() {
  console.log(a) // undefined
  console.log(b) // err: b is not defined
  console.log(c) // err: Uncaught ReferenceError
  var a = b = 1   // 等价于 var a=1; b=1 
  let c = 1 
}
test() 
console.log(b) // 1
console.log(a) // err: a is not defined

var a = b = 1 等价于 var a=1; b=1 b 未定义直接使用会被声明到全局,因为没有 var 关键字所以不会提升,所以 console.log(b) 抛出的异常是变量未定义;
c 通过 let 定义,不会提升且不允许提前使用,所以抛出的异常是不允许在变量定义前使用。

demo7

var a = [];
for (var i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6]() // 10

var a = [];
for (let i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6]() // 6

同demo1

demo8

function a(age) {
  console.log(age)   // function age() {}
  var age = 2
  console.log(age)   // 2
  function age() {}
  console.log(age)   // 2
}
a(1)

a() 函数作用域中有 形参age、变量age、方法age 在编译阶段函数提升的优先级最高(出现同名情况函数将会覆盖其他标识符和编译顺序无关)所以第一个 console.log(age) 是 function age() {};
当运行到 var age = 2 时 age 被 2 覆盖,后面的 console.log(age) 都是2。

demo9

var a = 1
function f() {
  console.log(a)
  if(false) {
    var a = 2
  }
}
f() // undefined

编译阶段并不关注 if 语句有没有执行,都会把 var a 提升到当前作用域顶端。