likes
comments
collection
share

手动实现前端常见手写题

作者站长头像
站长
· 阅读数 41

前言

相信大家在面试的过程中,或多或少都会遇到过要求手写某些函数,常见的可能就是改变函数this指向的 call, apply, bind 函数;创建实例的 new 一个对象的过程;判断某个变量是否属于指定目标的实例的 instanceof 函数以及函数柯里化 curry 函数的实现。接下来,本文将实现手敲以上函数,希望能给大家一点帮助。

call, apply, bind 实现

区别

call, apply, bind 三者的作用主要都是改变函数的this指向。他们的区别有以下两点:

  1. 在函数执行方面:applycall函数,执行后都会立即执行给定的函数。而 bind 只会改变 this 指向,不会立即执行提供的函数。
  2. 在参数提供方面:三者的第一个参数都是指定的上下文,而 callbind 函数之后的参数都是逐个提供的,而apply的参数提供方式是参数数组。如下图所示:

手动实现前端常见手写题

注意事项

call 函数怎么使用,我们都知道。例如像这样使用:

let obj = {
  a: 1,
}

function test(x){
  console.log("test ==> ", this);
  return x;
}

test.call(obj, 1);

手动实现前端常见手写题

可以看到,函数内部打印的 this ,是我们指定的 obj 对象,并且返回结果也符合预期。这就是我们通常使用 call 的方式。

但如果我们需要手动实现的话,还有一些情况需要关注:

  1. 如果不提供第一个参数,函数内部打印的this是什么?
  2. 如果提供的第一个参数是 nullundefined,函数内部打印的 this 是什么?
  3. 如果提供的第一个参数是普通类型数据,函数内部打印的this又会是什么?

首先,看一下不提供第一个参数的情况,打印的 this 是什么:

手动实现前端常见手写题

如果提供的是 nullundefined 打印的是什么:

手动实现前端常见手写题

如果提供的是普通类型数据,打印的结果是什么:

手动实现前端常见手写题

从以上三个截图可以看到:当第一个参数为 nullundefined 或者不提供,this指向 window。当提供的是普通类型数据,this 指向的是被对应类型构造函数创建的对象:

手动实现前端常见手写题

call函数的第一个参数是 null, undefined 或不提供第一个参数的情况下,我们好处理,直接把上下文指定为 window 即可。可是当第一个参数提供的是普通类型数据时,怎么办呢?难道是手动判断其类型,再调用对应的构造函数创建一个实例出来吗。即使这样能实现,也太不方便了吧。有没有更好的方式呢?自然是有的,我们可以使用 Object 函数去实现:

手动实现前端常见手写题

可以看到,利用 Object 函数,不仅可以解决我们的问题,而且当传入的是一个对象时,返回的也是传入的对象,简直完美。

call 实现

思路

总体上,我们可以分为以下4个步骤:

  1. 创建上下文
  2. 把函数作为上下文的属性
  3. 执行函数获取结果
  4. 返回结果

关于第一步,我们可能会疑惑:我们已经把上下文作为第一个参数提供了,为什么还要创建?因为可能存在上面“注意事项”里提到的情况,存在没有提供上下文参数,或者上下文参数是 null, undefined或者是普通类型数据的情况,所以需要针对性地处理下。

关于第二步,为什么需要把函数作为上下文的属性?因为我们目的是改变函数的 this 指向,那么,如果我们把函数作为上下文的一个属性,当函数执行时,函数内部不就指向上下文了吗。

关于第三,第四步,因为在第二步已经完成了改变this指向,所以直接调用函数并返回结果即可:

Function.prototype._call = function(ctx, ...args){
  // 1. 创建上下文
  const targetCtx = (ctx === undefined || ctx === null) ? window : Object(ctx);
  // 2. 把函数作为上下文的属性
  const key = Symbol();
  targetCtx[key] = this;
  // 3. 执行函数获取结果
  const res = targetCtx[key](...args);
  Reflect.deleteProperty(targetCtx, key);
  // 4. 返回结果
  return res;
}

代码里为了不污染上下文,所以临时新增了一个独一无二的键名key = Symbol(),然后把函数赋值给 ctx[key],后续获取函数返回结果后会删除这个键名。这里可能有人会一时没反应过来,ctx[key] = this 里的 this 为什么是我们需要的函数。这里解释一下:我们知道,调用对象的方法时,例如: obj.fn() ,那么该方法里的上下文,是不是就是这个对象 obj?那么同样的,我们以 testFn.call() 的方式调用 call 函数时,call 函数内部的 this 是不是就指向了函数 testFn

验证

call 函数实现了,我们来验证下:

手动实现前端常见手写题

手动实现前端常见手写题

可以看到,结果符合预期。前三个打印语句输出的时 window 对象,后三个打印语句输出的是对应构造函数创建的实例对象,最后一个输出的是 obj 对象。

apply 实现

思路

apply 函数的实现跟 call 基本一致,只是传参的方式不同,传的是参数数组:

Function.prototype._apply = function (ctx, params = []) {
    // 1. 创建上下文
    const targetCtx = (ctx === undefined || ctx === null) ? window : Object(ctx);
    // 2. 把函数作为上下文的属性
    const key = Symbol();
    targetCtx[key] = this;
    // 3. 执行函数获取结果
    const res = targetCtx[key](...params);
    Reflect.deleteProperty(targetCtx, key);
    // 4. 返回结果
    return res;
};

验证

手动实现前端常见手写题

手动实现前端常见手写题

可以看到,结果跟 call 函数的输出一致,并且最后一个打印语句的返回值是 3, 跟我们在 testApply._apply(applyObj, [1, 2]) 里传递的参数数组 [1, 2] 对得上。

bind 实现

思路

bind 函数的实现,建立在 call 实现的基础上,因为 bind 函数绑定上下文后不会马上执行。所以bind函数的返回值会是一个函数,函数里包含call函数实现的逻辑。并且,考虑到bind处理后的函数,可能会被 new 的形式调用,所以返回的函数内还需要多一个判断逻辑,即判断是否是以构造函数的形式被调用。

综上,bind函数的实现,可以分为以下步骤:

  1. 保存当前函数
  2. 返回一个函数
  3. 特殊情况处理
  4. 返回最终结果

如果不考虑特殊情况,即以 new 的方式调用函数。那么,我们只需在返回的函数里包含 call 函数的实例逻辑即可:

Function.prototype._bind = function(ctx, ...args){
    // 1. 保存当前函数
    const fn = this;
    // 2. 返回一个函数
    return function F(...params){
        // call 函数的实现逻辑
        const targetCtx = (ctx === undefined || ctx === null) ? window : Object(ctx);
        const key = Symbol();
        targetCtx[key] = fn;
        let res = targetCtx[key](...args, ...params);
        Reflect.deleteProperty(targetCtx, key)
        // 返回结果
        return res;
    }
}

验证结果也是正常的:

手动实现前端常见手写题

手动实现前端常见手写题

但如果以 new 的方式调用,就会有问题:

手动实现前端常见手写题

手动实现前端常见手写题

这里打印的是 widnow 对象,明显是不对的。因为我们知道,构造函数内的this指向的是实例对象,所以这里应该是一个空对象{},因为testBind函数内部除了一个打印语句外,没有进行任何操作。

针对这种情况,我们应该判断bind返回的函数,是不是以 new 的形式被调用了。如何判断呢?很简单,利用刚刚说的“构造函数内的this指向的是实例对象”即可。举个例子:

手动实现前端常见手写题

从截图里可以看到,this打印的是实例对象,并且 this 是构造函数 Person的实例。借助这个,我们可以这样判断:

Function.prototype._bind = function(ctx, ...args){
    // 1. 保存当前函数
    const fn = this;
    // 2. 返回一个函数
    return function F(...params){
        let res;
        // 3. 特殊情况处理
        if(this instanceof F){
            res = new fn(...args, ...params);
        }else{
            // call 函数的实现逻辑
            const targetCtx = (ctx === undefined || ctx === null) ? window : Object(ctx);
            const key = Symbol();
            targetCtx[key] = fn;
            res = targetCtx[key](...args, ...params);
            Reflect.deleteProperty(targetCtx, key)
        }
        // 4. 返回结果
        return res;
    }
}

我们给返回的函数一个名字,这里是F,然后内部判断 this instanceof F 的结果,如果为真,说明F是以 new 的方式调用的,此时把结果赋值为 new fn(...args, ...params) 即可;如果为假,则按原来写好的逻辑走即可。

此时我们来再看一下验证结果:

手动实现前端常见手写题

手动实现前端常见手写题

可以看到,console.log(new t8())的打印结果是 {} ,符合预期,说明bind函数改造完成。

new 实现

注意事项

我们知道,通过new方式创建的实例对象,含有构造函数运行时初始化的属性,像这样:

手动实现前端常见手写题

p1实例对象含有构造函数运行时初始化的name属性。一般来说,我们不会在构造函数内部返回内容。但有一点需要注意的是,当我们在构造函数内部返回复杂类型数据时,new出来的就不是实例了,而是这个复杂类型数据:

手动实现前端常见手写题

而如果构造函数内部返回的是普通类型数据,则会忽略这个返回值,最终得到的是实例对象:

手动实现前端常见手写题

思路

根据构造函数内部以 this.xxx = xxx的形式初始化属性,并且返回值是一个实例对象。我们这样来实现:

  1. 创建一个空对象并绑定原型
  2. 以步骤1中创建的空对象为上下文执行构造函数获取返回结果
  3. 返回结果
function _new(fn, ...args){
  // 1. 创建空对象并绑定原型
  const obj = Object.create(fn.prototype);
  // 2. 以空对象为上下文执行构造函数
  const res = fn.call(obj, ...args);
  // 3. 返回结果
  return (res instanceof Object) ? res : obj;
}

验证

首先,验证下一般的情况:

手动实现前端常见手写题

手动实现前端常见手写题

可以看到,打印的结果是实例对象,并且实例的属性,方法都是正确的。

再来看下,构造函数有返回值的情况:

手动实现前端常见手写题

手动实现前端常见手写题

可以看到,返回值是普通类型数据时,得到的结果仍然是实例对象。返回值是复杂类型数据时,得到的结果就是该复杂类型数据,跟预期一致。

instanceof 实现

思路

实现 instanceof ,思路就是顺着给定数据的原型链,一直去比对,最终返回比对的结果:

function _instanceof(item, target){
  try {
    let proto = Reflect.getPrototypeOf(item);
    let res = false;
    while(proto){
      if(proto === target.prototype){
        res = true;
        break;
      }
      proto = Reflect.getPrototypeOf(proto);
    }
    return res;
  } catch (e) {
    // item 是普通类型数据时,获取原型会报错
    return false; 
  }
}

结合代码,我们以item为给定的数据,target为比对目标。这里我们顺着item的原型链,利用Reflect.getPrototypeOf获取原型链上的原型,然后与target.prototype比对。若找到,则返回true,找不到则返回false

item是普通类型数据时,使用 Reflect.getPrototypeOf API会报错:

手动实现前端常见手写题

所以我们用一个try-catch包裹,并且catch内直接返回false

验证

我们来验证下:

手动实现前端常见手写题

手动实现前端常见手写题

可以看到,结果都是对的,符合我们的预期。

curry 实现

我们有时候会遇到,要求实现这样一个函数,并且可以以这样的方式调用:

test(1, 2, 3);
test(1, 2)(3);
test(1)(2)(3);

即可以一次性提供所有参数,也可以先提供部分参数再把剩余的也提供了,甚至是逐个参数提供。利用函数柯里化,我们可以解决这个问题。

思路

实现函数柯里化,核心的点就一个:利用数组把提供的参数保存起来,当保存起来的参数总数大于等于形参的个数时,就执行函数:

/**
 * 函数柯里化
 * @param {Function} fn 目标函数
 * @param {Number} len 形参个数
 * @param  {...any} args 参数
 * @returns 柯里化后的函数
 */
function curry(fn, len = fn.length, ...args){
  return function(...params){
    const allParams = [...args, ...params];
    return (allParams.length >= len) ? fn.call(this, ...allParams) : curry(fn, len, ...allParams);
  }
}

我们声明一个名为curry的函数,它的第一个参数是一个函数,即需要进行柯里化的函数;第二个参数是形参的个数,即提供的函数需要的形参的个数;后续的参数就是具体提供给函数的参数。

我们在curry函数内部返回一个函数,把该函数接收的参数以...params的方式保存起来,在该函数内部,把所有的参数保存起来,即把curry函数的args参数以及返回的函数的params保存起来,放在allParams数组里,然后判断总参数个数是否大于等于形参个数,是的话就把所有的参数提供给目标函数并执行,否则就递归调用curry函数,继续把参数保存起来。

验证

我们来验证下:

手动实现前端常见手写题

手动实现前端常见手写题

从第一个打印语句可以看到,当提供的参数少于形参个数时,函数不会执行。从第二个打印语句可以看出,即使多提供了参数,也不会使用多余的参数。剩余的打印语句证明,无论参数提供的形式是怎样的,最终的结果都是一致的,并且是正确的。

总结

本文实现了call, apply, bind, new, insctanceof以及curry的实现。只要我们掌握了其中的知识点,相信大家自然就会知道如何去手动实现。

代码地址

转载自:https://juejin.cn/post/7268317430672785445
评论
请登录