--本文采自本人公众号【猴哥别瞎说】
在 JavaScript 中,作用域似乎是最简单的。但对我而言,有时候与它有关的操作结果却会人感到困惑。特别是当提起“闭包”的时候,总会感到不那么踏实。于是想要通过这个文章,彻底搞懂与“JavaScript作用域”相关的一切。
作用域的定义
首先需要明确作用域是什么?让我们从编程语言的基本功能聊起吧。
几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能够在之后对这个值进行访问和修改。事实上,正是这种储存和访问变量的值的能力,将状态带给了程序。若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但是它会受到高度限制。
将程序引入程序会引起几个有意思的问题:这些变量存储在哪里?更重要的,程序需要时如何找到它们?
这些问题说明需要一套设计良好的规则来储存变量,并且之后依靠这个规则来找到这些变量。这套规则被称为作用域。
值得注意的是,实际存储变量的位置是栈内存或者堆内存,很多人会认为那个是作用域。它们是存储变量的地方,并不能将这些理解为作用域。具体的规则才是作用域。
哦,原来 作用域是查找名称或者变量的一套规则。那么它在什么地方会被使用呢?是在 JavaScript 引擎中,更多的可以看看这篇文章中有关于 JavaScript 引擎的描述。
那么,既然是规则,我们来看看这些规则是怎样的?
作用域是嵌套的
在实际使用中,通常需要同时顾及几个作用域。常见的就是作用域的嵌套。
当一个函数(或者块)嵌套在另一个函数(或者块)的时候,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量的时候,JavaScript 引擎就会在外层嵌套的作用域中继续查找,知道找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
考虑代码:
function foo(a){
console.log(a + b);
}
var b=2;
foo(18); // 20
对变量 b 的引用无法在 foo 内部完成,但可以在上一层作用域中找到。
这样一层层的往外嵌套的过程,可以简单理解为一个作用域链。
动态作用域与静态作用域
作用域共有两种主要工作模型。
第一种是最为普遍的,被大多数编程语言采用的词法作用域。此法作用域的定义在词法阶段的作用域。简单说,此法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此词法分析器在处理代码时会保持作用域不变。
第二种则是动态作用域,典型代表是bash语言。这种模型让作用域作为一个在运行时被动态确定的形式,即它不关心函数和作用域是怎样声明以及在何处声明的,只关心他们从何处调用的。换句话说,它的作用域链是基于调用栈的,而不是代码中的作用域嵌套。
为了说明区别,我们来看看例子吧:
首先是以 JavaScript 代码为例的词法作用域:
/*
执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value
如果没有,就根据书写的位置,查找外面一层的作用域,也就是 value 等于 1
所以结果会打印 1
*/
var value = 1;
function foo() {
console.log(value); // 1
}
function bar() {
var value = 2;
foo();
}
bar();
然后是 bash 为代表的动态作用域:
/*
执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value
如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量
所以结果会打印 2
*/
value=1
function foo () {
echo $value; // 2
}
function bar () {
local value=2;
foo;
}
bar
划重点:在 JavaScript 中,无论函数在哪里调用,也无论它何时被调用,它的词法作用域都只有函数被声明时所处的位置决定。
作用域的遮蔽效应
当我们理解了词法作用域的用法,那么我们就可以看到它的遮蔽效应:作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。
这个遮蔽效应,也是常见的测试题目。当你理解了之后,就会觉得很简单。
谁会生成新的作用域
我们讲完了作用域的一些细节。更进一步地,我们想要知道,在 JavaScript 中,哪些操作可以生成新的作用域呢?只有函数会生成新的作用域么?还有其他的结构或操作可以产生作用域呢?
函数作用域
正如我们设想的那样,每声明一个函数,都会为其自身创建一个作用域。我们以一个例子来理解:
function foo(a){
var b = 2;
function bar(){...}
// ... more code here
var c = 3;
}
在这个代码片段中,foo()的作用域中包含了标识符a、b、c、bar。bar()拥有自己的作用域。全局作用域只包含一个标识符:foo。
由于标识符a、b、c、bar都附属于foo()的作用域,那么无法从foo()的外部对它们进行访问。也就是说,这些标识符无法从全局作用域中被访问到。
函数作用域的这个限制,在我们想要将某些函数代码进行封装的时候格外有用。通过创造函数作用域,就相当于隐藏了内部实现。
为什么要隐藏内部实现?可以从软件设计角度的最小特权原则或者最小暴露原则来考虑:仅最小限度地暴露内容,而将其他内容都“隐藏”起来。
块作用域
如果你了解除了 JS 之外更多的主流编程语言,你会发现块作用域是一个常见的概念。但可惜的是:常见的块作用域写法(没错,就是那个非常简单的花括号表示法),在 JS 中竟然没有。(是的,就是没有。果不其然 JS 是作者 Brendan Eich 只花了10天设计出来的语言。。)
所以你也就应该明白,下面的写法,是非常危险的:
//由于这里并无法形成块作用域,因此变量i实际被挂在了外部作用域中(在此处,即全局作用域)
for(var i = 0; i< 5; i++){
console.log("the i is : ", i);
}
try/catch
但是,虽然常见的块作用域语法没有,但 JS 语法中有一个非常不起眼的规范(从 ES3 开始生效):try/catch 中的 catch 分句会创建一个块作用域,其中的声明只会在 catch 内部生效。
try {
undefined(); // 强行制造一个Error
}
catch(err){
console.log( err ); // 能够正常执行
}
console.log( err ); //ReferenceError : err not found
看到这个之后,是不是会发现很无奈?你会想:这个有啥卵用呢?谁会写这么丑陋的代码呀?就为了使用块作用域。。。
但它确实有可用之处。现如今(2020年初)的我们知道,ES6 语法中的let关键字可以创造块作用域,实现我们一直想要的功能。但是,我们如何将let关键字的语法切换到让 ES6 以下的环境也能够适用呢?(即降级服务)
一般情况下,都是类似 babel 这样的工具协助我们将 ES6 代码转化为 ES5 代码,我们并不关心它们是怎么实现的,对吧?现在可以告诉你: 将其写成 try/catch 的方式,是 ES6 中的绝大部分功能迁移的首选方式,以生成兼容 ES5 的代码。
ES6 新关键字: let/const
为了解决 JavaScript 一开始设计时的如上缺陷,ES6 语法规范定义了两个新的关键字:let/const。它们提供了除var以外的另外两种声明方式,作用都是可以用来创建块作用域变量。
重点来看let关键字,它可以将变量绑定到所在的任意作用域中(通常是{..}内部)。换句话说,let为其声明的变量隐式地劫持了所在的作用域。用个栗子来说明吧:
var foo = true;
if(foo){
let bar = foo *2;
console.log( bar );
}
console.log( bar ); // RefenceError
可以看到,此时因为let的声明,条件语句的{}被拓展为了一个新的作用域,外部作用域就无法访问到该作用域内的变量了。不过要注意,这种方式是隐式创建了新的作用域。
有了let,我们就可以轻轻松松实现我们想要的块作用域的功能啦,而且还是显式声明的方式:
function bar() {
var b = 2;
{
let a = 10;
console.log(a);
}
console.log( b ); // 2
console.log( a ); // ReferenceError
}
不过需要注意的是:使用let进行的声明,不会在块作用域中进行提升。关于提升,可以看看接下来的章节。
而const关键字,其产生的效果与let类似。只不过,经const声明的变量,是一个常量,不允许程序对其进行修改。
提升
作用域中,经常会被问到的一个问题,就是关于提升。提升分为两大类:变量声明的提升、函数声明的提升。我们来分开看:
变量声明的提升
我们来看一个栗子:
a = 2;
var a;
console.log(a);
上面的结果,很多开发者会认为是 undefined,因为var a声明在 a = 2的后面,自然会被认为变量会被重新赋值(默认值 undefined)。但是,真正输出的结果是2。
想想看前一节我们讲到的和编译器与引擎相关的文章,就会知道:当引擎看到var a = 1;的时候,它会将其看成是两个声明:var a和 a = 1。其中,第一个声明是在编译阶段进行的,第二个赋值声明会被留在原地等待执行阶段。
于是,JavaScript 会这样处理我们的栗子代码:
var a; //编译阶段
a = 2; //执行阶段
console.log(a); //执行阶段
这种将定义声明(或函数声明)从他们在代码中的位置被“移动”到了最上面的做法,就是提升。那么,我们是否真的理解了这个概念呢?再来一个例子:
console.log(a);
var a = 2;
这个时候,会输出什么呢?答案是 undefined。JavaScript 的处理顺序是这样的:
var a; //编译阶段
console.log(a);
a = 2;
不过值得注意的是,所谓的提升,只是在特定作用域内的提升。特定的作用域,是提升的限制规则所在。
函数声明的提升
函数声明的提升与变量的提升类似。我们来看栗子吧:
foo();
function foo(){
console.log("foo");
}
这个是可以正常输出的。
不过函数声明有两个值得注意的点:
- 函数声明会被提升,但是函数表达式却不会被提升。
- 函数声明与变量声明都会被提升,但是函数会被优先提升,其次才是变量。
还是用栗子来说话吧:
foo(); // 不是ReferenceError,而是TypeError
var foo = function(){
console.log("foo");
}
此时的 JavaScript 引擎是这样看待这个代码的:
var foo;
foo(); //foo有定义,于是不会ReferenceError,但foo此时的值是undefined, 对其调用()会报TypeError
foo = function(){
console.log("foo");
}
我想这是较好理解的。
闭包
我们理解了词法作用域、懂得了作用域链之后,此时来看闭包,就不会那么没底了。
关于闭包,先来一个直截了当的定义:
当函数可以记住并访问其所在的词法作用域的时候,就产生了闭包(即使函数是在其所在的词法作用域之外被执行的)。
还是举个栗子来理解这段话吧:
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2 -- 这就是闭包
我们以作用域的角度来看待代码:变量a是在foo函数的词法作用域内。因为作用域链嵌套的关系,bar函数可以访问到变量a。但最外层作用域是无法访问到它(变量a)。
代码将bar函数返回到最外层作用域,但它依然能够记住它所能够访问的词法作用域,而不受到在何处被执行的影响。
这就是闭包。只要把握住了作用域的核心概念,万变不离其宗。闭包就没有那么难。
我们来一个复杂一点的闭包形式:
function foo(){
var a = 3;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
fn();
}
foo();
这个栗子包含了函数提升以及闭包。但是稍微捋一捋,你就知道答案啦~
JavaScript 深入系列文章: