JavaScript 深入之 call 和 apply 的模拟实现
Category(分类): JavaScript Status: 已整理(2026)
原文是“JavaScript 深入系列”中关于
call/apply的文章。本文保留原文从“把函数临时放到对象属性上”理解this的教学路径,同时修复抓取导致的代码换行、删除“代码解读/复制代码”等页面 UI 文本,并补充严格模式、箭头函数、原始值、异常清理和现代Reflect.apply()语义。
一、call 做了什么
Function.prototype.call() 会在指定的 this 值和一组参数下立即调用函数,并返回函数的返回值:
const foo = { value: 1 }
function bar(name) {
return `${name}: ${this.value}`
}
console.log(bar.call(foo, 'value')) // value: 1
原文强调了两点:
call为这次调用指定了this;bar被立即执行。
还要补充第三点:call 不会修改 foo,调用完成后也不会在 foo 上留下临时属性。
二、原文“临时属性”思路
原文把调用过程想象成:
const foo = {
value: 1,
bar() {
return this.value
}
}
foo.bar() // 1
因为以 foo.bar() 形式调用,普通函数中的 this 会指向 foo。于是早期的教学思路是:
- 把待调用函数临时设置为对象的属性;
- 以
object[property](...)形式调用; - 删除临时属性。
这个思路有助于理解隐式绑定,但它不是原生 call 的等价实现:原生 call 不会修改目标对象,也能正确处理冻结对象、Proxy、已有同名属性、原始值、严格函数和箭头函数。
2.1 一个只用于理解的改良版
如果只演示“普通非严格函数 + 对象接收者”的简单场景,可以使用唯一 Symbol,避免覆盖普通字符串属性,并用 try/finally 保证清理:
function callByTemporaryProperty(func, object, ...args) {
if (typeof func !== 'function') {
throw new TypeError('func 必须是函数')
}
if (object === null || (typeof object !== 'object' && typeof object !== 'function')) {
throw new TypeError('这个教学版本只接受对象接收者')
}
const key = Symbol('temporary-call')
Object.defineProperty(object, key, {
configurable: true,
value: func
})
try {
return object[key](...args)
} finally {
delete object[key]
}
}
const foo = { value: 1 }
function bar(name) {
return `${name}: ${this.value}`
}
console.log(callByTemporaryProperty(bar, foo, 'value')) // value: 1
console.log(Object.getOwnPropertySymbols(foo)) // []
这个版本仍然有明确限制:不可扩展对象会让 defineProperty 失败;Proxy 可以观察属性操作;严格函数收到的 this 与原生 call 不同;原始值接收者也没有按规范处理。因此它只能作为理解调用点的练习,不能替代 call。
原文的 context.fn = this; context.fn(); delete context.fn 版本还会覆盖已有的 fn 属性,并且被调用函数抛错时无法执行删除。上面的 Symbol 和 try/finally 只改善了教学实现的碰撞和清理问题,没有消除“修改接收者”这一根本差异。
三、第一版 call2 为什么不能直接照搬
原文的第一版大意是:
// 仅表示原文思路,不是推荐实现
context.fn = this
context.fn()
delete context.fn
它有几个问题:
context为null或undefined时不能直接赋属性;context为0、false或字符串时,原生非严格函数会使用对应包装对象,但这段代码不一致;context可能被冻结、不可扩展或由 Proxy 管理;context可能已有fn,删除后会丢失用户数据;- 调用抛错时不会执行清理;
- 严格函数和箭头函数的
this规则不能靠“挂属性”正确模拟; - 直接给
Function.prototype增加可枚举方法还会污染所有函数和遍历结果。
所以现代文章应先给原生语义正确的包装,再展示历史思路,而不是把第一版称为完整模拟。
四、现代的 call2:使用 Reflect.apply
Reflect.apply() 是语言提供的反射调用 API,接收目标函数、thisArg 和参数列表。对于教学或业务封装,最可靠的“call2”其实是:
function call2(func, thisArg, ...args) {
if (typeof func !== 'function') {
throw new TypeError('func 必须是可调用对象')
}
return Reflect.apply(func, thisArg, args)
}
const object = { value: 1 }
function readValue(prefix, suffix) {
return `${prefix}${this.value}${suffix}`
}
console.log(call2(readValue, object, '[', ']')) // [1]
它没有把 call2 安装到 Function.prototype,因此不会改变全局内建对象。如果为了练习必须写成 bar.call2(foo),也应在隔离的示例环境中使用不可枚举属性,并明确不建议在生产代码中扩展内建原型:
const call2 = Symbol('call2')
Object.defineProperty(Function.prototype, call2, {
configurable: true,
value: function (thisArg, ...args) {
return Reflect.apply(this, thisArg, args)
}
})
function bar() {
return this.value
}
console.log(bar[call2]({ value: 1 })) // 1
delete Function.prototype[call2]
使用 Symbol 只是避免常见名称冲突,仍然会修改全局原型,所以更推荐前面的独立函数版本。
五、thisArg 的严格模式边界
原文说“this 参数传 null 时指向 window”,这只适用于非严格普通函数在浏览器经典脚本中的典型情况,不是 call 的普遍规则:
function sloppyThis() {
return this
}
function strictThis() {
'use strict'
return this
}
console.log(sloppyThis.call(null) === globalThis) // true:非严格函数的典型结果
console.log(strictThis.call(null)) // null
console.log(strictThis.call(undefined)) // undefined
console.log(strictThis.call(1)) // 1
非严格普通函数会按语言规则把 null/undefined 替换为全局对象,并把原始值包装成对象;严格函数收到的则是传入的原始 thisArg。箭头函数不使用 call 提供的 thisArg:
const arrow = () => this
const object = { value: 1 }
console.log(arrow.call(object) === arrow()) // true
globalThis 只是跨宿主访问全局对象的标准入口,不能作为所有函数的默认 this 替代品。Node.js、ES module、浏览器经典脚本和 Worker 的顶层环境不同。
六、参数处理:不要使用 eval
原文为了支持不定长参数,先把 arguments[1]、arguments[2] 拼成字符串,再使用:
eval('context.fn(' + args + ')')
这在现代 JavaScript 中没有必要,也会受到 CSP unsafe-eval 限制。参数可能是字符串、对象、Symbol、函数或带副作用的值,应该使用 rest 参数和 Reflect.apply:
function call2WithArguments(func, thisArg, ...args) {
return Reflect.apply(func, thisArg, args)
}
function describe(name, options) {
return { name, mode: options.mode, value: this.value }
}
console.log(call2WithArguments(
describe,
{ value: 1 },
'demo',
{ mode: 'safe' }
))
如果目标是支持现代环境,展开语法也足够清晰:
function callDirect(func, thisArg, args) {
return func.apply(thisArg, args)
}
但业务代码通常直接调用原生 func.call(...)、func.apply(...) 或 Reflect.apply(...),不需要再包一层。
七、返回值和异常
call 应返回被调用函数的结果,并把异常原样抛给调用者:
function createResult(name, age) {
return {
value: this.value,
name,
age
}
}
const result = createResult.call({ value: 1 }, 'Ada', 20)
console.log(result) // { value: 1, name: 'Ada', age: 20 }
try {
function fail() {
throw new Error('调用失败')
}
fail.call({})
} catch (error) {
console.log(error.message) // 调用失败
}
临时属性式教学实现必须使用 try/finally,否则异常会留下临时属性;原生 call 不存在这个问题。
八、apply 的模拟与规范边界
apply 与 call 的区别主要是参数的传递形式:
call(thisArg, arg1, arg2)接收逐个参数;apply(thisArg, argsArray)接收一个 array-like 参数对象;apply的第二个参数为null或undefined时,等价于没有参数;- 参数列表的
length和索引访问遵循规范的 array-like 转换,不只接受真正的数组。
现代封装可以这样写:
function apply2(func, thisArg, argsArray) {
if (typeof func !== 'function') {
throw new TypeError('func 必须是可调用对象')
}
const args = argsArray == null ? [] : argsArray
return Reflect.apply(func, thisArg, args)
}
function sum(a, b) {
return this.base + a + b
}
console.log(apply2(sum, { base: 10 }, [1, 2])) // 13
console.log(apply2(sum, { base: 10 }, { 0: 1, 1: 2, length: 2 })) // 13
console.log(apply2(sum, { base: 10 }, null)) // NaN:缺少参数,不会自动补 0
Reflect.apply 的第三个参数需要是 null/undefined 或可转换成参数列表的 array-like 对象。使用 Reflect.apply 还避免了原文中 eval、重复读取 arr.length 和直接改写 context.fn 的问题。
原文示例直接写:
Function.prototype.apply = function (context, arr) {
// ...
}
这会覆盖标准内建方法,复制运行后会影响整个页面或 Node.js 进程,属于严重的教学风险。应改名为 apply2,或者使用独立的 apply2(func, thisArg, argsArray),绝不能为了演示而覆盖原生 Function.prototype.apply。
九、call、apply、Reflect.apply 的选择
function read() {
return this.value
}
const object = { value: 42 }
console.log(read.call(object))
console.log(read.apply(object, []))
console.log(Reflect.apply(read, object, []))
- 参数数量固定、直接调用:使用
call; - 已经有数组或 array-like 参数:使用
apply; - 需要把函数和参数列表作为变量传递,或写通用反射工具:使用
Reflect.apply; - 不要为了“手写 API”在生产代码中复制引擎内部的
this、严格模式和代理语义。
十、与箭头函数、构造函数的关系
const arrow = () => this.value
console.log(arrow.call({ value: 1 })) // 仍使用箭头定义处的 this
class User {
constructor(name) {
this.name = name
}
}
// User.call({}):TypeError,class 构造器不能像普通函数一样直接调用
const user = new User('Ada')
console.log(user.name) // Ada
Reflect.apply 也不能把 class 构造器变成普通函数。需要动态创建实例时,应使用 new、工厂函数或明确的构造器封装。
十一、原文示例的现代化对照
原文用下面的代码演示返回值:
const obj = { value: 1 }
function createUser(name, age) {
return {
value: this.value,
name,
age
}
}
console.log(createUser.call(obj, 'kevin', 18))
// { value: 1, name: 'kevin', age: 18 }
这个示例保留了原意,但不再把 call(null) 的结果写成固定的浏览器 window 属性,也不把 var value、经典脚本和 ES module 的行为混在一起。