前端开发那些事儿

深入call,apply,bind到手动封装

2020-08-05  本文已影响0人  深度剖析JavaScript

call、apply、bind的作用是改变函数运行时的this指向。

我们先来聊聊this
你最开始的时候是在哪里听到this的呢?现在提起它第一印象是什么呢?
记得我最开始接触this时,是在构造函数构造出对象的时,如下:

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayInfo = function(){
        console.log("我叫" + this.name + ",我今年" + this.ag + "岁了");
    };
}
var alice = new Person("Alice",20);

那时候知道this代表的就是当前对象,this很灵活
但随着学习的深入,发现this被使用地方很多。当逻辑变得复杂时,this指向也变得混乱,以至于一时间难以想明白哪个指向哪个。原来this里面有大学问,所以笔试面试也经常问到。比如下面代码输出什么:

var obj = {
  foo: function(){
    console.log(this)
  }
}
var bar = obj.foo
obj.foo() 
bar() 

答案是:obj、window
不知道答对了没有,对了就恭喜你哈!错了也别伤心

我们先来梳理梳理,看看this指向的几种情况吧:

  1. 构造函数通过new构造对象时 this指向该对象
    构造函数通过new产生对象时,里面this指代就是这个要生成的新对象;这个比较容易理解,因为new的内部原理:
function Person(name, age) {
    this.name = name;
    this.age = age;
    console.log(this);
}
var alice = new Person("Alice",20);
  1. 全局作用域中this指向window
  2. 谁调用,this指向谁;如obj.fn(),fn()里面的this就指向obj;
var a = "window";
var obj = {
    a: "obj",
    fn: function () {
        console.log(this.a)
    }
}
obj.fn()//obj
  1. 普通函数普通执行时,this指向window; 普通执行,就是指非通过其他人调用
//1. 普通的函数执行
function fn(){
  console.log(this)//window
}
fn()

//2. 函数嵌套的执行,非别人调用
function fn1() {
    function fn2() {
        console.log(this)//window
    }
    fn2()
}
fn1()

//函数赋值之后再调用
var a = "window";
var obj = {
    a: "obj",
    fn: function () {
        console.log(this.a)
    }
}
var fn1 = obj.fn
fn1()//window
  1. 数组里面的函数,按数组索引取出运行时,this指向该数组
function fn1(){
    console.log(this);
}
function fn2(){}
var arr = [fn1,fn2]
arr[0]();//arr
  1. 箭头函数内的this值继承自外围作用域
    运行时会首先到父作用域找,如果父作用域还是箭头函数,那么接着向上找,直到找到我们要的this指向。即箭头函数中的 this继承父级的this(父级非箭头函数)。call或者apply都无法改变箭头函数运行时的this指向。

  2. call,apply,bind可以改变函数运行时的this指向
    当然是非箭头函数
    这里我们分开来讲并实现封装

var obj = {}
function fn(){
    console.log(this);
}
fn.call(obj);//obj

观察发现

fn()相当于fn.call(null)
fn1(fn2)相当于fn1.call(null,fn2)
obj.fn()相当于obj.fn.call(obj)

在仔细想想,视乎fn.call(obj)相当于obj对象里添加一个一样的fn函数并执行fn(),执行完后删除该属性。(记住这点,理解这点有助于接下来手写实现call函数)

当call函数传入第一个参数this为null或者undefined时,默认指向window,严格模式下指向 undefined

var English = 60;
var qulity =60;
var alice = {
    name: "alice",
    age: 10,
    English: 100,
    qulity: 90
}
function sum( {
    console.log(this.English + this.qulity);
}
sum.call(alice);//100+90
sum.call(null);//60+60

另外,fn.call(undefined) 或者fn.call(null) 可以简写为 fn.call()

了解了call的基本用法,接下来手写call函数
首先,因为它是每个方法身上都有calll方法,所以call应该是定义在Function原型上的,并且参数个数不定,那就先不写,到时候我们用arguments来操作参数

Function.prototype._call = function(){
}

再来想想,我们通过_call方法要实现:

  1. 改变函数运行时的this指向,让它指向我们传递的第一个参数,即arguments[0]
  2. 让函数执行

其实就这两点,关键是怎么实现呢?
上面有一点让大家记住的,就是fn.call(obj)相当于obj对象里添加一个一样的fn函数,并执行fn(),执行完后删除该属性。
先来得到我们传递的第一个参数(this指向),用个变量保存起来,方便到时调用函数。但是当没有传入或者传入null、undefined时默认window:

var _obj = arguments[0] || window;

接着,在_obj对象中添加一个属性fn,值为要执行call的函数。因为在函数调用call的时候this就是指代该函数,所以:

_obj.fn = this;

接着就是要执行_obj.fn(),到这里fn执行的时候,fn里面的this就是指向_obj了。关键在于怎么执行呢,因为fn里面传递的参数是不确定的,从arguments[0]到arguments.length-1,一个个传递过去显然办不到。这里我们使用一个函数eval(),这个函数可以将传递的字符串当js代码来执行,返回执行结果。
所以我们先将参数都处理成字符串格式就好:

var _args = [];
for (var i = 1; i < arguments.length; i++) {
    _args.push("arguments[" + i + "]");
}
var _str = _args.join(",");

得到的_str的值为"arguments[1],arguments[2],arguments[3],arguments[4],arguments[5]...."
接着就可以通过eval执行函数了

eval('_obj.fn(' + _str + ')');

函数执行完,将我们在对象身上添加的fn删掉即可

delete _obj.fn;

完整代码:

Function.prototype._call = function () {
    var _obj = arguments[0] || window;
    _obj.fn = this;//将当前函数赋值给对象的一个属性            
    var _args = [];
    for (var i = 1; i < arguments.length; i++) {
        _args.push("arguments[" + i + "]");
    }
    var _str = _args.join(",");    
    var result = eval('_obj.fn(' + _str + ')');
    delete _obj.fn;
    return result;
}

var obj = {
    name: 'obj'
}
function fn() {
    console.log(this);
    console.log(arguments);
}
fn._call(obj, 1, 2, 3, 4);

修改成ES6的写法:

Function.prototype._call = function () {
    let params = Array.from(arguments);//得到所以实参数组
    let _obj = params.splice(0, 1)[0];//获取第一位作为对象,即this指向
    _obj.fn = this
    var result = _obj.fn(...params);//splice截取了第一位,params包含剩下的参数
    delete _obj.fn
    return result;
}
Function.prototype._call = function (_obj, args) {
}

跟call一样,当第一个参数为null、undefined的时候,默认指向window。

Function.prototype._apply = function (obj, args) {
    var _obj = obj || window;
    _obj.fn = this;
    // 执行函数_obj.fn()前,将参数处理成字符串,最后删除属性即可
    var result;
    if (args) {
        var _args = [];
        for(var i = 0;i<args.length;i++){
            _args.push('args['+i+']');
        }
        var str = _args.join(",");
        result = eval("_obj.fn(" + str + ")");
    } else {
        result = _obj.fn();
    }
    delete _obj.fn;
    return result;
}

用ES6的写法简化如下:

Function.prototype._apply = function (_obj, args) {
    _obj.fn = this;  
    var result = args ? _obj.fn(...args) : _obj.fn();
    delete _obj.fn;
    return result;
}

是不是发现apply 和 call 的用法几乎相同?是的!唯一的差别在于:当函数需要传递多个变量时, apply 可以接受一个数组作为参数输入, call 则是接受一系列的单独变量。

利用call和apply可改变函数this指向的特性,可以借用别的函数实现自己的功能,如下:

function Person(name, age, sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
}
function Student(name, age, sex, grade, tel, address) {
    this.name = name;
    this.age = age;
    this.sex = sex;
    this.grade = grede;
    this.tel = tel;
    this.address = address;
}
var alice = new Student("alice", 20, 'famale',88,"134****4559","海天二路33号")

我们发现在构建Student对象时,Person和Student两个类存在很大的耦合,代码优化中也说尽量低耦合。那这种情况我们可以使用call和apply

function Person(name, age, sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
}
function Student(name, age, sex, grade, tel, address) {
    Person.call(this,name, age, sex);
    this.grade = grade;
    this.tel = tel;
    this.address = address;
}
var alice = new Student("alice", 20, 'famale',88,"134****4559","海天二路33号")

这有点像继承的感觉
同样利用call和apply来借用别的函数实现自己的功能还有很多,再举几个例子开发一下思路:

function fn(){    
    return Array.prototype.slice.call(arguments);
}
console.log(fn(1,2,3,4));//[1,2,3,4]
var arr1 = [1,2,3];
var arr2 = [4,5,6];
var total = [].push.apply(arr1, arr2);//6
// arr1 [1, 2, 3, 4, 5, 6]
function isArray(obj){
    return Object.prototype.toString.call(obj) == '[object Array]';
}
isArray([]) // true
isArray('a') // false
function log(){
  console.log.apply(console, arguments);
}

当然也有更方便的 var log = console.log()

讲完call和apply,最后再来看看bind

我们先看个例子,再来详细小结一下bind:

var n = 1;
var obj = {
    n:2
}
function fn(){
    console.log(this.n);
}
var temp = fn.bind(obj);//temp-->fn(){}
temp();//2

再来看:

function fn1() {
    console.log(this,arguments)
}
var o = {},
    x = 1,
    y = 2,
    z = 3;
var fn2 = fn1.bind(o,x,y);
fn2("c");//o, [1, 2, "c"]

请再来看看,哈哈:

function Fn1() {
    console.log(this,arguments)
}
var obj = {};
var Fn2 = Fn1.bind(obj);
console.log(new Fn2().constructor);//Fn1

惊不惊喜意不意外,new Fn2().constructor居然是Fn1!而且new Fn2()里面的this是对象本身,因为new的关系
我们一起来总结一下吧
小结:
1. 函数调用bind方法时,需要传递函数执行时的this指向,选择传递任意多个实参(x,y,z....);
2. 返回新的函数等待执行;
3. 返回的新函数在执行时,功能跟旧函数一致,但this指向变成了bind的第一个参数;
4. 同样在新函数执行时,传递的参数会拼接到函数调用bind方法时传递的实参后面,两部分参数拼接后,一并在内部传递给函数作为参数执行;
5. bind返回的函数通过new构造的对象的构造函数constructor依旧是旧的函数(如上例子new Fn2().constructor是Fn1);而且bind传递的this指向,不会影响通过bind返回的函数通过new构造的对象其里面的this;

所以有了这些总结,我们来开始模拟实现我们的bind
为了不乱,我们先实现基本功能吧:

Function.prototype._bind = function (target) {
    //target:改变返回函数执行时的this指向
    var obj = target || window;
    var args = [].slice.call(arguments,1);//获取bind时传入的绑定实参
    var self = this;//要bind的函数
    var _fn= function(){
        var _args = [].slice.call(arguments,0);//新函数执行时传递的实际参数
        return self.apply(obj,args.concat(_args));
    }
    return _fn
}

接着,让new新函数生成对象的constructor是旧函数
通过中间函数实现继承

Function.prototype._bind = function (target) {
    //target:改变返回函数执行时的this指向
    var obj = target || window;
    var args = [].slice.call(arguments,1);//获取bind时传入的绑定实参
    var self = this;//要bind的函数
    var temp = function(){};//作为中间函数,用于实现继承
    var _fn= function(){
        var _args = [].slice.call(arguments,0);//新函数执行时传递的实际参数
        return self.apply(obj,args.concat(_args));
    }
    //让中间函数的原型指向,要bind函数的原型
    temp.prototype = self.protoype;
    //让新函数的原型指向中间temp的对象,然后找到要bind函数的原型
    _fn.prototype = new temp();//这样新函数生成的对象的constructor就能找到旧的函数
    return _fn
}

剩下问题是,如果是以new的形式来执行新函数,那里面的this就不要修改成传递的this了。即让new新函数生成新对象里面的this还是指向这个新生成的对象;

那怎么来判断是否以new的方式来执行新的这个函数呢?

通过instanceof来判断(这里会比较难理解)
instanceof的用法是判断左边对象是不是右边函数构造出来的
最终的代码如下:

//bind的模拟实现
Function.prototype._bind = function (target) {
    //target:改变返回函数执行时的this指向
    var temp = function () { };//作为中间函数,用于实现继承
    //target不存在this默认window,当new调用时无需修改this指向
    var obj = this instanceof temp ? this : (target || window);
    var args = [].slice.call(arguments, 1);//获取bind时传入的绑定实参
    var self = this;//要bind的函数            
    var _fn = function () {
        var _args = [].slice.call(arguments, 0);//新函数执行时传递的实际参数
        return self.apply(obj, args.concat(_args));
    }
    //让中间函数的原型指向,要bind函数的原型
    temp.prototype = self.protoype;
    //让新函数的原型指向中间temp的对象,然后找到要bind函数的原型
    _fn.prototype = new temp();//这样新函数生成的对象的constructor就能找到旧的函数
    return _fn
}

//下面为测试代码
var a = 1;
var o = {
    a:2
}
function A(){
    console.log(this.a);
    return arguments;
}
var fn1 =  A._bind(o,1,2,3);
var fn2 = A.bind(o,4,5,6);
console.log(fn1(111),fn2(222))

最后总结一下call,apply,bind及其区别

总结

相同点:

区别在于:

参考资料
原型,原型链,call/apply(下)
一次性讲清楚apply/call/bind
call、apply和bind方法的用法以及区别
你不知道的JS-call,apply手写实现
this 的值到底是什么?一次说清楚
你不知道的JS-bind模拟实现

上一篇 下一篇

猜你喜欢

热点阅读