Better

Ethan的博客,欢迎访问交流

编写有弹性的组件

来自 Dan 大神的博客摘录,一篇阅读摘要哈。

风格指南

别被虚幻的问题分散了注意力:指的就是明明正确的代码,由于代码风格的问题,被提示为错误,但这其实是 linter 配置产生的。

关于 linter 看法:通过良好的配置,linter 是一个很好的工具,它可以在 bug 出现前就能发现它们。但它对代码风格的关注过多,使其变得会分散注意力。

因此你需要做的一件事情就是:整理你的 Lint 配置,把你的团队叫到一起,一条条过一下你们项目中启用的 lint 规则,接着问问自己:“这条规则有帮我找到过 bug 吗?” 如果不是,关掉这条规则。不要假设一年前你或别人添加到你的 lint 配置中的任何东西,都是“最佳实践”。保持质疑,找到答案。

关于代码格式化:用 Prettier 然后忘掉 “风格”。

不阻断数据流

渲染中不要阻断数据流

常见的一个错误是,把 props 复制到 state,会导致后续 props 数据的更新会被忽略,这通常不是我们所希望的。

很少情况下,这样的行为是有意为之的,请确认将这样的属性取名为 initialXXXdefaultXXX 来表明组件会忽略这个属性的改变。

如果一个值,需要使用 props 经过复杂的计算来得到,这是一个会将 props 复制到 state 的场景。但通常我们要准备很多额外的事情才能保证没有 bug,以下是常见的方式

  • 如果仅仅初始化 state 的时候进行计算,则 props 更新时会数据不新鲜
  • 可以把计算放到 render 中,组件改为 PureComponent
  • 以上方式你也知道,PureComponent 不能比较引用类型,如果 children 改变,会导致调用 render,会导致重新计算
  • 直观上会想到在 componentDidUpdate 中调用计算,只有值不相等才重新计算,但这会导致每次更新都会有两次 render
  • 可以使用已不推荐的 componentWillReceiveProps 生命周期函数。然而,大家经常把 side effects 放这。这反过来又往往会给即将到来的并发渲染 特性像 Time Slicing 和 Suspense 带来问题。而更 “安全” 的 getDerivedStateFromProps 又有点难用。
  • 回归问题的本质,我们是想记忆一个值,除非输入改变,否则重新计算输出
    • 函数组件使用 useMemo
    • 类组件使用 memoize-one

不要在 Side Effects 里阻断数据流

这个咋一看有点难以理解,首先理解什么是副作用,可以理解成该函数不是纯函数。比如我们数据获取,每次都会对 state 进行赋值,这里就是副作用。

同样表现在,如果我们数据的获取,依赖 props 传递的参数,此时我们应该响应 props 的每一次变化,否则通常而言,就会存在 bug

  • 我们可能不仅在 componentDidMount 中执行数据获取,同样在 componentDidUpdate 中进行依赖判断,如果 props 响应的值改变了,则重新获取数据
  • 上述存在的问题就是,如果某天数据获取函数,需要多依赖 props 传递的一个参数,componentDidUpdate 中需要增加条件判断逻辑,但是这个逻辑通常我们很可能会忘记,应为没有任何提示
  • 如果我们能够以某种方式自动捕捉到这些错误,那不是很好吗?useEffect 就可以做到这件事情,将逻辑放在 effect 中,这样可以更容易地看到它从 React 数据流中依赖了哪些值。这些值称为“依赖”,而且由于 useEffect 依赖关系是显示的,可以使用 lint 规则检验是否一致,这通常可以帮我们避免 bug
  • 使用 class API,你必须自己考虑一致性,并验证对每个相关 prop 或 state 的更改是否该由 componentDidUpdate 处理。

不要在优化中阻断数据流

你可能会意外忽略对 props 的更改。当你手动优化组件时,可能会发生这类错误。

注意,使用浅比较的优化方法(如 PureComponentReact.memo)与 默认比较 是安全的。

如果你尝试通过编写自己的比较方法来 “优化” 组件,你可能会错误地忘记比较函数属性,一开始很容易错过这个错误,因为对于类,你通常会传递一个方法,所以它会有相同的身份。

如何避免这个问题呢?建议避免手动实现 shouldComponentUpdate ,也要避免在 React.memo() 中使用自定义的比较方法。如果你坚持使用自定义的比较,请确保不跳过函数

在类组件中很容易错过这个问题,因为方法标识通常是稳定的(但并非总是如此——而这就是 debug 困难的地方)。有了Hooks,情况不同了:

  • function 在每个渲染中都不同,所以你能马上发现这个问题
  • 通过 useCallback 和 useContext,你能避免往下传递函数。这让你优化渲染时不用太担心函数的问题。

时刻准备渲染

这个标题咋一看,会有点懵逼。大概意思就是说:不要试图在组件行为中,假设任何不必要的时间假设。你的组件应该随时可以重新渲染。

这里提到一个时间假设,组件渲染的三种可能,父组件更新子组件更新,子组件自身更新,这里应该是没有时间先后的,同时多次组件更新,子组件不应该被破坏掉。更具体一点:父组件的重渲染,不应该污染子组件的状态

组件应该具有弹性,能适应更少或更频繁地渲染,否则它们与特定父组件存在过多耦合。

违背这个原则的方式是什么样的?React 让这没那么容易发生——但你可以使用传统的 componentWillReceiveProps 生命周期方法来实现。

我们可以看到各种优化,例如 PureComponent、shouldComponentUpdate 和 React.memo,它们不应该用于控制行为。只有提高性能的场景下,去使用它们。如果删除优化就会破坏某个组件,那么它就太脆弱了

没有单例组件

有时我们假设某个组件只会显示一次,如导航栏。在一段时间内这也许是对的,然而,这种假设导致的设计问题,常常会在后期显现。

要重现这类问题也很容易,试试渲染你的应用两次

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

你的应用仍然正常运行吗?或者你是否看到奇怪的崩溃和错误?偶尔对复杂组件进行压力测试是个好主意,可以确保组件存在多个拷贝时不会相互冲突。

作者写过的几次有问题的代码情况

  • componentWillUnmount 中执行全局状态 “清理”
  • componentDidMount 执行全局状态的重置

这些模式是检测组件是否脆弱的好指标。显示或隐藏 一颗树,不应该破坏树之外的组件。

隔离本地状态

React 组件可能有本地状态。但是哪个状态真的是自己的呢?

如果你不确定某个状态是否属于本地,请问自己:“如果此组件呈现两次,交互是否应反映在另一个副本中?” 只要答案为“否”,那你就找到本地状态了。

别把该本地的状态全局化了,这涉及到我们的 “弹性” 主题:组件之间发生的意外同步更少。作为奖励,这也修复了一大类性能问题。

总结

让我们再一次回顾一下这些原则:

  • 不阻断数据流:props 和 state 可能会更新,组件应该处理好这些更新,不论什么时候。
  • 时刻准备渲染:一个组件不应该被或多或少的渲染而损坏。
  • 没有单例组件:即使组件只渲染一次,但通过设计让它渲染两次也不会被破坏,是更好了。
  • 隔离本地状态:想想哪个状态是特定 UI 展示下的本地状态——并且除非必要,不要将该状态提升到更高的地方。

生命周期的变化

16.4 版本的生命周期较 16 版本之前有比较大的变化。原来(React v16.0前)的生命周期在 React v16 推出的 Fiber 之后就不合适了,因为如果要开启 async rendering,在 render 函数之前的所有函数,都有可能被执行多次。

原来(React v16.0前)的生命周期有哪些是在render前执行的呢?

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

禁止不能用比劝导开发者不要这样用的效果更好,所以除了 shouldComponentUpdate,其他在 render 函数之前的所有函数(componentWillMountcomponentWillReceivePropscomponentWillUpdate)都被 getDerivedStateFromProps 静态函数替代

用一个静态函数 getDerivedStateFromProps 来取代被 deprecate 的几个生命周期函数,就是强制开发者在 render 之前只做无副作用的操作,而且能做的操作局限在根据 propsstate 决定新的 state

static getDerivedStateFromProps(props, state) 在组件创建时和更新时的render方法之前调用,它应该返回一个对象来更新状态,或者返回 null 来不更新任何内容。

getSnapshotBeforeUpdate() 被调用于 render 之后,可以读取但无法使用 DOM 的时候。它使您的组件可以在可能更改之前从 DOM 捕获一些信息(例如滚动位置)。此生命周期返回的任何值都将作为参数传递给 componentDidUpdate()。

最常见的误解就是 getDerivedStateFromProps 和 componentWillReceiveProps 只会在 props “改变”时才会调用。实际上只要父级重新渲染时,这两个生命周期函数就会重新调用,不管 props 有没有“变化”。

派生 state

在 React 的官网 blog 中提到你可能不需要使用派生 state,和这篇文章的内容有一定的相关性,这里也学习一下。

getDerivedStateFromProps 的存在只有一个目的:让组件在 props 变化时更新 state

有一个原则:我们应该保守使用派生 state,因为它可能会引入 bug。

用 props 传入数据的话,组件可以被认为是受控(因为组件被父级传入的 props 控制)。数据只保存在组件内部的 state 的话,是非受控组件(因为外部没办法直接控制 state)。

为避免出现问题,有两种常见的方式

  • 完全可控的组件
  • 有 key 的非可控组件

如果没法使用 key 的话,这里分为两种情况

  • 如果某些情况下 key 不起作用(可能是组件初始化的开销太大),一个麻烦但是可行的方案是在 getDerivedStateFromProps 观察 ID 的变化
  • 更少见的情况是,即使没有合适的 key,我们也想重新创建组件。一种解决方案是给一个随机值或者递增的值当作 key,另外一种是通过 ref 得到组件实例,通过调用实例方法,强制重置内部状态

设计组件时,重要的是确定组件是受控组件还是非受控组件。

  • 不要直接复制 props 的值到 state 中,而是去实现一个受控的组件
  • 对于不受控的组件,当你想在 prop 变化(通常是 ID )时重置 state 的话,可以选择一下几种方式:
    • 建议: 重置内部所有的初始 state,使用 key 属性
    • 选项一:仅更改某些字段,观察特殊属性的变化(比如 props.userID)。
    • 选项二:使用 ref 调用实例方法。

memoization

仅在输入变化时,重新计算 render 需要使用的值————这个技术叫做 memoization。

在使用 memoization 时,请记住这些约束:

  • 大部分情况下, 每个组件内部都要引入 memoized 方法,已免实例之间相互影响。
  • 一般情况下,我们会限制 memoization 帮助函数的缓存空间,以免内存泄漏。(上面的例子中,使用 memoize-one 只缓存最后一次的参数和结果)。

原文



留言