2
讨论还请到原 github issue 下:https://github.com/LeuisKen/l...

什么是异步迭代器

关注tc39或者通过其他渠道关注JavaScript发展的同学应该早已注意到了一个新的草案:proposal-async-iteration。该草案在本文成文时,已经进入了ECMAScript® 2019规范,也就是说,成为了JavaScript语言本身的一部分。这项草案就是我本文中,我将要提到的异步迭代器(Asynchronous Iterators)

这个新的语法,为之前的生成器函数(generator function)提供了异步的能力。举个例子,就是下面这样。

// 之前的生成器函数
function* sampleGenerator(array) {
    for (let i = 0; i < array.length; i++) {
        yield array[i];
    }
}

// 现在的异步生成器函数,让我们可以在生成器函数前面加上 async 关键字
async function* sampleAsyncGenerator(getItemByPageNumber, totalPages) {
    for (let i = 0; i < totalPages; i++) {
        // 这样我们就能在里面使用 await 了
        yield await getItemByPageNumber(i);
    }
}

业务场景

我们学习新的东西,必然是要伴随着业务价值的。因此我去学习异步迭代器,自然也是为了解决我在业务中所遇到的问题。接下来我来分享一个场景:

在移动端,经常会有滑到页面底部,加载更多的场景。比如,我们在浏览新闻的时候,选择一个分类,就能看到对应分类的很多新闻,这些新闻通常是新的在前,旧的在后,顺序的排列下来。例如,百度新闻:https://news.baidu.com/news#/

本质上,这是一个分页器。通常的实现是,前端向服务端发送一个带有指定类别、指定页码(或者时间戳)的数据请求,服务端返回一个数据列表,该列表长度通常是固定的。然后前端在拿到这部分数据后,将数据渲染到视图上。值得我们注意的是,在这个场景下,因为是用户滑动到底部,触发对下一页的加载,所以是不存在从第一页跳到第五页这种跳页的需求的。

我们也许会用这样的代码来实现这个需求:

let page = 1;       // 从第一页开始
let isLastPage = false;

function getPage(type) {
    $.ajax({
        url: '/api/list',
        data: {
            page,
            type
        },
        success(res) {
            isLastPage = res.isLastPage;    // 是否为最后页
            // 根据 res 更新视图
            page++;
        }
    })
}

// 用户触发加载的事件处理函数
function handleLoadEvent() {
    if (isLastPage) {
        return;
    }
    getPage('推荐');
}

不去管一些其他的实现细节(如,throttle、异步竞态),这段代码虽然不甚优雅,但是足够实现我们的业务需求了。

需求总是会变的

假设不久之后,我们接到了一个新的需求,我们业务中的某两个(或者三个、四个)类别的列表需要在同一个页面上展示。也就是说,数据的映射关系,发生了如下改变:

image

方案设计

让我们先思考一下:如何去合并列表数据,让我们的列表还能像之前一样保证有序?为了方便讨论,我在这里抽象出两个数据源A、B,他们里面的内容是两个有序数组,如下所示:

A ---> [1, 3, 5, 7, 9, 11, …]
B ---> [0, 2, 4, 6, 8, …]

那么我们预期的合并后列表就是:

merged ---> [0, 1, 2, 3, 4, 5, 6, …]

假设我们每次分页去取数据,预期的数据长度(记为:pickNumber)是3,那么我们在第一次取数据后,回调中预期请求到的值就是[0, 1, 2]。那么如果我们从A中拿3个,B中也拿3个,那么排序后,从排序的结果中取3个,就拿到了我们想要的[0, 1, 2]。要取出合并后列表中有序的pickNumber个数据,就先从各个数据源中取pickNumber个数据,然后对结果排序,取出前pickNumber个数据,这就是我所选择的保证数据有序的策略。

这个策略,在一些极限情况下,比如合并后列表的前几页都是A等等,都是可以保证顺序的。

实现设计

方案确定后,我们来设计下我们要实现的函数,很自然的,我们会想到这样的实现:

/**
 * 从多个 type 列表中获取数据
 *
 * @param {Array} types 需要合并的 type 列表
 * @param {Function} sortFn 排序函数
 * @param {number} pickNumber 每页需要的数据
 * @param {Function} callback 返回页数据的回调函数
 */
function getListFromMultiTypes(types, sortFn, pickNumber, callback) {

}

这样的实现,做出来其实也是可以满足业务需求的,但是他不是我想要的。因为type这个东西和业务耦合的太严重了。当然,我可以把types改成urls,但是这种程度的抽象,还是需要我们把$.ajax这个东西内置到我们的函数里,而我想要的仅仅只是一个merge。所以,我们还是需要去追求更好的形式来抽象这个业务。

追求更好的抽象

下面我把前面的A和B换一种形式组织起来,如果我们忽略掉他们其实是异步的东西的话,其实他们可以被抽象为二维数组:

// A
[
    [1, 3, 5],
    [7, 9, 11],
    …
]

// B
[
    [0, 2, 4],
    [6, 8, 10],
    …
]

抽象成了二维数组,我们可以发现只要去迭代A、B,我们就可以获得想要的数据了。也就是说,A和B其实就是两个不同的迭代器。加上异步的话,那么一个分页的服务端列表数据源,在前端可以抽象成一个异步的迭代器,这样抽象后,我的需求,就变成了把两个数组merge一下就ok了~

使用异步生成器函数抽象分页逻辑

我们可以用Promise$.ajax的逻辑封装一下:

/**
 * 请求数据,返回 Promise
 *
 * @param {string} url 请求的 url
 * @param {Object} data 请求所带的 query 参数
 * @return {Promise} 用于处理请求的 Promise 对象
 */
function getData(url, data) {
    return new Promise(function (resolve, reject) {
        $.ajax({
            url,
            type: 'GET',
            data,
            success: resolve
        });
    });
}

这样,一个分页器的异步生成器函数就可以用如下代码实现:

/**
 * 获取 github 某仓库的 issue 列表
 *
 * @param {string} location 仓库路径,如:facebook/react
 */
async function* getRepoIssue(location) {
    let page = 1;
    let isLastPage = false;

    while (!isLastPage) {
        let lastRes = await getData(
            '/api/issues',
            {location, page}
        );
        isLastPage = lastRes.length < PAGE_SIZE;
        page++;
        yield lastRes;
    }
}

使用起来可以说是非常简单了:

const list = getRepoIssue('facebook/react');

btn.addEventListener('click', async function () {
    const {value, done} = await list.next();
    if (done) {
        return;
    }
    container.innerHTML += value.reduce((cur, next) =>
        cur + `<li><div>Repo: ${next.repository_url}</div>`
            + `<div>Title: ${next.title}</div>`
            + `<div>Time: ${next.created_at}</div>`, '');
});

再设计

有了异步迭代器的抽象,我们重新来看看我们的设计,相信大家心中都有了答案:

/**
 * 合并多个异步迭代器,返回一个新的异步迭代器
 * 该迭代器每次返回 pickNumber 个数据
 * 数据按照 sortFn 排序
 *
 * @param {Array} iterators 异步迭代器数组对象
 * @param {Function} sortFn 对请求结果进行排序的函数
 * @param {number} pickNumber 迭代器每次返回的元素数量
 * @return {Iterator} 合并后的异步迭代器
 */
export default async function* mixLoader(iterators, sortFn, pickNumber) {

}

实现

mixLoader取意是混合的加载器(老实说,并不是一个非常合适的名字),这个函数我做了一版最简单的实现,后续 @STLighter 帮我从算法层面上进行了多次优化,在此非常感谢~~

结语

  • 还请注意,如果是有跳页需求的话,就不能这么封装了
  • 除了更好的抽象带来的可读性,代码也变得更加容易测试了

LeuisKen
2.3k 声望1.2k 粉丝

专注WEB前端开发,欢迎加入前端自学互助交流群:240528099。