逻辑复用

Misaka10032进阶技巧逻辑复用跨组件通信控制反转大约 5 分钟

继进阶技巧记录之后,再起一篇,专门记录逻辑复用的技巧与跨组件通信方法

逻辑复用

复用的目标:组件状态逻辑

复用的内容:state 状态、操作 state 状态的方法

在 Hooks 推出之前,组件的状态逻辑复用经历了:mixins、HOC、render-props 等模式

注意:这几种方式不是 API,而是利用 React 自身特点的编码技巧,演化而成的固定写法模式

已废弃的 mixins

React 的 mixins 跟 Vue2 的 mixins 配置项很类似,都是采用组件混合的方式进行的,但缺点也很明显:组合混乱、命名冲突、维护复杂。因此 React 现在已废弃 mixins

HOC

HOC 是通过装饰器模式,实现组件状态逻辑复用的,接收要包装的组件,返回增强后的组件。

高阶组件命名约定以 with 开头,如:withMouse、withRouter 等

原理:高阶组件内部创建一个类组件,在这个类组件中提供复用的状态逻辑代码,通过 prop 将复用的状态传递给被包装组件

注意点:

  1. 设置 displayName 方便在 devTools 中展示名称
  2. 注意传递 props,不传递 props 会导致增强组件丢失 props

更多记录详见高阶组件

render-props模式

render-props将要复用的状态逻辑代码封装到一个组件中,通过一个值为函数的 prop 对外暴露数据,实现状态逻辑复用

不使用 prop 函数的情况下,在组件标签内部声明的 jsx 元素或者 jsx 函数,在组件的 render 函数中,默认从 children 属性中获取

最新方案:Hooks

详见Hook

为什么要有Hooks

我们先分析 Hooks 出现之前 React 存在的问题

  1. 组件的状态逻辑复用

已废弃的 mixins 的问题:数据来源不清晰、命名冲突

HOC、render-props的问题:重构组件结构,JSX 嵌套地狱

  1. class 组件自身的问题

this 指向存在学习门槛

关联代码需要被拆分到不同生命周期函数中

不利用代码压缩和优化,也不利于 TS 类型推导

控制反转

案例

嵌套父子组件
const { useState } = React;

const Children = () => {
  console.log("Children rendered");
  return <p>I am Children</p>;
};

const Father = () => {
  const [count, setCount] = useState(0);
  console.log("Father rendered");
  return (
    <div>
      <p>I am Father tag, {count}</p>
      <button onClick={() => setCount(count + 1)}>Add Count</button>
      <br />
      <Children />
    </div>
  );
};

export default Father;

打开 F12 可以看到,父子组件初次渲染会各打印一次信息,随后每次 button 点击都会重新触发渲染,每点一次就各打印一次信息

这是因为父组件 state 更新触发重新渲染,连带子组件一起重新渲染,但是实际上子组件是一个静态 p 标签,并没有冲渲染的需要,那么这个组件更新就是没有必要的。如果子组件的渲染开销比较大,就可能引发严重的性能问题

我们的第一反应可能是给子组件增加React.memo转为 props 浅比较,但是秉承能不用就不用的原则,我们选择其他的办法:控制反转(Inversion of Control)

因为<br/>标签与子组件并不需要来自父组件的状态,所以对上面的代码进行改动,在父组件和子组件之间添加一个 IOC 组件:

IOC
const { useState } = React;

const Children = () => {
  console.log("Children rendered");
  return <p>I am Children</p>;
};

const FatherIoc = ({ children }) => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>I am Father tag, {count}</p>
      <button onClick={() => setCount(count + 1)}>Add Count</button>
      {children}
    </div>
  );
};

const Father = () => {
  console.log("Father rendered");
  return (
    <>
      <FatherIoc>
        <br />
        <Children />
      </FatherIoc>
    </>
  );
};

export default Father;

再打开 F12,无论点击多少次 button,控制台都只停留在页面第一次渲染时打印的信息,说明多次更新都没有触发父组件和子组件的重新渲染

分析

这个案例实际上通过巧妙的render-props隔离了依赖 state,将子组件挂在 children 上,IOC 组件的更新既不触发外层 Father 组件 rerender,也不触发插槽 Children 组件 rerender

一句话描述:因为 Children 来自于父组件,子组件的重新渲染并不会导致其也重新渲染

完整描述:

按照组件树更新原理5项条件

  1. 从 Father 组件开始,Father 满足前 4 个条件,但是其子组件 FatherIoc 存在更新,不满足第 5 个条件,所以进入复用逻辑但不会跳过子组件的对比,此时父节点的 props 也不进入更新,原值赋予给新的子节点
  2. FatherIoc 子组件的 state 发生了变化,不进入复用逻辑,重新调用生成新 Fiber 节点
  3. 更新前后父节点的 props 完全相同,children 节点不会重新创建。<br/><Children>实际位于父组件树,并非 FatherIoc 的子组件,判断更新条件 5 个条件均满足,跳过对比直接复用

而没有 IOC 组件的原始组件中更新是:

Father 的 state 发生变化,调用生成新的 Fiber 节点,子组件的 props 虽然是空对象,但是 JSX 转换 createElement 的时候,新的空对象与旧的空对象不等,继而重新创建,触发重新渲染