react-copy-write 基于新 Context 和 immer 的 React 状态管理库

1,274 阅读4分钟

react-copy-write 一个比较新的 React 状态管理库,通过 immer 实现了可变状态接口和部分 reselect 记忆功能。可通过下图快速了解该库的整体架构:

rcw

Context

新的 Context 给出多个 API 解决了深层传递数据的笨重。 其中 Provider 提供的数据在整个组件树中可认为是 “全局” 的,而 Consumer 则可以在任一层自由的订阅该 “全局” 数据,所有的 Consumer 会随着 Provider 的数据改变而重新渲染。但是有时会出现不必要的重新渲染,其主要原因是判断 “全局” 数据是否改变的依据是 Object.is

class App extends React.Component {
  render() {
    return (
      <Provider value={{ something: 'something' }}>
        <Toolbar />
      </Provider>
    )
  }
}

每次 App 组件渲染 Providervalue 都是新的对象。解决该类问题方法就是将 value 的数据提升。

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      value: { something: 'something' }
    }
  }

  render() {
    return (
      <Provider value={this.state.value}>
        <Toolbar />
      </Provider>
    )
  }
}

还有一种情况也会出现多余的渲染,当 Consumer 只订阅了部分 Provider 提供的数据,因为 setState 的原因,每次都会得到新的数据,此时即使数据的值没有改变, Consumer 仍然会重新渲染,。

const themes = {
  light: {
    foreground: '#ffffff',
    background: '#222222'
  },
  dark: {
    foreground: '#000000',
    background: '#eeeeee'
  }
}

const ThemeContext = React.createContext({
  theme: themes.dark,
  another: 'aaa'
})

function ThemeButton(props) {
  return <button onClick={props.changeTheme}>Change Theme</button>
}

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      theme: themes.light,
      another: 'bbb'
    }

    this.toggleTheme = () => {
      this.setState(state => ({
        theme: state.theme === themes.dark ? themes.light : themes.dark
      }))
    }
  }

  render() {
    return (
      <ThemeContext.Provider value={this.state.another}>
        <ThemeButton changeTheme={this.toggleTheme} />
        <ThemeContext.Consumer>
          {state => {
            console.log('re-render')
            return <button>{state}</button>
          }}
        </ThemeContext.Consumer>
      </ThemeContext.Provider>
    )
  }
}

ReactDOM.render(<App />, document.getElementById('root'))

每次点击 ThemeButton 时,this.state.another 数值并没有改变,但是控制栏都会显示 're-render' 。解决这种问题的方法是使用 shouldComponentUpdate 方法,但针对深层数据 共享结构 才是最佳选择,immutable.js 和 immer 都提供了共享结构的方案。

immer

共享结构或结构化共享或结构共享化(structural sharing)保证在更新数据时重用未改变的数据引用,immutable.js 和 immer 库都拥有这种特性。

import produce from 'immer'

const baseState = [
  {
    todo: 'Learn typescript',
    done: true
  },
  {
    todo: 'Try immer',
    done: false
  }
]

const nextState = produce(baseState, draftState => {
  draftState.push({ todo: 'Tweet about it' })
  draftState[1].done = true
})

expect(baseState.length).toBe(2)
expect(nextState.length).toBe(3)

expect(baseState[1].done).toBe(false)
expect(nextState[1].done).toBe(true)

expect(nextState[0]).toBe(baseState[0]) // 未改变部分
expect(nextState[1]).not.toBe(baseState[1])

将上述两部分链接在一起,就是 react-copy-write 的目的。

react-copy-write

查看 react-copy-write 源码仅有不到 200 行(注释占了一半),该库对外提供了一个 API,接口返回一个对象

return {
  Provider: CopyOnWriteStoreProvider,
  Consumer: CopyOnWriteConsumer,
  Mutator: CopyOnWriteMutator,
  update,
  createMutator
}

Provider

Provider 作用与 Context.Provider 完全一致。

import createState from 'react-copy-write'

const State = createState({
  user: null,
  loggedIn: false
})

class App extends React.Component {
  render() {
    return (
      <State.Provider>
        <AppBody />
      </State.Provider>
    )
  }
}

Consumer

Consumer 含有一个 selector 属性,用来选取关注的数据

const UserAvatar = ({ id }) => (
  <State.Consumer selector={state => state.users[id].avatar}>
    {avatar => (
      <div>
        <img src={avatar.src} />
      </div>
    )}
  </State.Consumer>
)

selector 属性可以是多个 selectors 组合,甚至是数组

// 多个 selectors 组合
const UserPosts = () => (
  <State.Consumer selector={state => ({ posts: state.posts, userId: state.user.id }}>
   {({posts, userId}) => {
     const filteredPosts = posts.filter(post => post.id === userId)
     return posts.map(post => <Post id={post.id} />)
   }
  </State.Consumer>
)

// selector 数组
const UserPosts = () => (
  <State.Consumer selector={[state => state.posts, state => state.userId]}>
    {[posts, userId] =>
        const filteredPosts = posts.filter(post => post.id === userId)
        return posts.map(post => <Post id={post.id} />)
    )}
  </State.Consumer>
)

在使用多个 selectors 时, selector 会依次执行,不过上述写法会带来不必要的渲染,因为每次都会生成新的对象。可以修改为

const UserPosts = () => (
  <State.Consumer selector={state => state.posts}>
    {posts => (
      <State.Consumer selector={state => state.user.id}>
        {userId => {
          const filteredPosts = posts.filter(post => post.id === userId)
          return posts.map(post => <Post id={post.id} />)
        }}
      </State.Consumer>
    )}
  </State.Consumer>
)

可这种做法又过度防护重新渲染,因为如果 state.posts 没有改变,而 state.user.id 改变了,组件则没有正常重新渲染。解决该问题的一个选择便是使用数组方式。

update

ConsumerContext.Consumer 的区别在 children 属性, Consumerchildren 会通过一个中间组件,该中间组件不仅进行了 shouldComponentUpdate 判断,还将 update 赋予了 children

update 首先使用了 immer 的 produce 生成新的 state ,然后将其发送给 Provider

const Post = ({ id }) => (
  <div className="post">
    <State.Consumer selector={state => state.posts[id]}>
      // mutate 就是 update
      {(post, mutate) => (
        <>
          <h1>{post.title}</h1>
          <img src={post.image} />
          <p>{post.body}</p>
          <button
            onClick={() =>
              // Here's the magic:
              mutate(draft => {
                draft.posts[id].praiseCount += 1
              })
            }
          >
            Praise
          </button>
        </>
      )}
    </State.Consumer>
  </div>
)

回顾最初的图,至此状态管理形成了闭环:

  • update 通过 immer 生成共享结构的 state ,
  • Provider 更新 state ,
  • Consumer 通过 selector 订阅部分 state, 并将 update 传递给 children 组件,
  • children 组件通过交互调用 update

个人拙见

  • 在 state 开销能够承受的情况下, Provider 完全可以作为根组件存在。
  • 能否将 Provider 继续包装成 Observable ,声明式更加方便(对了, createMutatorupdate 的声明式语法糖),也可以将 DOM 敏感和数据敏感结合。
  • 为了模块清晰调试简单或实现时间旅行,是否可提供类似 combineReducers 的方法,将 Provider 组合。