react中为何推荐设置key

关于react渲染原理的相关知识,可参考我的另一篇文章preact源码解析,从preact中理解react原理

我想很多人刚开始使用react框架的时候,和我刚开始一样迷惘。只知道 map 返回的元素项推荐设置key,却不知为何要设置key。当初我也在网上找了一些相关的文章,却看得一塌糊涂,始终没明白为啥要设置key。后来研究了preact源码,才恍然大悟。

设置key可以帮助react识别那个项被修改、添加或者移除。我们写的jsx语法,首先在项目打包阶段转换为js语法,然后在浏览器执行后会是一个虚拟节点树,最后调用react-dom的render方法渲染成页面中的真实dom树。用示例说明:

//react代码
ReactDom.render(<div>
  {[1, 2].map(item => <span>{item}</span>)}
</div>, document.getElementById('app'));
//首先babel编译替换
ReactDom.render(React.createElement(
  "div",
  null,
  [1, 2].map(function (item) {
    return React.createElement("span", null, item);
  })
), document.getElementById('app'));
//然后先执行render的参数(这儿省略了虚拟节点的其它属性)
ReactDom.render({
  type: 'div', key: null, props: {
    children: [
      {
        type: 'span', key: null, props: {
          children: 1
        }
      },
      {
        type: 'span', key: null, props: {
          children: 2
        }
      }
    ]
  }
}, document.getElementById('app'));
//最后执行render方法渲染为真实dom树
<div>
  <span>1</span>
  <span>2</span>
</div>

首次渲染时,只有新虚拟节点,没有老虚拟节点,因此不会涉及到新老节点的对比(常说的diff)。而当页面渲染完成后,单击某个组件会调用setState方法,引发它的状态改变。这时会开始渲染这个组件,执行组件的render方法,并返回新的虚拟节点树。假如以上示例中的[1,2]数组数据,如果单击了删除按钮,删除了数组的第一个项后变为[2],我们来分析下新的虚拟节点树与之前的虚拟节点树渲染对比。此时新的虚拟节树点为:

{
  type: 'div', key: null, props: {
    children: [
      {
        type: 'span', key: null, props: {
          children: 2
        }
      }
    ]
  }
}
  1. 首先开始渲染div虚拟节点。从之前虚拟节点树的同辈节点中遍历查找老的虚拟节点,遍历的过程中会不断对比。在与之前第一个div虚拟节点对比中,由于type和key都与新虚拟节点的相同(key都为null),于是会把这个虚拟节点作为新div虚拟节点对应的老节点。如果存在老节点,假如他是字符串类型的节点,会储存着上次渲染的真实dom节点,假如是函数类型的节点,会储存着之前组件的实例,这样就不会再去实例一个组件。由于这儿直接发现了老节点,所以会直接读取老节点储存的真实dom节点作为新div虚拟节点的dom节点,也就是直接使用之前页面中已经存在的div这个节点,这样就不会重新创建一个div类型的dom节点。
  2. 接下来开始比较虚拟节点div的子节点。从第一个虚拟节点span开始,先查找之前对应的老虚拟节点。由于之前第一个span虚拟节点的type与key和新的span虚拟节点相同,所以进而作为老节点,直接复用之前储存的dom节点,然后比较第一个虚拟节点span的子节点文本2。由于与老节点的子节点中文本节点1不同(文本节点只比较内容),所以会新创建内容为2的文本节点,然后移除之前第一个span的文本节点1。新的虚拟div子节点对比完成后,开始处理没有使用的老虚拟节点,也就是老的第二个span虚拟节点没有被使用,所以会被清空并移除对应的真实dom节点。

由于上面没有加key,虽然最后的渲染结果是正常的,但实际渲染流程并不是我们以为的那样。我们只删除了第一个span虚拟节点,所以只要移除对应的第一个真实span节点就行了。而我们分析后发现先创建了文本节点2,然后移除了文本节点1,再移除了第二个真实span节点。我们知道对于真实的dom节点操作是非常昂贵的,比较耗性能。并且如果是类组件,那就意味着会销毁以前已经实例的组件,然后会新实例一次类组件,再执行一遍这个组件的一些初始生命周期。这样diff效率不高,并且会发生一些意想不到的意外。

如果我们给span设置key,那么我们再分析下渲染对比流程。对应的虚拟节点树为:

//数据为[1,2]的虚拟节点树
{
  type: 'div', key: null, props: {
    children: [
      {
        type: 'span', key: 1, props: {
          children: 1
        }
      },
      {
        type: 'span', key: 2, props: {
          children: 2
        }
      }
    ]
  }
}
//数据为[2]的虚拟节点树
{
  type: 'div', key: null, props: {
    children: [
      {
        type: 'span', key: 2, props: {
          children: 2
        }
      }
    ]
  }
}

此时当[1,2]变为[2]后,div的虚拟节点渲染还是同样的逻辑。而在比较第一个span虚拟节点时,首先判断先前第一个span虚拟节点{type:'span',key:1}。虽然type相同但key不同,于是继续从同辈节点中查找,开始与第二个span虚拟节点{type:'span':key:2}对比。发现type与key都相同,于是把这个节点作为新节点对应的老节点。继续比较内容为2的虚拟节点,发现与老节点中子节点2相同,继而复用,这样就不会创建新的文本节点。接下来发现老的虚拟节点{type:'span',key:1}已不在使用,所以移除真实的<span>1</span>的节点。

通过以上分析说明,设置key后,再次渲染组件时逻辑符合我们的预期。所以设置了key能够提升渲染效率,减少一些不必要的意外。

常见问题

1.数组中index可以作为key吗?

如果不会涉及到列表中节点位置变动,比如只是更新了一些项的数据,那么用index作为key与不设置key是一样的,都不会对diff影响什么。只不过不设置key会在react开发模式中控制台有警告信息。其它的情况就不能用index做为key,比如添加或者删除一些项。这会影响节点的位置移动,这时就需要key与项有关联来标识之前的项在何位置,以便增加渲染性能,减少一些意外问题。例如下面的虚拟节点树,当头部新增节点后,如果用index作为key,react在diff过程中会以为更新了前两个节点,并在尾部新增了一个节点。

[
  {type:'div',key:0,props:{children:'item 0'}},
  {type:'div',key:1,props:{children:'item 1'}}
]
//头部新增项后
[
  {type:'div',key:0,props:{children:'item new'}},
  {type:'div',key:1,props:{children:'item 0'}},
  {type:'div',key:2,props:{children:'item 1'}}
]

2.只有map返回的节点需要设置key吗?

其实不只是能显而易见通过map返回节点的这种结构,只要是同辈相同类型的节点,会发生动态的出现或者不出现,尤其会影响到某个节点重新渲染需要耗费大量时间,也建议设置key来提高渲染性能,以便减少一些不必要的问题。例如下面的组件:

<div>
    {this.state.step === 'base' && <div key="base">{this.getBaseForm()}</div>}
    {this.state.step === 'fee' && <div key="fee">{this.getFeeForm()}</div>}
</div>

编辑于 2020-04-10 16:40