翻译|The Elegance Of React

原文参见

内容

本篇文章着眼是如何编写优雅的 React 代码.我们会结合 React和Ramda,以函数式风格来编写应用.所有的概念对于 lodash/fp其他的函数式编程库也是适合的.关键在于你使用哪种库.

本文示例使用*eslint-config-cleanjs*,来强化函数式风格,其中包括no-thisno-classes规则.这些函数式规则可以让我在开始下面的示例时采用更规范.如果你对配置感兴趣,可以到 github 仓库看看具体的规则设置

Compose组件

让我们从更为容易接受的方法开始吧!看看下面的代码:

1
const comp=(f,g)=>x=>f(g(x))

如果是组件,实现代码是这样的:

1
const TodoList=(List,mapItems)=>s=>List(mapItems(s))

这么做就有意义了,可以让我们通过compose小的组件来构建更大的组件.

1
2
3
4
const List = c => <ul>{c}</ul>
const Item = todo => <li key={todo.id}>{todo.name}</li>
const TodoList = compose(List, map(Item))
const result = TodoList([{id: 1, name: 'foo'}])

TodoList是一个函数等待应用的 state并且根据对应的 state 返回新的组件.代码非常简洁,有意义.我们遍历了一些 todo 的条目,并由此创建Item列表.之后结果借助 props 传递给 TodoList,并且在组件内部渲染.

牢记:

1
const App = state => List(map(Item, state)))

组件渲染其他子组件的想法工作良好,编写的大多数组件都不依赖JSX,只依赖传递进入的 state.所以这个方法只对小的子组件起作用.

下面是使用 Ramda map,compose,prop 方法的实例代码:

1
2
3
4
5
6
7
8
9
import React from 'react'
import { render } from 'react-dom'
import { compose, map, prop } from 'ramda'
const List = items => <ul>{items}</ul>
const Item = todo => <li key={todo.id}>{todo.text}</li>
const getTodos = prop('todos')
const TodoList = compose(List, map(Item), getTodos)
const props = {todos: [{id: 1, text: 'foo'}, {id: 2, text: 'bar'}]}
render(<TodoList {...props} />, document.getElementById('root'))

Compose和组件的限制

现在我们已经可以compose 组件并渲染出 Todo list. 接下来看看更为常见的方法,也就是 props 可以自顶向下传递.这些 props可以是任何内容,包括回调函数,其他组件以及数组和对象.下面的代码和 Todo List 的代码相同,只是包含额外的 Header 组件

1
2
3
const Header = title => <h1>A Todo List: {title}</h1>
const List = items => <ul>{items}</ul>
const Item = todo => <li key={todo.id}>{todo.text}</li>

没有什么特别的,但是看看回头看看之前的实现方法,明确的表明,在 List 中包含一个 header 是不可能的.之前的compse:

1
const TodoList = compose(List, map(Item), getTodos)

实际编程中我们需要能够 compose Header 和 List 的方法, Header放在哪里合适? 我们向 TodoList 函数传递了应用的state,接着经过筛选,然后遍历筛选过的 todos创建Items 的数组,然后传递给 List.Header组件如何才能从 state 中获取到标题信息?需要更好的办法.

说的更明白一点:

1
const TodoHeader = mapStateToProps => Header(mapStateProps)

订正 这里哟一个更好的实现方法(感谢Thai Pangsakulyanont)

1
2
const TodoHeader = todoState =>
Header(getTitleFromTodoState(todoState))

我们希望是传递应用的 state接着使所有的组件各取所需的 properties.为了让思路更清晰,调用 mapStateToProps 函数

1
const mapStateToProps = curry((f, g) => compose(g, f))

mapStateToProps等待一个函数还有组件,之后首先针对提供的 state 应用函数,之后结果传递给组件.需要注意这一点,我们柯理化了函数,仅仅是想把筛选 state的定义和实际的组件分离开. 在这个问题上, Ramda的绝大多数函数都是自动柯理化的.

下面代码是如何在 Header组价中应用mapStateToProps.

1
2
const TodoHeader = mapStateToProps(s => s.title, Header)
const result = TodoHeader(state)

这看起来和 react-redux 的connect()函数和类似了.我们使用mapStateToProps把state的特定部分转化为 props. 现在 Header 和之前的 List组价可以分别获取各自的 state信息了.

1
2
const TodoList = mapStateToProps(getTodos, compose(List, map(Item))
const result = TodoList(state)

显然,mapStateToProps只解决了一部分问题,我们仍然需要 compose TodoList 和 Header来创建整个应用的能力.

使用 compose 不能解决这个问题.所以来实现我们自己的工具函数combine.需要两个组件并返回一个新的组件.

1
const combine = curry((c, o) => x => (<div>{c(x)} {o(x)}</div>))

使用 combine函数可以 compose Header 和 List创建新的函数.

1
2
3
4
const TodoHeader = mapStateToProps(s => s.title, Header)
const TodoList = mapStateToProps(getTodos, compose(List, map(Item)))
const App = combine(TodoHeader, TodoList)
render(<App {...state} />, document.getElementById('root'))

现在 compose 两个分别获取特定state 的方式已经有了.接着更进一步看看怎么 compose 更多的组件

Reducing组件

如果需要在应用中添加一个显示当前年份的 Footer 组件.怎么才能做到这一点? 首先想到的办法是:

1
const App = combine(TodoHeader, combine(TodoList, TodoFooter))

首先Combine TodoList 和 TodoFooter,接着再 combine 之前的结果和 TodoHeader.这么做是可行的,但是如果组件再多一点,代码就不太好懂了.

可以考虑像下面一样操作:

1
2
3
4
// array of components 
const comps = [TodoHeader, TodoList, TodoFooter]
const App = comps =>
reduce((acc, x) => combine(acc, x), init, comps)

有了想法,看看实际的实现

1
2
3
4
const combineComponents = (...args) => {
const [first, ...rest] = args
return reduce((acc, c) => combine(acc, c), first, rest)
}

参考 redux中的combineReducers,我们把自己的 reducer 称为combineComponents, combineComponents 接收一组组件,并 Reduce为等待组件 state 的单个函数.

1
2
const App = combineComponents(TodoHeader, TodoList, TodoFooter)
render(<App {...state} />, document.getElementById('root'))

有了mapStateToProps, combine, combineComponents 的协助,我们现在就可以 compose 组件了.考虑到 mapStateToProps, 我们可以最一下最后的提炼.看看刚开始的实现方法

1
const mapStateToProps = curry((f, g) => compose(g, f))

实际上,我们完全没有必要自己实现它.Ramda或者 lodash/fp 已经提供了一个函数:pipe. pipe函数从左至右运行所有的函数.看看下面的例子

1
2
3
4
5
6
const add = x => x + 1
const multiplyByFour = x => x * 4
// pipe === flip(compose)
const rCompose = flip(compose)
rCompose(add, multiplyByFour)(1) === compose(multiplyByFour, add)(1)
rCompose(add, multiplyByFour)(1) === pipe(add, multiplyByFour)(1)

所以pipecompose很像,只不过参数方向是相反的. 我们使用了 Ramda的flip函数,这个函数在本实例中 翻转两个参数的方向.意味着,我们现在可以重构 mapStatToProps为:

1
const mapStateToProps = pipe

或者直接使用pipe函数,让 Ramda 担起全责.这么做以后,留给我们两个函数combinecombineRedcers需要处理.甚至combine 函数都可以隐藏起来,但是为了推理清晰一点,还是保留吧!

完整的代码如下:

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
import React from 'react'
import { render } from 'react-dom'
import { compose, map, prop, curry, reduce, pipe } from 'ramda'

const combine = curry((c, o) => x => (<div>{c(x)} {o(x)}</div>))
const combineComponents = (...args) => {
const [first, ...rest] = args
return reduce((acc, c) => combine(acc, c), first, rest)
}

const state = {
year: '2016',
title: 'Random stuff',
todos: [{id:1, text: 'foo'}, { id:2, text: 'bar'}]
}

const getTodos = prop('todos')

const Header = title => <h1>A Todo List: {title}</h1>
const List = items => <ul>{items}</ul>
const Item = todo => <li key={todo.id}>{todo.text}</li>
const Footer = text => <div>{text}</div>

const TodoHeader = pipe(s => s.title, Header)
const TodoList = pipe(getTodos, compose(List, map(Item)))
const TodoFooter = pipe(s => s.year, Footer)

const App = combineComponents(TodoHeader, TodoList, TodoFooter)
const result = render(<App {...state} />, document.getElementById('root'))

Adding Redux

Reduce一切组件? 下面的伪代码,帮助我们创建要达成目标的心里模型

1
const App=(state,action)=>TodoList

上面的代码看起来有点像典型的 Redux reducers,不同点是我们在这里返回的是一个 React 组件,并不是经过计算的state.如果要借助 Readux 来完成这个目标? 试试看

我们仍然来构建一个 TodoList,并且保持清晰明了,会使用译注 redux todomvc 的actions 和 todo reducer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

// constants
const ADD_TODO = 'ADD_TODO'
const DELETE_TODO = 'DELETE_TODO'
// actions
const addTodo = text => ({type: ADD_TODO, text })
const deleteTodo = id => ({ type: DELETE_TODO, id })
// reducers
const todos = createReducer([], {
[ADD_TODO]: (state, action) => [
{ id: getNextId(state), completed: false, text: action.text },
...state
],
[DELETE_TODO]:(state, action) =>
reject(propEq('id', action.id), state),
})

部分原始的reducer 代码通过使用 Ramda的 reject
propEq重构来过滤已经删除掉的 todo条目.万一你想知道reject是什么函数,reject是 filter的补集.我们可以编写一组助手函数:

1
2
3
4
5
6
7
8
9
// redux utils
// alternative is to use defaultTo instead propOr
const createReducer = (init, handlers) =>
(state = init, action) =>
propOr(identity, prop('type', action), handlers)(state, action)
const addOne = add(1)
const getAllIds = pluck('id')
const getMax = reduce(max, 0)
const getNextId = compose(addOne, getMax, getAllIds)

getNextId是用于获取下一个 id 的函数,在添加新的条目是,需要用到它.createReducer已经在在 Redux的顶层输出中出现了,但是这里的是使用 Ramda重写的版本.
现在我们已经有了 reducers和 Action.现在需要适配他们和我们的组件以便于处理添加和删除组件.为了保持简单,我们用一个 add 按钮来代替输入文本添加 todo项文本的操作.

1
2
3
4
5
const Add = onSave => (
<div>
<button onClick={() => onSave('foobar')}>Add</button>
</div>
)

最后还需要一个删除按钮.在 Item组件中添加一个删除按钮就足够了.

1
2
3
4
5
const Item = ({todo, removeTodo }) => (
<li key={todo.id}>
{todo.text} <button onClick={removeTodo}>Remove</button>
</li>
)

需要的部分都已经就绪了.这里仍然有一些部分需要澄清:removeTodo应该 dispatch deleteTodo action. 另一个需要考虑的方面是,我们需要一个方法定义必须要提供的 dispatcher. 现在我们还仅仅是映射 state 到 props.

来添加一个 getRender函数,等待输入应用入口节点,返回一个等待 React 组件的函数.

1
2
3
const getRender = node => app => ReactDOM.render(app, node)
const render = getRender(document.getElementById('root'))
render(<App {...state} />)

接下来编写一个 bindActionCreator.

1
2
3
4
// define a bindActionCreator
const bindAction = curry((dispatch, actionCreator) =>
compose(dispatch, actionCreator))
const bindActionCreator = bindAction(store.dispatch)

接着隐藏掉 dispatch方法,同时传递bindActionCreator 和 state 到应用,并且订阅到 Redux 的 store,当代触发渲染. 要声明一下, Redux 已经有可以直接使用的 bindActionCreators 函数.

1
2
3
4
5
const run = store.subscribe(() =>
render(
<App {...store.getState()} dispatch={bindActionCreator} />
)
)

最后的一些收尾工作是适配 Item和 TodoList 组件, Items 期待 todo条目还有 onDelete函数

1
2
3
4
5
6
const Item = ({todo, onDelete}) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => onDelete(todo.id)}>Remove</button>
</li>
)

因为现在 Item组件也需要onDelete函数,我们需要适配map to props函数.我们已经获取了 dispatch,所以返回一个todo items 的数组了,需要范湖一个包含 todo数组和onDelete函数的对象.

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// for clearer understanding extracted mapItems
const mapItems = ({todos, onDelete}) =>
map(todo => Item({todo, onDelete}), todos)
const TodoList = pipe(props =>
({todos: props.todos, onDelete: props.dispatch(deleteTodo)}),
compose(List, mapItems)
)
```

下面就是最终的代码,也可以查看[`实例代码`](https://plnkr.co/edit/adPsyH9r6OIjsu9fDhgz?p=preview)

```js
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, combineReducers } from 'redux'
import * as R from 'ramda'

// composition helper
const combine = R.curry((c, o) => x => (<div>{c(x)} {o(x)}</div>))
const combineComponents = (...args) => {
const [first, ...rest] = args
return R.reduce((acc, c) => combine(acc, c), first, rest)
}

// helpers
const targetValue = e => e.target.value
const getTodos = R.prop('todos')

// redux utils
const createReducer = (init, handlers) =>
(state = init, action) =>
R.propOr(R.identity, R.prop('type', action), handlers)(state, action)

const addOne = R.add(1)
const getAllIds = R.pluck('id')
const getMax = R.reduce(R.max, 0)
const getNextId = R.compose(addOne, getMax, getAllIds)

// constants
const ADD_TODO = 'ADD_TODO'
const DELETE_TODO = 'DELETE_TODO'

// actions
const addTodo = text => ({type: ADD_TODO, text })
const deleteTodo = id => ({ type: DELETE_TODO, id })

// reducers
const todos = createReducer([], {
[ADD_TODO]: (state, action) => [
{ id: getNextId(state), completed: false, text: action.text },
...state
],
[DELETE_TODO]:(state, action) => R.reject(R.propEq('id', action.id), state),
})

const year = createReducer('', {})
const title = createReducer('', {})

// combine reducer and create store
const reducers = combineReducers({todos, year, title})

const initialState = {
year: '2016',
title: 'Random stuff',
todos: [{id:1, text: 'foo'}, { id:2, text: 'bar'}]
}

const store = createStore(reducers, initialState)

// components
const Header = title => <h1>A Todo List: {title}</h1>
const Add = ({onSave}) => (
<div>
<button onClick={() => onSave('foobar')}>Add</button>
</div>
)
const List = items => <ul>{items}</ul>
const Item = ({todo, onDelete}) => (
<li key={todo.id}>
{todo.text} <button onClick={() => onDelete(todo.id)}>Remove</button>
</li>
)
const Footer = text => <div>{text}</div>

// define a bindActionCreator
const bindAction = R.curry((dispatch, actionCreator) =>
R.compose(dispatch, actionCreator))
const bindActionCreator = bindAction(store.dispatch)

// map state to props
const TodoHeader = R.pipe(props => props.title, Header)
const TodoAdd = R.pipe(props => ({onSave: props.dispatch(addTodo)}), Add)
const mapItems = ({todos, onDelete}) =>
R.map(todo => Item({todo, onDelete}), todos)
const TodoList = R.pipe(props =>
({todos: props.todos, onDelete: props.dispatch(deleteTodo)}),
R.compose(List, mapItems)
)
const TodoFooter = R.pipe(props => props.year, Footer)

// combine all components
const App = combineComponents(TodoHeader, TodoAdd, TodoList, TodoFooter)

// we could also have used curry...
const getRender = node => app => ReactDOM.render(app, node)
const render = getRender(document.getElementById('root'))

const run = store.subscribe(() =>
render(<App {...store.getState()} dispatch={bindActionCreator} />))

// start
const init = store.dispatch({type: '@@INIT'})

Outro

这篇文章的目的是介绍如何联合 Ramda,React和 Redux来编写更加优雅的代码.
例子只是用来说明如何在React 或者 Redux 中使用 Ramda.在实际的编程中,你可以在应用的某些部分借助Ramda或者 lodash/fp 来编写优雅的代码.

例如可以重构mapDispatchToProps函数,根据定义好的 propTypes自动映射 state到应用的props,代替手动输入.

1
2
3
4
5
const getPropTypes = prop('propTypes')
const pickKeys = compose(pick, keys)
const mapStateToProps = compose(pickKeys, getPropTypes)
// map state to defined propTypes.
export default connect(mapStateToProps(App))(App)

也可以使用 Ramda 的 pick函数替代 mapDispatchToProps 函数.

1
export default connect(pick(['todos']))(App)

If you have any questions or feedback don’t hesitate to leave feedback @ twitter.

这篇文章受到Brian Lonsdorf 在 React Rally上演讲的启发.