JavaScript 中的 call 和 apply

JavaScript 中的 call 和 apply

JavaScript 中的 call()apply() 方法主要是用来扩充函数的作用域和改变 this 的指向(改变被调用函数的上下文),它们都接收两个参数

  • apply() 方法,第一个参数是作用域,第二个是参数数组,其中第二个参数可以是数组实例,也可以是 arguments 对象
  • call() 方法,也接收两个参数,仅仅在于和 apply() 的传参方式不同,传递函数的参数必须逐个写入,而不再是传递数组

不过需要注意的是,调用 call 或者 apply 的对象必须是个函数,因为这两者是挂载在 Function 对象上的两个方法,只有函数才有这些方法

两者的区别

两者的使用方式如下

1
2
3
function().apply(object, [a, b, c ...])

function().call(object, a, b, c ...)

功能基本一样,都是对象 Object 调用这里的 funciton(),不同之处是 call 参数从第二个开始都是传递给 funciton 的,可以依次罗列用 ',' 隔开,而 apply 只有两个参数,第二个是一个数组,其中存储了所有传递给 function 的参数

1
2
3
4
5
6
7
8
9
10
var bar = {baz: 'baz'}

function foo () {
console.log(this)
}

foo.call('bar')

// 输出 Object {baz: 'baz'}
// 其实就是让一个对象调用一个函数,在使用了 call 以后,即调用了显式绑定,this 就指向了所传进去的对象

call 和 apply 的第一个参数

callapply 用来改变函数的执行上下文(this),它们的第一个参数 thisArg 是个对象,即作为函数内的 this,在多数时候你传递什么给函数,那么它就是什么

1
2
3
4
5
6
7
8
function fun() {
alert(this)
}

fun.call(1) // 1
fun.call('a') // a
fun.call(true) // true
fun.call({name: 'aaa'}) // [object Object]

但是有两种情况需要注意,就是在传递 nullundefined 的时候,执行环境会是全局的(window/global),至于原因可以参考 15.3.4.4 - Function.prototype.call()

1
2
fun.call(null)            // window
fun.call(undefined) // window

但是在严格模式下,给 callapply 传入的任何参数也不再会转换

1
2
3
4
5
6
7
'use strict'
function fun() {
alert(this)
}

fun.call(null) // null
fun.call(undefined) // undefined

另外一个例子

1
2
3
4
5
6
7
function foo(x, y) {
'use strict'
console.log(x, y, this)
}

foo.apply(null) // undefined undefined null
foo.apply(undefined) // undefined undefined undefined

简单总结就是

  • 如果不传值或者第一个值为 nullundefined 时,this 指向 window
  • 如果第一个参数是 stringnumberbooleancall/apply 内部会调用其相应的构造器 StringNumerBoolean 将其转换为相应的实例对象
  • 严格模式下,给 callapply 传入的任何参数也不再会转换

原理

callapply 本质是一样的,区别就在于参数的不同,这里我们就以 call 方法为例来进行介绍,call 方法的定义是 Function.prototype.call(),简单来说就是

  • call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法
  • apply() 方法在使用一个指定的 this 值和参数值必须是数组类型的前提下调用某个函数或方法

call()apply() 的第一个参数是要调用函数的母对象,它是调用上下文,在函数体内通过 this 来获得它的引用,比如以对象 o 的方法来调用函数 f()

1
2
3
f.call(o)

f.apply(o)

大致原理如下所示

1
2
3
4
o.m = f    // 将 f 存储为 o 的临时方法
o.m() // 调用它,不传入参数

delete o.m // 将临时方法删除

在严格模式中,call()apply() 的第一个参数都会变成 this 的值,哪怕传入的实参是原始值甚至是 nullundefined,而在非严格模式中,传入的 nullundefined 都会被全局对象代替,而其他原始值则会被相应的包装对象(wrapper object)所替代,简单来说就是,f.call(o) 其原理就是先通过 o.m = ff 作为 o 的某个临时属性 m 存储,然后执行 m,执行完毕后将 m 属性删除

接下来,我们就可以尝试着手动来实现我们自己的 callapply 方法,一步一步的理清它们到底是如何实现的

实现

我们先来看看 call 的实现,如果想要手动来实现一个 call 方法,我们首先需要了解在使用 call 的过程中到底发生了哪些事情,根据上面提到的原理,我们可以整理出大致的实现思路,总的来说,分为四个步骤

  • 首先需要设置上下文对象,简单来说,也就是 this 的指向,因为第一个参数是要调用函数的母对象,它是调用上下文
  • 通过设置 Context(上下文),来将函数的 this 绑定到 Context
  • 执行函数并且传递参数
  • 删除临时属性,并且返回函数执行结果

我们可以根据以上来得出我们的第一版代码,如下

1
2
3
4
5
Function.prototype.call = function (context) {
context.fn = this
context.fn()
delete context.fn
}

虽然可以勉强实现效果,但是不够完善,因为原生的 call 还具备一些其他功能,如下

  • 首先,call 方法是可以接收参数的
  • this 参数可以传递 null 或者不传,当为 null 的时候,需要将其指向 window
  • 而且函数是可以指定返回值的

下面我们就来逐一完善

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Function.prototype.call = function (context, ...args) {
// 根据之前关于 call 和 apply 的第一个参数可知,传递 null 或 undefined 时,执行环境会是全局的(window/global)
// 而对于原始值,this 则会指向该原始值的实例对象
if (context === null || context === undefined) {
context = window
} else {
context = Object(context)
}

// 绑定到 context 上
context.fn = this

// 这里注解见下方
let result = context.fn(...args)

// 删除临时属性并且返回函数执行结果
delete context.fn
return result
}

其中 let result = context.fn(...args) 的作用是因为我们最终的目的是为了达到类似于 context.fn(arg1, arg2, arg3 ...) 这样的调用方式,这里使用扩展运算符来达到参数传递的功能,如果不采用该方法,也可以使用字符串拼接的方式在配合 eval() 方法来实现

1
2
3
4
5
6
7
8
var args = []

// 从第二位开始循环
for (var i = 1; i < arguments.length; i++) {
args.push('arguments[' + i + ']')
}

var result = eval('context.fn(' + args + ')')

如果为了追求完美,那么这里还存在一个小小的问题,即 context.fn = this,这里我们只是假设不存在名为 fn 的属性,所以这里我们需要保证 fn 的唯一性,所以在这里可以采用 ES6 提供的 Symbol 数据类型,直接添加即可

1
2
var fn = Symbol()
context[fn] = this

如果不使用 Symbol,也可以来手动模拟一个,简单来说就是随机定义一个属性名称,然后在进行赋值的时候判断一下

1
2
3
4
5
6
7
8
9
10
11
12
function symbol(obj) {
var unique_prop = '00' + Math.random()
if (obj.hasOwnProperty(unique_prop)) {
// 如果已经存在这个属性,则递归调用,直到没有这个属性
arguments.callee(obj)
} else {
return unique_prop
}
}

// 使用
var fn = symbol(context)

完整代码如下

1
2
3
4
5
6
7
8
9
10
11
12
Function.prototype.call = function (context, ...args) {
if (context === null || context === undefined) {
context = window
} else {
context = Object(context)
}
let fn = Symbol()
context[fn] = this
let result = context[fn](...args)
delete context[fn]
return result
}

现在我们有了 call 方法,那么实现 apply 方法也是同样的思路,只需要针对不同的地方略作调整即可,如下

  • 传递给函数的参数与 call 方法不一样,其他部分则跟 call 方法是一致的
  • apply 方法的第二个参数为类数组对象

实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Function.prototype.apply = function (context) {
if (context === null || context === undefined) {
context = window
} else {
context = Object(context)
}

// 绑定到 context 上
let fn = Symbol()
context[fn] = this

// 这里注解也见下方
let result = arguments[1] ? context[fn](...arguments[1]) : context[fn]()

// 删除临时属性并且返回函数执行结果
delete context[fn]
return result
}

在这里我们需要判断一下,如果只传入了一个参数,则直接执行函数即可,如果传递了第二个参数,则依次执行函数并且传递函数参数,基本原理就是这样了,如果为了完善一些,在这里可以针对 apply 的第二个参数(类数组对象)来进行判断一下

1
2
3
4
5
6
7
8
9
10
11
12
const args = arguments[1]
let result
if (args) {
if (!Array.isArray(args) && !isArrayLike(args)) {
throw new TypeError(`second parameter needs to be an array or class array object`)
} else {
args = Array.from(args)
result = context[fn](...args)
}
} else {
result = context[fn]()
}

当中使用的 isArrayLike 方法如下

1
2
3
4
5
6
7
8
9
10
11
function isArrayLike(o) {
if (o && // o 不是 null、undefined 等
typeof o === 'object' && // o 是对象
isFinite(o.length) && // o.length 是有限数值
o.length >= 0 && // o.length 为非负值
o.length === Math.floor(o.length) && // o.length 是整数
o.length < 4294967296) // o.length < 2^32
return true
else
return false
}

在了解完了 callapply 的实现原理以后,下面我们再来看看它们的一些实际使用场景

延伸

下面再来看两个实际的使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function sum(num1, num2) {
return num1 + num2
}

function callSum1(num1, num2) {
// 使用 sum 这个函数来完成一次调用,调用的参数就是 callSum1 这个函数的参数
// apply 的第二个参数表示一组参数数组
return sum.apply(this, arguments)
}

function callSum2(num1, num2) {
// 第二个参数是数组
return sum.apply(this, [num1, num2])
}

callSum1(12, 22)
callSum2(22, 32)

function callSum3(num1, num2) {
// call 是通过参数列表来完成传递,其余和 apply 没什么区别
return sum.call(this, num1, num2)
}

callSum3(32, 42)

另外一个实例

1
2
3
4
5
6
7
8
9
10
11
12
13
var color = 'red'
function showColor () {
alert(this.color)
}

function Circle (color) {
this.color = color
}

var c = new Circle('yellow')

showColor.call(this) // 使用上下文来调用 showColor,结果是red
showColor.call(c) // 上下文对象是 c,结果就是 yellow

通过以上发现,使用 callapply 以后,对象中可以不需要定义重复的方法了,这就是 callapply 的一种运用

this.init.apply(this, arguments)

prototype 框架中有如下一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var Class = {
create: function () {
return function () {
this.initialize.apply(this, arguments);
}
}
}

// Class 使用方法如下
var A = Class.create()

A.prototype = {
initialize: function (v) {
this.value = v
},
showValue: function () {
alert(this.value)
}
}

var a = new A('hello')
a.showValue()

var a = new A('hello') 其实这句话的含义就是构造个一个 function 复制给 a,这个 function

1
2
3
function () {
this.initialize.apply(this, arguments)
}

这个 function 方法是用来做构造函数的,使用 function 构造对象时,会让构造出来的对象的 initialize 方法执行 apply() 方法,function 中的第一个 this 是指用 new 调用构造函数之后生成的对象,也就是前面的 a,那么第二个 this 也当然应该是指同一个对象

this 调用 initialize 方法,参数是 arguments 对象(参数的数组对象),在构造函数执行时,对象 a 就会去执行 initialize 方法来初始化 arguments 作为 create 返回的构造函数的实参数组,传递给方法 apply,在调用 initialize 时作为参数传递给初始化函数 initialize,那么在 var a = new A('hello') 的时候 'hello' 就是实参数组(虽然只有一个字符串),传递给方法 apply,然后在调用 initialize 的时候作为参数传递给初始化函数 initialize

下面是一个与其类似的的实际使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+function () {
function Circle(nRadius, sMessage) {
this.init.apply(this, arguments)
}

Circle.prototype = {
init: function (nRadius, sMessage) {
this.nR = nRadius
this.sMessage = sMessage
},
PI: 3.14,
fnGetArea: function () {
return this.sMessage + ': ' + this.PI * this.nR * this.nR
}
};

var c = new Circle(5, '构造初始化 面积')
alert(c.fnGetArea()) //构造初始化 面积: 78.5
}()

Math.max.apply(null, arr)

求取数组中的最大值或者最小值是开发中比较常见的需求,我们一般会使用 Math.max() 或者 Math.min() 来进行实现,我们这里就以 max() 为例来进行说明,max() 方法可以返回两个指定的数中较大的那个数

Math.max() 方法,支持传递多个参数,比如 Math.max(1, 3, 5, 7, 9, 11),但是它不支持直接传递一个数组作为参数,比如 Math.max(new Array(1, 3, 5, 7, 9, 11)),这里,只要我们有方法把数组,一个一个拆分开来,传递到 Math.max() 方法中,就实现了传递数组的方法,这里就可以利用到 apply() 函数

1
2
3
4
5
var arr = [1, 3, 5, 7, 9, 11, 2, 4, 6, 8, 10]
console.log(Math.max.apply(null, arr))

// 使用 call 方法可以达到同样目的
// Math.max.call(null, 1, 2, 3, 4, 5)

所有函数都有 apply(作用域链, 参数) 这个方法,这个函数的参数接收一个数组,并且是将数组中的每个值分开来,传递给调用函数,所以就实现了传递一个数组,取得最大值的方法

Function.apply()JavaScript 的一个 OOP 特性,一般用来模拟继承和扩展 this 的用途,xx.apply 是一个调用函数的方法,其参数为 apply(Function, Args)Function 为要调用的方法,Args 是参数列表,当 Functionnull 时,默认为上文,即

1
Math.max.apply(null, arr)

下面我们再来看几种其他方法来求取数组中的最大值或者最小值,可以与上面的方法可以进行一下对比,第一种,比较原始的方法,即使用循环来进行比对

1
2
3
4
5
6
7
8
var arr = [1, 3, 5, 7, 9, 11, 2, 4, 6, 8, 10]
var result = arr[0]

for (let i = 1; i < arr.length; i++) {
result = Math.max(result, arr[i])
}

console.log(result)

第二种,也是现在使用较多的,即在 ES6 以后,我们可以使用 ... 运算符来简化操作

1
Math.max(...arr)

第三种,既然是通过遍历数组求出一个最终值,那么我们也可以使用 reduce 方法

1
2
3
4
5
6
7
var arr = [1, 3, 5, 7, 9, 11, 2, 4, 6, 8, 10]

var result = arr.reduce((a, b) => {
return Math.max(a, b)
})

console.log(result)

第四种,使用排序,因为我们进行过排序,那么最大值就是最后一个值,但是这个方法是存在缺陷的,因为 sort() 返回的结果不一定准确

1
2
3
4
5
6
7
var arr = [1, 3, 5, 7, 9, 11, 2, 4, 6, 8, 10]

arr.sort((a, b) => {
return a - b
})

console.log(arr[arr.length - 1])

Array.prototype.slice.call(arguments, 0)

在平常开发过程当中,我们经常会在一些第三方库等地方会看到类似 Array.prototype.slice.call(arguments, 0) 这样的写法,其实这个方法的本质作用就是『把类数组对象转换成一个真正的数组』,这里主要涉及到 slice() 方法和 call() 方法,我们先来简单的了解一下 slice() 方法,在数组和字符串当中都有这个 slice 方法,这个方法的作用是截取一段数据

  • 在数组中,该方法的返回值是『包含』截取元素的组成的数组
  • 在字符串中,该方法的返回值是『包含』截取字符串组成的字符串
1
2
3
4
// 参数 start 表示数组片段开始处的下标,如果是负数,它声明从数组末尾开始算起的位置
// 参数 end 表示数组片段结束处的后一个元素的下标,如果没有指定这个参数,切分的数组包含从 start 开始到数组结束的所有元素
// 如果这个参数是负数,它声明的是从数组尾部开始算起的元素(不包括结束位置)
array.slice(start, end)

如果 slice() 方法没有传递参数,则默认是从 index 序列为 0 开始截取(见 MDN - Array.prototype.slice()

需要注意的是,操作使用 slice() 生成的数组不会影响原数组,也就是说使用 slice() 后会生成原对象的一个浅拷贝的副本,如下

1
2
3
4
5
6
7
8
// slice() 方法
var a = [1, 2, 3]
var b = a.slice()

b.push(4)

console.log(a) // [1, 2, 3]
console.log(b) // [1, 2, 3, 4]

下面来看几个示例

1
2
3
4
5
6
7
[1, 2, 3, 4, 5, 6].slice(2, 4)
[1, 2, 3, 4, 5, 6].slice(-4, -2)
[1, 2, 3, 4, 5, 6].slice()

'everything'.slice(2, 4)
'everything'.slice(-4, -2)
'everything'.slice()

前两个的返回值均为 [3, 4],为数组,而后两个的返回值分别为 'er''hi',为字符串

  • 如果之传入一个参数的话,那就是输出从开始位置到结束位置的所有元素
  • 而如果不传递参数,则是从 0 开始计算(可以认为返回一个原对象的副本,因为 slice 方法返回的是一个新的数组)

在字符串中,和 slice() 方法类型的还有两个方法 substring()substr() 方法,其中,substring() 方法表示『返回从开始位置到结束位置的字符串』,substr() 接收两个参数,『第一个参数表示开始位置,第二个参数表示要截取的字符个数』,和前两个方法略有不同,当传入方法的参数为负数时,这三种方法又略有不同

  • slice(),像上面说的,是负数加上字符串的长度得出相应的正值
  • substring(),方法的参数均置为零
  • substr(),方法的第一个参数为负值加上字符串长度得到的正值,第二个参数置为零

在了解了 slice() 方法的基本用法以后,我们就正式的来看看 Array.prototype.slice.call(arguments, 0) 具体含义,在 Array.prototype.slice.call(arguments, 0) 中,Array.prototype.slice 调用的是 Array 的原型方法

对于正真的数组是有 slice() 方法,但是对于像 arguments 或者自己定义的一些『类数组对象』,虽然存在 length 等若干属性,但是并没有 slice() 方法,所以对于这种类数组对象就得使用原型方法来使用 slice() 方法,即 Array.prototype.slice(如果在自定义中的类数组对象中自定义了 slice() 方法,那么自然可以直接调用)

简单点说就是对于 arguments 类数组,我们调用 Array.prototype.slice 原型方法,并用 call() 方法,将作用域限定在 arguments 中,这里 Array.prototype 就可以理解为 arguments,同参数 0slice() 方法的第一个参数,即开始位置索引,通过这种方法就将 arguments 类数组转换成了真数组

Array.prototype.slice.call(arguments) 能将具有 length 属性的对象转成数组,除了 IE 下的节点集合

因为 IE 中的所有 DOM 对象都是以 COM 对象的形式实现的,这意味着 IE 中的 DOM 对象与原生 JavaScript 对象的行为或活动特点并不一致

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = { 
length: 2,
0: 'first',
1: 'second'
}

Array.prototype.slice.call(a) // ['first', 'second']

var a = {
length: 2
}

Array.prototype.slice.call(a) // [undefined, undefined]

call 和 apply 哪个速度更快一些

最后我们在来看一个有趣的问题,那就是 callapply 哪个速度更快一些,通常来说,call 是要比 apply 快一些的,至于为什么,这就要看它们在被调用之后发生了什么,关于发生了什么我们可以通过查询规范来进行了解,可以见 15.3.4.3 Function.prototype.apply (thisArg, argArray)15.3.4.4 Function.prototype.call(thisArg, arg1, arg2 …),两者对比如下

Function.prototype.apply (thisArg, argArray)

  1. 如果 IsCallable(Function)false,即 Function 不可以被调用,则抛出一个 TypeError 异常
  2. 如果 argArraynull 或未定义,则返回调用 Function[[Call]] 内部方法的结果,提供 thisArg 和一个空数组作为参数
  3. 如果 Type(argArray) 不是 Object,则抛出 TypeError 异常
  4. 获取 argArray 的长度,调用 argArray[[Get]] 内部方法,找到属性 length, 赋值给 len
  5. 定义 nToUint32(len)
  6. 初始化 argList 为一个空列表
  7. 初始化 index0
  8. 循环迭代取出 argArray,重复循环 while(index < n)
    • 将下标转换成 string 类型,初始化 indexNameToString(index)
    • 定义 nextArg 为 使用 indexName 作为参数调用 argArray[[Get]] 内部方法的结果
    • nextArg 添加到 argList 中,作为最后一个元素
    • 设置 index = index+1
  9. 返回调用 Function[[Call]] 内部方法的结果,提供 thisArg 作为该值,argList 作为参数列表

Function.prototype.call (thisArg [ , arg1 [ , arg2, .. ] ] )

  • 如果 IsCallable(Function)false,即 Function 不可以被调用,则抛出一个 TypeError 异常
  • 定义 argList 为一个空列表
  • 如果使用超过一个参数调用此方法,则以从 arg1 开始的从左到右的顺序将每个参数附加为 argList 的最后一个元素
  • 返回调用 func[[Call]] 内部方法的结果,提供 thisArg 作为该值,argList 作为参数列表

经过对比,可以很明显的发现,call 的执行步骤要比 apply 少的多,这是因为 apply 中定义的参数格式(数组),使得被调用之后需要做更多的事,需要将给定的参数格式改变(步骤 8 中所示), 同时也有一些对参数的检查(步骤 2),而在 call 中却是不必要的,另外在 apply 中不管有多少个参数,都会执行循环,也就是步骤 68,而在 call 中也就是对应步骤 3 ,是有需要才会被执行

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×