首頁 >web前端 >js教程 >深入理解JavaScript中的作用域和上下文(圖)

深入理解JavaScript中的作用域和上下文(圖)

黄舟
黄舟原創
2017-03-09 14:14:551531瀏覽

JavaScript對於作用域(Scope)和上下文(Context)的實作是這門語言的一個非常獨到的地方,部分歸功於其獨特的靈活性。 函數可以接收不同的上下文和作用域。這些概念為JavaScript中的許多強大的設計模式提供了堅實的基礎。 然而這也概念也非常容易帶給開發人員困惑。為此,本文將全面的剖析這些概念,並闡述不同的設計模式是如何利用它們的。

上下文(Context)和作用域(Scope)

首先需要知道的是,上下文和作用域是兩個完全不同的概念。多年來,我發現很多開發者會混淆這兩個概念(包括我自己), 錯誤的將兩個概念混淆了。平心而論,這些年來很多術語都被混亂的使用了。

函數的每次呼叫都有與之緊密相關的作用域和上下文。從根本上來說,作用域是基於函​​數的,而上下文是基於物件的。 換句話說,作用域涉及到所被呼叫函數中的變數訪問,並且不同的呼叫場景是不一樣的。上下文始終是this關鍵字的值, 它是對擁有(控制)目前所執行程式碼的物件的參考。

變數作用域

一個變數可以定義在局部或全域作用域中,這建立了在執行時間(runtime)期間變數的存取性的不同作用域範圍。 任何被定義的全域變量,意味著它需要在函數體的外部被聲明,並且存活於整個運行時(runtime),並且在任何作用域中都可以被存取。 在ES6之前,局部變數只能存在於函數體中,並且函數的每次呼叫它們都擁有不同的作用域範圍。 局部變數只能在其被呼叫期的作用域範圍內被賦值、檢索、操縱。

需要注意,在ES6之前,JavaScript不支援區塊級作用域,這表示在if語句、switch語句、for迴圈、while迴圈中無法支援區塊級作用域。 也就是說,ES6之前的JavaScript並不能建構類似Java中的那樣的區塊級作用域(變數不能在語句區塊外被存取)。但是, 從ES6開始,你可以透過let關鍵字來定義變量,它修正了var關鍵字的缺點,能夠讓你像Java語言一樣定義變量,並且支援區塊級作用域。看兩個例子:在

ES6之前,我們使用var關鍵字定義變數:

function func() {
  if (true) {
    var tmp = 123;
  }
  console.log(tmp); // 123
}

之所以能夠訪問,是因為var#關鍵字宣告的變數有一個變數提升的過程。而在ES6場景,建議使用let關鍵字定義變數:

function func() {
  if (true) {
    let tmp = 123;
  }
  console.log(tmp); // ReferenceError: tmp is not defined
}

這種方式,能夠避免很多錯誤。

什麼是this上下文

上下文通常取決於函數是如何被呼叫的。當一個函數被當作物件中的一個方法被呼叫的時候,this被設定為呼叫該方法的物件上:

var obj = {
    foo: function(){
        alert(this === obj);    
    }
};

obj.foo(); // true

這個準則也適用於當呼叫函數時使用new操作符來建立物件的實例的情況下。在這種情況下,在函數的作用域內部this的值被設定為新建立的實例:

function foo(){
    alert(this);
}

new foo() // foo
foo() // window

當呼叫一個為綁定函數時,this預設是全域上下文,在瀏覽器中它指向window物件。需要注意的是,ES5引入了嚴格模式的概念, 如果啟用了嚴格模式,此時上下文預設為undefined

執行環境(execution context)

JavaScript是一個單執行緒語言,意味著同一時間只能執行一個任務。當JavaScript解釋器初始化執行程式碼時, 它會先預設進入全域執行環境(execution context),從此刻開始,函數的每次呼叫都會建立一個新的執行環境。

這裡會經常引起新手的困惑,這裡提到了一個新的術語-執行環境(execution context),它定義了變數或函數有權存取的其他數據,決定了它們各自的行為。 它更偏向作用域的作用,而不是我們前面討論的上下文(Context)。請務必仔細的區分執行環境和上下文這兩個概念(註:英文容易造成混淆)。 說實話,這是一個非常糟糕的命名約定,但是它是ECMAScript規範制定的,你還是遵守吧。

每個函數都有自己的執行環境。當執行流進入函數時,函數的環境就會被推入一個環境堆疊中(execution stack)。在函數執行完後,堆疊將其環境彈出, 把控制權傳回給先前的執行環境。 ECMAScript程式中的執行流程正是由這個便利的機制所控制。

执行环境可以分为创建和执行两个阶段。在创建阶段,解析器首先会创建一个变量对象(variable object,也称为活动对象 activation object), 它由定义在执行环境中的变量、函数声明、和参数组成。在这个阶段,作用域链会被初始化,this的值也会被最终确定。 在执行阶段,代码被解释执行。

每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。 需要知道,我们无法手动访问这个对象,只有解析器才能访问它。

作用域链(The Scope Chain)

当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问。 作用域链包含了在环境栈中的每个执行环境对应的变量对象。通过作用域链,可以决定变量的访问和标识符的解析。 注意,全局执行环境的变量对象始终都是作用域链的最后一个对象。我们来看一个例子:

var color = "blue";

function changeColor(){
  var anotherColor = "red";

  function swapColors(){
    var tempColor = anotherColor;
    anotherColor = color;
    color = tempColor;

    // 这里可以访问color, anotherColor, 和 tempColor
  }

  // 这里可以访问color 和 anotherColor,但是不能访问 tempColor
  swapColors();
}

changeColor();

// 这里只能访问color
console.log("Color is now " + color);

上述代码一共包括三个执行环境:全局环境、changeColor()的局部环境、swapColors()的局部环境。 上述程序的作用域链如下图所示:

scope chain example

从上图发现。内部环境可以通过作用域链访问所有的外部环境,但是外部环境不能访问内部环境中的任何变量和函数。 这些环境之间的联系是线性的、有次序的。

对于标识符解析(变量名或函数名搜索)是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始, 然后逐级地向后(全局执行环境)回溯,直到找到标识符为止。

闭包

闭包是指有权访问另一函数作用域中的变量的函数。换句话说,在函数内定义一个嵌套的函数时,就构成了一个闭包, 它允许嵌套函数访问外层函数的变量。通过返回嵌套函数,允许你维护对外部函数中局部变量、参数、和内函数声明的访问。 这种封装允许你在外部作用域中隐藏和保护执行环境,并且暴露公共接口,进而通过公共接口执行进一步的操作。可以看个简单的例子:

function foo(){
    var localVariable = 'private variable';
    return function bar(){
        return localVariable;
    }
}

var getLocalVariable = foo();
getLocalVariable() // private variable

模块模式最流行的闭包类型之一,它允许你模拟公共的、私有的、和特权成员:

var Module = (function(){
    var privateProperty = 'foo';

    function privateMethod(args){
        // do something
    }

    return {

        publicProperty: '',

        publicMethod: function(args){
            // do something
        },

        privilegedMethod: function(args){
            return privateMethod(args);
        }
    };
})();

模块类似于一个单例对象。由于在上面的代码中我们利用了(function() { ... })();的匿名函数形式,因此当编译器解析它的时候会立即执行。 在闭包的执行上下文的外部唯一可以访问的对象是位于返回对象中的公共方法和属性。然而,因为执行上下文被保存的缘故, 所有的私有属性和方法将一直存在于应用的整个生命周期,这意味着我们只有通过公共方法才可以与它们交互。

另一种类型的闭包被称为立即执行的函数表达式(IIFE)。其实它很简单,只不过是一个在全局环境中自执行的匿名函数而已:

(function(window){

    var foo, bar;

    function private(){
        // do something
    }

    window.Module = {

        public: function(){
            // do something 
        }
    };

})(this);

对于保护全局命名空间免受变量污染而言,这种表达式非常有用,它通过构建函数作用域的形式将变量与全局命名空间隔离, 并通过闭包的形式让它们存在于整个运行时(runtime)。在很多的应用和框架中,这种封装源代码的方式用处非常的流行, 通常都是通过暴露一个单一的全局接口的方式与外部进行交互。

Call和Apply

这两个方法内建在所有的函数中(它们是Function对象的原型方法),允许你在自定义上下文中执行函数。 不同点在于,call函数需要参数列表,而apply函数需要你提供一个参数数组。如下:

var o = {};

function f(a, b) {
  return a + b;
}

// 将函数f作为o的方法,实际上就是重新设置函数f的上下文
f.call(o, 1, 2);    // 3
f.apply(o, [1, 2]); // 3

两个结果是相同的,函数f在对象o的上下文中被调用,并提供了两个相同的参数12

在ES5中引入了Function.prototype.bind方法,用于控制函数的执行上下文,它会返回一个新的函数, 并且这个新函数会被永久的绑定到bind方法的第一个参数所指定的对象上,无论该函数被如何使用。 它通过闭包将函数引导到正确的上下文中。对于低版本浏览器,我们可以简单的对它进行实现如下(polyfill):

if(!('bind' in Function.prototype)){
    Function.prototype.bind = function(){
        var fn = this, 
            context = arguments[0], 
            args = Array.prototype.slice.call(arguments, 1);
        return function(){
            return fn.apply(context, args.concat(arguments));
        }
    }
}

bind()方法通常被用在上下文丢失的场景下,例如面向对象和事件处理。之所以要这么做, 是因为节点的addEventListener方法总是为事件处理器所绑定的节点的上下文中执行回调函数, 这就是它应该表现的那样。但是,如果你想要使用高级的面向对象技术,或需要你的回调函数成为某个方法的实例, 你将需要手动调整上下文。这就是bind方法所带来的便利之处:

function MyClass(){
    this.element = document.createElement('p');
    this.element.addEventListener('click', this.onClick.bind(this), false);
}

MyClass.prototype.onClick = function(e){
    // do something
};

回顾上面bind方法的源代码,你可能会注意到有两次调用涉及到了Arrayslice方法:

Array.prototype.slice.call(arguments, 1);
[].slice.call(arguments);

我们知道,arguments对象并不是一个真正的数组,而是一个类数组对象,虽然具有length属性,并且值也能够被索引, 但是它们不支持原生的数组方法,例如slicepush。但是,由于它们具有和数组类似的行为,数组的方法能够被调用和劫持, 因此我们可以通过类似于上面代码的方式达到这个目的,其核心是利用call方法。

这种调用其他对象方法的技术也可以被应用到面向对象中,我们可以在JavaScript中模拟经典的继承方式:

MyClass.prototype.init = function(){
    // call the superclass init method in the context of the "MyClass" instance
    MySuperClass.prototype.init.apply(this, arguments);
}

也就是利用callapply在子类(MyClass)的实例中调用超类(MySuperClass)的方法。

ES6中的箭头函数

ES6中的箭头函数可以作为Function.prototype.bind()的替代品。和普通函数不同,箭头函数没有它自己的this值, 它的this值继承自外围作用域。

对于普通函数而言,它总会自动接收一个this值,this的指向取决于它调用的方式。我们来看一个例子:

var obj = {

  // ...

  addAll: function (pieces) {
    var self = this;
    _.each(pieces, function (piece) {
      self.add(piece);
    });
  },

  // ...

}

在上面的例子中,最直接的想法是直接使用this.add(piece),但不幸的是,在JavaScript中你不能这么做, 因为each的回调函数并未从外层继承this值。在该回调函数中,this的值为windowundefined, 因此,我们使用临时变量self来将外部的this值导入内部。我们还有两种方法解决这个问题:

使用ES5中的bind()方法

var obj = {

  // ...

  addAll: function (pieces) {
    _.each(pieces, function (piece) {
      this.add(piece);
    }.bind(this));
  },

  // ...

}

使用ES6中的箭头函数

var obj = {

  // ...

  addAll: function (pieces) {
    _.each(pieces, piece => this.add(piece));
  },

  // ...

}

在ES6版本中,addAll方法从它的调用者处获得了this值,内部函数是一个箭头函数,所以它集成了外部作用域的this值。

注意:对回调函数而言,在浏览器中,回调函数中的thiswindowundefined(严格模式),而在Node.js中, 回调函数的thisglobal。实例代码如下:

function hello(a, callback) {
  callback(a);
}

hello('weiwei', function(a) {
  console.log(this === global); // true
  console.log(a); // weiwei
});

小结

在你学习高级的设计模式之前,理解这些概念非常的重要,因为作用域和上下文在现代JavaScript中扮演着的最基本的角色。 无论我们谈论的是闭包、面向对象、继承、或者是各种原生实现,上下文和作用域都在其中扮演着至关重要的角色。 如果你的目标是精通JavaScript语言,并且深入的理解它的各个组成,那么作用域和上下文便是你的起点。


以上是深入理解JavaScript中的作用域和上下文(圖)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn