跳到主要内容

手写 JS 系列:从 apply 到 bind,最后手写一个 Promise

· 阅读需 12 分钟

手写系列的意义不是「记住答案」,而是把 API 背后那句被藏起来的话重新说出来:「改变 this」本质是什么?「异步」到底是怎么串起来的? 顺着 apply → call → bind → Promise 这条线写下来,你会发现它们是一脉相承的——前面三个解决的是一件事(函数调用时的 this 和参数),最后一个把「回调」正式升级成了「一等公民」。

一、手写 apply

原生 API 长什么样

func.apply(thisArg, argsArray)
  • thisArg:调用 func 时绑定的 this
  • argsArray:参数数组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 用宽松等于——一次判断同时覆盖 nullundefined,两者都回退到全局对象。
  • 为什么用 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 有本质区别:不立即执行,返回一个新函数。这个新函数有两个特性:

  1. this 被永久绑定为 thisArg(之后再用 call/apply 也改不掉,除非用 new
  2. 支持柯里化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 → FULFILLEDPENDING → 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」,而是一台你自己也能造出来的机器。