利用聚乙烯改进 JavaScript 函数式编程
如果你使用过 JavaScript 和数组,肯定用过一些相关的函数式工具,例如filter`map` map、reduce`filter` 等。它们在很多情况下都非常有用,但也存在一些明显的缺点:
- 每次调用函数式方法都会返回一个新的数组。这会创建不必要的中间数组,浪费时间和内存。
- 这些工具仅适用于数组。几年前这还不是问题,但随着新方法的引入
Symbol.iterator,for...of现在已经不够用了。 - 完全不支持异步操作。没有回调,没有 Promise,没有事件,什么都没有:你的代码必须是同步的,数据必须已经加载到内存中。
多亏了for..of这些方法,我们可以通过重新实现它们来解决所有这些问题,并针对每种情况进行调整,但这违背了最初使用函数式实用程序的初衷。我们该怎么办?
聚乙烯来拯救你了。聚乙烯可以解决以上所有问题,甚至还能解决一些你可能都没意识到的问题。我们来逐一看看,稍后我会详细说明。
但首先要声明:我是《聚乙烯》一书的作者,所以请记住这一点来理解我在这里所说的一切。
此外,您在此处看到的所有代码都假定您已按如下方式导入聚乙烯:
const Poly = require('polyethylene');
这就是你需要知道的全部内容,让我们开始吧!
保存数组副本
由于聚乙烯对象是纯生成器,因此无需花费时间和空间来存储函数调用链的中间结果。这可以大大加快处理长数组的速度。
我们举个例子。假设我们有一个列表,其中包含人的姓名、国家代码和年龄。我们想知道居住在西班牙的人的平均年龄:
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!
注:我知道
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!
唯一的区别在于,在启动函数链时,我们将数组封装成了一个 `<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)
两种情况下,我们最终都会得到一个包含一百万人的可迭代对象,但在第二种情况下,永远不会创建包含一百万个条目的数组。然后我重复了我的实验,并增加了重复次数:
| 数量 | 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();
让我们一步一步来:
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;
}
假设我们要打印列表中每个城市今天的最低和最高气温;如果查询的城市匹配多个地点,我们会打印多次。如果不用聚乙烯,我会这样做:
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);
}
}
还不错,不过如果需要更多步骤的话就会变得复杂。
聚乙烯可以让我们以更简化的方式完成这项工作,但有一点需要注意,我们稍后会提到:
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));
唯一奇怪的地方在于第二个例子中.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))
这有点繁琐,因为我们需要循环以及向数组中添加元素才能正常工作。但主要问题在于很难实现可重用性,因为由于过滤器的作用,我们无法确定要加载的商品总数,所以我们需要逐页加载。
使用 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));
这需要一些解释。该Poly.iterate方法通过无限重复调用传入的函数来创建一个可迭代对象,并将最后一个元素作为参数传递(第二个参数用于iterate指定初始值)。我们使用这些属性来返回after字段和一个done标志,该标志指示页面是否已遍历完毕,同时传递posts转发值。然后,我们将帖子扁平化并获取它们的 data 属性。
然后,你可以对任何子版块调用该函数,就能得到一个包含所有帖子的列表,简单明了。我们调用它,根据我们的条件进行筛选,只取前 100 个帖子并打印出来。轻而易举。
除了功能性工具之外:预取/预加载
等等,还有更多!
我们还有一个绝招:预加载和预取。您可以将这两个选项传递给异步迭代的任何阶段,然后神奇的事情就会发生:
- 如果
preload启用此选项,则会尽快生成该阶段的第一个元素。这样,即使可迭代对象需要一段时间才能迭代完成,也能确保该元素立即可用。不过,大多数情况下,这用处不大,因为您很可能会立即进行迭代。 - 如果启用此选项,则会在冻结当前元素之前
prefetch请求迭代的下一个元素。这意味着,如果某个阶段之后需要长时间处理,则下一个元素将可用,因为它将并行生成。
这两个选项可以加快链上的聚合处理时间,因为它们允许并行化,但默认情况下它们不会激活,因为如果您使用限制阶段,它们会请求比必要更多的元素。
这篇文章很长。
这就是聚乙烯(Polyethylene)。这是我之前启动的一个小型项目,但我认为它非常实用,尤其是异步部分。我仍在思考如何改进它,欢迎大家贡献想法、建议、bug报告、批评,当然还有代码。
文章来源:https://dev.to/danielescoz/improving-javascript-function-programming-with-polyethen-47e5