React 设计模式

Jun 21, 2018

React 自身非常简单,为了写出可维护性高,可以最大化复用的代码这就出现了了一些设计模式

在不使用设计模式的情况下,我们要实现一个简单的 Tabs 组件, 它是这样的

const Tab = ({ value, active, onChange, children }) => (
  <div className={active && 'active'} onClick={evt => onChange(value, evt)}>
    {children}
  </div>
)

用起来是这样的:

class Index extends React.Component {
  state = {
    value: 'a'
  }

  handleChange = value => {
    this.setState({ value })
  }

  render() {
    return (
      <div>
        <Tab value="a" active={this.state.value === 'a'} onChange={this.handleChange}>
          Tab A
        </Tab>
        <Tab value="b" active={this.state.value === 'b'} onChange={this.handleChange}>
          Tab B
        </Tab>
        <Tab value="c" active={this.state.value === 'c'} onChange={this.handleChange}>
          Tab B
        </Tab>
      </div>
    )
  }
}

从上面的代码可以看出,Tab 组件的 Props 中只有 valuechildren 是由使用者决定的,activeonChange 是有固定模式且重复的,我们可以想一些办法将这些有清晰模式且重复的东西自动传给 Tab 组件,避免我们每次都手动维护

Compound Components

这种模式需要增加一个 Tabs 组件,它使用 React.Children 这个 API 来给每个 Tab 组件自动传入 valueactive props,这样我们使用 Tab 组件时只需要提供 valuechildren 就可以了

const Tabs = ({ value, onChange, onClick, children }) =>
  React.Children.map(children, el => {
    const { value: childValue } = el.props
    return React.cloneElement(el, {
      onChange,
      onClick: onChange,
      active: value === childValue,
      value: childValue
    })
  })

用起来是这样的:

class Index extends React.Component {
  state = {
    value: 'Tab A'
  }

  handleChange = value => {
    this.setState({ value })
  }

  render() {
    return (
      <Tabs value={this.state.value} onChange={this.handleChange}>
        {['Tab A', 'Tab B'].map(tab => (
          <Tab value={tab} key={tab}>
            {tab}
          </Tab>
        ))}
      </Tabs>
    )
  }
}

这种模式主要的缺点在于限制了 DOM 结构,比如:

  • Tab 组件只能做为 Tabs 组件的直系子组件,无法增加 Wraper
  • Tabs 组件只能包含 Tab 组件

Context

我们用 React 16 新增的 context API 来重写上面的例子

const initialValue = {
  value: '',
  onChange() {}
}

const TabsContext = React.createContext(initialValue)

const Tab = ({ value, children }) => (
  <TabsContext.Consumer>
    {ctx => (
      <div className={ctx.value === value && 'active'} onClick={evt => ctx.onChange(value, evt)}>
        {children}
      </div>
    )}
  </TabsContext.Consumer>
)

const Tabs = ({ value, onChange, children }) => (
  <TabsContext.Provider value={{ value, onChange }}>{children}</TabsContext.Provider>
)

现在可以这样使用:

class Index extends React.Component {
  state = {
    value: 'Tab A'
  }

  handleChange = value => {
    this.setState({ value })
  }

  render() {
    return (
      <Tabs value={this.state.value} onChange={this.handleChange}>
        {['Tab A', 'Tab B'].map(tab => (
          <span key={tab}>
            <Tab value={tab}>{tab}</Tab>
          </span>
        ))}
      </Tabs>
    )
  }
}

上面的代码里 Tabs 组件内可以随意嵌套组合其他组件,并不会影响 Tab 组件

这种模式主要的优点在于可以 props 透传 ,它在一定程度上可以用来代替 Redux

Render Props

在前面的 Tab 组件里可以看到它返回了一个 div 元素,但在有些情况下我们想要用其他组件来代替这个 div 元素,这种情况可以使用一个 callback 作为 prop,将 Tab 组件的核心状态作为参数传给它,至于最终怎么渲染完全交给用户来决定

将开头的 Tab 组件修改为如下:

const Tab = ({ value, active, onChange, children, render }) => {
  if (typeof render === 'function') {
    return render({ value, active, onChange })
  }
  return (
    <div className={active && 'active'} onClick={evt => onChange(value, evt)}>
      {children}
    </div>
  )
}

现在可以自定义 render 内容了:

class Index extends React.Component {
  state = {
    value: 'Tab A'
  }

  handleChange = value => {
    this.setState({ value })
  }

  render() {
    return (
      <Tabs value={this.state.value} onChange={this.handleChange}>
        {['Tab A', 'Tab B'].map(tab => (
          <Tab
            value={tab}
            render={({ value, active, onChange }) => (              <li className={active && 'active'} onClick={evt => onChange(value, evt)}>                <span>{tab}</span>              </li>            )}            key={tab}
          />
        ))}
      </Tabs>
    )
  }
}

这种模式可以很好的提取纯粹的逻辑组件,最大化的复用业务逻辑

高阶组件 HOC(Higher Order Component)

所谓高阶组件就是一个函数,它可以接收一个组件并返回一个组件,它可以用来装饰组件,给组件注入 props

例如,我们整个应用中有几个不同地方都散布着几个类似下面这样的的组件:

const NavBar = ({ user }) => (
  <nav>
    User: <span>{user ? user.name : 'fetching...'}</span>
  </nav>
)

这些组件都有一个相同的 user prop,这个 user 是需要从其他地方动态获取的,为了避免获取 user 的操作重复,我们可以把它提取为一个 HOC:

const withUser = WrappedComponent =>
  class HocWithUser extends React.Component {
    state = {
      user: null
    }

    componentDidMount() {
      fetchUser().then(user => {
        this.setState({ user })
      })
    }

    render() {
      return <WrappedComponent {...this.porps} user={this.state.user} />
    }
  }

然后可以这样使用:

const NavBar = ({ user }) => (
  <nav>
    User: <span>{user ? user.name : 'fetching...'}</span>
  </nav>
)
const NavBarWithUser = withUser(NavBar)

class Index extends React.Component {
  render() {
    return <NavBarWithUser />
  }
}

通过这个 HOC 我们可以将 user 注入给指定的组件,而不必重复 user 获取逻辑,但是目前还有两个问题:

  1. withUser 返回的组件在 ReactDevTools 里显示的名称都是相同的 HocWithUser,这会影响我们的 Debug 效率
  2. 调用 withUser 返回的组件是无法访问到 WrappedComponent 的静态属性,这显然是不行的

针对第一个问题,通常做法是在 HocWithUser 增加一个静态属性 displayName,如下:

const withUser = WrappedComponent =>
  class HocWithUser extends React.Component {
    static displayName = `withUser(${WrappedComponent.displayNanme || WrappedComponent.name || 'Component'})`
    state = {
      user: null
    }

    componentDidMount() {
      fetchUser().then(user => {
        this.setState({ user })
      })
    }

    render() {
      return <WrappedComponent {...this.porps} user={this.state.user} />
    }
  }

针对第二个问题,我们可以遍历 WrappedComponent,将它的静态属性添加到 HocWithUser,但是要排除 React 相关的属性,这个操作可以用 hoist-non-react-statics 或者 recompose/hoistStatics 代劳

这里使用后者,将前面的 withUser 作为 hoistStatics 的第一个参数即可:

import hoistStatics from 'recompose/hoistStatics'

const withUser = hoistStatics(
  WrappedComponent =>
    class HocWithUser extends React.Component {
      static displayName = `withUser(${WrappedComponent.displayNanme || WrappedComponent.name || 'Component'})`

      state = {
        user: null
      }

      componentDidMount() {
        fetchUser().then(user => {
          this.setState({ user })
        })
      }

      render() {
        return <WrappedComponent {...this.porps} user={this.state.user} />
      }
    }
)

这里的 user 在实际应用中通常是整个应用共享的,这种情况实际上使用前面提到的 context 模式比较合适,当然 context 模式也可以和 HOC 模式很好的配合

上面的模式可以在这里查看 在线 Demo