把 js 的数据分成 pull 和 push 处理—— collection-query

681 阅读4分钟

collection-query

collection-query 是一个集合操作类库,它把数据集合分成 pull 和 push 两种类型,再用同样语义的方法去操作它们。下文简称 cq 。

普通的数组操作

介绍 cq 之前,先看一下普通的数组操作。

// 据说变量名长度小于7便于阅读
// 统计一下全局有多少个这样的变量

const x = Object.keys(globalThis) // 获取数据的集合
  .map((x) => x.length) // 变换
  .filter((x) => x < 7) // 变换
  .reduce((r, _) => r + 1, 0); // 聚合

console.log(x);

codesandbox xx.length

在这里,数组的操作可以简单分为3个步骤。

  1. 获取数据的集合。
  2. 把集合变换为另一个集合。
  3. 把集合聚合成一个值。

cq 的集合操作

接下来,用 cq 复刻上一节的数组操作。

import { transfer } from "collection-query";
import { createFrom, map, filter, reduce } from "collection-query/pull";

// 获取数据的集合
const s = createFrom(Object.keys(globalThis));
// 变换
const s2 = transfer(s, [
  map((x: string) => x.length),
  filter((x: number) => x < 7),
]);
// 聚合
const x = reduce(s2, (r, _) => r + 1, 0);

console.log(x);

codesandbox

其实这个例子就揭示了 cq 的主要用法。

  1. 确定集合的类型是 pull 或 push ,选择对应的方法集。
  2. 把集合变换为另一个集合。
  3. 把集合聚合成一个值或消费。

相比之下

相比之下,这个例子的代码结构显得冗余,而这是 cq 对环境的妥协。

  • 不采用链式操作,是为了打包时支持 tree sharking ,减少体积。
  • 使用 transfer(s, [...methods]) 这种形式,是为了支持 ts 的类型推导。如:上文的 const x... 最终能推导为 number 类型。

pull 和 push

初步了解 cq 后,再回来看下数据的 pull 和 push 。

  • pull ,数据被动生产,代码主动消费。
  • push ,数据主动生产,代码被动消费。

rxjs 就介绍得很好,cq 也受此启发。相关文档

PullStream

之前 cq 的例子采用的正是 pull ,确切来说是 PullStream 。

cq 的 PullStream 实际上是原生对象 Generator Function 的别名。(与生成器相关)

原生数组也是一个 pull 类型的数据,但是用 PullStream 可以更抽象地去描述数据。

import { transfer } from "collection-query";
// 采用 PullStream 的方法集
import { take, map, filter, count } from "collection-query/pull";

// 获取数据的集合;生成一个代表自然数的 PullStream
const s = function* () {
  let count = 0;
  while (true) {
    yield count++;
  }
};

// 变换;前100个数里能被3整除的数
const s2 = transfer(s, [take(100), map((x) => x % 3), filter((x) => x === 0)]);

// 聚合;求元素的个数
const x = count(s2);

console.log(x);

codesandbox

  • 用原生语法“星号函数”就能方便地创建 PullStream。
  • PullStream 是 lazy 的,在聚合或消费前都不会产生数据,可以节省一定的内存空间。
  • PullStream 可以轻易地表达数组不能表达的不限长度的数据。

AsyncPullStream

AsyncPullStream 是 PullStream 的异步版本,对应的原生对象是 Async Generator Function 。(与 for await...of 相关)

// 感谢 cnodejs 的 api 支持跨域
// 这里统计一下在首页发帖的用户,他们的收藏情况

import { transfer } from "collection-query";
// 采用 AsyncPullStream 的方法集
import { flatten, map, forEach } from "collection-query/async-pull";

// 获取数据的集合
const s = async function* () {
  for (let page = 1; page <= 2; page++) {
    // 请求首页收据,每次10条
    const resp = await fetch(
      `https://cnodejs.org/api/v1/topics?mdrender=false&&limit=10&&page=${page}`
    );
    const data = await resp.json();

    yield data.data;
  }
};

// 变换
const s2 = transfer(s, [
  flatten,
  map((x) => x.author.loginname),
  map(async (x) => {
    // 请求某个用户的收藏
    const resp = await fetch(`https://cnodejs.org/api/v1/topic_collect/${x}`);
    const data = await resp.json();
    return data.data;
  }),
  flatten,
  map((x) => x.title),
]);

// 消费
// for await (const x of s2()) {
//   console.log(x);
// }
forEach(s2, (x) => console.log(x));

codesandbox

cq 操作 AsyncPullStream 只是采用了不同的方法集,流程与之前操作 PullStream 一致。

PushStream 和 AsyncPushStream

而 cq 对应的 push 类型实现是 PushStream 和 AsyncPushStream ,它们不是原生对象,这里先不赘述它们的结构。

groupBy

_.groupBy

就说 PushStream 最有趣的一点,它能表达 lazy 的 groupBy 。

当一个集合 s 经过 groupBy 变换后,我们会希望新的集合是一个元素为 [key, group_s] 的集合。

// 假如能牺牲一些打包空间,链式调用还挺优雅的。

s.groupBy(keyOf)
  .map((x) => {
    const [key, group_s] = x;
    // group_s 也是一个集合
    const count = group_s.filter(pred).count();
    return [key, count];
  })
  .forEach((x) => console.log(x));

如果此时的 s 是 PullStream ,那么 groupBy 后 map 第一个元素就会遍历 s 的所有内容,这会失去流式处理数据的空间优势。

如果此时的 s 是 PushStream ,那么被动消费的 PushStream 可以通过异步操作等待 group_s 的消费返回,不需要提前遍历 s 的所有内容。

以下是 PullStream groupBy 的实例。

// 统计 cnodejs 首页各个 tab 回复大于0的帖子数量。

import { transfer, EmitType } from "collection-query";
// 采用 PushStream 的方法集
import {
  create,
  flatten,
  groupBy,
  map,
  count,
  filter,
  incubate,
  forEach,
} from "collection-query/push";

// 获得数据的集合
const s = create(async (emit) => {
  // 请求首页收据
  const resp = await fetch("https://cnodejs.org/api/v1/topics?mdrender=false");
  const data = await resp.json();

  emit(EmitType.Next, data.data);
  emit(EmitType.Complete);
});

// 变换
const s2 = transfer(s, [
  flatten,
  groupBy((x) => x.tab),
  map(async (x) => {
    // 获得数据的集合
    const [key, group_s] = x;
    // 变换
    const s2 = transfer(group_s, [
      map((x) => x.reply_count),
      filter((x) => 0 < x),
    ]);
    // 聚合
    const _count = await count(s2);
    return [key, _count];
  }),
  incubate,
]);

// 消费
forEach(s2, (x) => console.log(x));

codesandbox

写到最后

cq 的介绍到此结束了,这里复述一遍 cq 的主要用法。

  1. 确定集合的类型,选择对应的方法集。
  2. 把集合变换为另一个集合。
  3. 把集合聚合成一个值或消费。