46

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

 6 years ago
source link: https://segmentfault.com/a/1190000016047312?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

前言

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函数的执行过程中,碰到 yieldreturn 命令时会停止执行并将执行权返回。当然,执行到此函数末尾时自然会返回执行权。每次返回执行权之后再次调用它的 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 方法实现的。

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

执行G函数,返回的是一个遍历器对象。每次调用它的 next 方法,会得到一个具有 valuedone 字段的对象。 value 存储了移出控制权时输出的数据(即 yieldreturn 后的跟随值), 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 以及接下来的 throwreturn ,实际是存在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

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

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

// 依次打印出: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

实例方法 returnthrow 的情况相同,与 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;
}

延伸

ES6精华:Symbol ES6精华:Promise Iterator:访问数据集合的统一接口


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK