call、apply、bind的原理剖析及实现

序言

不知其理,何以谈用,在这里简单记录一下个人对call、apply、bind的理解,并根据理解思路实现一下。

众所周知 call、apply、bind 的作用都是‘改变’作用域,但是网上对这这‘改变’说得含糊其辞,并未做详细说明,‘改变’是直接替换作用域?谁替换谁?怎么产生效果?这些问题如果不理解清楚,就算看过手写实现,估计也记不长久,基于此,这里做简单记录,以免时间过长遗忘,方便回顾。


call

call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数

这就是 MDN 上给出的描述,看得一脸懵逼的我只能自己一步一步去剖析,一波分析下来其实发现 call 的作用无非就是当执行一个方法的时候希望能够使用另一个对象来作为作用域对象而已,简单来说就是当我执行 A 方法的时候,希望通过传入参数的形式将一个对象 B 传进去,用以将 A 方法的作用域对象替换为对象 B


知道了这个那么就一个进一步解析如何去实现,如下有一段简单的代码:

function consoleLog () {
	console.log('输出:' + this.value)
}

let tempObj = {
    value: '渣渣逆天'
}

tempObj.fn = consoleLog
tempObj.fn()
// 输出:'渣渣逆天'

咋一看这是什么鬼,好像和我们要提的东西毫无关联,但仔细看会发现如果我们直接执行 consoleLog 方法,作用域就是window 对象,但是当我们绑定到另一个对象上时,作用域就被替换了,这和 call 的中心思想何其相似,当执行一个方法的时候希望将其方法的作用域对象替换为另一个对象

再一看 call 的使用方式:

function Product(name, price) {
	this.name = name
	this.price = price
}

function Food(name, price) {
	// 调用Product的call方法并将当前作用域对象this传入替换掉Product内部的this作用域对象
	Product.call(this, name, price)
	this.category = 'food'
}

let tempFood = new Food('cheese', 5)

console.log('输出:' + tempFood.name, tempFood.price, tempFood.category)
// 输出:cheese 5 food

这是一个简单使用 call 来实现的继承操作,在上面我们可以看到 Food 这个构造函数它自身并没有定义 name 和 price 这两个属性,重点就在 Product.call(this, name, price) 这一行代码上面,调用 Product 的 call (调用 call 方法会调用一遍自身)方法并将当前作用域对象 this 传入替换掉 Product 内部的 this 作用域对象,都知道当对象作为参数的时候都是地址传递,对任何一个引用修改都会修改到源对象,所以这里最终 new 出来的对象就有了 name 和 price 属性

把上面了解一波后 call 的大体功能及设计思路应该都有了一定的了解

接下来看一下具体的实现:

Function.prototype.imitateCall = function (context) {
    // 赋值作用域参数,如果没有则默认为 window,即访问全局作用域对象
    context = context || window    
    // 绑定调用函数(.call之前的方法即this,前面提到过调用call方法会调用一遍自身,所以这里要存下来)
    context.invokFn = this    
    // 截取作用域对象参数后面的参数
    let args = [...arguments].slice(1)
    // 执行调用函数,记录拿取返回值
    let result = context.invokFn(...args)
    // 销毁调用函数,以免作用域污染
    Reflect.deleteProperty(context, 'invokFn')
    return result
}

都是实践是检验真理的唯一标准,那么就拉出来溜溜看

function Product(name, price) {
	this.name = name
	this.price = price
}

function Food(name, price) {
	Product.imitateCall(this, name, price)
	this.category = 'food'
}

let tempFood = new Food('cheese', 5)

console.log('输出:' + tempFood.name, tempFood.price, tempFood.category)
// 输出:cheese 5 food

perfect,十分完美


apply

使用过的人应该都知道,apply 和 call 的功能完全一致,区别唯有使用上的一丝丝差别

Function.prototype.call = function(context, args1, args2, args3 ...)

Function.prototype.apply = function(context, [args1, args2, args3 ...])

很明显了吧,唯参数形式不同而已,依此只需要稍微改动 imitateCall 方法即可模拟出我们的 imitateApply 方法

Function.prototype.imitateApply = function (context) {
    // 赋值作用域参数,如果没有则默认为 window,即访问全局作用域对象
    context = context || window
    // 绑定调用函数(.call之前的方法即this,前面提到过调用call方法会调用一遍自身,所以这里要存下来)
    context.invokFn = this
    // 执行调用函数,需要对是否有参数做判断,记录拿取返回值
    let result
    if (arguments[1]) {
        result = context.invokFn(...arguments[1])
    } else {
        result = context.invokFn()
    }
    // 销毁调用函数,以免作用域污染
    Reflect.deleteProperty(context, 'invokFn')
    return result
}

bind

bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被bind的第一个参数指定,其余的参数将作为新函数的参数供调用时使用

这同样是 MDN 上给出的解释,意思应该已经很明显了,和 call 方法类似,调用是都是将内部的 this 作用域对象替换为第一个参数,不过需要注意开始和结尾,调用 bind 方法时会创建一个新的函数返回待调用

先看个例子

const people = {
    names: '渣渣逆天',
    getName: function () {
        return this.names
    }
}

const temp = people.getName

console.log('输出:' + temp())
// 输出:undefined

在惯性思维下我想大多数看到这个问题的人都会得出 输出:'渣渣逆天' 的答案,仔细看会发现,虽然这里的 temp 方法和 getName 方法指向同一个对地址(即同一段代码块),但是这里的 temp 的调用者是 window,然而在 window 对象上找 names 属性就会发现是 undefined

那看看绑定 bind 后的效果

const people = {
        names: '渣渣逆天',
        getName: function () {
            return this.names
        }
    }

const temp = people.getName,
      context = temp.bind(people)

console.log('输出:' + context())
// 输出:渣渣逆天

这里就完美诠释了来自 MDN 的解释,需要注意的是这里新建并被返回的方法当被执行时,绑定 bind 的原方法将被调用,并将原方法内部作用域对象替换为绑定 bind 时传入的第一个参数,即然如此,应该能联想到 bind 的实现离不开 call 或 apply

有了上面的分析,那么接着来看看如何实现

Function.prototype.imitateBind = function (context) {
    // 获取绑定时的传参
	let args = [...arguments].slice(1),
        // 定义中转构造函数,用于通过原型连接绑定后的函数和调用bind的函数
        F = function () {},
        // 记录调用函数,生成闭包,用于返回函数被调用时执行
        self = this,
        // 定义返回(绑定)函数
        bound = function () {
            // 合并参数,绑定时和调用时分别传入的
            let finalArgs = [...args, ...arguments]
            
            // 改变作用域,注:aplly/call是立即执行函数,即绑定会直接调用
            // 这里之所以要使用instanceof做判断,是要区分是不是new xxx()调用的bind方法
            return self.call((this instanceof F ? this : context), ...finalArgs)
        }
    
    // 将调用函数的原型赋值到中转函数的原型上
    F.prototype = self.prototype
    // 通过原型的方式继承调用函数的原型
    bound.prototype = new F()
    
    return bound
}

这是《JavaScript Web Application》一书中对 bind() 的实现:通过设置一个中转构造函数 F,使绑定后的函数与调用 bind() 的函数处于同一原型链上,用 new 操作符调用绑定后的函数,返回的对象也能正常使用 instanceof,因此这是最严谨的 bind() 实现

注:绑定函数内部具体选择使用 call 还是 apply 来实现并没有明文规定,个人猜想是 ES6 的展开运算符未普及时,已有人实现该方法,ES5 下使用 apply 来实现确实简洁一些,然后网上一些人抄来抄去的不加以思考就直接使用 apply 了,个人感觉 getInfo(name, age) 模式比 getInfo([name, age]) 更常规,就算参数格式不确定也可以使用不定参来进行获取,所以这里改用 call 实现


遛遛效果如何

const people = {
        names: '渣渣逆天',
        getName: function (surname) {
            return surname + this.names
        }
    }

const temp = people.getName,
      context = temp.imitateBind(people)

console.log('输出:' + context('屌丝'))
// 输出:屌丝渣渣逆天

总结

既然 call/apply 和 bind 的功能如此相似,那什么时候该使用 call、apply,什么时候使用 bind 呢?其实这个也没有明确的规定,一通百通而已,只要知其理,相互转化何其简单,主要的区别无非就是 call/apply 绑定后是立即执行,而 bind 绑定后是返回引用待调用

就像这样

const people = {
    age: 18
};
 
const girl = {
    getAge: function() {
        return this.age;
    }
}
 
console.log('输出:' + girl.getAge.bind(people)());  // 输出:18
console.log('输出:' + girl.getAge.call(people));    // 输出:18
console.log('输出:' + girl.getAge.apply(people));   // 输出:18

一次看到个有趣的问题是如果多次 bind 呢,会有什么效果?

const people1 = {
    age: 18
}

const people2 = {
    age: 19
}

const people3 = {
    age: 20
}

const girl = {
    getAge: function() {
        return this.age
    }
}

const callFn = girl.getAge.bind(people1)
const callFn1 = girl.getAge.bind(people1).bind(people2)
const callFn2 = girl.getAge.bind(people1).bind(people2).bind(people3)

console.log(callFn(), callFn1(), callFn2())
// 18 18 18

这里都输出 18 ,而没有期待中的 19 和 20 ,原因是在 Javascript 中,多次 bind() 是无效的。更深层次的原因, bind() 的实现,相当于使用函数在内部包了一个 call / apply ,第二次 bind() 相当于再包住第一次 bind() ,故第二次及以后的 bind 是无法生效的


再看一段示例

const tempFn = function () {
    console.log(this, [...arguments])
}

const cont1 = tempFn.bind({
        name: '渣渣逆天'
    }, 1)

cont1.call({
        age: 24
    }, 2)
// {name: "渣渣逆天"}  [1]

const cont2 = cont1.bind({
		apper: 'bueaty'
    }, 2)

cont2()
// {name: "渣渣逆天"}  [1, 2]

const cont3 = cont2.bind({
		fat: 'thin'
    }, 3)

cont3()
// {name: "渣渣逆天"}  [1, 2, 3]

从上面的代码执行结果中我们发现一点,第一次 bind 绑定的对象是固定的,也就是后面通过 bind 或者 call 再次绑定的时候,就无法修改这个 this 了,从 ES5 文档中我们能找到答案

When the [[Call]] internal method of a function object, F, which was created using the bind function is called with a this value and a list of arguments ExtraArgs, the following steps are taken:

Let boundArgs be the value of F’s [[BoundArgs]] internal property.
Let boundThis be the value of F’s [[BoundThis]] internal property.
Let target be the value of F’s [[TargetFunction]] internal property.
Let args be a new list containing the same values as the list boundArgs in the same order followed by the same values as the list ExtraArgs in the same order.
Return the result of calling the [[Call]] internal method of target providing boundThis as the this value and providing args as the arguments.

这段话中说到如果我们在一个由 bind 创建的函数中调用 call,假设是 x.call(obj, y, z, …) 并且传入 this,和参数列表的时候会执行下面的步骤:

  1. 首先用三个参数分别保存函数x函数的内部属性中存的this值、目标函数和参数 列表。
  2. 然后执行目标函数的内部 call 函数,也就是执行目标函数的代码,并且传入1中保存的 this 和实参(这里的实参是目标函数本来就有的也就是 bind 时传入的实参加上调用 call 时传的实参)

重点在1中,从 ES5 的 bind 函数说明中我们知道,当我们用一个函数调用 bind 的时候,返回的函数中会保存这三个参数。所以最后调用 call 的时候执行的函数是目标函数,也就是调用了 bind 的函数,传入的 this 也是 bind 调用时传入的,这些都是无法被修改的了,但是参数是调用 bind 和 call 时的叠加,这是我们唯一可以修改的地方。执行两次 bind 的原理可以参考 bind 的源码,和 call 的差不多,也是目标函数和 this 是被固定的了,只有参数列表会叠加

posted @ 2019-08-27 17:24  渣渣逆天  阅读(4493)  评论(0编辑  收藏  举报