[重要] 深入理解 JavaScript 中的作用域和上下文

在本教程中,我们将深入学习 JavaScript 中作用域(Scope)的一切。 所以,来吧。

01介绍

JavaScript中有一个被称为作用域(Scope)的特性。虽然对于许多新手开发者来说,作用域的概念并不是很容易理解,我会尽我所能用最简单的方式来解释作用域。理解作用域将使你的代码脱颖而出,减少错误,并帮助您使用它强大的设计模式。

什么是作用域(Scope)?

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

为什么说作用域是最小访问原则?

那么,为什么要限制变量的可见性呢,为什么你的变量不是在代码的任何地方都可用呢?一个优点是作用域为您的代码提供了一定程度的安全性。计算机安全的一个常见原则是用户应该一次只能访问他们需要的东西。

想象一下计算机管理员。由于他们对公司的系统有很多控制权限,因此向他们授予超级管理员权限就好了。他们都可以完全访问系统,一切工作顺利。但突然发生了一些坏事,你的系统感染了恶意病毒。现在你不知道谁犯的错误?你意识到应该授予普通用户权限,并且只在需要时授予超级访问权限。这将帮助您跟踪更改,并记录谁拥有什么帐户。这被称为最小访问原则。看起来很直观?这个原则也适用于编程语言设计,在大多数编程语言中被称为作用域,包括我们接下来要研究的 JavaScript 。

当你继续在你的编程旅程,您将意识到,您的代码的作用域有助于提高效率,帮助跟踪错误并修复它们。作用域还解决了命名问题,在不同作用域中变量名称可以相同。记住不要将作用域与上下文混淆。它们的特性不同。

02JavaScript中的作用域

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

  • 全局作用域
  • 局部作用域(也叫本地作用域)

定义在函数内部的变量具有局部作用域,而定义在函数外部的变量具有全局范围内。每个函数在被调用时都会创建一个新的作用域。

全局作用域

当您开始在文档中编写JavaScript时,您已经在全局作用域中了。全局作用域贯穿整个javascript文档。如果变量在函数之外定义,则变量处于全局作用域内。

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

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

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

局部作用域

函数内定义的变量在局部(本地)作用域中。而且个函数被调用时都具有不同的作用域。这意味着具有相同名称的变量可以在不同的函数中使用。这是因为这些变量被绑定到它们各自具有不同作用域的相应函数,并且在其他函数中不可访问。

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

块语句

块语句,如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。在块语句中定义的变量将保留在它们已经存在的作用域中。

if (true) {     
    // 'if' 条件语句块不会创建一个新的作用域     
    var name = 'Hammad'; // name 依然在全局作用域中 
} 
console.log(name); // logs 'Hammad'

ECMAScript 6 引入了 let 和 const 关键字。可以使用这些关键字来代替 var 关键字。

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

与 var 关键字相反,let 和 const 关键字支持在局部(本地)作用域的块语句中声明。

if (true) {     
    // 'if' 条件语句块不会创建一个新的作用域     
    // name 在全局作用域中,因为通过 'var' 关键字定义     
    var name = 'Hammad';     
    // likes 在局部(本地)作用域中,因为通过 'let' 关键字定义     
    let likes = 'Coding';     
    // skills 在局部(本地)作用域中,因为通过 'const' 关键字定义
    const skills = 'JavaScript and PHP'; 
} 
console.log(name); // logs 'Hammad'
//Uncaught ReferenceError:likes is not defined
console.log(likes); 
//Uncaught ReferenceError: skills is not defined
console.log(skills);

只要您的应用程序生活,全球作用域就会生存。 只要您的函数被调用并执行,局部(本地)作用域就会存在。

03上下文

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

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

// 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() 是一种将对象存储在变量中然后调用logName函数的简单方法。在这里,您不需要创建一个新的变量。

您会注意到,如果您使用 new 关键字调用函数,则上下文的值会有所不同。然后将上下文设置为被调用函数的实例。考虑上面的示例,通过 new关键字调用的函数。

function logFunction() {     
    console.log(this); 
} 
new logFunction(); // logs logFunction {}

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

04执行期上下文(Execution Context)

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

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

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

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

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

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

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

创建阶段

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

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

变量对象

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

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

作用域链

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

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

执行期上下文对象

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

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

代码执行阶段

在执行期上下文的第二阶段,即代码执行阶段,分配其他值并最终执行代码。

05词法作用域

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

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

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

06闭包( Closures)

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

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

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

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

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'

07公共作用域和私有作用域

在许多其他编程语言中,您可以使用公共,私有和受保护的作用域来设置类的属性和方法的可见性。考虑使用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() {         
        // do something     
    }    
    return {         
        publicMethod: function() {             
            // can call privateMethod();         
        }     
    }; 
})();

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

Module.publicMethod(); // 可以正常工作 
// Uncaught ReferenceError: privateMethod is not defined
Module.privateMethod(); 

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

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函数,您只需要在函数上调用它,而不是使用一对括号调用函数,并将新的上下文作为第一个参数传递。

函数自己的参数可以在上下文之后传递。(call或apply用另一个对象来调用一个方法,将一个函数上下文从初始的上下文改变为指定的新对象。简单的说就是改变函数执行的上下文。)

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

// 在这里你可以传递上下文(this 值)作为第一个参数
hello.apply(context); 

.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> <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]);         }         // Output logs:         // 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() ( 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,在数组中传递参数。

08结论

这些概念是 JavaScript 的根本,对于了解高级语法很重要。我希望你能更好地了解JavaScript作用域和他相关的事情。

原文发布于微信公众号 - 胡哥有话说(hugeyouhuashuo)

原文发表时间:2019-06-15

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券