react指北:react组件间通信的几种方式你都会吗

777 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第5天,点击查看活动详情

前言

我们在react指北:如何让你的react组件变的更清晰一文中谈到,当组件变大的时候,我们可以通过一定的方式去拆分组件,来控制组件的颗粒度。这时候,随之而来的会有另外一个问题,当组件的颗粒度变小的时候,一个复杂的组件就需要比之前拥有更多的子组件,这时候,一套合理的组件间通信方式就显得很重要了。本文就重点对组件间通信的方式做一个归纳和总结。

组件的关系

在讲组件间通信的几种方式之前,我们先来讲讲组件间的关系。

组件间的关系其实并不复杂,熟悉html的人都知道,元素之间大概有三种关系:父子关系、兄弟关系和祖孙关系。简单归纳一下就是:父组件和子组件的关系就是父子关系;两个组件共有一个父组件,就是兄弟关系;某个组件是另外一个组件的子组件的后代(子组件或者子组件的子组件)就是子孙关系。

react组件间的关系大致也是分为这三种,下面我们就这三种组件关系分别来阐述下该如何合理的选择通信方式。

组件的通信方式

组件之间,不可避免的需要有一些耦合,通过彼此之间数据或者逻辑上的依赖来组合完成一个更加复杂的组件。**如何让组件之间彼此存在耦合的时候依旧保持有序不混乱(能够很容易的往组件里面添加或者移除一个子组件,而不是移除子组件的时候,整个组件都崩了,或者根本理不清组件间的依赖关系,父子组件如同麻花般的胶合在一起),就需要一套合理的解决方案来解决上述问题,而这套解决方案,就是我们今天要提及的通信方式。 **

组件间的通信方式,日常归纳总结下来主要有以下几种:

  1. 属性传递
  2. 自定义事件
  3. 组件方法
  4. context
  5. global state

下面,我们就来挨个介绍下这些基本的通信方式

属性传递

属性传递是react中最常见的组件通信方式了。子组件通过props来接收父组件传递给它的数据,比较简单,就不多说了。

自定义事件

自定义事件其实严格来讲也算是属性传递中的一种,子组件内部定义好一个自定义的事件,然后通过props的方式暴露给父组件,父组件通过传递符合事件约束的方法给子组件,完成某些特定的调用。

例如,我们常见的onClick、onScroll等就是常见的自定义事件(为什么这也算自定义事件,思考下呢)。

自定义事件之所以要单独拿出来讲是因为自定义事件在封装的是,需要开发者具备一些观察者模式的设计思想,如何合理的封装自定义事件,以及如何合理的设计事件签名,是开发过程中需要重点考虑的。

// 子组件
const Child = ({ onInit }) => {
  useEffect(() => {
    // 想象成一个异步任务,例如接口请求等
    setTimeout(() => {
      // 通知父组件,自己已经初始化完成,可以进行后续的动作
      onInit()
    }, 500)
  }, [])
  return <div>我是子组件</div>
}
// 父组件

const Parent = () => {
  const onChildInit = useCallback(() => {
    console.log('加载完成...')
  }, [])
  return (
    <div>
      我是父组件
      {/* 监听子组件初始化 */}
      <Child onInit={onChildInit} />
    </div>
  )
}

上面一个demo,是一个简单的自定义事件的例子。父组件通过onInit方法来监听子组件是否加载完成。

为了遵循react规范,自定义事件一般用on开头。且,自定义事件的逻辑执行,不应该直接影响组件本身。不要在自定义事件中把修改自己渲染逻辑的方法暴露出去,这会导致你的组件渲染逻辑很难被跟踪。

极其不推荐在组件中处理自定义事件的返回值,因为自定义事件的目的是我告诉了父元素我做了什么事情,而不是让父元素来帮我做什么事情。如果你这样做了,父子之间的关系就会变得复杂。

组件方法

组件方法在class component的时代非常流行,因为class component天生就是支持方法声明的,我们可以很容易的通过ref来获得子组件的引用,继而在父组件中调用子组件的方法。

在fc的时代,这种情况相对变少了,因为在fc中对外暴露方法的成本比class component中高上了那么一点点(其实也没多高)。

一个在fc中暴露方法的例子:

// 子组件
const Child = forwardRef(({ onInit }, ref) => {
  const init = useCallback(() => {
    console.log('我要开始加载了')
  }, [])
  useImperativeHandle(
    ref,
    () => {
      return { init }
    },
    [init],
  )

  return <div>我是子组件</div>
})
// 父组件

const Parent = () => {
  const child = useRef()
  useEffect(() => {
    // 主动调用子组件的方法,让他开始加载
    child.current?.init()
  }, [])
  return (
    <div>
      我是父组件
      <Child ref={child} />
    </div>
  )
}

这个例子中,我们通过forwardRef来转移组件的ref属性,之后通过useImperativeHandle来给ref属性赋值的方式完成init的方法的暴露。

和自定义事件的例子不同,自定义事件是我处理好了通知父元素,这里则是我需要父元素告诉我什么时候来处理逻辑。

你可以在组件方法中对组件进行任意的更新和操作,因为这是你自己的方法。

个人非常喜欢用这种方式来更新组件,因为这样的话,组件的更新粒度就变得非常可控,我可以准确的控制在哪个时候更新那个组件。而不是像props一样,必须依赖父组件的state变化而更新,这样的话,很多原本无需更新的组件,也不得不更新了。

context

context就不多说了,这一直是祖孙关系组件中通信的最有效的方式。不过这种方式也有个弊端,就是只允许祖先元素向后代元素传递数据或者方法,但是无法让祖先元素主动访问后代元素(实际业务中有这种例子吗?好像比较少)。

使用方法就不多说了。插入一段react context发展史上的插曲:其实在很早很早以前,react团队就意识到了context的重要性,但是一直没有合理的方式来组织这种交互,直到dan加入react团队后,对之前的context实现方式进行了重写,之后,context正式登上了react的舞台。

global state

global state指的就是redux、mobx或者自己封装的公共状态管理工具,这些工具天然就是为了跨组件通信而出现的。

有一点需要记住的,以为是这些工具的global属性,也就是谁都能用,所以很多人就习惯把任何相关或者不相关的状态都放在一个state里面,导致state越来越难维护。或者原本不需要共享的一些状态都放在了global state里面,这显然是不合理的。

它很强大,但是它不是垃圾桶,什么都装。

如何合理的选择通信方式

上面我们简单的归纳了下组件的通信方式,接下来我们接着讲,如何在合适的时候选择合适的通信方式。

父子组件的通信方式

父子组件的通信方式,优先考虑props和组件方法,即:子组件向父组件暴露自定义事件或者属性,使得父组件具备获得或者修改自己当前的状态的能力;通过暴露方法的方式,来让父组件在适当的时候主动修改和获得自己的状态。

如果父组件需要在特定时机获得或者修改子组件,可以让子组件暴露方法,其他时候,请优先考虑属性。

除了props和组件方法之外,你不需要其他的任何方式来完成父子组件间的通信。

记住,是任何,虽然别的方式也可以用,但是没必要。

兄弟组件的通信方式

兄弟组件间的通信相对来说比较复杂,因为他们之前没有任何直接的联系,实际的业务中,你应该尽量避免这种情况的出现,因为他们本来没有关系,是你的设计模式选择上,让他们产生了关系。

如果你实在无法避免这种情况,可以考虑通过借助父元素通信来完成他们之间的通信。即:子元素先和父元素通信,父元素再把自己得到的消息派发给另外一个需要知道消息的子元素,类似于桥接模式。

除此之外,global state也能帮你解决这个问题,但是global state的问题是你一开始就需要把状态放置在global state中管理。如果你一开始没有考虑到兄弟元素之间要交互,改造起来可能需要一点成本。所以有可能的话,还是借助父元素的能力吧。

祖先关系的通信方式

组件关系,优先考虑context。但是我们之前也说了,context只允许祖先元素向后代元素传递消息,如果祖先元素需要调用后代元素的方法,就凉凉了(尽量避免吧,实在无法避免,还有global state这条路)。

另外,个人不太建议在context传递除了dispatch之类的任何方法或者事件,因为你不知道哪个组件会调用。想象一下,你的init事件本来只想响应一次,但是却因为多个后代元素都有这个自定义事件,并且都从context里面取了,结果导致多次响应,那酸爽。。。

那么,为什么dispatch可以呢?因为dispatch,不属于任何人。

总结

在组件通信方式的选择上,我们应该优先考虑使用属性和自定义事件,其次是组件方法,context和global state看团队的开发习惯定,可以都用,也可以二者选其一。都用的话,一般context的优先级高于global state。

还有一点小提示,为了避免你写出麻花状的组件(父组件和子组件高度耦合,完全无法分离),父组件不要传递任何方法给子组件,使得子组件具备随时修改自己状态的能力(自定义事件只是在特定的阶段执行,两者很像,但不是一个东西,简单来讲就是,方法规范上可以在任意地方被执行,自定义事件,在规范上应该约束,只在特定阶段执行)。

感觉篇幅有点长,很多东西只是提到了下,还不是特别详尽,有疑问或者不同意见的,欢迎大家在评论区探讨。

over~