JavaScript的作用域、闭包、(apply, call, bind)

介绍

JavaScript 有一个特征————作用域。理解作用域scope可以使你的代码脱颖而出,减少错误,帮助你用它构造强大的设计模式。

什么是作用域

作用域就是在代码执行期间变量,函数和对象能被获取到的特定的代码范围。换句话说,作用域决定了变量和其他资源在你的代码区域中的可见性。

为什么会有作用域?———最小存取原则

那么,限制变量的可见性不让其在代码中处处可见的意义是什么?优势之一 是作用域使你的代码具备一定的安全性。一个通用的计算机安全性原则就是让用户每次只访问他们需要的东西。

想想计算机管理员:他们需要控制很多公司系统的东西,给他们完全的用户权限似乎是可以理解的。假设一个公司有三个系统管理员,他们都有系统的所有权限,一切进展顺利。但是突然厄运降临,其中一人的系统被可恶的病毒感染了,而现在不知道是谁哪里出错了。现在意识到应该给他们基本权限的用户账户只在他们需要的时候授予他们完全的权限。这会帮助你追踪变动并一直知晓哪个账户做了什么。这就叫做最小存取原则。好像很直观吧,这个原则也用于程序语言设计,在包括JS在内的编程语言中它叫做作用域

当你享受编程之旅时,你会意识到你的代码的作用域部分帮助你提升效率,追踪bug并减少bug。作用域同时解决了你在编程时不同作用域内的同名变量的问题。不要把环境/上下文作用域搞混,他们是不同的。

JavaScript的作用域

JavaScript有两种类型的作用域:

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

定义在函数内部的变量在本地范围内,而定义在函数外部的变量的作用域是全局。每个函数的触发调用都会创建一个新的作用域。

全局作用域

当你开始写JS的时候,你就已经处在全局范围内了,一个变量若不在函数内,便是全局变量。

// the scope is by default global
var name = 'Hammad';

全局范围内的变量可以在其他范围内获取或修改。

var name = 'Hammad';

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

function logName() {
    console.log(name); // 'name' is accessible here and everywhere else
}

logName(); // logs 'Hammad'

局部作用域

定义在函数内的变量就在局部作用域。
每次调用那个函数他们都有不同的作用域,也就是说同名变量可以在不同的函数内使用。因为这些变量与他们各自的函数绑定,各自有不同的作用域,无法在其他函数内获取。

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

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

块语句

ifswitch这种条件语句或forwhile这种循环语句————非函数的块语句,不会创造新的作用域。定义在块语句内的变量将保持他们当前的作用域。

if (true) {
    // this 'if' conditional block doesn't create a new scope
    var name = 'Hammad'; // name is still in the global scope
}

console.log(name); // logs 'Hammad'
ECMAScript 6引入了letconst关键字,可以用于替换var。相比var,后者支持块作用域的声明。
if (true) {
    // this 'if' conditional block doesn't create a scope

    // name is in the global scope because of the 'var' keyword
    var name = 'Hammad';
    // likes is in the local scope because of the 'let' keyword
    let likes = 'Coding';
    // skills is in the local scope because of the 'const' keyword
    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
只要你的应用激活了,全局作用域也就激活了。局部作用域则随着你的函数的调用和执行而激活。

Context————上下文/环境

许多开发者经常把作用域和上下文弄混淆,好像它们是相同的概念。非也。作用域就是我们以上讨论的,而上下文是指你得代码特定区域内this的值。作用域指变量的可见性,上下文指同一范围下this的值。我们可以用函数方法改变上下文,这个稍后讨论。在全局范围内,上下文总是window对象。

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

function logFunction() {
    console.log(this);
}
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// because logFunction() is not a property of an object
logFunction();

如果作用域是一个对象的方法,上下文就是方法所属的对象。

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

(new User).logName(); // logs User {}
(new User).logName() 是一个在变量中存储对象并调用logName的快捷的方法。这里你不需要创建一个新变量。
你可能会注意到一件事情:如果你用new关键字调用函数,上下文的值会改变为所调用的函数的实例。例如:
function logFunction() {
    console.log(this);
}

new logFunction(); // logs logFunction {}

严格模式下上下文默认为undefined

  • 将"use strict"放在脚本文件的第一行,则整个脚本都将以"严格模式"运行。如果这行语句不在第一行,则无效,整个脚本以"正常模式"运行。
  • 如果不同模式的代码文件合并成一个文件,这一点需要特别注意。(严格地说,只要前面不是产生实际运行结果的语句,"use strict"可以不在第一行,比如直接跟在一个空的分号后面。)将"use strict"放在函数体的第一行,则整个函数以"严格模式"运行。
  • 对于脚本,最好将整个脚本文件放在一个立即执行的匿名函数之中。

执行上下文

为了彻底弄清楚以上困惑,在执行上下文中的上下文指的是作用域而不是上下文。这是个奇怪的命名惯例但是因为JavaScript已经明确了它,我们只需记住即可。
JavaScript是一个单线程语言所以他一次只能执行一个任务。剩下的任务在执行上下文中以队列形式存在。正如我之前所说,当JavaScript编译器开始执行代码时,上下文(作用域)就被默认设置为全局的了。这个全局的上下文会添加在执行上下文中,它实际上是启动执行上下文的第一个上下文。
随后,
每个函数请求会添加它的上下文到执行上下文。当函数内的另一个函数或其他地方的函数调用时也一样。

每个函数都会创建自己的执行上下文。
一旦浏览器执行完上下文的代码,上下文会从执行上下文中弹出, 在执行上下文中的当前上下文的状态会被传递给父级上下文。浏览器总会执行在任务栈最顶端的执行上下文(也就是你代码中最内部的作用域)。
只能有一个全局上下文但函数上下文可以有多个。

执行上下文有两个阶段:创建 和 执行。

创建阶段

第一个阶段是创建阶段,是指函数被调用还没有被执行的时期,在创建阶段会做三件事情:

  1. 创建变量对象
  2. 创建作用域链
  3. 设置上下文的值(this

代码执行阶段

第二个阶段是代码执行阶段,这个阶段将为变量赋值,最终执行代码。

词法域

词法域是指在一组函数中,内部函数可以获取到他的父级作用域内的变量和其他资源。这意味这子函数在词法上绑定了父级的执行上下文。词法域有时也指静态域。

function grandfather() {
    var name = 'Hammad';
    // likes is not accessible here
    function parent() {
        // name is accessible here
        // likes is not accessible here
        function child() {
            // Innermost level of the scope chain
            // name is also accessible here
            var likes = 'Coding';
        }
    }
}

您将注意到词法域提前工作,意思是可以通过它的孩子的执行上下文访问name。但它在其父级无效,意味着likes不能被父级访问获取。也就是说,同名变量内部函数的优先权高于外层函数。

闭包

闭包的概念与词法域关系紧密。当一个内部函数试图访问外部函数的作用域链即其词法域外的变量值时,闭包就会被创建了。闭包包含他们自己的的作用域链,他们父级作用域链以及全局的作用域。闭包就是能够读取其他函数内部变量的函数,由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成"定义在一个函数内部的函数"。

闭包不仅可以获取函数内部的变量,也可以获取其外部函数的参数资源。
 var name = "The Window";

  var object = {
    name : "My Object",

    getNameFunc : function(){
      return function(){
        return this.name;
      };

    }

  };

  alert(object.getNameFunc()());   // =>The Window
 var name = "The Window";

  var object = {
    name : "My Object",

    getNameFunc : function(){
      var that = this;
      return function(){
        return that.name;
      };

    }

  };

  alert(object.getNameFunc()());  // My Object

闭包甚至在函数已经返回后也可以获取其外部函数的变量。这允许返回函数一直可以获取其外部函数的所有资源。

当一个函数返回一个内部函数时,即使你调用外部函数时返回函数并不会被请求执行。你必须用一个独立的变量保存外部函数的调用请求,然后以函数形式调用该变量:

function greet() {
    name = 'Hammad';
    return function () {    //这个函数就是闭包
        console.log('Hi ' + name);
    }
}

greet(); // nothing happens, no errors

// the returned function from greet() gets saved in greetLetter
greetLetter = greet();

 // calling greetLetter calls the returned function from the greet() function
greetLetter(); // logs 'Hi Hammad'

同样可以用()()替换变量分配执行的过程。

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

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

闭包最大用处有两个,一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中

公共域和私有域

在许多其他编程语言中,你可以用 public, private and protected设置属性和类的方法的可见性。JavaScript中没有类似的公共域和私有域的机制。但是我们可以用闭包模拟这种机制,为了将所有资源与全局域独立开来,应该这样封装函数:

(function () {
  // private scope
})();

()在函数最后是告诉编译器直接在读到该函数时不用等到函数调用就执行它,我们可以在里面添加函数和变量而不用担心他们被外部获取到。但是如果我们想让外部获取它们即想暴露部分变量或函数供外部修改获取怎么办?模块模式————闭包的一种,支持我们在一个对象内利用公共域和私有域访问审视我们的函数。

模块模式

模块模式:

var Module = (function() {
    function privateMethod() {
        // do something
    }

    return {
        publicMethod: function() {
            // can call privateMethod();
        }
    };
})();

模块的返回语句包含了我们的公共函数。那些没有返回的便是私有函数。没有返回函数使得它们在模块命名空间外无法被存取。但是公共函数可以存取方便我们的辅助函数,ajax请求以及其他需要的函数。

Module.publicMethod(); // works
Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined
一个惯例是私有函数的命名一般以__开头并返回一个包含公共函数的匿名对象。
var Module = (function () {
    function _privateMethod() {
        // do something
    }
    function publicMethod() {
        // do something
    }
    return {
        publicMethod: publicMethod,
    }
})();

Immediately-Invoked Function Expression (IIFE)立即调用函数表达式

另一种形式的闭包叫立即调用的函数表达式,这是一个在window上下文中自我触发的匿名函数,意思就是this的值是window。它可以暴露一个可交互的全局接口。

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

一种常见的闭包导致的bug由立即调用函数表达式解决的例子:

// This example is explained in detail below (just after this code box).​
​function celebrityIDCreator (theCelebrities) {
    var i;
    var uniqueID = 100;
    for (i = 0; i < theCelebrities.length; i++) {
      theCelebrities[i]["id"] = function ()  {
        return uniqueID + i;
      }
    }
    
    return theCelebrities;
}
​
​var actionCelebs = [{name:"Stallone", id:0}, {name:"Cruise", id:0}, {name:"Willis", id:0}];
​
​var createIdForActionCelebs = celebrityIDCreator (actionCelebs);
​
​var stalloneID = createIdForActionCelebs [0];

console.log(stalloneID.id()); // 103

事实上结果的所有id都是103,而不是按顺序得出的101,102,103...。
因为for循环中的匿名函数得到是外部函数变量的引用而非变量实际值,而i的值最终结果为3,故所有id103,这样修改可以得到预想效果:

function celebrityIDCreator (theCelebrities) {
    var i;
    var uniqueID = 100;
    for (i = 0; i < theCelebrities.length; i++) {
        theCelebrities[i]["id"] = function (j)  { // the j parametric variable is the i passed in on invocation of this IIFE​
            return function () {
                return uniqueID + j; // each iteration of the for loop passes the current value of i into this IIFE and it saves the correct value to the array​
            } () // BY adding () at the end of this function, we are executing it immediately and returning just the value of uniqueID + j, instead of returning a function.​
        } (i); // immediately invoke the function passing the i variable as a parameter​
    }
​
    return theCelebrities;
}
​
​var actionCelebs = [{name:"Stallone", id:0}, {name:"Cruise", id:0}, {name:"Willis", id:0}];
​
​var createIdForActionCelebs = celebrityIDCreator (actionCelebs);
​
​var stalloneID = createIdForActionCelebs [0];

console.log(stalloneID.id); // 100​
​
​var cruiseID = createIdForActionCelebs [1];
console.log(cruiseID.id); // 101

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

CallApply 函数 在调用函数时可以用来改变上下文。这赋予了你难以置信的编程能力。为了使用两个函数,你需要在函数上调用它而非用()触发,并将上下文作为第一个参数传递。函数本身的参数可在上下文后传递。

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

hello(); // the way you usually call it
hello.call(context); // here you can pass the context(value of this) as the first argument
hello.apply(context); // here you can pass the context(value of this) as the first argument

.call().apply()的不同之处在于,在传递剩余参数时,.call()将剩余参数以,隔开,而.appley()会将这些参数包含在一个数组里传递。

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'); // the way you usually call it
introduce.call(window, 'Batman', 'to save Gotham'); // pass the arguments one by one after the contextt
introduce.apply('Hi', ['Bruce Wayne', 'businesses']); // pass the arguments in an array after the context

// 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>
        // Saves a NodeList of all list items on the page in listItems
        var listItems = document.querySelectorAll('ul li');
        // Loops through each of the Node in the listItems NodeList and logs its content
        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>

这里我想起来以前看到过的.caller().callee():

  • .caller()是指调用函数的函数体,返回函数体,类似于toString()
  • .callee()Arguments的一个成员,表示对函数对象本身的引用,常用属性是lengtharguments.length是指实参长度,callee.length形参长度。

具体可参考这里

对象可以有方法,同样函数对象也可以有方法。事实上,一个JavaScript函数生来就有四种内置函数

  • Function.prototype.apply()
  • Function.prototype.bind() (Introduced in ECMAScript 5 (ES5))
  • Function.prototype.call()
  • Function.prototype.toString() 将函数字符串化
.prototype => .__proto__

不同于CallApplyBind本身不调用函数,只用来在调用函数前绑定上下文的值和其他参数,例如:

(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传递一个数组,它返回的是一个新函数

var person1 = {firstName: 'Jon', lastName: 'Kuperman'};
var person2 = {firstName: 'Kelly', lastName: 'King'};

function say() {
    console.log('Hello ' + this.firstName + ' ' + this.lastName);
}

var sayHelloJon = say.bind(person1);
var sayHelloKelly = say.bind(person2);

sayHelloJon(); // Hello Jon Kuperman
sayHelloKelly(); // Hello Kelly King

Happy Coding!

相关推荐