[路飞]如何简单手写call、apply、bind

203 阅读5分钟

「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战」。

前言

关于手写实现 JavaScript api 是一件非常有意思的事情,通过自己手写实现能够更好的掌握 JavaScript 这门语言。手写到了极点就是像 babel 预先支持了还在提案中的新的api。

call,apply,bind 的手写是很经典的题目,这三个方法是改变上下文的 this 来改变方法的操作目标。但是 this 怎么才会改变呢,话不多说先看代码,再解释原理。

实现

用过这三个方法的都会知道,他们之间的差异是一个递增的关系,所以只要实现 call,后面的两个只要基于 call,调整一下执行过程就可以了。

call

    function call(fn, ctx, ...args) {
        if (ctx == undefined) {
            return fn(...args)
        }
        
        let res
        const b = Symbol()
        const prototype = Object.getPrototypeOf(ctx)
        prototype[b] = fn
        res = ctx[b](...args)
        delete prototype[b]
        
        return res
    }
    
    console.log(call(Array.prototype.slice, '123', 2)) // '3'
    console.log(call(Object.prototype.toString, 1)) // [object Number]
    console.log(call(Object.prototype.toString, Symbol())) // [object Symbol]

apply

    function apply(fn, ctx, args) {
        return call(fn, ctx, ...args)
    }
    
    // 例子
    function ko(a, b) {
        return this.a + a + b
    }
    
    console.log(apply(ko, { a: 2 }, [1, 2])) // 5

bind

    function bind(fn, ctx) {
        return (...args) => call(fn, ctx, ...args)
    }
    
    console.log(bind(ko, { a: '2' })(1, 2)) // '212'

原理

this 是怎么指向的

实现起来其实代码也不是很多,而且主要是 call 的实现。那么先解释一下 call 吧。这个要从 this 是怎么来的说起。this 是函数执行时所处的上下文环境,但是要做一个概念的明晰,不是函数在哪里调用所处的环境就是哪里,而是看函数是怎么读取内存中的函数。比如以下的代码。

    function foo() {
        bar() // 这个 bar 里面的 this 是 window
        obj.bar() // 这里 bar 的 this 才是 obj
    }

如果函数是读取了之后赋值给变量,那么调用这个函数的时候,是直接从内存中把这个函数取出来,函数没有绑定任何的上下文环境,所以采用默认的全局上下文 window。当 obj.bar 的时候,函数是从 obj 中存的函数的内存地址来读取这个函数的,所以函数处在 obj 之中,this 自然就是 obj

这种叫做 this 的隐式指向。除此之外还要 new 指向,call,apply,bind等显式指向,如果光执行函数则是默认指向。

对象类型上下文时的 call 实现

因为要实现 call,apply,bind 那自然不能使用他们来实现他们。所以我们采用隐式指向的方法来实现。只要我们将函数赋值到我们想绑的上下文上,然后 ctx.fn 调用就会得到想要的 this。

但是这种方法只适合对象类型的上下文,比如 [], {}, function() {}。因为属性挂上去之后就一定能够调用。

基本数据类型为上下文时的 call 实现

但是我们在基本数据类型上没有办法赋值方法,这是因为包装类的存在。

什么是包装类呢?我们有时候执行 'foo'.replace('f', 'a') 能得到 'aoo'

但是 . 是对象读取属性的语法,'foo' 明明是字符串, typeof 'foo' --> 'string', 'foo' instanceof String --> false。种种迹象表明了 'foo' 不是对象。

但是我们能读到 String 的原型上的方法,这是因为在 . 的时候,会自动帮我们包装一个实例对象,然后读到里面的方法进行调用。就类似于下面的代码。

    'foo'.replace('f', 'a')
    
    // 等价于
    
    const temp = new String('foo')
    temp.replace('f', 'a')
    temp = null

因为包装类的存在所以我们采用 obj.bar 的想法就会落空,因为包装类是执行这一条语句时临时生成的,执行完就会回收。所以我们下次在 obj.bar 的时候就拿不到之前赋的值。

    // 这两条语句会进行两次不同的包装
    'foo'.replace('f', 'a')
    'foo'.replace('f', 'a')

那基本数据类型可以作为上下文进行 call 绑定吗?

    Object.prototype.toString.call(1) // '[object Number]'
    Array.prototype.slice.call('123', 2) // '3'

看起来 call 是实现了绑定基本数据类型,那我们该用什么办法来实现自己的 call 呢?从上面一路推导下来,根据 this 指向的几种方法,我们得出一个基础的结论就是我们只能通过这种 obj.bar 隐式指向的方法来修改 this。

既然包装类是会改变的,那么我们就去寻找不变的东西,不变的东西就是对象。那么基本数据类型有什么对象呢?还真的有,那就是他们的构造函数的原型,包装类也是读原型上的方法嘛,那办法不就有了。只要我们在原型上进行赋值,就能通过 隐式原型链 实现 obj.bar。

所以在最开头代码实现里面,我们对 ctx 进行了读取原型的操作。Object.getPropertyOf(ctx)。然后将要执行的方法赋值到原型上。

而这样的方法对对象类型的上下文也适用,所以我们可以做同一个操作,这样就不用做传进来的上下文类型判断了。

这样就实现了基础的 call。然后 apply 和 bind 都能基于 call 实现。

call 的细节

最后聊一个细节,为什么在赋值的时候用了 symbol

这是利用了 symbol 的特性,看到这可以想一想为什么,下面会揭晓答案。

答案是利用了 symbol 的唯一性,因为我们用这种隐式指向的方法的话,我们不知道他传进来的上下文会有什么属性,假如他传进来 { a: 1 },我们赋值 ctx.a = fn 的话就直接把别人的 a 给覆盖了。

但可能有人说,我们不赋值 a 就可以了啊,我们赋值别的,例如 __sndofsfma 不就行了。

只能说要这么整,也不是不行。你开心就好。但仍然有冲突的概率,如果我们有一个冲突概率为 0 的办法,为什么不选呢。

最后

短短的一个 call,apply,bind 的实现就已经涉及了这么多基础知识,用到了 包装类this指向, 甚至 bind 还用到了闭包。不仅写的时候学到了不少,手写本身还是很有趣的。