【前端漫谈】复杂表单下无限重绘问题引发的思考

541 阅读10分钟

前言

近期团队在进行业务系统之间的迁移和复杂业务系统开发,将前端代码规范 TypeScript + Reack Hooks + Eslint 深入推进到项目中,整体开发进度和代码质量有了明显的提升。在这个过程中,我遇到了两次复杂表单下无限重绘的问题,在深入定位问题的时候,触发我的一些对于前端重构开发的一些有趣的思考,大家一起来看下吧。

事故现场

image.png

这是一个常见的信息编辑控件,负责将图中表格里的素材信息进行编辑和保存。但是在渲染已有数据时发现数据出现了无限渲染的 bug,数据在初始值和填充数据之间动态跳动,进而将浏览器的性能全部消耗,页面完全卡死。

事故探索过程

经过 debug 发现下面预期的渲染是出现了的,随后就被初始值空数组给替换了,然后无限重复了上述过程。

image.png 通过简单的console.log也能够验证上述的猜测,定位到无限渲染的触发路径。

image.png

到这里常见的原因是:在 React 的生命周期中,进行了错误的 state 或者 props 的数据值变更或者引用变更,导致 React 的 props 出现周期变化。按照这个思路,梳理了组件层级代码结构。

image.png 发现组件层级之间,将 props 中的 value 和组件内部状态中的 value 通过 useEffect 和o nChange 实现了数据联动,

  const [materials, setMaterials] = useState<MaterialInfo[]>([]);

  useEffect(() => {
    setMaterials(defaultMaterials);
  }, [defaultMaterials]);

这种设计显然是很容易造成上面推测的情况。

在进一步的 debugger 断点时发现,ManualInput 的 onChange 并没有被频繁触发,仅仅公共素材组件 MaterialInput 的联动部分在初始值和后续值之间交替变化,推测是组件渲染的内部出现了问题。

  useEffect(() => {
    onChange?.(data);
  }, [data]);

  useEffect(() => {
    if ((value || []).map(item => item.content).join(',') !== data.map(item => item.content).join(',')) {
      setData(
        value.map(item => {
          return {
            ...item,
            key: item.id,
          };
        }),
      );
    }
  }, [value]);

最终通过移除了ManualInput的数据联动,渲染直接依赖 props 中 defaultMaterials,移除无用内部 state 解决了这个问题。

事故推测原因

由于三级联动会导致任意数值的变化,会导致各个层级的组件被频繁触发重绘,而在组件初始化的时候且页面渲染压力大时,其渲染顺序和 render 的结果合并出现了错误,导致最后一级的组件 props 的在初始值和后续值的渲染顺序或者渲染合并出现了问题,导致初始值和后续值形成两个进程去交替渲染和引发对方渲染的循环过程。涉及了 React 内部 Diff 机制,渲染机制,可能还涉及内存泄漏,例如下图中的setTimeout提示。\

image.png

但是从这个案例推上面的推论还是有点勉强的,接下来讨论另外一个类似案例。

线上正常运行的表单页面的一个复杂时区时间选择器也出现了同样的情况,经过排查发现代码没有任何改动,版本号无重大变更,最终发现是后端的一个数据从 string[] 变成了 { value: string, name: string }[] 导致 Form.Item 组件的 value 也发生相同的变更。

image.png

其组件的层级结构如下:

image.png

由于中层组件是承接 Form.Item 封装和公共组件的中间层,移除联动机制的话业务逻辑要整体修改,且旧有逻辑担心出错,大范围修正根本来不及。同样浏览器的控制台看到了大量的 setTimeout Violation warning 提示,猜测到可能是由于 { value: string, name: string }[] 两层地址引用的数据结构给 React Diff 带来很大的压力,startWorkLoopTimer 出现了超时。对这方面有兴趣的可以去了解《React源码》《react-fiber-architecture》 Andrew Clark 图解

从这个思路出发,最终的解决方案是十分简单的,将值进行析构或者 深Copy 来降低React的 diff 压力。

// FormItem 部分
<FormItem
  name="timeline"
  timeline={[...config.timeline]}
  defaultSelectTimeline={config.defaultSelectTimeline}
  countries={[...regionInfo]}
  addonText={config.addonText}
  component={TimeLine}
  channels={defaultFormData.campaignRegionInfos || []}
  isReview={disabled}
  disabled={disabled}
  disabledFn={item => item !== 'preTeasing' && !isCreateFromTb}
  disabledCountry={createFromTB}
  required
/>
// --- TimeLine组件 ---
// 降低render的触发次数
shouldComponentUpdate(nextProps) {
  if (
    JSON.stringify(nextProps.countries) !== JSON.stringify(this.props.countries) ||
    ... // 其他判定条件
  ) {
    return true;
  }
  return false;
}
// 保持第三级组件的渲染性能
<TimelineSelector
  required
  disabled={disabled}
  channels={channels}
  isHide={hideTimeline}
  timeline={timeline}
  defaultSelectTimeline={defaultTimeline}
  onChange={e => this.handleSelectTimeline(e)}
  disabledFn={disabledFn}
/>
<CountrySelector
  required
  formItemLayout={formItemLayout}
  isHide={hideVenture}
  countries={countries || []}
  onChange={e => this.handleSelectCountry(e)}
  defaultSelectCountry={selectCountry}
  disabledCountry={disabledCountry}
/>

 这里对于案例的解析就到此为止了,那么我们实际更应该去思考如何去解决或者避免这些问题,去提升前端页面的健壮性和性能。

整体复盘

在问题解决之后,我的思维突然活跃了很多,想起了我之前做的分享,其中有一部分是关于前端组件设计原则的,其文章来源是Front end component design principles :

在设计组件的时候需要考虑到很多方面,以便它们可以很好的复用、组合、分离和低耦合。但是功能往往可以比较稳定的实现,但是这些设计在实际开发中,往往做起来很难,在现实中大家实际并没有足够的时间去按照最优的方式去做。但是我还是希望大家能够尽可能去追求良好的组件设计准则,这不仅仅是提高自身组件设计能力的唯一途径,在未来的重构和维护过程中能够更加易读,更好的复用,节约宝贵的开发时间。

在这篇文章中,作者提出了其中以下8点值得大家去关注的。

Hierarchy and class diagrams
Flat, data-oriented state/props State change purity
Loose coupling
Auxiliary code separation
View distillation
Timely modularisation
Centralised state considerations \

简单翻译下:

1、层次结构和 UML 类图

2、扁平化、面向数据的 state/props

3、更加纯粹的 State 变化

4、低耦合

5、辅助代码分离

6、提炼精华

7、及时模块化

8、集中/统一的状态管理

对于这一部分,当时也没能找到合适的例子去验证这些原则,发现当下的这个问题实际是很好的验证了上面的1、2、3、4、8原则的。

1、组件设计结构应当清晰,重点模块的组件图能够很好的帮助你在应对变更和重构时保持良好的思路和设计准则。
生命周期内的任何改变状态或者外部值的操作都应该慎重,必要时需要画个层次结构、UML类图或者有限状态机,辅助自己判断。

2、组件设计应当简化,内部状态和组件入参推荐 javascript 的基本数据类型,深层次的对象应当避免,避免内存泄漏风险,减少 deps 的 diff 算法的压力。

在 state 和 props 频繁被 watch 和 update 的情况下,如果你有使用嵌套数据,那么你的性能可能会受到影响,尤其是在以下场景中,例如一些因为浅对于而触发的重新渲染;在涉及 immutability 的库中,比如 React,你必须创建状态的副本而不是像在 Vue 中那样直接更改它们,并且使用嵌套数据这样做可能会创建笨拙,丑陋的代码。

3、内部状态应当精简,避免无用 state 的出现,特别是 form 下的 value 和内部 state 的绑定,避免多级数据联动。

4、松耦合设计是必要的,尽量不要喝特定父子组件建立密切关联。
建议严格实行 typescript 代码声明,约定并且固化依赖,这里并不强制一定做到100%的松耦合,正如本章节开头所说大家并没有足够的事情去做这个事情,但是我们可以将依赖进行显式约定,明确逻辑过程。

5、集中/统一状态管理,其中 react hooks 的使用应当慎重,合理规划内部状态。
数据项过度分散,会引起复杂依赖更新,则会出现大量的过渡渲染;数据项过于复杂则会增加 react diff 的处理性能

6、关注首次渲染的逻辑,大多数深层次的 bug 的都是和浏览器性能瓶颈有关,也能够显著提示用户体验。
避免多次请求,多次无用或者过渡渲染,设置必要的缓存 useMemo 来保证所有数据到达后开启渲染。

function checkDataReady(data, version) {
  // check data version
  return data.version === version;
}

let defaultValue = ...;
// wait all data completed
const memoizedValue = useMemo(() => {
  if (!loading && checkDataReady(dataA, version) && checkDataReady(dataA, version)) {
    defaultValue = computeExpensiveValue(dataA, dataB);
    return computeExpensiveValue(dataA, dataB);
  }
  return defaultValue;
}, [loading, version, dataA, dataB, ...restData]);

有趣的发现

回归整体的问题排查过程,你会发现一些关于调试的有趣发现。

1、console 也会欺骗你

以下内容完全引自:《你不知道的javascript中卷》第二部分异步和性能 1.1 异步控制台部分:

并没有什么规范或一组需求指定console.* 方法族如何工作——它们并不是JavaScript 正式
的一部分,而是由宿主环境(请参考本书的“类型和语法”部分)添加到JavaScript 中的。因此,不同的浏览器和JavaScript 环境可以按照自己的意愿来实现,有时候这会引起混淆。

尤其要提出的是,在某些条件下,某些浏览器的console.log(..) 并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是JavaScript)中,I/O 是非常低速的阻塞部分。所以,(从页面/UI 的角度来说)浏览器在后台异步处理控制台I/O 能够提高性能,这时用户甚至可能根本意识不到其发生。

多数情况下,前述代码在开发者工具的控制台中输出的对象表示与期望是一致的。

到底什么时候控制台 I/O 会延迟,甚至是否能够被观察到,这都是游移不定的。

如果在调试的过程中遇到对象在 console.log(..)  语句之后被修改,可你却看到了意料之外的结果,要意识到这可能是这种 I/O 的异步化造成的。

2、析构并不是深拷贝,而你以为是

这可能是令人啼笑皆非的情况了,有的时候我们为了将对象或者数组重新赋值时,可能会下意识写下面的代码,

const [data, setData] = useState([{}]);

setData([...data]);

 你的初衷可能是给予 data 全新的值,事实上上他并没有改变子对象的引用,你的依赖其数据项的子组件就有异常的风险,这里之所以是说异常,而不是报错,是因为这种行为大多仅仅带来多一次渲染而已,会被 react 的渲染合并机制来抹平痕迹的。

这个时候 lodash 代码库可以帮助你了。

var objects = [{ 'a': 1 }, { 'b': 2 }];
 
var deep = _.cloneDeep(objects);
console.log(deep[0] === objects[0]); // false

3、自定义 react hooks 是强大的,但是请不要忘记 useDeugValue(),完善调试流程

引用 React 的官方介绍:

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ...

  // Show a label in DevTools next to this Hook
  // e.g. "FriendStatus: Online"
  useDebugValue(isOnline ? 'Online' : 'Offline');

  return isOnline;
}

 以上是我对于这个问题一些漫谈,带有一些个人的主观倾向,希望能够在求同存异的基础上给大家一些思维的碰撞,也真心的希望大家能够在评论区将自己的想法一些贡献出来,一起讨论下。