深入JavaScript系列01---作用域与闭包

254 阅读12分钟

“You know nothing Jon Snow”

这篇文章是对《你不知道的 JavaScript (上卷)》第一部分的学习和总结. 主要对作用域、作用域(链)、变量/函数提升、闭包、垃圾回收做了简析。

作用域相关

意义:定义了变量储存和查找的规则

编译原理

对于大部分编程语言, 编译大致有三个步骤.

  • 分词/词法分析 (Tokenizing/Lexing):将由字符组成的字符串分解成有意义的代码块(词法单元), 如代码 const myName = 'Axiner' 会被分解成 const, myName, =, 'Axiner'

  • 解析/语法分析 (Parsing):将词法单元流转换成由元素逐级嵌套组成的语法结构树——“抽象语法树”(Abstract Syntax Tree, AST)。语法分析会根据 ECMAScript 的标准来解析成 AST, 比如你写了 const class = 'Axiner', 就会报错 「Uncaught SyntaxError: Unexpected token class」,在解析过程中class无法被当做变量名.

  • 代码生成这个阶段就是将 AST 转换为可执行代码, 像 V8 引擎会将 JavaScript 字符串编译成二进制代码(创建变量、分配内存、将一个值存储到变量里...)

与传统编译语言稍有区别的是,JavaScript代码编译通常发生在代码执行前几微秒,JavaScript引擎还针对性能做了相应的优化...

代码执行

不管是编译阶段还是运行时, 都离不开 「引擎」, 「编译器」, 「作用域」.

  • 引擎用来负责 JavaScript 程序的编译和执行.
  • 编译器负责语法分析、代码生成等工作.
  • 作用域用来收集并维护所有变量访问规则.

变量查询规则

  • LHS:左查询,查询变量“容器”
  • RHS:右查询,查询变量值

以代码 const myName = 'Axiner' 为例, 首先编译器遇到 const myName, 会询问 「作用域」 是否已经有一个同名变量在当前作用域集合, 如果有编译器则忽略该声明, 否则它会在当前作用域的集合中声明一个新的变量并命名为 myName.

接着编译器会为引擎生成运行时所需的代码, 用于处理 myName = 'Axiner' 这个赋值操作. 引擎会先询问作用域, 在当前作用域集合中是否有个变量叫 myName. 如果有, 引擎就会使用这个变量, 否则继续往上查找.

引擎在作用域中查找元素时有两种方式:LHSRHS.

LHS和RHS的含义是“赋值操作的左侧或右侧”,但并不一定意味着就是“=赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”,用自己的话来讲就是查询变量名Identifier 和查找值value

//eg:
function foo(a) {
  var b = a;
  return a + b;
}
var bar = foo(1);
  1. var bar = foo(1); 引擎会在作用域里找是否有 foo 这个函数, 这是一次 RHS 查找, 找到之后将其赋值给变量 bar, 这是一次 LHS 查找.
  2. function foo(a) { 这里将实参 2 赋值给形参 a, 所以这是一次 LHS 查找.
  3. var b = a; 这里要先找到变量 a对应的值, 所以这是一次 RHS 查找. 接着将变量 a 对应的值赋值给变量符 b, 这是一次 LHS 查找.
  4. return a + b; 查找 ab所对应的值, 所以是两次 RHS 查找.

作用域嵌套

  • 概念: 当一个块或者函数嵌套在另外一个块或函数中,就发生了作用域的嵌套。
  • 变量查找规则:在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
//eg:
function foo(a) {
    return (a + b)
}
var b = 1;
var c = foo(2);
console.log(c) //3


词法作用域

JavaScript所采用的作用域模型为词法作用域,此外还有动态作用域,在此不作赘述。

  • 概念:结合编译原理中的词法分析来分析一下,词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。词法分析阶段基本能够知道全部变量名所处位置和声明方式,从而能够预测在执行过程中如何对它们进行查找(这一特性和闭包的产生息息相关)。

函数作用域和块作用域

  • 函数作用域:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。
  • 块作用域:将代码从在函数中隐藏信息扩展为在块中隐藏信息。
  • ES6引入了let、const关键字,提供了除var以外的另外一种变量声明方式,以let、const声明的变量可以在所在的块作用域内使用和复用(嵌套)。
  • 词法作用域是定义在词法阶段的作用域, 换句话说就是你写代码时将变量和块作用域写在哪里决定的. JavaScript 可以通过 evalwith 来改变词法作用域, 但这两种会导致引擎无法在编译时对作用域查找进行优化, 因此不要使用它们,最佳实践是让词法作用域根据词法关系保持书写时的自然关系不变.
//函数作用域
function foo() {
  const myName = 'Axiner';
  function sayMyName() {
    console.log(`Hello, ${myName}`);
  }
  sayMyName();
}

foo(); // 'Hello, Axiner'

console.log(myName); // 外部无法访问到内部变量
sayMyName(); // 外部无法访问到内部函数

//块作用域
{
  const myName = 'Axiner';
  function sayMyName() {
    console.log(`Hello, ${myName}`);  }
  sayMyName(); //'Hello, Axiner'
}
console.log(myName); // 外部无法访问到内部变量
sayMyName(); // 外部无法访问到内部函数

全局作用域

以浏览器环境为例:

  • 「最外层函数」「在最外层函数外面」定义的变量拥有全局作用域
  • 所有末定义直接赋值的变量自动声明为拥有全局作用域( 在严格模式下报错)
  • 所有 window 对象的属性拥有全局作用域
const a = 1; // 全局变量

// 全局函数
function foo() {
  b = 2; // 未定义却赋初值被认为是全局变量 在严格环境下声明会报错

  const myName = 'Axiner'; // 局部变量

  // 局部函数
  function bar() {
    console.log(myName);
  }
}

window.location; // window 对象的属性拥有全局作用域

全局作用域的缺点很明显, 就是会占据全局命名空间, 当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。因此很多库的源码都会使用 (function(){....})(). 此外, 模块化 (ES6、commonjs 等等) 的广泛使用也为防止污染全局命名空间提供了更好的解决方案.


提升

变量提升

因此, 对于代码 var i = 2; 而言, JavaScript 实际上会将这句代码看作 var i;i = 2, 其中第一个是在编译阶段, 第二个赋值操作会原地等待执行阶段. 换句话说, 这个过程将会把变量和函数声明放到其作用域的顶部, 这个过程就叫做提升.

为什么 let 和 const 不存在变量提升呢?这是因为在编译阶段, 当遇到变量声明时, 编译器要么将它提升至作用域顶部(var 声明), 要么将它放到 「临时死区(TDZ)」, 也就是用 let 或 const 声明的变量. 「访问 TDZ 中的变量会触发运行时的错误」, 只有执行过变量声明语句后, 变量才会从 TDZ 中移出, 这时才可访问.

例.

typeof null; // 'object'

typeof []; // 'object'

typeof someOneName; // 'undefined'

typeof myName; // Uncaught ReferenceError: myName is not defined
const myName = 'Axiner';

第一个, 因为 null 根本上是一个指针, 所以会返回 'object'. 深层次一点, 不同的对象在底层都表示为二进制, 在 Javascript 中二进制前三位都为 0 的会被判断为 Object 类型, null 的二进制全为 0, 自然前三位也是 0, 所以执行 typeof 时会返回 'object' (null属于基本类型,null自身就是该类型的唯一值,typeof null返回 'object' 属于js早期语言设计上的问题).

第二个想强调的是, typeof 判断一个引用类型的变量, 拿到的都是 'object', 因此该操作符无法正确辨别具体的类型, 如 Array 还是 RegExp.

第三个, 当 typeof 一个 「未声明」 的变量, 不会报错, 而是返回 'undefined'

第四个, str 先是存在于 TDZ, 上面说到访问 TDZ 中的变量会触发运行时的错误, 所以这段代码直接报错(let const声明的变量不能提前访问 否则会报错).

函数提升

函数声明和变量声明都会被提升, 但值得注意的是, 函数首先被提升, 然后才是变量.

test();

function test() {
  foo();
  bar();
  var foo = function() {
    console.log("this won't run!");
  };
  function bar() {
    console.log('this will run!');
  }
}

上面的代码会变成下面的形式: 内部的 bar 函数会被提升到顶部, 所以可以被执行到;接下来变量 foo 会被提升到顶部, 但变量无法执行(赋值操作发生在代码执行阶段), 因此执行 foo() 会报错.

function test() {
  var foo;
  function bar() {
    console.log('this will run!');
  }
  foo();
  bar();
  foo = function() {
    console.log("this won't run!");
  };
}
test();

避免重复声明,特别是当普通的var声明和函数声明混合在一起的时候,否则会引起很多危险的问题,来看一个例子:

foo(); //1

var foo;

function foo() {
console.log(1);
}
foo = function() {
console.log(2);
}

var foo尽管出现在function foo()...的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。尽管重复的var声明会被忽略掉,但出现在后面的赋值表达式还是可以覆盖前面的,相当糟糕的阅读体验。

闭包

当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行. -- 《你不知道的 JavaScript(上卷)》

"记住"和“访问”

function foo() {
  var count = 0;
  function bar() {
    console.log(count); //0 在bar函数内部“记住”了count
  }
 return bar
}
var baz = foo(); 
baz() //0  在外部访问bar hi 这就是闭包 

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。在这个例子中,我们将bar所引用的函数对象本身当作返回值。在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。

我们重新将上面的定义用自己的语言组织一下:

  • 当函数不在定义词法环境下执行,但是依然保持对词法环境的引用,这个引用叫做闭包(封闭的词法)


一道“闭包”题

下面是一道经典的面试题. 我们希望代码输出 0 ~ 4, 每秒一次, 每次一个. 但实际上, 这段代码在运行时会以每秒一次的频率输出五次 5.

for (var i = 0; i < 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

因为 setTimeout 是异步执行的, 1000 毫秒后向任务队列里添加一个任务, 只有主线程上的任务全部执行完毕才会执行任务队列里的任务, 所以当主线程 for 循环执行完之后 i 的值为 5, 而用这个时候再去任务队列中执行任务, 因此 i 全部为 5. 又因为在 for 循环中使用 var 声明的 i 是在全局作用域中, 因此 timer 函数中打印出来的 i 自然是都是 5.

我们可以通过在迭代内使用 IIFE 来给每个迭代都生成一个新的作用域, 使得延迟函数的回调可以将新的作用域封闭在每个迭代内部, 每个迭代中都会含有一个具有正确值的变量供我们访问. 代码如下所示.

for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

还可以写成下面的形式(同一个意思,不同表达式):

for (var i = 0; i < 5; i++) {
    setTimeout(function(j) {
        console.log(j);
    }, i * 1000, i);
}

当然最好的方式是使用 let 声明 i, 这时候变量 i 就能作用于这个循环块, 并且不会污染外部作用域。for循环头部的let声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

for (let i = 0; i < 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

模块

JavaScript中的模块可以看成是一种基于闭包的代码模式。

//eg:丐版
function JsModule() {
    let myName = "Axiner";
    let myHours = ['c','o','d','e','r'];
    function sayMyName() {
        console.log(myName);
    }
    function myJob() {
        console.log(myName + "is" + myHours.join(""));
    }
    return {
        sayMyName,myJob
    }
}

var foo = JsModule();
foo.sayMyName(); //Axiner
foo.myJob(); //Axiner is coder

特征 - (1)为创建内部作用域而调用了一个包装函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例); - (2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

单例模式:

let selfModule = (function() {
    let myName = "Axiner";
    let myHours = ['c','o','d','e','r'];
    function sayMyName() {
        console.log(myName);
    }
    function myJob() {
        console.log(myName + "is" + myHours.join(""));
    }
    return {
        sayMyName,myJob
    }
})();
selfModule.sayMyName(); //Axiner
selfModule.myJob(); //Axiner is coder


参考文献 《你不知道的JavaScript》

本文使用 mdnice 排版