Better

Ethan的博客,欢迎访问交流

使用 React 设计层面的思考

最近使用 React 的过程,让我非常困惑,因为我总是想把组件写薄,尽可能使得逻辑下沉,这让对我组件设计、使用 FP 还是 OOP、对于开源方案的选择等,总是会带来一些思考成本。额外内容:React Strict 给我的奇妙体验。

背景

最近有个小需求,主要是针对复杂对象的修改(嵌套、列表),本身逻辑并不复杂,但直接写在组件里,会让我很不爽,尤其是使用函数式组件的情况下,看着更加变扭了,因为这些东西的存在,如果多起来,对于理解一个组件的主干,会造成额外的困扰,有点违背封装的味道。

既然如此,那就使用 OOP 封装一下,比如按照领域模型(DDD)的设计思想,创建业务对象(BO),使用数据(DO)作为输入,提供一系列方案完成业务需求,但在 React 组件中应用时,组件中代码量确实减少了,但代码看起来比之前更难受了,因为 React 的设计思想更多的探讨使用对象的不变性能做些什么,而 OOP 对象通常都是会变异的,由于对象引用本身没有发生改变,在 React 中组件是不会刷新的,因此我必须这样做

const bo = UserList.from(do);
bo.delete(id);
// trigger react comp to rerender
setState(bo.toVO());
// if you want to submit to the server
bo.toDO()

这不禁让我思考 React 使用 OOP 以及引入领域模型的合理性。

FP 与 OOP

在 React 推出 hooks 后,OOP 的影子感觉被彻底干掉了,社区甚至掀起了两种编程风格的利弊争论,即使 React 团队说,类组件会长期保留维护。但我看来,脱离场景谈利弊都是耍流氓,你理解了一个思想的标准,是你知道什么时候你该放弃它

所以 React 和 OOP 能很好的结合吗

  • React 生态倾向于函数式编程:普通的 JS 对象和数组数据,不可变的更新,函数式操作符(map/filter/reduce)
  • OOP 原则:模型数据类、继承和变异

这两种编程风格的切入点确实完全不同的,而 React 组件设计中针对 DOM 渲染这一层,恰恰选中的就是利用数据的不可变性来驱动,单看这一点在 React 组件中强行写 OOP 代码的确有些强行。

但在一个复杂的应用中,一定会存在很多不只是操作 DOM 的场景,如果你有那个 sense,你可能会封装很多的可被复用的模块,如果复杂度上来了,应用 OOP 依然是首选。那么 OOP 对象如何和 React 组件做粘合呢,这个下面会提到。

领域模型

再来谈谈领域模型,它是什么?在谈这个问题之前,又的先来聊聊耳熟能详的 MVC 的分层模式。

MVC 分层开发的两种不同模式

  • 基于贫血模型的 MVC 三层架构开发模式,但它却违反了面向对象编程风格,是彻彻底底的面向过程的编程风格(模型只定义数据,具体的逻辑对应层中,和定义数据 + utils 是一样的处理方式,将数据和操作分离,破坏了面向对象的封装特性)
  • 基于贫血模型的开发模式被人诟病,而基于充血模型的 DDD 开发模式开始越来越被人提倡

基于充血模型的开发模式也是按照 MVC 架构封层的,跟基于贫血模型的传统开发模式的区别主要在 Service 层。贫血模型:重 Service 轻 BO,充血模型:轻 Service 重 Domain

顺便扩展聊聊,普遍都会困惑到的一个问题:DO、BO、VO 很多时候都是一样的,该如何考虑呢

  • 推荐每层都定义各自的数据对象,原因如下
    • 并非完全一样
    • 代码虽然重复,但语义不重复。如果合并为同一个类,那也会存在后期因为需求的变化而需再拆分的问题
    • 减少每层之间的耦合。层与层之间通过接口交互。数据从下一层到上一层的时候,将下层的数据对象转化成上一层的数据对象,再继续处理。虽然繁琐,但分层清晰。
  • 解决重复
    • 继承
    • 组合
  • 转换优化:DO 到 BO、BO 到 VO 的转化
    • 方法一:手动复制
    • 方法二:对象转换工具,比如相关 copyProperties utils 工具

MVC 和 DDD 都是后端发展总结出来的东西,直接应用到前端,终究有些地方很尴尬

DDD 全称:Domain-Driven Design

  • 通过将底层数据模型与领域逻辑关联起来,管理软件应用程序的复杂性
  • 通常两种方法来分离关注点和从组件模板中提取逻辑
    • presenters:将所有的可视化表达逻辑从模板中抽离出来,从而使表示层尽可能干净简单
    • utilities:为所有的前端逻辑收集共用的函数,和模板没那么相关

总有一个时刻(场景),我特别希望有一个对象,能帮我管理好一些计算逻辑,这么说可能有些抽象,举个简单例子(实际可能更复杂),比如已知层高和层数,计算楼高,这时候我就特别希望有这么个对象,比如 building.getHeight() 的对象给我用,而不是存在这么一个 utils,getBuildingHeight(building),这个问题,可能通常有三种解决方式

  • 最次的方式:直接模板里写逻辑,<div>{floorHeight * floorNumber}</div>,这种方式是最恶心的,因为同一个逻辑在不同组件中可能实现多次,如果后面需求有变,我表示吐了,最最恶心的是,针对复杂逻辑,每个人的实现逻辑可能还是不一样的
  • 稍有点抽象意识的,会找某个地方写一个 utils,可能在自己的业务里,可能在全局的 utils,这种方式存在的问题在我看来,封装性不够好,不易被发现,且给新人培训时,不够体系化
  • 抽象一个领域模型,这种方式带来的好处是封装性好,由于上升到了一个新的概念,更容易被重视发现,新人入职后,可以让其先熟悉相关领域模型,这对于迅速理解业务很有帮助,而不是迅速的迷失在众多组件之后,同时以后编写代码也很有帮助,知道哪些逻辑已经存在,且加入新的逻辑也知道该放往何处

还是总结下不使用领域模型会存在哪些问题(有些来自社区)

  • 视图层过厚
    • 存在的问题:视图层原本只需要展示 DOM 的结构,但这里却承担了各种逻辑判断、数据筛选、数据转换等“杂活”
    • 导致的后果:难以直观地理解视图结构
    • 优化思路:视图层最好单一,数据展示到视图层之前,做好数据的筛选、转换,判断逻辑抽象成公用函数放入 util 中。
  • 判断或计算逻辑重复
    • 例子:判断是不是管理员、计算面积、高度
    • 存在的问题:同样的逻辑在多个视图层中重复出现,如果逻辑比较复杂,甚至各成员实现方式不一致,在后期维护将会造成许多问题
    • 导致的后果:违反代码重复原则,后期维护成本高。团队中各成员知识不同步,同样的功能 A、B 都实现了,但彼此不知道,甚至可能因实现方案不同导致的结果不一致的问题
    • 优化思路:将某个实体抽象成一个类,在类中封装对应的方法供视图层调用
  • 忽略业务整体
    • 存在的问题:在一个庞大、多人协作的项目,作为其中一员很可能出现对整个系统理解不够,只知道自己负责的那几个页面
    • 导致的后果:代码重复性问题。没有整体的了解,会缺乏“可拓展性”与“预判未来性”的考虑。接手其他成员负责的领域时会不好上手。需求评审时,缺乏更深入的思考,被产品经理无脑驱动
    • 优化思路:将每一块业务划分成不同的领域,各领域下包含哪些服务,提高内聚性,加强成员对业务的理解,让团队成员力量进行聚焦,共同思考业务

前端设计的目标

  • 视图层尽可能薄:获得的数据能够直接使用到视图层中,禁止在视图层中对数据进行转换、筛选、计算等逻辑操作。
  • 不写重复逻辑
  • 不同职责的代码合理分层
  • 可纵观全局领域

既然模型领域有这么多好处,那怎么和 React 的不变性结合起来呢,恼火哦。以下是一些简单思考

  • 如果对象逻辑比较复杂,比如会有很多计算属性,且为不可变对象,则可以构建一个涵盖 95% 常用场景的领域对象类。其他情况通过构造 Wrapper 解决。
  • utils 中一定是通用的业务逻辑,如果业务对象中需要某个会被复用的业务函数,建议根据具体目的,抽象出对应的 er/or 对象,语义更明确
    • Builder.build
    • Filter.filter
    • Finder.find
    • Creator.create
    • Optimizer.optimize
    • Strategy
    • Validator
    • Translator
    • Interpreter
    • Parser
    • Processor
    • Handler

如下情况就推荐有领域对象

interface UserDO {
  firstName: string;
  lastName: string;
  age: number;
  male: 0 | 1 | 2;
}

class User {
  firstName: string;
  lastName: string;
  age: number;
  male: 0 | 1 | 2;
  constructor(user: UserDO) {
    this.firstName = user.firstName;
    this.lastName = user.lastName;
    this.age = user.age;
    this.male = user.male;
  }
  static fromDO(user: UserDO) {
    return new User(user);
  }

  getName() {
    return this.lastName + this.firstName;
  }

  getMaleString() {
    if (this.male === 0) {
      return '未知';
    }
    return this.male === 1 ? '男' : '女';
  }
}

React 生态

上面谈的都是通过 OOP 如何解决这个问题,那么使用 React 的方式呢?

先谈谈纯使用 React,哪些地方让你觉得不方便呢?

我们的关注点是,如何将逻辑与视图解耦,也就是尽可能就将逻辑从组件里面搬出来,把组件本身掏空。你会碰到哪些难题呢?当然,组件和类都一样,讲究一个度,太胖了不好看,太瘦了也不好看,不胖不瘦才最美

  • 情形一:对于某个复杂对象数据,存在很多的逻辑操作,比如增删某个元素,修改列表中某个对象的属性等,如果这些逻辑比较多,都放在组件中的话,就会显示的非常的丑陋,违背了逻辑与 UI 解耦的原则。
  • 情形二:React 基于树结构进行渲染,如果某个数据要被多个组件共享,通常的解决方式就是提取到公共的父节点,这样带来的问题是,如果组件层级比较深,比如超过了 3 层,会存在中间组件很多 props 仅仅是为了透传给更下一层,同样非常的丑陋。

我突然意识到有一个方式可以帮你把修改数据的逻辑从组件中提取出来,在 hooks 中就是使用 useReducer。因为 reducer 是一个纯函数,你可以定义在组件外,作为一个单独的模块也是 OK 的。除了抽取逻辑外,还带来了一系列好处

  • reducer 脱离组件后变成单独的逻辑,非常易于测试和扩展,以表达复杂的更新逻辑。
  • useEffect 依赖项目过多且频繁变化时,状态会变得非常不可控,你可以通过 useReducer 将 state 更新逻辑移到 effect 之外呢,因为 useReducer 的 dispatch 身份永远是稳定的,大大减少你的顾虑

在 hooks 中使用 React Context 非常方便,它可以帮助我们解决层层传递的问题,配合 useReducer,更是能力无限,比如如下代码

function CountProvider(props) {
  const [state, dispatch] = useReducer(countReducer, {count: 0})
  // 推荐 memo 一下,减少不必要的更新
  const value = useMemo(() => [state, dispatch], [state])
  return <CountContext.Provider value={value} {...props} />
}
function useCount() {
  const context = useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [state, dispatch] = context;
  const increment = () => dispatch({type: 'INCREMENT'})
  return {
    state,
    dispatch,
    increment,
  }
}

通常使用 Context,建议简单的封装一下对于 useContext 和 Provider 的使用,而不是直接暴露出去 Context 对象,这样会具有语义一些。

使用 Context 需要注意的点

  • 不是应用中所有的状态都需要放在同一个状态对象中,保持它们从逻辑上分离。比如用户的设置不需要和提示信息放在一个 context 里,你可以创建多个 providers
  • 不是所有的 context 都需要被全局访问,尽可能地把状态和需要它的地方放的近一些

如果不使用社区方案,仅使用 Context 和 useReducer 也可以达到同样的目的,只是会缺少一些高级特性,比如 Context 获取到一个新值时,所有消费它的组件都会被更新且必须被渲染,哪怕是只关心其中部分数据的函数组件,会带来潜在的性能问题,而 Redux 内部会帮你解决这个问题。关于社区开源方案,我们后面再讨论。

我们先探讨下使用进阶方案的必要性,哪怕是使用 Context 方案。前面也提到均衡问题,不胖不瘦才最美。因此有没有办法合理的设计避开这个问题呢,保持使用最简单方案。

先来谈谈为什么使用 Props Down

  • 如果你使用过基于全局变量的设计,比如 AngularJS 的 $scope$rootScope、甚至基于单例的 Service 机制,社区拒绝该方案的原因是,它不可避免地会导致应用程序的数据模型非常混乱。任何人都很难找到数据在哪里初始化、在哪里更新和在哪里使用。比如你很难回答:我可以修改或删除这个代码吗?
  • 基于 Props Down 的方式,简单的在整个应用中传递值,你可以简单的通过静态分析追踪到所有使用它的地方

技术没有银弹,这时候别人就会说了,代码确实清晰了,但我传起来很烦啊。

先回顾一下 React 设计哲学

  • 组合、单向数据流、显示变异
  • 根据单一功能原则判定组件的范围
  • 保持 state 最小完整表示
    • 你可能不需要派生 state
    • 不会被更改的值、不会被模板渲染的值,不应该作为 state
    • 所有能够通过 state 或 props 计算而来的值,不应该作为 state
  • state 位置:尽量在最小范围内自我管理

其实中最重要的是组件设计和模块化划分要做好,这样一来大部分情况,如果组件简单,很好理解,自然不需要引入其他解决方案

  • 让状态尽可能靠近它相关的地方,而不是在最上层的组件。简单来说:自己能管理好的事情,就自己管理好,如无必要,不必上报
  • 如果真的要工作在的深层次的树结构中,可以考虑使用 Context API。虽然上下文有点把我们带回到全局变量的时代。不同之处在于,由于 API 的设计方式,您仍然可以相对容易地静态地找到上下文的源以及任何消费者。
  • 使用 Redux 要谨慎,即使要使用,也仅仅是真的需要。什么状态都往 Redux 中放会导致非常多的问题。比如当你在维护任何状态交互时,都会涉及到 reducer、action creator/types 以及 dispatch 调用,导致我们必须打开一堆文件,并在大脑中追溯代码,才能弄明白发生了什么,以及它对代码库其他部分产生了什么样的影响。

聊到 Redux,那么就来聊聊社区方案。

开源方案

层出不穷的社区方案,在我看来做的都是同一件事:把逻辑从组件中拿出来、以及解决层层传递问题。然后才是哪些进阶功能,比如性能优化啥的。

  • Redux:把数据修改逻辑拿出来,以及传递问题
  • Redux-saga/……:数据修改逻辑是拿出来了,但是异步逻辑还在组件里呀,那就通过 saga 把异步逻辑,比如发送请求等逻辑也拿出去吧。

React 发展了这么久,关于状态管理的方案也是越来越多,下面了解一些有趣的方案。

HookState 这个方案就非常有意思,简单介绍如下

  • State 类型
    • 全局 State:放在组件外通过 createState 创建的就是全局 State,组件内部通过 useState 即可使用
    • 本地 State:组件内直接使用 useState 即可
  • State 不再是普通对象,而是具备 get/set 方法的对象
    • 意味着渲染值需要调用 get 获取值
    • 最有意思的 set 方法,将 state 向下传递时,一来你修改值,直接下层组件调用 set 即可,不在需要使用 event up 的方式了
    • 不仅顶层 state 有 get/set,嵌套对象属性同样有,比如 state.name.set 同样是 OK 的
    • 高阶扩展:merge、keys、value
  • 对于异步的支持:State 值可以是一个 Promise
  • 结论:感觉通过 OOP 的方式,解决了传递和修改的问题,非常的 magic

React Query 非常的特别,关注于管理服务端的数据

  • 将状态分为客户端状态和服务端状态,从服务端状态进行切入
  • 服务器状态库:更好的管理程序中的获取、缓存、同步和更新服务器状态,结构化共享并存储查询结果
  • 提供各种查询的高级功能
    • 有依赖的查询、加载状态提示、焦点影响数据刷新、禁用暂停查询、查询重试
    • 分页查询、无限查询、预取数据、数据更新、查询取消
  • 核心概念
    • 查询 queries
    • 修改 mutations
    • 查询错误处理 Query Invalidation
  • 作用
    • 用少数几行代码替代了用于管理客户端状态中的缓存数据的样板代码和相关联的代码。

Recoil 由 facebook 推出的状态管理工具,目前在实验阶段。其提供的 api 非常的简单,这让我很欢喜

  • atom:用于存储状态,可更新、可订阅,多个组件使用相同的 atom 时,则共享状态
  • useRecoilState:组件通过该 api 订阅 atom,用于读取和写入值
  • selector:纯函数,入参为 atom 和 selector,当上有更新时,重新执行 selector,类似 memo,避免冗余 state
  • 支持异步查询
  • ……

总结

每个方案的切入思想不太一样,说明这块值得争论空间很大,都是魔法,需要用的时候再用。

同时这里会带来新的问题:数据变得非常容易获得,那么一个组件的能做的事情就很多了,就像大圣一样,如果不加以限制,那么捣乱的能力也是很大的。组件还是有必要进行区分,比如容器容器还是展示组件,业务组件还是通用组件。你有能力拿到某些数据,甚至做某些操作,但你要不要去做就很重要了,很多事情不是你能做到应该去做的,毕竟做多错多。从另一个角度而言,你做的事情越多,说明功能不单一,那么可复用度就越低。

派生 state

文中提到派生 state,关于这一块多聊几句,因为我踩坑了。

关于派生 state 让我联想起当初实现一个特定场景的 NumberInput 的噩梦,大致需求如下

  • 显示服务端的数据值
  • 子组件值更新时,需要做一些自动修正,比如超过最大最小值时拒绝输入、超过精度时自动修正,触发 change 事件
  • 失焦或者回车时,触发提交 submit 事件,内部会存储上次提交值,如果和当前值相同,则不触发。如果当前是空值,则恢复到上一个非空值(内部存储),其他情况直接触发

版本迭代之路

  • 受控组件,内部存储上次提交值和上次非空值。如果组件被复用了,也就是说数据源从 A 切换到 B,这时候组件内部的值上次提交值和上次非空值就是错的,导致还原时就是错的,而且我还找不到重置时机。想到最简单的方式就是组件使用是添加唯一 key,避免被复用,这时候组件直接销毁重建,也就不存在脏状态了。更恶心的是,由于会拒绝超过最大最小值的输入,会导致无法输入的情况,比如最小值是 5,我想输入 30,一开始数字 3 就输入不了,因为小于 5
  • 改成非控组件,内部维护一个 innerValue 值,父组件使用时传入整个数据源 A,而不只是 value 值,内部监听 A,如果发生变化,则重置相关状态。与此同时 innerValue 可以显示任何值,包括非法状态,但只有在合法时,才会触发 change 事件

整个写下来就非常的恶心,非常的不放心,总觉得会存在各种问题。

为什么实现一个自动修复到上一次正确值的 InputNumber 组件是一件困难的事情

  • 确保输入是数值
  • 超过最大最小值的时候拒绝输入?首先任何时候都建议不要做拒绝输入的事情,会导致一个意料之外的事情,比如你可能无法清空输入框重新输入,推荐改为在失去焦点时进行修正
  • 超过精度时自动修正?比如精度为 2,输入 12.345 是自动修正为 12.35。同样不建议做影响用户输入的事情,改为在失去焦点时修正
  • 额外需求:失焦或回车时,触发 submit 事件,如果值没有发生变化(相比上次提交值),则不触发。如果当前没空值,则恢复到上一个非空值
  • 可能存在的问题:组件被复用时,内部存储的上次值就是错的,导致还原时,不满足要求,而且你找不到重置时机。

大部分使用派生 state 导致的问题的场景

  • 直接复制 props 到 state 上
  • 如果 props 和 state 不一致就更新 state

定义组件是受控还是非受控:常用来指表单的输入,也可以用来描述数据频繁更新的组件。用 props 传入数据的话,被认为受控。数据只保存在组件内部的 state,则认为是非受控组件。

反面模式

  • 直接复制 prop 到 state,这很容易发现问题,因为父组件一更新,内部 state 就被 prop 修改了,明显不符合预期,就不啰嗦了
  • 在 props 变化后修改 state,这种情况问题就比较难以发现,且很容易犯这样的错

我们很容易写出这样的代码,组件内部创建 state,初始值来源于 prop,且在 prop 更新时,重新设置 state 值,但组件内部会操作这个 state,看上去逻辑还挺严谨的,但如果组件被复用了,本身是给数据源 A 用,切换到 B 了,但 A、B 对应的值是一样的,这样依赖组件内部的 state 就被复用了,但很多时候这都是不合适的。解决的办法是:任何数据,都要保证只有一个数据来源。比如这个好像来源 prop,又好像来源 state 的操作就很容易出现问题,且不易察觉。

建议的模式

  • 完全可控的组件:子组件删除 state,直接使用 props。如果仍然想要保存临时的值,则需要父组件手动执行保存的动作,类似于有一个 draftValue 的味道。让我猛然想起产品说的,自动保存正确值的需求!!!
  • 有 key 的非可控组件:组件仍然可以从 prop 接受初始值,这时候我们通常使用 default 打头命名,表示为非控的,只有首次有效。
  • 如果认为 key 值导致组件销毁重建开销大,还是想复用组件的话,那就只能接收一个类似唯一 id 的方式,内部判断 id 是否发生修改,来决定只在需要时使用 props 更新 state
  • 使用实例方法重置非受控组件:antd form 就是这么做的,虽然从声明式变成了命令式,但是复杂场景该用还得用

有没有发现:设计一个组件是否可控可选,还挺麻烦的,比如我常用的方案如下,其实细品还是有些问题的,有点好奇 antd 是怎么解决的,看到 ahooks 中有个对应的 hooks,具体下面说。

function App({defaultValue, value, onChange}) {
    const [innerValue, setInnerValue] = useState(value || defaultValue);
    useEffect(() => {
        setInnerValue(value);
        onChange(value);
    }, [value])
}

magic hooks

由于 hooks 的推出,诞生了非常多的魔法 hooks,这里列举两个开源方案

具体可以去看源码哈,这里谈几个有趣的

  • 函数或值总是变化,但我不能放进 deps 怎么办,但 effect 中的确需要,没关系,我们有魔法
    • usePersistFn/useMemoizeFn:既保证引用不变,同时不会存在旧值问题
    • useGetState:提供第三个参数稳定函数 getState,用于获取值
  • 生命周期类,可恶的 hooks,让我没有生命周期用了,没关系,我们有魔法
    • useMount
    • useUnMount
    • useUpdate
    • useUpdateEffect
  • OOP 对象类,React 讲究个不变异性,难道哪些优秀的对象我就不能用了吗,比如 Map、Set,没关系,我们有魔法
    • useMap:内部实现其实很简单哈,就是使用当前值,新建 Map 对象
    • useSet
  • 开发协助类:这个还挺好的
    • useTrackedEffect 追踪哪个改变使得 effect 执行
    • useWhyDidYouUpdate 追踪为什么组件会更新
  • OOP 新建
    • useCreation:useRef 或 useMemo 的替代品,因为 useMemo 在未来不保证一定不会被重计算,因为 render 阶段可能会重复执行,而对于 useRef 每次重渲染,都会重新执行
  • 其他
    • useControllableValue:解决组件受控问题,到底是内部 state 管理,还是管理 props,amazing,非常 trick 的实现方式
    • ……

严格模式

这部分和状态无关哈,只是属于我踩坑了。

StrictMode 用来突出显示应用程序中潜在问题的工具,不会渲染任何可见的 UI,仅在开发模式运行,不会影响生产构建。

目标:为其后代元素触发额外的检查和警告

  • 识别不安全的生命周期
  • 关于过时字符串 ref API 的警告
  • 关于使用废弃的 findDOMNode 方法的警告
  • 检查意外的副作用
  • 检查过时的 Context API

目的:解决项目中严格模式所识别出来的问题,会使得在未来的 React 版本中使用 concurrent 渲染变得更容易。

与此同时,我观察了一下 antd 在 StrictMode 下存在的问题还不少,具体可以看 React 18 and StrictMode support,最多的为如下几类

  • findDOMNode warning
  • 开启并发模式后,很多组件直接工作异常,看来副作用写不少

关于检测意外的副作用

深入聊聊关于检测意外副作用,因为把我坑的不要不要的。

之前在代码编写过程中,我发现我的监听总是会被调用两次,但我几经排查,确定自己确实只调用了一次,关键是我打印 log,也只有一次,但是我 debugger 却有两次,当时我就世界观崩塌了。后面发现是 React 做了手脚。

在 React 17 开始,React 会自动修改 console 的方法,以在对生命周期函数的第二次调用中静默日志,难怪啊,我以为见鬼呢。你可以通过在模块顶部使用 log 记录 console.log,然后通过 log 方法打印日志的方式来避过。

从概念上讲,React 分两个阶段工作

  • 渲染阶段:确定需要进行哪些更改,比如 DOM,此阶段 React 调用 render 生成 DOM 树,然后将结果与上次渲染的结果进行比较。
    • 类组件会执行 constructor、render、shouldComponentUpdate、getDerivedStateFromProps、setState 更新函数(第一个参数)
    • 函数式组件会执行 useState、useMemo、useReducer 钩子
  • 提交阶段:发生在 React 应用变化时,对于 DOM 而言,发生在 React 插入,更新及删除 DOM 节点的时候。
    • 类组件会调用 componentDidMount 和 componentDidUpdate 方法
    • 函数式组件会执行 useEffect 钩子

提交阶段通常会很快,但渲染过程可能很慢。因此即将推出的 concurrent 模式 (默认情况下未启用) 将渲染工作分解为多个部分,对任务进行暂停和恢复操作以避免阻塞浏览器。这意味着 React 可以在提交之前多次调用渲染阶段生命周期的方法,或者在不提交的情况下调用它们(由于出现错误或更高优先级的任务使其中断)。

由于渲染阶段的函数可能会被调用多次,所以不要在他们内部编写副作用相关的代码,这很重要,否则可能会导致各种问题的产生,包括内存泄露或出现无效的应用状态。这些问题很难被发现,因为它们具有非确定性。

不要在渲染结果执行带有副作用的操作,由于 React 无法感知是否执行了副作用,因此 React 在严格模式下,会故意重复执行渲染阶段的方法,使得我们在开发阶段能更容易发现这类 bug。(并不是所有渲染阶段的生命周期都会被执行)

严格模式并不能自动检测到你的副作用,但它可能帮助你发现它们,使得他们更具确定性,通过故意重复调用以下函数来实现该操作

  • 类组件:constructor,render,shouldComponentUpdate、getDerivedStateFromProps
  • 函数组件:函数体、传递给 useState,useMemo 或者 useReducer(当 dispatch action 时,reducer 函数会执行两次)的函数
  • setState updater 函数,包括函数式组件和类组件

关于 setState updater 函数:函数式组件 useState 的 setXXX updater 函数,需要在模板中明确使用该 state,否则只会看到第二次触发执行两次。类组件 setState updater 必执行两次

结论:在严格模式下,useState、useMemo、useReducer 的第一个参数、Hook 函数体都会被执行两次,set functions 会执行两次,不要在这里执行带有副作用的操作

关于这个严格模式的讨论也是非常之多

资料



留言