你好,我是周爱民。欢迎回到我的专栏。

相信上一讲的迭代过程已经在许多人心中留下了巨大的阴影,所以很多人一看今天的标题,第一个反应是:“又来!”

其实我经常习惯用同一个例子,或者同类型的示例的细微不同去分辨与反映语言特性上的核心与本质的不同。如同在第 2 讲和第 3 讲中都在讲的连续赋值,看起来形似,却根本上不同。

同样,我想你可能也已经注意到了,在第 5 讲(for (let x of [1,2,3]) …)和第 9 讲((…x))中所讲述的内容是有一些相关性的。它们都是在讲循环。但第 5 讲主要讨论的是语句对循环的抽象和如何在循环中处理块。而第 9 讲则侧重于如何通过函数执行把(类似第 5 讲的)语句执行重新来实现一遍。事实上,仅仅是一个“循环过程”,在 JavaScript 中就实现了好几次。这些我将来都会具体地来为你分析。

至于今天,我还是回到函数的三个语义组件,也就是“参数、执行体和结果”来讨论。上一讲本质上讨论的是对“执行体”这个组件的重造,今天,则讨论对“参数和结果”的重构。

将迭代过程展开

通过上一讲,你应该知道迭代器是可以表达为一组函数的连续执行的。那么,如果我们要把这一组函数展开来看的话,其实它们之间的相似性是极强的。例如:

// 迭代函数
function foo(x = 5) {
return {
next: () => {
return {done: !x, value: x && x–};
}
}
}

let x = new Object;
x[Symbol.iterator] = foo; // default x is 5
console.log(…x);

事实上相当于只调用了 5 次 return 语句,可以展开如下:

// 上例在形式上可以表达为如下的逻辑
console.log(
/*return */{done: false, value: 5}.value,
/*return */{done: false, value: 4}.value,
/*return */{done: false, value: 3}.value,
/*return */{done: false, value: 2}.value,
/*return */{done: false, value: 1}.value
);

既然事实上连续的 tor.next() 调用最终也仅是为了获取它们的值(result.value),那么如果封装这些值的生成过程,就可以用一个新的函数来替代一批函数。

这样的一个函数就称为生成器函数

但是,缘于函数只有一个出口(RETURN),所以用“函数的退出”是无法映射“函数包含一个多次生成值的过程”这样的概念的。如果要实现这一点,就必须让函数可以多次进入和退出。而这,就是今天这一讲的标题上的这个yield 运算符的作用。这些作用有两个方面:

  1. 逻辑上:它产生一次函数的退出,并接受下一次 tor.next() 调用所需要的进入;
  2. 数据上:它在退出时传出指定的值(结果),并在进入时携带传入的数据(参数)。

所以,yield实际上就是在生成器函数中用较少的代价来实现一个完整“函数执行”过程所需的“参数和结果”。而至于“执行体”这个组件,如果你听过上一讲的话,相信你已经知道了:执行体就是 tor.next() 所推动的那个迭代逻辑。

例如,上面的例子用生成器来实现就是:

function *foo() {
yield 5;
yield 4;
yield 3;
yield 2;
yield 1;
}

或者更通用的过程:

function *foo2(x=5) {
while (x–) yield x;
}

// 测试
let x = new Object;
x[Symbol.iterator] = foo2; // default x is 5
console.log(…x); // 5 4 3 2 1

我们又看到了循环,尽管它被所谓的生成器函数封装了一次。

逻辑的重现

我想你已经注意到了,生成器的关键在于如何产生yield运算所需要的两个逻辑:(函数的)退出和进入。

因为事实上生成器内部是顺序的 5 行代码,还是一个循环逻辑,对于外部的使用者来说是不可知的。生成器通过一个迭代器接口的界面与外部交互,只要for..of...x以及其他任何语法、语句或表达式识别该迭代器接口,那么它们就可以用 tor.next() 以及 result.done 状态来组织外部的业务逻辑,而不必界面后面的(例如数据传入传出的)细节了。

然而,对于生成器来说,“(函数的)退出和进入”是如何实现的呢?

在第 6 讲(x: break x;)中提到过“执行现场”这个东西,它事实包括三个层面的概念:

  1. 块级作用域以及其他的作用域本质上就是一帧数据,交由所谓“环境”来管理;
  2. 函数是通过 CALL/RETURN 来模拟上述“数据帧”在栈上的入栈与出栈过程,也称为调用栈;
  3. 执行现场是上述环境和调用栈的一个瞬时快照(包括栈上数据的状态和执行的“位置”)。

其中的“位置”是一个典型的与“(逻辑的)执行过程”相关的东西,第六讲中的“break”就主要在讲这个“位置”的控制——包括静态的标签,以及标签在执行过程中所映射到的位置。

函数的进入(CALL)意味着数据帧的建立以及该数据帧压入调用栈,而退出(RETURN)意味着它弹出栈和数据帧的销毁。从这个角度上来说,yield运算必然不能使该函数退出(或者说必须不能让数据帧从栈上移除和销毁)。因为yield之后还有其他代码,而一旦数据帧销毁了,那么其他代码就无法执行了。

所以yield是为数不多的能“挂起”当前函数的运算。但这并不是yield主要的、标志性的行为。yield操作最大的特点是它在挂起当前函数时,还将函数所在栈上的执行现场移出了调用栈。由于yield可以存在在于生成器函数内的第 n 层作用域中,例如:

function foo3() { // 块作用域 1
if (true) { // 块作用域 2
while (true) { // 块作用域 3
yield 100

所以yield发生时需要向这个数据帧(作用域链)外层检索到第一个函数帧(FunctionEnvironment),并挂起包括它内部的全部环境。而执行位置,将会通过函数的调用关系,一次性地返回到上一次 tor.next() 的下一行代码。也就是说相当于在 tor.next() 内部执行了一次return

为了简化所谓“向外层检索”这一行为,JavaScript 通常是使用所谓“执行上下文”来管理这些数据帧(环境)与执行位置的。执行上下文与函数或代码块的词法上下文不同,因为执行上下文只与“可执行体”相关,是 JavaScript 引擎内部的数据结构,它总是被关联(且仅只关联)到一个函数入口。

由于 JavaScript 引擎将 JavaScript 代码理解为函数,因此事实上这个“执行上下文”能关联所有的用户代码文本。

“所有的代码文本”意味着“.js 文件”的全局入口也会被封装成一个函数,且全部的模块顶层代码也会做相同的封装。这样一来,所有通过文件装载的代码文本都会只存在于同一个函数中。由于在 Node.js 或其他一些具体实现的引擎中,无法同时使用标准的 ECMAScript 模块装载和.js 文件装载,因此事实上来说,这些引擎在运行 JavaScript 代码时(通常地)也就只有一个入口的函数。

而所有的代码其实也就只运行在该函数的、唯一的一个“执行上下文”中。

如果用户代码——通过任意的手段——试图挂起这惟一的执行上下文,那么也就意味着整个的 JavaScript 都停止了执行。因此,“挂起”这个上下文的操作是受限制的,被一系列特定的操作规范管理。这些规范我在这一讲的稍晚部分内容中会详细讲述,但这里,我们先关注一个关键问题:到底有多少个执行上下文?

如果模块与文件装载机制分开,那么模块入口和文件入口就是二选一的。当然在不同的引擎中这也不尽相同,只是在这里分开讨论会略为清晰一些。

模块入口是所有模块的顶层代码的顺序组合,它们被封装为一个称为“顶层模块执行(TopLevelModule Evaluation Job)”的函数,作为模块加载的第一个执行上下文创建。类似的,一般的.js 文件装载也会创建一个称为“脚本执行(Script EvaluationJob)”的函数。后者,也是文件加载中所有全局代码块称为“Script 块”的原因。

除了这两种执行上下文之外,eval() 总是会开启一个执行上下文的。

JavaScript 为 eval() 所分配的这个执行上下文,与调用 eval() 时的函数上下文享有同一个环境(包括词法环境和变量环境等等),并在退出 eval() 时释放它的引用,以确保同一个环境中“同时”只有一个逻辑在执行。

接下来,如果一个一般函数被调用,那么它也将形成一个对应的执行上下文,但是由于这个上下文是“被”调用而产生的,所以它会创建一个“调用者(caller)”函数的上下文的关联,并创建在 caller 之后。由于栈是后入先出的结构,因此总是立即执行这个“被调用者(callee)”函数的上下文。

这也是调用栈入栈“等义于”调用函数的原因。

但这个过程也就意味着这个“当前的(活动的)”调用栈是由一系列执行上下文以及它们所包含的数据帧所构成的。而且,就目前来说,这个调用栈的底部,要么是模块全局(_TopLevelModuleEvaluationJob_ 任务),要么就是脚本全局(_ScriptEvaluationJob_ 任务)。

一旦你了解了这些,那么你就很容易理解生成器的特殊之处了:

所有其他上下文都在执行栈上,而生成器的上下文(多数时间是)在栈的外面。

有趣的.next() 方法

如果有一行yield代码出现在生成器函数中,那么当这个生成器函数执行到yield表达式时会发生什么呢?

这个问题貌似不好回答,但是如果问:是什么让这个生成器函数执行到“yeild表达式”所在位置的呢?这个问题就好回答了:是 tor.next() 方法。如下例:

function* foo3() {
yield 10;
}
let tor = foo3();

到现在为止,tor 已经获得了来自 foo3() 的一个迭代器对象。并且,在语法形式上,貌似 foo3() 函数已经执行了一次。但是,事实上 foo3() 所声明的函数体并没有执行,而是直到用户代码调用如下方法:

调用迭代器方法

tor.next()
{ value: 10, done: false }

这时,foo3() 所声明的函数体正式执行并直到表达式yeild 10,生成器函数才返回了第一个值10。如同上一讲中所说到的——这表明在代码tor = foo3()中,函数调用“foo3()”的实际执行效果是:生成一个迭代过程,并将该过程交给了 tor 对象。

换而言之:tor 是 foo3() 生成器(内部的)迭代过程的一个句柄。从引擎内的实现过程来说,tor 其实包括状态(state)和执行上下文(context)两个信息,它是GeneratorFunction.prototype的一个实例。这个 tor 所代表的生成器在创建出来的时候将立即被挂起,因此状态值(state)初始化置为"启动时挂起(suspendedStart)",而当在调用 tor.next() 因yield运算而导致的挂起称为"Yield 时挂起(suspendedYield)"。

另一个信息,即 context,就指向 tor 被创建时的上下文。上面已经说过了,所谓上下文一定指的是一个外部的、内部的或由全局 / 模块入口映射成的函数。

接下来,当 tor.next() 执行时,tor 所包括的 context 信息被压到栈顶执行;当 tor.next() 退出时,这个 context 就被从栈上移除。这个过程与调用 eval() 是类似的,总是能保证指定栈是全局唯一活动的一个栈。

如果活动栈唯一,那么系统就是同步的。

因为只需要一个执行线程。

对传入参数的改造

生成器对“函数执行”的执行体加以改造,使之变成由 tor.next() 管理的多个片断。用来映射多次函数调用的“每个 body”。除此之外,它还对传入参数加以改造,使执行“每个 body”时可以接受不同的参数。这些参数是通过 tor.next() 来传入,并作为 yield 运算的结果值而使用的。

这里 JavaScript 偷偷地更换了概念。也就是说,在:

x = yield x

这行表达式中,从语法上看是表达式yield x求值,实际的执行效果是:

  1. yield向函数外发送计算表达式x的值;

x = ...的赋值语义变成了:

  1. yield接受外部传入的参数并作为结果值赋给 x。

将 tor.next() 联合起来看,由于 tor 所对应的上下文在创建后总是挂起的,因此第一个 tor.next() 调用总是将执行过程“推进”到第一行yield并挂起。例如:

function* foo4(x=5) {
console.log(x–); // tor = foo4()时传入的值 5
// …

x = yield x; // 传出 x 的值
console.log(x); // 传入的 arg
// …
}

let tor = foo4(); // default x is 5
result = tor.next(); // 第一次调用.next() 的参数将被忽略
console.log(result.value)

执行结果将显示:

5 // <- defaut 5
4 // <- result.value 4

而 foo4() 函数在yield表达式执行后将挂起。而当在下一次调用 tor.next(arg) 时,一个已经被yield挂起的生成器将恢复(resume),这时传入的参数 arg 就将作为yield表达式(在它的上下文中的)结果值。也就是上例中第二个 console.log(x) 中的x值。例如:

传入 100,将作为 foo4() 内的 yield 表达式求值结果赋给x = ...

tor.next(100)
100

知识回顾

今天这一讲,谈的是将迭代过程展开并重新组织它的语义,然后变成生成器与 yield 运算的全过程。

在这个过程中,你需要关注的是 JavaScript 对“迭代过程”展开之后的代码体和参数处理。

事实上,这包含了对函数的全部三个组件的重新定义:代码体、参数传入、值传出。只不过,在yield中尤其展现了对传入传出的处理而已。

思考题

今天的这一讲不安排什么特别的课后思考,我希望你能补充一下一个小知识点的内容:由于今天的内容中没有讲“委托的 yield”这个话题,因此你可以安排一些时间查阅资料,对这个运算符——也就是“yeild*”的实现过程和特点做一些深入探索。

欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。