理解 JavaScript 中的作用域、上下文、闭包

原文链接:Understanding Scope in JavaScript
原文作者:Hammad Ahmed (@shammadahmed)

前言:

JavaScript有一个特性叫做作用域。诚然,对于开发新手来说作用域的概念不那么好理解,我将会尽我所能用最浅显易懂的方式向你们解释作用域的含义。理解作用域的用法可以让你的代码错误率降低、形成优雅的代码风格,使你编写代码起来更加的得心应手。

什么是作用域:

作用域决定了在你代码运行时,变量、函数以及对象是否可以被访问。换言之,作用域决定了这些变量和某些资源的可见性。

相关课程: Getting Started with JavaScript for Web Development

为什么要使用作用域?什么是最低访问原则

那么限制变量可访问性、不让变量可随时随地被访问使用的意义在哪里呢?一个好处是,作用域为你的代码保证了一定程度上的安全性。计算机安全的一个公共原则就是用户每次操作只能访问到他们当时需要的东西。

试想作为电脑管理员,他们拥有公司系统的很多控制权限,看起来为他们开通完整的访问用户帐户似乎是可以的。假设这个公司有三位管理员,他们每个人都有全部的系统权限并且一切都运行正常。但是突然发生了一些异常,公司的系统之一被感染了恶意病毒。这时候就无法断定这是谁导致的问题。现在你应该知道你应该为他们分配基础账户,并只在需要的时候才赋予他们完全的访问权限。这将会帮助你追踪变化,并记录下谁做了什么。这就被称为最少访问原则。是不是很直观?这个原则同样被应用于程序语言设计,在包括JavaScript的众多程序语言中,都被称为是作用域,我们将在接下来的学习中遇到。

在你继续你的编程之路的时候,你会发现使用作用域会让你的代码运行更加有效率,让你更好的减少bug以及debug。作用域的存在还可以让你在不同作用域下用同名的变量。记住,不要将作用域(scope)与上下文(context)混淆,他们是JavaScript的不同特性。

JavaScript的作用域

在JavaScript中有两种作用域类型:

  • 全局作用域
  • 局部作用域

在函数内部定义的变量就是在局部作用域中;
在函数外部定义的变量就是在全局作用域中;
当函数被调用时会产生一个新的作用域。

全局作用域

当你在文档中编写JavaScript时,你就已经在全局作用域中。在一个js文档中有且仅有一个全局作用域。如果在函数外定义一个变量,那么它的作用域就是全局的(全局都可访问到该变量)。

// 默认作用域为全局作用域
var name = 'Hammad';

在全局作用域中的变量可以在页面任何的作用域里被访问和更改。

var name = 'Hammad';

console.log(name); // logs 'Hammad'

function logName() {
    console.log(name); // 'name' 可在任何作用域中被访问
}

logName(); // logs 'Hammad'

局部作用域

在函数内部定义的变量的作用域为局部作用域。当函数被不同的方法调用时,变量也会获得不同的作用域。这意味着我们可以在不同的函数中使用拥有同样变量名的的变量。这是因为这些变量被绑定到了各自的函数中,有着自己独立的作用域,彼此之间没有联系、不能互相访问到。

// 全局作用域
function someFunction() {
    // 局部作用域 #1
    function someOtherFunction() {
        // 局部作用域 #2
    }
}

// 全局作用域
function anotherFunction() {
    // 局部作用域 #3
}
// 全局作用域

块语句

块语句,例如 ifswitch 这样的条件语句,或是 forwhile这样的循环语句,不同于函数的是,它们不会创建出新的作用域。在块语句内定义的变量将会保留在全局作用域内。

[译者注]:由于JavaScript的变量作用域实际上是函数内部,我们在for循环等语句块中是无法定义具有局部作用域的变量的。

if (true) {
    // if条件语句不会创建新的作用域
    var name = 'Hammad'; // name依旧在全局作用域中
}

console.log(name); // logs 'Hammad'

ECMAScript 6 引入了 letconst 关键字,这些关键字可以替代var关键字。

var name = 'Hammad';
let likes = 'Coding';
const skills = 'Javascript and PHP';

不同于 var 的是,在块语句中以letconst 代替 var声明变量,可以在块语句中为其创建出局部作用域。``

if (true) {
    // 使用if的条件语句不会创建出局部作用域

    // 由于使用了var声明变量,name的作用域是全局作用域
    var name = 'Hammad';
    // likes由let声明,所以在块语句的局部作用域中
    let likes = 'Coding';
    // skills由let声明,所以在块语句的局部作用域中
    const skills = 'JavaScript and PHP';
}

console.log(name); // logs 'Hammad'
console.log(likes); // Uncaught ReferenceError: likes is not defined 引用错误
console.log(skills); // Uncaught ReferenceError: skills is not defined 引用错误

Global scope lives as long as your application lives. Local Scope lives as long as your functions are called and executed.
只要你的应用在运行,全局作用域就一直存在。而局部作用域只会在函数被调用执行时才会存在。

上下文

许多开发者都会将作用域上下文混淆,就好像它们引用相同的概念一样。但并非如此,作用域我们在上面进行了阐述,上下文是在代码的某些特定部分中引用到的一些外部参数值。([译者注]:也就是代码的执行环境)。
作用域是指变量是否可读,上下文是指在同一作用域内的值。我们也可以。在全局作用作用域中,上下文永远是window对象。

每一段程序都有很多外部变量。只有像Add这种简单的函数才是没有外部变量的。一旦你的一段程序有了外部变量,这段程序就不完整,不能独立运行。你为了使他们运行,就要给所有的外部变量一个一个写一些值进去。这些值的集合就叫上下文。

// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
console.log(this);

function logFunction() {
    console.log(this);
}
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// 因为logfunction()不是一个对象的属性
logFunction(); 

如果作用域在一个方法或者对象中,它的上下文将会是这个方法所在的对象。

class User {
    logName() {
        console.log(this);
    }
}

(new User).logName(); // logs User {}

(new User).logName() 是一种简洁的调用方式,在一个变量中存储对象,然后调用指定的函数就可以了。。这样的话就不需要创建一个新的变量了。

你会注意到的一点是,如果你用 new 关键字调用函数,它的上下文环境将会有所改变,它会被设置为被调用函数的实例的上下文。考虑上面的示例,通过 new
关键字调用的函数。

function logFunction() {
    console.log(this);
}

new logFunction(); // logs logFunction {}

在严格模式下调用函数,它的上下文将会默认为undefined

执行上下文

上面我们提到了作用域与上下文,为了消除歧义,“执行上下文”这个词中的“上下文”意指“作用域”而不是我们上面提到的“上下文”。这是一个怪异的命名规范,但是基于JavaScript的规范,我们得把它们联系到一块儿去。

JavaScript是一个单线程语言,所以一次只能执行一个任务。其余的任务要在执行上下文中排队等待。正如我们上面说到的,当JavaScript编译器开始执行代码时,它的上下文环境将被设置为全局(也就是说它的作用域是全局作用域)。这个全局上下文被插入到执行上下文中,实际上也是第一个启动执行上下文的上下文。

再之后,每个函数在调用时都会将它的上下文注入到执行上下文中。当另一个函数在其他地方或是在该函数中调用后,也会发生同样的事情。

一个函数在被调用时都会创建出它自己的执行上下文。

一旦浏览器执行完那段上下文中的代码后,那段上下文将会从执行上下文中销毁掉,且它在执行上下文中的当前状态会被传递到它的父级上下文中。浏览器总是执行执行上下文堆栈顶部的上下文(这实际上是代码中层次最深的作用域)。

无论函数的上下文有多少个,代码中有且仅有一个全局上下文。

执行上下文分为创建和执行两个阶段。

创建阶段

创建阶段是执行上下文的第一个阶段,它发生在函数被调用但尚未执行的时候。以下是创建阶段会做的操作:

  • 创建变量对象
  • 创建作用域链
    *设置上下文环境。(this的值)
变量对象

变量对象,也被称为是激活对象,它囊括了所有的变量、函数以及定义在执行上下文其他分支中的声明。当一个函数被调用,浏览器解析器会预先加载所有资源,包括函数、变量和其他声明。这些在被包装成一个单独的对象后,就会成为变量对象。

'variableObject': {
    // 包含函数、参数、内部变量和函数声明
}
作用域链

在执行上下文的创建阶段,作用域链的创建在变量对象创建之后。作用域链本身包含了变量对象。作用域链用于解析这些变量。当要解析一个变量时,JavaScript会从代码嵌套的最内层开始并逐步向父级作用域查找,知道找到它要解析的变量或是相关的资源为止。作用域链可以被简单的定义为一个包含了变量对象和它的执行上下文、以及他们的父级的执行上下文,作用域链是一个拥有其他很多对象的对象。

'scopeChain': {
    // 包含了它自己的变量对象以及它父级的变量对象的执行上下文
}
执行上下文对象

执行上下文可以被抽象成这样的一个对象:

executionContextObject = {
    'scopeChain': {}, //  包含了它自身的变量对象和其父级执行上下文内的变量对象
    'variableObject': {}, // 包含了函数、内部变量和函数声明
    'this': this 的值
}
代码执行阶段

代码执行阶段为执行上下文创建的第二阶段,代码和数值的操作将会被执行。

词法作用域

词法作用域表示在一组嵌套的函数中,内部函数可以访问到它外层父级函数中的变量和其他一些资源。这意味着子函数的词法作用域绑定了它父级函数的执行上下文。词法作用域也被称为静态作用域。

function grandfather() {
    var name = 'Hammad';
    // likes 无法被访问
    function parent() {
        // name 可以被访问
        // likes 无法被访问
        function child() {
            // 这是嵌套里的最深层级
            // name 可以被访问
            var likes = 'Coding';
        }
    }
}

你会发现词法作用域只能向前(自外而内)作用,name可以被嵌套的内部函数的执行上下文访问。但是这是单向的,不能向后(自内而外),likes 不能被它的父级函数访问到。这也告诉我们不同的执行上下文中的同名的变量会按照在执行堆栈中的顺序自上而下执行。一个变量若和另一个变量同名,嵌套中最深层的函数(、执行堆栈中最上层的上下文)会有更高的优先权(先被赋值或使用)。

闭包

闭包的概念和我们上面说过的词法作用域很相似,闭包是在一个内部函数试图访问作用域链中它的外层函数、也就是词法作用域之外的变量时产生的。闭包里包含它自己的作用域链,而作用域链里有它们父级以及全局的作用域。

闭包不仅可以访问它的外部函数中定义的变量,还可以访问外部函数的参数。

即使闭包的外部函数被返回了,闭包依旧可以访问外部函数中的变量。这使得被返回的函数也可以访问它外部函数中的资源。

当从一个函数中返回它的一个内部函数,当你试图调用外部函数时,那个被返回的函数不会被调用。我们必须先将要调用的外部函数保存在一个单独的变量中,然后再调用这个函数,可参考下面的例子:

function greet() {
    name = 'Hammad';
    return function () {
        console.log('Hi ' + name);
    }
}

greet(); // 什么也不会发生,也没有错误

// greet()中返回的函数被保存在greetLetter中
greetLetter = greet();

 // 调用的greetLetter 函数调用了greet()函数中被返回的函数
greetLetter(); // logs 'Hi Hammad'

我们要注意的一点是,greetLetter这个函数可以访问到 greet 这个函数中的 name 变量,及时它被返回了。一种不需要靠重新声明变量就从 greet 函数中调用其返回的函数的方法是使用两次括号 () ,用 ()() 进行调用,如下:

function greet() {
    name = 'Hammad';
    return function () {
        console.log('Hi ' + name);
    }
}

greet()(); // logs 'Hi Hammad'

公共作用域和私有作用域

在其他很多编程语言中,你可以设置变量、方法或者类是否可被访问,例如使用 publicprivateprotected等关键词去声明。可参考如下的PHP代码:

// 公共作用域
public $property;
public function method() {
  // ...
}

// 私有作用域
private $property;
private function method() {
  // ...
}

// 被保护的作用域
protected $property;
protected function method() {
  // ...
}

在公共作用域(全局作用域)中封装函数可以让它们免于被攻击。而在JavaScript中,并没有公共作用域和私有作用域的概念。但是我们可以用闭包模拟这一特性。为了保持不被全局的资源访问的状态,我们需要像下面这样封装函数:

(function () {
  // 私有作用域
})();

函数末尾的圆括号会告诉浏览器,即使在没有被调用的情况下,读取完成后就立即执行函数。我们可以在其中添加函数和变量,且它们不会被外界访问到。但如果我们想要在外面访问它,也就是说我们希望它们中的一部分是公共的一部分是私有,要怎么办呢?有一种闭包是我们可以使用的,它叫做模块模式。这种方式允许我们在一个对象里既可以用公共的方式定义变量,又可以用私有的方式定义变量。

模块模式

模块模式的写法如下:

var Module = (function() {
    function privateMethod() {
        // 主代码
    }

    return {
        publicMethod: function() {
            // 可以调用 privateMethod();
        }
    };
})();

这个模块里返回的语句中就包含了公共函数,而私有的函数并没有被返回,这就保证了在该模块的命名空间下的外部函数无法访问没有被返回的函数。但是公共函数可以访问私有函数,这些私有函数可以做一些其他的操作辅助公共函数,比如ajax请求等。

Module.publicMethod(); // 可执行
Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined  引用错误,privateMethod并未定义

私有函数的命名有一个惯例,它们通常以下划线_作为开头,然后返回一个包含公共函数的匿名对象。这使得它们在一个很长的对象中非常便于管理。比如下面这样:

var Module = (function () {
    function _privateMethod() {
        // 代码
    }
    function publicMethod() {
        // 代码
    }
    return {
        publicMethod: publicMethod,
    }
})();
立即执行函数(IIFE)

闭包还有一种类型,就是立即执行函数(IIFE)。这是一个在window这个全局上下文中自执行的匿名函数,也就是说它的this指向的是window。它会暴露出一个可供交互的单一接口。如下所示:

(function(window) {
    // 代码
})(this);

用.call(), .apply() and .bind()来更改上下文

call 函数和 apply 函数被用来在调用一个函数时更改该函数的上下文。这可以让你更好的编程(可拥有一些终极权限来驾驭代码)。你只需要在要调用的函数名后使用 callapply 去调用,并为 callapply 传入你指定的上下文作为第一个参数即可。而这个函数自身所需要的参数放在以上下文作为第一参数的参数之后即可。

function hello() {
    // 代码
}

hello(); // 我们常用的调用方式
hello.call(context); //在这里你可以将context(this的指向)作为第一个参数传值
hello.apply(context); // 在这里你可以将context(this的指向)作为第一个参数传值

.call() and .apply() 的区别在于,在 .call() 中,传递其余的参数时,以逗号 '作为间隔的字符串即可;而在 .apply() 中,其余的参数以数组的形式传递。

function introduce(name, interest) {
    console.log('Hi! I\'m '+ name +' and I like '+ interest +'.');
    console.log('The value of this is '+ this +'.')
}

introduce('Hammad', 'Coding'); // 我们通常调用函数的方式
introduce.call(window, 'Batman', 'to save Gotham'); // 以逗号分隔的字符串形式传递参数
introduce.apply('Hi', ['Bruce Wayne', 'businesses']); // 以数组的形式包装参数传参

// Output:
// Hi! I'm Hammad and I like Coding.
// The value of this is [object Window].
// Hi! I'm Batman and I like to save Gotham.
// The value of this is [object Window].
// Hi! I'm Bruce Wayne and I like businesses.
// The value of this is Hi.

Call的执行要比Apply稍快一些。

下面的这个例子会将文档中的项目列表逐个打印到控制台。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Things to learn</title>
</head>
<body>
    <h1>Things to Learn to Rule the World</h1>
    <ul>
        <li>Learn PHP</li>
        <li>Learn Laravel</li>
        <li>Learn JavaScript</li>
        <li>Learn VueJS</li>
        <li>Learn CLI</li>
        <li>Learn Git</li>
        <li>Learn Astral Projection</li>
    </ul>
    <script>

        // 将列表中所有项目的NodeList保存在listItems中
        var listItems = document.querySelectorAll('ul li');

        // 遍历listItems NodeList中的每个节点,并记录下它的内容
        for (var i = 0; i < listItems.length; i++) {
          (function () {
            console.log(this.innerHTML);
          }).call(listItems[i]);
        }

        // Output logs:
        // Learn PHP
        // Learn Laravel
        // Learn JavaScript
        // Learn VueJS
        // Learn CLI
        // Learn Git
        // Learn Astral Projection
    </script>
</body>
</html>

这个HTML中只包含了无序的项目列表。接着JavaScript会将他们从DOM中全部选出。列表会被循环遍历知道所有的条目都被记录。在循环中,我们将每个条目的内容都打印在控制台中。

log语句被包装在一个函数中,这个函数又被包装在被调用的函数的括号中。相符合的列表项会被传递到调用的函数中,所以会在控制台中打印出正确的结果。

Objects can have methods, likewise functions being objects can also have methods. In fact, a JavaScript function comes with four built-in methods which are:
对象可以拥有方法,就像函数作为对象拥有方法一样。事实上,一个JavaScript函数拥有四种内置方法:

  • Function.prototype.apply()
  • Function.prototype.bind() (ECMAScript 5 (ES5)中被引入)
  • Function.prototype.call()
  • Function.prototype.toString()

Function.prototype.toString()以字符串形式返回该函数的源代码。

现在,我可以来聊聊 .call() , .apply() 以及 toString()。不像Call和Apply,Bind自身不会调用函数,他只能在函数被调用之前用来为其绑定新的上下文。上述例子用Bind来操作,可如下:

(function introduce(name, interest) {
    console.log('Hi! I\'m '+ name +' and I like '+ interest +'.');
    console.log('The value of this is '+ this +'.')
}).bind(window, 'Hammad', 'Cosmology')();

// logs:
// Hi! I'm Hammad and I like Cosmology.
// The value of this is [object Window].

Bind 和 Call函数相同,它可以用以逗号为分隔的字符串的方式进行传参,而不是和Apply一样用数组的方式传参。

结语

这些都是JavaScript中很基本的概念,如果你想要对JavaScript有更深层次的认识与运用,一定要好好理解。很希望你可以通过这篇文章对JavaScript中的作用域有更好的理解。如果你有任何问题,都可以在下面的评论。
愿你们都能拥有美好的编程体验。
[译者注:最后上一张原文作者帅照]

image.png

<h1 style="text-align:center">Hammad Ahmed </h1>

推荐阅读更多精彩内容