Promise 像外卖订单

4 阅读8分钟

Promise 像外卖订单

中午十二点,我点了份黄焖鸡。APP 显示"预计 30 分钟送达"——我没盯着屏幕等,而是继续敲代码。25 分钟后,手机响了:"您的外卖已放到前台"。这种"先下单,后取餐"的模式,和 Promise 一模一样。

说实话,刚开始学前端的时候,我对 Promise 这玩意儿是抵触的。那时候我觉得回调函数挺好用的啊,写起来直来直去,虽然嵌套多了确实眼晕,但凑合能用。直到三年前那个周五,我接了一个支付接口的活儿,才真正明白 Promise 为什么存在。


那是下午三点,产品经理老王走过来,拍了拍我的肩膀:"支付回调改一下,要先查用户,再查订单,再查订单详情,最后还要校验一下库存。"我嗯了一声,打开代码一看,差点没背过气去。

旧代码是这样的:

getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetail(orders[0].id, (detail) => {
      console.log(detail);
    });
  });
});

三层回调,像俄罗斯套娃一样一层包一层。这还没完,老王又说:"每个环节都要加错误处理啊,支付这事儿不能马虎。"我试着加了几个 if (err),代码瞬间变成了金字塔形状——回调地狱这个词,我那天是真切体会到了。

改到下午六点,我眼睛都花了。屏幕上全是缩进,我像数牙签一样数着大括号。楼下烧烤摊的烟飘上来,孜然味混着空调房里的咖啡味,我肚子咕咕叫,但代码还没跑通。

这时候,我们组的后端老哥走了过来。他姓张,因为写的接口又稳又快,人称"张接口"。他端着保温杯,瞄了一眼我的屏幕,笑了:"你这代码,跟我刚入行时写的 SQL 嵌套有得一拼。"

我苦笑:"这回调嵌套太恶心了。"

他喝了口茶,问我:"你们前端不是有个叫 Promise 的东西吗?是不是跟我老婆网购差不多——下单时就知道会有结果,但不用一直盯着物流?"

我一愣,这比喻绝了。


张接口说得对,Promise 本质上就是一个"承诺"。

想象你去饭店吃饭,如果是普通的回调,你得站在柜台前等着,厨师喊一声"好了"你才能拿走。但 Promise 呢?你扫码下单,拿到一个订单号,然后就可以去找座位玩手机了。饭做好了自然会叫号,你不用盯着厨房看。

那个订单号,就是 Promise 对象。它代表了一个尚未完成但预期将来会完成的异步操作。

const order = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('黄焖鸡送到了');
  }, 3000);
});

order.then(food => console.log(food));

这里的 resolve 就像是服务员喊"您的餐好了",then 就是你起身去取餐。在这 3 秒钟里,JavaScript 引擎不会被阻塞,该干嘛干嘛——渲染页面、响应用户点击、执行其他代码。

但外卖也有翻车的时候。万一下单后商家发现没米了,或者没有骑手接单,怎么办?

const order = new Promise((resolve, reject) => {
  const hasRider = Math.random() > 0.5;
  hasRider
    ? resolve('配送中')
    : reject('暂无骑手接单');
});

order
  .then(msg => console.log(msg))
  .catch(err => console.log('退单:' + err));

reject 就是"这单做不了了",catch 就是你接到电话后处理退单。有了这套机制,错误处理从嵌套的最深层一下子浮到了表面,不用再一层一层往上抛了。

张接口看我似懂非懂,又补了一句:"你看啊,Promise 其实有三种状态——pending、fulfilled、rejected。就像外卖订单的'待接单''配送中''已送达'。一旦变成 fulfilled 或 rejected,就改不了了,这叫'不可变'。"

我点点头,这设计确实妙。状态确定了就是确定了,不会出现"既成功又失败"的诡异情况。


第二天,我开始用 Promise 重构那个支付接口。写着写着,发现新的问题——虽然解决了回调地狱,但如果要在 then 里面再发起请求,还是会嵌套。

张接口端着豆浆路过,指着我的屏幕说:"你这 then 里面再包 Promise,可以拍平了写。"

"拍平?"

"就是链式调用啊,return 一个新的 Promise,then 会自动等它完成。"

我恍然大悟,改成了这样:

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => console.log(detail))
  .catch(err => console.log('出错了:', err));

从金字塔变成了一条链,像流水线一样顺。每个 then 接收上一个 then 的返回值,如果是 Promise,就自动等它 resolve。

更妙的是,每个环节还能加工数据,传给下一个:

fetchUser(1001)
  .then(user => {
    console.log('用户:', user.name);
    return fetchOrders(user.id);
  })
  .then(orders => {
    console.log('订单数:', orders.length);
    return orders.filter(o => o.paid);
  })
  .then(paidOrders => console.log('已支付:', paidOrders));

那天雨很大,我 chain 完代码,发现窗外的梧桐叶被打落了一地。代码却意外地顺利跑通了,逻辑清晰得像那条笔直的雨线。


过了一周,要做个数据看板,需要同时调三个接口:用户信息、订单统计、库存预警。

我习惯性地开始写:

const user = await fetchUser();
const orders = await fetchOrders();
const inventory = await fetchInventory();

张接口正好路过,看了一眼:"你这样写,三个接口是串行的。fetchOrders 要等 fetchUser 回来才发请求,没必要啊。"

"那怎么办?"

"一起发啊, Promise.all,等最后一个回来就行了。"

const p1 = fetchUser();
const p2 = fetchOrders();
const p3 = fetchInventory();

Promise.all([p1, p2, p3])
  .then(([user, orders, inventory]) => {
    renderDashboard({ user, orders, inventory });
  });

三个请求一起走,像三辆外卖车同时从不同的商家出发。Promise.all 会等最后一辆车到齐,才把三份餐一起端给你。

但张接口提醒我:"有个坑啊,Promise.all 是'一损俱损'的。"

Promise.all([
  Promise.resolve('A'),
  Promise.reject('B 出错了'),
  Promise.resolve('C')
])
  .then(console.log)
  .catch(err => console.log(err)); // "B 出错了"

有一个退单,整单取消。只要有一个 Promise 被 reject,整个 Promise.all 就失败了,拿不到任何结果。这跟现实中点外卖还真不一样——现实中一份餐没了,其他的还能吃。

后来我查文档,发现 ES2020 出了个 Promise.allSettled,可以等全部完成,不管成功失败。但在那会儿,我们只能用 Promise.all,然后小心翼翼地处理错误。


又过了一阵子,我在做图片加载优化。页面上有几张大图,从主站 CDN 加载有时候慢,我们备了两个 CDN 源。

我跟张接口说:"我想两个 CDN 同时请求,哪个快用哪个。"

他说:"这就是 race,竞速。"

const cdn1 = fetch('https://cdn-a.com/img.png');
const cdn2 = fetch('https://cdn-b.com/img.png');

Promise.race([cdn1, cdn2])
  .then(img => showImage(img));

两个请求赛跑,谁先回来用谁,另一个自动放弃。这就像你同时点了两个外卖,谁先到你吃谁——虽然现实中这么做有点缺德,但在代码里这可是正经的优化手段。

这个特性还常被用来封装请求超时:

const timeout = new Promise((_, reject) => {
  setTimeout(() => reject('请求超时'), 5000);
});

Promise.race([fetchData(), timeout])
  .then(data => console.log(data))
  .catch(err => console.log(err));

给请求加个 5 秒倒计时,超时算输。这招在弱网环境下特别好用,避免用户盯着空白页面发呆。

张接口看了我的代码,满意地点点头:"会用 race 了,进阶了。"


Promise 链写多了,总觉得还是不够优雅。那一串 .then().then().then() 虽然比回调地狱强,但看着还是像链条,不够自然。

ES2017 出了 async/await,张接口兴奋地跑过来:"这是语法糖,但糖甜啊!"

async function loadData() {
  try {
    const user = await getUser(1001);
    const orders = await getOrders(user.id);
    const detail = await getOrderDetail(orders[0].id);
    return detail;
  } catch (err) {
    console.log('出错了:', err);
  }
}

看起来像同步代码,一行一行往下走,但实际上每一步都是异步的。await 会暂停函数执行,等 Promise resolve 了再继续,而不会阻塞主线程。

这种写法最大的好处是——可以用 try/catch 处理异步错误了。以前 Promise 链的错误处理要么用 catch,要么在每个 then 里写错误回调,现在直接一个 try/catch 包起来,和写同步代码一样自然。

但这里有个坑要注意:await 会阻塞后续代码。如果你这样写:

async function loadSlow() {
  const user = await fetchUser();      // 等 1 秒
  const orders = await fetchOrders();  // 再等 1 秒
  const inventory = await fetchInventory(); // 又等 1 秒
  return { user, orders, inventory };
}

三个串行请求,总共要等 3 秒。想并行还得配合 Promise.all:

async function loadDashboard() {
  const [user, orders, inventory] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchInventory()
  ]);
  return { user, orders, inventory };
}

await 配合 Promise.all,既保留了代码的简洁性,又不损失性能。张接口说这就是"既要又要",语法糖和效率两不误。


代码写完,天已经黑了。我揉揉脖子,想起中午点的黄焖鸡早就凉透了。

张接口收拾包准备走,路过我工位时说:"现在明白 Promise 了吧?其实就是个思维方式——不要等着,要等着的时候告诉我,好了我再处理。"

我点点头。这不仅是代码的哲学,也挺像人生很多事—— submit 了一份简历,发起了一个请求,然后继续过自己的生活,等 HR 的回调。

现在每次看到外卖 APP 上的"配送中",我都会想起 Promise。那是一份承诺——饭会来,结果会来,只是需要等一等。而在等待的时间里,CPU 不会空转,页面不会卡住,其他的代码该跑跑,该渲染渲染。

异步的美妙就在于此。

门外传来脚步声,由远及近,停在了门口。是骑手吗?

#前端 #JavaScript #技术生活 #编程感悟 #工程师日常