React 设计模式和思想

June 21, 2018

React 社区在近几年的发展过程中诞生了很多有趣的组件设计 "模式",通过合理的运用这些模式可以让我们写出更好用的 React 组件。

比如,在不使用任何设计模式的情况下,要实现一个类似这样的 Tabs 组件

tabs component

最初的代码可能是这样的

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

这种模式利用 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 的直系子组件,不能被其他组件包裹
  • 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 透传,可以让 DOM 结构更灵活。

Render Props

Render Props 模式本质上相当于控制反转。在前面的 Tab 组件里可以看到它返回了一个 div 元素,但在有些情况下用户会想要用其他组件来代替这个 div 元素,这种情况可以使用一个 callback 作为 prop,将 Tab 组件的核心状态作为参数传给它,用户拿到这些核心状态后到底是渲染 div 还是渲染其他组件就完全交给用户来决定了

将开头的 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 模式很好的配合

在 controlled 和 uncontrolled 之间切换

在 React 应用里,受控组件也叫木偶组件,就是这个组件的 value 都是父组件控制的,父组件传给它什么内容,它就展示什么,当内容发生变化的时候,通过调用父组件传下来的 onChange 回调函数通知父组件内容发生了变化。

这种模式的好处是可以将展示逻辑和控制逻辑分离,并且可以方便组件之间进行组合复用。

一般情况下受控组件的 props 看起来是这样的

type Props = {
  value: boolean
  onChange: () => void
}

使用的时候通常需要这样:

class Container extends React.Component {
  state = {
    value: false
  }

  handleChange = () => {
    this.setState(prevState => ({ value: !prevState.value }))
  }
  render() {
    return <ControlledComponent value={this.state.value} onChange={this.handleChange} />
  }
}

受控组件必须配合容器组件或者 hooks 来使用,有时候难免会让人觉得啰嗦,因为不是任何场景都需要受控组件,所以我们的目标是让组件又可以受控,也可以不受控,这样使用起来就既灵活也方便。

在上面的例子中可以利用 value 这个 prop 来判断是否应该是一个受控组件,如果 value 存在那就使用这个值,需要改变值得时候就调用 onChange 让父级组件自己决定怎么改变 value 的值。

value 存在的时候这个组件是受控组件,不存在的时候就是非受控组件。

class MyComponent extends React.Component {
  state = {
    value: false
  }

  get value() {
    return 'value' in this.props ? this.props.value : this.state.value
  }

  handleClick = () => {
    const { onChange } = this.props
    if (onChange) {
      onChange()
    } else {
      this.setState(prevState => ({ value: !prevState }))
    }
  }

  render() {
    return <div onClick={this.handleClick}>state: {this.value}</div>
  }
}

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

React Hooks

hoc-vs-renderProps-vs-hooks

从前面的例子可以看出,在 React Hooks 出现之前,逻辑复用主要依靠 HOC 和 Render Props,但是这两种模式本质上都是都过包一层组件来实现的,有一个很致命的问题是嵌套,如果你有10个 HOC 或者 Render Props 实现的逻辑组件,那么当你需要同时使用这 10个逻辑组件的时候,就会出现嵌套地狱的情况。

<CurrentUserQuery>
  {(currentUserQuery) => (
    <SettingsQuery user={currentUserQuery.data}>          
      {(settingsQuery) => (
        <ThemeQuery user={currentUserQuery.data}> 
          {(themeQuery) => (
            <ProjectsQuery user={currentUserQuery.data}>
              {(projectsQuery) => (
                projectsQuery.data &&
                projectsQuery.data.map(p => (
                  <Project {...p} settings={settingsQuery.data} theme={themeQuery.data} />
                  )
              )}
            </ProjectsQuery>
          )}
        </UserThemeQuery>
      )}
    </UserSettingsQuery>
  )}
</CurrentUserQuery>

除此之外,在 React Hooks 之前,函数式组件并没有生命周期的概念,所以副作用和生命周期都得放到 class 组件中完成,但是 class 组件的生命周期在使用上也是有很多痛点和心智负担的,比如在 didMount 里面监听了一个事件,那么你要记得在 unmount 生命周期里清除这个事件监听,否则就容易造成内存泄露等问题。还有在 class 组件里面监听一个值的变化也非常麻烦不直观,你需要在 didUpdate 里面手段做很多判断逻辑。

因为以上多种问题,最终 React 团队在 16.8 这个版本正式推出了革命性的 React Hooks,它不仅可以让传统的函数式组件具备 class 组件的能力,同时更有意义的是,它让 React 更加函数式,在一定程度上改变了开发者的思维习惯,让组件的状态和副作用之间的同步关系更加清晰明确。当然,Hooks 也不是银弹,它也带来了额外的一些问题。

hooks 参考资料:

如果你喜欢我的内容,请考虑请我喝杯咖啡☕吧,非常感谢🥰 。

If you like my contents, please support me via BuyMeCoffee, Thanks a lot.