理解JavaScript中的作用域

发布于 大漠

JavaScript有一个名为scope的特性。尽管对于许多新开发人员来说,scope的概念并不是那么容易理解,但我还是会尽我最大的努力,用最简单的内容向你们阐述scope。理解scope将使你的代码脱颖而出,减少错误,并帮助您使用它做出强大的设计模式。

什么是Scope

scope是在运行时,代码中某些特定部分的变量、函数和对象的可访问性。换句话说,scope决定了代码中变量和其他资源的可见性。

Scope最少存取原则

在你的代码中的任何变量并不是到处都可用的,也就是限制变量的可见性。这样做的优点是,scope为代码提供了一定程度的安全性。计算机安全的一个共同原则是,用户只能一次访问他们需要的东西。

可以将其比作为是计算机管理员。由于他们对公司的系统有很大的控制权,所以向他们提供完全的访问用户账号似乎是可以的。假设你的公司拥有三个管理员,他们都能完全访问系统,一切都很顺利。但突然发生了一些不好的事情,你的系统中有一个被恶意病毒感染了。现在你不知道那是谁的错了?你意识到应该使用基本的用户账号,并且只在需要的时候授予完全的访问权限。这将帮助您能更好的跟踪更改并记录谁做了什么。这被称为最小访问原则。看起来直观吗?这一原则也适用于编程语言设计,它在大多数编程语言中称为Scope(范围或作用域),包括我们接下来要学习的JavaScript。

当你继续在阅读这篇文章时,你将会意识到你的代码的scope有助于提高效率,跟踪Bug。当变量具有相同的名称但在不同的作用域时,作用域也解决了命名问题。记住不要混淆范围(scope)和上下文(context)。它们都是具有不同的特性。

JavaScript中的作用域

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

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

函数内部定义的变量存在于局部作用域,而在函数外部定义的变量存在于全局作用域。当调用时,每个函数都创建一个新的作用域。

全局作用域

当你在document中开始写JavaScript时,你已经就在全局作用域内了。在整个JavaScript的document中,只有一个全局作用域。如果变量在函数之外定义,则在全局作用域内。

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

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

var name = 'Hammad';

console.log(name); // => 'Hammad'

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

logName(); // => 'Hammad'

局部作用域

函数内部定义的变量在局部作用域内。每次调用一个函数都有不同的作用域。这意味着,具有相同名称的变量可以在不同的函数中使用。这是因为这些变量绑定到各自的函数,每个函数有不同的作用域,在其他函数中是不可访问。

// Global Scope
function someFunction() {
    // Local Scope #1
    function someOtherFunction() {
        // Local Scope #2
    }
}

// Global Scope
function anotherFunction() {
    // Local Scope #3
}
// Global Scope

块语句

ifswitch这样的条件块语句以及while循环块语句与function不同,他们不会创建新的scope。在块语句内部定义的变量将保留在它们已经存在的作用域内。

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

console.log(name); // => 'Hammad'

在ES6中引入了letconst关键词。这些关键词可以替代var关键词。

var name = 'Hammad';

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

var关键词相反,letconst关键词支持在块语句中声明的局部作用域。

if (true) {
    // 'if' 条件语句块内不会创建一个作用域

    // name 存在全局作用域内,因为是 'var' 关键词声明的变量
    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

全局作用域一直存在于你的整个应用程序中。局部作用域名只有调用并执行了函数才存在。

上下文(context)

许多开发人员经常混淆作用域(scope)和上下文(context),很多时候误解为它们是相同的一个概念。但事实并非如此。我们在上面讨论了作用域(scope),而上下文(context)是用来指定代码中某些特定部分中的this值。作用域是指变量的可访问性,上下文是指this在同一作用域内的值。我们也可以使用用函数的方法来改变上下文,稍后我们会讨论这方面的知识。在全局作用域中上下文始终是Window对象。

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

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

// => Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// logFunction() 不是一个对象的属性
logFunction(); 

取决于JavaScript 的宿主换环境,在浏览器中在全局作用域(scope)中上下文中始终是Window对象。在Node.js中在全局作用域(scope)中上下文中始终是Global 对象

如果作用域在对象的方法中,则上下文将是该方法所属的对象。

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)

上面我们了解了作用域和上下文,为了消除混乱,特别需要注意的是,执行期上下文中的上下文这个词语是指作用域而不是上下文。这是一个奇怪的命名约定,但由于JavaScipt规范,我们必须链接他们之间的联系。

JavaScript是一种单线程语言,因此它一次只能执行一个任务。其余的任务在执行期上下文中排队。正如我刚才所说,当 JavaScript 解释器开始执行代码时,上下文(作用域)默认设置为全局。这个全局上下文附加到执行期上下文中,实际上是启动执行期上下文的第一个上下文。

之后,每个函数调用(启用)将其上下文附加到执行期上下文中。当另一个函数在该函数或其他地方被调用时,会发生同样的事情。

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

一旦浏览器完成了该上下文中的代码,那么该上下文将从执行期上下文中销毁,并且执行期上下文中的当前上下文的状态将被传送到父级上下文中。 浏览器总是执行堆栈顶部的执行期上下文(这实际上是代码中最深层次的作用域)。

无论有多少个函数上下文,但是全局上下文只有一个。

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

创建阶段

第一阶段是创建阶段,当一个函数被调用但是其代码还没有被执行的时。 在创建阶段主要做的三件事情是:

  • 创建变量(激活)对象
  • 创建作用域链
  • 设置上下文(context)的值( this

变量对象

变量对象,也称为激活对象,包含在执行期上下文中定义的所有变量,函数和其他声明。当调用函数时,解析器扫描它所有的资源,包括函数参数,变量和其他声明。包装成一个单一的对象,即变量对象

'variableObject': {
    // 包含函数参数,内部变量和函数声明
}

作用域链

在执行期上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。作用域链用于解析变量。当被要求解析变量时,JavaScript 始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量或其他任何资源为止。作用域链可以简单地定义为包含其自身执行上下文的变量对象的对象,以及其父级对象的所有其他执行期上下文,一个具有很多其他对象的对象。

'scopeChain': {
    // 包含自己的变量对象和父级执行上下文的其他变量对象
}

执行期上下文对象

执行期上下文可以表示为一个抽象对象,如下所示:

executionContextObject = {
    'scopeChain': {}, // 包含自己的变量对象和父级执行上下文的其他变量对象
    'variableObject': {}, // 包含函数参数,内部变量和函数声明
    'this': valueOfThis
}

词法作用域

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

function grandfather() {
    var name = 'Hammad';
    // likes 在这里不可以被访问
    function parent() {
        // name 在这里可以被访问
        // likes 在这里不可以被访问
        function child() {
            // 作用域链最深层
            // name 在这里也可以被访问
            var likes = 'Coding';
        }
    }
}

你会注意到词法作用域向内传递的,意味着 name 可以通过它的子级期执行期上下文访问。但是,但是它不能向其父对象反向传递,意味着变量 likes 不能被其父对象访问。这也告诉我们,在不同执行上下文中具有相同名称的变量从执行堆栈的顶部到底部获得优先级。在最内层函数(执行堆栈的最上层上下文)中,具有类似于另一变量的名称的变量将具有较高优先级。

闭包( Closures)

闭包的概念与我们在上面讲的词法作用域密切相关。 当内部函数尝试访问其外部函数的作用域链,即在直接词法作用域之外的变量时,会创建一个闭包。 闭包包含自己的作用域链,父级的作用域链和全局作用域。

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

即使函数返回后,闭包也可以访问其外部函数的变量。这允许返回的函数保持对外部函数所有资源的访问。

当从函数返回内部函数时,当您尝试调用外部函数时,不会调用返回的函数。您必须首先将外部函数的调用保存在单独的变量中,然后将该变量调用为函数。考虑这个例子:

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'

公共作用域和私有作用域

在许多其他编程语言中,您可以使用公共,私有和受保护的作用域来设置类的属性和方法的可见性。考虑使用PHP语言的这个例子:

// Public Scope
public $property;
public function method() {
    // ...
}

// Private Sccpe
private $property;
private function method() {
    // ...
}

// Protected Scope
protected $property;
protected function method() {
    // ...
}

来自公共(全局)作用域的封装函数使他们免受脆弱的攻击。但是在JavaScript中,没有公共或私有作用域。幸好,我们可以使用闭包来模拟此功能。为了保持一切与全局分离,我们必须首先将我们的函数封装在如下所示的函数中:

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

函数末尾的()会告知解析器在没有调用的情况下一旦读取完成就立即执行它。我们可以在其中添加函数和变量,它们将不能在外部访问。但是,如果我们想在外部访问它们,也就是说我们希望其中一些公共的,另一些是私有的?我们可以使用一种称为 模块模式 的闭包类型,它允许我们使用对象中公共和私有的作用域来对我们的函数进行调整。

模块模式

模块模式类似这样:

var Module = (function() {
    function privateMethod() {
        console.log('The Private Method')
    }

    return {
        publicMethod: function() {
            console.log('Can call PrivateMethod Function')
        }
    };
})();

Module 中的 return 语句包含了公共的函数。私有函数只是那些没有返回的函数。没有返回的函数不可以在 Module 命名空间之外访问。但是公共函数可以访问私有函数,这使它们对于助手函数,AJAX调用和其他事情很方便。

Module.publicMethod();  // => Can call PrivateMethod Function
Module.privateMethod(); // => Uncaught ReferenceError: privateMethod is not defined

私有函数一个惯例是用下划线开始,并返回一个包含我们公共函数的匿名对象。这使得它们很容易在长对象中管理。它看起来是这样子的:

var Module = (function () {
    function _privateMethod() {
        // do something
    }
    function publicMethod() {
        // do something
    }
    return {
        publicMethod: publicMethod,
    }
})();

立即执行函数表达式(IIFE)

另一种类型的闭包是立即执行函数表达式(IIFE)。这是一个在 window 上下文中调用的自动调用的匿名函数,这意味着 this的值为window。暴露一个单一的全局接口来进行交互。他是这样的:

(function(window) {
    // do anything
})(this);

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

.call().apply()函数用于在调用函数时改变上下文。这给了你令人难以置信的编程能力(和一些终极权限来驾驭代码)。要使用call()apply()函数,您只需要在函数上调用它,而不是使用一对括号调用函数,并将新的上下文作为第一个参数传递。函数自己的参数可以在上下文之后传递。

function hello() {
    // do something...
}

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

call()apply()用另一个对象来调用一个方法,将一个函数上下文从初始的上下文改变为指定的新对象。简单的说就是改变函数执行的上下文。

.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中保存页面上所有列表项的NodeList
            var listItems = document.querySelectorAll('ul li');
            // 循环遍历listItems NodeList中的每个节点,并记录其内容
            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() 函数包含在调用函数中的括号中。将相应的列表项传递给调用函数,以便控制台语句中的 this 关键字记录正确对象的 innerHTML

对象可以有方法,同样的函数对象也可以有方法。 事实上,JavaScript函数附带了四种内置方法:

  • Function.prototype.apply()
  • Function.prototype.bind()
  • 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')();

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

.bind() 就像.call()函数一样,它允许你传递其余的参数,用逗号分隔,而不是像apply(),在数组中传递参数。

结论

这些概念是 JavaScript 的根本,对于了解高级语法很重要。我希望你能更好地了解JavaScript作用域和他相关的事情。如果没用弄明白这些问题,欢迎在下面的评论中提问。

本文根据@Hammad Ahmed的《Understanding Scope in JavaScript》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://scotch.io/tutorials/understanding-scope-in-javascript

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/understanding-scope-in-javascript.htmlWomen's Singlets - Stussy, ?tzi, Champion, Nike, Adidas & More!