JavaScript 做用域详解

本文首发于贝壳社区FE专栏,欢迎关注!javascript

1、什么是做用域

编译原理

  • 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成(对编程语言来讲)有意义的代码块,这些代 码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序一般会被分解成 为下面这些词法单元:var、a、=、2 、;。空格是否会被看成词法单元,取决于空格在 这门语言中是否具备意义。
  • 解析/语法分析(Parsing) 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的表明了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。 var a = 2; 的抽象语法树中可能会有一个叫做 VariableDeclaration 的顶级节点,接下 来是一个叫做 Identifier(它的值是 a)的子节点,以及一个叫做 AssignmentExpression 的子节点。AssignmentExpression 节点有一个叫做 NumericLiteral(它的值是 2)的子 节点。
  • 代码生成 将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息 息相关。 抛开具体细节,简单来讲就是有某种方法能够将 var a = 2; 的 AST 转化为一组机器指 令,用来建立一个叫做 a 的变量(包括分配内存等),并将一个值储存在 a 中。

简而言之:java

  1. 将代码以词为单位拆分红一个个词法单元。
  2. 解析词法单元转换成 AST 语法树。
  3. 生成机器指令。

编译过程

编译过程

整个编译过程有三个角色须要登场:node

  • 引擎 负责整个 JavaScript 程序的编译及执行过程。
  • 编译器 负责语法分析及既期代码生成。
  • 做用域 负责收集并维护全部声明的变量组成的一系列查询。

那么整个 var a = 2; 的编译过程以下:es6

  • 编译器拿到 var a = 2; 这段代码,进行语法分析。
  • 编译器分析到 var a,向做用域进行变量定义操做。
    • 若是做用域中已有 a 变量,直接通知编译器
    • 若是做用域中没有 a 变量,建立 a 变量并通知编译器
  • 编译器收到通知,继续执行并将 a = 2 这段代码编译为及其语言传给引擎
  • 引擎拿到 a = 2做用域中去查找 a 变量,准备赋值操做。
    • 若是 a 所在做用域下有 a 变量,做用域直接通知引擎
    • 若是 a 所在做用域下没有 a 变量,则不断向外部做用域查找 a 变量。
      • 在外部做用域找到 a 变量,做用域通知引擎
      • 在外部做用域找 a 变量直到全局做用域下也没有找到,做用域通知引擎未找到 a 变量。
  • 引擎收到通知
    • 若是找到 a 变量,引擎做用域内对变量 a 赋值。
    • 若是没有找到 a 变量,引擎发出 Refence Error 错误。

流程图

做用域的好处

  • 安全性 —— 变量和函数能够定义在最小做用域下。
  • 减小命名冲突 —— 做用域帮咱们较少命名冲突发生的几率。
  • 代码复用性 —— 好的局部做用域能够提高代码的复用性。

2、LHS 与 RHS

定义

我对于 LHS 和 RHS 的理解是:全部赋值操做都是 LHS,如 a = 2;;而全部的取值操做都是 RHS,如 console.log(a);面试

当变量出如今赋值操做的左侧时进行 LHS 查询,出如今右侧时进行 RHS 查询。 —— 《你不知道的 JavaScript》编程

差别

在非严格模式下,当变量 a 未被定义,像 console.log(a) 这样的RHS 查找会报 ReferenceError 的错误,而像 b = 2 这样的 LHS 查找会在全局做用域下建立变量并进行赋值。数组

console.log(a); // type: RHS, output: ReferenceError
b = 2; // type: LHS, output: 2
复制代码

而在严格模式下,LHS 和 RHS 的效果是相同的,都会报 ReferenceError安全

示例

function foo(a){
  console.log(a);
}
foo(2);
复制代码

在以上例子中有 3 次 RHS 和 1 次 LHSbash

  • RHS foo(2) 查找 foo 函数。
  • LHS foo(2) 隐藏着 a = 2 赋值行为。
  • RHS console.log(a) 查找 console 对象
  • RHS console.log(a) 查找 a 变量
function foo(a) {
  var b = a;
  return a + b;
}
var c = foo(2);
复制代码

找出 3 次 LHS 4 次 RHS。闭包

  • RHS: foo(2) 查找 foo 函数。
  • LHS: foo(2) 隐藏有 a = 2 赋值行为。
  • LHS: var c = foo(2) 是赋值行为。
  • RHS: var b = a 查找 a 变量。
  • LHS: var b = a 是赋值行为。
  • RHS: return a + b 查找 a 变量。
  • RHS: return a + b 查找 b 变量。

3、词法做用域及欺骗词法

词法做用域

词法做用域就是指咱们代码词法所表示的做用域。看下以下代码:

function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log( a, b, c );
  }
  bar( b * 3 ); 
}
foo( 2 ); // 2, 4, 12
复制代码

这段代码的词法做用域如图:

词法做用域

其实就是咱们在代码编写时所定义的做用域即词法做用域。

欺骗词法

固然也有不按词法规则来的写法,称为欺骗词法。

eval

相似于 eval() 方法会将字符串解析成 JS 语言的执行。它将破坏词法做用域的规则。如

function foo() {
  eval('var a = 3')
  console.log(a) // 3
}

var a = 2;
foo();
复制代码

with

with 这个冷门的关键词一般被看成重复引用同一个对象中的多个属性的快捷方式,能够不须要重复引用对象自己。

var obj = { 
  a: 1,
  b: 2,
  c: 3
};
// 单调乏味的重复 "obj" 
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式 
with (obj) {
  a = 3;
  b = 4;
  c = 5;
}
复制代码

两种赋值方式看似等价。但若是赋值目标是 obj 对象中没有的变量,两种赋值效果是不一样的。

var obj = { 
  a: 1,
  b: 2,
  c: 3
};

obj.d = 11;  

console.log(obj) // { a: 1, b:2, c:3, d: 11 } 
console.log(d) // ReferenceError
复制代码
var obj = { 
  a: 1,
  b: 2,
  c: 3
};

with (obj) {
  d = 11;
}

console.log(obj) // { a: 1, b:2, c:3 } 
console.log(d) // 11

复制代码

能够看到在 with 函数中的对于变量 d 的赋值行为(LHS)是定义在了 window 对象上的。

4、函数做用域和块做用域

函数做用域

一般状况下,函数内的变量没法在函数外调用。即变量存在于函数做用域下,因此函数做用域起到了局部变量或者变量隐藏的做用。以下例子

var a = 2;

function foo() {
  var a = 3;
  console.log(a); // 3
} 
foo();
console.log(a); // 2
复制代码

以上写法将 foo 方法中的 a 变量隐藏了起来。不过也产生了一个问题 —— 全局做用域下多了一个 foo 函数变量。解决这种污染的方式是当即执行函数(IIFE),咱们将上面的代码进行改造:

var a = 2;
(function foo() {
  var a = 3;
  console.log(a); // 3
})();
console.log(a); // 2
console.log(foo) // ReferenceError
复制代码

这种写法就能够将 foo 函数变量也隐藏起来,避免对全局做用域的濡染。

块做用域

定义

块级做用域存在于 if, for, while, {} 等语法中,这些做用域中使用 var 定义的变量是不在这个做用域内的。

块做用域和函数定义域的区别在于:函数定义域隐藏函数内的变量,而块做用域隐藏块中的变量。举个栗子:

// 函数做用域,隐藏变量a
function test() {
  var a = 2
}
console.log(a) // ReferenceError
复制代码
// 块做用域,隐藏变量 i
// 不隐藏变量 a (不是函数做用域)
for (let i = 0; i < 10; i++) {
  var a = 2;
}
console.log(i) // ReferenceError
复制代码

with 与 try/catch

with 和 catch 关键字都会建立块级做用域,由于他们建立的做用域在外部做用域中无效。

var obj = {
  a: 1
}

with(obj) {
  a = 2
}

console.log(obj) // { a: 2 }
console.log(a) // ReferenceError
复制代码
try {
  undefined(); // 执行一个非法操做来强制制造一个异常
} catch (err) {
  console.log(err); // 可以正常执行! 
}
console.log(err); // ReferenceError
复制代码

let 与 const

let 和 const 关键字能够将变量绑定到所在的任意做用域中。换句话说,let 和 const 为其声明的变量隐式地了所在的块做用域。

{
  let a = 2;
}
console.log(a) // ReferenceError
复制代码

可见 const 和 let 可以保证变量隐藏在所在做用域中。

var 与 let 的差别

因为 ES5 只有全局做用域和函数做用域,没有块级做用域,这带来不少不合理的场景。

而 ES6 所提出的 let 和 const 为 JavaScript 带来了块做用域解决了这个问题。

下面列出4点 var 与 let 的差别之处:

  1. let 不存在变量提高。(var 的变量提高下文有说起)
console.log(foo); // undefined
var foo = 2;

console.log(bar); // ReferenceError
let bar = 2;
复制代码
  1. let 在块做用域内定义了变量后不受外部做用域变量影响。
var a = 3
{
  console.log(a) // ReferenceError
  let a
}
console.log(a) // 3
复制代码
  1. 不容许重复申明。
  2. 最大的不一样是在于 let 做用域块做用域,而 var 只做用域函数做用域和全局做用域。

5、变量提高

在使用 var 定义变量和使用 function 定义函数时,会出现变量提高的状况。

编译顺序

看几个例子来理解下变量提高:

var a = 2; 
console.log( a );
// JavaScript 的处理逻辑
var a;
a = 2;
console.log(a); // 2
复制代码
console.log( a );
var a = 2; 
// JavaScript 的处理逻辑
var a;
console.log(a); // undefined
a = 2;
复制代码
foo();

function foo() {
  console.log(a); // undefined 
  var a = 2;
}
// JavaScript 的处理逻辑
function foo() {
  var a;
  console.log(a); // undefined 
  a = 2;
}
foo();
复制代码

**为何呢?**回忆一下上文说到的编译过程就能理解了。看图!

流程图

能够看到编译器会将变量都定义到做用域中,而后再编译代码给引擎去执行代码命令。var a = 2; 是被拆开执行的且 var a 变量会提早被定义。

再来看一个不靠谱的函数定义方法:

foo(); // "b"
var a = true;
if (a) {
  function foo() {
    console.log("a");
  }
} else {
  function foo() {
    console.log("b");
  }
}
复制代码

输出结果与《你不知道的 JavaScript》中的有所不一样,在 node v10.5.0 中输出的是 TypeError 而非 b。这个差别有待考证。

函数优先

虽然函数和变量都会提高,可是编译器会先提高函数,再是变量。看以下例子:

foo(); // 1
var foo;

function foo() {
  console.log(1);
}
foo = function () {
  console.log(2);
};
复制代码

同时是函数定义,可是第二种是定义变量的形式,因此听从函数优先原则,以上代码会变为:

function foo() {
  console.log(1);
}
var foo; // 无心义

foo(); // 1

foo = function () {
  console.log(2);
};
复制代码

6、闭包

下面是人见人怕的闭包。

定义

函数能够记住并访问所在的词法做用域时,就产生了闭包。 当函数能够记住并访问所在的词法做用域时,就产生了闭包。 当函数能够记住并访问所在的词法做用域时,就产生了闭包。 重要的定义说三遍!

function foo() {
  var a = 2;

  function bar() {
    return a;
  }
  return bar;
}
var baz = foo();
console.log(baz()); // 2 <-- 这就是闭包
复制代码

按照咱们对于函数做用域的理解,函数做用域外是没法获取函数做用域内的变量的。

可是经过闭包,函数做用域被持久保存,而且闭包函数能够访问到做用域下的变量。

下面再展现几个闭包便于理解:

var fn;

function foo() {
  var a = 2;

  function baz() {
    console.log(a);
  }
  fn = baz; // 将 baz 分配给全局变量 
}

function bar() {
  fn(); // <-- 闭包!
}
foo();
bar(); // 2
复制代码
function foo() {
  var a = 2;

  function baz() {
    console.log(a); // 2
  }
  bar(baz);
}

function bar(fn) {
  fn(); // <-- 闭包!
}
复制代码
function wait(message) {
  setTimeout(function timer() {
    console.log(message);
  }, 1000);
}
wait("Hello, closure!");
// timer 持有 wait 函数做用域,因此是闭包。
复制代码

上面几个例子能够概括下闭包的特性:

  1. 闭包一定是函数。
  2. 函数能够在当前词法做用域外持有并访问词法做用域。

就这么简单!按照这个定义其实全部的回调函数都属因而闭包。

经典的循环面试题解析

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
复制代码

看看以上写法最终输出的是什么呢?因为 var i = 0 是在全局做用域下,且没有任何地方存 i 的变化值,因此最终输出是 5 个 6

解决方案有两种:

  1. 使用闭包的持有做用域特性,为每个 timer 函数封闭一个做用域保存当前的 i。
  2. 使用 let 块做用域封闭 for 循环中的做用域,保存当前的 i 值。
// 闭包写法
for (var i = 1; i <= 5; i++) {
  (function (j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}
复制代码
// 块做用域写法
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
复制代码

参考文档

最后

本文旨在更方便和全面的理解做用域的相关知识,但愿能对你有所帮助 JavaScript 的做用域知识不论是在面试中仍是在实际工做中都是很是重要的。

相关文章
相关标签/搜索