前言
前端技术更新本来就很快,最近又有 chatGPT 火遍全球,更有“前端已死”的话语出圈。我们切勿焦虑,还是要调整好心态,深入学习前端技术的基础和技术主干,把基础原理吃透,技术深度比技术广度重要。
后续会持续更新JS基础或面试相关知识,感兴趣的同学可以支持点赞关注哟。
你有没有思考这样一个问题:我们明明从大量书籍,名牌讲师或优秀文章中,输入了很多JavaScript系统知识,自我感觉我们都掌握了。但是每当别人问的时候,问题就来了,往往只有只言片语,或者东拼西凑,最后甚至连一个简单的知识点都说不明白。这无疑对我们来说是很大的一个打击,也不是我们想要的。
所以这个面试系列就是要解决这个问题,以对话的形式,边看文章边思考,加深对知识点的理解,最后可以尽可能流畅,清晰的表达给他人。
模拟场景
小白和小高是合租的室友,小白是刚毕业一年的前端开发人员,小高则是前端大牛。小白觉得当前工资低,最近又到了金三银四的好时节,以自己目前的水平可以换一个高工资的工作。这天就来找小高,想让小高给点建议,小高说那我给你出几道高频面试题,看下你对知识点掌握的情况。
作用和来源
小高:apply,call,bind这3个方法你肯定用过,那你知道他们的作用和来源吗?
小白:这个简单,我肯定用过呀,他们都是修改this指向的,嗯,嗯...来源是啥呀,我没考虑过。
小高:首先它们的作用你回答的对,他们确实都是修改this指向的,但是说的完整一点,他们都是用来改变函数的执行时this的方法。至于来源,首先我们来看下代码:
function isFun(type) {
return Function.prototype.hasOwnProperty(type);
}
console.log(isFun('call')); // true
console.log(isFun('apply')); // true
console.log(isFun('bind')); // true
上面代码中,都返回了 true,表明这三个方法都是继承自 Function.prototype 中的,属于实例方法。
小白:喔喔,我明白了,知道这个来源,就对这3个方法更了解了。
用法和区别
小高:那你知道他们的用法和区别吗?
小白:我知道啊,他们接收参数的方式不同,call是数组传参,喔,不对,是参数列表,哎我有点弄不清,反正有的数组,有的参数列表,具体我忘记了。
小高:哈哈,没事,不要紧张,确实是接收参数方式不同。我们可以写个例子,加深印象。
var name="小明";
function getFoods(fruit1,fruit2) {
return `${this.name}喜欢吃${fruit1}和${fruit2}`
}
let obj={name:'小宇'}
console.log(getFoods.call(null,'苹果','香蕉')); //小明喜欢吃苹果和香蕉
console.log(getFoods.call(obj,'苹果','香蕉')); //小宇喜欢吃苹果和香蕉
console.log(getFoods.apply(obj,['苹果','香蕉'])); //小宇喜欢吃苹果和香蕉
console.log(getFoods.bind(obj)('苹果','香蕉')); //小宇喜欢吃苹果和香蕉
从上述可以看出,apply,call,bind 改变了this指向,指向obj。
call,apply 基本类似,除接收的参数不同,call 接收的是参数列表,而 apply 是包含多个参数的数组。
bind 创建了一个新函数,并且在调用该函数时将其 this 对象绑定到指定值,该函数可以用于以后调用:
总的来说,如果你想立即调用一个函数并改变它的 this 对象,那么你可以使用 call 或者 apply;如果你想创建一个新函数,而在以后调用它,并且在调用时始终改变其 this 对象,那么你可以使用 bind。
小白:喔喔,通过你这样一讲,我就很清晰了,有种豁然开朗的感觉。
2. 实现原理
小高:好了,它们的用法和区别你已经掌握了,那你会手写实现吗?
小白:手写?嗯。。。嗯。。。不会
小高:没事,接下来我们一起来看下,首先我们手写下call的实现原理,call你掌握了,apply,bind就很容易了。
call
小高:call咱们刚才用了,那你现在总结下这个方法的关键点是啥?
小白:修改this指向;参数列表形式传参。
小高:是这样的,但是你遗漏了还有重要的一点【执行函数】。总结一下实现的 call 方法的关键在于:
-
给函数绑定新的 this;
-
执行函数;
Function.prototype.myCall=function(context){
// 给函数绑定新的this
context['fn']=this;
//绑定后执行函数
context['fn']()
}
小高:这样我们基本就实现了call的方法,是不是很简单。
小白:实现了?这么简单,那让我写个例子测试一下;
//使用 globalThis 访问全局变量
globalThis name='小明';
let obj={
name:'小宇'
};
function getName() {
console.log(this.name);
}
getName(); //小明
getName.myCall(obj); //小宇
从上述可以看出改变了this指向,输出“小宇”;
小白:哇,真的呀,原理这么简单!不对,咱们没有传参,是不是还要接收参数。
小高:对,很聪明嘛,看来咱们上面说的你真的明白了,接下来然后我们将代码进行一些优化,不光是传参,还要增加代码可扩展性
Function.prototype.myCall=function(context,...args){
// 1. 检查调用call的对象是否是函数
if(typeof(this)!=='function'){
throw new TypeError('not a function')
}
// 2. 若不传入context,this默认会指向window,这里使用glbalthis访问全局变量
context=context||globalThis;
// 3. 利用es6 Symbol(),给context创建一个独一无二的属性
let fn=Symbol();
// 4. 执行函数并赋值
// 把当前的函数给传进来的目标对象context身上拷贝了一份
context[fn]=this;
// 执行的时候就执行对象上context[fn]的方法,this指向就是当前对象了
const res=context[fn](...args);
// 5. 删除新增的属性
delete context[fn];
// 6. 返回结果
return res;
}
小白:明白了明白了,以后面试再让手写call我就不怕了。
apply
小高:手写call你已经掌握了,apply就很容易,apply 和call 原理一致,只是第二个参数传入的是数组
Function.prototype.myApply=function(context,args){
// 1. 检查调用apply的对象是否是函数
if(typeof(this)!=='function'){
throw new TypeError('not a function')
}
// 2. 若不传入context,this默认会指向window,这里使用glbalthis访问全局变量
context=context||globalThis;
// 3. 利用es6 Symbol(),给context创建一个独一无二的属性
let fn=Symbol();
// 4. 执行函数并赋值
context[fn]=this;
const res=context[fn](...args);
// 5. 删除新增的属性
delete context[fn];
// 6. 返回结果
return res;
}
测试一下:
var year = 2022
function getDate(month, day) {
const date= `${this.year}-${month}-${day}`
console.log(date);
}
let obj = {year: 2023}
getDate.myApply(obj,[2,21]) //2023-2-21
小白:哇,谢谢谢谢,我都佩服自己了,一小会儿的功夫,已经掌握了手写call(),apply()方法,我原来都觉得那是很深奥的东西,不是我这种菜鸟能理解和掌握的,你这么一讲,我就很清楚了。
小高:别高兴的太早了,咱们还有bind呢,你不想掌握手写bind方法吗。
小白:想呀,想呀,咱们继续,我要一气呵成,以后这3个方法在哪都不怕了。
bind
小高:bind 的实现虽然要复杂一点,但是在前面 call,apply 实现的基础上,也比较容易上手了。那你再回忆一下,bind和call的区别是啥?
小白:bind和call接收的参数都是参数列表的形式,但是call可以直接调用,而bind返回的是函数。
小高:对,bind返回的是函数,在这里需要注意的是,既然是函数,那么可以普通函数调用,也可以构造函数调用,所以我们要考虑一下几个要素:
- bind调用的时候可以传参,调用之后生成新的函数也可以传参,所以需要将剩余的参数和传入的参数拼接,作为新的参数:
newArgs=[...args, ...innerArgs]
- 判断是否为构造函数,使用instanceof操作符判断
// 判断是否为构造函数
context = this instanceof myBound ? this : context;
- 实现原型继承,参考JS中的继承与原型链
// Object.create拷贝原型对象
myBound.prototype = Object.create(this.prototype);
根据以上几个要素,接下来进行分步实现 bind() 方法:
- 首先在原型上定义一个函数 myBind,函数的第一个参数作为context,后面的参数作为返回新方法的参数:
Function.prototype.mybind = function (context, ...args) {
// ...
}
2.myBind 方法会返回一个新函数,该函数将外层函数的参数,与内层函数的参数连接起来一起作为参数:
Function.prototype.mybind = function (context, ...args) {
return function () {
//使用es6扩展运算符合并参数
newArgs=[...args, ...innerArgs]
}
}
3.接下来可以使用 apply() 来完成 this 指向变更,在那之前可以使用变量 that 先保存原函数:
Function.prototype.mybind = function (context, ...args) {
let that = this;
return function () {
newArgs=[...args, ...innerArgs];
//使用apply修改this的指向
return that.apply(context, ...newArgs);
}
}
以上就实现了普通函数调用 bind(),是不是很容易理解。
- 当传入的函数是一个构造函数时,不需要更改 this 的指向。
Function.prototype.mybind = function (context, ...args) {
let that = this;
// 因为需要构造函数,所以不能是匿名函数了
let myBound=function () {
newArgs=[...args, ...innerArgs];
// 判断是否为构造函数
context = this instanceof myBound ? this : context;
return that.apply(context, ...newArgs);
}
//返回函数
return myBound;
}
- 新返回的函数与原函数的原型对象并没有建立联系,所以new出来的对象不能访问到原函数的原型对象上的方法,换句话说就是不能继承构造函数原型属性和方法。
Function.prototype.mybind = function (context, ...args) {
let that = this;
let myBound=function () {
newArgs=[...args, ...innerArgs];
context = this instanceof myBound ? this : context;
return that.apply(context, ...newArgs);
}
// 实现继承1: 构造一个中间函数来实现继承
// let mFun = function () { }
// mFun.prototype = this.prototype
// result.prototype = new mFun() //原型式继承
// 实现继承2: 使用Object.create拷贝原型对象
result.prototype = Object.create(this.prototype)
return myBound;
}
完整实现过程
Function.prototype.myBind = function (context, ...args) {
if(typeof(this)!=='function'){
throw new TypeError('not a function')
}
context=context||window;
let fn = Symbol();
context[fn] = this;
// 保存外部函数的this
const that = this;
//返回一个新函数
let myBound=function (...innerArgs) {
newArgs=[...args, ...innerArgs];
context = this instanceof myBound ? this : context;
return that.call(context, ...newArgs);
}
// 实现继承1: 构造一个中间函数来实现继承
// let mFun = function () { }
// mFun.prototype = this.prototype
// result.prototype = new mFun() //原型式继承
// 实现继承2: 使用Object.create拷贝原型对象
myBound.prototype = Object.create(this.prototype)
return myBound;
}
小高:好,实现代码我们写完了,接下来测试下:
function Product(name,count){
console.log(`${name}${count}个`);//香蕉2个
console.log(this); // this指向实例对象 Product {}
console.log(`${this.name}${this.count}个`);//undefinedundefined个
}
Product.prototype.getColor=function(color){
console.log(color);//yellow
}
let obj={
name:'苹果',
count:'10'
}
// 构造函数调用
let fn=Product.myBind(obj,'香蕉');
let pp=new fn('2');
pp.getColor('yellow');
小白:我有个疑问,你看上述这个代码,既然传入的obj没有生效,那我们用bind实现了个啥,总感觉怪怪的。
小高:因为new出来的构造函数,this指向创建出的对象,在构造函数中使用bind的作用就是传参。
小白:那既然没什么用,为什么还要传入obj?
小高:哈哈,你忘了我们还有普通函数调用,你别急,你看下面这个普通函数调用,是不是指向了obj:
let obj={
name:'苹果',
count:'10'
}
//普通函数调用
function getProduct(name,count) {
console.log(`${name}${count}个`);//西瓜5个
console.log(this);//this指向传进来的obj
console.log(`${this.name}${this.count}个`);//苹果10个
}
let fnBind=getProduct.myBind(obj);
fnBind('西瓜','5');
小白:喔喔,我明白了,就是构造函数就是传参使用,不改变this指向;普通函数调用才改变this指向,你上面其实已经说过了,就是那个判断this的代码
context = this instanceof myBound ? this : context;
小高:这个我们就到这里吧,其实实现原理这里面还涉及到了原型,原型链,new操作符和ES6的相关知识点,后续我们也会一一讨论这些,让我们一起期待吧。