目前不同类型函数调用指尖的主要区别在于:最终作为函数上下文(可以使用this参数隐式引用到)传递给执行函数的对象不同。对于方法而言,即为方法所在的对象;对于顶级函数而言是window或者undefined(取决于是否处于严格模式下);对于构造函数而言是一个新创建的实例对象。
但是,如果想改变函数上下文怎么办?如果想要显示指定它怎么办?如果......好吧,我们为什么会提出这样的问题?
为了解释我们关心这个能力的原因,先来看一个实例,实例中是一个与事件处理相关的经典错误。现在假设事件被触发,绑定的函数被调用,函数上下文将被设置为事件绑定到的对象。
<!--随后会为该按钮附加事件处理器-->
<button id="testClick">Click Me!</button>
<script>
/*为对象赋值事件处理器的构造函数,该事件处理器反映出按钮的状态。通过这个事件处理器,我们能够跟踪按钮是否被点击。*/
function Button() {
/*单击事件处理器的声明函数。由于该函数是对象的方法,所以在函数中使用this来获取对象的引用*/
this.clicked = false;
//单击事件处理器的声明函数。由于该函数是对象的方法,所以在函数中使用this来获取对象的引用。
this.click = function () {
this.clicked = true;
if (button.clicked) {
//在该方法中,我们测试了按钮是否在单击后正确的改变了状态。
console.log('The button has been clicked.');
}
};
}
//创建一个用于跟踪按钮是否被单击的实例
var button = new Button();
//在按钮上添加单击处理器
var elem = document.getElementById('testClick');
elem.addEventListener('click', button.click);
在这个例子中,我们定义了一个按钮<button id='test'>Click Me!</button>,并且想知道它是否被单击过。为了保存单击的状态信息,我们使用构造函数创建一个名为button的实例化对象,通过该对象我们可以存储被单击的状态:
/*为对象赋值事件处理器的构造函数,该事件处理器反映出按钮的状态。通过这个事件处理器,我们能够跟踪按钮是否被点击。*/
function Button() {
/*单击事件处理器的声明函数。由于该函数是对象的方法,所以在函数中使用this来获取对象的引用*/
this.clicked = false;
//单击事件处理器的声明函数。由于该函数是对象的方法,所以在函数中使用this来获取对象的引用。
this.click = function () {
this.clicked = true;
if (button.clicked) {
//在该方法中,我们测试了按钮是否在单击后正确的改变了状态。
console.log('The button has been clicked.');
}
};
}
//创建一个用于跟踪按钮是否被单击的实例
var button = new Button();
在该对象中,我们还定义了一个click方法作为单击事件按钮时触发的事件处理函数。该方法将clicked属性设置为true,然后测试实例化对象中的状态是否正确(我们有意使用button标识符而非this关键字——毕竟,它们应该具有相同的指向,但事实上,但事实结果真如此吗?)。最后,我们创建了button.click方法作为按钮的点击处理函数。
//在按钮上添加单击处理器
var elem = document.getElementById('testClick');
elem.addEventListener('click', button.click);
当我们在浏览器中加载示例代码并单击按钮时,发现log未打印,其实说明了click函数的上下文对象并非像我们预期的一样指向button对象。上述代码测试失败?我们对按钮单击状态的改变去哪儿了?通常情况下,事件回调函数的上下文触发事件的对象(在本例中是HTML)中的按钮,而非button对象。其实,如果通过button.click()调用函数,上下文将是按钮,因为函数将作为button对象的方法被调用。但在这个例子中,浏览器的事件处理系统将把调用的上下文定义为事件触发的目标元素,因此 上下文将是<button>元素,而非button对象。所以我们将单击状态设置到了错误的对象上!如何避免出现这种情况?现在讨论一下如何解决它:可以使用apply和call方法显示地设置函数上下文。
使用apply和call方法
JavaScript为我们提供了一种调用函数的方式,从而可以显示地指定任何对象作为函数的上下文。我们可以使用每个函数上都存在的这种方法来完成:apply和call。
是的,我们所指的正是函数的方法。作为第一类对象(顺便说一下,函数是由内置的Function构造函数所创建),函数可以像其他类型一样拥有属性,也包括方法。
若想使用apply方法调用函数,需要为其传递两个参数:作为函数上下文的对象和一个数组作为函数调用的参数。call方法的使用方式类似,不同点在于是直接以参数列表的形式,而不再是作为数组传递。
console.log('---------------------------使用apply和call方法来设置函数上下文-----------');
//函数“处理”了参数,并将结果result变量放在任意一个作为该函数上下文的对象上。
function juggle() {
var result = 0;
for (var n = 0; n < arguments.length; n++) {
result += arguments[n];
}
this.result = result;
}
//这些对象的初始化为空,它们会作为测试对象
var ninja1 = {};
var ninja2 = {};
//使用apply方法向ninja1传递一个参数数组
juggle.apply(ninja1, [1,2,3,4]);
//使用call方法向ninja2传递一个参数列表
juggle.call(ninja2, 5,6,7,8);
//测试展现了传入juggle方法中的对象拥有了结果值
if (ninja1.result === 10) {
console.log('juggled via apply!');
}
if (ninja2.result === 26) {
console.log('juggled via call!');
}
在这个例子中,我们定义了一个名为juggle的函数,函数的作用是将所有的参数加在一起并存储在函数上下文的result属性中(通过this关键字引用)。看起来似乎是一个不太实用的函数,但它能够帮助我们验证函数的传参是否正确,以及哪个对象最终作为函数上下文。
然后设置两个对象:ninja1和ninja2,我们将使用这两个对象作为函数上下文,将第一个对象连同一个参数数组一起传递给函数的apply方法,将第二个对象连同一个参数列表传递给函数的call方法:
值得注意的是,apply和call之间唯一的不同之处在于如何传递参数。在使用apply的情况下,我们使用参数数组;在使用call的情况下,我们则在函数上下文之后依次列出调用参数。
传入call和apply方法的第一个参数都会被作为函数上下文,不同处在于后续的参数。apply方法只需要一个额外的参数,也就是一个包含参数值的数组;call方法则需要传入任意数量的参数值,这些参数将用作函数的实参。
现在已经提供了函数上下文和参数,接下来继续测试!首先,检查传递给apply方法的ninja1对象,它应该拥有一个result属性,并存储了所有参数(1,2,3,4)的和。同样,传递给call方法的ninja2对象的result属性应该等于5、6、7、8的和;
//测试展现了传入juggle方法中的对象拥有了结果值
if (ninja1.result === 10) {
console.log('juggled via apply!');
}
if (ninja2.result === 26) {
console.log('juggled via call!');
}
call和apply这两个方法对于我们要特殊指定一个函数的上下文时特别有用,在执行回调函数时可能会经常用到。
function juggle() {
var result = 0;
for (var n = 0; n < arguments.length; n++) {
result += arguments[n];
}
this.result = result;
}
//这些对象的初始化为空,它们会作为测试对象
var ninja1 = {};
var ninja2 = {};
使用call和apply方法手动设置函数上下文,产生函数上下文(this)与argument。
强制指定回调函数的函数上下文
让我们来看一个具体的例子,将函数上下文强制设置为指定的对象。我们将使用一个简单的函数对数组的每个元素执行相应的操作。
在命令式编程中,常常将数组传给函数,然后使用for循环遍历数组,然后使用for循环遍历数组,再对数组的每个元素执行具体的操作。
function(collection) {
for (var n = 0; n < collection.length; n++) {
/* do something to collection[n].*/
}
}
而函数式方法创建的函数只处理单个元素:
function(item){
/*do something to item*/
}
二者的区别在于是否将作为程序的主要组成部分。也许你会认为这么做毫无意义,仅仅只是删除for循环,也没有对示例做任何优化。
为了实现更加函数式的风格,所有数组对象均使用forEach函数,对每个数组元素执行回调。对于熟悉函数式编程的开发者来说,这种方法比传统的for循环更加简洁。
forEach遍历函数将每个元素传给回调函数,将当前元素作为回调函数的上下文。
现在所有现代JavaScript引擎支持数组使用forEach方法。
console.log('-------------------------实现forEach迭代方法展示如何设置函数上下文------------------');
//forEach函数接收两个参数:需要遍历的集合和回调函数
function forEach(list, callback) {
for (var n = 0; n < list.length; n++) {
//当前遍历到的函数作为函数上下文调用回调函数
callback.call(list[n], n);
}
}
//测试数组
var weapons = [{type: 'shuriken'},{type:'katana'},{type:'nunchucks'}];
forEach(weapons, function (index) {
if (this === weapons[index]) {
console.log('Got the expected value of ' + weapons[index].type);
}
});
迭代函数接收需要遍历的目标数组作为第一个参数,回调函数作为第二个参数迭代函数遍历数组,对每个数组元素执行回调函数:
function forEach(list, callback) {
for (var n = 0; n < list.length; n++) {
//当前遍历到的函数作为函数上下文调用回调函数
callback.call(list[n], n);
}
}
使用call方法调用回调函数,将当前遍历到的元素作为第一个参数,循环索引作为第二个参数,使得当前元素作为函数上下文,循环索引作为回调函数的参数。
执行测试时,设置一个简单的数组weapons,然后调用froEach函数,传入数组以及回调函数:
forEach(weapons, function (index) {
if (this === weapons[index]) {
console.log('Got the expected value of ' + weapons[index].type);
}
});
在生产环境实现这类函数还需要做一些处理。例如,若传入的第一个参数不是数组该如何处理?若第二个参数不是函数该如何处理?如何允许调用者随时中断循环?作为练习,可以增加函数来处理这些情况。另一个练习任务时,允许调用者向回调函数传入除索引外的任意数量的参数。
apply与call的功能类似,但问题是二者中如何选择?答案与许多其他问题的答案类似:选择任意可以精简代码的方法。更实际的答案是选择与现有参数匹配的方法。如果有一组无关的值,则直接使用call方法。若已有参数是数组类型,apply方法是更佳选择。
参考《JavaScript忍者秘籍》