apply, call, bind是JavaScript中用于改变函数执行上下文(即函数内部的this指向)的方法,他们的存在和使用方式方式反应了关于函数是一等公民以及动态的this绑定规则
产生背景及原因
javascript的this关键字是语言中一个非常特别的概念,它指向函数执行时所在的上下文对象,在不同情况下,this可能指向全局对象、调用它的对象、构造函数创建的新对象,或者在严格模式下为undefined,这种灵活性虽然强大,但也会导致代码难以预测和调试,尤其是在函数作为参数传递或回调使用时。
为了给开发者提供更精细的控制,能够明确指定函数执行时的上下文对象,JavaScript引入的call, apply, bind方法,这些方法允许显示设置函数中的this值,并可以在不同场景下灵活应用
使用
call
call方法立即调用函数,并将函数内的this设置为指定的对象,可以接收多个参数,第一个参数是要绑定给this的对象,后面的参数是直接传递给被调用函数的
调用方式:fn.call(this, arg1, arg2)
function greet(name) {
console.log(`Hello, ${name}! I'm ${this.name}.`);
}
const person = { name: 'John' };
greet.call(person, 'Alice')//立即执行,打印 Hello, Alice! I'm John.
apply
与call方法类似,立即调用函数,并将函数内的this设置为指定的对象,apply函数可以接收两个参数,第一个参数是要绑定给this的对象,第二个参数是一个数组,数组中的每一项依次是传递给被调用函数的
调用方式:fn.apply(this,[arg1, arg2])
function greet(name1,name2) {
console.log(`Hello, ${name1} and ${name2}! I'm ${this.name}.`);
}
const person = { name: 'John' };
greet.apply(person, ['Alice','Bob']);//立即执行,打印 Hello, Alice and Bob! I'm John.
bind
bind方法不会立即调用函数,而是返回一个新的函数,这个新函数的this被永久绑定到传入bind的第一个参数所指的对象上,,可以提供部分应用一些参数(即部分应用函数),并流程其余参数在之后调用时再填充
调用方式: bind(this, arg1, arg2)
function greet(greeting) {
console.log(greeting + ', ' + this.name);
}
const person = { name: 'Bob' };
const greetBob = greet.bind(person);
greetBob('Hi'); // 打印 Hi, Bob
实现call方法
思路:相当于在obj上调用fn方法,此时的this是指向obj的,call就是模拟了这个过程,context就相当于obj
let obj = {
a: 'pppp',
fn: function(){
console.log(this,'fn')//{a: 'pppp', fn: ƒ}a: "pppp"fn: ƒ ()[[Prototype]]: Object 'fn'
}
}
实现代码
(context = window, …args)这里使用…args剩余参数,因为不知道调用mycall的函数需要的参数个数,所以使用剩余参数将传入的参数收集,然再调用 contextfnKey的时候使用展开语法将收集的参数一个个拿出来
Function.prototype.mycall = function(context = window, ...args) {//此处的...args是用剩余语法将剩余的参数组成一个新数组
// args 传递过来的参数 this表示调用了mycall函数的fn,比如greet.mycall(person,'hello' )中this就是greet函数 context就是调用mycall函数时传入的this
// 如果是值类型,把他变成对象类型
if (typeof context !== 'object') {
context = new Object(context)
}
// 设置一个唯一值作为属性名称,避免出现属性名称覆盖
let fnKey = Symbol();
// 相当于 obj[fnKey] = this,这里的this就是调用mycall函数的函数
context[fnKey] = this;
// 相当于obj.fn()执行,此时的this指向obj,在这里指向的就是context这个传入的this,执行完将结果保存在变量中
let result = context[fnKey](...args)//此处...args,是将上面用剩余语法得到的那个args数组,展开后,作为函数的参数传递
// 删除fn, 防止污染(即清掉obj上的fn属性)
delete context[fnKey]
// 返回结果
return result
}
实现apply方法
思路:与实现call方法一致,不过参数的处理不同,(context=window, args)这里直接接收调用myapply时传过来的数组,然后在调用 contextfnKey用展开语法将args里面的每一个取出来
实现代码
Function.prototype.myapply = function(context=window, args){
// args的处理和call不一样
if(typeof context != 'object') context = new Object(context)
let fnKey = Symbol()
context[fnKey] = this;
let result = context[fnKey](...args)
delete context[fnKey];
return result
}
关于展开语法(Spread Syntax)和剩余语法(Rest Syntax)
上面手写call和apply方法的区别在于参数的处理不同。同时又因为展开语法和剩余语法看起来很相似,所以记录区分一下
展开语法(Spread Syntax)
可以在函数调用或数组构造时,将数组表达式或者string在语法层面展开;还可以在构造字面量对象时,将对象表达式按照key-value方式展开。(字面量指[1,2,3]或者{name:‘小明’}这种简洁的构造方式
在函数调用时使用展开语法
function test(a, b,c){
console.log(a,b,c)
}
let args = [1,2,3]
test(...args)
test(…args)中…args的操作表示将数组agrs数组进行展开操作,展开之后的效果就相当于这样调用test函数:test(1, 2,3),通过打印参数也能看出来
在new表达式中使用展开语法
function Test(a, b,c){
console.log(a,b,c)
}
var args = [1,2,3]
new Test(...args)
构造字面量数组时使用展开语法
在没有展开语法的时候只能通过,push,slice,concat等方法,将已有数组元素变为新数组的一部分
var args = [1,2,3]
var arr = ['a','b',...args,'o','p']
console.log(arr)
构造字面量对象的时候使用展开语法
字面量对象的展开特性,是将已有对象的所有可枚举属性拷贝到新构造的对象中
对象的合并,对象的浅拷贝都可以使用展开语法
浅拷贝:只复制最外层的数据结构,对于嵌套的引用类型(数组或对象),仍然共享相同的引用
let obj1 = {
a: 1,
name: 'hello',
test:{
c: 1
}
}
let obj2 = {b: 2}
// 浅拷贝
let obj1ShallowCopy={...obj1}
console.log(obj1ShallowCopy)
// 合并成为新对象
let mergeObj = {...obj1, ...obj2}
console.log(mergeObj)
obj1.test.c = 100;
obj1ShallowCopy.name = 'hi'
console.log('after the changeof obj1 mergeObj', mergeObj)
console.log('after the changeof obj1', obj1ShallowCopy,obj1)
只能用于可迭代对象
在数组或者函数参数中使用展开语法时只能用于可迭代对象
let obj1 = {'key1': 'value1'}
let obj2 = [...obj1]
剩余语法(Rest Syntax)
看起来和展开语法完全相同,不同在于,剩余参数用于解构数组和对象,从某种意义上来说,剩余语法和展开语法是相反的,展开语法将数组展开味其中的各个元素,而剩余语法则是将多个元素手机起来,并凝聚为单个元素
剩余参数允许我们将一个不定数量的参数表示为一个数组
语法描述
如果函数掉最后一个命名参数以…为前缀,则它将成为一个由剩余参数组成的真数组,其中从0包括到theArgs.length的元素由传递给函数的实际参数提供
function(a, b, …args) {}中,调用时传过来的参数,第一个映射到a,第二个映射到b,剩下的参数都收集到args中
剩余参数和arguments对象的区别
函数的剩余参数和arguments对象都是用来处理不定数量的参数传递给函数的方式,但它们之间是有区别的
**剩余参数:**使用三个点…和一个形参名来定义,它明确表示该函数接受可变数量的参数,并将这些参数收集到一个数组中,剩余参数只能出现在参数列表的最后,也就是剩余参数只接收没有对应形参的实参
function test(a,b, ...args){
console.log(a,b, args)//1 2 (3) [3, 4, 5], args是一个真正的数组
}
test(1,2,3,4,5)
arguments对象:是一个类数组对象,包含了传递给函数的所有实参,它不是真正意义上的数组,是个类数组,所以不具备数组的方法如push,map,forEach等,
function test(a,b, ...args){
console.log(arguments)
console.log(args)
}
test(1,2,3,4,5)
实现bind方法
可查看:解析 bind 原理,并手写 bind 实现
bind()方法创建一个新的函数,在bind()被调用时,这个新的函数的this被指定为bind()的第一个参数,而其余参数将作为新函数的参数,供调用时使用。与apply,call直接执行函数不同,bind方法返回一个绑定上下文的函数,对于这个函数有两种方式调用,一直是直接调用,另一种是通过new的方式。对于直接调用,通过apply的实现方式,但是对于参数需要注意,bind可以实现类似fn.bind(thisArg,1)(2)因此需要将两边的参数拼接起来;对于通过new 方式调用来说,不会被任何方式改变this,所以对于这周情况要忽略传入的this
箭头函数的底层是bind,无法改变this,只能改变参数
关于bind与new
一个绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器,提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。与直接调用构造函数创建实例对象是一样的
bindBottle()中this指向bottle,所以打印’Hello,bottle’,
new bindBottle()调用时,使用new操作符调用一个函数时,它会创建一个新的空对象,并将这个空对象设置胃函数内部发this值,不管之前通过.bind()方法已经设置了什么this值,因此,即使bindBottle已经绑定了bottle作为它的this值,当你使用new操作符时,this会被重置为新创建的对象。由于这个新对象并没有nickname属性,所以输出结果为Hello, undefined。
顺便复习new操作符做了些什么事情
创建一个新对象
对象连接到构造函数原型上,并绑定 this(this 指向新对象)
执行构造函数代码(为这个新对象添加属性)
返回新对象
this.nickname = 'window'
let bottle = {
nickname: 'bottle',
}
function sayHello(){
console.log(`Hello,${this.nickname}`)
}
let bindBottle = sayHello.bind(bottle)
bindBottle();// Hello,bottle
new bindBottle();// Hello,undefined
作为构造函数使用的绑定函数
function Bottle(nickname) {
this.nickname = nickname;
}
Bottle.prototype.sayHello = function () {
console.log('Hello, ', this.nickname)
};
let bottle = new Bottle('bottle');
bottle.sayHello()
let BindBottle = Bottle.bind(null, 'bindBottle');//通过bind方法创建了一个新的函数,,预设了第一个参数是bindBottle
let b1 = new BindBottle('b1');
b1 instanceof Bottle; // true
b1 instanceof BindBottle; // true
new Bottle('bottle1') instanceof BindBottle; // true
b1.sayHello()// Hello, bindBottle
new BindBottle(‘b1’)当使用new关键字调用BindBottle时,JavaScript引擎做了以下几件事
1、创建一个新的空对象
2、将这个空对象的原型设置味Bottle.prototype(即继承自Bottle)
3、将this绑定到这个新对象
4、执行Bottle构造函数,但是,此处nickname参数已经被.bind()设置为了bindBottle,即使传递了b1,它也会被忽略
5、返回这个新对象
let value = 2;
let foo = {
value: 1
};
function bar(name, age) {
return {
value: this.value,
name: name,
age: age
}
};
bar.call(foo, "Jack", 20); // 直接执行了函数
// {value: 1, name: "Jack", age: 20}
let bindFoo1 = bar.bind(foo, "Jack", 20); // 返回一个函数
bindFoo1();
// {value: 1, name: "Jack", age: 20}
let bindFoo2 = bar.bind(foo, "Jack"); // 返回一个函数
bindFoo2(20);
// {value: 1, name: "Jack", age: 20}
function foo(x, y, z) {
this.name = "张三";
console.log(this.num, x + y + z);
}
var obj = {
num: 666
}
var foo2 = foo.bind(obj, 1,2,3);
console.log(new foo2());
实现
Function.prototype.mybind = function (context = window, ...args) {
if (typeof this !== "function") {
throw newTypeErrot("is not a function");
}
let self = this; //fn.bind(thisArg, a), self 就是fn
let fBound = function (...innerArgs) {
/**
* 对两种调用情况进行处理
* 1、如果是通过new关键字调用fBound,会创建一个新的实例,根据JavaScript的行为规范,
* 构造函数应该初始并返回一个新的对象实例,此时的this指向新创建的实例,此处如果也把context作为apply的参数,
* 那么新创建的对象实例就不能被正确的初始化,也不会继承构造函数的原型属性和方法
* 2、如果是正常调用,那么apply的第一个参数就是context,然后通过concat将内外层参数拼接起来,作为apply的第二个参数
*/
//
return self.apply(
this instanceof fBound ? this : context,
args.concat(innerArgs)
);
};
/**
* 通过new方式调用fBound,那么需要继承构造函数原型属性和方法:保证源函数的原型对象上的属性不丢失
*/
fBound.prototype = Object.create(self.prototype);
return fBound;
};