手动实现前端常见手写题
前言
相信大家在面试的过程中,或多或少都会遇到过要求手写某些函数,常见的可能就是改变函数this指向的 call, apply, bind 函数;创建实例的 new 一个对象的过程;判断某个变量是否属于指定目标的实例的 instanceof 函数以及函数柯里化 curry 函数的实现。接下来,本文将实现手敲以上函数,希望能给大家一点帮助。
call, apply, bind 实现
区别
call, apply, bind 三者的作用主要都是改变函数的this指向。他们的区别有以下两点:
- 在函数执行方面:
apply和call函数,执行后都会立即执行给定的函数。而bind只会改变this指向,不会立即执行提供的函数。 - 在参数提供方面:三者的第一个参数都是指定的上下文,而
call和bind函数之后的参数都是逐个提供的,而apply的参数提供方式是参数数组。如下图所示:

注意事项
call 函数怎么使用,我们都知道。例如像这样使用:
let obj = {
a: 1,
}
function test(x){
console.log("test ==> ", this);
return x;
}
test.call(obj, 1);

可以看到,函数内部打印的 this ,是我们指定的 obj 对象,并且返回结果也符合预期。这就是我们通常使用 call 的方式。
但如果我们需要手动实现的话,还有一些情况需要关注:
- 如果不提供第一个参数,函数内部打印的
this是什么? - 如果提供的第一个参数是
null或undefined,函数内部打印的this是什么? - 如果提供的第一个参数是普通类型数据,函数内部打印的
this又会是什么?
首先,看一下不提供第一个参数的情况,打印的 this 是什么:

如果提供的是 null 或 undefined 打印的是什么:

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

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

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

可以看到,利用 Object 函数,不仅可以解决我们的问题,而且当传入的是一个对象时,返回的也是传入的对象,简直完美。
call 实现
思路
总体上,我们可以分为以下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函数的实现,可以分为以下步骤:
- 保存当前函数
- 返回一个函数
- 特殊情况处理
- 返回最终结果
如果不考虑特殊情况,即以 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中创建的空对象为上下文执行构造函数获取返回结果
- 返回结果
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