前端业务实践中的异步问题

1,498 阅读6分钟

异步任务相比同步任务的复杂之处,主要在于返回结果的时机不可控,由此带来超时控制、顺序控制、竞态、最大并发等一系列问题。


单个的异步和简单的并发一般在语言层面就获得了支持,就前端而言,Promise 和 Async 是如此普及,以至于有些新人可能连 callback 都不认识了,建议看看《深入浅出nodejs》中关于异步的章节,补补历史知识。


不过有些场景,光靠原生 API 还搞不定,需要用点额外的技巧。本文总结了业务实践中可能遇到的四种场景:两个关于序列(Sequence),两个关于并发(Concurrency)。


1、单一异步任务循环

这类的问题是说:不定数量的同类异步任务如何依次执行。比如按序上传/下载、按序执行某种耗时任务、toast 组件多次调用时先展示完上一个再展示下一个等等。


解决这类问题可以借助某种队列或循环机制,比如:

const f = () => new Promise((resolve) => setTimeout(() => {
  console.log(Date.now())
  resolve()
}, 1000));

(async function () {
  let count = 3
  while (count--) {
    await f()
  }
})();

这篇文章详细介绍了各类 Loop/Iterator + Promise/Async 的写法,市面上也有不少现成的库可以处理这种问题,比如 async.seriesdeferred-queuepromise-sequenceco 等。


2、异步任务组合序列

这类问题是说:不定数量的非同质异步任务如何依次执行。比如开户流程需要依次执行多种不同的校验、提交一个复杂的表单需要分步校验再分步提交、返回逻辑存在多种拦截提示和跳转分支等等。无论是业务特性决定的,还是接口设计不合理导致的,可能都需要前端自己消化。


直接硬编码,很容易写出低维护性的面条代码,如果还不加节制的使用响应式(比如在 vue 里通过 watch 触发任务)或者用订阅发布 emit 来 dispatch 去,会进一步降低代码的可读性。我们期望每个函数是单一职责的、可测试的,然后有一种独立的调度方式来组织整个任务序列。


比如借鉴 Webpack。Webpack 原本只是一个打包工具,后来逐步演化成前端构建工具。构建的核心是构建过程管理,或者说构建任务管理。任务有同步、异步,过程有并行、串行,看上去很复杂,但是 Webpack 通过 Tapable 这个库对各类同步、异步场景做了很好的抽象,Tapable 提供了一系列被称为 Hook 的 api 来处理各类同步、异步的场景。

{
    SyncHook,// 同步序列任务
    AsyncSeriesHook, // 异步序列任务
    AsyncSeriesBailHook, // 可中断的异步序列任务
    AsyncSeriesWaterfallHook // 可传参的异步序列任务
    ...
}

以 AsyncSeriesHook 为例(单个任务 resolve 即继续下一步,reject 则退出整个流程),可以写出如下代码(在线体验):

import { AsyncSeriesHook } from 'tapable'

const hook = new AsyncSeriesHook()

function a () {
  return new Promise((resolve) => {
    console.log('aaa')
    setTimeout(resolve, 1000)
  })
}

function b () {
  return new Promise((resolve) => {
    console.log('bbb')
    setTimeout(resolve, 1000)
  })
}

function c () {
  return new Promise((resolve) => {
    console.log('ccc')
    setTimeout(resolve, 1000)
  })
}

// 按序注册任务
hook.tapPromise('task a', a)
hook.tapPromise('task b', b)
hook.tapPromise('task c', c)

// 开始执行
hook.promise()
    .then(() => console.log('done'))
    .catch(() => console.log('error'))

单个任务执行完成后,只有三种结果:继续下一步(next),正常结束整个流程(done)、因异常结束整个流程(exit)。基于 AsyncSeriesBailHook 进一步封装,可以实现如下的效果(在线体验):

task
   .push(taskA)
   .push(taskB)
   .push(taskC)
   .run()
   .then((res: any) => {
        // handle result
    })
    .catch(err => {
        // handle error
    })

需注意,使用 Tapable,所有的任务都需要在一开始注册好,然后再 run,不能在中途添加/插入任务。


3、并发任务的“单例”问题

这个问题的典型是 loading 组件该如何设计:假设每个接口发起请求时都会展示 loading,请求结束隐藏 loading。同一时间的接口请求可能有很多,但每时每刻界面上应该只能有一个 loading。比如 a 请求发出,展示 loading,之后 b 请求发出,如果 a 请求结束时,b 还没有结束,那么继续展示 loading,反之则隐藏 loading,这可怎么办呢?


解决这个问题可以采用一种类似信号量的策略:

const loading = {
    count: 0,
    el: document.createTextNode('loading'),
    show () {
        if (this.count === 0) {
            document.body.appendChild(this.el)
        }
        this.count += 1
    },
    hide () {
        this.count -= 1
        if (this.count === 0) {
            document.body.removeChild(this.el)
        }
    }
}

如果可能同时存在多个局部 loading(比如按模块并行加载的页面),那么最好将上述方法封装成一个类,便于生成多个实例。如果 loading 和 toast 之间还存在互斥关系的话,那么组件的设计还会更麻烦一些。


4、并发任务的竞态问题

这个问题的典型场景是:同一类请求,先后发送,以哪一个的返回为准?比如用户搜索 A 类电影,由于接口迟迟未返回,用户选择搜索 B 类电影,如果 B 的请求还没有返回,A 却返回了,这时能取 A 返回的结果吗?显然不能。


和 Promise.race 不同, Promise.race 是说谁先返回就取谁,上述问题是说谁最后请求就取谁。一种直接的思路是每次发出新请求就把前面的请求 cancel 掉,不过 Promise 没有提供 cancel api,那么另一种容易想到的思路是用时间戳来标识每个请求,然后判断请求的新鲜度:

const map = {
    'fetchMovie': 0
}
function fetchMovie () {
    const stamp = Date.now()
    map.fetchMovie = stamp
    fetch(url, params).then(res => {
        if (stamp < map.fetchMovie) return null // 该请求已过时
        return res
    })
}

不过这种方案侵入性比较强,更理想的方案是实现一个类似 redux-saga 的 takeLatest 方法。takeLatest 的基本思路是:只要有最新的请求,就将之前的请求 cancel 掉。虽然某些 Promise 的实现提供了 cancel 能力,但有没有办法用原生 Promise 的同时又能实现 cancel 呢?

// 模拟一个在 t 时间后返回结果的接口请求
function request (t) {
    return new Promise(resolve => {
        setTimeout(function () {
            resolve(t)
        }, t)
    })
}

const map = {}
let uid = 0

function takeLatest (fn) {
    const key = uid++
    return function () {
        let resolve
        let reject
        
        // 嘿嘿
        const a = new Promise((_res, _rej) => {
            resolve = _res
            reject = _rej
        })
    
        const t = Date.now()
        map[key] = t

        fn.apply(null, arguments).then(res => {
            if (t < map[key]) return
            resolve(res)
        }).catch(error => {
            if (t < map[key]) return
            reject(error)
        })
        
        return a
    }
}

// 测试
const f1 = takeLatest(request)
const f2 = takeLatest(request)

f1(3000).then(res => {
    console.log(res)
})

f2(3050).then(res => {
    console.log(res)
})

setTimeout(() => {
    f1(1000).then(res => {
        console.log(res)
    })
    
    f2(1050).then(res => {
        console.log(res)
    })
}, 1000)

// 返回 setTimeout 里的两个“最新”的请求结果:1000,1050

所谓 cancel 呢,其实就是额外包一层 Promise,这个 Promise 既不 resolve 又不 reject,过段时间就被垃圾回收了

还有借助 AbortController API 的解决思路,亦可参考。