组合 ( composition )

React 最大的好处是它的可组合性。就我个人而言,我不知道还有哪个框架能提供这样如此简单地创建和组合组件的方式。本节我们将探讨一些好用的组合技巧,这些技巧都是经过实战验证的。

我们来看一个简单示例。假设我们的应用有一个头部,我们想在头部中放置导航。我们有三个 React 组件 —AppHeaderNavigation 。这三个组件是一个嵌套一个的,所以我们得到的依赖关系如下:

  1. <App> -> <Header> -> <Navigation>

组合这些组件的简单方法是在需要它们的时候引用即可。

  1. // app.jsx
  2. import Header from './Header.jsx';
  3. export default function App() {
  4. return <Header />;
  5. }
  6. // Header.jsx
  7. import Navigation from './Navigation.jsx';
  8. export default function Header() {
  9. return <header><Navigation /></header>;
  10. }
  11. // Navigation.jsx
  12. export default function Navigation() {
  13. return (<nav> ... </nav>);
  14. }

但是,这种方式会引入一些问题:

  • 我们可以把 App 看作是主要的组合场所。Header 可能还有其他元素,比如 logo、搜索框或标语。如果它们是以某种方式通过 App 组件传入的就好了,这样我们就无需创建目前这种硬编码的依赖关系。再比如说我们如果需要一个没有 NavigationHeader 组件该怎么办?我们无法轻松实现,因为我们将这两个组件紧绑在了一起。
  • 代码很难测试。在 Header 中或许有一些业务逻辑,要测试它的话我们需要创建出一个组件实例。但是,因为它还导入了其他组件,所以我们还要为这些导入的组件创建实例,这样的话测试就变的很重。如果 Navigation 组件出了问题,那么 Header 组件的测试也将被破坏,这完全不是我们想要的效果。(注意: 浅层渲染 ( shallow rendering ) 通过不渲染 Header 组件嵌套的子元素能在一定程度上解决此问题。)

使用 React children API

React 提供了便利的 children 属性。通过它父组件可以读取/访问它的嵌套子元素。此 API 可以使得 Header 组件不用知晓它的嵌套子元素,从而解放之前的依赖关系:

  1. export default function App() {
  2. return (
  3. <Header>
  4. <Navigation />
  5. </Header>
  6. );
  7. }
  8. export default function Header({ children }) {
  9. return <header>{ children }</header>;
  10. };

注意,如果不在 Header 中使用 { children } 的话,那么 Navigation 组件永远不会渲染。

现在 Header 组件的测试变得更简单了,因为完全可以使用空 <div> 来渲染 Header 组件。这会使用组件更独立,并让我们专注于应用的一小部分。

将 child 作为 prop 传入

每个 React 组件都接收属性。正如之前所提到的,关于传入的属性是什么并没有任何严格的规定。我们甚至可以传入其他组件。

  1. const Title = function () {
  2. return <h1>Hello there!</h1>;
  3. }
  4. const Header = function ({ title, children }) {
  5. return (
  6. <header>
  7. { title }
  8. { children }
  9. </header>
  10. );
  11. }
  12. function App() {
  13. return (
  14. <Header title={ <Title /> }>
  15. <Navigation />
  16. </Header>
  17. );
  18. };

当遇到像 Header 这样的组件时,这种技术非常有用,它们需要对其嵌套的子元素进行决策,但并不关心它们的实际情况。

高阶组件

很长一段时期内,高阶组件都是增强和组合 React 元素的最流行的方式。它们看上去与 装饰器模式 十分相似,因为它是对组件的包装与增强。

从技术角度来说,高阶组件通常是函数,它接收原始组件并返回原始组件的增强/填充版本。最简单的示例如下:

  1. var enhanceComponent = (Component) =>
  2. class Enhance extends React.Component {
  3. render() {
  4. return (
  5. <Component {...this.props} />
  6. )
  7. }
  8. };
  9. var OriginalTitle = () => <h1>Hello world</h1>;
  10. var EnhancedTitle = enhanceComponent(OriginalTitle);
  11. class App extends React.Component {
  12. render() {
  13. return <EnhancedTitle />;
  14. }
  15. };

高阶组件要做的第一件事就是渲染原始组件。将高阶组件的 props 传给原始组件是一种最佳实践。这种方式将保持原始组件的输入。这便是这种模式的最大好处,因为我们控制了原始组件的输入,而输入可以包含原始组件通常无法访问的内容。假设我们有 OriginalTitle 所需要的配置:

  1. var config = require('path/to/configuration');
  2. var enhanceComponent = (Component) =>
  3. class Enhance extends React.Component {
  4. render() {
  5. return (
  6. <Component
  7. {...this.props}
  8. title={ config.appTitle }
  9. />
  10. )
  11. }
  12. };
  13. var OriginalTitle = ({ title }) => <h1>{ title }</h1>;
  14. var EnhancedTitle = enhanceComponent(OriginalTitle);

appTitle 是封装在高阶组件内部的。OriginalTitle 只知道它所接收的 title 属性,它完全不知道数据是来自配置文件的。这就是一个巨大的优势,因为它使得我们可以将组件的代码块进行隔离。它还有助于组件的测试,因为我们可以轻易地创建 mocks 。

这种模式的另外一个特点是为附加的逻辑提供了很好的缓冲区。例如,如果 OriginalTitle 需要的数据来自远程服务器。我们可以在高阶组件中请求此数据,然后将其作为属性传给 OriginalTitle

  1. var enhanceComponent = (Component) =>
  2. class Enhance extends React.Component {
  3. constructor(props) {
  4. super(props);
  5. this.state = { remoteTitle: null };
  6. }
  7. componentDidMount() {
  8. fetchRemoteData('path/to/endpoint').then(data => {
  9. this.setState({ remoteTitle: data.title });
  10. });
  11. }
  12. render() {
  13. return (
  14. <Component
  15. {...this.props}
  16. title={ config.appTitle }
  17. remoteTitle={ this.state.remoteTitle }
  18. />
  19. )
  20. }
  21. };
  22. var OriginalTitle = ({ title, remoteTitle }) =>
  23. <h1>{ title }{ remoteTitle }</h1>;
  24. var EnhancedTitle = enhanceComponent(OriginalTitle);

这次,OriginalTitle 只知道它接收两个属性,然后将它们并排渲染出来。它只关心数据的表现,而无需关心数据的来源和方式。

  • 关于高阶组件的创建问题,Dan Abramov 提出了一个 非常棒的观点,像调用 enhanceComponent 这样的函数时,应该在组件定义的层级调用。换句话说,在另一个 React 组件中做这件事是有问题的,它会导致应用速度变慢并导致性能问题。 *

将函数作为 children 传入和 render prop

最近几个月来,React 社区开始转向一个有趣的方向。到目前为止,我们的示例中的 children 属性都是 React 组件。然而,有一种新的模式越来越受欢迎,children 属性是一个 JSX 表达式。我们先从传入一个简单对象开始。

  1. function UserName({ children }) {
  2. return (
  3. <div>
  4. <b>{ children.lastName }</b>,
  5. { children.firstName }
  6. </div>
  7. );
  8. }
  9. function App() {
  10. const user = {
  11. firstName: 'Krasimir',
  12. lastName: 'Tsonev'
  13. };
  14. return (
  15. <UserName>{ user }</UserName>
  16. );
  17. }

这看起来有点怪怪的,但实际上它确实非常强大。例如,当某些父组件所知道的内容不需要传给子组件时。下面的示例是待办事项的列表。App 组件拥有全部的数据,并且它知道如何确定待办事项是否完成。TodoList 组件只是简单地封装了所需的 HTML 标记。

  1. function TodoList({ todos, children }) {
  2. return (
  3. <section className='main-section'>
  4. <ul className='todo-list'>{
  5. todos.map((todo, i) => (
  6. <li key={ i }>{ children(todo) }</li>
  7. ))
  8. }</ul>
  9. </section>
  10. );
  11. }
  12. function App() {
  13. const todos = [
  14. { label: 'Write tests', status: 'done' },
  15. { label: 'Sent report', status: 'progress' },
  16. { label: 'Answer emails', status: 'done' }
  17. ];
  18. const isCompleted = todo => todo.status === 'done';
  19. return (
  20. <TodoList todos={ todos }>
  21. {
  22. todo => isCompleted(todo) ?
  23. <b>{ todo.label }</b> :
  24. todo.label
  25. }
  26. </TodoList>
  27. );
  28. }

注意观察 App 组件是如何不暴露数据结构的。TodoList 完全不知道 labelstatus 属性。

除了使用 render 属性渲染待办事项而不是 children ,这种叫 render prop 的模式和前面的模式几乎一样。

  1. function TodoList({ todos, render }) {
  2. return (
  3. <section className='main-section'>
  4. <ul className='todo-list'>{
  5. todos.map((todo, i) => (
  6. <li key={ i }>{ render(todo) }</li>
  7. ))
  8. }</ul>
  9. </section>
  10. );
  11. }
  12. return (
  13. <TodoList
  14. todos={ todos }
  15. render={
  16. todo => isCompleted(todo) ?
  17. <b>{ todo.label }</b> : todo.label
  18. } />
  19. );

将函数作为 children 传入render prop 是我最近非常喜欢的两个模式。当我们想要复用代码时,它们提供了灵活性和帮助。它们也是抽象代码的一种强有力的方式。

  1. class DataProvider extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.state = { data: null };
  5. setTimeout(() => this.setState({ data: 'Hey there!' }), 5000);
  6. }
  7. render() {
  8. if (this.state.data === null) return null;
  9. return (
  10. <section>{ this.props.render(this.state.data) }</section>
  11. );
  12. }
  13. }

DataProvider 刚开始不渲染任何内容。5 秒后我们更新了组件的状态并渲染出一个 <section><section> 的内容是由 render 属性返回的。可以想象一下同样的组件,数据是从远程服务器获取的,我们只想数据获取后才进行显示。

  1. <DataProvider render={ data => <p>The data is here!</p> } />

我们描述了我们想要做的事,而不是如何去做。细节都封装在了 DataProvider 中。最近,我们在工作中使用这种模式,我们必须将某些界面限制只对具有 read:products 权限的用户开放。我们使用的是 render prop 模式。

  1. <Authorize
  2. permissionsInclude={[ 'read:products' ]}
  3. render={ () => <ProductsList /> } />

这种声明式的方式相当不错,不言自明。Authorize 会进行认证,以检查当前用户是否具有权限。如果用户具有读取产品列表的权限,那么我们便渲染 ProductList

结语

你是否对为何还在使用 HTML 感到奇怪?HTML 是在互联网创建之初创建的,直到现在我们仍然在使用它。原因在于它的高度可组合性。React 和它的 JSX 看起来像进化的 HTML ,因此它具备同样的功能。因此,请确保精通组合,因为它是 React 最大的好处之一。