发布于 2026-01-06 0 阅读
0

利用聚乙烯改进 JavaScript 函数式编程

利用聚乙烯改进 JavaScript 函数式编程

如果你使用过 JavaScript 和数组,肯定用过一些相关的函数式工具,例如filter`map` mapreduce`filter` 等。它们在很多情况下都非常有用,但也存在一些明显的缺点:

  • 每次调用函数式方法都会返回一个新的数组。这会创建不必要的中间数组,浪费时间和内存。
  • 这些工具仅适用于数组。几年前这还不是问题,但随着新方法的引入Symbol.iteratorfor...of现在已经不够用了。
  • 完全不支持异步操作。没有回调,没有 Promise,没有事件,什么都没有:你的代码必须是同步的,数据必须已经加载到内存中。

多亏了for..of这些方法,我们可以通过重新实现它们来解决所有这些问题,并针对每种情况进行调整,但这违背了最初使用函数式实用程序的初衷。我们该怎么办?

聚乙烯来拯救你了。聚乙烯可以解决以上所有问题,甚至还能解决​​一些你可能都没意识到的问题。我们来逐一看看,稍后我会详细说明。

但首先要声明:我是《聚乙烯》一书的作者,所以请记住这一点来理解我在这里所说的一切。

此外,您在此处看到的所有代码都假定您已按如下方式导入聚乙烯:

const Poly = require('polyethylene');
Enter fullscreen mode Exit fullscreen mode

这就是你需要知道的全部内容,让我们开始吧!

保存数组副本

由于聚乙烯对象是纯生成器,因此无需花费时间和空间来存储函数调用链的中间结果。这可以大大加快处理长数组的速度。

我们举个例子。假设我们有一个列表,其中包含人的姓名、国家代码和年龄。我们想知道居住在西班牙的人的平均年龄:

const people = [{name: 'Dani', country: 'ES', age: 27}, /* more people */];

const {age, num} = people
  .filter(person => person.country === 'ES') // filter by country
  .map(person => person.age) // we're only interested in their age
  .reduce( // find total age and number of people
    (acc, age) => ({age: acc.age + age, num: acc.num + 1}),
    {age: 0, num: 0}
  );

const avgAge = age / num; // we have the average now!
Enter fullscreen mode Exit fullscreen mode

注:我知道map舞台并非必要,但为了便于说明而放置。

如果我们运行这段代码,就能算出数据集中所有西班牙人的平均年龄。很简单,对吧?但如果我们的数据集不是一个人,甚至不是几百人,而是成千上万人,问题就出现了。因为我们每一步都要创建数组,所以必须花费时间和空间来存储和填充所有这些数组。我们可以用一个简单的步骤将这段代码适配到 Polyethylene 对象:将我们的数组封装到一个 Polyethylene 对象中:

const Poly = require('polyethylene');
const people = [{name: 'Dani', country: 'ES', age: 27}, /* more people */];

const {age, num} = Poly.from(people)
  .filter(person => person.country === 'ES') // filter by country
  .map(person => person.age) // we're only interested in their age
  .reduce( // find total age and number of people
    (acc, age) => ({age: acc.age + age, num: acc.num + 1}),
    {age: 0, num: 0}
  );

const avgAge = age / num; // we have the average now!
Enter fullscreen mode Exit fullscreen mode

唯一区别在于,在启动函数链时,我们将数组封装成了一个 `<Publisher>` 对象Poly.from(people)。这将创建一个聚乙烯Iterable对象,可用于类似的函数链。然而,不同之处在于,不会再创建任何中间数组。

在这个简单的例子中,当用大约一百万人的数据进行测量时,我注意到时间减少了大约 10%。然而,我创建数据集的方法是:将相同的 1000 个人重复 1000 次,将其存储在一个数组中,然后才使用 Polyethylene 函数。但事实证明,我们也可以直接使用 Polyethylene 函数来实现这一点!

/* Array-only version */
const repeatedPeople = Array(1000).fill().flatMap(() => somePeople)

/* Polyethylene version */
const repeatedPeople = Poly.range(1000).flatMap(() => somePeople)
Enter fullscreen mode Exit fullscreen mode

两种情况下,我们最终都会得到一个包含一百万人的可迭代对象,但在第二种情况下,永远不会创建包含一百万个条目的数组。然后我重复了我的实验,并增加了重复次数:

数量 1000 5000 10000 50000 100000
大批 212毫秒 1123毫秒 2190毫秒 10350毫秒 碰撞
聚合物 84毫秒 380毫秒 749毫秒 3671毫秒 7446毫秒

正如你所见,在处理超大型数据集时,Polyethylene 的速度要快得多。这一点在本例中尤为明显,因为使用数组时,我们需要先构建数据集,然后再进行处理。正如你所看到的,当处理 1 亿条记录时,数组版本直接崩溃了:它耗尽了内存。Polyethylene 版本可能需要很长时间,但绝不会因此而崩溃。

需要注意的是,这并非总是如此。对于小型数组,由于生成器的开销以及缓存机制,Polyethylene 的运行速度实际上可能会更慢。不过,性能并非 Polyethylene 的主要目标,而只是一个不错的附加效果。

在数组以外的可迭代对象中使用函数式实用程序

现在我们进入了聚乙烯无法胜任的领域。在这种情况下,它指的是对非数组迭代器执行功能性操作。

为了举例说明,我们将使用数学。假设我们想找出前 100 个快乐数

const first100HappyNums = Poly.range(1, Infinity)
  .filter(isHappy) // assume we already have an `isHappy` function
  .take(100)
  .toArray();
Enter fullscreen mode Exit fullscreen mode

让我们一步一步来:

  • Poly.range(1, Infnity)遍历介于 n1和 n 之间的所有数字Infinity。可以想象,这是一个无限迭代,但由于后面的限制,我们可以处理这个问题。
  • .filter(isHappy)假设函数运行正常,最终只会留下那些满足条件的数字isHappy。结果仍然是无穷大,但密度会大大降低。
  • .take(100)这将导致一次有限迭代,仅包含前 100 个元素。因为我们已经只剩下快乐数,所以这将是前 100 个快乐数。
  • .toArray()最后会收集所有元素并返回一个数组。

如您所见,使用函数式实用程序,用数组是无法实现这一点的。因此,聚乙烯填补了功能上的空白。

不过,你不需要无限次迭代就能实现这个功能。Poly.from它适用于任何可迭代对象,所以你可以使用 `Object` Set、`Object`Buffer或任何其他实现了迭代器接口的对象。

但是,我们对聚乙烯的用途还只是略知皮毛……

使用async回调函数和异步可迭代对象

我们目前只使用了同步函数,但 Polyethylene 也支持将async函数作为回调函数处理。不过,要实现这一点,我们需要先在链式调用中将 Iterable 转换为 AsyncIterable .sacync()。从那时起,所有操作都将是异步的。

我们举个例子。假设我们有一份城市列表,想知道它们的天气预报。我将使用MetaWeatherrequest-promise来调用该服务,所以您也可以尝试一下,无需注册任何账号。

首先,我们来定义用于查询 API 的函数:

const reqProm = require('request-promise');

async function searchLocation (query) {
  return reqProm({
    uri: 'https://www.metaweather.com/api/location/search',
    qs: {query},
    json: true,
  });
}

async function getWeather (id) {
  const response = await reqProm({
    uri: `https://www.metaweather.com/api/location/${id}`,
    json: true,
  });

  return response.consolidated_weather;
}
Enter fullscreen mode Exit fullscreen mode

假设我们要打印列表中每个城市今天的最低和最高气温;如果查询的城市匹配多个地点,我们会打印多次。如果不用聚乙烯,我会这样做:

const today = new Date().toISOString().split('T')[0];
const cities = ['madrid', 'san']; // 'san' will yield 11 results

for (const city of cities) {
  const searchResult = await searchLocation(city);

  for (const location of searchResult) {
    const weatherList = await getWeather(location.woeid);
    const todaysWeather = weatherList.find(w => w.applicable_date === today);
    console.log('%s: %s, %s', location.title, todaysWeather.min_temp, todaysWeather.max_temp);
  }
}
Enter fullscreen mode Exit fullscreen mode

还不错,不过如果需要更多步骤的话就会变得复杂。
聚乙烯可以让我们以更简化的方式完成这项工作,但有一点需要注意,我们稍后会提到:

const today = new Date().toISOString().split('T')[0];
const cities = ['madrid', 'san'];

Poly.from(cities)
  .async()
  .flatMap(searchLocation)
  .flatMap(async (loc) => (await getWeather(loc.woeid))
    .map(w => ({city: loc.title, ...w}))
  )
  .filter(res => res.applicable_date === today)
  .forEach(res => console.log('%s: %s, %s', res.city, res.min_temp, res.max_temp));
Enter fullscreen mode Exit fullscreen mode

唯一奇怪的地方在于第二个例子中.flatMap,我们需要用嵌套的映射来注入城市名称,以便后续使用。在前一个例子中,由于代码本身的嵌套结构,我们不需要这样做。这说明 Polyethylene 并非完美无缺,有时我们需要对代码进行一些调整才能使其正常工作。

如您所见,我们已经能够使用async函数进行flatMap调用。我们也可以将它们用于filter其他操作forEach。这一切都得益于该.async()调用,如果我们不使用它,我们的迭代器将是同步的,一切都将无法正常工作。

但这还不是全部,Polyethylene 最棒的特性之一就是它能够直接处理异步可迭代对象。我非常喜欢的一个例子是在页面中加载 Reddit 数据。假设我们想要列出某个子版块中排名前 100 的帖子,这些帖子不是置顶帖而是文本帖子(类型为self)。一种方法可能是:

const reqProm = require('request-promise');

async function getRedditPage (subreddit, {limit = 50, before, after} = {}) {
  return reqProm({
    uri: `https://reddit.com/r/${subreddit}.json`,
    qs: {limit, before, after},
    json: true,
  });
}

const WANTED = 50;
const posts = [];
let after = null;

while (posts.length < WANTED) {
  const page = await getRedditPage('factorio', {limit: 100, after});

  posts.push(...page.data.children.filter(post => !post.data.stickied && 
  post.data.post_hint === 'self'));
  after = page.data.after;
}

posts.slice(0, WANTED)
  .forEach((post, i) => console.log('[%s]', post.data.name, post.data.title))
Enter fullscreen mode Exit fullscreen mode

这有点繁琐,因为我们需要循环以及向数组中添加元素才能正常工作。但主要问题在于很难实现可重用性,因为由于过滤器的作用,我们无法确定要加载的商品总数,所以我们需要逐页加载。

使用 Polyethylene,我们可以创建一个函数,该函数首先列出该子版块中的所有帖子,然后对其进行筛选并打印出来。我们可以用iterate它来实现这一点:

function listSubreddit (subreddit) {
  return Poly.iterate(async ({done, after}) => {
    if (done) {
      return {done, posts: []};
    }

    const result = await getRedditPage(subreddit, after);
    return {
      after: result.data.after,
      posts: result.data.children,
      done: after == null,
    };
  }, {done: false})
    .flatMap(({posts}) => posts)
    .map(post => post.data);
}

listSubreddit('factorio')
  .filter(post => !post.stickied && post.post_hint === 'self')
  .take(100)
  .forEach((post, i) => console.log('[%s]', post.name, post.title));
Enter fullscreen mode Exit fullscreen mode

这需要一些解释。该Poly.iterate方法通过无限重复调用传入的函数来创建一个可迭代对象,并将最后一个元素作为参数传递(第二个参数用于iterate指定初始值)。我们使用这些属性来返回after字段和一个done标志,该标志指示页面是否已遍历完毕,同时传递posts转发值。然后,我们将帖子扁平化并获取它们的 data 属性。

然后,你可以对任何子版块调用该函数,就能得到一个包含所有帖子的列表,简单明了。我们调用它,根据我们的条件进行筛选,只取前 100 个帖子并打印出来。轻而易举。

除了功能性工具之外:预取/预加载

等等,还有更多!

我们还有一个绝招:预加载和预取。您可以将这两个选项传递给异步迭代的任何阶段,然后神奇的事情就会发生:

  • 如果preload启用此选项,则会尽快生成该阶段的第一个元素。这样,即使可迭代对象需要一段时间才能迭代完成,也能确保该元素立即可用。不过,大多数情况下,这用处不大,因为您很可能会立即进行迭代。
  • 如果启用此选项,则会在冻结当前元素之前prefetch请求迭代的下一个元素。这意味着,如果某个阶段之后需要长时间处理,则下一个元素将可用,因为它将并行生成。

这两个选项可以加快链上的聚合处理时间,因为它们允许并行化,但默认情况下它们不会激活,因为如果您使用限制阶段,它们请求比必要更多的元素。


这篇文章很长。

这就是聚乙烯(Polyethylene)。这是我之前启动的一个小型项目,但我认为它非常实用,尤其是异步部分。我仍在思考如何改进它,欢迎大家贡献想法、建议、bug报告、批评,当然还有代码。

在npmGitHub上查找聚乙烯

文章来源:https://dev.to/danielescoz/improving-javascript-function-programming-with-polyethen-47e5