轻松将 Ramda 集成到您的 React 工作流中
最初发表于Hint 的博客。
在 Hint,我们经常使用 React 编写用户界面。我们喜欢它的声明式 API、易于团队沟通和协作的思维模型,尤其是最近新增的 hooks。然而,React 并没有提供完整的工具包。它缺少一些开箱即用的功能:数据获取、异步函数处理、以实用的方式应用样式等等。
在我学习 React 的过程中,我发现 React 功能集中最大的漏洞其实是 JavaScript 本身的问题。相比 Ruby 或 Elixir 等其他依赖大量工具包的语言,JavaScript 提供的可用资源并不多。我开始编写自己的辅助库,直到一位朋友告诉我关于 Ramda 的信息。Ramda的主页上是这样写的:
面向 JavaScript 程序员的实用函数库。
嘿!我喜欢函数式的东西、库、JavaScript……而且我还是个程序员!我对它一见钟情(不,我一点也不觉得羞耻)。
Ramda 的第一个障碍是函数式编程。如果你从未涉足函数式编程领域,请阅读Randy Coulman 的“Thinking in Ramda”系列,非常精彩。
作为一名 React 开发者,Ramda 的第二个挑战是如何有效地将它与 React 结合使用。我仍在学习和尝试这两个库如何协同工作,我想分享一些过去几年我坚持的模式。让我们开始吧!
isNil
使用And让你的代码更易读isEmpty
有时候,React 代码的可读性并不好。我认为,后 Hook 时代的情况更糟。组件主体中添加的逻辑越来越多,而且没有生命周期方法可以自动组织代码render
,因此我只能尽力清理代码。
Ramda 的isNil
和isEmpty
是让你的组件主体炫目的一个很好的开始🕺。例如:
const Entry = ({ client }) => (
<Query query={currentUserQuery}>
{({ loading, data }) => {
if (!loading && !data.user.posts)
return <NoPosts />
if (data.user) {
setErrorTrackingContext(data.user)
getPostMetaData(data.user, client)
}
return (
// code that renders things here
)
}}
</Query>
)
关于代码示例的说明:本文中的所有代码均基于我编写的真实代码。文中引用了一些Hint 非常喜欢的Apollo React 库。为了简洁起见,大多数导入代码均已删除。本文没有博客文章式的、fooBar
填充式的、伪代码。几乎可以投入生产™。
注意第一点if
:如果加载完成且data.user.posts
为假值,我们会提前返回一个组件。第二点if
:如果有用户,让我们设置正在使用的错误跟踪上下文(提示:我们喜欢Honeybadger),然后获取一些帖子元数据。我们先不考虑这些函数的具体实现,专注于逻辑。乍一看,情况还不错——但“还不错”并非标准。卓越才是!让我们再来一次,不过使用 Ramda:
import { isNil, isEmpty } from 'ramda'
const Entry = ({ client }) => (
<Query query={currentUserQuery}>
{({ loading, data }) => {
if (isNil(loading) && isEmpty(data.user.posts))
return <NoPosts />
if (data.user) {
setErrorTrackingContext(data.user)
getPostMetaData(data.user, client)
}
return (
// code that renders things here
)
}}
</Query>
)
注意顶部的 ,以及对第一个.import
的更新,如果是或 ,则会返回。这个函数非常有用,因为它不仅仅检查值是否为,这本质上就是它之前所做的()。后腿免于一个讨厌的虫子!if
isNil
true
loading
null
undefined
falsy
!loading
在同一行,如果传入的值是、或 ,则isEmpty
返回。使用 GraphQL 时,如果你请求一个集合,但没有任何值,通常会返回一个空数组。我们之前的逻辑检查,也可能引入了一个意想不到的错误!Hindquarters 再次得救了。true
''
[]
{}
!data.user.posts
专业提示
第一点,已经是专业提示了?今天是个好日子。
Ramda 由许多具有特定用途的小函数组成。将它们组合在一起,你就能创造出一些有趣的东西!让我们创建一个与 逆相关的助手isNil
:
import { isNil, isEmpty, complement } from 'ramda'
const isPresent = complement(isNil)
const Entry = ({ client }) => (
<Query query={currentUserQuery}>
{({ loading, data }) => {
if (isNil(loading) && isEmpty(data.user.posts))
return <NoPosts />
if (isPresent(data.user)) {
setErrorTrackingContext(data.user)
getPostMetaData(data.user, client)
}
return (
// code that renders things here
)
}}
</Query>
)
complement
它的第一个参数是一个函数,第二个参数是一个值。如果调用时返回一个假值,则输出为true
(反之亦然)。使用complement
可以使我们的第二个参数if
更美观一些。
你可能会说:“这真的很简单。为什么 Ramda 没有自带这样的辅助函数呢?” 可以把 Ramda 函数想象成一个个独立的乐高积木。它们单独使用时功能不多,但把它们组合起来,就能创造出非常有用的东西。如果你想要一套更“全面的实用程序”,可以看看Ramda Adjunct。
单独操作对象很危险!以下函数prop
:path
如果你明白标题笑话的意思,网络积分+1
作为开发者,没有什么比深度访问对象更可怕的了。如果这还不让你感到畏缩的话:
if (foo.bar.baz.theLastPropertyIPromise.justKiddingOneMore) doTheThing()
那我们需要谈谈。如果你提出的解决方案是:
if (
foo &&
foo.bar &&
foo.bar.baz &&
foo.bar.baz.theLastPropertyIPromise &&
foo.bar.baz.theLastPropertyIPromise.justKiddingOneMore
)
doTheThing()
那我们确实需要谈谈。
玩笑归玩笑,我们都经历过这种情况。很容易完全忽略复杂的检查,或者编写占用过多字节且难以阅读的条件语句。Ramda 为我们提供了prop
和path
来安全地访问对象。让我们看看它们是如何工作的:
import { prop, path, pipe } from 'ramda'
const obj = { foo: 'bar', baz: { a: 1, b: 2 } }
const getFoo = prop('foo')
getFoo(obj) // => 'bar'
const getBazA = path(['baz', 'a'])
getBazA(obj) // => 1
太棒了!“但这又怎么安全呢?你要求的所有属性都存在!”很高兴你问了这个问题:
import { path, pipe } from 'ramda'
const obj = { foo: 'bar', baz: { a: 1, b: 2 } }
const getSomethingThatDoesNotExist = path([
'foo',
'bar',
'baz',
'theLastPropertyIPromise',
'justKiddingOneMore'
])
getSomethingThatDoesNotExist(obj) // => undefined
感谢 Ramda!Hindquarters 又一次得救了。注意undefined
,返回的是一个假值。这对于存在性检查非常有用!让我们将新学到的知识应用到我们的<Entry />
组件中:
import { isNil, isEmpty, complement, prop } from 'ramda'
const getUser = prop('user')
const userIsPresent = pipe(
getUser,
complement(isNil)
)
const Entry = ({ client }) => (
<Query query={currentUserQuery}>
{({ loading, data }) => {
if (isNil(loading) && isEmpty(data.user.posts))
return <NoPosts />
if (userIsPresent(data)) {
const user = getUser(data)
setErrorTrackingContext(user)
getPostMetaData(user, client)
}
return (
// code that renders things here
)
}}
</Query>
)
看起来确实好多了。第二个条件还可以进一步重构if
。为了好玩,看看你能不能用 Ramda 把它合并if
成一个函数。答案就在文章末尾!
准备道具evolve
将组件 props 转换为有用的内容是常见的做法。让我们来看这个例子,其中我们连接了姓和名,并格式化了日期:
const NameAndDateDisplay = ({ date, firstName, lastName }) => (
<>
<div>
Hello {firstName.toUpperCase()} {lastName.toUpperCase()}!
</div>
<div>It is {dayjs(date).format('M/D/YYYY dddd')}</div>
</>
)
这段代码看似简单,但其实有些蹊跷。你能发现吗?问题在于它太过简单了。处理真实数据、真实 API 以及人类编写的真实代码时,事情并不总是那么简单。有时,你的项目会使用第三方 API,而你无法完全控制从服务器返回的内容。
在这些情况下,我们倾向于将所有逻辑都放入组件主体中,如下所示:
const NameAndDateDisplay = ({ date, firstName, lastName }) => {
const formattedDate = formatDate(date)
const formattedFirstName = formatFirstName(firstName)
const formattedLastName = formatLastName(lastName)
return (
<>
<div>
Hello {firstName} {lastName}!
</div>
<div>It is {formattedDate}</div>
</>
)
}
这带来了一些问题。一些非常重要的逻辑被绑定到组件主体中,这给测试带来了困难。测试这些格式化程序的唯一方法是渲染组件。此外,这确实使组件主体臃肿不堪。在 Rails 中,你会听到“胖模型,瘦控制器”的说法;在 React 中,类似的术语是“胖助手,瘦组件主体”。
幸运的是,Ramdaevolve
确实可以帮助我们。evolve
它接受两个参数;第一个参数是其值为函数的对象,第二个参数是您想要操作的对象。
import { evolve, toUpper } from 'ramda'
evolve({ foo: toUpper }, { foo: 'weeee' })
// => { foo: 'WEEEE' }
太棒了!有两点需要注意evolve
:它是递归的,并且它不会对第一个参数中未指定的值进行运算。
import { evolve, toUpper, add } from 'ramda'
const format = evolve({
foo: toUpper,
numbers: { a: add(2) },
dontTouchMe: 'foobar'
})
format({ foo: 'weeee', numbers: { a: 3 } })
// => { foo: 'WEEEE', numbers: { a: 5 }, dontTouchMe: 'foobar' }
有了这些新知识,让我们重构我们的组件:
import { evolve, pipe } from 'ramda'
const prepProps = evolve({
date: formatDate,
firstName: formatFirstName,
lastName: formatLastName
})
const NameAndDateDisplay = ({ date, firstName, lastName }) => (
<>
<div>
Hello {firstName} {lastName}!
</div>
<div>It is {date}</div>
</>
)
export default pipe(
prepProps,
NameAndDateDisplay
)
太棒了!我们成功地将格式化代码与渲染代码分离了。
总结
React 和 Ramda 都是非常强大的工具。了解它们的工作原理和交互方式可以简化并加快开发速度。
以后,当你需要将辅助库从一个项目复制粘贴到另一个项目时,请务必记住 Ramda。很有可能,Ramda 函数可以完成相同的任务,甚至更多!本文未涵盖更多 Ramda 函数。请参阅Ramda 文档了解更多信息。
重构答案
我们的第二个if
条件,完全重构:
// setErrorTrackingContextAndGetPostMetaData.js
import { prop, pipe, complement, when, converge, curry, __ } from 'ramda'
const getUser = prop('user')
const userIsPresent = pipe(
getUser,
complement(isNil)
)
const curriedGetPostMetaData = curry(getPostMetaData)
const setErrorTrackingContextAndGetPostMetaData = client =>
when(
userIsPresent,
converge(getUser, [
setErrorTrackingContext,
curriedGetPostMetaData(__, client)
])
)
export default setErrorTrackingContextAndGetPostMetaData
// Entry.js
// in the body of <Entry />
// ...
setErrorTrackingContextAndGetPostMetaData(client)(data)
// ...