• home > webfront > ECMAS > javascript >

    从Iterator到Generator与:手搓generator来理解Async/Await风靡前端

    Author:[email protected] Date:

    Promise的出现,让程序员告别了痛苦的回调地狱,generator,让异步代码像同步代码一样执行,Generator其是 Iterator的简化版,而async await使你不必再困扰于异步任务间的顺序问题,而能够更专注于里头的逻辑部分。

    从《ECMAScript进化史(1):话说Web脚本语言王者JavaScript的加冕历史》看,JavaScript是一门很弱鸡的语言。所以,ECMAScript标准的演进目标之一就是不断提高语言的表达力和处理数据的能力,Iterator(迭代器)和后来的Generator(生成器)极大地增强了JavaScript处理集合数据的能力

    Iterator

    同样先看MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Iterator

    Iterator 对象是一个符合迭代器协议的对象,其提供了 next() 方法用以返回迭代器结果对象。所有内置迭代器都继承自 Iterator 类。Iterator 类提供了 @@iterator 方法,该方法返回迭代器对象本身,使迭代器也可迭代。它还提供了一些使用迭代器的辅助方法。


    迭代器协议:

    可迭代协议允许 JavaScript 对象定义或定制它们的迭代行为,例如,在一个 for..of 结构中,哪些值可以被遍历到。

    要成为可迭代对象,该对象必须实现 @@iterator 方法,这意味着对象(或者它原型链上的某个对象)必须有一个键为 @@iterator 的属性,可通过常量 Symbol.iterator 访问该属性。

    一些内置类型同时是内置的可迭代对象,原生具备 Iterator 接口的数据结构如下。

    • Array

    • Map

    • Set

    • String

    • TypedArray

    • 函数的 arguments 对象

    • NodeList 对象

    下面的例子是数组的Symbol.iterator属性。

    let arr = ['a', 'b'];
    let iter = arr[Symbol.iterator]();
    
    iter.next() // { value: 'a', done: false }
    iter.next() // { value: 'b', done: false }
    iter.next() // { value: undefined, done: true }

    普通的对象(例如{a:1, b:2}这样的字面量对象)并不是可迭代对象,因为它们默认并不实现Symbol.iterator接口。自ES2017起,Object.entries()和Object.values()正式成为了标准,提供了一种直接遍历对象属性的便捷方式

    迭代器协议

    只有实现了一个拥有以下语义(semantic)的 next() 方法,一个对象才能成为迭代器

    next()

    无参数或者接受一个参数的函数,并返回符合 IteratorResult 接口的对象(见下文)。如果在使用迭代器内置的语言特征(例如 for...of)时,得到一个非对象返回值(例如 false 或 undefined),将会抛出 TypeError("iterator.next() returned a non-object value")。

    所有迭代器协议的方法(next()、return() 和 throw())都应返回实现 IteratorResult 接口的对象。它必须有以下属性:

    done(可选)

    如果迭代器能够生成序列中的下一个值,则返回 false 布尔值。(这等价于没有指定 done 这个属性。)

    如果迭代器已将序列迭代完毕,则为 true。这种情况下,value 是可选的,如果它依然存在,即为迭代结束之后的默认返回值。

    value(可选)

    迭代器返回的任何 JavaScript 值。done 为 true 时可省略。

    return(value) (可选)

    无参数或者接受一个参数的函数,并返回符合 IteratorResult 接口的对象,其 value 通常等价于传递的 value,并且 done 等于 true。调用这个方法表明迭代器的调用者不打算调用更多的 next(),并且可以进行清理工作。

    throw(exception) (可选)

    无参数或者接受一个参数的函数,并返回符合 IteratorResult 接口的对象,通常 done 等于 true。调用这个方法表明迭代器的调用者监测到错误的状况,并且 exception 通常是一个 Error 实例。

    迭代器DEMO

    让我们构建一个简单的迭代器作为示例,这个迭代器将模拟一个掷骰子的游戏。游戏规则是:

    • 连续掷骰子直到总和达到或超过20。

    • 如果某一次掷骰得到1,则游戏立即结束,并抛出错误表示失败。

    • 如果成功达到或超过20而没有掷出1,则游戏成功结束。

    function createDiceGameIterator() {
      let sum = 0;
    
      return {
        next() {
          if (sum >= 20) {
            return { value: sum, done: true };
          } else {
            const roll = Math.floor(1 + Math.random() * 6);
            sum += roll;
            if (roll === 1) {
              throw new Error("Game over: Rolled a 1");
            }
            return { value: roll, done: false };
          }
        },
        [Symbol.iterator]() { return this; },
      };
    }
    
    // 创建游戏迭代器
    const game = createDiceGameIterator();
    
    try {
      // 迭代游戏过程
      for (let roll of game) {
        console.log(`Rolled: ${roll} | Total: ${game.next().value}`);
      }
    } catch (error) {
      console.error(error.message);
    }
    • 迭代器协议:我们的createDiceGameIterator函数返回一个对象,这个对象有一个next方法和一个Symbol.iterator方法,使其符合迭代器协议和可迭代协议。这样可以直接在for...of循环中使用。

    • next方法:每次调用返回当前掷骰子的结果,并更新总和。如果总和达到20,迭代完成。

    • done属性:返回true表示迭代完成,这里当掷骰子的总和达到或超过20时发生。

    • throw:如果掷骰子得到1,通过抛出错误来立即终止迭代。注意这不是迭代器协议的一部分,而是这个示例特有的逻辑,用于处理特定情况。

    其是,大部分情况只需next即可

    // 创建一个可迭代对象来生成斐波那契数列
    const fibonacciIterable = {
      // 实现 [Symbol.iterator] 方法,使对象可迭代
      [Symbol.iterator]() {
        let a = 1, b = 1;
        return {
          // 迭代器协议要求提供 next 方法
          next() {
            let returnValue = a;
            [a, b] = [b, a + b];
            // next 方法需要返回一个包含 value 和 done 属性的对象
            // 在这个例子中,我们将永远返回 {done: false},因为斐波那契数列是无限的
            return { value: returnValue, done: false };
          }
        };
      }
    };
    
    // 使用 for...of 循环来迭代斐波那契数列的前10个数
    for (const num of fibonacciIterable) {
      console.log(num);
      if (num > 50) break; // 给循环一个终止条件以避免无限循环
    }

    在上面的代码中next函数的实现是迭代器的核心,但是每次都要手动实现,生成器的出现就是为了更简单的使用迭代器

    Generator

    Generator是一个对象,是由生成器函数 (generator function)返回的,并且它符合可迭代协议和迭代器协议

    generator function函数是在普通的函数名称前加一个*号,且函数内部使用yeild关键词定义函数断点,让函数从上到下分批次执行并返回值。

    function* loggerator() {
      console.log('开始执行');
      yield '暂停';
      console.log('继续执行');
      return '停止';// 如果没有
    }
    
    let logger = loggerator();
    console.log(logger.next()); // 开始执行 { value: '暂停', done: false }
    console.log(logger.next()); // 继续执行 ,{ value: '停止', done: true }
    console.log(logger.next());//  { value: undefined,, done: true }

    看下MDN解释:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/function*

    function* 声明创建一个绑定到给定名称的新生成器函数。生成器函数可以退出,并在稍后重新进入,其上下文(变量绑定)会在重新进入时保存。

    function* idMaker() {
      let index = 0;
      while (index<10) {
        yield index++;
      }
    }
    
    const gen = idMaker();
    
    console.log(gen.next().value); // 0
    console.log(gen.next().value); // 1
    console.log(gen.next().value); // 2
    console.log(gen.next().value); // 3

    function* 声明创建一个 GeneratorFunction 对象。每次调用生成器函数时,它都会返回一个新的 Generator 对象,该对象符合迭代器协议。当迭代器的 next() 方法被调用时,生成器函数的主体会被执行,直到遇到第一个 yield 表达式,该表达式指定了迭代器要返回的值,或者用 yield* 委托给另一个生成器函数。next() 方法返回一个对象,其 value 属性包含了 yield 表达式的值,done 属性是布尔类型,表示生成器是否已经返回了最后一个值。如果 next() 方法带有参数,那么它会恢复生成器函数的执行,并用参数替换暂停执行的 yield 表达式。

    看的不明觉厉,再来看前端科普大佬,阮老师的:https://www.ruanyifeng.com/blog/2015/04/generator.html

    Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。

    function* gen(x){
      var y = yield x + 2;
      return y;
    }

    整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。

    var g = gen(1);

    注意:function* 函数并不会执行此函数,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是迭代器对象(Iterator Object)

    Generator的底层实现原理

    1. 状态机:Generator函数本质上是一个状态机,封装了多个内部状态。执行Generator函数会返回一个遍历器对象,该对象本质上是一个封装了Generator内部状态的指针对象。

    2. 函数暂停与恢复Generator函数的执行可以在yield表达式处暂停,并在稍后通过next方法恢复执行。这种暂停和恢复的能力是通过函数调用栈的操作实现的当遇到yield时,引擎会保存当前函数的上下文(包括变量状态、指令位置等),并将控制权交还给调用者。当通过next方法恢复时,引擎会恢复之前保存的上下文,并从上次暂停的位置继续执行

    3. 协程:Generator可以被看作是轻量级的协程(coroutines),它们允许多个入口点用于暂停和恢复代码执行。JavaScript引擎实现了协程的调度机制,使得Generator函数的执行可以在任意yield点暂停和恢复。

    4. 迭代器协议:Generator遵循迭代器协议,即它们的返回对象具有next方法。这个next方法返回一个对象,该对象包含两个属性:value(yield表达式产出的值)和done(一个布尔值,表示Generator是否已经产出了它的最后一个值)。

    Generator 函数通过yield关键字暂停和恢复执行,但它本身并不是专门为异步编程设计的

    Generator函数本身并不直接参与JavaScript的事件循环机制:Generator函数的执行是同步的,它的yield和next操作并不会导致JavaScript引擎将任务排入宏任务(macrotask)或微任务(microtask)队列。相反,Generator函数的控制流是由外部代码显式管理的,通常是通过调用next方法来实现。


    通过结合Promise和迭代器协议,可以使用Generator来管理异步流程。

    V8引擎中Generator函数工作原理概述

    编译阶段:

    当V8遇到一个Generator函数时,它会将这个函数编译成一系列的字节码指令。这些字节码指令包括了处理yield表达式的特殊指令,用于暂停和恢复函数执行

    函数调用栈:

    在JavaScript中,每当一个函数被调用时,一个新的帧(frame)就会被推入调用栈中。这个帧包含了函数的局部变量、参数和返回地址等信息

    对于Generator函数,当遇到yield表达式时,V8会保存当前函数帧的状态,包括局部变量、当前执行位置等信息,并将控制权返回给调用者。这个过程涉及到将函数帧的状态序列化到堆内存中,以便之后可以恢复。

    暂停和恢复:

    当外部代码通过迭代器对象调用next方法时,V8会检查是否有一个已经暂停的Generator函数帧。如果有,V8会从堆内存中反序列化该函数帧的状态,并将其推回调用栈中

    V8随后会从上次暂停的位置恢复执行字节码指令,直到遇到下一个yield表达式或函数结束。

    状态管理:

    V8内部会为每个Generator函数实例维护一个状态。这个状态表明Generator是正在执行(executing)、已经暂停(suspended)、已经完成(completed)还是出错(errored)。

    当Generator函数执行完毕或抛出错误时,V8会更新这个状态,并处理任何必要的清理工作。

    与事件循环的交互:

    Generator函数的执行本身是同步的,但它们可以用于管理异步操作。例如,你可以在yield表达式中等待一个异步操作(如Promise)的完成。

    在这种情况下,当异步操作完成时,它的回调函数(可能是微任务)会调用Generator的next方法,从而恢复Generator函数的执行。


    Generator注意事项

    next、return参数可以完成花来……

    Generator  next参数

    next方法可以带一个参数,该参数会被当作上一个yield表达式的返回值。这提供了一种在Generator的不同阶段之间进行通信的方式

    yield除了返回一个值之外,还能用来接收外界的输入。next()方法中传的第一个参数会被yield接收。

    通过Generator函数和next方法的参数在函数的内部各个阶段之间传递信息,从而实现复杂的控制流程。

    next参数的示例解释:

    function* loginFlow() {
      const username = yield '请输入用户名:';
      const password = yield '请输入密码:';
    
      if (username === 'admin' && password === '123456') {
        yield '登录成功';
      } else {
        yield '用户名或密码错误';
      }
    }
    
    const login = loginFlow(); // 初始化Generator函数,此时函数暂不执行
    
    console.log(login.next().value); // 输出:请输入用户名:
    console.log(login.next('admin').value); // 将'admin'作为上一个yield的返回值,并输出:请输入密码:
    console.log(login.next('123456').value); // 将'123456'作为上一个yield的返回值,并输出:登录成功

    Redux-Saga: 在这个用于管理应用状态的库中,使用Generator(生成器,它基于Iterator接口)来处理异步流和复杂的同步流程。

    推荐阅读:https://medium.com/@tanner.west/a-few-insights-for-better-understanding-redux-saga-and-javascript-generators-68efaef44c9e

    Generator  return参数

    return() 将会忽略生成器中的任何代码。它会根据传值设定 value,并将 done 设为 true。

    任何在 return() 之后进行的 next() 调用都会返回 done 属性为 true 的对象

    function* generatorFunction() {
      yield 1;
      yield 2;
      yield 3;
    }
    
    // 创建一个Generator对象
    const generator = generatorFunction();
    
    // 获取第一个yield的值
    console.log(generator.next()); // 输出:{ value: 1, done: false }
    
    // 终止Generator,那么返回值就 无了
    console.log(generator.return()); // 输出:{ undefined,: 100, done: true }
    // 终止Generator,并指定返回值
    console.log(generator.return(100)); // 输出:{ value: 100, done: true }
    
    // 再次调用next(),验证Generator已经完成
    console.log(generator.next()); // 输出:{ value: undefined, done: true }

    这个特性可以提前终止后续的yield,比如:Generator函数用来迭代数组元素,但基于某些条件,我们可能想提前结束遍历并返回一个特定的结果。yield delegator(yield委托)

    带星号的 yield 可以代理执行另一个 generator。这样你就可以根据需要连续调用多个 generator。

    function* generateZeroToOne() {// 生成0到1的数字
        yield 0;
        yield 1;
    }
    
    function* generateTwoAndThree() {// 生成数字2和数字3
        yield 2;
        yield 3;
    }
    
    function* generateNumbers() {// 通过yield*委托将上述两个Generator函数结合起来
        yield* generateZeroToOne(); // 委托给generateZeroToOne
        yield* generateTwoAndThree(); // 委托给generateTwoAndThree
    }
    
    for (let value of generateNumbers()) {// 遍历generateNumbers产生的值
        console.log(value); // 预期依次输出:0, 1, 2, 3
    }

    generator可以将一系列相关的生成逻辑模块化地组织在不同的Generator函数中,而且还可以通过yield*委托机制灵活地组合这些逻辑,实现更加复杂的数据生成和处理策略。

    Generator ES5实现

    function asyncToSyncAndRun(gen){
      var g = gen(); //此时g为生成器对象
      
      function next(data){
        var result = g.next(data);
        //注意:前面说过 result的结构,result是一个对象,里面的value对应yield后表达式的返回值
        //所以result.value是一个Promise对象
        if (result.done) return result.value;//如果遍历结束,return
        //未遍历结束,就把下一个next执行放在现在的Promise对象的回调中去
        result.value.then(function(data){
          next(data);
        });
      }
      next();//触发next方法~
    }
    //自动执行
    asyncToSyncAndRun(asyncFun)


    Generator ES5为何被弃用

    Generator函数在ES6中被引入,以提供一种更优雅的异步编程解决方案。它们通过yield关键字允许函数执行的暂停和恢复,这在处理复杂的异步操作时非常有用。然而,尽管Generator函数的引入带来了新的编程范式,它们在实际项目中的使用仍然相对较少,原因主要包括:

    1. 学习曲线: Generator函数引入了一种新的编程概念,包括yield关键字、必须使用特殊的迭代器对象来控制函数执行等。这些新概念为JavaScript开发者带来了额外的学习负担,特别是对于那些不熟悉协程概念的人。

    2. 异步编程解决方案的演进: 当Generator函数被引入时,它们被视为异步编程的一种改进,特别是与回调地狱相比。然而,随后引入的async/await语法提供了更加简洁和直观的异步编程方式。async/await背后的机制仍然基于Promise,它更容易理解和使用,并且能够更好地与现有的JavaScript库和框架集成。因此,很多开发人员和项目转而采用了async/await,而不是Generator函数。

    3. 调试和错误处理: Generator函数的执行不是线性的,它们可以在任何yield表达式处暂停和恢复。这种非线性执行模型可能会给调试带来额外的复杂性。而且,错误处理机制(如异常处理)也需要更多的工作,与传统的同步代码或Promise-based异步代码相比,可能更容易引入bug。

    4. 性能考虑: 虽然在很多场景下,Generator的性能完全可以满足需求,但它们引入了额外的抽象层,可能会比直接使用Promise或async/await有轻微的性能开销。

    5. 兼容性: 当Generator首次引入时,不是所有JavaScript环境都支持它们(虽然通过转译器如Babel可以实现兼容)。随着时间的推移,环境支持得到了改善,但早期的兼容性问题可能对它们的采用有一定的影响。

    个人认为:根本原因JavaScript 生来就是简单的脚本语言(出圈基因),而Generator 太复杂!



    参考文章:

    https://dennisgo.cn/Articles/JavaScript/Generator.html

    深入解析 JavaScript 中的 Generator 生成器 https://zhuanlan.zhihu.com/p/636245402

    手写generator核心原理,再也不怕面试官问我generator原理 https://juejin.cn/post/6859281096152973326

    手写generator核心原理及源码简析 https://blog.csdn.net/qq_46193451/article/details/110064977

    「一次写过瘾」手写Promise全家桶+Generator+async/await https://segmentfault.com/a/1190000038537123

    Promise从手写到扩展 | Promise/Generator/async | [Promise系列二](一) https://developer.aliyun.com/article/977152





    转载本站文章《从Iterator到Generator与:手搓generator来理解Async/Await风靡前端》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/js/2016_0202_503.html