JavaScript 深入之 call 和 apply 的模拟实现(进阶边界)
Category(分类): JavaScript Status: 已整理(2026)
当前文件的原始抓取内容与
31-JavaScript深入之call和apply的模拟实现_1.md基本重复,并再次混入“代码解读/复制代码”和粘连换行。这里保留原文关于临时属性、参数数组、返回值和apply的核心思路,删除重复正文,改为补充规范边界和可验证的现代实现。历史来源仍保留在文末。
一、从“模拟”转向“理解语义”
call 和 apply 的共同目标是:在一次调用中指定 thisArg,再把参数传给可调用对象。它们不会修改 thisArg,也不会把函数永久挂到对象上:
function describe(prefix, suffix) {
return `${prefix}${this.name}${suffix}`
}
const user = { name: 'Ada' }
console.log(describe.call(user, '[', ']'))
console.log(describe.apply(user, ['[', ']']))
console.log(Reflect.apply(describe, user, ['[', ']']))
原文的“把函数临时放到 context.fn 上再调用”的方法,是解释隐式绑定的直观模型,不是原生实现。它会遇到对象属性冲突、不可扩展对象、Proxy、异常清理、原始值、严格函数和箭头函数等问题。
如果只是希望在变量形式下完成同样的反射调用,现代 JavaScript 已经提供了 Reflect.apply:
function call2(func, thisArg, ...args) {
if (typeof func !== 'function') {
throw new TypeError('func 必须是可调用对象')
}
return Reflect.apply(func, thisArg, args)
}
function add(value) {
return this.base + value
}
console.log(call2(add, { base: 10 }, 5)) // 15
这不是重新实现引擎内部算法,而是一个不污染原生 API 的现代封装。生产代码通常直接使用 call、apply 或 Reflect.apply。
二、为什么临时属性不是等价实现
原文的简化过程是:
// 原文的教学模型
context.fn = func
context.fn(...args)
delete context.fn
即使改用 Symbol 和 try/finally,也只能得到一个受限的教学近似:
function callByTemporaryProperty(func, object, ...args) {
const key = Symbol('temporary-call')
Object.defineProperty(object, key, {
configurable: true,
value: func
})
try {
return object[key](...args)
} finally {
delete object[key]
}
}
限制包括:
object不可扩展时无法定义临时属性;- Proxy 可以观察、拒绝或改变属性操作;
- 目标对象的属性描述符、原型和访问器行为会影响结果;
- 严格函数收到的原始
thisArg不等于包装后的对象; null/undefined、数字、字符串和 Boolean 接收者不能靠简单赋属性处理;- 被调用对象可能是绑定函数、Proxy 或其他可调用对象,不只是普通函数;
- 若没有
try/finally,异常会让临时属性残留。
因此,该代码适合放在“为什么 object.method() 会有对象 this”的历史教学段落,不应称作完整的 call polyfill。
三、thisArg 的准确行为
3.1 严格函数与非严格函数
function nonStrict() {
return this
}
function strict() {
'use strict'
return this
}
console.log(nonStrict.call(null) === globalThis) // 非严格函数的典型结果
console.log(strict.call(null)) // null
console.log(strict.call(undefined)) // undefined
console.log(strict.call(7)) // 7
非严格普通函数会对 null/undefined 使用运行时全局对象,并对原始值进行对象包装;严格普通函数接收原始 thisArg。这里的 globalThis 只是示例环境的全局对象,不应把结果固定写成浏览器 window。
3.2 箭头函数与绑定函数
const receiver = { value: 1 }
const arrow = () => this
console.log(arrow.call(receiver) === arrow()) // true
function read() {
return this.value
}
const bound = read.bind(receiver)
console.log(bound.call({ value: 2 })) // 1:绑定函数的 this 已固定
箭头函数没有自己的 this;绑定函数的 this 也不能再通过普通 call/apply 改写。bind 生成的函数在用 new 调用时,新实例可以优先成为 this,但预绑定参数仍会保留。
四、apply 的参数对象
apply 的第二个参数不是必须为真正的数组,而是可以转换为参数列表的 array-like 对象:
function collect(a, b, c) {
return [this.label, a, b, c]
}
const args = {
0: 'a',
1: 'b',
2: 'c',
length: 3
}
console.log(collect.apply({ label: 'values' }, args))
console.log(Reflect.apply(collect, { label: 'values' }, args))
现代代码中也可以使用展开语法,但展开前需要把数据整理成真正的数组:
const values = ['a', 'b', 'c']
console.log(collect.call({ label: 'values' }, ...values))
console.log(collect.apply({ label: 'values' }, values))
null 或 undefined 参数列表表示没有参数:
function count(...args) {
return args.length
}
console.log(count.apply(null, null)) // 0
console.log(count.apply(null, undefined)) // 0
数组、arguments、字符串和许多带有非负 length 的对象都可以作为参数列表;length 会经过规范的长度转换。自定义实现不应在循环条件中反复读取可能带副作用的 length,也不要用字符串拼接参数。
一个不污染原生 API 的 apply2 可以写成:
function apply2(func, thisArg, argsArray) {
if (typeof func !== 'function') {
throw new TypeError('func 必须是可调用对象')
}
return Reflect.apply(func, thisArg, argsArray == null ? [] : argsArray)
}
function multiply(a, b) {
return this.factor * a * b
}
console.log(apply2(multiply, { factor: 2 }, [3, 4])) // 24
不要执行原文中的:
// 错误的生产实践:会覆盖整个运行时的原生方法
// Function.prototype.apply = function () { ... }
覆盖 Function.prototype.apply 会影响第三方库、框架和引擎内部依赖,调试后果远大于示例收益。
五、返回值、异常和异步函数
call、apply 和 Reflect.apply 都返回目标函数的返回值;目标函数抛出的异常会继续抛出:
function createUser(name) {
return {
name,
value: this.value
}
}
console.log(createUser.apply({ value: 1 }, ['Ada']))
try {
function fail() {
throw new Error('failed')
}
fail.call(null)
} catch (error) {
console.log(error.message) // failed
}
如果目标函数是 async 函数,call 只负责同步地启动这次调用,返回 Promise;它不会自动等待、取消或捕获异步错误:
async function load(id) {
return { id }
}
load.call(undefined, 1).then(result => {
console.log(result.id) // 1
})
六、与 new 和 class 的边界
call/apply 只能调用可调用对象,不能把 class 构造器当作普通函数执行:
class User {
constructor(name) {
this.name = name
}
}
try {
User.call({}, 'Ada')
} catch (error) {
console.log(error.name) // TypeError
}
const user = new User('Ada')
console.log(user.name) // Ada
需要动态传递构造参数时,使用 new 配合展开语法、工厂函数或明确的构造器设计,而不是 Reflect.apply(User, ...)。
七、函数、Proxy 和可调用对象
call/apply 的接收者必须是可调用对象。普通对象没有 [[Call]] 内部方法,不能被直接调用:
const callable = function (value) {
return value * 2
}
const proxy = new Proxy(callable, {
apply(target, thisArg, args) {
console.log('拦截调用', args)
return Reflect.apply(target, thisArg, args)
}
})
console.log(proxy.call(null, 3)) // 6
代理的 apply 陷阱会参与调用过程。把函数临时写入对象属性的教学实现可能额外触发 set/defineProperty/get 等陷阱,因而更不可能作为通用 polyfill。
八、常见的原生方法借用
call 常用于借用其他对象的方法,但现代 API 往往提供了更直接的替代:
const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty)
const object = { name: 'Ada' }
console.log(hasOwn(object, 'name')) // true
console.log(Object.hasOwn(object, 'name')) // true:现代首选
转换 array-like 数据时,原文时代常见 Array.prototype.slice.call(arguments);现代代码可以使用 Array.from() 或展开语法:
function toArray() {
return Array.from(arguments)
}
console.log(toArray('a', 'b')) // ['a', 'b']
call/apply 仍有价值,但应优先选择语义更直接的标准 API。
九、测试矩阵
const assert = require('node:assert/strict')
function nonStrict() {
return this
}
function strict() {
'use strict'
return this
}
const object = { value: 1 }
assert.strictEqual(nonStrict.call(object), object)
assert.strictEqual(strict.call(object), object)
assert.strictEqual(strict.call(null), null)
assert.equal(nonStrict.call(7).valueOf(), 7)
assert.equal(strict.apply(object, []).value, 1)
assert.equal(Reflect.apply(strict, object, []), object)
const arrow = () => object
assert.strictEqual(arrow.call(null), object)
console.log('call/apply 边界测试通过')
这个矩阵展示了普通函数、严格函数、原始值、箭头函数和 Reflect.apply 的差异。实际项目还应根据浏览器经典脚本、ES module、Node.js 和 Worker 分别验证宿主环境。
十、与 31 号文章的关系
原始文件 32..._2.md 抓取了 31..._1.md 的正文两遍,第二遍并没有提供新的实现或概念。现在保留共同的历史主线,并把本篇定位为进阶边界:
- 31 号文章:从调用点和临时属性模型理解
call/apply,再转向Reflect.apply; - 32 号文章:集中说明严格模式、箭头函数、array-like 参数、Proxy、
new、异常和 API 借用; - 两篇都不覆盖原生方法,不把
eval或临时属性方案当成生产实现。