前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >关于React的Key导致的bug总结

关于React的Key导致的bug总结

作者头像
gary12138
发布2022-10-05 16:12:46
6300
发布2022-10-05 16:12:46
举报
文章被收录于专栏:前端随笔

故事要从项目中做一个可编辑表格组件,表格使用了一个二维数组作为基础数据。类似[[1,2,3],[4,5,6]],外层坐标代表行,内层坐标代表列数。

因为本身数据没有类似id的唯一值,这里便不假思索的启用了索性作为key,渲染也就完成了,顺便也加上了添加和删除功能,一切都是那么顺利。因为需要编辑,这里及把最初的展示组件替换成了input组件,这里并没有使用受控组件,而使用非受控组件,监听blur后再进行数据更新上传至服务器,所以input只设置了defaultvalue值,然后测试,发现删除怎么也删不了第一行。如图所示:

20210630_121708.gif
20210630_121708.gif

代码如下:

代码语言:javascript
复制
import React, { useCallback } from 'react';
import { useImmer } from 'use-immer';

const getMock = () => {
  const result = [];
  for (let i = 0; i < 3; i++) {
    result.push([
      `${result.length}-${i}`,
      `${result.length}-${i + 1}`,
      `${result.length}-${i + 2}`
    ]);
  }
  return result;
}

const Cell = ({
  onBlur,
  value
}) => {
  return (
    <div className="cell">
      <input
        onBlur={(e) => onBlur(e.target.value)}
        defaultValue={value}
      />
    </div>
  );
};

const Row = ({
  onRemove,
  children
}) => {
  return (
    <div className="flex">
      {children}
      <button onClick={onRemove}>删除</button>
    </div>
  );
};

const TableV2 = () => {
  const [dataSource, setData] = useImmer(getMock());

  const handleRemove = useCallback((rowIndex) => {
    setData((data) => {
      data.splice(rowIndex, 1);
    });
  }, []);

  const handleBlur = useCallback((value, rowIndex, colIndex) => {
    setData((data) => {
      data[rowIndex][colIndex] = value;
    });
  }, []);

  return (
    <div>
      {
        dataSource.map((data, rowIndex) => (
          <Row key={rowIndex} onRemove={() => handleRemove(rowIndex)}>
            {
              data.map((cell, colIndex) => (
                <Cell
                  onBlur={(value) => handleBlur(value, rowIndex, colIndex)}
                  value={cell}
                  key={colIndex}
                />
              ))
            }
          </Row>
        ))
      }
    </div>
  )
};

为什么会出现如此情况呢?其实是因为使用了数组索引作为key的原因导致(eslint规则可以对此做验证来避免问题的发生),这里就不得不提react的diff算法,为什么会导致这一奇怪的”bug”呢?这里这里为了找寻问题,我们尝试把input替换成了span标签,结果操作又不会发生上述情况了,是否很疑惑刚刚说是因为key原因导致,但是修改为展示组件却没问题,而使用input就不行呢?这就不得不需要详细了解react diff算法内部如何对组件进行对比、更新、销毁组件。

为什么有diff算法

在了解react diff算法之前,我们先了解一下为什么前端框架都在用diff算法。在远古时代,页面中内容如果需要变化,需要服务器重新生成新的html,然后全量重新渲染,这个时候前端没有复杂的交互仅仅文字和图片,全量更新变成了最快捷的方式。

然后来到ajax时代,前端独立交互初现,这个时候我们更改页面中的某个值时我们使用jquery获取到要修改的dom然后进行修改、删除、移动,如果现在再来看,这些操作可以比喻成我们自己手动进行了diff算法,找到了最优解然后进行dom操作。

直到现在,前端不再是简单的页面仔了,前端业务开始复杂,微前端,可视化,nocode等等业务问世,前端数据交互也是越来越复杂,一个新手很难再用jquery来开发和维护如此庞大的业务,三大框架应运而生,数据驱动视图概念出世,为了解决过去每次修改UI数据都要去进行dom操作,框架就在上层封装了一层虚拟dom层,dom的修改全都交付给框架完成,使用者几乎不再需要操作dom去更新视图。而框架则需要使用高效快捷的方法在虚拟dom中做对比,diff算法随之而来。

diff算法是什么

传统diff算法是寻找一个树转化为另一个树的最小操作的算法,通过循环递归进行依次对比,算法复杂度O(n^3)。也就是说1000个节点的树,需要十亿次的步骤才能完成树的转换,而这种效率明显无法适用于实际场景。所以react则在diff算法上做了改进,使之达到O(n)。

react的diff算法

react为了优化算法,在传统diff算法上加入了两个限制:

1. 两个不同类型的元素会产生出不同的树;

当根节点为不同类型时,react会直接销毁组件,并重新创建一个新的组件插入树中,且不会再递归它的子节点,一刀切,全部销毁。官网示例:

代码语言:javascript
复制
// 修改前
<div>
  <Counter />
</div>

// 修改后
<span>
  <Counter />
</span>

上述代码因为div标签修改成了span标签,react会判断当前节点类型不同,所以会整个组件重新创建,而Counter组件是其子组件及时它并没有发生任何变化,也随之销毁,再重新走创建挂载的生命周期。

如果进行对比时,类型是同一类型,则react不会对组件进行销毁,而且检查需要更新的属性,进行update操作。示例:

代码语言:javascript
复制
// 修改前
<Counter count={1}/>

// 修改后 counter不会被卸载,而是执行更新操作
<Counter count={2}/>

2. 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定

当节点绑定唯一key时,是为了告知react以此作为唯一标识,如果key相同并且类型相同,则react会复用组件,而不会对组件进行销毁,官网示例:

代码语言:javascript
复制
// 修改前
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

// 修改后 添加了一条记录
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

现在 React 知道只有带着 ‘2014’ key 的元素是新元素,带着 ‘2015’ 以及 ‘2016’ key 的元素仅仅移动了。 先对比key再对比type,如何都相同则表示可复用,如果不相同则销毁重新创建。这便是我们最开始demo的问题所在,我们使用了index作为key,在删除第一个组件时,第二个组件的key被修改为0,此时因为type相同并且key相同,react默认复用了第一个组件,并没有把第一个组件进行销毁,在更新时只是发现props defaultValue发生了变化,所以只是对组件进行了更新,而input defaultValue更新并不能修改value的值,所以导致了我们最开始发生的无法删除问题。

如何解决这个问题

既然我们现在知道原因,我们应该如何处理这个问题呢?

我们可以把非受控组件改为受控组件,但是在做删除时会引发全量更新。

给每个list添加一个唯一id,这样就完成了我们最基础的功能。 修改后代码:

代码语言:javascript
复制
import React, { useCallback } from 'react';
import { useImmer } from 'use-immer';
import { v1 } from 'uuid';

const getMock = () => {
  const result = [];
  for (let i=0; i<3; i++) {
    result.push({
      rowId: `row${i}`,
      value: new Array(3).fill(undefined).map((n, x) => ({
        colId: `col${i}${x}`,
        value: n
      }))
    });
  }
  return result;
}

const Cell = ({
  onBlur,
  value
}) => {
  return (
    <div className="cell">
      <input
        onBlur={(e) => onBlur(e.target.value)}
        defaultValue={value}
      />
    </div>
  );
};

const Row = ({
  onRemove,
  children
}) => {
  return (
    <div className="flex">
      {children}
      <button onClick={onRemove}>删除</button>
    </div>
  );
};

const TableV3 = () => {
  const [dataSource, setData] = useImmer(getMock());

  const handleRemove = useCallback((rowId) => {
    setData((data) => {
      const index = data.findIndex((d) => d.rowId === rowId);
      data.splice(index, 1);
    });
  }, []);

  const handleBlur = useCallback((value, rowId, colId) => {
    setData((data) => {
      const rowIndex = data.findIndex((d) => d.rowId === rowId);
      const colIndex = data[rowIndex].value.findIndex((c) => c.colId === colId);
      data[rowIndex][colIndex] = value;
    });
  }, []);

  return (
    <div>
      {
        dataSource.map((data) => (
          <Row key={data.rowId} onRemove={() => handleRemove(data.rowId)}>
            {
              data.value.map((cell) => (
                <Cell
                  onBlur={(value) => handleBlur(value, data.rowId, data.colId)}
                  value={cell.value}
                  key={cell.colId}
                />
              ))
            }
          </Row>
        ))
      }
    </div>
  )
};

测试时发现,当渲染一个10000万cell的表格时,每次修改数据后会产生不顺畅,是因为修改数据后没有做优化导致所有的Row、Cell都重新render。而我们希望每次只修改了一个cell,就是只重新渲染修改的cell,虽然现在我们使用了uuid做为Key使得组件得以复用,但是因为没有对props进行对比导致组件重新渲染。这里我们可以通过React.memo对Row和Cell组件进行优化。

延伸

上面我们说到key的作用,在实际项目中我们常用于列表渲染才使用key,既然我们了解到key可以作为react的标识,也就是可以通过key来做一些优化。我们有时候会遇到类似这类需求,用户通过一个按钮切换使某个组件置顶,类似代码:

代码语言:javascript
复制
import React, { useEffect, useState } from 'react';

const Test1 = () => {
  useEffect(() => {
    console.log('test1 挂载');
    return () => {
      console.log('test1 卸载');
    };
  }, []);
  console.log('test1 render');
  return (
    <div>test1</div>
  );
};

const Test2 = () => {
  useEffect(() => {
    console.log('test2 挂载');
    return () => {
      console.log('test2 卸载');
    };
  }, []);
  console.log('test2 render');
  return (
    <div>test2</div>
  );
};

const VisibleTest = () => {
  const [visible, setVisible] = useState(true);
  return (
    <div>
      <button onClick={() => setVisible(!visible)}>
        {!visible ? 'Test1顶置' : 'Test1置顶'}
      </button>
      <div>
        {
          visible ? (
            <React.Fragment>
              <Test1 />
              <Test2 />
            </React.Fragment>
          ) : (
              <React.Fragment>
                <Test2 />
                <Test1 />
              </React.Fragment>
            )
        }
      </div>
    </div>
  )
};

我们会通过visible控制Test1组件和Test2组件的位置,这时候在切换visible为false时,各个组件的生命周期执行过程

  • Test1
    • 初始化: render-挂载
    • visible改变:render-卸载-挂载
  • Test2
    • render-挂载
    • visible改变:render-卸载-挂载

上述可以看出我们仅仅是改变了组件的位置,缺导致了两个组件都被卸载并且重新挂载了,这个时候我们为Test1组件和Test2组件分别添加一个key

代码语言:javascript
复制
{
  visible ? (
    <React.Fragment>
      <Test1 key="1"/>
      <Test2 key="2"/>
    </React.Fragment>
  ) : (
      <React.Fragment>
        <Test2 key="2"/>
        <Test1 key="1"/>
      </React.Fragment>
    )
}

然后再重新执行上述操作,打印出各个组件的生命周期执行过程

  • Test1
    • 初始化:render-挂载
    • visible改变:render
  • Test2
    • 初始化:render-挂载
    • visible改变:render

测试后发现Test1和Test2组件并没有重新卸载,而是被react复用了。利用这种方式在某些场景能有效提高页面性能,避免组件的卸载。

最后

现在我们简单了解了react组件更新销毁机制,这样我们就可以在平时业务中进行更多的性能优化和bug感知。如果觉得有用?喜欢就收藏,顺便点个赞吧,你的支持是我最大的鼓励!觉得没用?评论区交流您的想法,虚心接受您的指导。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-07-01 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么有diff算法
  • diff算法是什么
  • react的diff算法
  • 如何解决这个问题
  • 延伸
  • 最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档