手写new/call/apply/bind:彻底搞懂JavaScript this与函数实现 📅 发布时间:2026/9/1 12:54:03 👁 浏览次数: 1. 从一道前端面试题说起为什么总考手写这4个方法前两周帮一个师弟模拟面试问到手写bind的时候他在白板上写了大概三行就卡住了。不是不会写是写着写着开始纠结context到底要不要判空new调用的时候怎么办参数怎么拼接才对这些细节我太熟悉了——当年我第一次被问到的时候也是死记硬背了一个版本结果面试官一追问能不能处理new的情况我就懵了。其实不光bindnew、call、apply这三兄弟也是面试高频题。你去看各种前端面经几乎每条八股文清单里都有它们的身影。有人觉得这是死记硬背我个人的观点不太一样这几个手写实现是最适合用来考察JavaScript基本功的题目没有之一。为什么这么说你看一眼它们牵涉到的知识点this指向的四种绑定规则、prototype原型链、arguments类数组、闭包、展开运算符、柯里化、new.target、Symbol属性、Object.create……基本上把函数式编程和对象模型的底子都串起来了。面试官问一道手写bind表面上考的是你背没背过这个API实际想看到的是你对JavaScript执行机制的底层理解到不到位。另外一个很现实的原因是这些API在业务代码里太常用了。apply用来合并数组、call用来调用继承来的方法、bind用在React事件绑定和定时器回调里、手动new的工厂函数在封装类库时经常出现。你如果只会在IDE里点着.apply(让补全帮你想参数真到源码里遇到这里为什么用一个apply而不是直接调的场景很容易看懵。这篇文章我按自己的理解把这4个方法掰开揉碎讲一遍。不是简单贴一份答案而是把每一步背后的为什么都说清楚顺带把面试官最喜欢追问的几个细节也展开。2. new操作符的底牌手写实现里藏着四个关键步骤2.1 先搞清楚new到底干了什么new大概是JavaScript里最被低估的操作符。表面上你只是写了一句new Person(张三)实际上JS引擎偷偷做了4件事创建一个全新的普通对象把这个空对象的原型链指向构造函数的prototype属性把构造函数作为普通函数调用并将内部的this绑定到这个新对象上如果构造函数有返回值且返回的是一个对象则返回该对象否则返回第一步创建的新对象我见过不少同学手写new的时候只写了前三步把第四步漏了。这不算致命伤大多数构造函数不写返回值默认返回undefined所以结果不受影响。可一旦某个构造函数里写了return { a: 1 }这种骚操作你的实现就跟原生的new行为对不上了。写代码之前先看核心逻辑。根据上面的步骤最直白的手写版本是这样的function myNew(Constructor, ...args) { // 步骤1和2创建一个新对象并让它的__proto__指向Constructor.prototype const obj Object.create(Constructor.prototype); // 步骤3调用构造函数绑定this为新对象 const result Constructor.apply(obj, args); // 步骤4处理返回值 return isObjectLike(result) ? result : obj; } function isObjectLike(value) { return value ! null (typeof value object || typeof value function); }2.2 每个步骤为什么这么写先说Object.create(Constructor.prototype)这一步。很多人会写成obj.__proto__ Constructor.prototype或者obj {}; obj.constructor Constructor。能用但不够严谨。Object.create是ES5标准里专门用来指定原型创建对象的方法语义最清晰、性能也更好。手动操作__proto__虽然也能达到目的但__proto__本身是个非标准属性虽然在各大浏览器里都实现了在纯规范层面不推荐依赖它。然后是Constructor.apply(obj, args)。这里必须先拿到Constructor再通过apply把this指向新对象。有些实现会用Constructor.call(obj, ...args)效果完全一样。我习惯用apply因为args本来就是数组省一次展开运算。接着是那个isObjectLike判断。为什么要单独判断因为构造函数返回null的时候要特别处理。typeof null object但原生new面对构造函数返回null的情况会忽略这个返回值仍然返回新对象。这个细节非常容易踩坑我见过不止一个面试者在这里翻车。2.3 手写new的边界情况与加分项如果你只写出上面的版本面试官大概率会继续追问这几个问题Q1箭头函数能不能被new不能。箭头函数没有自己的this没有prototype属性所以不能作为构造函数。手写实现里Object.create(Constructor.prototype)拿到的Constructor.prototype是undefinedObject.create(undefined)会直接抛错。这个追问考察的是你对箭头函数本质的理解。Q2new.target在手写实现里怎么体现new.target是ES6引入的元属性在通过new调用构造函数时指向构造函数本身在普通调用时是undefined。手写实现模拟的是构造函数被new调用时的执行环境所以真正的new.target你没法伪造——但你可以显式判断function Person(name) { if (!(this instanceof Person)) { throw new Error(必须使用 new 调用 Person); } this.name name; }这是ES5时代模拟new.target检查的经典写法理解它对理解instanceof的实际语义也很有帮助。Q3怎么验证你的myNew和原生new行为一致写两个测试对象一个用原生new一个用myNew跑一下原型链属性和返回值的对比function Person(name) { this.name name; } Person.prototype.sayHello function() { return 我是${this.name}; }; const p1 new Person(张三); const p2 myNew(Person, 张三); console.log(p1.sayHello()); // 我是张三 console.log(p2.sayHello()); // 我是张三 console.log(p1 instanceof Person); // true console.log(p2 instanceof Person); // true再看一个构造函数返回对象的特殊情况function Animal() { this.type cat; return { type: dog }; } const a1 new Animal(); const a2 myNew(Animal); console.log(a1.type, a2.type); // dog dog两边行为一致实现才算过关。3. call和apply说白了是一个临时挂载的思路3.1 核心原理函数本身也是对象call和apply是Function.prototype上的方法它们的作用只有一个——改变函数执行时this的指向并立即执行。区别仅在于传参方式call接收参数列表apply接收类数组或数组。手写它们之前先理解一个底层事实函数在JavaScript里也是一个对象上面可以挂属性和方法。这给了我们一条实现路径——既然this的指向规则是谁调用this就指向谁那我只要把目标函数临时挂到指定上下文对象上再作为它的方法调用不就把this绑定过去了比如我有一段代码const person { name: 李四 }; function greet(greeting, punctuation) { return ${greeting}, ${this.name}${punctuation}; }如果用原生call调用预期输出是Hello, 李四!。手写版本的核心就是模拟这个过程Function.prototype.myCall function(context, ...args) { // this是调用myCall的那个函数即greet const fn this; // 在context上临时挂一个属性指向fn context.fn fn; // 调用context.fn此时this自然指向context const result context.fn(...args); // 调用完删掉临时属性避免污染 delete context.fn; return result; };这个版本能跑但有两个小问题一是直接用context.fn容易和原对象上的fn属性冲突二是完全没有处理context为null或undefined的情况。3.2 用Symbol保证属性安全第一个问题的解决方式是用Symbol生成一个唯一key确保不会覆盖原对象的任何属性Function.prototype.myCall function(context, ...args) { const fn this; // 如果context是null或undefined按照规范指向全局对象 context context null ? globalThis : Object(context); const key Symbol(临时调用key); context[key] fn; const result context[key](...args); delete context[key]; return result; };这里我额外做了两件事用context null判断兼容了null和undefined两种值用Object(context)把基本类型包装成对象。第二点解释一下规范原生call允许传数字、字符串、布尔值当context执行时会自动把它们包装成对应的包装对象Number、String、Boolean但函数内部拿到的this是包装对象不是原始值。我们用Object()手动包装一次就是为了对齐这个行为。在浏览器里globalThis就是window在Node里就是global。ES2020之后用globalThis统一写法最稳。3.3 apply的实现和call几乎一样apply和call的唯一区别就是参数格式不同Function.prototype.myApply function(context, args) { const fn this; context context null ? globalThis : Object(context); // 兼容不传参数的情况 args args || []; const key Symbol(临时调用key); context[key] fn; const result context[key](...args); delete context[key]; return result; };你可能会问原生apply不传第二个参数会怎样规范要求第二个参数是一个类数组对象不传的话默认视为没有参数。我上面的写法直接兜底为[]手动调用也问题不大。不过有个细节需要注意原生apply对第二个参数有合法性校验如果你传一个number进去会直接抛TypeError。手写版不需要严格复刻这个报错逻辑但至少在注释里要标注一下。3.4 call/apply面试追问很多面试官会接着问为什么context[key](...args)调用的时候this就能指向context答案很简单——JavaScript的this绑定规则里方法调用时this指向调用者。context[key]就是读取了context上的某个属性得到的是一个函数这个函数以context[key]()的形式调用调用者当然是context。这就是临时挂载方法的整个原理根基。另一个常见的追问是eval在call的实现里有什么用老版本的polyfill里确实出现过eval(context.fn( args ))这种写法目的是把参数列表动态拼成代码字符串再执行。但这种方式在严格模式下会有作用域问题还容易引发性能问题和安全隐患现在完全不需要了。ES6的展开运算符...已经能优雅地解决参数展开的问题。4. bind的完整实现远不止返回一个新函数这么简单4.1 基本版闭包加参数拼接bind和call/apply最大的区别在于bind不立即执行而是返回一个新函数同时把this绑定好。这意味着实现方式要从调用时设置上下文切换成利用闭包把上下文存起来等真正调用时再取出来用。最简单的实现Function.prototype.myBind function(context, ...prefixArgs) { const fn this; return function(...restArgs) { return fn.apply(context, [...prefixArgs, ...restArgs]); }; };这个版本已经能覆盖绝大多数使用场景。闭包捕获了三个东西fn原函数、context目标this、prefixArgs预设参数。返回的新函数被调用时把预设参数和实际参数合并成完整参数列表再通过apply传给原函数。为什么用apply不用call因为合并后的参数是一个数组apply正好接收数组。这也是apply和call在实现层面的经典协作——bind的实现依赖applyapply的实现依赖临时挂载整个链条环环相扣。4.2 支持new调用的完整版到这里还没有结束。手写bind最令人头疼的细节是bind返回的新函数如果被new调用this应该指向新创建的对象而不是之前bind的那个context。这是JavaScript函数调用的一个特殊规则new绑定的优先级高于显式绑定call/apply/bind。换句话说如果你实现了上面的基本版然后执行function Person(name, age) { this.name name; this.age age; } const BoundPerson Person.myBind({ fake: true }, 张三); const p new BoundPerson(18); console.log(p.name); // 张三是有了 console.log(p.fake); // 期望是undefined但基本版输出true基本版返回的新函数内部写死了fn.apply(context, ...)导致new BoundPerson(18)的时候this被绑定到了context上而不是BoundPerson新创建的对象。这跟原生bind的行为不一致——原生的new BoundPerson(18)会创建一个新对象this指向新对象context完全被忽略。怎么修复关键在于利用this instanceof bound来判断当前是不是通过new调用的Function.prototype.myBind function(context, ...prefixArgs) { const fn this; const bound function(...restArgs) { // 如果this是bound的实例说明是通过new调用的 return fn.apply(this instanceof bound ? this : context, [...prefixArgs, ...restArgs]); }; // 让bound.prototype继承fn.prototype这样new bound()创建的实例能沿着原型链找到原函数的方法 bound.prototype Object.create(fn.prototype); return bound; };这版代码的信息量很大我拆开讲bound是一个普通函数当它被new调用时this指向以bound.prototype为原型创建的新对象this instanceof bound检测的就是当前this的原型链上有没有bound.prototype。如果结果是true说明确实是用new调用的此时绑定this为新对象否则绑定到contextbound.prototype Object.create(fn.prototype)保证了新对象能通过原型链访问原构造函数原型上的方法有人会问为什么不直接写bound.prototype fn.prototype答案很简单——引用传递。如果两个函数的prototype指向同一个对象你往bound.prototype上添加方法会污染fn.prototype导致所有通过原函数创建的实例都多出不相关的方法。用Object.create做一层隔离继承关系在但互不影响。4.3 bind实现里容易踩的坑第一递归陷阱。不要尝试用在bound内部再调用myBind来实现参数的无限拼接很容易把调用栈写爆。我之前见过一个实现是进来一个参数就递归一次最后一个多层bind直接报RangeError: Maximum call stack size exceeded。很典型的热搜词当初我搜这个问题的时候发现一半的案例是递归写崩的另一半是循环依赖。第二没有处理bound作为构造函数调用后返回对象的情况。完整版里我用的是fn.apply(...)并没有像手写new那样检查返回值。原生bind在new调用时如果原函数返回了一个对象new操作依然会采用返回对象优先的规则。这点在极少数场景下会影响行为一致性不过日常开发碰不上我在测试用例里也基本不覆盖。第三丢失了原函数的length属性。手写版的bound.length永远是0而原生bind返回的函数的length等于原函数length减去预设参数个数。这个差异平时无感但如果你在写一些依赖函数元信息的库比如表单校验、参数解析路由就有影响了。想对齐的话可以用Object.defineProperty来设置length但说实话在面试里写出这一步属于加分项问到再说也不迟。4.4 验证一下完整版的行为function Person(name, age) { this.name name; this.age age; } Person.prototype.sayHi function() { return ${this.name}${this.age}岁; }; const BoundPerson Function.prototype.myBind.call(Person, { fake: true }, 张三); const p new BoundPerson(25); console.log(p.name); // 张三 console.log(p.age); // 25 console.log(p.fake); // undefined说明this没有被绑定到context上去 console.log(p.sayHi()); // 张三25岁对比原生bindconst NativeBound Person.bind({ fake: true }, 李四); const p2 new NativeBound(30); console.log(p2.name, p2.age, p2.fake); // 李四 30 undefined行为一致逻辑过关。5. 四个方法放在一起看关系、优先级与面试追问5.1 一张表理清调用关系很多同学把四个方法当四个独立知识点背其实它们的关联性很强。我习惯用一张表把它们的核心区分列出来方法是否立即执行返回内容this指向参数形式直接调用是函数返回值调用者参数列表call是函数返回值手动指定参数列表apply是函数返回值手动指定数组或类数组bind否新函数手动指定参数列表可预设new是新对象或构造函数返回的对象新创建的对象参数列表从这个表能看出来call和apply本质上是一对孪生兄弟bind是在它们基础上多了延迟执行和参数预设而new则完全走的是另一套对象创建机制。5.2 手写实现之间的依赖关系从代码角度看这4个手写实现有一个隐式的依赖链手写myNew用到了apply来绑定this手写bind用到了apply来合并参数并调用原函数手写apply用到了临时挂载delete的技巧手写call和apply几乎共用一套逻辑只是参数格式不同所以你只要把apply的机制吃透了剩下的call和bind都是在其上做变形。这也是为什么很多面试官习惯先问你手写一个apply再追问bind和它的区别是什么——就是顺着依赖链往下问的。5.3 面试官最爱追问的优先级问题JavaScript的this绑定优先级从低到高是默认绑定 隐式绑定 显式绑定call/apply/bind new绑定。面试官通常会给你一段代码让你判断输出const obj { name: obj }; function getName() { return this.name; } const boundGetName getName.bind(obj); const instance new boundGetName(); console.log(instance.name); // undefined console.log(instance instanceof boundGetName); // truenew boundGetName()返回了一个对象这个对象的name是undefined因为getName里没有给this赋属性。但这并不影响instance instanceof boundGetName为true——继承链还在。这个例子恰好验证了上面说的new绑定优先级高于bind绑定的规则。还有一个经典组合call和bind混用时会怎样const obj1 { name: obj1 }; const obj2 { name: obj2 }; function getName() { return this.name; } const boundGetName getName.bind(obj1); console.log(boundGetName.call(obj2)); // obj1不是obj2bind一旦执行this就被永久锁定了后续再call/apply也改不回来。这正是bind叫硬绑定的原因。5.4 一些容易混淆的名词此bind非彼bind我在搜集这个问题相关资料的时候经常在网上搜到一个报错信息address already in use(#10048 in bind)。这个bind是网络编程里socket绑定的错误跟JavaScript的bind方法完全是两码事。还有一些Node.js的报错比如cannot call write when a stream was destroyed、uncaught error: call to undefined function putenv()这些都是后端运行时的问题不要在复习前端手写实现的时候被这些热词带偏了。真正和本文相关的高频报错是RangeError: Maximum call stack size exceeded一般就是递归写崩了或者循环引用导致调用栈溢出。我前文也提过手写bind的时候最容易出这个错的就是在bound函数内部又调用了fn.bind而不是fn.apply形成无限递归。如果你看到这个报错第一反应去检查递归出口而不是怀疑栈空间不够。6. 从面试题到实际业务这几个手写思路能干嘛6.1 用apply做类数组转换ES6出了Array.from之后类数组转数组已经不需要apply了但在老代码或者某些面试题里你还是会看到这种写法function toArray() { return Array.prototype.slice.apply(arguments); } // 或者更经典的一行版 const args Array.prototype.slice.call(arguments);背后的原理是slice方法内部依赖length属性和索引访问类数组恰好满足这两个条件通过call把this指向arguments就能借用数组的slice方法完成转换。这就是借用方法的经典案例也是理解call实际应用的最好例子。现在有了Array.from(arguments)这种原生API借用方法的需求少了很多但借用方法的思路在框架源码里依然大量存在。比如很多源码里会写Object.prototype.toString.call(value)来判断类型因为toString的this指向谁返回的字符串里就显示谁的类型。这个写法比typeof精确得多function getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } console.log(getType([])); // Array console.log(getType({})); // Object console.log(getType(1)); // Number console.log(getType(null)); // Null console.log(getType(new Date())); // Date6.2 bind在业务代码里的几个真实场景场景一事件处理函数的this修正。React类组件里经常要写this.handleClick this.handleClick.bind(this)目的就是确保事件回调里的this是组件的实例。现在React函数式组件用hooks解决了这个问题但如果你维护的老项目还用类组件这个操作还是每天在做。场景二定时器回调的this绑定。setTimeout里的回调函数this默认指向全局对象。想让回调里的this指向你想要的上下文用bind是最干净的方式const user { name: 王五, greet() { setTimeout(function() { console.log(你好我是${this.name}); // 这里this是全局对象拿不到name }, 1000); } }; // 修正版 const user2 { name: 王五, greet() { setTimeout(function() { console.log(你好我是${this.name}); }.bind(this), 1000); } };场景三函数柯里化的简化。bind天然支持参数预设用它实现一个简单的柯里化function multiply(a, b, c) { return a * b * c; } const multiplyBy2 multiply.bind(null, 2); const multiplyBy2And3 multiplyBy2.bind(null, 3); console.log(multiplyBy2And3(4)); // 24注意这里第一个参数传了null意味着this绑到全局上但函数内部没用到this所以无所谓。bind的参数预设能力是它区别于call和apply最大的业务价值所在。6.3 手写实现可以扩展出的进阶玩法如果你把上面这4个实现都吃透了其实还可以顺藤摸瓜去看一些更高阶的内容function.prototype[Symbol.hasInstance]如何影响instanceof判断、Proxy的construct拦截器如何拦截new、Reflect.construct和原生new的关系、函数式编程里的partial和curry怎么用bind来实现。这些都是在手写bind这道题上自然延伸出来的问题面试官如果顺着追问到这些说明你是真的理解了不是背的。我实际带人的经验是能把new、call、apply、bind手写实现讲透的人写代码时的思路会清晰很多。因为这几个函数逼迫你思考一个函数在被调用的时候内部到底发生了哪些事情而这个思考过程是没法靠背诵替代的。最后分享一个我自己的小习惯面试前如果时间紧我不会从头背代码而是闭着眼在脑子里过一遍整个调用链路——从函数被调用时this如何确定开始再到临时挂载方案再到闭包延迟执行最后到new优先级的处理。理清了这条线手写实现就是水到渠成的事。你可能不会一个字节不差地写出来但核心逻辑一定不会跑偏这比背模板有用得多。