Hi,我是前端人类学!
JavaScript 中有一道“传说级”面试题,几乎贯穿了从初级到高级的整个进阶路径:手写 call、apply、bind,并实现一个完整的深拷贝。这道题考察的绝不仅仅是 API 记忆,而是对原型链、this 绑定、闭包底层机制的综合理解。
本文不满足于“实现功能”,而是从底层原理出发,带你一步步理解“为什么这样写”,最终完成生产级别的函数实现。
文章目录
- 一、原型链:一切函数和对象的“根”
- 二、this 指向的本质:调用位置决定一切
- 三、手撕 call:给函数加一个“临时属性”的巧妙思路
- 四、手撕 apply:与 call 的唯一区别是参数形式
- 五、手撕 bind:闭包 + 柯里化的经典结合
- 六、深拷贝完整版:从“浅”到“深”的进阶之路
- 6.1 基础版:JSON 方法(有缺陷)
- 6.2 完整版:递归 + WeakMap 解决循环引用
- 6.3 处理 Symbol 类型的 key
- 6.4 生产级优化:类型识别 + 可配置
- 七、总结:底层原理的融会贯通
一、原型链:一切函数和对象的“根”
在动手实现之前,必须先理解一个核心事实:JavaScript 中的函数也是对象。每个函数都有一个prototype属性(指向原型对象),同时函数实例也有__proto__(或[[Prototype]])指向Function.prototype。
当我们执行fn.call()时,实际上是在Function.prototype上查找call方法——因为fn.__proto__ === Function.prototype。
理解这个链条的意义在于:我们手写的 call/apply/bind,本质上是给所有函数添加一个“临时方法”,通过修改 this 指向来实现借用。
二、this 指向的本质:调用位置决定一切
this 的指向不是在定义时确定的,而是在函数被调用时根据调用方式动态绑定。核心规则如下:
| 调用方式 | this 指向 |
|---|---|
| 普通函数调用 | 全局对象(严格模式下为 undefined) |
| 对象方法调用 | 该对象 |
call/apply/bind | 显式指定的对象 |
| 箭头函数 | 外层作用域的 this(无法被 bind 改变) |
new调用 | 新创建的实例对象 |
我们手写call/apply/bind的核心,就是模拟“显式指定 this”这个能力。
三、手撕 call:给函数加一个“临时属性”的巧妙思路
call的核心功能是:将函数的 this 绑定到指定对象,并立即执行该函数。
实现思路非常巧妙——将函数作为目标对象的一个临时属性,调用完后删除:
Function.prototype.myCall=function(context,...args){// 1. 处理 context 为 null/undefined 时指向全局对象constctx=context===null||context===undefined?globalThis:Object(context);// 2. 用 Symbol 创建唯一 key,避免覆盖原对象属性constfnKey=Symbol('fn');// 3. 将当前函数(this)作为 ctx 的临时方法ctx[fnKey]=this;// 4. 执行函数,并传入参数constresult=ctx[fnKey](...args);// 5. 删除临时属性deletectx[fnKey];returnresult;};关键点解析:
- 使用
Object(context)确保基本类型(如数字、字符串)被包装为对象。 - 用
Symbol作为属性名,避免与目标对象原有属性冲突。 this指向调用myCall的函数本身,这正是“借用”的体现。
四、手撕 apply:与 call 的唯一区别是参数形式
apply与call功能完全一致,唯一区别是参数以数组(或类数组)形式传入。
Function.prototype.myApply=function(context,argsArray){constctx=context===null||context===undefined?globalThis:Object(context);constfnKey=Symbol('fn');ctx[fnKey]=this;// 关键区别:判断参数是否为数组/类数组letresult;if(argsArray&&typeofargsArray==='object'&&Symbol.iteratorinObject(argsArray)){result=ctx[fnKey](...argsArray);}elseif(argsArray===undefined||argsArray===null){result=ctx[fnKey]();}else{thrownewTypeError('第二个参数必须是数组或类数组对象');}deletectx[fnKey];returnresult;};这里使用了Symbol.iterator来判断是否为可迭代对象,既支持数组,也支持arguments等类数组。
五、手撕 bind:闭包 + 柯里化的经典结合
bind与前两者最大的不同是:它不会立即执行函数,而是返回一个新的函数,且新函数的 this 被永久绑定到指定对象。
实现 bind 需要结合闭包和柯里化:
Function.prototype.myBind=function(context,...boundArgs){// 保存原函数(this)constoriginalFn=this;// 返回一个新函数returnfunctionboundFn(...callArgs){// 关键:如果 boundFn 被 new 调用,则 this 指向实例,不绑定 contextif(new.target){returnneworiginalFn(...boundArgs,...callArgs);}// 普通调用:使用 context 作为 thisreturnoriginalFn.apply(context,[...boundArgs,...callArgs]);};};底层原理解析:
- 闭包:
boundArgs和originalFn被返回的函数所“记住”,形成了闭包。 - 柯里化:
bind时传入的部分参数(boundArgs)与调用时传入的参数(callArgs)合并执行。 - new 优先级:当
boundFn作为构造函数被new调用时,this指向新创建的实例,而不是绑定的 context。这是bind实现中最容易被忽略的细节。
六、深拷贝完整版:从“浅”到“深”的进阶之路
深拷贝的实现是对 JavaScript 数据类型、引用、循环引用等底层特性的全面考察。
6.1 基础版:JSON 方法(有缺陷)
functiondeepClone(obj){returnJSON.parse(JSON.stringify(obj));}缺陷:无法拷贝undefined、Symbol、函数、Date、RegExp、循环引用等。
6.2 完整版:递归 + WeakMap 解决循环引用
functiondeepClone(obj,hash=newWeakMap()){// 基本类型或 null 直接返回if(obj===null||typeofobj!=='object'){returnobj;}// 处理循环引用if(hash.has(obj)){returnhash.get(obj);}// 处理日期if(objinstanceofDate){returnnewDate(obj);}// 处理正则if(objinstanceofRegExp){returnnewRegExp(obj.source,obj.flags);}// 处理数组if(Array.isArray(obj)){constarrCopy=[];hash.set(obj,arrCopy);for(leti=0;i<obj.length;i++){arrCopy[i]=deepClone(obj[i],hash);}returnarrCopy;}// 处理普通对象constclonedObj=Object.create(Object.getPrototypeOf(obj));hash.set(obj,clonedObj);// 拷贝所有自有属性(包括不可枚举属性?用 getOwnPropertyDescriptors)constdescriptors=Object.getOwnPropertyDescriptors(obj);for(constkeyindescriptors){constdescriptor=descriptors[key];if(descriptor.value!==undefined){descriptor.value=deepClone(descriptor.value,hash);}// 处理 getter/setter:直接赋值Object.defineProperty(clonedObj,key,descriptor);}returnclonedObj;}6.3 处理 Symbol 类型的 key
Symbol 类型的 key 无法通过for...in或Object.keys遍历,需要使用Reflect.ownKeys:
functiondeepClone(obj,hash=newWeakMap()){// ... 前面的类型判断保持不变 ...constclonedObj=Object.create(Object.getPrototypeOf(obj));hash.set(obj,clonedObj);// 使用 Reflect.ownKeys 获取所有 key(包括 Symbol)constallKeys=Reflect.ownKeys(obj);for(constkeyofallKeys){constdescriptor=Object.getOwnPropertyDescriptor(obj,key);if(descriptor.value!==undefined){descriptor.value=deepClone(descriptor.value,hash);}Object.defineProperty(clonedObj,key,descriptor);}returnclonedObj;}6.4 生产级优化:类型识别 + 可配置
对于生产环境,通常需要支持更多类型(Map、Set、Error、ArrayBuffer等):
functiondeepClone(obj,hash=newWeakMap()){// 处理基本类型if(obj===null||typeofobj!=='object'){returnobj;}if(hash.has(obj))returnhash.get(obj);// 处理内置对象类型constconstructor=obj.constructor;consttype=Object.prototype.toString.call(obj);letclonedObj;switch(type){case'[object Date]':returnnewDate(obj);case'[object RegExp]':returnnewRegExp(obj.source,obj.flags);case'[object Map]':clonedObj=newMap();hash.set(obj,clonedObj);obj.forEach((value,key)=>{clonedObj.set(deepClone(key,hash),deepClone(value,hash));});returnclonedObj;case'[object Set]':clonedObj=newSet();hash.set(obj,clonedObj);obj.forEach(value=>{clonedObj.add(deepClone(value,hash));});returnclonedObj;case'[object Array]':clonedObj=[];break;case'[object Object]':clonedObj=Object.create(Object.getPrototypeOf(obj));break;default:// 其他类型(如 Error、ArrayBuffer)可扩展returnobj;}hash.set(obj,clonedObj);constallKeys=Reflect.ownKeys(obj);for(constkeyofallKeys){constdescriptor=Object.getOwnPropertyDescriptor(obj,key);if(descriptor.value!==undefined){descriptor.value=deepClone(descriptor.value,hash);}Object.defineProperty(clonedObj,key,descriptor);}returnclonedObj;}七、总结:底层原理的融会贯通
通过手写这几个核心 API,我们实际上走通了一条 JavaScript 底层能力进阶路线:
| 实现 | 核心依赖 | 考察能力 |
|---|---|---|
myCall | 对象属性动态赋值、Symbol | this 指向、属性操作 |
myApply | 迭代器判断、参数展开 | 类型判断、类数组处理 |
myBind | 闭包、柯里化、new.target | 作用域链、构造函数优先级 |
deepClone | 递归、WeakMap、Reflect.ownKeys | 引用类型、循环引用、数据完整性 |
这些手写实现不是为了“背诵代码”,而是为了让你理解 JavaScript 最核心的三个底层机制——原型链(函数借用)、作用域闭包(状态保持)、引用传递(深拷贝)。当你真正理解它们时,你会发现自己对 JavaScript 的掌控力上了一个全新的台阶。