【译】为什么说使用 `_.chain` 是个错误

为什么说使用 _.chain 是个错误

将你的代码从_.chain的枷锁中解放出来。

图片来源于Jeremy Booth; 特别要感谢Eric Baer, Brooklyn Zelenka & Jason Trill 他们的无私的帮助和知识;也特别要感谢 John-David Dalton 不仅是因为 lodash 本身,还有他谦虚的指出了这篇文章的一些不准确之处以及介绍了许多有关 lodash 内部工作原理的花边知识。

为什么说使用 _.chain 是个错误

在任何一个使用 .chain 的应用或者是类库当中,都有这样两个微不足道的小问题:它们通常通过将整个lodash 引入到文件中来使用,和他们难以添加扩展出新的方法。首先,让我们去了解一下为什么人们会使用 .chain, 这是一件很有意义的事。然后寻找出我们要如何使用一些编程技巧来去其糟粕,取其精华。我们将会关注如何得到一个2倍的构建时间优化以及1.5倍的文件大小的减少,通过把下面这个:

1
2
3
4
5
6
7
import _ from 'lodash'
_.chain([1, 2, 3])
.map(x => [x, x*2])
.flatten()
.sort()
.value();

转变成这:

1
2
3
4
5
6
import map from "lodash/fp/map";
import flatten from "lodash/fp/flatten";
import sortBy from "lodash/fp/sortBy";
import flow from "lodash/fp/flow";
flow(map(x => [x, x*2]),flatten,sortBy(x => x))([1,2,3]);

注意:这篇文章中使用了一些现代 JavaScript 的语言特性:模块,箭头函数以及常量变量。如果你以前还未见过他们,那么你应该去查看一下,以得到最好的阅读体验。

背景介绍

lodash 库是由 John-David Dalton 构建维护,提供了许多 JavaScript 所没有提供的的一致的,高性能的还有一套统一的函数来做一些常见的工作,又或是一些容易出错的任务。在 lodash 当中有超过 300 个函数, 同时,这个包每个月有超过2千万下载量。这是一个梦幻般的作品。

lodash:一个现代化的 javaScript 工具库提供模块化,高性能和扩展化。

它给出一个清晰的方案,让这些细小的工具函数灵活地连接在一起(同时提高某些场景下的性能)。

考虑以下操作:

1
(_.flatten(_.map([1, 2, 3], x => [x, x*2]))).slice().sort();

按理说,下面这样让输入对象从一个函数流动到下一个函数直到结束,这样的形式会比一系列的传递参数更加“清晰”:

1
2
3
4
5
_.chain([1, 2, 3])
.map(x => [x, x*2])
.flatten()
.sort()
.value();

人们真的真的很喜欢 _.chain 而且事情看起来也不错,直到一段时间以后。

问题所在

然而圣光之下潜伏着阴影。随着时间流逝,越来越多的函数被加到 lodash 当中;人们忽然意识到其实他们并不需要所有的功能。人们也同时意识到他们会想要添加他们自己的功能:一个新的方法作为 _.chain 的一部分;但是他们发现这些新的方法并不总是能很好的和其他函数一同运行。

它可以变的十分庞大

在一个网页应用中引入 lodash 可能会让其体积变得相当巨大。现在有两个 lodash 版本(v4)存在:一个是堆满所有东西的厨房水槽以及另一个相对少一些功能的更加微小的构建版本。
这俩包的大小(min版并且gz压缩过)大约在21kb和4kb左右。要找到一个正确的平衡点是很难的,最理想的情况是只引入那些你需要的,因为大多数的应用其实只用到 lodash 全部功能中相当少的一部分。然而,为了要让 _.chain 正常工作,你就可能会引入比你需要的用到的更多的部分。

这是因为 _.chain 函数所需要的,对输入来说,是一个(通常是类数组)对象,接着是返回,或者说是输出,会是一个带有当前 lodash 中所有可以被链式调用的函数的链对象。

1
_.chain = (array) => wrap(array, _); // chain 概念的一个粗糙版本

因此,你只能引入整个 lodash 又或者是将你想要添加的方法扩展到全局的 lodash 对象上。

整个引入是最常见的情形,其结果就是你要在你的构建中带上 lodash 全家桶。这种做法最为笨重但也最为方便简单

1
2
import _ from "lodash"; // Import everything.
_.chain([1,2,3]).map(x => x+1).value(); // Use the methods.

为了避免全盘引入带来的巨大构建大小,你可以混合你想要的函数到一个空的 lodash 对象中

1
2
3
4
5
6
7
8
9
10
11
import chain from "lodash/chain";
import value from "lodash/value";
import map from "lodash/map";
import mixin from "lodash/mixin";
import _ from "lodash/wrapperLodash";
// 添加你想要的方法,chain()就会返回拥有这些方法的对象
// 现在就有了这些方法了.
mixin(_, {map: map, chain: chain, value: value});
_.chain([1,2,3]).map(x => x+1).value(); //使用这些方法.

这显然是一种非常令人费解的处理方案,不得不引入一个空的包裹原型然后在你调用他们以前构建连接他们。更不要说,修改全局变量重来都不是一个好想法。这里有一个由 lodash 提供的,不那么令人费解的方法叫 _.runInContext ,但是这个方法最让人伤心的一点是,他只在整体构建的版本中起作用。所以我们陷入了一个两难之地。

它对扩展不友好

添加你自己的处理方法比你想象中更糟糕,他们必须被添加或者注入到这个链对象的包裹当中。有少数办法可以通过肮脏而勉强可被接受的实现做到这一点。

让我们假定一个虚构的函数,我们想要使用它来测试一个字符串数组并返回他们当中所有的元音字符

1
2
import filter from "lodash/filter";
const vowels = (array) => filter(array, str => "/[aeiou]/i".test(str);

就像上面提到的,你在整体构建以及模块化构件中都可以使用 _.mixin
而在整体构建的版本中,这样会是相当不错的实现:

1
2
3
import _ from "lodash";
_.mixin({vowels: vowels});
_.chain(['ab','cd','ef']).vowels().value(); // Use the methods.

这不算太糟糕,但是当我们转向模块化的版本的时候,事情开始变得更加混乱了。

1
2
3
4
5
6
7
8
9
10
11
12
import chain from "lodash/chain";
import value from "lodash/value";
import map from "lodash/map";
import mixin from "lodash/mixin";
import _ from "lodash/wrapperLodash";
mixin(_, {
chain: chain,
value: value,
map: map, // Add existing lodash methods you need.
vowels: vowels, // Add your own lodash methods.
});
_.chain(['ab','cd','ef']).vowels().value(); // Use the methods.

最纯净无污染使用 _.chain 的方法是用 _.tap 或者 _.thru 虽然这没有修改全局对象并且保留了流式处理结构,然而这个方法仍然需要将一切函数包裹在它们两个方法中的一个里。再一次,整体构建下与模块构建下的例子:

1
2
import _ from "lodash";
_.chain(['ab','cd','ef']).thru(vowels).value();

并且再一次的,在模块化的版本中变得十分迟钝:

1
2
3
4
5
6
7
8
9
10
11
12
13
import chain from "lodash/chain";
import value from "lodash/value";
import mixin from "lodash/mixin";
import thru from "lodash/thru";
import _ from "lodash/wrapperLodash";
// 这样一份文件只需要引入你需要的核心 lodash 方法
mixin(_, {
chain: chain,
value: value,
thru: thru,
});
_.chain(['ab','cd','ef']).thru(vowels).value();

不用这两种模式中任何一种,(相对与模块化的版本而言)是一个非常具有吸引力的想法,那么我们该做些什么呢?

无事生非,瞎折腾

对于这些问题有一个现成的解决方案,简单来说就是“不要用 chain”作为核心“来构建一个新的库”。幸运的是,这个问题也是可以被简单解决的,那些合理的方案连同那些漂亮的函数式编程工具已经存在于 lodash 工具箱中了。

解决方案

事实证明,_.chain 完全可以被别的范式所替代。在 lodash 中所提供的关于这种范式的两个的关键的机制是 函数柯里化和函数组合;这两种工具在函数式语言中都是相当普通的(比方说 Haskell, Elm, OCaml 和 PureScript),但是令人难以相信的是在普通的 javaScript 世界中这是不支持的

柯里化

要提到柯里化,我们必须首先说说关于偏函数,偏函数的意思是输入一个有 n个参数的函数然后返回一个新的函数,只有 n-i个参数(假定i \< n),i个参数具体是多少这由那个新的函数来控制。这就是说,除了最后一个参数,我们可以按顺序“锁定”每一个参数(不保留一个参数,这个函数就没有偏向了)。在 lodash 当中我们可以用 _.partial 做到这件事:

1
2
3
4
const add = (a, b) => a + b;
const add5 = _.partial(add, 5); // 现在参数a被限定为 5
const add5 = (b) => 5 + b; // 这是他内部实际上的样子
add5(3); // 等价于 add(5, 3);

函数柯里化是一个连续的顺序的偏函数,直到最后一个参数也被输入了的时候,这个函数才会返回其结果。一个进过柯里化的版本的加法函数会像是这样的:

1
2
const add = (a) => (b) => a + b;
add(1)(2); // 重复的偏函数

这就可以创造出一个将除了最后一个参数以外,其他全部的参数的值都确定了的新函数。举个例子,我们可将 add 函数进行柯里化,并得到一个总是加5的新函数:

1
const add5 = add(5); // 内含偏函数

组合

函数组合能让你实现 _.chain 最重要的本质功能(将结果从一个步骤传递到下一个步骤),而函数组合最需要说明的一点就是他只适用于函数。不过,它仍然能让那些非常长的,结合在一起的函数的可读性得到显著地提高。举个例子。一个这样的组合版本:

1
const add8 = (x) => add5(add3(x));

可以通过用组合的方式改写成更加清晰形式:

1
const add8 = compose(add5, add3);

你将会看见,通过这两个我们可以从 lodash 现有功能中得到元素,我们可以获得与 _.chain 相同的行为而又没有其不足之处。

都用起来

所以说,还记得柯里化就是让你的参数是按顺序地偏函数化。如果我们着眼于看大多数现有的 lodash 函数,会发现他们都把 数组作为他们的第一个参数:

1
2
import "map" from "lodash/map";
map([1, 2, 3], (x) => x*2);

这就是为什么我们无法偏函数化映射函数到_.map的原因。我们希望让这变为可能,那么全部这些函数,我们通常只需要输入一个数组作为他们唯一的输入:

1
2
const double = map((x) => x*2); // 我们想要这样
double([1, 2, 3]); // 现在这个操作只需要输入数组

感谢 lodash 提供所有这些函数的函数式的兼容,经过柯里化的,数据在后的版本,都在 fp 这个包当中。这个包是支持cherry-picking的,可以在v4.1.0以后的 lodash 中找到(即我们想要的,保持构建大小下降)

1
2
import map from "lodash/fp/map";
map((x) => x*2)([1, 2, 3]); // 我们有了它!

这个 rearg 的行为的文档(以及其他更多的文档)同样是于 lodash/fp 文档的一部分 。所以现在我们有了全部的工具来移除掉我们原来的 _.chain

1
2
3
4
5
6
import _ from "lodash";
_.chain([1, 2, 3])
.map(x => [x, x*2])
.flatten()
.sort()
.value();

通过使用 compose 来组合先前在上面 chain中提到的单个函数,我们可以到的同样的功能。

1
2
3
4
5
6
7
8
9
import map from "lodash/fp/map";
import flatten from "lodash/fp/flatten";
import sortBy from "lodash/fp/sortBy";
import compose from "lodash/fp/compose";
compose(
sortBy(x => x),
flatten,
map(x => [x, x*2])
)([1,2,3]);

这实际上可以得到你之前用 _.chain 所做的全部功能的一个1比1的精确映射。而这仅仅是在语法有些许不同。

注意

你可能会想要问一些(也确实该问)问题,比如:’为什么那些参数被逆序了?’,“_.value() 到哪里去了?”以及最为重要的“为什么它不工作?!”

参数的顺序

参数看起来是逆向的,这是因为这个函数的正常计算顺序就是这样的。在原始的代码中,map 在第一位,然后是 flatten 最后是 sort 。还记得 compose 是如何工作吗,你会看到这到底是发生了什么事:

1
2
3
4
5
compose(
sortBy(x => x),
flatten,
map(x => [x, x*2])
);

等同于:

1
2
3
4
5
sortBy(x => x)(
flatten(
map(x => [x, x*2])([1, 2, 3])
)
);

这里首先计算的是 map 然后将结果传递给flatten 最后传递给sort。不过,因为这还是不够直观。lodash 提供了一个反向的组合机制叫 flow,它允许将函数排序成相反的(或者说是更加自然的)顺序。

1
2
3
4
5
6
7
8
9
import map from "lodash/fp/map";
import flatten from "lodash/fp/flatten";
import sortBy from "lodash/fp/sortBy";
import flow from "lodash/fp/flow";
flow(
map(x => [x, x*2]),
flatten,
sortBy(x => x)
)([1, 2, 3]);

没有包裹对象

由于组合工作在真是的数组之上,没有了 lodash 的 chain 包裹对象, 被 _.chain 用来从 chain 对象中提取出真正的计算结果的 _.value() 方法自然也不再需要了。

有趣的是:缺少了整体构建也意味着你失去了 lodash 提供惰性计算的性能好处。然而缺少 _.chain 却不会。

实际上我们的组合化函数 flow/flowRight 在内部使用了链化(当处于整体构建的时候)来支持快速地组合已经组合过的方法。

由于这种特定的应用场景在一般的前端 lodash 使用场景中只有相当少的一部分,其性能的差异可以忽略不计。

小心柯里化

不是所有 _.chain 中的方法都能简单的复制黏贴到函数组合这个新大陆上。要记住,为了让一切工作,flow 或者 compose 必须调用一系列的函数,这些函数需要返回其每个后续函数所期望的,正确类型的返回值(这个场景中是需要一路数组到底)。

让我们看看这个 chain 过的 sort :

1
_.chain([1, 6, 2]).sortBy().value();

而这个看起来像是函数其组合化的版本却不会工作:

1
flow(sortBy)([1, 6, 2]);

为什么? 嗯,这是因为 sortBy 是柯里化的,并且类型签名看起来会像是这样:

1
const sortBy = (sorter = _.identity) => (array) => ...

这就导致了单个参数的调用会返回一个返回初始数组的函数——完全没有得到 sortBy 函数所期望的有效排序,这样当然和组合链中其他的函数不兼容。这可以归结于:

1
sortBy([1, 6, 2]);

但你真正想要的是

1
sortBy(/* _.identity */)([1, 6, 2]);

将其转化为最终的组合形式:

1
flow(sortBy())([1, 6, 2]);

由于 Javascript 是无类型的,你将不得不去小心翼翼地处理你自己这些错误,一个流式类型检查器是一个非常吸引人的选项,比如说能给一些层级的消息,可能会对定位这一类特定的问题有所帮助。

lodash/fp 函数组可能相比起他的原生版本会有稍微的不同。如果在一些场合你忽略了他,这里有一个完整的指导在 wiki 页上,是关于这个函数式大家族在 lodash 中可用部分的说明。

未来展望

这整个范式都不是新鲜的:现有的语言已经利用这些技巧非常久了。JavaScript 也在缓慢的开始向结合越来越多的像这样的函数式的工具集。这里有许多非常好的关于 函数式风格的想法,以及其他一些让你开始思考关于函数式.的资源。

举个例子,在 Haskell 中一个函数的声明总是柯里化的,并且可以被解读为一个函数接受一个参数然后返回一个新的函数去接受其他的参数:

1
2
3
add :: Num -> Num -> Num
add = x y = x + y
add 2 4

对于经过我们的柯里化的 JavaScript(唯一的一个不同之处就是,JavaScript 需要更加明确的设置这些函数的调用)来说是不是很亲切:

1
2
// Num -> Num -> Num
const add = (a) => (b) => a + b;

haskell 有着完全等价,语言级别的函数组合功能的提供,以点符号作为操作符。

1
add8 = add5 . add3

又是如此,看起来如此熟悉而又比在 javaScript 中更加优雅:

1
2
// add5 . add3
const add8 = compose(add5, add3);

Haskell不是唯一一种语言提供了这些特性作为一级类型支持。比如说,Elixir 和 bash scripts 有管道操作符(其标记分别像是这样 |> and | )来等价于 flow 操作。

为了不被淘汰,这里现在有个javaScript的语言扩展题案,是关于添加|>符号,来提供给你一些非常清晰美观的代码:

1
2
3
4
[1, 2, 3]
|> map(x => [x, x*2])
|> flatten,
|> sortBy(x => x);

总结

lodash 工具集唯一一个由于类似于这两种反范式行为之一, 没有一个好的命名以及因为把全部方法都链接到全局变量中而导致小的可怜的利用率,而遭到指责的框架。

虽然没有银弹,但是有效的函数式工具可以优雅的解决一些特定类型的问题。一旦你从如何将一切用点符号分割的境地跳脱出来。组合是一个在许多情形下都非常优秀的抽象概念, 当然也提供了一个非常清晰的,令人信服的 _.chain 替代方案。

我们这个简单的例子,其完整的形式是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
{
"presets": ["metalab"]
}
.babelrc
import _ from 'lodash';
console.log(_.chain([1,2,3]).map(x => [x,x*2]).flatten().sort().value());
app-chain.js
import map from "lodash/fp/map";
import flatten from "lodash/fp/flatten";
import sortBy from "lodash/fp/sortBy";
import flow from "lodash/fp/flow";
const run = flow(map(x => [x,x*2]), flatten, sortBy(x=>x));
console.log(run([1,2,3]));
app-flow.js
{
"name": "medium-example",
"version": "0.1.0",
"scripts": {
"build:flow": "webpack -p --config ./webpack-flow.js",
"build:chain": "webpack -p --config ./webpack-chain.js",
"build": "npm run build:flow && npm run build:chain"
},
"dependencies": {
"babel-loader": "^6.2.1",
"babel-core": "^6.4.5",
"babel-preset-metalab": "^0.2.0"
}
}
package.json
module.exports = {
entry: {
chain: './app-chain.js',
},
module: {
loaders: [{
test: /\.js$/,
loader: "babel-loader?compact=false"
}],
},
output: {
filename: "[name].js",
},
};
webpack-chain.js
module.exports = {
entry: {
flow: './app-flow.js',
},
module: {
loaders: [{
test: /\.js$/,
loader: "babel-loader?compact=false"
}],
},
output: {
filename: "[name].js",
},
};
rawwebpack-flow.js

通过wepack来构建:

其生产模式下的输出文件小了 1.4倍同时webpack的编译时间也快了将近 2倍,就像是失去了体重一样,你的结果将会非常依赖于你框架的大小。随着 tree shaking tree shaking的到来(除非你使用一个 babel 的插件,就目前而言连 lodash-es 都不被支持)以及其他的智能构建工具。要让高效益的网页应用成为现实,真正的模块化还会有很长一段路要走。

抗击黑夜,振兴javaScript 从停止使用 _.chain 开始。