ES6 Generators 工作原理

1,162 阅读4分钟

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战

说在前面话

编写正确运行的软件可能是困难的,但是我们知道这仅仅是挑战的开始。对一个好的解决方案建模可以把编程从 “可以运行” 转换到 “这样做更好”。相对的,有些事就像下面的注释一样:

//
//亲爱的代码维护者:
// 
//一旦你试图优化代码,
//并且最终意识到这是一个错误的决定,
//请把下面的变量加一,
//来警告下一个家伙
//
//total_hours_wasted_here = 42
//

源自: stackoverflow.com/questions/1…

今天,我们会去探索 ES6 Generators 的工作原理,从而更好的理解它并用新的方式来解决一些老的问题。

异步/非阻塞

你可能已经听过编写非阻塞 javascript 代码的重要性。当我们处理 I/O 操作,比如发送 HTTP 请求或者写数据库,我们通常都会使用回调或者 promises。阻塞代码会冻结整个应用,还会浪费cpu资源,在绝大多数场景下都不是一个可以被使用的方案。

这样做的另外一个后果是,如果你写了一段无限循环的 javascript 代码,比如

while(true) {}

它很可能卡死你的电脑并且需要系统重启,请不要在自己的电脑上尝试。

ES6 的 Generators 允许我们在函数的中间暂停执行然后在未来的某个时候恢复执行时。

虽然有些工具比如 Regenerator 和 Babel 已经把这些特性实现在了 ES5。但是你有想过过它们是怎样做到这些的?今天,你会找到真相。 希望你可以更深入的理解 generators, 更好的发挥它的作用。

一个惰性序列

让我们从一个简单的例子开始。比如你要操作一个序列,你可能会创建一个数组并且按照数组的方式操作其值。但是如果这个序列是无限长的呢?数组就不行了,我们可以使用 generator 函数来做:

function* generateRandoms (max) {
  max = max || 1;

  while (true) {
    let newMax = yield Math.random() * max;
    if (newMax !== undefined) {
      max = newMax;
    }
  }
}

注意 function* 部分,这个 * 表示这是一个 “generator 函数”,并且表现与普通函数不同。另一个重要的部分是 yield 关键字。普通的函数仅仅通过 return 返回结果,而 generator 函数在 yield 时返回结果。

我们可以读出上面函数的意图 “每次你请求下一个值,它都会给你一个从 0 到 max 的值,直到程序退出(直到人类科技毁灭)。

根据上面的代码,我们仅仅在需要时才会得到一个值,这是非常重要的,否则,无限序列会很快的耗尽我们的内存和 CPU。我们使用 迭代器(Iterator) 的 next 方法来获取需要的值:

var iterator = generateRandoms();

console.log(iterator.next());     // { value: 0.4900301224552095, done: false }
console.log(iterator.next());     // { value: 0.8244022422935814, done: false }

Generators 允许两种交互,正如我们下面将要看到的,generators 在没有被调用时会被挂起,而当迭代器请求下一个值时会被唤醒。所以当我们调用 iterator.next 并且传递了参数后,参数会被赋值到 newMax

console.log(iterator.next());     // { value: 0.4900301224552095, done: false }

// 为 `newMax` 赋值,该值会一直存在
console.log(iterator.next(1000)); // { value: 963.7744706124067, done: false }
console.log(iterator.next());     // { value: 714.516609441489, done: false }

在 ES5 中使用 Generators

为了更好的理解 generators 工作原理,我们可以看一下 generators 是怎样转换成 ES5 代码的。你可以安装 babel 然后看一下它转换后的代码,或者你也可以去 Babel 的官网在线转换

image.png

/* eslint-disable */
var _marked = /*#__PURE__*/regeneratorRuntime.mark(generateRandoms);

function generateRandoms(max) {
    var newMax;
    return regeneratorRuntime.wrap(function generateRandoms$(_context) {
        while (1) {
            switch (_context.prev = _context.next) {
                case 0:
                    max = max || 1;

                case 1:
                    if (!true) {
                        _context.next = 8;
                        break;
                    }

                    _context.next = 4;
                    return Math.random() * max;

                case 4:
                    newMax = _context.sent;

                    if (newMax !== undefined) {
                        max = newMax;
                    }

                    _context.next = 1;
                    break;

                case 8:
                case "end":
                    return _context.stop();
            }
        }
    }, _marked);
}

如你所见,generator 函数的核心代码被转换成了 switch 块, 这对于我们探索其内部原理提供了很有价值的线索。我们可以把 generator 想象成一个循环状态机,它根据我们的交互切换不同的状态。变量 context$1$0 保存了当前的状态,case 语句都根据该状态中执行。

看一下这些 switch 的条件:

  • case 0: 初始化 max 的值并且执行到了 case: 1
  • case 1: 返回一个随机值然后 GOTO 4
  • case 4: 检查迭代器是否设置了 newMax 的值,如果是,就更新 max 的值,然后 GOTO 1, 返回一个随机值。

这就解释了为什么 generator 可以在遵循非阻塞的原则上可以无限循环和暂停。

何时退出循环

你可能注意到我跳过了这段代码:

if (!true) {
    context$1$0.next = 8;
    break;
}

这里发生了什么?它其实是原始代码 while (true) 被转换后的代码(就是对 while 的条件取反,没有其他用意)。

每当状态机循环时,它都会检查是否已经到了最后一步。在我们的示例中,是没有循环结束的,但你在编码时可能会遇到很多时候需要退出循环。当符合循环结束条件时,状态机 GOTO 8, generator 之行完毕。

迭代器中的私有状态

另外一个有趣的事情是 generator 是如何为每一个独立的迭代器保存私有状态的。因为变量 max 在 regeneratorRuntime.wrap 的外层作用域,它的值会被保留以供之后的 iterator.next() 访问。 如果我们调用 randomNumbers() 创建一个新的迭代器,那么一个新的闭包也会被创建。这也就解释了迭代器在使用同一个 generator 时有自己的私有状态而不会相互影响。

状态机内部

目前为止,我们已经看到 switch 的本质就是状态机。你可能已经注意到这个函数被包了两层:regeneratorRuntime.markregeneratorRuntime.wrap。 这些是 regenerator 模块,它可以在 ES5 中定义 ES6 generator 形式的状态机。

当 被调用时,会执行 regeneratorRuntime.wrap,传入两个函数,一个是原函数被 babel 编译后的结果(switch),另一个则是 regeneratorRuntime.mark(generateRandoms) 的结果。

function wrap(innerFn, outerFn, self, tryLocsList) {
    var generator = Object.create((outerFn || Generator).prototype);

    // 获得一个闭包,一个新的状态机开始,
    generator._invoke = makeInvokeMethod(
      innerFn, self || null,
      new Context(tryLocsList || [])
    );

    return generator;
  }

mark做的事情也很简单,就是为函数补全原型对象。使其看起来更像一个 Generator 函数

// 改造 genFun 的原型, 使得 genFun.prototype instanceof Generator 为 true
runtime.mark = function(genFun) {
    genFun.__proto__ = GeneratorFunctionPrototype;
    genFun.prototype = Object.create(Gp);
    return genFun;
  };

我们的重点在 wrap 中调用的 makeInvokeMethod 函数。源代码: runtime.js:130-133

function makeInvokeMethod(innerFn, self, context) {
    var state = GenStateSuspendedStart;

    return function invoke(method, arg) {
        if (state === GenStateExecuting) {
            throw new Error("Generator is already running");
          }

          if (state === GenStateCompleted) {
            return doneResult();// 会执行 context.next = 0
          }

在这里并没有发生什么事,它仅仅是创建并返回了一个函数。这也意味着当我们调用 var iterator = generateRandoms(), generateRandoms 内部并没有执行。

next

当我们调用 iterator.next(), generator 函数(也就是 switch 的那段代码的函数)会在 tryCatch 中被调用:

源代码: runtime.js:234

var record = tryCatch(innerFn, self, context);
if (record.type === "normal") {
    // If an exception is thrown from innerFn, we leave state ===
    // GenStateExecuting and loop back for another invocation.
    state = context.done
        ? GenStateCompleted
        : GenStateSuspendedYield;

    var info = {
        value: record.arg,
        done: context.done
    };

    if (record.arg === ContinueSentinel) {
        if (context.delegate && method === "next") {
            // Deliberately forget the last sent value so that we don't
            // accidentally pass it on to the delegate.
            arg = undefined;
        }
    } else {
        return info;
    }

} else if (record.type === "throw") {
    state = GenStateCompleted;
    // Dispatch the exception by looping back around to the
    // context.dispatchException(arg) call above.
    method = "throw";
    arg = record.arg;
}

// tryCatch 如下,只要执行不报错就是 normal
function tryCatch(fn, obj, arg) {
    try {
        return { type: "normal", arg: fn.call(obj, arg) };
    } catch (err) {
        return { type: "throw", arg: err };
    }
}

如果返回结果是普通的 return (而不是 throw), 会把结果包装成 {value, done}。新的状态是 GenStateCompleted 或者 GenStateSuspendedYield。由于我们的示例是无限循环,所以将总是跳转到 GenStateSuspendedYield 状态。

那么何时会 tryCatch 中何时才会出现 fn 抛出错误呢?通过 fn 我们发现只有 _context.next 为 8 时才会结束,此时会调用 _context.stop()

function generateRandoms$(_context) {
    while (1) {
        switch (_context.prev = _context.next) {
            case 0:
                max = max || 1;

            case 1:
                if (!true) {
                    _context.next = 8;
                    break;
                }

                _context.next = 4;
                return Math.random() * max;

            case 4:
                newMax = _context.sent;

                if (newMax !== undefined) {
                    max = newMax;
                }

                _context.next = 1;
                break;

            case 8:
            case "end":
                return _context.stop();
        }
    }
}

_context.stop 的作用就是抛出错误,源码如下:runtime.js:404-414

stop: function() {
    this.done = true;

    var rootEntry = this.tryEntries[0];
    var rootRecord = rootEntry.completion;
    if (rootRecord.type === "throw") {// 抛出错误
        throw rootRecord.arg;
    }

    return this.rval;
},

就这样完成了 generate, Regenerator runtime 是一个很长的话题,这里我们仅仅只结束其中比较核心的部分。

总结一下

今天我们用 generator 函数实现了一个惰性序列状态机。这个特性现在就可以使用: 现代浏览器都已经原生支持了 generator, 而对于老的浏览器,也很容易做代码转换。

其实现原理是先将 generator 函数通过 babel 转化为状态机(switch),然后通过调用 next 方法来不断的执行状态机更新状态 _context.next

每当执行调用 next 函数就会执行一次 switch 语句,当遇到 return 后就会结束(暂停),然后等待下一次调用 next 继续执行。这就解释了为什么 generator 可以在遵循非阻塞的原则上可以无限循环和暂停

真正厉害的过程是 babel 如何将 generator 转化为状态机的过程,babel yyds。

👍👍👍⭐⭐⭐(暗示)

近期精彩文章推荐

UglifyJS为了极致的压缩你的代码,用了哪些奇技淫巧?

V8 Promise源码全面解读(强烈推荐看看)

盘点2021年V8发布的新特性和API (ES2021)

一道看似简单但是90%的人都答错的js题目

DNS面试也会问,赶紧来看看