字节前端面试真题解析系列(第三篇):手写进阶!字节高频手写难题,搞定直接冲二面
前言
继上一篇吃透基础手写题(防抖、节流、深拷贝、Promise)后,这一篇咱们升级难度 ——字节一面进阶手写题!
字节的手写题,从来不是 “会写就行”,而是 “写得严谨、懂原理、能拓展”。比如 call/apply/bind,不只是让你实现功能,还要你说清区别;Promise 进阶,要你实现链式调用、Promise.all,这些都是字节一面后半段、二面前半段的高频考点。
很多人栽在进阶手写题上,不是不会,而是没吃透底层逻辑(比如 bind 的柯里化特性、Promise.all 的失败快速返回)。这一篇,我依旧拿字节真实面试原题,用最接地气的语言,把每道题的原理、代码、细节、追问都讲透,让你写出来的代码,直接戳中面试官的加分点,不用死记硬背,理解后就能默写。
真题 1:手写 call/apply/bind —— 字节高频,三者成对考
面试原题
请分别手写 call、apply、bind 方法,要求:
- 实现改变 this 指向的核心功能
- 说明三者的区别
- 解释 bind 为什么会返回一个新函数,以及它的柯里化特性
核心考察点
this 指向的手动控制、函数参数传递、闭包的应用、柯里化思想(字节超爱抠 bind 的细节)
通俗解析(先懂核心,再写代码)
call、apply、bind 的核心作用,都是「手动改变函数的 this 指向」,就像 “借调”—— 比如函数 A 本来指向对象 X,通过这三个方法,能让它临时指向对象 Y,执行 Y 的 “任务”。
举个生动的例子:你(函数)本来在自己家(this 指向自己),call/apply 就像 “临时去朋友家帮忙”(立即执行,帮完就回自己家);bind 就像 “把朋友家的钥匙拿过来,以后想什么时候去帮忙就什么时候去”(不立即执行,返回一个新函数,后续可多次调用)。
1. 手写 call 方法(最基础,字节入门手写)
js
// 给 Function 原型添加 myCall 方法(所有函数都能调用) Function.prototype.myCall = function (context) { // 1. 处理 context:如果没传,默认指向 window(非严格模式) context = context || window; // 2. 把当前函数(this)挂载到 context 上,作为它的一个属性(比如 context.fn = 当前函数) // 用 Symbol 避免属性名冲突(字节细节加分点) const fn = Symbol('fn'); context[fn] = this; // 3. 处理参数:call 是单个传参,从第二个参数开始,获取所有参数 const args = Array.from(arguments).slice(1); // 4. 执行函数,拿到返回值(此时函数的 this 指向 context) const result = context[fn](...args); // 5. 删除挂载的函数,避免污染 context(字节关键细节) delete context[fn]; // 6. 返回函数执行结果(保持原生 call 的特性) return result; };2. 手写 apply 方法(和 call 几乎一样,区别在参数)
js
// 给 Function 原型添加 myApply 方法 Function.prototype.myApply = function (context) { context = context || window; const fn = Symbol('fn'); context[fn] = this; // 关键区别:apply 第二个参数是数组/类数组,没有就传空数组 let result; if (arguments[1]) { result = context[fn](...arguments[1]); } else { result = context[fn](); } delete context[fn]; return result; };3. 手写 bind 方法(字节重点,难在柯里化和 this 绑定)
js
// 给 Function 原型添加 myBind 方法 Function.prototype.myBind = function (context) { const self = this; // 保存当前函数(this),避免闭包中 this 指向改变 // 1. 处理参数:bind 支持柯里化,先获取第一次传入的参数(从第二个开始) const args1 = Array.from(arguments).slice(1); // 2. 返回一个新函数(bind 不立即执行,这是和 call/apply 的核心区别) const boundFn = function () { // 3. 处理第二次传入的参数(柯里化特性) const args2 = Array.from(arguments); // 4. 合并两次参数,执行函数 // 关键:判断当前 this 是不是 boundFn 的实例(如果用 new 调用,this 指向实例) return self.apply( this instanceof boundFn ? this : context, args1.concat(args2) ); }; // 5. 继承原函数的原型(字节加分细节,保证 new 调用时的原型链正确) boundFn.prototype = Object.create(self.prototype); boundFn.prototype.constructor = boundFn; return boundFn; };三者核心区别(字节必问,一句话记死)
表格
| 方法 | 核心区别 | 参数传递 | 是否立即执行 |
|---|---|---|---|
| call | 单个传参 | 逗号分隔(fn.call (ctx, 1, 2, 3)) | 是 |
| apply | 数组传参 | 数组 / 类数组(fn.apply (ctx, [1,2,3])) | 是 |
| bind | 柯里化传参 | 分多次传参(fn.bind (ctx,1)(2,3)) | 否(返回新函数) |
字节高频追问(必背)
- bind 为什么会返回一个新函数?答:因为 bind 的核心是 “绑定 this 指向”,但不立即执行函数,返回新函数后,后续可以根据需要多次调用,符合实际业务场景(比如绑定事件回调,避免 this 指向错误)。
- bind 的柯里化特性是什么?答:柯里化就是 “将多参数函数,拆分成多个单参数 / 少参数函数”,bind 支持分多次传递参数,第一次传 this 和部分参数,后续调用新函数时,再传剩余参数,最终合并所有参数执行。
- 用 new 调用 bind 返回的新函数,this 会指向谁?答:会指向 new 创建的实例,而不是 bind 绑定的 context—— 这是字节面试官常考的细节,也是我手写代码中判断
this instanceof boundFn的原因。
真题 2:手写函数柯里化 —— 字节进阶,结合 bind 考
面试原题
请手写一个通用的柯里化函数,要求:
- 支持分多次传递参数,直到参数传完才执行
- 举例说明应用场景,结合字节业务说明
- 说明柯里化的核心作用
核心考察点
闭包的深度应用、函数参数收集、函数执行控制(字节常和 bind、数组方法结合考)
通俗解析(柯里化到底是什么?)
柯里化就像「拼乐高」:一个需要多个零件(参数)的乐高模型,你不用一次性把所有零件都凑齐,而是先拼一部分,再拼一部分,直到所有零件都凑齐,才完成整个模型(执行函数)。
比如 add (1)(2)(3) = 6,就是最经典的柯里化 —— 分三次传递参数,每次传递一个,直到传递完 3 个参数,才执行加法操作。字节业务中,很多场景需要 “分步骤收集参数”,比如表单提交(先收集用户名,再收集密码,最后收集验证码,全部收集完再提交)。
字节标准手写代码(通用版,可直接复用)
js
// 通用柯里化函数:fn=目标函数,args=初始参数(可选) function currying(fn, ...args) { // 核心:判断当前收集的参数个数,是否等于目标函数的形参个数 const needArgs = fn.length; // 目标函数需要的参数个数 const currentArgs = args; // 已收集的参数(闭包保存) // 返回一个新函数,用于继续收集参数 return function (...newArgs) { const allArgs = currentArgs.concat(newArgs); // 合并已收集和新传入的参数 // 1. 如果参数够了,执行目标函数 if (allArgs.length >= needArgs) { return fn.apply(this, allArgs); } // 2. 如果参数不够,继续柯里化,返回新函数 else { return currying(fn, ...allArgs); } }; }测试代码(面试时主动写,加分)
js
// 测试1:简单加法柯里化 const add = (a, b, c) => a + b + c; const curriedAdd = currying(add); console.log(curriedAdd(1)(2)(3)); // 6(分3次传参) console.log(curriedAdd(1, 2)(3)); // 6(分2次传参) // 测试2:字节业务场景(表单参数收集) const submitForm = (username, password, code) => { console.log(`提交表单:用户名${username},密码${password},验证码${code}`); }; const curriedSubmit = currying(submitForm); curriedSubmit('zhangsan')('123456')('666666'); // 提交表单:用户名zhangsan,密码123456,验证码666666字节业务应用场景(面试必说,加分)
- 表单参数收集(字节后台管理系统、抖音注册表单):分步骤收集用户输入的参数,全部收集完成后,再发起提交请求,避免参数缺失。
- 接口请求封装(字节接口开发):提前固定部分参数(比如接口地址、请求方式),后续只需要传递变化的参数(比如请求参数),简化接口调用。
- 函数复用(抖音组件开发):将常用函数柯里化,固定部分参数,生成新的复用函数,减少重复代码。
字节高频追问(必背)
- 柯里化的核心作用是什么?答:① 分步骤收集参数,提升代码灵活性;② 复用函数,固定部分参数,减少重复代码;③ 延迟执行,直到参数齐全才执行函数。
- 柯里化和 bind 的关系是什么?答:bind 的柯里化特性,本质就是柯里化的应用 ——bind 先固定 this 和部分参数,返回新函数,后续调用新函数时,再传递剩余参数,和柯里化 “分步骤收集参数” 的核心逻辑一致。
- 如何实现一个支持不定参数的柯里化函数?(进阶追问)答:不用判断参数个数,而是当调用函数时不传参数,就执行目标函数(比如 curriedAdd (1)(2)(3)() = 6),修改代码即可实现(下文可补充)。
真题 3:Promise 进阶手写(链式调用 + Promise.all)—— 字节重中之重
面试原题
请完善上一篇的 Promise,要求:
- 实现 then 方法的链式调用(支持 .then ().then ())
- 手写 Promise.all 方法
- 说明 Promise.all 的失败机制
核心考察点
Promise 底层原理、异步回调的链式处理、Promise 并发控制(字节二面高频,难度中等)
通俗解析(Promise 链式调用到底怎么实现?)
上一篇我们写的 Promise,then 方法只能调用一次,不能链式调用 —— 而真实的 Promise,之所以能 .then ().then (),核心是「每次调用 then,都返回一个新的 Promise」,上一个 then 的返回值,会作为下一个 then 的参数。
就像 “接力赛”:第一棒跑完(上一个 then 执行完成),把接力棒(返回值)交给第二棒(下一个 then),第二棒跑完,再交给第三棒,直到所有接力棒跑完(所有 then 执行完成)。
而 Promise.all,就像 “团队比赛”:多个选手(Promise)同时出发,只要有一个选手失败(reject),整个团队就失败;只有所有选手都成功(resolve),团队才成功,最终返回所有选手的结果。
1. 完善 Promise,实现 then 链式调用
js
const PENDING = 'pending'; const FULFILLED = 'fulfilled'; const REJECTED = 'rejected'; class MyPromise { constructor(executor) { this.status = PENDING; this.value = undefined; this.reason = undefined; // 新增:保存 then 回调(处理异步 resolve/reject) this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.status !== PENDING) return; this.status = FULFILLED; this.value = value; // 异步 resolve 时,执行所有成功回调 this.onFulfilledCallbacks.forEach((cb) => cb()); }; const reject = (reason) => { if (this.status !== PENDING) return; this.status = REJECTED; this.reason = reason; // 异步 reject 时,执行所有失败回调 this.onRejectedCallbacks.forEach((cb) => cb()); }; try { executor(resolve, reject); } catch (err) { reject(err); } } // 完善 then 方法,实现链式调用 then(onFulfilled, onRejected) { // 处理默认回调(如果没传,就透传值/错误) onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (value) => value; onRejected = typeof onRejected === 'function' ? onRejected : (reason) => { throw reason; }; // 核心:每次 then 都返回一个新的 Promise,实现链式调用 const newPromise = new MyPromise((resolve, reject) => { // 成功状态:异步执行回调(保证 then 回调异步执行,符合原生特性) if (this.status === FULFILLED) { setTimeout(() => { try { // 执行上一个 then 的成功回调,拿到返回值 const result = onFulfilled(this.value); // 判断返回值是不是 Promise,如果是,等待它执行完成 if (result instanceof MyPromise) { result.then(resolve, reject); } else { // 不是 Promise,直接 resolve 返回值 resolve(result); } } catch (err) { // 回调执行出错,reject 错误 reject(err); } }, 0); } // 失败状态:和成功状态逻辑一致 if (this.status === REJECTED) { setTimeout(() => { try { const result = onRejected(this.reason); if (result instanceof MyPromise) { result.then(resolve, reject); } else { resolve(result); // 失败回调返回非 Promise,依旧 resolve(原生特性) } } catch (err) { reject(err); } }, 0); } // 等待状态:将回调保存起来,等 resolve/reject 时执行 if (this.status === PENDING) { this.onFulfilledCallbacks.push(() => { setTimeout(() => { try { const result = onFulfilled(this.value); if (result instanceof MyPromise) { result.then(resolve, reject); } else { resolve(result); } } catch (err) { reject(err); } }, 0); }); this.onRejectedCallbacks.push(() => { setTimeout(() => { try { const result = onRejected(this.reason); if (result instanceof MyPromise) { result.then(resolve, reject); } else { resolve(result); } } catch (err) { reject(err); } }, 0); }); } }); return newPromise; // 返回新 Promise,实现链式调用 } }2. 手写 Promise.all 方法(字节高频,核心是并发控制)
js
// 给 MyPromise 新增 all 静态方法 MyPromise.all = function (promises) { return new MyPromise((resolve, reject) => { // 1. 处理参数:如果不是数组,直接 reject if (!Array.isArray(promises)) { return reject(new TypeError('Promise.all 接收的参数必须是数组')); } const result = []; // 保存所有 Promise 的成功结果 let count = 0; // 记录已完成的 Promise 个数 // 2. 如果数组为空,直接 resolve 空数组 if (promises.length === 0) { return resolve(result); } // 3. 遍历每个 Promise,执行并收集结果 promises.forEach((p, index) => { // 用 then 处理每个 Promise,无论传入的是 Promise 还是普通值 MyPromise.resolve(p).then( (res) => { result[index] = res; // 按顺序保存结果(字节细节,保证结果顺序和传入顺序一致) count++; // 所有 Promise 都成功,才 resolve 结果数组 if (count === promises.length) { resolve(result); } }, (err) => { // 核心:只要有一个 Promise 失败,立即 reject 错误(失败快速返回) reject(err); } ); }); }); }; // 新增 resolve 静态方法(处理普通值转 Promise) MyPromise.resolve = function (value) { if (value instanceof MyPromise) { return value; // 如果是 Promise,直接返回 } // 如果是普通值,返回一个 resolved 状态的 Promise return new MyPromise((resolve) => resolve(value)); };测试代码(面试时主动写,加分)
js
// 测试 then 链式调用 const p1 = new MyPromise((resolve) => { setTimeout(() => resolve(1), 1000); }); p1.then((res) => { console.log(res); // 1秒后输出 1 return res + 1; }).then((res) => { console.log(res); // 输出 2 }); // 测试 Promise.all const p2 = new MyPromise((resolve) => setTimeout(() => resolve(2), 500)); const p3 = new MyPromise((resolve) => setTimeout(() => resolve(3), 1000)); MyPromise.all([p2, p3]).then((res) => { console.log(res); // 1秒后输出 [2, 3](按传入顺序) }); // 测试 Promise.all 失败 const p4 = new MyPromise((resolve, reject) => setTimeout(() => reject('失败'), 300)); MyPromise.all([p2, p4, p3]).then( (res) => console.log(res), (err) => console.log(err) // 300毫秒后输出 失败(快速失败) );字节高频追问(必背)
- Promise.then 为什么能链式调用?答:因为每次调用 then 方法,都会返回一个新的 Promise,上一个 then 的回调返回值,会作为下一个 then 的参数;如果返回值是 Promise,下一个 then 会等待这个 Promise 执行完成,再执行回调。
- Promise.all 的失败机制是什么?答:“快速失败” 机制 —— 只要有一个 Promise 被 reject,Promise.all 会立即 reject 这个错误,不再等待其他 Promise 执行,这是字节面试官看重的核心特性。
- Promise.all 和 Promise.race 的区别?(进阶追问)答:Promise.all 是 “所有成功才成功,一个失败就失败”;Promise.race 是 “谁先执行完成(无论成功还是失败),就返回谁的结果”,字节业务中,常用 Promise.race 实现请求超时控制。
本篇总结(字节面试直接背)
字节一面进阶手写题,核心就这 3 类,考频排序:Promise 进阶 > call/apply/bind > 柯里化,记住这 3 个关键点:
- call/apply/bind 核心是「改变 this 指向」,bind 多了 “柯里化 + 返回新函数”,一定要处理原型继承和 new 调用的场景;
- 柯里化核心是「分步骤收集参数」,通用版要判断参数个数,结合字节业务场景说明更加分;
- Promise 链式调用的核心是「每次 then 返回新 Promise」,Promise.all 核心是「并发控制 + 快速失败」,细节要处理异步回调和结果顺序。
这一篇的手写题,你能默写下来,字节一面的手写关基本满分,直接有底气冲二面!
