技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

JavaScript 深入之 call 和 apply 的模拟实现

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/31-JavaScript深入之call和apply的模拟实现_1
本文目录12 个章节
  1. 一、call 做了什么
  2. 二、原文“临时属性”思路
  3. 三、第一版 call2 为什么不能直接照搬
  4. 四、现代的 call2:使用 Reflect.apply
  5. 五、thisArg 的严格模式边界
  6. 六、参数处理:不要使用 eval
  7. 七、返回值和异常
  8. 八、apply 的模拟与规范边界
  9. 九、call、apply、Reflect.apply 的选择
  10. 十、与箭头函数、构造函数的关系
  11. 十一、原文示例的现代化对照
  12. 十二、参考资料与原文

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

原文强调了两点:

  1. call 为这次调用指定了 this
  2. bar 被立即执行。

还要补充第三点:call 不会修改 foo,调用完成后也不会在 foo 上留下临时属性。

二、原文“临时属性”思路

原文把调用过程想象成:

const foo = {
  value: 1,
  bar() {
    return this.value
  }
}

foo.bar() // 1

因为以 foo.bar() 形式调用,普通函数中的 this 会指向 foo。于是早期的教学思路是:

  1. 把待调用函数临时设置为对象的属性;
  2. object[property](...) 形式调用;
  3. 删除临时属性。

这个思路有助于理解隐式绑定,但它不是原生 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 属性,并且被调用函数抛错时无法执行删除。上面的 Symboltry/finally 只改善了教学实现的碰撞和清理问题,没有消除“修改接收者”这一根本差异。

三、第一版 call2 为什么不能直接照搬

原文的第一版大意是:

// 仅表示原文思路,不是推荐实现
context.fn = this
context.fn()
delete context.fn

它有几个问题:

  • contextnullundefined 时不能直接赋属性;
  • context0false 或字符串时,原生非严格函数会使用对应包装对象,但这段代码不一致;
  • 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 的模拟与规范边界

applycall 的区别主要是参数的传递形式:

  • call(thisArg, arg1, arg2) 接收逐个参数;
  • apply(thisArg, argsArray) 接收一个 array-like 参数对象;
  • apply 的第二个参数为 nullundefined 时,等价于没有参数;
  • 参数列表的 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

九、callapplyReflect.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 的行为混在一起。

十二、参考资料与原文

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS