你可能没有听说过的一种异步管理模式 - Generator 异步管理器

前言

最近在写 Node.js 的知识点,之前的内容如下:

之前深入了异步管理中 promise 的高级内容。现在是异步管理的第二部分 生成器(generator) 管理异步任务。

我们将实现一个跟框架无关的 Generator 异步管理器,思想来源于 redux-saga, 它弥补了 promise 本身无法中途取消执行的问题,而且还可以自定义很多异步管理复杂的操作符。

本文主要实现一个可以中断异步请求的 Generator 异步管理器中的操作符, 你可以在此之上实现各种各样的其它需求的操作符,最核心的目的是:

让大家认识到 Generator 也是一种管理异步请求的方式,弥补在这个知识领域,少有探讨的资料的问题

其实在 go 语言里,借助协程(在 Javascript 中 Generator 是协程的一种实现),所以 Javascript 本身是可以写出 go 语言里 goroutine 的那种语法的,有兴趣深入的同学可以查看 js-csp 这个库。

解决场景举例

我们想象一个常见不能再常见的前端场景了,如下图:

再进一步说明问题

按钮 A 按了之后,ajax请求的数据显示在input type=text框里,B按钮也是。

问题就是如果先按 A,此时 ajax 发出去了,但是数据还没返回来, 我们等不及了,马上按 B 按钮,结果此时 A 按钮请求的数据先回来,这就尴尬了,按的 B 按钮,结果先显示 A 按钮返回的数据,怎么解决?

如果 promise 可以中断,我们在切换按钮的时候,能把之前的 promise 取消了的话,这样就完美解决这个问题了。

我试图简化这个过程,我们假设有一个按钮,每次点击请求后端的数据都不一样,然后我只要保证有新的请求,就取消之前所有的请求,就能保证一定最后渲染的,就是最后一个发出请求返回的数据。

实现一个例如:

javascript 复制代码
const promiseA = new Promise(后端请求函数).then(()=> 得到后端数据,刷新视图)
const promiseB = new Promise(后端请求函数).then(()=> 得到后端数据,刷新视图)

当调用 promiseB 的时候,自动取消 promiseA 正在执行的任务,也就是后端请求可能已经发出了,但是不会刷新视图,因为在执行 promiseA 视图刷新的时候,知道 promiseB 已经调用了,所以取消刷新。

然后推而广之,我们假设有 100个promise,如果同时调用,都只会执行最后一个。

好了,用 promise 的话,还是不太方便的。我们从介绍基本的生成器开始我们今天的内容。

我已经把其封装为一个很简陋的库,在线demo地址(主要代码在 ./saga.js 文件中):

codesandbox.io/p/sandbox/s...

注意: 在线 demo 中有这么一段代码:

js 复制代码
    const onClick = () => {
      takeLastChannel.put("1");
      takeLastChannel.put("2");
      takeLastChannel.put("3");
    };

    document.getElementById("button").addEventListener("click", onClick);

这里我们连续触发了 3 次,fetchUserIterator,每次传入不同的参数,可我们点击按钮会发现,每次只会显示最后一次调用后的值,也就是 3。其中 1 和 2 都被我们取消渲染了。

有些同学可能学习 es6 以来压根没用过生成器,我借助阮一峰老师的的 es6 课程内容,简单介绍一下生成器,熟悉的同学可以略过

Generator 简介

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。本章详细介绍 Generator 函数的语法和 API。

Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是"产出")。

javascript 复制代码
function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var hw = helloWorldGenerator();

上面代码定义了一个 Generator 函数helloWorldGenerator,它内部有两个yield表达式(helloworld),即该函数有三个状态:hello,world 和 return 语句(结束执行)。

然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上一章介绍的遍历器对象(Iterator Object)。

下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。

yaml 复制代码
hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }

上面代码一共调用了四次next方法。

第一次调用,Generator 函数开始执行,直到遇到第一个yield表达式为止。next方法返回一个对象,它的value属性就是当前yield表达式的值hellodone属性的值false,表示遍历还没有结束。

第二次调用,Generator 函数从上次yield表达式停下的地方,一直执行到下一个yield表达式。next方法返回的对象的value属性就是当前yield表达式的值worlddone属性的值false,表示遍历还没有结束。

第三次调用,Generator 函数从上次yield表达式停下的地方,一直执行到return语句(如果没有return语句,就执行到函数结束)。next方法返回的对象的value属性,就是紧跟在return语句后面的表达式的值(如果没有return语句,则value属性的值为undefined),done属性的值true,表示遍历已经结束。

第四次调用,此时 Generator 函数已经运行完毕,next方法返回对象的value属性为undefineddone属性为true。以后再调用next方法,返回的都是这个值。

总结一下,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着valuedone两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。

小结:

当在外部调用了 next() 后,会返回类似 {value: 0, done: false} 这样的结果,其中 done 是一个 boolean 值,代表迭代器是否完毕

既然可以用 done 判斷迭代器是否執行完,那就來點迴圈或是遞迴吧!

Generator Runner 的概念

我们构建一个 runner 函数,用来执行一个 Generator 函数。在 runner 函数里面有一个 next 函数,这个函数便是不断地用 Generator 传入的 done 确认是否已经迭代完,如果还没迭代完,则调用迭代器的下一行指令。

js 复制代码
import axios from "axios";

function proc(genFn) {
  const itr = genFn();

  function next(arg) {
    let result = itr.next(arg);

    if (result.done) {
      return arg;
    } else {
      return Promise.resolve(result.value).then(next);
    }
  }

  return next();
}

function* genFn() {
  const USER_URI = "https://reqres.in/api/users";
  let res = yield axios.get(USER_URI);
  const userId = res.data.data[0].id;
  yield axios.get(`${USER_URI}/${userId}`);
}

const result = runner(genFn);
Promise.resolve(result).then(res => console.log(res.data));

channel

首先我们要实现这样一个效果,就是点击按钮,触发调用一个 Generator Runner 来自动执行 Generator

js 复制代码
$btn.addEventListener('click', () => {
  chan.put('传递给 Generator Runner 要执行的参数');
}, false);

这里我们需要引入一个概念------channel

上面的 chan 就是 channel 的实例。

channel是对事件源的抽象,作用是先注册一个待执行的 gennerator ,当put触发时,执行之前注册的 gennerator。

简单来说,chan.take,注册 gennerator, chan.put,将之前 注册 gennerator 取出,然后用 proc,也就是Generator Runner 去自动迭代 gennerator(我们后续会写一个比上面稍微复杂一点的 proc)。

channel的简单实现如下:

js 复制代码
function channel() {
  // 当前正在执行的 gennerator
  let currentTaker = null;
  // cancelCurrentTaker:记录正在执行 gennerator,以方便后续取消
  // currentArgs:给 gennerator 传入自定义参数,这样可以在触发 put 操作符的时候,接收这些参数
  let info = {
    cancelCurrentTaker: undefined,
    currentArgs: undefined,
  };
  function take(taker) {
    currentTaker = taker;
  }

  function put(args) {
    // 每次执行之前查看是否有已经执行的任务,有就取消,这样可以保证每次只触发最后一次任务
    info.cancelCurrentTaker?.cancel();
    // 记录参数
    info.currentArgs = args;
    // Generator Runner 执行
    proc(currentTaker());
  }

  return { take, put, info };
}

export const chan = channel();

effect 的概念

effect 是一个 JavaScript 对象,用來描述我们的迭代器操作符要如何执行的說明 。举例来说,我们经常会用到 call

  • type :执行的 effect 类别 。
  • fn:被执行的函数。
  • args :被执行函数的参数。
js 复制代码
export function call(fn, ...args) {
  return {
    isEffect: true,
    type: "call",
    fn,
    args
  };
}

effect Runner

每一种 effect 都有相对应的 effect runner,让每个 effect runner 各司其职,不用把很多概念混杂在一起,分开时会比较好管理。 举例来说,call 的 effect runner 会长这个样子:

  • fn : 在 saga function 中我们定义的 callback function。
  • args : 在 saga function 中我们指定的 fn --- callback function 传入的参数。
  • next : Generator runner 中的 next(),用于进行下一次迭代
js 复制代码
export function call(fn, ...args) {
  return {
    isEffect: true,
    type: "call",
    fn,
    args
  };
}

正式开始我们的代码逻辑梳理

有了上面的基础后,我们才能继续讲,这也是为啥 Generator 异步管理器不能流行的原因,就跟 redux-saga 虽然流行,但仍然对很多人来说晦涩难懂。既然这么晦涩了,还不如直接学 rxjs,比这个来的实在。

因为我们的实现跟框架无关,所以我直接在 html 中去实现它:

html 结构如下

html 复制代码
    <h1 id="h1">渲染内容</h1>
    <p>点击下方按钮同时发出 3 个请求,但最终只会渲染最后一个</p>
    <button id="button">按钮</button>

然后,我们业务逻辑如下:

首先引入我们的库

arduino 复制代码
import { call, put, takeLastChannel, fork } from "./saga.js";

然后使用 takeLastChannel 来注册我们要调用的 gennerator

js 复制代码
takeLastChannel.take(fetchUserTakeLatest);

fetchUserTakeLatest 是我们业务中调用后端接口的相关的函数,实现如下:

js 复制代码
    function fetchUser() {
      return new Promise((res) => {
        setTimeout(() => res("success"), 1000);
      });
    }

    function* fetchUserIterator() {
      const user = yield call(fetchUser);
      yield put(
        (args) => (document.getElementById("h1").innerHTML = args),
        takeLastChannel
      );
    }

    export function* fetchUserTakeLatest() {
      yield fork(fetchUserIterator, takeLastChannel);
    }

最后,我们把触发事件绑定到按钮上,

js 复制代码
    const onClick = () => {
      takeLastChannel.put("1");
      takeLastChannel.put("2");
      takeLastChannel.put("3");
    };

    document.getElementById("button").addEventListener("click", onClick);

这里我们连续触发了 3 次,fetchUserIterator,每次传入不同的参数,可我们点击按钮会发现,每次只会显示最后一次调用后的值,也就是 3。其中 1 和 2 都被我们取消渲染了。

takeLastChannel.put 就是任务触发器。

接下来 saga.js 的代码如下,经过上面的铺垫,感觉大家会更好理解其内容,如果感觉困难,没有关系,这种模式的晦涩程度已经跟 rxjs 差不多了,对于复杂异步管理,rxjs 毕竟有着成熟的操作符库,但是有了生成器的基础,你就可以用生成器实现 async 函数了,其实 async 本身也可以使用我们的 Generator runner 来实现,我们下期再说:

js 复制代码
// constant
const TASK_CANCEL = "TASK_CANCEL";

// utils
export const is = {
  isPromise(p) {
    return typeof p.then === "function" && typeof p.catch === "function";
  },
  isEffect(effect) {
    return !!effect.isEffect;
  },
};

const { isPromise, isEffect } = is;

// runner
export function proc(iterator) {
  let task = { cancel: () => next(null, TASK_CANCEL) };
  next();

  function next(err, pre) {
    let temp;
    if (err) {
      temp = iterator.throw(err);
    } else if (pre === TASK_CANCEL) {
      temp = iterator.return(pre);
    } else {
      temp = iterator.next(pre);
    }
    if (temp.done) return;

    const value = temp.value;
    if (isPromise(value)) {
      value
        .then((success) => next(null, success))
        .catch((error) => next(error));
    } else if (isEffect(value)) {
      const effectRunner = effectRunnerMap[value.type];
      effectRunner(value, next);
    } else {
      next(null, value);
    }
  }
  return task;
}

function TakeLastChannel() {
  let currentTaker = null;
  let info = {
    cancelCurrentTaker: undefined,
    currentArgs: undefined,
  };
  function take(taker) {
    currentTaker = taker;
  }

  function put(args) {
    info.cancelCurrentTaker?.cancel();
    info.currentArgs = args;
    console.log("currentTaker", currentTaker);
    proc(currentTaker());
  }

  return { take, put, info };
}

export const takeLastChannel = TakeLastChannel();

export function call(fn, ...args) {
  return {
    isEffect: true,
    type: "call",
    fn,
    args,
  };
}

export function put(action, channel) {
  return {
    isEffect: true,
    type: "put",
    action,
    channel,
  };
}

export function fork(saga, channel) {
  return {
    isEffect: true,
    type: "fork",
    saga,
    channel,
  };
}

function runCallEffect({ fn, args }, next) {
  fn.call(null, args)
    .then((success) => next(null, success))
    .catch((error) => next(error));
}

function runPutEffect({ action, channel }, next) {
  action(channel.info.currentArgs);
  next();
}

function runForkEffect({ saga, channel }, next) {
  const task = proc(saga());
  if (channel) channel.info.cancelCurrentTaker = task;
  next(null);
}

const effectRunnerMap = {
  // take: runTakeEffect,
  call: runCallEffect,
  put: runPutEffect,
  fork: runForkEffect,
};
相关推荐
有梦想的刺儿16 分钟前
webWorker基本用法
前端·javascript·vue.js
cy玩具36 分钟前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
清灵xmf1 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据1 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161771 小时前
防抖函数--应用场景及示例
前端·javascript
334554322 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事2 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶2 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json