封装一个 myCall
函数,用来实现 call
函数的功能。
myCall
函数接收多个参数,第一个参数是 fn
,代表要执行的函数,第二个参数是 context
,代表需要显式改变的 this
指向,之后的参数都是 fn
的参数。
const person = {
userName: "zhangsan",
};
function fn() {
return this.userName;
}
myCall(fn, person); // 执行函数 fn,返回 'zhangsan'
// fn 传参数的情况
const obj = {
count: 10,
};
function fn(x, y, z) {
return this.count + x + y + z;
}
myCall(fn, obj, 1, 2, 3); // 执行函数 fn,返回 16
题解分析
在尝试去实现 call
函数之前,我们再来回顾一下它的用法,call
函数是为了改变 this
的指向,代码如下:
var userName = "xxx";
const person = {
userName: "zhangsan",
};
function fn() {
console.log(this.userName);
}
fn.call(); // 直接调用,this 指向 window,输出 'xxx'
fn.call(person); // 用 call,this 指向 person,输出 'zhangsan'
call
是写到 Function.prototype
上的方法,而本节我们要实现的 myCall
是把函数当作参数传递进去,两者只是调用形式不同,原理都是一样的。
我们尝试来实现一下显式改变 this
指向的功能,调用对象中的函数,this
指向为这个对象,所以我们需要做的操作是:
- 把函数
fn
挂载到要指向的对象context
上。 - 执行
context.fn
,执行完了删除context
上的fn
函数,避免对传入对象的属性造成污染。
代码实现如下:
function myCall(fn, context) {
// 把函数 fn 挂载到对象 context 上。
context.fn = fn;
// 执行 context.fn
context.fn();
// 执行完了删除 context 上的 fn 函数,避免对传入对象的属性造成污染。
delete context.fn;
}
测试一下:
var userName = "xxx";
const person = {
userName: "zhangsan",
};
function fn() {
return this.userName;
}
myCall(fn, person); // 输出 'zhangsan'
myCall(fn, window); // 输出 'xxx'
可以看到,仅仅三行代码,我们就实现了 call
函数的核心功能。
不过这里面有一些其他细节需要处理,比如:
- 要处理
context
不传值的情况,传一个默认值window
。 - 处理函数
fn
的参数,执行fn
函数时把参数携带进去。 - 获取执行函数
fn
产生的返回值,最终返回这个返回值。
最终实现代码如下:
// 要处理 context 不传值的情况,传一个默认值 window。
function myCall(fn, context = window) {
context.fn = fn;
// 处理函数 fn 的参数,执行 fn 函数时把参数携带进去。
const args = [...arguments].slice(2);
// 获取执行函数 fn 产生的返回值。
const res = context.fn(...args);
delete context.fn;
// 最终返回这个返回值
return res;
}
const obj = {
count: 10,
};
function fn(x, y, z) {
console.log(this.count + x + y + z);
}
myCall(fn, obj, 1, 2, 3); // 执行函数 fn,输出 16
这样我们就实现了 call
函数该有的功能,原生的 call
函数是写到 Function.prototype
上的方法,我们也尝试在函数的原型上实现一个 myCall
函数,只需稍加改造即可,代码实现如下:
// 写到函数的原型上,就不需要把要执行的函数当作参数传递进去
Function.prototype.myCall = function (context = window) {
// 这里的 this 就是这个要执行的函数
context.fn = this;
// 参数少了一个,slice(2) 改为 slice(1)
const args = [...arguments].slice(1);
const res = context.fn(...args);
delete context.fn;
return res;
};
处理边缘情况
上文在函数原型上实现的 myCall
函数,还有优化的空间,有一些边缘的情况,可能会导致报错,比如把要指向的对象指向一个原始值,代码如下:
fn.myCall(0); // Uncaught TypeError: context.fn is not a function
这时,就需要参考一下原生的 call
函数是如何解决的这个问题,我们打印出来看一下:
var userName = "xxx";
const person = {
userName: "zhangsan",
};
function fn(type) {
console.log(type, "->", this.userName);
}
fn.call(0, "number");
fn.call(1n, "bigint");
fn.call(false, "boolean");
fn.call("123", "string");
fn.call(undefined, "undefined");
fn.call(null, "null");
const a = Symbol("a");
fn.call(a, "symbol");
fn.call([], "引用类型");
可以看到,undefined
和 null
指向了 window
,原始类型和引用类型都是 undefined
。
其实是因为,原始类型指向对应的包装类型,引用类型就指向这个引用类型,之所以输出值都是 undefined
,是因为这些对象上都没有 userName
属性。
改造一下我们的 myCall
函数,实现原始类型的兼容,代码如下:
Function.prototype.myCall = function (context = window) {
if (context === null || context === undefined) {
context = window; // undefined 和 null 指向 window
} else {
context = Object(context); // 原始类型就包装一下
}
context.fn = this;
const args = [...arguments].slice(1);
const res = context.fn(...args);
delete context.fn;
return res;
};
还有另外一种边缘情况,假设对象上本来就有一个 fn
属性,执行下面的调用,对象上的 fn
属性会被删除,代码如下:
const person = {
userName: "zhangsan",
fn: 123,
};
function fn() {
console.log(this.userName);
}
fn.myCall(person);
console.log(person.fn); // 输出 undefined,本来应该输出 123
因为对象上本来的 fn
属性和 myCall
函数内部临时定义的 fn
属性重名了。
还记得 Symbol
的作用吗,可以用 Symbol
来防止对象属性名冲突问题,继续改造 myCall
函数,代码实现如下:
Function.prototype.myCall = function (context = window) {
if (context === null || context === undefined) {
context = window;
} else {
context = Object(context);
}
const fn = Symbol("fn"); // 用 symbol 处理一下
context[fn] = this;
const args = [...arguments].slice(1);
const res = context[fn](...args);
delete context[fn];
return res;
};
call 使用场景
call
的使用场景非常多,所有调用 call
的使用场景都是为了显式地改变 this
的指向,能用 call
解决的问题也能用 apply
解决,因为它们俩只是传参形式不同。下面一起来看 call
常用的四个使用场景。
1.精准判断一个数据类型
精准地判断一个数据的类型,可以用到 Object.prototype.toString.call(xxx)
。
调用该方法,统一返回格式 [object Xxx]
的字符串,用来表示该对象。
// 引用类型
console.log(Object.prototype.toString.call({})); // '[object Object]'
console.log(Object.prototype.toString.call(function () {})); // "[object Function]'
console.log(Object.prototype.toString.call(/123/g)); // '[object RegExp]'
console.log(Object.prototype.toString.call(new Date())); // '[object Date]'
console.log(Object.prototype.toString.call(new Error())); // '[object Error]'
console.log(Object.prototype.toString.call([])); // '[object Array]'
console.log(Object.prototype.toString.call(new Map())); // '[object Map]'
console.log(Object.prototype.toString.call(new Set())); // '[object Set]'
console.log(Object.prototype.toString.call(new WeakMap())); // '[object WeakMap]'
console.log(Object.prototype.toString.call(new WeakSet())); // '[object WeakSet]'
// 原始类型
console.log(Object.prototype.toString.call(1)); // '[object Number]'
console.log(Object.prototype.toString.call("abc")); // '[object String]'
console.log(Object.prototype.toString.call(true)); // '[object Boolean]'
console.log(Object.prototype.toString.call(1n)); // '[object BigInt]'
console.log(Object.prototype.toString.call(null)); // '[object Null]'
console.log(Object.prototype.toString.call(undefined)); // '[object Undefined]'
console.log(Object.prototype.toString.call(Symbol("a"))); // '[object Symbol]'
这里需要调用 call
就是为了显式地改变 this
指向为我们的目标变量。
如果不改变 this
指向为我们的目标变量 xxx
,this
将永远指向调用的 Object.prototype
,也就是原型对象,对原型对象调用 toString
方法,结果永远都是 [object Object]
,如下代码所示:
2.伪数组转数组
伪数组转数组,在 es6 之前,可以使用 Array.prototype.slice.call(xxx)
。
function add() {
const args = Array.prototype.slice.call(arguments);
// 也可以这么写 const args = [].slice.call(arguments)
args.push(1); // 可以使用数组上的方法了
}
add(1, 2, 3);
原理同精准判断一个数据类型相同,如果不改变 this
指向为目标伪数组,this
将永远指向调用的 Array.prototype
,就不会生效。
// 从 slice 方法原理理解为什么要调用 call
Array.prototype.slice = function (start, end) {
const res = [];
start = start || 0;
end = end || this.length;
for (let i = start; i < end; i++) {
res.push(this[i]); // 这里的 this 就是伪数组,所以要调用 call
}
return res;
};
3.ES5 实现继承
在一个子构造函数中,你可以通过调用父构造函数的 call
方法来实现继承。
function Person(name) {
this.name = name;
}
function Student(name, grade) {
Person.call(this, name);
this.grade = grade;
}
const p1 = new Person("zhangsan");
const s1 = new Student("zhangsan", 100);
上面的代码示例中,构造函数 Student
中会拥有构造函数 Person
中的 name
属性,grade
属性是 Student
自己的。
这里的代码如果换成 ES6 的,就等价于下面的代码:
class Person {
constructor(name) {
this.name = name;
}
}
class Student extends Person {
constructor(name, grade) {
super(name);
this.grade = grade;
}
}
const p1 = new Person("zhangsan");
const s1 = new Student("zhangsan", 100);
关于继承,掌握 ES6 的实现方式就好,ES5 的做了解即可,因为现在大家基本上都用 ES6 的写法了。
4.处理回调函数 this 丢失问题
执行下面的代码,回调函数会导致 this
丢失。
const obj = {
userName: "zhangsan",
sayName() {
console.log(this.userName);
},
};
obj.sayName(); // 输出 'zhangsan'
function fn(callback) {
if (typeof callback === "function") {
callback();
}
}
fn(obj.sayName); // 输出 undefined
导致这样现象的原因是回调函数执行的时候 this
指向已经是 window
了,所以输出 undefined
。
可以使用 call
改变 this
指向,代码如下:
const obj = {
userName: "zhangsan",
sayName() {
console.log(this.userName);
},
};
obj.sayName(); // 输出 'zhangsan'
function fn(callback, context) {
// 定义一个 context 参数,可以把上下文传进去
if (typeof callback === "function") {
callback.call(context); // 显式改变 this 值,指向传入的 context
}
}
fn(obj.sayName, obj); // 输出 'zhangsan'