New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

异步迭代器在业务中的实践 #5

Open
LeuisKen opened this Issue Apr 20, 2018 · 0 comments

Comments

Projects
None yet
1 participant
@LeuisKen
Owner

LeuisKen commented Apr 20, 2018

什么是异步迭代器

关注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 帮我从算法层面上进行了多次优化,在此非常感谢~~

结语

  • 还请注意,如果是有跳页需求的话,就不能这么封装了
  • 除了更好的抽象带来的可读性,代码也变得更加容易测试了
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment