能否模拟实现JS的call和apply方法
Category(分类): JavaScript Status: 已核对原文并更新(2025)
原文:若川(掘金),https://juejin.cn/post/6844903728147857415 ,2018 年发布,属其「面试官问」系列。本文保留原文全部思路与代码,修正了 ES5 规范摘抄中重复的步骤、个别拼写笔误,更新了
typeof类型清单,并在文末补充「现代视角」。原始授权归作者所有,转载请遵循原文出处声明。
之前写过两篇《面试官问:能否模拟实现JS的new操作符》和《面试官问:能否模拟实现JS的bind方法》
其中模拟bind方法时是使用的call和apply修改this指向。但面试官可能问:能否不用call和apply来实现呢。意思也就是需要模拟实现call和apply的了。
附上之前写文章写过的一段话:已经有很多模拟实现call和apply的文章,为什么自己还要写一遍呢。学习就好比是座大山,人们沿着不同的路登山,分享着自己看到的风景。你不一定能看到别人看到的风景,体会到别人的心情。只有自己去登山,才能看到不一样的风景,体会才更加深刻。
先通过MDN认识下call和apply
MDN 文档:Function.prototype.call()
语法
fun.call(thisArg, arg1, arg2, ...)
thisArg
在fun函数运行时指定的this值。需要注意的是,指定的this值并不一定是该函数执行时真正的this值,如果这个函数处于非严格模式下,则指定为null和undefined的this值会自动指向全局对象(浏览器中就是window对象),同时值为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象。
arg1, arg2, ...
指定的参数列表
返回值
返回值是你调用的方法的返回值,若该方法没有返回值,则返回undefined。
MDN 文档:Function.prototype.apply()
func.apply(thisArg, [argsArray])
thisArg
可选的。在 func 函数运行时使用的 this 值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装。
argsArray
可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。如果该参数的值为 null 或 undefined,则表示不需要传入任何参数。从ECMAScript 5 开始可以使用类数组对象。
返回值
调用有指定this值和参数的函数的结果。
直接先看例子1
call 和 apply 的异同
相同点:
1、call和apply的第一个参数thisArg,都是func运行时指定的this。而且,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装。
2、都可以只传递一个参数。
不同点:apply只接收两个参数,第二个参数按 ES5 规范的 CreateListFromArrayLike 语义处理:要求是对象类型(数组、类数组,甚至任何带 length 的普通对象都可以——引擎会按 length 和索引属性取值,缺失的索引以 undefined 传入),多余参数忽略不计。call接收第二个及以后一系列参数。
看两个简单例子1和2**:
// 例子1:浏览器环境 非严格模式下
var doSth = function(a, b){
console.log(this);
console.log([a, b]);
}
doSth.apply(null, [1, 2]); // this是window // [1, 2]
doSth.apply(0, [1, 2]); // this 是 Number(0) // [1, 2]
doSth.apply(true); // this 是 Boolean(true) // [undefined, undefined]
doSth.call(undefined, 1, 2); // this 是 window // [1, 2]
doSth.call('0', 1, {a: 1}); // this 是 String('0') // [1, {a: 1}]
// 例子2:浏览器环境 严格模式下
'use strict';
var doSth2 = function(a, b){
console.log(this);
console.log([a, b]);
}
doSth2.call(0, 1, 2); // this 是 0 // [1, 2]
doSth2.apply('1'); // this 是 '1' // [undefined, undefined]
doSth2.apply(null, [1, 2]); // this 是 null // [1, 2]
typeof 的返回值在原文写作时(2018 年)有 7 种(undefined number string boolean symbol object function),ES2020 加入 bigint 后是 8 种,笔者当年把每种都验证了一遍:更加验证了相同点第一点,严格模式下,函数的this值就是call和apply的第一个参数thisArg,非严格模式下,thisArg值被指定为 null 或 undefined 时this值会自动替换为指向全局对象,原始值则会被自动包装,也就是new Object()。
重新认识了call和apply会发现:它们作用都是一样的,改变函数里的this指向为第一个参数thisArg,如果明确有多少参数,那可以用call,不明确则可以使用apply。也就是说完全可以不使用call,而使用apply代替。
也就是说,我们只需要模拟实现apply,call可以根据参数个数都放在一个数组中,给到apply即可。
模拟实现 apply
既然准备模拟实现apply,那先得看看ES5规范。ES5规范 英文版,ES5规范 中文版。apply的规范下一个就是call的规范,可以点击打开新标签页去查看,这里摘抄一部分(原文此处第 2、3 步文字重复,已按 ES5 15.3.4.3 修正步骤编号)。
Function.prototype.apply (thisArg, argArray)
当以
thisArg和argArray为参数在一个func对象上调用apply方法,采用如下步骤:
1.如果 IsCallable(func) 是 false, 则抛出一个 TypeError 异常。
2.如果
argArray是null或undefined, 则返回提供thisArg作为this值并以空参数列表调用func的[[Call]]内部方法的结果。3.如果
Type(argArray)不是Object, 则抛出一个TypeError异常。4~8 略(按
argArray的length与索引属性逐个取实参,组成argList)9.提供
thisArg作为this值并以argList作为参数列表,调用func的[[Call]]内部方法,返回结果。
apply方法的length属性是2。
在外面传入的 thisArg 值会修改并成为 this 值。thisArg 是 undefined 或 null 时它会被替换成全局对象,所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。
结合上文和规范,如何将函数里的this指向第一个参数thisArg呢,这是一个问题。
这时候请出例子3:
// 浏览器环境 非严格模式下
var doSth = function(a, b){
console.log(this);
console.log(this.name);
console.log([a, b]);
}
var student = {
name: '若川',
doSth: doSth,
};
student.doSth(1, 2); // this === student // true // '若川' // [1, 2]
doSth.apply(student, [1, 2]); // this === student // true // '若川' // [1, 2]
可以得出结论1:在对象student上加一个函数doSth,再执行这个函数,这个函数里的this就指向了这个对象。那也就是可以在thisArg上新增调用函数,执行后删除这个函数即可。
知道这些后,我们试着容易实现第一版本:
// 浏览器环境 非严格模式
function getGlobalObject(){
return this;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){ // `apply` 方法的 `length` 属性是 `2`。
// 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
if(typeof this !== 'function'){
throw new TypeError(this + ' is not a function');
}
// 2.如果 argArray 是 null 或 undefined, 则
// 返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}
// 3.如果 Type(argArray) 不是 Object, 则抛出一个 `TypeError` 异常 .
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}
if(typeof thisArg === 'undefined' || thisArg === null){
// 在外面传入的 thisArg 值会修改并成为 this 值。
// ES3: thisArg 是 undefined 或 null 时它会被替换成全局对象 浏览器里是window
thisArg = getGlobalObject();
}
// ES3: 所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。
thisArg = new Object(thisArg);
var __fn = '__fn';
thisArg[__fn] = this;
// 9.提供 thisArg 作为 `this` 值并以 `argList` 作为参数列表,调用 func 的 `[[Call]]` 内部方法,返回结果
var result = thisArg[__fn](...argsArray);
delete thisArg[__fn];
return result;
};
实现第一版后,很容易找出两个问题:
__fn同名覆盖问题,thisArg对象上有__fn,那就被覆盖了然后被删除了。
针对问题1
解决方案一:采用ES6 Symbol() 独一无二的。可以本来就是模拟ES3的方法。如果面试官不允许用呢。
解决方案二:自己用Math.random()模拟实现独一无二的key。面试时可以直接用生成时间戳即可。
// 生成UUID 通用唯一识别码
// 大概生成 这样一串 '18efca2d-6e25-42bf-a636-30b8f9f2de09'
function generateUUID(){
var i, random;
var uuid = '';
for (i = 0; i < 32; i++) {
random = Math.random() * 16 | 0;
if (i === 8 || i === 12 || i === 16 || i === 20) {
uuid += '-';
}
uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random))
.toString(16);
}
return uuid;
}
// 简单实现
// '__' + new Date().getTime();
如果这个key万一这对象中还是有,为了保险起见,可以做一次缓存操作。比如如下代码:
var student = {
name: '若川',
doSth: 'doSth',
};
var originalVal = student.doSth;
var hasOriginalVal = student.hasOwnProperty('doSth');
student.doSth = function(){};
delete student.doSth;
// 如果没有,`originalVal`则为undefined,直接赋值新增了一个undefined,这是不对的,所以需判断一下。
if(hasOriginalVal){
student.doSth = originalVal;
}
console.log('student:', student); // { name: '若川', doSth: 'doSth' }
- 使用了
ES6扩展符...解决方案一:采用eval来执行函数。
eval把字符串解析成代码执行。
语法
eval(string)
参数
string
表示JavaScript表达式,语句或一系列语句的字符串。表达式可以包含变量以及已存在对象的属性。
返回值
执行指定代码之后的返回值。如果返回值为空,返回undefined
解决方案二:但万一面试官不允许用eval呢,毕竟eval是魔鬼。可以采用new Function()来生成执行函数。
MDN 文档:Function
语法
new Function ([arg1[, arg2[, ...argN]],] functionBody)
参数
arg1, arg2, ... argN
被函数使用的参数的名称必须是合法命名的。参数名称是一个有效的JavaScript标识符的字符串,或者一个用逗号分隔的有效字符串的列表;例如"x","theValue",或"A,B"。
functionBody
一个含有包括函数定义的JavaScript语句的字符串。
接下来看两个例子:
// 简单例子:
var sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6)); // 8
// 稍微复杂点的例子:
var student = {
name: '若川',
doSth: function(argsArray){
console.log(argsArray);
console.log(this.name);
}
};
// var result = student.doSth(['Rowboat', 18]);
// 用new Function()生成函数并执行返回结果
var result = new Function('return arguments[0][arguments[1]](arguments[2][0], arguments[2][1])')(student, 'doSth', ['Rowboat', 18]);
// 个数不定
// 所以可以写一个函数生成函数代码:
function generateFunctionCode(argsArrayLength){
var code = 'return arguments[0][arguments[1]](';
for(var i = 0; i < argsArrayLength; i++){
if(i > 0){
code += ',';
}
code += 'arguments[2][' + i + ']';
}
code += ')';
// return arguments[0][arguments[1]](arg1, arg2, arg3...)
return code;
}
你可能不知道在ES3、ES5中 undefined 是能修改的
可能大部分人不知道。ES5中虽然在全局作用域下不能修改,但在局部作用域中也是能修改的,不信可以复制以下测试代码在控制台执行下。虽然一般情况下是不会的去修改它。
function test(){
var undefined = 3;
console.log(undefined); // chrome下也是 3
}
test();
(undefined 不是保留字,所以局部 var undefined = 3 合法地遮蔽了全局值;这在今天的浏览器中依然成立。ES5 之后全局的 undefined 属性被规范为不可写、不可配置。)
所以判断一个变量a是不是undefined,更严谨的方案是typeof a === 'undefined'或者a === void 0;
这里面用的是void,void的作用是计算表达式,始终返回undefined,也可以这样写void(0)。
更多可以查看韩子迟的这篇文章:为什么用「void 0」代替「undefined」
解决了这几个问题,比较容易实现如下代码。
使用 new Function() 模拟实现的apply
// 浏览器环境 非严格模式
function getGlobalObject(){
return this;
}
function generateFunctionCode(argsArrayLength){
var code = 'return arguments[0][arguments[1]](';
for(var i = 0; i < argsArrayLength; i++){
if(i > 0){
code += ',';
}
code += 'arguments[2][' + i + ']';
}
code += ')';
// return arguments[0][arguments[1]](arg1, arg2, arg3...)
return code;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){ // `apply` 方法的 `length` 属性是 `2`。
// 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
if(typeof this !== 'function'){
throw new TypeError(this + ' is not a function');
}
// 2.如果 argArray 是 null 或 undefined, 则
// 返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}
// 3.如果 Type(argArray) 不是 Object, 则抛出一个 `TypeError` 异常 .
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}
if(typeof thisArg === 'undefined' || thisArg === null){
// 在外面传入的 thisArg 值会修改并成为 this 值。
// ES3: thisArg 是 undefined 或 null 时它会被替换成全局对象 浏览器里是window
thisArg = getGlobalObject();
}
// ES3: 所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。
thisArg = new Object(thisArg);
var __fn = '__' + new Date().getTime();
// 万一还是有 先存储一份,删除后,再恢复该值
var originalVal = thisArg[__fn];
// 是否有原始值
var hasOriginalVal = thisArg.hasOwnProperty(__fn);
thisArg[__fn] = this;
// 9.提供 `thisArg` 作为 `this` 值并以 `argList` 作为参数列表,调用 `func` 的 `[[Call]]` 内部方法,返回结果。
// ES6版
// var result = thisArg[__fn](...args);
var code = generateFunctionCode(argsArray.length);
var result = (new Function(code))(thisArg, __fn, argsArray);
delete thisArg[__fn];
if(hasOriginalVal){
thisArg[__fn] = originalVal;
}
return result;
};
利用模拟实现的apply模拟实现call
Function.prototype.callFn = function call(thisArg){
var argsArray = [];
var argumentsLength = arguments.length;
for(var i = 0; i < argumentsLength - 1; i++){
// argsArray.push(arguments[i + 1]);
argsArray[i] = arguments[i + 1];
}
console.log('argsArray:', argsArray);
return this.applyFn(thisArg, argsArray);
}
// 测试例子
var doSth = function (name, age){
var type = Object.prototype.toString.call(this);
console.log(typeof doSth);
console.log(this === firstArg);
console.log('type:', type);
console.log('this:', this);
console.log('args:', [name, age], arguments);
return 'this--';
};
var name = 'window';
var student = {
name: '若川',
age: 18,
doSth: 'doSth',
__fn: 'doSth',
};
var firstArg = student;
var result = doSth.applyFn(firstArg, [1, {name: 'Rowboat'}]);
var result2 = doSth.callFn(firstArg, 1, {name: 'Rowboat'});
console.log('result:', result);
console.log('result2:', result2);
细心的你会发现注释了这一句argsArray.push(arguments[i + 1]);,事实上push方法,内部也有一层循环。所以理论上不使用push性能会更好些。面试官也可能根据这点来问时间复杂度和空间复杂度的问题。
// 看看(早期)V8 引擎中的具体实现(示意):
function ArrayPush() {
var n = TO_UINT32( this.length ); // 被push的对象的length
var m = %_ArgumentsLength(); // push的参数个数
for (var i = 0; i < m; i++) {
this[ i + n ] = %_Arguments( i ); // 复制元素 (1)
}
this.length = n + m; // 修正length属性的值 (2)
return this.length;
};
(说明:这是早期 V8 源码中的 JS 风格示意,%_Arguments 等是当年的内部内建函数;现代 V8 的数组方法已改用 Torque/CSA 实现,这段代码只能作为原理示意,不能在现代源码中找到。且 % 开头的语法在普通脚本中不合法,此处仅作阅读参考。)
行文至此,就基本结束了,你可能还发现就是写的非严格模式下,thisArg原始值会包装成对象,添加函数并执行,再删除。而严格模式下还是原始值这个没有实现,而且万一这个对象是冻结对象呢,Object.freeze({}),是无法在这个对象上添加属性的。所以这个方法只能算是非严格模式下的简版实现。最后来总结一下。
总结
通过MDN认识call和apply,阅读ES5规范,到模拟实现apply,再实现call。
就是使用在对象上添加调用apply的函数执行,这时的调用函数的this就指向了这个thisArg,再返回结果。引出了ES6 Symbol,ES6的扩展符...、eval、new Function(),严格模式等。
事实上,现实业务场景不需要去模拟实现call和apply,毕竟是ES3就提供的方法。但面试官可以通过这个面试题考察候选人很多基础知识。如:call、apply的使用。ES6 Symbol,ES6的扩展符...,eval,new Function(),严格模式,甚至时间复杂度和空间复杂度等。
读者发现有不妥或可改善之处,欢迎指出。另外觉得写得不错,可以点个赞,也是对笔者的一种支持。
// 最终版 删除注释版,详细注释看文章
// 浏览器环境 非严格模式
function getGlobalObject(){
return this;
}
function generateFunctionCode(argsArrayLength){
var code = 'return arguments[0][arguments[1]](';
for(var i = 0; i < argsArrayLength; i++){
if(i > 0){
code += ',';
}
code += 'arguments[2][' + i + ']';
}
code += ')';
return code;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){
if(typeof this !== 'function'){
throw new TypeError(this + ' is not a function');
}
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}
if(typeof thisArg === 'undefined' || thisArg === null){
thisArg = getGlobalObject();
}
thisArg = new Object(thisArg);
var __fn = '__' + new Date().getTime();
var originalVal = thisArg[__fn];
var hasOriginalVal = thisArg.hasOwnProperty(__fn);
thisArg[__fn] = this;
var code = generateFunctionCode(argsArray.length);
var result = (new Function(code))(thisArg, __fn, argsArray);
delete thisArg[__fn];
if(hasOriginalVal){
thisArg[__fn] = originalVal;
}
return result;
};
Function.prototype.callFn = function call(thisArg){
var argsArray = [];
var argumentsLength = arguments.length;
for(var i = 0; i < argumentsLength - 1; i++){
argsArray[i] = arguments[i + 1];
}
return this.applyFn(thisArg, argsArray);
}
现代视角(2025)
原文写于 2018 年,模拟思路(把函数挂到 thisArg 上再调用)至今仍是面试的标准解法,但有一些现代背景值得补充:
规范措辞的变化。 ES5 里的「替换全局对象 / ToObject 包装」在 ES2015 之后的规范中被描述为 OrdinaryCallBindThis:严格模式的函数直接把 thisArg 原样绑定(所以 this 可以是原始值甚至 null);非严格模式的函数先经过 ToObject,其中 null/undefined 替换为全局对象。原文的结论与现行规范一致。
Reflect.apply 是现代标准做法。 ES2015 提供了 Reflect.apply(target, thisArgument, argumentsList),等价于 Function.prototype.apply.call(target, thisArgument, argumentsList),这正是 polyfill 库(如 core-js)内部用来实现 bind 等方法的原语。面试中若被问「不用 call/apply 如何以指定 this 调用函数」,除了挂属性法,也可以提到它(以及它的局限——它本身就是另一种 apply)。
apply 与展开运算符的分工。 有了 f(...args) 之后,apply 在新代码里的主要用途收窄为「参数本身是数组且长度不定」的场景(如 Math.max.apply(null, arr) → 如今写作 Math.max(...arr))。call 则常见于借用方法:Object.prototype.toString.call(x)、Array.prototype.slice.call(arrayLike)。
模拟实现的固有局限(面试加分点):
- 严格模式无法模拟:严格模式下函数的
this是原始值本身,而「挂属性再调用」必须把宿主变成对象,语义上就不等价了。 - 不可扩展对象:
Object.freeze(obj)、Object.preventExtensions(obj)以及被冻结的内置对象(如Object.freeze({}))无法添加临时属性,模拟实现会静默失败或抛错。 - Proxy 拦截:
new Proxy(target, { set() { return false } })会让thisArg[__fn] = this失败。 - 时间戳 key 并发碰撞:同一毫秒内两次调用
applyFn会生成相同的__fn键,虽有恢复逻辑兜底,仍可能在 getter/setter 场景触发副作用。用Symbol()生成键是更稳的方案(但它就不是「模拟 ES3」了)。
不要在生产代码中修改内置原型。 原文把 applyFn/callFn 挂在 Function.prototype 上是为了教学对齐(doSth.applyFn(...) 与原生调用形式一致),生产环境中给内置原型添加方法是公认的禁忌(命名冲突、破坏 for...in、多 realm 不一致等)。教学实现写成独立函数 myApply(fn, thisArg, args) 即可。
扩展阅读
- 《JavaScript设计模式与开发实践》- 第 2 章 this、call 和 apply(曾探著,图灵社区)
- JS魔法堂:再次认识Function.prototype.call
- 不用call和apply方法模拟实现ES5的bind方法
- JavaScript深入之call和apply的模拟实现(冴羽版,思路同源)
- MDN:Reflect.apply
- MDN:严格模式