翻译《理解 JavaScript 中的作用域》

尝试翻译一下

发现于InfoQ《前端开发周报:Vue 2.2发布,React在GitHub突破6万star》
《理解 JavaScript 中的作用域》:JavaScript 中的作用域、闭包以及上下文绑定一直是令人凌乱的知识,此文作者详细地从函数作用域、块作用域、词法作用域、闭包等进行详细阐述,值得一读做个梳理。

原文链接Understanding Scope in JavaScript https://scotch.io/tutorials/understanding-scope-in-javascript

Contact me if any infringement

以下为翻译内容(使用 Markdown 语法编辑):


在本文中,我们将了解关于JavaScript中作用域(Scope)的一切,开始吧。

前言

JavaScript有个特性叫做作用域,但是对于很多新手来说,作用域的概念并不是那么容易理解,我会尽可能用简单的方式想你解释这些概念。理解作用域有助于写出更棒的代码,减少编码错误甚至运用它作出很棒的设计模式。

什么是作用域?

作用域是代码在运行过程中,变量(variable)、函数(function)、对象(object)的可访问性(accessibility)。也就是说,作用域决定了变量及其他资源在代码中不同位置的可见性。

为什么会有作用域?最小访问的原则

为什么要限定变量的可见性,而不是让他们在代码中的任何地方都可访问?其中一个好处是,作用域为你的代码提供了一定程度的安全性。就像在计算机安全性方面,一个通用的原则是,用户应该只能够获取到他们需要的资源的访问权限。

想象一下,计算机系统管理员需要管理和控制公司内所有的系统,给他们最高访问权限的用户账号似乎没什么问题。假如你有一家公司并有三位系统管理员帮你管理所有的系统,他们都有所有系统的完全访问权限并且一切都有条不紊,但突然某个系统感染了病毒,却没有办法确定是谁导致的这个问题。你会意识到,如果给他们基本的用户账号并相应的只给他们各自需要管理的系统授予完全的访问权限,会帮助你跟踪系统变化并找到哪位管理员的账号导致的。这就是最小访问原则的体现。这个例子很直观吧。这个原则运用在编程语言设计上,就有了在绝大数编程语言中称为作用域的概念,包括JavaScript。

在编写代码的过程中,作用域帮助你提升效率,暴露并减少bug。它也会帮你解决变量命名问题,比如当你想在不同的作用域中使用相同的变量名时。需要记住的是,别把作用域(Scope)跟上下文(context)混淆,他们是不同的特性。

JavaScript中的作用域

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

全局作用域(Global Scope)和本地作用域(Local Scope)

举例来说,变量定义在函数内部就在本地作用域,而定义在函数之外就在全局作用域。每个函数在执行的时候创建一个新的作用域。

全局作用域(Global Scope)

当你开始在文件中编写JavaScript代码时,就已经在全局作用域中了,在一个JavaScript文件中只有一个全局作用域(译者注:前端工程化等原因,这里更要考虑代码在运行时,是否会在一个文档(document)中,而不是在开发时是否在一个物理文件中)。如果变量定义在函数的外面,它就在全局作用域中。

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

全局作用域中的变量可以在任何其他作用域中访问和修改它的值。

var name = 'Hammad';

console.log(name); // 输出 'Hammad'

function logName() {
  console.log(name); // 变量 'name' 可以在这里和其他任何地方被访问
}

logName(); // 输出 'Hammad'

本地作用域(Local Scope)

如果变量定义在一个函数的内部,它就在本地作用域中,并且这个函数每次调用(call)都会产生不同的作用域。同名的变量在不同的函数中使用,因为这些变量绑定到了各自不同的函数中,有各自不同的作用域,在某个函数中定义的变量在其他函数中不可访问。

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

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

语句块(Block Statements)

不同于函数,类似 ifswitch 的条件语句块或 forwhile 的循环语句块不创建作用域,定义在语句块中的变量仍然会在他们已经在的作用域中。

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

console.log(name); // 输出 'Hammad'

ECMAScript 6 引入了 letconst 关键字,他们在某些场景可以替代 var 关键字。

var name = 'Hammad';

let likes = 'Coding';
const skills = 'Javascript and PHP';

var 关键字相反,letconst 关键字支持在代码块中声明本地作用域。

if (true) {
  // 'if' 条件语句块不创建新的作用域

  // name 使用 var 关键字,在全局作用域中is in the global scope because of the 'var' keyword
  var name = 'Hammad';
  // likes 使用 let 关键字,在本地作用域
  let likes = 'Coding';
  // skills 使用 const 关键字,在本地作用域
  const skills = 'JavaScript and PHP';
}

console.log(name); // 输出 'Hammad'
console.log(likes); // Uncaught ReferenceError: likes is not defined
console.log(skills); // Uncaught ReferenceError: skills is not defined

只要你的应用(application)在运行,全局作用域就一直存在,而本地作用域生存周期仅限于函数被调用执行的过程中。

上下文(Context)

很多开发者经常混淆作用域和上下文就好像他们引用了相同的概念似的,但实际上不是的。作用域上面已经讨论过了,上下文是用来在特定的代码片段中引用 this 的值。作用域涉及到变量的可见性,而上下文涉及到在相同的作用域内 this 的值。稍后我们将讨论用函数方法改变上下文。在全局作用域,上下文是 Window 对象。

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

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

如果作用域在一个对象的方法中,上下文会是该对象。

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

(new User).logName(); // 输出 User {}

(new User).logName() 是个将对象存储在一个变量中的快捷方法,紧接着可以调用对象的 logName 函数,在这里,不需要创建一个新的变量。

如果用 new 关键字调用函数,你会发现上下文的值会不同。上下文会设置到被调用的函数的实例中去。上面的例子使用 new 关键字用函数调用。

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

new logFunction(); // 输出 logFunction {}

当在严格模式(Strict Mode)下调用函数时,上下文默认会被置为 undefined。

执行上下文(Execution Context)

执行上下文(Execution Context)中,上下文(context)这个词涉及到作用域而不是上下文。这是个怪异的命名习惯,但是因为JavaScript的规范,我们也没办法。

JavaScript是一种单线程语言,所以它同时只能执行一项任务,其他的任务在执行上下文中排队等待。就像之前说明的,当JavaScript解释器开始执行你的代码时,上下文和作用域默认设置成全局,这个全局上下文是第一个上下文,并启动执行上下文,然后附加到你的执行上下文(译者追加:堆栈的底部。参考http://dmitrysoshnikov.com/ecmascript/chapter-1-execution-contexts/ 及 http://www.cnblogs.com/justinw/archive/2010/04/16/1713086.html)。

在这之后,每个函数被调用时,会将自身的执行上下文附加到执行上下文堆栈中,同样的,函数中的函数或其他函数在被调用时都会将自身的执行上下文附加到执行上下文堆栈中。

每个函数都会创建它自己的执行上下文。

一旦浏览器执行完某个执行上下文中的代码,该执行上下文会从队列中弹出,堆栈中的当前执行上下文会变成该执行上下文的父执行上下文。浏览器始终运行堆栈中顶部的执行上下文(实际上是你代码中最内层的执行上下文)。

只会有一个全局上下文,但会有很多函数上下文。

执行上下文有两个阶段,创建和执行代码。

创建阶段

第一个阶段是创建阶段,出现在函数被调用,但代码还没有执行时,会发生以下三件主要的事情:

创建变量对象(Variable Object)

创建作用域链(Scope Chain)

设置上下文的值(this

变量对象(Variable Object)

变量对象,也被成为激活对象(Activation Object),包含所有变量、函数以及其他在执行上下文的特定分支上定义的声明。当一个函数被调用,解释器扫描它的所有相关资源,包括函数的参数、变量以及其他声明,这所有的东西打包成一个单一的对象,就成为一个变量对象。

'variableObject': {
  // 包括函数的参数、内部变量以及其他函数声明
}

作用域链(Scope Chain)

在执行上下文的创建阶段中,作用域链在变量对象之后被创建。作用域链本身包含变量对象,作用域链用于解析变量。当被请求解析一个变量时,JavaScript始终从嵌套代码的最内层开始,一直向父作用域跳回,直到变量或其他所要寻找的资源。作用域链可以简单的被定义为一个含有自己的执行上下文中的变量对象,和所有的父执行上下文中的变量对象,一个含有一堆其他对象的对象。

'scopeChain': {
  // 包含自身及其所有父执行上下文的变量对象
}

执行上下文对象(The Execution Context Object)

执行上下文对象可以表现为一个像下面这样的抽象对象:

executionContextObject = {
  'scopeChain': {}, // 包含自身及其所有父执行上下文的变量对象
  'variableObject': {}, // 包括函数的参数、内部变量以及其他函数声明
  'this': this的值
}

执行代码阶段

执行上下文的的第二个阶段,是代码执行阶段,其他值被分配,而且代码被执行。

语法作用域(Lexical Scope)

语法作用域指的是,在一组嵌套的函数中,内层的函数可以访问其父函数作用域中的变量以及其他资源。这表示子函数在语法上绑定到了其父函数的执行上下文上。语法作用域有时也指静态作用域(Static Scope)。

function grandfather() {
  var name = 'Hammad';
  // likes 变量在这里无法访问
  function parent() {
    // name 变量在这里可以访问
    // likes 变量在这里无法访问
    function child() {
      // 作用域链的最内层
      // name 变量在这里也可以访问
      var likes = 'Coding';
    }
  }
}

你会注意到语法作用域是向前工作,name 变量可以被它的子执行上下文访问,但相反不行,likes 变量不鞥你被它的父执行上下文访问。这也告诉我们同名变量在不同的执行上下文中的优先权是从执行上下文堆栈的顶部到底部。同名变量在最内层函数中(位于执行上下文堆栈的最顶部)拥有更高的优先权。

闭包(Closure)

闭包的概念与我们上面学习的语法作用域有密切的关联。当一个内层函数尝试去访问作用域链中它的外部函数,即当前语法作用域之外的变量时,闭包被创建。

闭包不仅能访问内部定义的变量,还能访问外部函数的参数。

闭包甚至在函数返回后也能访问它的外部函数中的变量。这就允许返回的函数保持对它外部函数的所有资源的访问。

当你在一个函数中返回其一个内层函数,试图调用外层函数时这个返回的函数不会被调用。你必须先把外层函数保存到另一个变量,然后再把这个变量作为一个函数来调用。思考下面的例子:

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

greet(); // 没有输出,没有错误

// greet() 中返回的函数被保存进了 greetLetter 变量
greetLetter = greet();

// 作为一个函数调用 greetLetter,会调用 greet() 函数内返回的函数
greetLetter(); // 输出 'Hi Hammad'

这里关键点是 greetLetter 函数能访问 greet 函数 的 name 变量,就算它已经被返回。从 greet 函数中调用返回的函数的一个不用变量的方法是使用两次小括号 ()(),像下面这样:

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

greet()(); // 输出 'Hi Hammad'

公共和私有作用域(Public and Private Scope)

在许多其他的编程语言中,你可以通过使用公共、私有和受保护的作用域设置类中的属性和方法的可见性。比如使用 PHP 语言:

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

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

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

封装函数离开公共(全局)作用域保护他们不易受攻击。但是在JavaScript中,并没有同样的公共或私有作用域。但是我们能通过使用闭包来模拟这种特性。为了把函数从全局分离出去我们必须首先将其封装到像下面这样的函数里:

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

函数末尾的小括号告诉解释器不用调用,一读到它就执行。我们可以在里面添加函数和变量,在外层它们不会被访问到。但是如果我们想在外面访问怎么办,即我们想让其中一部分变成公共的,另一部分变成私有的?我们可以使用一种类型的闭包,叫做模块模式(Module Pattern),允许我们在一个对象中同时使用公共和私有作用域。

模块模式(Module Pattern)

模块模式像下面这样:

var Module = (function() {
  function privateMethod() {
    // 处理...
  }

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

模块中的返回代码块包含我们的公共函数。私有函数是那些没有返回的函数。在模块名字空间外访问不到没有返回的函数,但是我们的公共函数可以访问私有函数,使他们对帮助类的函数,AJAX调用等使用很方便。

Module.publicMethod(); // 有效
Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined

一个惯例是私有函数名用一个下划线开头,返回一个包含公共函数的匿名对象。这样在一个长的对象中方便管理。像下面这样:

var Module = (function () {
  function _privateMethod() {
    // 处理...
  }
  function publicMethod() {
    // 处理...
  }
  return {
    publicMethod: publicMethod,
  }
})();

立即执行函数表达式(IMMEDIATELY-INVOKED FUNCTION EXPRESSION (IIFE))

另一种类型的闭包是立即执行函数表达式。这是一个在Window上下文中自启动的匿名函数,即 this 的值被设置成 window。这暴露了一个单一的全局交互接口,像下面这样:

(function(window) {
  // 处理...
})(this);

使用.call()、.apply() 和 .bind() 改变上下文

调用函数时,Call 和 Apply 函数用来改变上下文。这给了你难以置信的编程能力(和统治世界的终极力量)。使用 Call 或 Apply 函数,你不用一对括号来启动它,而只需要调用函数并传递上下文作为第一个参数。函数自己的参数可以在上下文之后传递。

function hello() {
  // 处理....
}

hello(); // 通常的调用方式
hello.call(context); // 传递上下文(this的值)作为第一个参数
hello.apply(context); // 传递上下文(this的值)作为第一个参数

.call().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']); // 在上下文后面用一个数组传递参数

// 输出:
// 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>
      // 保存页面上列表内所有的项目到 listItems 变量中
      var listItems = document.querySelectorAll('ul li');
      // 循环 listItems 中每个节点并打印其内容
      for (var i = 0; i < listItems.length; i++) {
        (function () {
          console.log(this.innerHTML);
        }).call(listItems[i]);
      }

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

这段HTML只包含了一个未排序的项目列表。JavaScript代码从DOM中获取了所有的项目,循环这个列表,并在循环中将列表中的项目内容打印到控制台。
打印代码块包装在一个函数内,进而包装在 call 函数调用的小括号内。相应的列表项目传递给 call 函数,打印代码块可以使用 this 关键字将对象的 innerHTML 打印出来。

对象可以有方法,同样的函数对象也可以有。实际上,JavaScript函数有如下4个内置方法:

  • Function.prototype.apply()
  • Function.prototype.bind() (在 ECMAScript 5 (ES5) 引进)
  • Function.prototype.call()
  • Function.prototype.toString()

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

到现在为止,我们已经讨论了 .call().apply()toString()。Bind不像 Call 和 Apply,它自己不调用函数,只用来在调用之前绑定上下文和其他参数的值。

(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')();

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

Bind 像 call 函数一样,允许你用逗号一个一个的传递参数,不像 apply 需要用数组传递参数。

结束语

这些概念是JavaScript的根基,如果你想理解更高级的话题,理解它们非常重要。希望你已经更好理解了JavaScript作用域及相关的概念。如果有什么不明白的地方,请随意在下面的评论区提问。

提升你的代码,Happy Coding!

posted @ 2017-03-02 09:44  DeanJi  阅读(441)  评论(0编辑  收藏  举报