likes
comments
collection
share

[译] JavaScript的作用域和上下文

作者站长头像
站长
· 阅读数 16

背景

对于一个前端工程师来说,JavaScript 的基础非常重要。

笔者在学习 JavaScript基础 的过程中读了一篇外文文章,文中详细解释了 JS作用域上下文,把这两个概念写的通俗易懂,使笔者大为触动。

为了加深印象,笔者消化知识后,🔥索性输出了一篇译文,现分享给大家,但由于水平有限,译注不对之处,还请掘友批评指正~

原文介绍

原文地址:Understanding Scope And Context In JavaScript

原文作者:Ryan Morr

作者简介:

Ryan Morr 是一名居住在加拿大安大略省巴里市的程序员。他对与前端 Web 开发相关的一切充满热情。 JavaScript 是他的初恋,他觉得这种语言似乎总是保持新鲜,并且不断激发出创造力和新技术,从而完成新的解决方案。

他还有一些其他兴趣,比如足球。不过由于他是一名自豪的加拿大人,曲棍球才是他的第一体育项目。同时他还是一个巨大的电影迷,最喜欢的电影有《现代启示录》、《肖申克的救赎》和《拯救大兵瑞恩》。

译文

JavaScript作用域上下文的实现,是它的一项独有特性,这是JS灵活性的一种体现。

函数可以在各种上下文中执行,作用域可以被封装和保留,这些概念支撑着JS,形成了她强大的设计模式。

然而,这也给开发人员造成了巨大困惑。

下面是对作用域上下文的全面解释,包括它们的概念、区别,还有各种设计模式下如何使用它们。

上下文 vs 作用域

首先要清晰地认识到,上下文作用域是两个不同的概念。

我发现大量工作多年的开发者经常混淆这两个概念,将其中一个错误地描述为另一个。

老实说,这几年术语变得相当混乱。

每个函数调用都有属于自己的作用域,以及一个与之关联的上下文

从根本上说,作用域基于函数的,而上下文基于对象的。

换句话说,作用域函数被调用时的变量访问有关,并且每次调用一个函数(不管是不是同一个函数),都会产生一个独一无二的作用域。

上下文始终是this关键字的值,它是对拥有当前执行代码的对象引用

作用域中的变量

一个变量可以在局部作用域或者全局作用域中定义,这在运行时确定了从不同作用域是否可以访问该变量。

任何全局变量,也就是在函数体之外定义的变量,将在整个运行时存活,并且从任何作用域都可以访问修改它们。

本地变量只存在于定义它们的函数内部。每次调用该函数,都将产生不同的作用域。本地变量仅仅能在本次调用的作用域(函数内部)进行值分配、检索和控制,在该作用域之外不能访问到它们。

目前JavaScript不支持块级作用域。所谓块级作用域,指的是if语句、swtich语句、for循环以及while循环形成的作用域块级作用域意味着在该的左大括号和右大括号之外,不能访问内部的变量。

目前的JS中,从块的外部可以访问到块内部的变量,然而,这很快就会改变,let关键字已经添加到ES6规范中,它可以作为var关键字的替代,从而支持定义块级作用域本地变量

什么是"this"上下文

上下文通常由函数的调用方式决定。当一个函数作为对象的方法被调用时,this会被设置为调用这个方法的对象:

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

object.foo(); // true

当使用new操作符调用函数时,会创建一个对象实例,这时也符合上面的原则。这种情况下,作用域this的值会被设置为新创建的对象实例:

function foo(){
    alert(this);
}

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

当作为没有任何绑定的函数调用时,this默认指向全局上下文,比如浏览器环境下的window对象。然而,如果函数在严格模式下执行,这个上下文默认为undefined

执行上下文和作用域链

JavaScript是单线程语言。这意味着浏览器一次只能做一件事。

JavaScript解释器开始执行代码时,首先会创建全局执行上下文,后面每次调用一个函数,都会创建一个新的执行上下文

这就是出现混淆的地方。实际上,术语执行上下文的表达目的在于作用域,而不是前面讨论的上下文

这是一个不幸的命名约定。然而,它是由ECMAScript规范定义的术语,所以大家还是这么称呼它。

每次创建一个执行上下文时,该执行上下文就会被添加到作用域链的顶部。作用域链有时候也被称为执行栈或者调用栈。浏览器总是执行位于作用域链顶部的执行上下文,一旦执行完毕,该执行上下文就会从作用域链中被移除,然后控制权来到链的下一个执行上下文中。

观察下面例子:

function first(){
    second();
    function second(){
        third();
        function third(){
            fourth();
            function fourth(){
                // do something
            }
        }
    }   
}
first();

运行上面的代码将导致嵌套函数一直执行到函数fourth。这时候作用域链从上到下依次是: fourththirdsecondfirstglobal。函数fourth将有权限访问全局变量、所有firstsecondthird函数中定义的变量以及函数本身。当函数fourth执行完毕,它产生的作用域将从作用域链中被移除,然后执行权来到函数third。这个过程将重复进行,直到所有代码执行完毕。

不同执行上下文中可能存在同样的变量名。区分的方法是沿着作用域链,从当前到全局查找。这意味着,如果存在同名变量,优先使用当前执行上下文的变量,如果当前执行上下文找不到,再沿着作用域链往下查找。

一个执行上下文可以被分为创建阶段执行阶段

创建阶段JS解释器首先会创建一个变量对象(也称为激活对象)。变量对象由执行上下文中定义的所有变量、函数以及参数组成。然后,初始化作用域链。最后确定this的值。

到了执行阶段,代码被解释和执行。

简而言之,每次您尝试在一个执行上下文中访问变量时,查找过程总是从这个执行上下文自己的变量对象开始。如果在当前变量对象中没有找到这个要访问的变量,程序就会继续在作用域链中查找。它会沿着作用域链,检查剩下的变量对象,寻找与目标变量名匹配的项。

闭包

当一个内部函数在定义它的函数之外可以被访问时,就会形成一个闭包。通常这个内部函数会在外部函数执行返回后,被调用。它维护了外部函数定义的本地变量、参数以及函数。

这样的封装使得我们在暴露一个公共接口的同时,隐藏、保留了外部函数的执行上下文。因此,我们可以进一步操作,观察下面这个简单的例子:

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

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

最流行的闭包模式之一是众所周知的模块。它允许您模拟publicprivate、以及privileged成员:

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

    function privateMethod(args){
        //do something
    }

    return {

        publicProperty: "",

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

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

可以看到,函数末尾有(),因此该模块就像一个单例一样,在编译器解释它时立即执行。

闭包执行上下文之外唯一可用的成员们是您返回的公共方法和公共属性(比如Module.publicMethod),然而,由于执行上下文被保留,所有的私有属性和私有方法都将在整个应用的生命周期中存在。

这意味着,后面的代码中,私有变量可以通过模块暴露的公共方法进行交互。

另一种类型的闭包是IIFE(立即执行函数表达式),它是在window上下文中执行的自调用匿名函数

function(window){

    var a = 'foo', b = 'bar';

    function private(){
        // do something
    }

    window.Module = {

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

})(this);

当尝试保留全局命名空间时,这个表达式非常有用,因为在立即执行函数体内声明的所有变量,都将成为闭包的本地变量,它们在整个运行时都是存活的。

这是封装应用程序以及框架源代码的流行方式,通常暴露一个全局接口以进行交互。

call&apply

这两个简单的方法是所有函数固有的,通过它们您可以指定一个上下文,函数会在其中执行。

方法call需要将参数一个一个列出来,而方法apply需要将参数组合成一个数组传递进来:

function user(first, last, age){
    // do something 
}
user.call(window, 'John', 'Doe', 30);
user.apply(window, ['John', 'Doe', 30]);

两次调用的结果完全相同。函数userwindow上下文中被执行两次,接收的是同一批参数。

ECMAScript 5(ES5)引入了一种操作上下文的方法——Function.prototype.bind。它返回一个新函数,无论如何使用这个新函数,它的上下文都永久绑定在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);
        }
    }
}

bind通常用于上下文缺失的地方: 面向对象和事件处理。由于nodeaddEventListener方法,应该在一个node的上下文中执行回调,所以bind是必要的。如果您使用高级面向对象技术,并且要求您的回调是一个实例的方法,您就需要手动调整上下文,这就是bind的用武之地:

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

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

回看上面bind函数的polyfill源代码,您可能会注意到一行看起来相对简单的代码,其中包含一个Array的方法:

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

有趣的是,这里的arguments实际上不是数组,但它通常被描述为一个类似数组的对象。

就像执行document.getElementsByTagName方法返回的node列表一样。这种类数组对象包含一个length属性,以及索引值,但是它们仍然不是数组,因此不支持数组的任何特有方法,如slicepush

不过,由于类数组对象和数组的相似特征,可以劫持数组的特有方法,使它在类数组对象的上下文中执行,就像上面代码所做的那样。

JavaScript中模拟后端语言的经典继承模式时,这种"借用"另一个对象方法的技术同样适用:

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

通过在子类(MyClass)的上下文中调用父类(MySuperClass)的方法,我们可以模拟出这种强大的设计模式。

结论

在开始接触高级设计模式之前,理解这些概念很重要,因为作用域上下文在现代JavaScript中扮演着重要的基础的角色。无论是谈论闭包面向对象继承,还是各种原生实现上下文作用域都在其中发挥着重要作用。如果您的目标是掌握JavaScript语言,并理解它包含的所有内容,那么作用域上下文应该是您的起点之一。