Mather

We create our own demons.

有用但不愿意使用的机制 — JavaScript 作用域

作用域 Scope

词法作用域是一套关于引擎如何查找变量以及存储变量的规则

JavaScript 代码片段在执行前都要进行编译,与大多数语言不同,编译的过程不是发生在构建前(在浏览器中也没有构建),而是在执行前。

作用域使我们可以有效访问变量或函数的区域。大致可分为三种情况,词法作用域函数作用域块作用域

两年前我在 《混乱作用域、闭包问题》一文中描述了几个关于作用域的例子,说明了混乱的原因,但没有深入分析理解 JavaScript 作用域。

变量的定义和赋值

要理解作用域的产生,首先要理解变量是怎么被定、赋值和引用的。

var a = "a"

编译器会在当前作用域中声明变量 a,如果之前没被声明过。运行时引擎会在作用域中查找该变量,如果能找到就对它赋值。

词法作用域

词法作用域就是定义在词法编译阶段的作用域。该作用域是由你编写代码时变量和块语句来决定的。

//global namespace
var g = "global";

function globalFunc() {
  var a = "a"
  function innerFunc() {
    var i = "i"
    var g = "innerFunc"
    console.log(a) // a
    console.log(i) // i
    console.log(g) // innerFunc
    console.log(window.g) // global
  }
  innerFunc();
}

globalFunc() // "innerFunc"

var b = "b"
if(b) {
    var b = "new b"
    console.log(b) // new b
}

在函数中声明变量,是常见的作用域嵌套现象,不仅发生在函数中,还发生在块{}中。

那么引擎就会尝试在当前块和函数中查找这个变量,又或者在外层嵌套中继续查找,直到知道为止。

函数作用域

我们常在模块设计和对象设计时,运用到函数作用域,设计时遵循最小暴露原则,避免变量冲突情况。

//global namespace
var g = "global";

function globalFunc() {
  var a = "a"
  function innerFunc() {
    var i = "i"
    var g = "innerFunc"
    console.log(g) // innerFunc
  }
  innerFunc();
}

globalFunc()   // innerFunc
console.log(a) // a is not defined
console.log(i) // i is not defined
console.log(g) // global

函数作用域是指,属于这个函数的全部变量都可以在整个函数内使用。

但函数作用域也会产生一些意外的问题,比如占用了 globalFunc 声明,还有必须显式调用 globalFunc() 才能运行代码。IIFE 是非常好的解决在某些情况下如果不需要函数名和要求自动运行的问题。

IIFE 立即调用函数表达式

IIFE 一般由括号运算符和匿名/具名函数组成。

(function globalFunc() {
    var a = 2
})();

globalFunc() // globalFunc is not defined

被包含在括号内 () 的函数具有独立的词法作用域,不仅避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。尾部的括号 (),表示引擎会执行该语句。

块作用域

除了函数内存在作用域,还存在其他类型的作用域,如 try/catch let const 等。

我在 《混乱作用域、闭包问题》一文中提到的例子:

<button>A</button>
<button>B</button>
<button>C</button>
<button>D</button>
<p id="output"></p>
var buttons = document.querySelectorAll('button');
var output  = document.querySelector('#output');

for (var i = 0; i < buttons.length; ++i) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = buttons[i - 1].innerText;
    }, false)
}

该程序理想结果是点击按钮,按钮中的字符会显示在页面上。实际上并非如此,任意按钮都只是输出 D

实际上 i 变量创建在全局, i 变量作为参数绑定到了每个监听器在响应函数中,那么结果都是一样。

for (let i = 0; i < buttons.length; ++i) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = buttons[i].innerText;
    }, false)
}

let 关键字可以让 i 变量绑定到 { } 括号内,这样每次点击按钮的回调就可以获取到对应的按钮对象。

image

Chrome 开发者工具调试代码

使用开发者工具可以很直观的让我们理解到这个问题,任意界面按 F12 ,将这段代码粘贴到文档中打开。

image

for 循环中的代码块中并没有创建作用域,导致 i 变量泄露到外围作用域,addEventListener 的匿名函数调用的是全局的 i 变量,当 for 循环执行完毕。观察右侧的 Watch 一栏,变量 i 的值是 4

为观察点击事件的回调函数执行情况,在行 5 行 6 加入断点。

image

分析 var i = 0; 这条语句,在全局作用域,通过 var i 声明了变量 i

buttons[i - 1].innerText; 执行时引用了全局作用域上的 var i

点击任意 A B C D 按钮,结果如猜想一致。变量 i 的值已经是 4,点击任意的按钮结果都是 4

var let const 关键字的区别

通过块作用域的例子,可以很容易地得总结出 let const 关键字形成的作用域

varletconst
作用域函数作用域块级作用域块级作用域
作用域内声明提升
是否可重复声明
是否可重复赋值
初始化时是否必需赋值

参考

从手动操作 DOM 到数据驱动视图 — MVVM 的那些事

发表评论
撰写评论