1. 核心思路(一句话)
利用"对象属性调用隐式绑定 this"的原理,通过 Symbol 创建唯一临时属性避免污染,结合 try-finally 确保异常安全清理,并严格处理 null/undefined 及原始值包装等边界条件。
2. 问题矛盾分析
| 矛盾类型 | 具体表现 | 解决策略 |
|---|---|---|
| 主要矛盾 | 如何在不使用原生 API 的前提下改变函数执行时的 this 指向 |
将函数作为目标对象的临时属性进行调用(隐式绑定) |
| 次要矛盾1 | 临时属性名可能与对象原有属性冲突(属性污染) | 使用 Symbol() 生成全局唯一的键名 |
| 次要矛盾2 | 函数执行报错导致临时属性无法删除(内存泄漏/状态污染) | 使用 try...finally 结构保证清理逻辑必执行 |
| 次要矛盾3 | this 传入 null/undefined 或原始值时的规范对齐 |
null/undefined 转全局对象;原始值用 Object() 包装为对象 |
| 次要矛盾4 | call 与 apply 参数形式差异 |
call 用剩余参数收集;apply 校验数组并用扩展运算符展开 |
3. 解决方案流程图(文本版)
text
[开始: myCall/myApply]
│
▼
[1. this 边界处理]
├─ null / undefined → globalThis (浏览器window/Node global)
└─ 原始值(number/string/boolean) → Object() 包装为对象
│
▼
[2. 创建唯一临时属性键]
└─ const fnKey = Symbol() ← 防止属性名冲突
│
▼
[3. 挂载函数到目标对象]
└─ context[fnKey] = this (当前要执行的函数)
│
▼
[4. try { 执行函数 } ]
├─ call: context[fnKey](...args)
└─ apply: context[fnKey](...(Array.isArray(args)?args:[]))
│
▼
[5. finally { 清理 }]
└─ delete context[fnKey] ← 无论成功/报错都执行
│
▼
[6. 返回执行结果]
4. 完整示例代码(含详细注释)
4.1 工业级 myCall 实现
javascript
/**
* 手写 Function.prototype.call
* 核心原理:利用 obj.fn() 调用时 this 自动绑定到 obj 的特性
*/
Function.prototype.myCall = function (context, ...args) {
// ========== 1. 边界处理:this 指向 ==========
// 规范:null/undefined 在非严格模式下指向全局对象
// 使用 globalThis 兼容浏览器(window)、Node(global)、WebWorker(self)
if (context === null || context === undefined) {
context = globalThis;
} else {
// 规范:原始值(数字/字符串/布尔)需要被包装为对应的对象
// Object(123) → Number对象,这样才能在其上添加临时属性
context = Object(context);
}
// ========== 2. 防止属性污染 ==========
// 使用 Symbol 创建全局唯一的属性键
// 避免与 context 上已有的同名方法冲突(如 context.fn 已存在)
const fnKey = Symbol('tempFn');
// 将要执行的函数(即调用 myCall 的那个函数)挂载为临时属性
context[fnKey] = this;
// ========== 3. 执行函数并确保清理 ==========
let result;
try {
// 通过对象方法调用触发隐式 this 绑定
// ...args 将剩余参数展开传递给原函数
result = context[fnKey](...args);
} finally {
// 【关键】finally 块无论 try 中是否抛错都会执行
// 确保临时属性一定被删除,避免内存泄漏和对象污染
delete context[fnKey];
}
// ========== 4. 返回原函数执行结果 ==========
return result;
};
4.2 工业级 myApply 实现
javascript
/**
* 手写 Function.prototype.apply
* 与 myCall 的唯一区别:第二个参数是数组(或类数组),需展开传递
*/
Function.prototype.myApply = function (context, argsArray) {
// ========== 1. this 边界处理(与 myCall 完全相同)==========
if (context === null || context === undefined) {
context = globalThis;
} else {
context = Object(context);
}
// ========== 2. 参数安全校验 ==========
// 规范:apply 第二个参数如果不是数组/类数组,应视为空参数列表
// 这里做防御性处理,避免非数组值导致展开运算符报错
const safeArgs = Array.isArray(argsArray) ? argsArray : [];
// ========== 3. Symbol + try-finally(与 myCall 完全相同)==========
const fnKey = Symbol('tempFn');
context[fnKey] = this;
let result;
try {
// 使用扩展运算符将数组展开为参数列表
result = context[fnKey](...safeArgs);
} finally {
delete context[fnKey];
}
return result;
};
4.3 测试用例(验证边界场景)
javascript
// ✅ 测试1:基本上下文切换
const obj = { name: 'Alice' };
function greet(greeting) {
return `${greeting}, ${this.name}`;
}
console.log(greet.myCall(obj, 'Hello')); // "Hello, Alice"
console.log(greet.myApply(obj, ['Hi'])); // "Hi, Alice"
// ✅ 测试2:null/undefined → 全局对象
function getGlobal() { return this; }
console.log(getGlobal.myCall(null) === globalThis); // true
console.log(getGlobal.myApply(undefined) === globalThis); // true
// ✅ 测试3:原始值 this 被正确包装
function getType() { return typeof this; }
console.log(getType.myCall(123)); // "object" (Number包装)
console.log(getType.myCall('hello')); // "object" (String包装)
// ✅ 测试4:属性不污染
const target = { existingProp: 'original' };
function noop() {}
noop.myCall(target);
console.log(target.existingProp); // "original" (未被覆盖)
console.log(Object.getOwnPropertySymbols(target).length); // 0 (Symbol已清理)
// ✅ 测试5:函数抛错时仍清理临时属性
function thrower() { throw new Error('boom'); }
try { thrower.myCall(obj); } catch(e) {}
console.log(Object.getOwnPropertySymbols(obj).length); // 0 (异常后仍清理)
// ✅ 测试6:apply 传入非数组不报错
function sum(a, b) { return (a||0) + (b||0); }
console.log(sum.myApply(null, null)); // 0
console.log(sum.myApply(null, 'not-array')); // 0
5. 使用场景与边界场景补充
| 场景分类 | 具体场景 | 说明 |
|---|---|---|
| 使用场景 | 借用其他对象的方法 | Array.prototype.slice.myCall(arguments) 将类数组转数组 |
| 构造函数继承 | Parent.call(this, ...args) 在子类中调用父类构造器 |
|
| 动态参数调用 | Math.max.myApply(null, arr) 求数组最大值 |
|
| 边界场景 | 严格模式下的 null/undefined |
⚠️ 注意 :原生 call 在严格模式下 this 就是 null 本身,不会转全局对象。上述实现按非严格模式处理,面试时需主动说明此差异 |
Symbol 不可用环境 |
IE11 及以下不支持 Symbol,降级方案:用 '__temp_' + Date.now() + Math.random() 生成伪唯一键 |
|
冻结对象 Object.freeze() |
冻结对象无法添加属性,myCall 会抛 TypeError。原生 call 同样如此,行为一致 |
|
apply 传入类数组 |
原生 apply 支持类数组(如 arguments),上述实现仅校验 Array.isArray。如需完整兼容,应改用 Array.from(argsArray) 或手动遍历 |
6. 更好的优化方案(加分项)
Reflect.apply 方案(现代环境推荐)
如果面试官允许使用 ES6+ API,可以用 Reflect.apply 一行实现,且天然规避属性污染问题:
javascript
// Reflect.apply 直接指定 this 和参数,无需临时属性
Function.prototype.myCall = function (context, ...args) {
// Reflect.apply 内部机制不涉及属性赋值,零污染风险
// 但仍需处理 null/undefined 和原始值的边界
const safeContext = (context === null || context === undefined)
? globalThis
: Object(context);
return Reflect.apply(this, safeContext, args);
};
优势 :无临时属性、无 Symbol 依赖、无 try-finally 开销、语义更清晰。
劣势 :IE 不支持;面试中通常要求不用原生 call/apply/reflect,所以此方案作为知识广度展示即可。
🏆 满分答案总结(面试口述版)
手写
call/apply的核心考察点是 this 绑定原理 + 边界防御 + 工程严谨性。我的实现分四步:
- this 规范化 :
null/undefined转globalThis,原始值用Object()包装,对齐 ECMAScript 规范;- 防属性污染 :用
Symbol()创建唯一临时属性键,杜绝与目标对象已有属性冲突;- 异常安全清理 :用
try...finally包裹执行逻辑,确保即使函数抛错,临时属性也一定被delete,避免内存泄漏;- 参数差异化处理 :
call用剩余参数收集,apply校验数组合法性后用扩展运算符展开。此外我还注意到两个易忽略点:严格模式下
null不转全局对象 的差异需在代码注释中标注;以及apply原生支持类数组 ,完整实现应用Array.from兼容。如果环境允许,Reflect.apply是最优解,因为它从根本上消除了临时属性的副作用。