JavaScript异步迭代器硬核实战:手写一个流式分页读取器

先说个让我印象深刻的场景。之前做一个小工具,要循环请求某个分页接口,每页20条,总共有几十页。当时没想太多,写了个递归函数,每拿到一页就往外面抛一个回调。后来页面多了,代码里面又嵌套了排序、过滤、去重逻辑,根本分不清数据在往哪条线上流动。差不多花了两小时才把逻辑捋清。

后来我发现用异步迭代器来处理“分页拉取”这种需求,简直顺手得不像话。数据什么时候流过来、什么时候停,都可以在一条同步风格的for循环里读明白。

所以今天就拿一个具体的例子来拆解它:用自己的异步迭代器读取分页接口。不需要任何库,一分钟就能读懂。

为什么等接口数据时要关心迭代器

普通数组是同步的可迭代对象。你想从头到尾读一遍,用for of就行。但接口返回是异步的,你不能在普通迭代器里“等一下下”再给下一个值。异步迭代器,就是专门给“下一个值不会立刻准备好”的情况准备的。

最简单理解方式:同步迭代器的next()返回{value, done},而异步迭代器的next()返回的是一个Promise,resolve后才是{value, done}

为了不用老写.then(),JavaScript 提供了for await...of语法,像同步for of一样去循环异步数据源。

先看一个极其简单的自定义异步迭代对象:

const asyncRange = {
    start: 0,
    end: 3,
    [Symbol.asyncIterator]() {
        let current = this.start;
        const end = this.end;
        return {
            next: () => {
                if (current <= end) {
                    return Promise.resolve({
                        value: current++,
                        done: false
                    });
                }
                return Promise.resolve({ done: true });
            }
        };
    }
};

(async () => {
    for await (let num of asyncRange) {
        console.log(num); // 依次输出0,1,2,3
    }
})();

你可能觉得,为了打印几个数字,搞这么复杂没必要。那我们来点真的:分页接口。

分页读取器的真实需求

假设有一个后端接口/api/users,传page和pageSize返回一段数据,返回结构如下:

{
    "code": 0,
    "data": {
        "list": ["用户A", "用户B", "..."],
        "total": 42,
        "page": 1,
        "pageSize": 20
    }
}

现在我们不想一次性把total条数据全塞到内存里。我们要一页一页拿,每拿到一页就“流式”地处理一条或者一批。这时,异步迭代器就派上用场了。

先把这个分页接口模拟出来,注意我故意用了setTimeout模拟网络延迟:

function fetchPage(page, pageSize = 10) {
    const total = 35; // 共35条,分4页
    const start = (page - 1) * pageSize;
    const end = Math.min(start + pageSize, total);
    const list = [];

    for (let i = start; i < end; i++) {
        list.push(`用户${i + 1}`);
    }

    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({
                code: 0,
                data: {
                    list,
                    total,
                    page,
                    pageSize
                }
            });
        }, Math.random() * 200 + 50);
    });
}

通常我们封装分页,可能会写一个递归函数,边拿边处理。那样比较容易犯错。改成异步迭代器以后,我们可以把“翻页”这个动作封装进一个对象里:

function createPaginatedReader({ pageSize = 10 } = {}) {
    let currentPage = 0;
    let hasMore = true;
    let buffer = [];
    let total = 0;
    let loadedCount = 0;

    return {
        // 关键点:给这个普通对象加上异步迭代器方法
        [Symbol.asyncIterator]() {
            return {
                next: async () => {
                    // 如果当前缓冲里没数据了,去请求下一页
                    if (buffer.length === 0 && hasMore) {
                        const page = currentPage + 1;
                        const res = await fetchPage(page, pageSize);
                        total = res.data.total;
                        buffer = res.data.list;
                        hasMore = page * pageSize < total;
                        currentPage = page;
                    }

                    if (buffer.length > 0) {
                        loadedCount++;
                        return {
                            value: buffer.shift(),
                            done: false
                        };
                    }

                    // 没有更多数据,结束迭代
                    return { done: true };
                }
            };
        }
    };
}

这代码乍一看有点长,其实很好懂。它维护了一个内部缓冲区。当buffer没有数据时,就异步去拉下一页。只要mysql有值,就从头部弹出一条返回。弹完这一页,下次next()发现buffer空了,又自动拉下一页。

使用起来非常舒服:

(async () => {
    const reader = createPaginatedReader({ pageSize: 10 });

    for await (const user of reader) {
        console.log('处理:', user);
        // 这里可以做一些同步/异步的处理
        // 比如 await saveToDatabase(user)
    }

    console.log('所有数据都读完了');
})();

运行结果大概是按顺序“处理: 用户1”、“处理: 用户2” …… 一直到“用户35”。也就是说,分页细节完全从主流程里消失了。

用“异步生成器”能更简洁

如果你的翻页逻辑比较直接,没必要再单独维护buffer,可以用异步生成器函数让代码缩短一半。异步生成器就是async function加个星号,内部可以yield值,Promise可以自动等。

async function* paginateUsers(pageSize = 10) {
    let page = 1;
    let total = Infinity;

    while (page * pageSize < total + pageSize) {
        const res = await fetchPage(page, pageSize);
        const { list, total: totalCount } = res.data;
        total = totalCount;

        // 逐个吐出当前页的用户
        for (const user of list) {
            yield user;
        }

        page++;
        // 如果这一页不满pageSize,说明到末尾了,直接停
        if (list.length < pageSize) break;
    }
}

这段逻辑比刚才手工实现迭代器要直观得多。使用它也一样:

(async () => {
    for await (const user of paginateUsers(10)) {
        console.log(user);
    }
})();

异步生成器是一块语法糖。它的运行方式类似状态机,每次执行到yield就会暂停,等外面索取下一个值时再往后跑。而且它天然支持异常处理,你在异步生成器里写try/catch,可以捕获到整个迭代过程中的异常。

如果我想中途停掉不读了,怎么办

for await...of时,只要遇到break、return 或throw,JavaScript会去调用迭代器的return()方法。异步生成器里,这相当于执行到finally块。

比如只取前5个用户:

(async () => {
    for await (const user of paginateUsers(10)) {
        console.log(user);
        if (user === '用户5') {
            break; // 不再往后翻页了
        }
    }
})();

注意,加了break以后,后面几页的数据其实不会被请求。具体原因说一下:for await调用了break以后,会调用异步生成器的return()方法,异步生成器内部在yield点抛出一个“return”的控制信号,让生成器执行完finally块并结束。所以循环外层的while也不会继续跑了。这在拉取大量数据时能节约不少接口请求。

如果是手动调reader.next(),那也只需不去调下一次next即可,想中断拉取也处于停止状态。

实战中的应用:Node.js 流式读取文件

分页接口只是其中一个小例子。实际上Node.js内置了很多异步迭代器。比如文件流:

import { createReadStream } from 'fs';

async function processFile(path) {
    const stream = createReadStream(path, { encoding: 'utf8' });

    // createReadStream返回的也是异步可迭代对象
    for await (const chunk of stream) {
        // 每个chunk是一段Buffer/字符串,可以在这里做分块处理
        console.log('收到块', chunk.length);
    }
}

这样不用把整个文件装进内存,也能逐块处理。

还有Readable.from,把数组转换成异步迭代流,中间再经过Transform处理都很方便。不过话题扯远了,重点是:for await已经把“异步数据流”变成了标准的循环抽象。

原生对象到底能不能直接 for await?

很多初学者会踩这个坑:

const obj = {};
for await (const x of obj) {
    // 这里会报错 obj is not async iterable
}

对象必须实现了[Symbol.asyncIterator]方法才可以。普通数组虽然是可以同步迭代的,但你不能直接在普通数组上用for await吗?其实是可以的!因为for await会先检查是不是异步可迭代,如果不是,退而求其次会把它当成同步可迭代来处理,然后自动把value包装成Promise。for await...of是允许循环同步可迭代对象的。

但反过来,你绝对不能在一个异步可迭代对象上用普通for of,因为for of会去取[Symbol.iterator]方法,异步对象没有。

这一点在实际项目中搞混了会报怪错:

  • 如果是异步可迭代,等它.next()返回的是Promise,for of根本不会等。
  • 如果同步可迭代,用for await会多一层没用包装,但不会报错。

一个让人主动封装的小模式

有的项目里接口翻页有个特殊逻辑:如果当前页的数据量刚好等于pageSize,并不能说明还有更多,因为可能最后一页刚好满员。需要额外传一个“是否还有下一页”的标记。

所以有时候会看到接口返回类似hasMore: true这种字段。那就可以把异步生成器里那个“list.length < pageSize”判断改为接口的hasMore字段。

async function* customPager() {
    let page = 1;
    let hasMore = true;
    while (hasMore) {
        const res = await fetchSomething({ page });
        yield* res.data.list; // yield* 可以展开一个数组
        hasMore = res.data.hasMore;
        page++;
    }
}

记住少做“把所有页的数据装到一个数组再循环”的傻事。用异步生成器,数据到了就处理,内存占用非常稳定。上面那个例子中,即使total有几百万条,你依然只用处理当前的一条/块,不会卡死进程。

异常处理有一点点不同

在异步生成器里面,如果fetchPage抛错了,错误会顺着for await的循环抛出来。可以用try/catch捕获:

try {
    for await (const user of paginateUsers(10)) {
        console.log(user);
    }
} catch (err) {
    console.error('抓取过程出错:', err);
}

但有个特别容易忽略的点:如果在普通异步可迭代对象的next()里抛错,上一次迭代可能已经消耗掉一页数据。而异步生成器在yield抛出异常时,仍然可以恢复,但如果你不捕获,它就会自动中断。所以建议在生成器内部做好局部重试,避免整条流停掉。

和我之前用递归翻页的对比

以前写分页拉取,大概率是这种模式:

function loadPage(page, callback) {
    fetchPage(page).then(res => {
        callback(res.data.list);
        if (page * pageSize < total) {
            loadPage(page + 1, callback);
        } else {
            console.log('全部完成');
        }
    });
}

回调版本的问题在于:你很难在中间“停一下”处理其他事情。比如你需要在取到第5条时把之前的数据去重,那必须把去重逻辑放进回调里,一层层嵌套。而异步生成器让你用直线式的思维去写。

还有就是我以前尝试过用数组push所有页的数据,但遇到大接口就内存爆炸。这个做法也不可取。

最后的思考

异步迭代器在JavaScript里不算是什么新东西,但很多前端同学并不熟悉它。可能是因为平时业务多以“一次性请求”为主,一次拿完整数组,前端顶多用个map、filter。

但是一旦遇到流式数据、长分页、大文件、SSE推送这类场景,异步迭代器就是最顺手的那把刀。以后你想写一个“边拉取边显示进度”的滚动列表,不需要再维护一堆外部状态了,试试把请求源做成一个异步迭代器,你会回来感谢我的。

建议你现在打开编辑器,把上面的paginateUsers跑一遍,再改一改,改成你自己的接口。实际跑通一次就会明白,原来处理异步数据流也能这么舒服。

JavaScript异步迭代器硬核实战:手写一个流式分页读取器
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript JavaScript异步迭代器硬核实战:手写一个流式分页读取器 https://www.taomawang.com/web/javascript/2723.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务