javascript 语言中的作用域和上下文的实现比较独特,在某种程度上是因为javascript是一种非常灵活的弱类型语言,函数可以用来封装并保存不同类型的上下文以及作用域;这些概念是由权威的javascript设计者提供的;然而,这也成为开发者们困惑的源头,下面详细介绍一下作用域和上下文之间的区别,以及如何利用各种设计模式.
上下文和作用域
首先要明白上下文和作用域不是一回事,我注意到许多开发人员多年来经常混淆这两个术语(包括我自己),错误地,用一个术语去描述另一个术语。平心而论,这样术语会变得很混乱。
每个Function在调用的时候都会创建一个新的作用域以及上下文,从根本上说,作用域是基于函数的,上下文是基于对象的。换句话说,作用域与函数调用时变量的访问有关,当它被调用的时候,每个调用都是唯一的。上下文常常代表关键字"this"的值,指的是调用该方法的对象.
(补充知识点:链式作用域(chain scope):父对象的所有变量对子元素都是可见的,子元素的变量对父元素不可见)
变量的作用域
变量可以定义在局部环境中,也可以定义在全局环境中,在运行时确定了不同作用域下的变量的可访问性.任何定义在全局中的变量,意味着一个在函数体之外声明的变量能在整个函数运行的过程中被调用,并且在任何地方都能被调用和修改,局部变量只存在于定义它们的函数体,每调用一次函数会产生不同的作用域;在一次调用的过程中进行赋值,检索和操作仅仅影响当前的作用域下的值,其他作用域下的值不会被改变.
javascript不支持块级作用域,类似于if语句,switch语句,for loop或者while loop语句.这意味着变量不能在这些语句之外被访问,目前看来,定义在语句块中的变量可以在语句块之外被访问到.但是这种现状很快就会改变,因为ES6规范中已经出现了一个新的关键字let代替var,它可以用来声明变量,并且产生块级作用域(译者增加:意思就是在语句块之外访问不到,是undefined,let也不存在变量提升,这里不做多余解释).
什么是this上下文
上下文通常是取决于一个函数如何被调用.当一个函数被当成一个对象的方法调用的时候,this指向当前调用该方法的对象;
var obj = {
foo: function(){
alert(this === obj);
}
};
obj.foo(); // true
同样的原则适用于通过new操作符来创建对象的一个实例来调用一个函数。以创建实例的方式调用时,这个函数的作用域内的值将被设置为新创建的实例.
function foo(){
alert(this);
}
foo() //window
new foo() // foo
当函数未绑定的时候,this指向全局上下文,或者浏览器中的window对象.但是如果函数是运行在严格模式下的话,上下文默认为undefined.
执行上下文
JavaScript是一个单线程的语言,这意味着一次只能执行一个任务.JavaScript解释器初次执行代码时,它首先进入一个全局默认的执行上下文。此后,每次调用一个函数将导致创建一个新的执行上下文。
这就是导致困惑的原因,术语"执行上下文"在这里指的是作用域,并不是前面讨论的上下文,更加不幸的是它已经作为ECMAScript规范存在,所以我们只能接受.(译者增加:术语"执行上下文"只是名字和"上下文"相似,而且有时候也会把前者简称为后者,但是他们并没有关系).
每次当创建一个新的执行上下文的时候,它都会被添加到当前执行栈的顶部,浏览器总是执行位于当前执行栈顶部的执行上下文.一旦结束,就会从栈顶移除,并且依次继续执行下面的函数.
一个执行上下文可以分为创建和执行阶段。在创建阶段,解释器将首先创建一个变量对象(也称为一个激活对象),是由所有的变量,函数声明,定义的参数组成的。接下来是原型链的初始化,this的值被决定。然后在执行阶段,解释和执行代码。
原型链
对于每个执行上下文都有一个原型链耦合。该作用域链包含了在执行堆栈中每个执行上下文中的变量对象。它是用来确定变量访问和标识符解析。例如:
function first(){
second();
function second(){
third();
function third(){
fourth();
function fourth(){
// do something
}
}
}
}
first();
运行前面的代码会导致嵌套函数一直向下执行到fourth()函数。此时原型链的范围,从上到下:fourth,third,second,first,global。fourth函数能够访问全局变量以及定义在first, second和third函数中的变量以及函数本身.
可以搜索作用域链来解决不同的执行上下文中的变量命名冲突的问题,从局部变量一直向上到全局变量,这意味着局部变量和作用域链更高的变量中具有相同名称时,会优先考虑局部变量,支持就近原则.
简而言之,每当你试图在函数的执行上下文中访问一个变量时,查找过程总是从自身的变量对象开始。如果变量的标识符在自身的对象中没有找到,搜索范围会持续一直到作用域链。它会查询整个作用域链检查每个执行上下文的变量对象范围来寻找匹配的变量名。
闭包
当在嵌套函数中访问函数之外的值的时候就会创建一个闭包。换句话说,一个嵌套函数内部定义另一个函数时就形成一个闭包,在这个函数内部允许访问外部函数的变量。它将在外部函数返回时被执行,允许在内部函数中访问外部函数的局部变量、参数和函数声明.封装允许我们从外部作用域中隐藏和保护执行上下文,而暴露公共接口,通过接口进一步操作,举一个简单的例子:
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);
}
};
})();
这个模块类似一个单例,因此,在函数末尾添加一对(),js解析器解析完成之后就会立即执行函数。在闭包执行的上下文之外唯一能获取到的是返回对象中的属性和公共方法(publicMethod)。然而,所有私有属性和方法将在应用执行上下文中一直存在,意味着变量通过公共方法会进一步发生改变。
另一种类型的闭包是立即调用函数(IIFE),也就是在window的执行上下文中的一个自调用的匿名函数
(function(window){
var foo, bar;
function private(){
// do something
}
window.Module = {
public: function(){
// do something
}
};
})(this);
这个表达式对于保护全局变量非常有用,任何一个在函数体内声明的变量都是局部变量,通过闭包在整个函数运行周期中都存在。这是一个受欢迎的封装源代码应用程序和框架的方法,通常暴露单一的全局接口进行交互。
Call 和 Apply
这两种方法固有的功能是对于所有函数允许你在任何期望的上下文中执行任何功能。这是令人难以置信的强大能力。call函数需要显式地列出的参数,apply函数允许您提供参数作为数组:
function user(firstName, lastName, age){
// do something
}
user.call(window, 'John', 'Doe', 30);
user.apply(window, ['John', 'Doe', 30]);
这两个调用的结果是完全相同的,在window的执行上下文中调用user函数,提供相同的三个参数。
ECMAScript 5(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([].slice.call(arguments)));
}
}
}
它经常用在上下文丢失的情况下;面向对象和事件处理,这很有必要,因为一个节点的addEventListener方法总是在绑定了节点事件处理器的上下文中去执行回调函数,的确也应该这么做,然而,如果你采用高级的面向对象技术,需要回调一个实例的方法,您将需要手动调整的上下文,此时bind派上了用场:
function MyClass(){
this.element = document.createElement('div');
this.element.addEventListener('click', this.onClick.bind(this), false);
}
MyClass.prototype.onClick = function(e){
// do something
};
当回顾Function.prototype.bind的源码的时候,你已经注意到关于数组的slice方法(译者增加:这是将类数组转化成数组的方法之一,上下两行代码功能一样)
Array.prototype.slice.call(arguments, 1);
[].slice.call(arguments);
比较有趣的一点是这里的arguments对象不再是一个真正意义上的数组,通常被称为类数组就像节点列表一样(可以是element.childNodes的任意返回值).它们具有length属性和index值,但依然不是数组,而且也不支持数组原生的内置方法,比如slice和push.然而,类数组和数组有着相似的表现形式,因此也可以执行数组的方法,上面的代码中,数组的方法就是在一个类数组的上下文中被执行的;
这种使用其他对象方法的技巧同样也适应于在javascript中模拟类继承时的面向对象;
MyClass.prototype.init = function(){
// call the superclass init method in the context of the "MyClass" instance
MySuperClass.prototype.init.apply(this, arguments);
}
通过调用子类(MyClass)对象实例下的superclass的方法(MySuperClass),我们可以充分利用这个强大的设计模式的能力来模仿调用的方法.
结论
在你开始接触设计模式之前理解这些概念非常重要,作用域和上下文在现代JavaScript中发挥着基础性的作用。在谈论闭包、面向对象和继承,或各种原生实现、上下文和作用域都发挥着重要的作用。如果你的目标是掌握JavaScript语言并深入理解它的组成,作用域和上下文应该是你的一个起点。