手动实现前端常见手写题
前言
相信大家在面试的过程中,或多或少都会遇到过要求手写某些函数,常见的可能就是改变函数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