手写 JS 系列:从 apply 到 bind,最后手写一个 Promise
手写系列的意义不是「记住答案」,而是把 API 背后那句被藏起来的话重新说出来:「改变 this」本质是什么?「异步」到底是怎么串起来的? 顺着 apply → call → bind → Promise 这条线写下来,你会发现它们是一脉相承的——前面三个解决的是一件事(函数调用时的 this 和参数),最后一个把「回调」正式升级成了「一等公民」。
一、手写 apply
原生 API 长什么样
func.apply(thisArg, argsArray)
thisArg:调用func时绑定的thisargsArray:参数数组(null/undefined表示不传参)- 返回值:
func的返回值
经典用法——借用方法求数组最大值:
Math.max.apply(null, [3, 5, 1]); // 5
手写:把函数变成对象的方法
apply 的全部秘密只有一句话:obj.fn() 调用时,fn 里的 this 就是 obj。 那我们就把函数挂到 context 上、当成方法调用,this 不就自然绑上了吗:
Function.prototype.myApply = function (context, argsArray) {
// ① this 处理:null/undefined 指全局,原始值装箱
context = context == null ? globalThis : Object(context);
// ② 用 Symbol 当临时键,避免覆盖 context 上已有的同名属性
const key = Symbol('myApply');
// ③ 把 this(要调用的函数)挂到 context 上
context[key] = this;
// ④ 方法调用 → this 自动是 context;参数可能是数组,也可能没传
const result = argsArray == null ? context[key]() : context[key](...argsArray);
// ⑤ 用完删除,不留痕
delete context[key];
return result;
};
三个容易忽略的点:
- 为什么
context == null用宽松等于——一次判断同时覆盖null和undefined,两者都回退到全局对象。 - 为什么用
Symbol——如果直接context.fn = this,万一 context 上本来就有个fn属性就被覆盖了;Symbol保证键唯一。 - 原始值要装箱——
Object(1)得到Number包装对象,函数里的this是包装对象(原生apply也是这个行为)。
二、手写 call
原生 API 长什么样
func.call(thisArg, arg1, arg2, ...)
和 apply 只有一个区别:参数是逐个列出,而不是数组。其余(this 绑定、返回值)完全一样。
手写:和 apply 只差一个「展开」
Function.prototype.myCall = function (context, ...args) {
context = context == null ? globalThis : Object(context);
const key = Symbol('myCall');
context[key] = this;
const result = context[key](...args); // 区别只有这里:直接展开参数列表
delete context[key];
return result;
};
所以记忆法很简单:apply 收「一坨」,call 收「一串」。用 ...args 收集、...args 展开,其余逻辑和 apply 完全一致。
三、手写 bind
原生 API 长什么样
func.bind(thisArg, arg1, arg2, ...)
和 call/apply 有本质区别:不立即执行,返回一个新函数。这个新函数有两个特性:
this被永久绑定为thisArg(之后再用 call/apply 也改不掉,除非用new)- 支持柯里化:
bind时传入的参数会预先填上,调用时再补剩下的
手写:返回新函数 + 硬绑定 + 柯里化
Function.prototype.myBind = function (context, ...bindArgs) {
const fn = this;
const bound = function (...callArgs) {
// ① new 调用:this 是 bound 的实例 → 用实例,忽略绑定的 context
const ctx = this instanceof bound ? this : context;
// ② 硬绑定:非 new 调用时 this 永远是 context
// ③ 柯里化:bind 的参数 + 调用时的参数拼接
return fn.apply(ctx, [...bindArgs, ...callArgs]);
};
// ④ 保持原型链:new bound() 的实例能拿到 fn.prototype 上的属性
bound.prototype = Object.create(fn.prototype);
return bound;
};
逐点拆解:
- 为什么不立即执行——
bind返回的是bound这个新函数,真正的调用推迟到bound(...)那一刻。 - 硬绑定怎么实现——
bound内部判断this instanceof bound:如果不是new调用,this要么是全局、要么是调用者,反正ctx都取context,于是原绑定的 this 怎么都改不掉——这正是原生 bind 的「硬绑定」。 - 柯里化靠参数拼接——
[...bindArgs, ...callArgs]:bind时预填的 + 调用时补的,顺序拼接。 - new 兼容靠原型链——
bound.prototype = Object.create(fn.prototype),让new bound()出来的实例instanceof fn成立,且能访问原函数原型上的方法。
三个放一起对比,脉络就清楚了:apply/call 是「临时借一个 this,立刻执行」;bind 是「把 this 焊死,发一把钥匙(新函数),钥匙一拧(调用)才执行」。
四、最后:手写 Promise
前面三个处理的是「同步调用的 this」,Promise 处理的是「异步调用的顺序」。手写 Promise 最有价值的地方,是强迫你把三件事想清楚:状态机、链式 then、解析规程。
下面按三个版本递进:先给一个能跑的最简版(只讲清状态机 + 回调),中间是完整 class 版(补全链式与解析规程),最后换成原型链写法(揭示 class 不过是 function + prototype 的语法糖)。三个版本是同一台机器,只是「封装方式」从裸到全、从糖到芯。
4.1 最简单的版本:只讲清「状态机 + 回调」
先把核心机器跑通。这个版本故意偷懒:只存一个回调、不做链式、不捕获异常、不处理返回值——它只回答一个问题:「状态怎么只翻一次,回调怎么在落定后被调用」。
function MyPromise(executor) {
this.state = 'pending';
this.value = undefined; // 成功的结果
this.reason = undefined; // 失败的原因
this.onFulfilled = null; // 只留一个槽位:then 只能调用一次
this.onRejected = null;
const resolve = (value) => {
if (this.state !== 'pending') return; // 硬币只翻一次
this.state = 'fulfilled';
this.value = value;
if (this.onFulfilled) this.onFulfilled(value); // 落定了就立刻调用
};
const reject = (reason) => {
if (this.state !== 'pending') return;
this.state = 'rejected';
this.reason = reason;
if (this.onRejected) this.onRejected(reason);
};
executor(resolve, reject);
}
MyPromise.prototype.then = function (onFulfilled, onRejected) {
if (this.state === 'fulfilled') onFulfilled(this.value); // 已落定:立刻执行
else if (this.state === 'rejected') onRejected(this.reason);
else { // 还没落定:先存起来
this.onFulfilled = onFulfilled;
this.onRejected = onRejected;
}
};
new MyPromise((resolve) => setTimeout(() => resolve('ok'), 0)).then(console.log); // ok
这一段已经能跑通「先存回调、落定后再调」的完整闭环。它做不到的,正是后面 class 版要补的四件事:
- 不支持链式——
then不返回新 Promise,.then().then()会直接报错; - 只支持一次 then——单槽位,第二个
.then会覆盖第一个; - 不捕获 executor 抛错——
executor里 throw 会漏出去; - 不处理返回值 / thenable——回调里
return一个 Promise 不会被「吸收」。
4.2 class 版本:补全链式与解析规程
4.2.1 状态机:三态 + 不可逆
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
Promise 的语义核心只有一句话:PENDING → FULFILLED 或 PENDING → REJECTED,一旦落定就不可再变。所有实现细节都是围绕「怎么保证这枚硬币只翻一次」。
4.2.2 骨架:executor + resolve / reject
class MyPromise {
constructor(executor) {
this.state = PENDING;
this.value = undefined; // 成功的结果
this.reason = undefined; // 失败的原因
// 回调存数组:then 可能被调用多次
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== PENDING) return; // 状态不可逆:只翻一次
this.state = FULFILLED;
this.value = value;
this.onFulfilledCallbacks.forEach((cb) => cb());
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.reason = reason;
this.onRejectedCallbacks.forEach((cb) => cb());
};
try {
executor(resolve, reject); // executor 同步执行
} catch (err) {
reject(err); // executor 抛错 → 等价于 reject
}
}
// ...then / catch / 静态方法,见下
}
resolve/reject开头那句if (this.state !== PENDING) return,就是「硬币只翻一次」的保证。- 回调存数组,因为一个 Promise 可以被
.then()多次。 executor是同步执行的,所以它抛错要捕获并转成reject。
4.2.3 then:链式调用的钥匙
then(onFulfilled, onRejected) {
// 值透传:没传回调就把值/错误原样往下传
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
onRejected = typeof onRejected === 'function' ? onRejected : (e) => { throw e; };
// 关键:then 返回一个新 Promise,链式才成立
const promise2 = new MyPromise((resolve, reject) => {
const run = (callback, value) => {
// 异步执行:真实 Promise 用微任务,这里用 setTimeout 近似
setTimeout(() => {
try {
const x = callback(value);
resolvePromise(promise2, x, resolve, reject); // 处理回调的返回值
} catch (err) {
reject(err);
}
}, 0);
};
if (this.state === FULFILLED) {
run(onFulfilled, this.value);
} else if (this.state === REJECTED) {
run(onRejected, this.reason);
} else {
// 还没落定 → 存起来,resolve/reject 时再执行
this.onFulfilledCallbacks.push(() => run(onFulfilled, this.value));
this.onRejectedCallbacks.push(() => run(onRejected, this.reason));
}
});
return promise2;
}
三个关键决定:
then返回新 Promise——没有这一行就没有链式。.then(a).then(b)里,b等的是a返回的那个新 Promise。resolvePromise(promise2, x, ...)——回调的返回值x可能是个普通值、也可能又是一个 Promise,必须统一处理。这就是下一节的解析规程。setTimeout包一层——保证回调异步执行(then的回调永远不在then同步调用栈里跑)。真实 Promise 用的是微任务queueMicrotask,这里用宏任务便于理解,语义上两者都是「先返回、后执行」。
4.2.4 resolvePromise:Promise 解析规程(Promise/A+ 的核心)
function resolvePromise(promise2, x, resolve, reject) {
// ① 防循环引用:then 返回的 promise2 又 resolve 了自己
if (promise2 === x) {
return reject(new TypeError('Chaining cycle detected for promise'));
}
// ② x 是 Promise → 吸收它的结果,继续递归
if (x instanceof MyPromise) {
return x.then((v) => resolvePromise(promise2, v, resolve, reject), reject);
}
// ③ x 是 thenable 对象(有 then 方法的对象)→ 取它的 then 来执行
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let then;
try {
then = x.then;
} catch (err) {
return reject(err);
}
if (typeof then === 'function') {
let called = false; // 防止 thenable 的 resolve/reject 被调用两次
try {
then.call(
x,
(y) => {
if (called) return;
called = true;
resolvePromise(promise2, y, resolve, reject);
},
(r) => {
if (called) return;
called = true;
reject(r);
},
);
} catch (err) {
if (!called) reject(err);
}
return;
}
}
// ④ 普通值 → 直接 resolve
resolve(x);
}
这一段是最容易被跳过、但恰恰最体现「手写价值」的:它让手写 Promise 能兼容任意库的 Promise(只要对方是标准 thenable)。递归 + called 防重入 + 循环引用检测,三件事缺一不可。
4.2.5 catch 与 finally
catch(onRejected) {
return this.then(null, onRejected); // 就是 then 的语法糖
}
finally(callback) {
// 不吞结果:无论成败,都把原 value/reason 原样传下去
return this.then(
(value) => MyPromise.resolve(callback()).then(() => value),
(reason) => MyPromise.resolve(callback()).then(() => { throw reason; }),
);
}
4.2.6 静态方法:resolve / reject / all / race
static resolve(value) {
return value instanceof MyPromise ? value : new MyPromise((r) => r(value));
}
static reject(reason) {
return new MyPromise((_, rej) => rej(reason));
}
static all(promises) {
return new MyPromise((resolve, reject) => {
const results = [];
let count = 0;
if (promises.length === 0) {
resolve([]);
return;
}
promises.forEach((p, i) => {
MyPromise.resolve(p).then((value) => {
results[i] = value; // 按下标存,保证顺序
count += 1;
if (count === promises.length) resolve(results); // 全部成功才 resolve
}, reject); // 任何一个失败 → 整体 reject
});
});
}
static race(promises) {
return new MyPromise((resolve, reject) => {
// 谁先落定算谁
promises.forEach((p) => MyPromise.resolve(p).then(resolve, reject));
});
}
all 的两个细节:按下标存结果保证顺序与完成顺序无关;计数到齐才 resolve,任何一个 reject 就整体失败。race 则是「先到先得」,直接让第一个落定者的回调去 resolve/reject 外层。
4.3 原型链版本:函数 + prototype 的等价写法
class 并不是什么新东西,它只是把「构造函数 + prototype」这套老玩法做了语法收编。把 class 版逐行翻译回原型链写法,class 的真相就露出来了:
function MyPromise(executor) {
this.state = PENDING;
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== PENDING) return;
this.state = FULFILLED;
this.value = value;
this.onFulfilledCallbacks.forEach((cb) => cb());
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.reason = reason;
this.onRejectedCallbacks.forEach((cb) => cb());
};
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
// 实例方法挂到 prototype 上(class 里的方法就长在这里)
MyPromise.prototype.then = function (onFulfilled, onRejected) {
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
onRejected = typeof onRejected === 'function' ? onRejected : (e) => { throw e; };
const promise2 = new MyPromise((resolve, reject) => {
const run = (callback, value) => {
setTimeout(() => {
try {
const x = callback(value);
resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err);
}
}, 0);
};
if (this.state === FULFILLED) run(onFulfilled, this.value);
else if (this.state === REJECTED) run(onRejected, this.reason);
else {
this.onFulfilledCallbacks.push(() => run(onFulfilled, this.value));
this.onRejectedCallbacks.push(() => run(onRejected, this.reason));
}
});
return promise2;
};
MyPromise.prototype.catch = function (onRejected) {
return this.then(null, onRejected);
};
MyPromise.prototype.finally = function (callback) {
return this.then(
(value) => MyPromise.resolve(callback()).then(() => value),
(reason) => MyPromise.resolve(callback()).then(() => { throw reason; }),
);
};
// 静态方法挂在构造函数上(class 里的 static 就长在这里)
MyPromise.resolve = function (value) {
return value instanceof MyPromise ? value : new MyPromise((r) => r(value));
};
MyPromise.reject = function (reason) {
return new MyPromise((_, rej) => rej(reason));
};
MyPromise.all = function (promises) {
return new MyPromise((resolve, reject) => {
const results = [];
let count = 0;
if (promises.length === 0) {
resolve([]);
return;
}
promises.forEach((p, i) => {
MyPromise.resolve(p).then((value) => {
results[i] = value;
count += 1;
if (count === promises.length) resolve(results);
}, reject);
});
});
};
MyPromise.race = function (promises) {
return new MyPromise((resolve, reject) => {
promises.forEach((p) => MyPromise.resolve(p).then(resolve, reject));
});
};
对照表帮你把糖衣揭掉:
| class 写法 | 原型链等价写法 |
|---|---|
class MyPromise { constructor(executor) {...} } | function MyPromise(executor) {...} |
then(onFulfilled, onRejected) {...} | MyPromise.prototype.then = function (...) {...} |
catch(...) / finally(...) | MyPromise.prototype.catch / MyPromise.prototype.finally |
static resolve(value) {...} | MyPromise.resolve = function (value) {...} |
static all(promises) {...} | MyPromise.all = function (promises) {...} |
resolvePromise 始终保持独立函数——它不属于任何实例,也不属于构造函数,所以既不挂 prototype 也不挂构造器。两个版本运行时的行为完全一致,选哪种只是「个人口味 + 团队规范」问题。
五、总结
整条线回头看,其实是 JS 里两个最核心机制的缩影:
- apply / call:借 this 立即执行——「把函数挂到对象上再调用」。
- bind:焊死 this 发钥匙——「返回新函数,推迟执行 + 柯里化 + 兼容 new」。
- Promise:把回调升级成状态机——「三态不可逆 + 链式 then + 解析规程吸收一切 thenable」。
手写一遍的价值不在代码本身,而在你被迫回答的那些问题:为什么状态只能翻一次?为什么 then 要返回新 Promise?为什么回调要异步?为什么返回值可能是 Promise 就得递归解析?这些问题答清楚了,Promise 对你就不再是「背 API」,而是一台你自己也能造出来的机器。
