Gatsby Starter Blog

深入React: 组件通信

January 22, 2020

项目重构中碰到了很多组件通信带来的问题,对组件通信进行总结。

react 组件通信有几种常见的场景

  • 父子组件通信( Demo1 ):父子组件通信最为简单,父组件向子组件传递 props,子组件接受父组件的回调
  • 跨级组件通信( Demo2 ):(使用 Context,典型的是 Redux)
  • 兄弟组件通信( Demo3 ) :在父组件维护 state,兄弟组件接受 props,兄弟组件修改父组件 state。
  • 全局事件通信( Demo4 ):全局事件需要保证派发事件时,监听者必须存在,否则可能会导致监听不到事件的发生,两种解决方式,1.存储发布的事件,当有订阅者订阅时执行存储的已发布事件,或者监听 DomContentLoaded 之后再进行事件派发,保证监听者已经存在。

以上是几种最基本的情形,但现实中还是可能存在种种问题。

UI 渲染不同步

要把数据同步到 UI 总共分两步

  1. 触发组件的 render 方法
  2. render 方法根据最新的 props,state 更新 UI

如果发现 UI 和数据不同步,则可能是 1.没有触发 render,2.render 方法里的渲染的数据源不是最新的

触发 render 的条件

  1. First Render: 首次渲染不会进行 SCU(ShouldComponentUpdate)检查,肯定会 render
  2. forceUpdate:会跳过 SCU 检查,强制调用 render(下个版本语义可能发生变化,不能保证一定会调用 render)
  3. stateChange: setState && (!SCU || SCU()) 组件没有实现 SCU 或组件的 SCU 返回 true。

React 的 Component 默认没有实现 SCU,所以如果组件没有实现 SCU,那么 setState 一定会导致 render,

React- redux 的 connect 第四个参数 options 有个【pure=true】的配置,其为组件添加 SCU,其对组件的 props 进行浅比较(默认是 true),所以如果在 reducer 直接修改的原 state 的属性,redux 组件并不能保证会触发 render。

  1. propChange: 父组件 render 导致调用子组件的 render 或者调用 ReactDOM.render && (!SCU || SCU),这都会触发 WillReceiveProps。其也会进行 SCU 检查,与 stateChange 逻辑一致。

对于 stateChange 和 propChange,并不能保证 state 和 prop 发生了改变,仅仅是触发了该生命周期而已,prop 是否发生改变需要用户自己去进行检查(涉及 deepEqual),如果对象嵌套过深,则需考虑 Immutable 对象,减小 deepEqual 代价。

数据更新

render 函数里渲染的数据可能分为四种( Demo6 )。

  • this.state
  • this.props.mutable
  • this.props.immutable(如’defaultValue’)
  • this.instanceVariable

如果 render 函数里同时包含这四种数据,则需要注意数据更新时,及时触发 render 进行更新,因为 React 并不会自动的为这些数据更新调用 render 函数。

常见问题

  • 如 instanceVariable 在构造函数里根据 props 进行初始化,当 props 发生变化时 instanceVariable 并未进行重新计算,导致渲染出错。解决方式是 1.在 willReceiveProps 里重新计算 instanceVariable,2.不使用 instanceVariable,而是在 render 里计算得到。3.模仿 Vue 那套,使用计算属性(watch.js/Rx.js/mobx)。

非受控组件

暂时我们进行如下定义( Demo7 ):

  • 受控组件:组件的状态维护在组件外部,组件响应 props 的变化,并提供向外派发命令的接口。
  • 非受控组件:组件状态维护在组件内部,组件只根据 config 进行初始化,后续 props 的变化不会导致组件重渲染,并提供向外的 onChange 接口。
  • 混杂组件:组件的状态既有一部分维护在外部,也有一部分维护在内部(或者说既可以从内部也可以从外部更新状态),因此需要同步状态。(混杂组件的好处在于既可以当做受控组件使用也可以当做非受控组件使用,使用方式十分灵活,antd 的一些组件就是这样做的,但是如果同时在外部和内部更新状态则很容易出问题。)

受控组件和非受控组件都是单向数据流,受控组件数据流方向自外向内,非受控组件数据流方向自内向外,较为容易管理。而对于混杂组件,由于内部和外部都维护状态,要处理好状态同步,尤其在存在异步的环境下(如 Redux),很容易出现问题,所以不建议使用。

React 官方建议大多数场景下应该使用受控组件,在某些场景下非受控组件也有其独特优势。

受控组件要求将组件状态维护在组件外部,一方面对于一些较复杂的组件,可能涉及很多的状态变量,放在外部维护会加重外部组件负担和造成组件的接口比较复杂。另一方面一些第三方插件封装为非受控组件也比受控组件更为容易。

topbuzz 的编辑器内部业务就较为复杂(涉及图片视频的上传,编辑器的编辑、存储、发布、预览、存草稿,草稿撤销功能等),涉及很多的状态,因此考虑将其封装为非受控组件。

设计如下:编辑器只在首次 mount 的时候根据传入的 config 初始化编辑器状态,后续的编辑器编辑时的状态均维护在组件内部,编辑器向外暴露 notifyArticleChange 接口。

这种方式,使用者仅仅需要传入初始的值和 onArticleChange 回调即可使用编辑器,而不需要关心编辑器内部的数据存储格式。

然而需求是不断变动的,产品后来提了新需求,需要编辑器支持重新载入新内容功能,这也就要求我们的编辑器能够重新根据新的 config 重新载入编辑器内容。

存在下面两种方案( Demo8 )

  1. 编辑器向外提供重新载入接口 loadFromConfig,外部需要重新载入时通过调用 editorInstance.loadFromConfig(newConfig)即可。
  2. 编辑器在 willReceiveProps 中响应 config 里 article 的变化,重新初始化 state。该方案存在的问题是父组件的 render 会触发编辑器的导致编辑器的 render,在 willReceiveProps 里造成错误的重新初始化 state(内部状态被清除了)。根源在于此时编辑器的 state 即维护在内部,又受到外部影响,会造成内外状态不一致。

非受控组件要解决的一个问题是如何防止父组件错误的更新子组件。(父组件的每次 render 都会触发子组件的 propChange),导致可能错误的更新(重构中碰到的一个问题就是编辑器内容经常被清空,就是因为父组件 render,导致使用旧的 config 初始化 state 导致的)。

  1. 父组件传入的 props 只负责做首次 mount 的初始化,因此 render 函数里的渲染数据应该不包含传入的 props 或者只包含不变的 props。

相关代码 react-communication

传递组件

重构 nav 时发现其中存在大量 Editor 相关业务代码,nav 和 Editor 通过全局事件进行通讯,nav 中存储 Editor 的实例。这样存在如下几个问题:

  1. nav 和 Editor 通过事件通讯,nav 监听 Editor 的相关事件,这要求 nav 要在 Editor 事件触发前已加载完毕,但是 React 并没有提供控制不同组件加载顺序的机制(React 只能保证子组件先于父组件加载完毕,在 React16 中添加了 AsyncComponent 组件,可以进行异步渲染,似乎可以解决这问题)。所以使用了监听 DomContentLoaded,这时能保证两个组件都已加载完毕(对于异步加载的组件这招好像行不通)。
  2. nav 中保存 Editor 的 instance,但是 instance 的属性更新并不会触发 render,需要手动的 forceUpdate 触发 render,容易忘记。

因此考虑将业务收敛到编辑器,但发现 nav 中还涉及到很多编辑器的 UI 交互,对于数据和回调很容易放到编辑器里,但是 UI 的交互却不容易收敛到编辑器,难道仍然要使用事件通信。

这时的问题变为 A 组件的 UI 渲染到 B 组件里(跨组件渲染)

React 为解决跨组件渲染给了一个 API

  • ReactDOM.unstable_renderSubtreeIntoContainer(parentElement,nextElement,container,callback)

其实这和 ReactDOM.render 的区别在于 ReactDOM.render 的 parentElement 是 null。而 parentElement 只是提供了一个 context。后续处理逻辑一致

 if (parentComponent) {
      var parentInst = ReactInstanceMap.get(parentComponent);
      nextContext = parentInst._processChildContext(parentInst._context);
    } else {
      nextContext = emptyObject;
 }

但使用时发现 A 组件里的传入的 nextElement 的 props 更新在 B 组件里进行重渲染,且 A 仍然需要依赖于 B 内的 container 节点已经存在。

解决方式是通过传递组件直接在编辑器生成 Nav 所需的 UI 组件,然后传递给 Nav,编辑器直接负责 UI 组件的更新。