Generator:JS执行权的真实操作者

简介:

前言
ES6提供了一种新型的异步编程解决方案: Generator函数(以下简称G函数)。它不是使用JS现有能力按照一定标准制定出来的东西( Promise是如此出生的),而是具有新型底层操作能力,与传统编程完全不同,代表一种新编程逻辑的高大存在。简洁方便、受人喜爱的 async函数就是以它为基础实现的。

1 意义
JS引擎是单线程的,只有一个函数执行栈。 当当前函数执行完后,执行栈将其弹出,销毁包含其局部变量的栈空间,并开始执行前一个函数。执行权由此单向稳定的在不同函数中切换。虽然 Web Worker的出现使我们能够自行创建多个线程,但这离灵活的控制:暂停执行、切换执行权和中间的数据交换等等,还是很有距离的。

G函数的意义在于,它可以在单线程的背景下,使执行权与数据自由的游走于多个执行栈之间,实现协程式编程。 调用G函数后,引擎会为其开辟一个独立的函数执行栈(以下简称G栈)。在执行它的过程中,可以控制暂停执行,并将执行权转出给主执行栈或另一个G栈(栈在这里可理解为函数)。而此G栈不会被销毁而是被冻结,当执行权再次回来时,会在与上次退出时完全相同的条件下继续执行。

下面是一个简单的交出和再次获得执行权的例子。

// 依次打印出:1 2 3 4 5。


let g = G();


console.log('1'); // 执行权在外部。

g.next(); // 开始执行G函数,遇到 yield 命令后停止执行返回执行权。

console.log('3'); // 执行权再次回到外部。

g.next(); // 再次进入到G函数中,从上次停止的地方开始执行,到最后自动返回执行权。

console.log('5');


function* G() {

  let n = 4;

  console.log('2');

  yield; // 遇到此命令,会暂停执行并返回执行权。

  console.log(n);

}

**2 登堂
2.1 形式**
G函数也是函数,所以具有普通函数该有的性质,不过形式上有两点不同。一是在 function关键字和函数名之间有一个 号,表示此为G函数。二是只有在G函数里才能使用 yield命令(以及 yield命令),处于其内部的非G函数也不行。由于箭头函数不能使用 yield命令,因此不能用作于 Generator函数(可以用作于 async函数)。

以下是它的几种定义方式。

// 声明式

function* G() {}


// 表达式

let G = function* () {};


// 作为对象属性

let o = {

  G: function* () {}

};


// 作为对象属性的简写式

let o = {

  * G() {}

};


// 箭头函数不能用作G函数,报错!

let o = {

  G: *() => {}

};


// 箭头函数可以用作 async 函数。

let o = {

  G: async () => {}

};

2.2 执行
调用普通函数会直接执行函数体中的代码,之后返回函数的返回值。但G函数不同,执行它会返回一个遍历器对象(此对象与数组中的遍历器对象相同),不会执行函数体内的代码。只有当调用它的 next方法(也可能是其它实例方法)时,才开始了真正执行。

在G函数的执行过程中,碰到 yield或 return命令时会停止执行并将执行权返回。当然,执行到此函数末尾时自然会返回执行权。每次返回执行权之后再次调用它的 next方法(也可能是其它实例方法),会重新获得执行权,并从上次停止的地方继续执行,直到下一个停止点或结束。

// 示例一

let g = G();

g.next(); // 打印出 1

g.next(); // 打印出 2

g.next(); // 打印出 3


function* G() {

  console.log(1);

  yield;

  console.log(2);

  yield;

  console.log(3);

}


// 示例二

let gg = GG();


gg.next(); // 打印出 1

gg.next(); // 打印出 2

gg.next(); // 没有打印


function* GG() {

  console.log(1);

  yield;

  console.log(2);

  return;

  yield;

  console.log(3);

}

**3 入室
3.1 数据交互**
数据如果不能在执行权的更替中取得交互,其存在的意义就会大打折扣。

G函数的数据输出和输入是通过 yield命令和 next方法实现的。 yield和 return一样,后面可以跟上任意数据,程序执行到此会交出控制权并返回其后的跟随值(没有则为 undefined),作为数据的输出。每次调用 next方法将控制权移交给G函数时,可以传入任意数据,该数据会等同替换G函数内部相应的 yield xxx表达式,作为数据的输入。

执行G函数,返回的是一个遍历器对象。每次调用它的 next方法,会得到一个具有 value和 done字段的对象。 value存储了移出控制权时输出的数据(即 yield或 return后的跟随值), done为布尔值代表该G函数是否已经完成执行。作为遍历器对象的它具有和数组遍历器相同的其它性质。

// n1 的 value 为 10,a 和 n2 的 value 为 100。

let g = G(10);


let n1 = g.next(); // 得到 n 值。

let n2 = g.next(100); // 相当将 yield n 替换成 100。


function* G(n) {

  let a = yield n; // let a = 100;

  console.log(a); // 100

  return a;

}

实际上,G函数是实现遍历器接口最简单的途径,不过有两点需要注意。一是G函数中的 return语句,虽然通过遍历器对象可以获得 return后面的返回值,但此时 done属性已为 true,通过 for of循环是遍历不到的。二是G函数可以写成为永动机的形式,类似服务器监听并执行请求,这时通过 for of遍历是没有尽头的。

--- 示例一:return 返回值。

let g1 = G();

console.log( g1.next() ); // value: 1, done: false

console.log( g1.next() ); // value: 2, done: true

console.log( g1.next() ); // value: undefined, done: true


let g2 = G();

for (let v of g2) {

  console.log(v); // 只打印出 1。

}


function* G() {

  yield 1;

  return 2;

}


--- 示例二:作为遍历器接口。

let o = {

  id: 1,

  name: 2,

  ago: 3,

  *[Symbol.iterator]() {

    let arr = Object.keys(this);

    for (let v of arr) {

      yield this[v]; // 使用 yield 输出。

    }

  }

}


for (let v of o) {

  console.log(v); // 依次打印出:1 2 3。

}


--- 示例三:永动机。

let g = G();

g.next(); // 打印出: Do ... 。

g.next(); // 打印出: Do ... 。

// ... 可以无穷次调用。


// 可以尝试此例子,虽然页面会崩溃。

// 崩溃之后可以点击关闭页面,或终止浏览器进程,或辱骂作者。

for (let v of G()) {

  console.log(v);

}


function* G() {

  while (true) {

    console.log('Do ...');

    yield;

  }

}

3.2 yield*
yield*命令的基本原理是自动遍历并用 yield命令输出拥有遍历器接口的对象,怪绕口的,直接看示例吧。

// G2 与 G22 函数等价。


for (let v of G1()) {

  console.log(v); // 打印出:1 [2, 3] 4。

}

for (let v of G2()) {

  console.log(v); // 打印出:1 2 3 4。

}

for (let v of G22()) {

  console.log(v); // 打印出:1 2 3 4。

}


function* G1() {

  yield 1;

  yield [2, 3];

  yield 4;

}


function* G2() {

  yield 1;

  yield* [2, 3]; // 使用 yield* 自动遍历。

  yield 4;

}


function* G22() {

  yield 1;

  for (let v of [2, 3]) { // 等价于 yield* 命令。

    yield v;

  }

  yield 4;

}

在G函数中直接调用另一个G函数,与在外部调用没什么区别,即便前面加上 yield命令。但如果使用 yield命令就能直接整合子G函数到父函数中,十分方便。因为G函数返回的就是一个遍历器对象,而 yield可以自动展开持有遍历器接口的对象,并用 yield输出。如此就等价于将子G函数的函数体原原本本的复制到父G函数中。

// G1 与 G2 等价。


for (let v of G1()) {

  console.log(v); // 依次打印出:1 2 '-' 3 4

}

for (let v of G2()) {

  console.log(v); // 依次打印出:1 2 '-' 3 4

}


function* G1() {

  yield 1;

  yield* GG();

  yield 4;

}


function* G2() {

  yield 1;

  yield 2;

  console.log('-');

  yield 3;

  yield 4;

}


function* GG() {

  yield 2;

  console.log('-');

  yield 3;

}

唯一需要注意的是子G函数中的 return语句。 yield*虽然与 for of一样不会遍历到该值,但其能直接返回该值。

let g = G();


console.log( g.next().value ); // 1

console.log( g.next().value ); // undefined, 打印出 return 2。


function* G() {

  let n = yield* GG(); // 第二次执行 next 方法时,这里等价于 let n = 2; 。

  console.log('return', n);

}


function* GG() {

  yield 1;

  return 2;

}

3.3 异步应用
历经了如此多的铺垫,是到将其应用到异步的时候了,来来来,喝了这坛酒咱就到马路上碰个瓷试试运气。 使用G函数处理异步的优势,相对于在这以前最优秀的 Promise来说,在于形式上使主逻辑代码更为的精简和清晰,使其看起来与同步代码基本相同。虽然在日常生活中,我们说谁谁做事爱搞形式多少包含有贬低意味。但在这程序的世界,对于我们编写和他人阅读来说,这些改进的效益可是相当可观哦。

// 模拟请求数据。

// 依次打印出 get api1, Do ..., get api2, Do ..., 最终值:3000 。


// 请求数据的主逻辑块

function* G() {

  let api1 = yield createPromise(1000); // 发送第一个数据请求,返回的是该 Promise 。

  console.log('get api1', api1); // 得到数据。

  console.log('Do somethings with api1'); // 做些操作。


  let api2 = yield createPromise(2000); // 发送第二个数据请求,返回的是该 Promise 。

  console.log('get api2', api2); // 得到数据。

  console.log('Do somethings with api2'); // 做些操作。


  return api1 + api2;

}


// 开始执行G函数。

let g = G();

// 得到第一个 Promise 并等待其返回数据

g.next().value.then(res => {

  // 获取到第一个请求的数据。

  return g.next(res).value; // 将第一个数据传回,并获取到第二个 Promise 。

}).then(res => {

  // 获取到第二个请求的数据。

  return g.next(res).value; // 将第二个数据传回。

}).then(res => {

  console.log('最终值:', res);

});


// 模拟请求数据

function createPromise(time) {

  return new Promise(resolve => {

    setTimeout(() => {

      resolve(time);

    }, time);

  });

}

上面的方式有很大的优化空间。我们执行函数时的逻辑是:先获取到异步请求并等待其返回结果,再将结果传递回G函数,之后重复操作。而按照此方式,意味着G函数中有多少异步请求,我们就应该重复多少次该操作。如果观众老爷们足够敏感,此时就能想到这些步奏是能抽象成一个函数的。而抽象出来的这个函数就是G函数的自执行器。

以下是一个简易的自执行器,它会返回一个 Promise。再往内是通过递归一步步的执行G函数,对其返回的结果都统一使用 resolve方法包装成 Promise对象。

// 与上一个示例等价。

RunG(G).then(res => {

  console.log('G函数执行结束:', res); // 3000

});


function* G() {

  let api1 = yield createPromise(1000);

  console.log('get api1', api1);

  console.log('Do somethings with api1');


  let api2 = yield createPromise(2000);

  console.log('get api2', api2);

  console.log('Do somethings with api2');


  return api1 + api2;

}


function RunG(G) {

  // 返回 Promise 对象。

  return new Promise((resolve, reject) => {

    let g = G();


    next();


    function next(data) {

      let r = g.next(data);


      // 成功执行完G函数,则改变 Promise 的状态为成功。

      if (r.done) return resolve(r.value);


      // 将每次的返回值统一包装成 Promise 对象。

      // 成功则继续执行G函数,否则改变 Promise 的状态为失败。

      Promise.resolve(r.value).then(next).catch(reject);

    }

  });

}


function createPromise(time) {

  return new Promise(resolve => {

    setTimeout(() => {

      resolve(time);

    }, time);

  });

}

自执行器可以自动执行任意的G函数,是应用于异步时必要的咖啡伴侣。上面是接地气的写法,我们来看看较为官方的版本。可以直观的感受到,两者主要的区别在对可能错误的捕获和处理上,这也是平常写的代码和构建底层库主要的区别之一。

function spawn(genF) {

  return new Promise(function(resolve, reject) {

    const gen = genF();

    function step(nextF) {

      let next;

      try {

        next = nextF();

      } catch(e) {

        return reject(e);

      }

      if(next.done) {

        return resolve(next.value);

      }

      Promise.resolve(next.value).then(function(v) {

        step(function() { return gen.next(v); });

      }, function(e) {

        step(function() { return gen.throw(e); });

      });

    }

    step(function() { return gen.next(undefined); });

  });

}

4 实例方法
实例方法比如 next以及接下来的 throw和 return,实际是存在G函数的原型对象中。执行G函数返回的遍历器对象会继承G函数的原型对象。在此添加自定义方法也可以被继承。这使得G函数看起来类似构造函数,但实际两者不相同。因为G函数本就不是构造函数,不能被 new,内部的 this也不能被继承。

function* G() {

  this.id = 123;

}

G.prototype.sayName = () => {

  console.log('Wmaker');

};


let g = G();

g.id; // undefined

g.sayName(); // 'Wmaker'

4.1 throw
实例方法 throw和 next方法的性质基本相同,区别在于其是向G函数体内传递错误而不是值。通俗的表达是将 yield xxx表达式替换成 throw 传入的参数。其它比如会接着执行到下一个断点,返回一个对象等等,和 next方法一致。该方法使得异常处理更为简单,而且多个 yield表达式可以只用一个 try catch代码块捕获。

当通过 throw方法或G函数在执行中自己抛出错误时。如果此代码正好被 trycatch块包裹,便会像公园里行完方便的宠物一样,没事的继续往下执行。遇到下一个断点,交出执行权传出返回值。如果没有错误捕获,JS会终止执行并认为函数已经结束运行,此后再调用 next方法会一直返回 value为 undefined、 done为 true的对象。

// 依次打印出:1, Error: 2, 3。

let g = G();


console.log( g.next().value ); // 1

console.log( g.throw(2).value ); // 3,打印出 Error: 2。


function* G() {

  try {

    yield 1;

  } catch(e) {

    console.log('Error:', e);

  }

  yield 3;

}

// 等价于

function* G() {

  try {

    yield 1;

    throw 2; // 替换原来的 yield 表达式,相当在后面添加。

  } catch(e) {

    console.log('Error:', e);

  }

  yield 3;

}

4.2 return
实例方法 return和 throw的情况相同,与 next具有相似的性质。区别在于其会直接终止G函数的执行并返回传入的参数。通俗的表达是将 yield xxx表达式替换成 return 传入的参数。值得注意的是,如果此时正好处于 try代码块中,且其带有 finally模块,那么 return方法会推迟到 finally代码块执行完后再执行。

let g = G();


console.log( g.next().value ); // 1

console.log( g.return(4).value ); // 2

console.log( g.next().value ); // 3

console.log( g.next().value ); // 4,G函数结束。

console.log( g.next().value ); // undefined


function* G() {

  try {

    yield 1;

  } finally {

    yield 2;

    yield 3;

  }

  yield 5;

}

// 等价于

function* GG() {

  try {

    yield 1;

    return 4; // 替换原来的 yield 表达式,相当在后面添加。

  } finally {

    yield 2;

    yield 3;

  }


  yield 5;

}

原文发布时间为:2018-08-29
本文作者:wmaker
本文来自云栖社区合作伙伴“前端大学”,了解相关信息可以关注“前端大学”。

相关文章
|
5月前
|
JavaScript 前端开发
js 数据添加方法
js 数据添加方法
36 1
|
3月前
|
算法 JavaScript
|
3月前
|
JavaScript
在store中的index.js中引入其他模块
在store中的index.js中引入其他模块
25 0
|
7月前
|
XML JavaScript 前端开发
JS的简介和作用还有为什么会产生JS?
JS的简介和作用还有为什么会产生JS?
63 2
|
8月前
|
JavaScript 数据格式
你不知道的js中“.“的作用
你不知道的js中“.“的作用
|
存储 JSON JavaScript
JS基础-方法
方法就是把函数放在对象的里面,对象只有两个东西:属性和方法
93 0
js-day01js引入输出&数据类型
js-day01js引入输出&数据类型
|
JavaScript 前端开发 网络架构
JS基础-函数
简单应用,通过key获得value set()新增或修改 delete()删除
81 0
|
存储 JavaScript 索引
JS基础- 对象
正常字符串我们使用,单引号,或者双引号包裹
71 0
|
JavaScript
JS引入方式
JS引入方式
60 0
JS引入方式