迭代器、生成器与异步迭代——让数据“按需流动”的艺术

一、迭代器:统一数据访问的"遥控器"

1.1 for...of 为什么能遍历这么多类型?

我们肯定写过这样的代码:

javascript

ini 复制代码
const arr = ['a', 'b', 'c'];
for (const item of arr) {
  console.log(item);
}

for...of 不仅能遍历数组,还可以遍历 MapSetStringNodeList,甚至是 arguments 对象。到底是什么在背后支撑这种"统一"的能力?

答案就是迭代器模式 。你可以把迭代器想象成一个"遥控器":不管背后是电视、空调还是投影仪,遥控器上的"下一个频道"按钮的用法是一样的。JavaScript 中的迭代器就是一个对象,它提供了一个 next() 方法,每次调用都会返回下一个值。

for...of 怎么知道一个对象能不能被遍历呢?它看的是这个对象有没有实现 Symbol.iterator 方法。如果有,for...of 就调用它拿到"遥控器"(迭代器),然后反复按 next() 按钮获取下一个值,直到遥控器说"没了"(done: true)。

1.2 解剖一个迭代器:看看 next() 到底返回了什么

先来看看数组默认的迭代器是怎么工作的:

javascript

ruby 复制代码
const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator](); // 拿到遥控器

console.log(iterator.next()); // { value: 'a', done: false }  ← 第一个频道
console.log(iterator.next()); // { value: 'b', done: false }  ← 第二个频道
console.log(iterator.next()); // { value: 'c', done: false }  ← 第三个频道
console.log(iterator.next()); // { value: undefined, done: true } ← 没频道了

每次调用 next(),都会得到一个对象:{ value, done }value 是当前的值,done 表示遍历是否结束。当你手动调用 next() 时,你完全控制了遍历节奏------这就是"按需消费"的核心。

二、生成器:一个可以"暂停"的函数

2.1 普通函数 vs 生成器函数

普通函数一旦执行,就像一列火车从起点开到终点,中途不停。但有时候我们希望函数能"暂停一下",比如:

  • 处理一批数据,每处理 100 条暂停一次,把主线程让给用户交互。
  • 串行调用多个接口,每个接口的结果决定下一个接口要不要发。

这时候就需要生成器函数 了。生成器函数在 function 后面加个 *,在函数体里用 yield 关键字设置"暂停点":

javascript

javascript 复制代码
function* simpleGenerator() {
  console.log('第一步:开始执行');
  const a = yield 1;        // 暂停点一:向外输出 1,等待外部传入值
  console.log('第二步:接收到外部传入的', a);
  const b = yield 2;        // 暂停点二:向外输出 2,等待外部传入值
  console.log('第三步:接收到外部传入的', b);
  return 3;                 // 结束:返回 3
}

const gen = simpleGenerator();

console.log(gen.next());      // 第一步:开始执行 → { value: 1, done: false }
console.log(gen.next('A'));   // 第二步:接收到外部传入的 A → { value: 2, done: false }
console.log(gen.next('B'));   // 第三步:接收到外部传入的 B → { value: 3, done: true }

执行过程就像一个对话:

  1. 第一次 gen.next() :函数开始执行,跑到第一个 yield 1,暂停,把 1 还给调用方。
  2. 第二次 gen.next('A') :函数从上次暂停处恢复,'A' 作为 yield 的返回值赋给 a,继续跑到 yield 2,把 2 还给调用方。
  3. 第三次 gen.next('B') :函数恢复,'B' 赋给 b,继续执行到 return 3,结束。

生成器本质上是一个双向通道yield 向外"吐"值,next(arg) 向内"喂"值。这种暂停和恢复的能力,让它在处理异步流程时特别有用。

2.2 用生成器串联异步任务

假设页面初始化需要串行执行三个异步任务,每个都依赖前一个的结果:

  1. 获取用户信息
  2. 根据用户 ID 查权限
  3. 根据权限拉取菜单

传统写法容易陷入嵌套,用生成器可以让代码看起来像"同步"的:

javascript

javascript 复制代码
function* initFlow() {
  const user = yield fetch('/api/user').then(r => r.json());
  console.log('用户信息', user);

  const permissions = yield fetch(`/api/permissions?userId=${user.id}`).then(r => r.json());
  console.log('权限', permissions);

  const menu = yield fetch(`/api/menu?role=${permissions.role}`).then(r => r.json());
  console.log('菜单', menu);

  return menu;
}

但这只是个"声明",还需要一个执行器来自动驱动它:

javascript

scss 复制代码
function runGenerator(genFunc) {
  const gen = genFunc();

  function handleNext(value) {
    const result = gen.next(value);
    if (result.done) {
      return Promise.resolve(result.value);
    }
    return Promise.resolve(result.value).then(handleNext);
  }

  return handleNext();
}

runGenerator(initFlow).then(menu => {
  console.log('初始化完成,最终菜单:', menu);
});

runGenerator 的逻辑很简单:

  1. 调一次 gen.next(),拿到 yield 出的 Promise。
  2. 等 Promise 完成,把结果通过 gen.next(result) 传回生成器内部。
  3. 重复以上两步,直到 done: true

这个迷你执行器其实就是一个简化版的 async/await 引擎 !ES2017 的 async/await 正是生成器 + Promise 的语法糖(在底层用类似的机制实现了异步函数)。理解了这一点,你就知道 await 为什么能"暂停"异步函数而不阻塞主线程了------它本质上就是生成器的暂停恢复机制。


三、生成器在前端框架中的实战:Redux-Saga 与 dva

3.1 Redux-Saga:生成器管理副作用

Redux-Saga 是 Redux 生态中经典的副作用管理中间件。它的核心就是利用生成器函数:

javascript

php 复制代码
function* fetchUserSaga(action) {
  try {
    const user = yield call(api.fetchUser, action.payload.userId);
    yield put({ type: 'USER_FETCH_SUCCEEDED', user });
  } catch (e) {
    yield put({ type: 'USER_FETCH_FAILED', message: e.message });
  }
}

这里的 callput 并不是真正的异步调用,它们只是普通的 JavaScript 对象(称为 Effect),描述了"我想要做什么"。Saga 中间件内部有一个类似我们上面写的 runGenerator 的执行器,每当生成器 yield 出一个 Effect 对象,就根据类型去执行真正的操作,然后把结果通过 gen.next(result) 传回去。

生成器给 Redux-Saga 带来的核心优势:

  • 副作用与组件分离:所有异步流程集中在 saga 文件里。
  • 可取消 :通过 takeLatest 等 Effect,自动取消上一次未完成的请求,解决"搜索竞态"问题。
  • 可测试 :单元测试时直接断言 gen.next().value 的结构,不需要 mock 网络请求。

3.2 dva:很多前端第一次真正写生成器,就是因为它

dva 是蚂蚁金服出品的轻量级前端框架,它的 Model 层直接内置了 Redux-Saga:

javascript

javascript 复制代码
export default {
  namespace: 'user',
  state: { list: [], loading: false },

  reducers: {
    save(state, { payload }) {
      return { ...state, list: payload };
    },
  },

  effects: {
    *fetchUsers({ payload }, { call, put }) {
      yield put({ type: 'setLoading', payload: true });
      try {
        const users = yield call(api.fetchUsers, payload);
        yield put({ type: 'save', payload: users });
      } catch (e) {
        console.error('获取用户列表失败', e);
      } finally {
        yield put({ type: 'setLoading', payload: false });
      }
    },
  },
};

几年前,dva曾经是很多团队的首选框架。大量前端开发者第一次在生产项目中真正接触生成器,就是在 dva 或 Redux-Saga 的场景下。 虽然现在 React Hooks 普及后,useEffect + async/await 覆盖了大部分组件级副作用,这两个库的使用量在下降,但如果你维护老项目或加入还在使用这些技术栈的团队,理解生成器就是理解项目核心数据流的关键。

四、异步迭代:让异步数据"边走边加载"

4.1 场景:逐页拉取接口数据

前面我们讲的 Symbol.iterator 和生成器都是同步的。但如果数据源是异步的------比如一个分页接口,每一页都需要等网络请求返回------普通的迭代器就不够用了。

假设我们需要逐页拉取几百页用户数据,传统写法需要手动管理页码:

javascript

ini 复制代码
let page = 1;
let hasMore = true;
while (hasMore) {
  const res = await fetch(`/api/users?page=${page}`);
  const data = await res.json();
  // 处理 data.items...
  hasMore = data.hasMore;
  page++;
}

这个逻辑散落在业务代码里,不易复用。

4.2 异步生成器:把"拉取"封装起来

ES2018 引入了异步生成器async function*),它既可以 yield 值,又可以用 await 等待异步操作。我们可以把分页拉取封装成一个异步生成器:

javascript

ini 复制代码
async function* fetchAllUsers() {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const res = await fetch(`/api/users?page=${page}&size=20`);
    const data = await res.json();

    for (const user of data.items) {
      yield user; // 每次产出 1 个用户
    }

    hasMore = data.hasMore;
    page++;
  }
}

消费方用 for await...of 遍历,就像遍历一个普通数组一样:

javascript

arduino 复制代码
for await (const user of fetchAllUsers()) {
  console.log('加载用户:', user.name);
  // 可在此逐个渲染 DOM,或做其他处理
}

关键点for await...of 在每次 yield 后自动暂停,等下一轮循环需要下一个值时才继续。你不必手动管理页码,也不必一次性把所有数据加载到内存。这就是按需加载的优雅实现。

4.3 实战场景:大文件分片上传

大文件上传需要切片、逐片发送,还可以支持暂停和断点续传。用异步生成器管理这个流程非常自然:

javascript

javascript 复制代码
async function* sliceFile(file, chunkSize = 1024 * 1024) {
  let offset = 0;
  while (offset < file.size) {
    const chunk = file.slice(offset, offset + chunkSize);
    offset += chunkSize;
    yield chunk; // 依次产出每个分片
  }
}

async function uploadFile(file) {
  const totalChunks = Math.ceil(file.size / (1024 * 1024));
  let uploaded = 0;

  for await (const chunk of sliceFile(file)) {
    const form = new FormData();
    form.append('chunk', chunk);
    form.append('index', uploaded);
    await fetch('/api/upload', { method: 'POST', body: form });
    uploaded++;
    console.log(`进度: ${uploaded}/${totalChunks}`);
  }

  console.log('上传完成');
}
  • 切片逻辑sliceFile)和上传逻辑uploadFile)完全分离,各司其职。
  • 如果用户点击"暂停",只需在 for await...of 循环内检查一个标志位,用 break 跳出,保存当前 uploaded 值即可。
  • 下次恢复时,从上次断点继续,不需要维护复杂的全局状态和回调链。

4.4 可取消的流式加载

配合 AbortController,异步生成器还能优雅地响应取消信号:

javascript

javascript 复制代码
async function* loadWithCancel(url, signal) {
  let page = 1;
  while (true) {
    if (signal.aborted) return;

    const res = await fetch(`${url}?page=${page}`, { signal });
    const data = await res.json();

    if (!data.items.length) break;

    for (const item of data.items) {
      yield item;
    }
    page++;
  }
}

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5 秒后取消

for await (const item of loadWithCancel('/api/data', controller.signal)) {
  console.log(item);
}

五、总结

迭代器和生成器,核心解决的都是数据流的控制问题

  • 迭代器 统一了数据访问方式,让任何数据结构都能用 for...of 遍历。
  • 生成器 让函数可以暂停和恢复,是 async/await 的底层引擎,也是 Redux-Saga 和 dva 的核心机制。
  • 异步生成器 将"异步"和"按需消费"结合,适合处理逐步产出的异步数据。

我们不需要在业务代码里到处用生成器,但掌握以下三个"最小核心知识"还是很有必要的。

  1. 会用 for...of 遍历数组,知道它背后靠的是 Symbol.iterator
  2. 能看懂生成器的 function*yield 语法,知道它能让函数暂停。
  3. 见过 for await...of 的写法,知道它是用来处理异步流式数据的。

这些知识会帮助我们更好地理解 async/await 的底层原理,以及 Redux-Saga 等状态管理库的设计思路。当遇到需要"逐步消费一个异步数据源"的场景时,异步生成器几乎总是最佳答案。

相关推荐
xiaodaoluanzha1 小时前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn1 小时前
Fetch 请求竞态终结者:AbortController 不只是用来"取消"的
前端
阡陌Jony1 小时前
关于前端路由中的参数问题的学习(一): params,query, hash(#)
前端
阡陌Jony1 小时前
缓存相关学习笔记(一):Service Worker 缓存
前端
假如让我当三天老蒯1 小时前
前端跨域解决方案(学习用)
前端·javascript·面试
阡陌Jony1 小时前
关于前端路由中的参数问题的学习(二)
前端
IT_陈寒2 小时前
SpringBoot自动配置这个坑,我踩进去又爬出来了
前端·人工智能·后端
铁皮饭盒3 小时前
Bun 哪比 Node.js 快?
javascript·后端
JieE21211 小时前
LeetCode 56. 合并区间|超清晰 JS 图解思路,面试高频区间题
javascript·算法·面试