12

原文:Functional Components with React stateless functions and Ramda

阅读本文需要的知识储备:

  • 函数式编程基本概念(组合、柯里化、透镜)
  • React 基本知识(组件、状态、属性、JSX)
  • ES6 基本知识(class、箭头函数)

React 无状态函数

React 组件最常见的定义方法:

const  List = React.createClass({
  render: function() {
    return (<ul>{this.props.children}</ul>);
  }
});

或者使用 ES6 类语法:

class List extends React.Component {
  render() {
    return (<ul>{this.props.children}</ul>);
  }
}

又或者使用普通的 JS 函数:

// 无状态函数语法
const List = function(children) {
  return (<ul>{children}</ul>);
};

//ES6 箭头函数语法
const List = (children) => (<ul>{children}</ul>);

React 官方文档对这种组件做了以下说明:

这种简化的组件 API 适用于仅依赖属性的纯函数组件。这些组件不允许拥有内部状态,不会生成组件实例,也没有组件的生命周期方法。它们只对输入进行纯函数转换。不过开发者仍然可以为它们指定 .propTypes.defaultProps,只需要设置为函数的属性就可以了,就跟在 ES6 类上设置一样。

同时也说到:

理想情况下,大部分的组件都应该是无状态的函数,因为在未来我们可能会针对这类组件做性能优化,避免不必要的检查和内存分配。所以推荐大家尽可能的使用这种模式来开发。

是不是觉得挺有趣的?

React 社区似乎更加关注通过 classcreateClass 方式来创建组件,今天让我们来尝鲜一下无状态组件。

App 容器

首先让我们来创建一个函数式 App 容器组件,它接受一个表示应用状态的对象作为参数:

import React from 'react';
import ReactDOM from 'react-dom';

const App = appState => (<div className="container">
  <h1>App name</h1>
  <p>Some children here...</p>
</div>);

然后,定义一个 render 方法,作为 App 函数的属性:

import React from 'react';
import ReactDOM from 'react-dom';
import R from 'ramda';

const App = appState => (<div className="container">
  <h1>App name</h1>
  <p>Some children here...</p>
</div>);

App.render = R.curry((node, props) => ReactDOM.render(<App {...props}/>, node));

export default App;

等等!有点看不明白了!
为什么我们需要一个柯里化的渲染函数?又为什么渲染函数的参数顺序反过来了?
别急别急,这里唯一要说明的是,由于我们使用的是无状态组件,所以状态必须由其它地方来维护。也就是说,状态必须由外部维护,然后通过属性的方式传递给组件。
让我们来看一个具体的计时器例子。

无状态计时器组件

一个简单的计时器组件只接受一个属性 secondsElapsed

import React from 'react';

export default ({ secondsElapsed }) => (<div className="well">
  Seconds Elapsed: {secondsElapsed}
</div>);

把它添加到 App 中:

import React from 'react';
import ReactDOM from 'react-dom';
import R from 'ramda';
import Timer from './timer';

const App = appState => (<div className="container">
  <h1>App name</h1>
  <Timer secondsElapsed={appState.secondsElapsed} />
</div>);

App.render = R.curry((node, props) => ReactDOM.render(<App {...props}/>, node));

export default App;

最后,创建 main.js 来渲染 App

import App from './components/app';
const render = App.render(document.getElementById('app'));

let appState = {
  secondsElapsed: 0
};

//first render
render(appState);

setInterval(() => {
  appState.secondsElapsed++;
  render(appState);
}, 1000);

在进一步说明之前,我想说,appState.secondElapsed++ 这种修改状态的方式让我觉得非常不爽,不过稍后我们会使用更好的方式来实现。

这里我们可以看出,render 其实就是用新属性来重新渲染组件的语法糖。下面这行代码:

const render = App.render(document.getElementById(‘app’));

会返回一个具有 (props) => ReactDOM.render(...) 函数签名的函数。
这里并没有什么太难理解的内容。每当 secondsElapsed 的值改变后,我们只需要重新调用 render 方法即可:

setInterval(() => {
  appState.secondsElapsed++;
  render(appState);
}, 1000);

现在,让我们来实现一个类似 Redux 风格的归约函数,以不断的递增 secondsElapsed。归约函数是不允许修改当前状态的,所有最简单的实现方式就是 currentState -> newState

这里我们使用 Ramda 的透镜(Lens)来实现 incSecondsElapsed 函数:

const secondsElapsedLens = R.lensProp('secondsElapsed');
const incSecondsElapsed = R.over(secondsElapsedLens, R.inc);

setInterval(() => {
  appState = incSecondsElapsed(appState);
  render(appState);
}, 1000);

第一行代码中,我们创建了一个透镜:

const secondsElapsedLens = R.lensProp('secondsElapsed');

简单来说,透镜是一种专注于给定属性的方式,而不关心该属性到底是在哪个对象上,这种方式便于代码复用。当我们需要把透镜应用于对象上时,可以有以下操作:

  • View
R.view(secondsElapsedLens, { secondsElapsed: 10 });  //=> 10
  • Set
R.set(secondsElapsedLens, 11, { secondsElapsed: 10 });  //=> 11
  • 以给定函数来设置
R.over(secondsElapsedLens, R.inc, { secondsElapsed: 10 });  //=> 11

我们实现的 incSecondsElapsed 就是对 R.over 进行局部应用的结果。

const incSecondsElapsed = R.over(secondsElapsedLens, R.inc);

该行代码会返回一个新函数,一旦调用时传入 appState,就会把 R.inc 应用在 secondsElapsed 属性上。

需要注意的是,Ramda 从来都不会修改对象,所以我们需要自己来处理脏活:

appState = incSecondsElapsed(appState);

如果想支持 undo/redo ,只需要维护一个历史数组记录下每一次状态即可,或者使用 Redux 。

目前为止,我们已经品尝了柯里化和透镜,下面让我们继续品尝组合

组合 React 无状态组件

当我第一次读到 React 无状态组件时,我就在想能否使用 R.compose 来组合这些函数呢?答案很明显,当然是 YES 啦:)

让我们从一个 TodoList 组件开始:

const TodoList = React.createClass({
  render: function() {
    const createItem = function(item) {
      return (<li key={item.id}>{item.text}</li>);
    };

    return (<div className="panel panel-default">
      <div className="panel-body">
        <ul>
          {this.props.items.map(createItem)}
        </ul>
      </div>
    </div>);
  }
});

现在问题来了,TodoList 能否通过组合更小的、可复用的组件来实现呢?当然,我们可以把它分割成 3 个小组件:

  • 容器
const Container = children => (<div className="panel panel-default">
  <div className="panel-body">
    {children}
  </div>
</div>);
  • 列表
const List = children => (<ul>
  {children}
</ul>);
  • 列表项
const ListItem = ({ id, text }) => (<li key={id}>
  <span>{text}</span>
</li>);

现在,我们来一步一步看,请一定要在理解了每一步之后才往下看:

Container(<h1>Hello World!</h1>);

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <h1>Hello World!</h1>
 *    </div>
 *  </div>
 */

Container(List(<li>Hello World!</li>));

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <ul>
 *        <li>Hello World!</li>
 *      </ul>
 *    </div>
 *  </div>
 */

const TodoItem = {
  id: 123,
  text: 'Buy milk'
};
Container(List(ListItem(TodoItem)));

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <ul>
 *        <li>
 *          <span>Buy milk</span>
 *        </li>
 *      </ul>
 *    </div>
 *  </div>
 */

没有什么太特别的,只不过是一步一步的传参调用。

接着,让我们来做一些组合的练习:

R.compose(Container, List)(<li>Hello World!</li>);

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <ul>
 *        <li>Hello World!</li>
 *      </ul>
 *    </div>
 *  </div>
 */

const ContainerWithList = R.compose(Container, List);
R.compose(ContainerWithList, ListItem)({id: 123, text: 'Buy milk'});

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <ul>
 *        <li>
 *          <span>Buy milk</span>
 *        </li>
 *      </ul>
 *    </div>
 *  </div>
 */

const TodoItem = {
  id: 123,
  text: 'Buy milk'
};
const TodoList = R.compose(Container, List, ListItem);
TodoList(TodoItem);

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <ul>
 *        <li>
 *          <span>Buy milk</span>
 *        </li>
 *      </ul>
 *    </div>
 *  </div>
 */

发现了没!TodoList 组件已经被表示成了 ContainerListListItem 的组合了:

const TodoList = R.compose(Container, List, ListItem);

等等!TodoList 这个组件只接受一个 todo 对象,但是我们需要的是映射整个 todos 数组:

const mapTodos = function(todos) {
  return todos.map(function(todo) {
    return ListItem(todo);
  });
};
const TodoList = R.compose(Container, List, mapTodos);
const mock = [
  {id: 1, text: 'One'},
  {id: 1, text: 'Two'},
  {id: 1, text: 'Three'}
];
TodoList(mock);

/**
 *  <div className="panel panel-default">
 *    <div className="panel-body">
 *      <ul>
 *        <li>
 *          <span>One</span>
 *        </li>
 *        <li>
 *          <span>Two</span>
 *        </li>
 *        <li>
 *          <span>Three</span>
 *        </li>
 *      </ul>
 *    </div>
 *  </div>
 */

能否以更函数式的方式简化 mapTodos 函数?

// 下面的代码
return todos.map(function(todo) {
  return ListItem(todo);
});

// 等效于
return todos.map(ListItem);

// 所以变成了
const mapTodos = function(todos) {
  return todos.map(ListItem);
};

// 等效于使用 Ramda 的方式
const mapTodos = function(todos) {
  return R.map(ListItem, todos);
};

// 注意 Ramda 的两个特点:
// - Ramda 函数默认都支持柯里化
// - 为了便于柯里化,Ramda 函数的参数进行了特定排列,
//   待处理的数据通常放在最后
// 因此:
const mapTodos = R.map(ListItem);

//此时就不再需要 mapTodos 了:
const TodoList = R.compose(Container, List, R.map(ListItem));

哒哒哒!完整的 TodoList 实现代码如下:

import React from 'React';
import R from 'ramda';

const Container = children => (<div className="panel panel-default">
  <div className="panel-body">
    {children}
  </div>
</div>);

const List = children => (<ul>
  {children}
</ul>);

const ListItem = ({ id, text }) => (<li key={id}>
  <span>{text}</span>
</li>);

const TodoList = R.compose(Container, List, R.map(ListItem));

export default TodoList;

其实,还少了一样东西,不过马上就会加上。在那之前让我们先来做些准备:

  • 添加测试数据到应用状态
let appState = {
  secondsElapsed: 0,
  todos: [
    {id: 1, text: 'Buy milk'},
    {id: 2, text: 'Go running'},
    {id: 3, text: 'Rest'}
  ]
};
  • 添加 TodoListApp
import TodoList from './todo-list';
const App = appState => (<div className="container">
  <h1>App name</h1>
  <Timer secondsElapsed={appState.secondsElapsed} />
  <TodoList todos={appState.todos} />
</div>);

TodoList 接受的是一个 todos 数组,但是这里却是:

<TodoList todos={appState.todos} />

我们把列表传递作为一个属性,所以等效于:

TodoList({todos: appState.todos});

因此,我们必须修改 TodoList,以便让它接受一个对象并且取出 todos 属性:

const TodoList = R.compose(Container, List, R.map(ListItem), R.prop('todos'));

这里并没有什么高深技术。仅仅是从右到左的组合,R.prop('todos') 会返回一个函数,调用该函数会返回其作为的参数对象的 todos 属性,接着把该属性值传递给 R.map(ListItem),如此往复:)

以上就是本文的尝鲜内容。希望能对大家有所帮助,这仅仅是我基于 React 和 Ramda 做的一部分实验。未来,我会努力尝试覆盖高阶组件和使用 Transducer 来转换无状态函数。

完整源码线上演示代码(译者新增)。


mingzhong
2.1k 声望3.2k 粉丝

世界很美好,时间很宝贵。