前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

ES6系列之Generator函数的暂停与自动执行

首页2018-12-21 16:20:31Front-End
ES6JavaScriptGenerator异步编程

第一次看到 function* 这个写法的人,多半会卡在同一个地方,就是「函数怎么可能停在中间」。普通函数一旦调用就一路跑到 return,中途谁也拦不住。Generator 打破的正是这条规则,它把一个函数切成好几段,每段之间由外部决定什么时候继续。

理解了这件事,后面很多东西才能串起来。async/await 为什么被叫做语法糖,co 模块在做什么,Redux-Saga 那套 yield put() 的写法凭什么能测试,源头都在这里。这篇把 function*、yield、next 三个关键字的配合关系拆开讲,重点放在「谁在控制执行权」这条主线上,最后落到一个能自动跑异步流程的执行器上。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • function*、yield、next 三者到底各自负责什么
  • 一次 next() 调用在引擎里走过的完整路径
  • yield 表达式的返回值从哪里来,next(data) 传参的意义
  • yield* 委托的真实语义,以及它等价于哪段 for...of
  • 用 Generator 把异步写成同步样子的三类典型场景
  • 手写一个 run 自动执行器,理解 async/await 的原型
  • 在任意对象上部署 Iterator 接口

# 一、什么是 Generator 函数

# 1.1 三个关键字

学习 Generator 语法,你需要先了解 function*、yield、next 三个基本概念。

function* 用来声明一个函数是生成器函数,它比普通的函数声明多了一个 *。* 的位置比较随意,可以挨着 function 关键字,也可以挨着函数名,两种写法引擎都认。团队里统一一种就行,我个人习惯 function* name() 这种,视觉上 * 跟 function 绑在一起,更像是在修饰「这是一种特殊的函数」。

yield 是产出的意思,这个关键字只能出现在生成器函数体内。生成器中也可以一个 yield 都没有,那样它就是一个调一次 next() 直接结束的生成器。函数遇到 yield 的时候会暂停,并把 yield 后面的表达式结果抛出去。

next 的作用是把代码的控制权交还给生成器函数。

这三个凑在一起,就构成了一套很朴素的协作关系:生成器函数负责标记「哪里可以停」,外部代码负责决定「什么时候继续」。

// 声明生成器函数
function* generator() {
    // A
    yield 'foo'
    // B
}
// 获取生成器对象
let g = generator();
// 第一个 next(),首次启动生成器
g.next(); // {value: "foo", done: false}
// 唤醒被 yield 暂停的状态
g.next();
// {value: undefined, done: true}
@前端进阶之旅: 代码已经复制到剪贴板

这里有个细节很多人第一次会漏掉:generator() 这一行并不执行函数体,注释 A 处的代码此时一行都没跑。它只是返回了一个生成器对象。真正启动执行的是第一次 next()。

# 1.2 一次调用走了哪几步

拿一个更完整的例子来跟踪执行流程。

// 分析一个简单例子
function* helloGenerator() {
   yield "hello";
   yield "generator";
   return;
}

var h = helloGenerator();

console.log(h.next()); // { value: 'hello', done: false }
console.log(h.next()); // { value: 'generator', done: false }
console.log(h.next()); // { value: undefined, done: true }
@前端进阶之旅: 代码已经复制到剪贴板

拆开看是这样的:

  • 创建了 h 对象,它是指向 helloGenerator 执行状态的一个句柄
  • 第一次调用 next(),执行到 yield "hello",暂缓执行,并返回了 "hello"
  • 第二次调用 next(),从上次暂停的地方继续,执行到 yield "generator",暂缓执行,并返回了 "generator"
  • 第三次调用 next(),直接执行 return,并返回 done: true,表明结束

原文这里第三次的输出写成了 { value: 'undefined', done: true },带引号。实际返回的是原始值 undefined,不是字符串 "undefined",上面我已经改过来了。这种细节在面试手写输出结果的时候会被抓住。

经过上面的分析,yield 实际就是暂缓执行的标示,每执行一次 next(),相当于指针移动到下一个 yield 位置。

Generator 函数执行流程,next 调用推动指针在 yield 之间移动

Generator 函数是 ES6 提供的一种异步编程解决方案。通过 yield 标识位和 next() 方法调用,实现函数的分段执行。

不过有一点要说清楚,Generator 本身跟异步没有任何关系。它只提供「暂停」和「恢复」这两个能力,是纯同步的语言特性。之所以能拿来处理异步,是因为有人在暂停的间隙里塞进了异步操作,等操作完成再调 next() 恢复。这个「有人」就是后面要讲的执行器。

# 1.3 yield 表达式与 yield*

yield 是 Generator 函数暂缓执行的标识,只能配合 Generator 函数使用,在普通的函数中使用会报错。

Generator 函数中还有一种 yield* 表达方式。

function* foo(){
   yield "a";
   yield "b";
}
function* gen(x, y){
   yield 1;
   yield 2;
   yield* foo();
   yield 3;
}
var g = gen();
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: "a", done: false}
console.log(g.next()); // {value: "b", done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}
@前端进阶之旅: 代码已经复制到剪贴板

原文这段的输出注释写错了,把 yield* foo() 产出的三次都标成了 done: true。这是不对的。done 只有在生成器整个函数体执行完(走到 return 或者函数尾)之后才会变成 true。委托给 foo 的过程中外层 gen 显然还没结束,done 一直是 false。上面的输出我按实际行为重新标了一遍,最后补了一次拿到 {value: undefined, done: true} 的调用。

当执行 yield* 时,实际是遍历后面那个可迭代对象,等价于下面的写法:

function* foo(){
   yield "a";
   yield "b";
}
function* gen(x, y){
    yield 1;
    yield 2;

    for (var value of foo()) {
      yield value;
    }

    yield 3;
}
@前端进阶之旅: 代码已经复制到剪贴板

这里再纠正原文一句话。原文写的是「yield 后面只能适配 Generator 函数」,这个说法两处都不准确。

yield 后面可以跟任何表达式,数字、字符串、Promise、函数调用都行,没有任何限制。有限制的是 yield*,它后面要跟一个可迭代对象,也就是实现了 Symbol.iterator 的东西。Generator 对象当然是可迭代的,但数组、字符串、Map、Set 同样可以:

function* gen() {
  yield* [1, 2];      // 依次产出 1、2
  yield* 'ab';        // 依次产出 'a'、'b'
  yield* new Set([9]); // 产出 9
}
console.log([...gen()]); // [1, 2, 'a', 'b', 9]
@前端进阶之旅: 代码已经复制到剪贴板

for...of 那个等价写法还有个不完全等价的地方,yield* 会把被委托生成器的 return 值作为整个 yield* 表达式的值返回,for...of 循环拿不到这个值。这个差别平时用不上,但在写嵌套流程的时候会咬人。

# 1.4 next 传参:真正的双向通信

前面讲的都是数据从生成器里往外流。反过来也可以,next(data) 传进去的参数,会成为上一个 yield 表达式的返回值。

function* echo() {
  const a = yield 'first';
  console.log('收到', a);
  const b = yield 'second';
  console.log('收到', b);
}

const it = echo();
it.next();       // {value: 'first', done: false},第一次 next 的参数会被丢掉
it.next('A');    // 打印「收到 A」,返回 {value: 'second', done: false}
it.next('B');    // 打印「收到 B」
@前端进阶之旅: 代码已经复制到剪贴板

第一次 next() 的参数是没有意义的,因为那时候还没有任何一个 yield 在等着接收值,函数体还没开始跑。我一开始也在这里绕过弯,写了个 it.next(初始值) 死活拿不到。

这个双向通道就是 Generator 能处理异步的关键。异步结果通过 next(result) 送回函数体内部,看起来就像是 yield 语句「返回」了异步的结果,同步写法的假象就是这么造出来的。

# 二、Generator 的应用场景

# 2.1 异步操作的同步化表达

Generator 函数暂停执行的效果,意味着可以把异步操作写在 yield 表达式里面,等到调用 next 方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在 yield 表达式下面,反正要等到调用 next 方法时再执行。所以 Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数。

function* loadUI() {
  showLoadingScreen();
  yield loadUIDataAsynchronously();
  hideLoadingScreen();
}
var loader = loadUI();
// 加载 UI
loader.next()

// 卸载 UI
loader.next()
@前端进阶之旅: 代码已经复制到剪贴板

上面代码中,第一次调用 loadUI 函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器调用 next 方法,则会显示 Loading 界面,并且异步加载数据。等到数据加载完成,再一次使用 next 方法,则会隐藏 Loading 界面。

这种写法的好处是所有 Loading 界面的逻辑都被封装在一个函数里,按部就班非常清晰。以前写这类逻辑要在请求发起前 show、在成功回调里 hide、在失败回调里再 hide 一次,三处代码分散在不同的地方,漏一处就是 loading 转圈转到天荒地老。

通过 Generator 函数部署 Ajax 操作,也可以用同步的方式表达。

fe
  • 一、什么是 Generator 函数
    • 1.1 三个关键字
    • 1.2 一次调用走了哪几步
    • 1.3 yield 表达式与 yield*
    • 1.4 next 传参:真正的双向通信
  • 二、Generator 的应用场景
    • 2.1 异步操作的同步化表达
    • 2.2 控制流管理
    • 2.3 部署 Iterator 接口
  • 三、现在还该用 Generator 吗
  • 总结
  • 参考

← ES6系列之async await的用法与实现原理ES6系列之Symbol的唯一性与内置符号 →