nitroge / memories

日常学习累积的点点滴滴滴滴点点
7 stars 0 forks source link

探索生成器(Generator)之拖拽从未如此简单 #8

Open nitroge opened 3 years ago

nitroge commented 3 years ago

探索生成器(Generator)之拖拽从未如此简单

生成器

关于生成器,我这里不做过多介绍,可以通过ECMAScript 6 入门快速入门。

高级教程可以看Exploring ES6

我对生成器的第一印象来自于phppython,写后台程序的时候,时不时有请求别的服务器资源的情况,这时候无论是在 python 下使用request,还是在 php 下使用curl,都是同步阻塞的,类似于在前端用XMLHttpRequest做 ajax 请求时,设置open方法的第三个参数为false,在这种情况下,在请求结果返回之前,其它代码是不会被执行的。

那么,有什么办法可以解决这种等待着啥都干不了干着急的状态呢?一般去搜索都会给出两种常用的方案:多线程多进程,稍微再广泛点找资料,就会发现另一个关键词:协程,也有叫纤程

简单来讲,协程是用户态的线程,何时执行,执行几行代码,何时停止,都由我们的代码来控制,对应的,我们常说的线程是由系统调度的,多个线程之间相互切换都是系统自动调度的。

这三个概念,想进一步了解的看《进程、线程和协程的概念》

要实现用户调度线程(伪),就需要请出我们今天的主角生成器

PS: 生成器与迭代器的关系也是暧昧不清的,请自行探索。

我们先用一个简单的例子一窥生成器的强大:

// 声明一个生成器函数
function* fn() {
  var ret = yield 123;
  console.log('recv==>', ret);
}

// 开始调用
var gen = fn(); // 得到生成器
var ret = gen.next();
// ret 为 {value: 123, done: false}
var ret1 = gen.next(456);
// 输出 recv==> 456
// ret1 为 {value: undefined, done: true}

从上面的例子,我们可以看到:

  1. 生成器函数fn可以通过yield关键字自行决定函数执行到哪行代码就交出控制权
  2. 还可以在交出控制权的同时,传递函数内的计算值出去
  3. 调用生成器函数,拿到生成器对象后,可以随时通过其next方法取得内部传出的值
  4. 生成器对象的next方法被调用后,会重新将代码执行权交还给fn,并且回到之前yield调用的那个位置
  5. 外部调用生成器对象的next方法时,可以把外部的值传入fn内部,作为对应的yield表达式的结果。

这套流程有木有很熟悉的感觉???

熟悉就对了,我们现在每天打交道的异步函数就是生成器的一个特殊情况(特殊在 await 后面一般是一个 Promise 对象),在那个没有异步函数的年代,大神tj就用生成器造出了co,实现了与我们现在使用的异步函数一样的编程体验。(墙裂推荐阅读其源码,就一个文件,加上注释、空行都不到 250 行代码)。

既然已经跑题了,就借力再跑远点。上面搬出co来镇场子,只是让你对生成器的强大抱有更大的信心。还有两个知名的对生成器用得很 6 的库是redux-sagajs-csp;

无论各路大神是如何使用生成器的,我们始终记住一个概念就好了:我们可以通过生成器决定一个函数何时运行、运行多少行、何时终止。

同步的业务流程,去中断一个函数的执行意义不大(至少我没想到这样的场景),但是涉及到异步的场景:计时、网络请求、事件...等等,配合生成器,我们可以把原来剪不断理还乱的异步流程,用人脑更容易理解的同步代码写出来。

拖拽

不知道你能否一口气实现一个基本没什么 bug 的拖拽,反正我是做不到。

逻辑上其实简单:

  1. 监听元素的 mousedown 事件,事件发生时记录鼠标坐标,并标记正在拖拽中
  2. 监听 mousemove 事件,事件发生时判断是否在拖拽中,是的话计算最元素最新的位置
  3. 监听 mouseup 事件,重置拖拽中标记

原生版本

就是一个这样看似简单的逻辑,先来看原生 js 版本:

在线运行

var box = document.getElementById('box');

var offset = {};
var draging = false;

function move(x, y) {
  box.style.left = x + 'px';
  box.style.top = y + 'px';
}

box.addEventListener('mousedown', function(e) {
  draging = true;
  offset.x = e.offsetX;
  offset.y = e.offsetY;
});

document.addEventListener('mouseup', function() {
  draging = false;
});

document.addEventListener('mousemove', function(e) {
  if (draging) {
    move(e.x - offset.x, e.y - offset.y);
  }
});

代码量其实并不大,但有两个问题不能忍受:

  1. 需要单独记录状态(副作用)
  2. 代码逻辑太分散,按下、拖拽、停止三个有先后顺序的逻辑分布在三个不同的事件回调之中,并不能体现先后顺序。

Rxjs 版本

再来看看 rxjs 的版本:

在线运行

const box = document.querySelector('#box');
const md$ = Rx.Observable.fromEvent(box, 'mousedown');
const mm$ = Rx.Observable.fromEvent(document, 'mousemove');
const mu$ = Rx.Observable.fromEvent(document, 'mouseup');

function move(x, y) {
  box.style.left = x + 'px';
  box.style.top = y + 'px';
}

// 核心逻辑
md$
  .flatMap(() => mm$.takeUntil(mu$))
  .withLatestFrom(md$, (e1, e2) => [e1.x - e2.offsetX, e1.y - e2.offsetY])
  .subscribe(([x, y]) => move(x, y));

干净清爽多了!理解 rxjs 关于的思想,一切也变得简单了。

生成器版本

最后看看,我用生成器实现的逻辑抽象版本:

在线运行

首先,我折腾了两个晚上,封装了一个库,暴露了两个接口:golistenergo接受一个生成器函数作为参数,生成器函数内部通过yield声明需要接收的信号,而listener则用于绑定事件并产生信号。然后,代码的逻辑就如下了:

const box = document.getElementById('box');
const mouseDown = listener(box, 'mousedown');
const mouseMove = listener(document, 'mousemove');
const mouseUp = listener(document, 'mouseup');

function move(x, y) {
  box.style.left = x + 'px';
  box.style.top = y + 'px';
}

// 核心逻辑
go(function*() {
  while (true) {
    let [{ offsetX, offsetY }] = yield mouseDown; // 现有mousedown 才有后面的事件
    while (true) {
      let [{ type, x, y }] = yield [mouseMove, mouseUp]; // mousedown之后监听mousemove与mouseup
      if (type === 'mouseup') break; // 如果是mouseup 结束本次拖拽
      move(x - offsetX, y - offsetY); // 否则计算最新拖拽位置
    }
  }
});

是不是线性的思考?

  1. 先得有 mousedown,才会有后续的操作
  2. 有了 mousedown,我们可能发生两种情况:mousemove 与 mouseup
  3. mouseup 是本轮拖拽终止的标志
  4. mousemove 在 mouseup 出现之前应该是不中断的,并实时计算元素位置

这个库的代码并不多,除开考虑比较好看的处理yield [mouseMove, mouseUp]这样的语法而增加的自定义数据结构linerDict,核心代码就那么几行:

const signalMap = linerDict();

function linerDict() {
  const map = new Map();

  function getAll(key) {
    return [...map.keys()].reduce((acc, cur) => {
      if (cur === key || (Array.isArray(cur) && ~cur.indexOf(key))) {
        acc.push(map.get(cur));
      }
      return acc;
    }, []);
  }

  function get(key) {
    return map.get(key);
  }

  function has(key) {
    let val = get(key);
    return val && val.length > 0;
  }

  function set(key, value) {
    map.set(key, value);
  }
  return {
    set,
    get,
    getAll,
    has,
  };
}
// 核心函数 dispatch 与 run
function dispatch(signal, ...args) {
  let arr = signalMap.getAll(signal);
  arr.forEach(tempArr => tempArr.splice(0).forEach(gen => run(gen, ...args)));
}

function run(gen, ...args) {
  let { value: signal } = gen.next(args);
  if (!signalMap.has(signal)) signalMap.set(signal, []);
  signalMap.get(signal).push(gen);
}

function go(fn) {
  run(fn());
}

// 生成事件信号的工具函数
function listener(dom, eventName) {
  const eventSymbol = Symbol(eventName);
  dom.addEventListener(eventName, e => dispatch(eventSymbol, e));
  return eventSymbol;
}

这个思路还有很大的扩展空间,后面再抽时间完善(打算按这种方式实现下 rxjs 的部分功能),有兴趣的小伙伴也可以一起来探讨。

就此打住,希望我的此次尝试能勾起你对生成器的兴趣,优化那曾经写成一团糟的异步流程。