一篇文章让你反问面试官Vue/React中的key到底是什么

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第14天,点击查看活动详情

前言

随着前端行业的不断发展,产出的优秀框架如雨后春笋般涌现出来,譬如Angular、Vue、React等。想必大家无论是在面试或者是日常开发中用的比较多的框架当属Vue/React,而Vue/React中离不开的核心概念就有——key。市面上关于key的文章更是层出不穷,比如key是什么以及它的作用、如何对key进行比较等等,所以本文也就不再进行赘述,而是从另一个切入点一起来了解下key的前世今生以及key在Vue/React中所发挥的重要性

一个很形象的比喻

在《高性能JavaScript》中有这么一句话:“把DOM看成一个岛屿,把JavaScript(ECMAScript)看成另一个岛屿,两者之间以一座收费桥连接。每次ECMAScript需要访问DOM时,你需要过桥,交一次“过桥费”。你操作DOM次数越多,费用就越高。一般的建议是尽量减少过桥次数,努力停留在ECMAScript岛上。”

重绘重排

在掀开key的序幕之前,我们先来快速回顾一下关于浏览器重绘重排的概念

// css
.box {
    width: 100px;
    height: 100px;
    background-color: black;
}
复制代码
// html
<div class="box"></div>
复制代码

重绘

当box元素的外观(颜色等)被改变时,浏览器会根据元素的新属性重新绘制,使元素呈现新的外观,这个过程称为重绘

重排

当box元素的几何属性(尺寸、位置、添加删除节点等)发生改变时,浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树,这个过程称为重排

两者关系

简单点来说,如果某一操作影响到了box的“占地面积”,那么一定会重排,而如果只是影响到了box这块“地”里面的东西,并不改变实际的“占地面积”,那么只会引起重绘。需要注意的是,重排一定会引起重绘,而重绘不一定引起重排,所以我们应该尽量减少“过桥次数”或者是尽可能多的合并“过桥次数”

任务背景

实现一个实时搜索功能,例如页面中有一个搜索框、一个展示列表,我们通过搜索框中的关键字来对展示列表中符合条目的文字进行高亮展示,不符合条目的文字不进行处理。通过这个例子我们可以深刻的认识到直接操作真实DOM的代价,以及为什么要有key、key应该为何值

原生JS实现

毫无疑问的是原生JS肯定是直接去操作真实DOM的。当我们在输入框中输入关键字时,下面这段代码中如果展示列表匹配到了相应的关键字,那么就会引起重绘重排,而每一次的重绘重排都是极其浪费时间、影响性能的。此处html中我放置了100个li,而随着li的增多,所耗费的时间、性能会越来越大,甚至浏览器会出现假死的现象。假设100个li中只有1个li匹配到了我们所输入的关键字,其它99个li并没有匹配到,即使是这种情况,匹配到的这1个li和其它99个li依然会被重新渲染,所以说直接操作真实DOM所带来的负面影响是肉眼可见的

// html
 <article id="root">
    <section class="app">
        <div class="search">
            <div class="search-text">
                <input type="text" />
            </div>
        </div>
        <div class="list">
            <ul class="list-ul">
                <li class="list-li">ojulmltmbao</li>
                <li class="list-li">mbhxrmrmwme</li>
                <li class="list-li">bdcedwbddvd</li>
                <!-- more li... -->
            </ul>
        </div>
    </section>
</article>
复制代码
// js
const li = document.querySelectorAll('.list-li')
const input = document.querySelector('input')
// pre存储之前的条目。beAll存储关键字。advance存储之后的关键字
const pre = [], beAll = [], advance = []
li.forEach((_, i) => advance[i] = li[i].innerHTML)
// 搜索之后的长度
const length = () => advance.reduce((pre, cur) => pre + cur.length, 0)
const returnHighText = value => `<span class="high">${value}</span>`
// 重新渲染
const change = preLength => {
    const nowLength = length()
    // 如果没有找到关键字,则不进行渲染
    if (preLength === nowLength) return
    advance.forEach((_, i) => li[i].innerHTML = advance[i])
}
// 添加关键字
const test = high => {
    // 不考虑多层节点
    const reg = new RegExp(high, 'g')
    // 记录搜索之前的长度
    const preLength = length()
    advance.forEach((_, i) => advance[i] = advance[i].replace(reg, returnHighText(high)))
    change(preLength)
}
// 删除关键字
const testDel = value => {
    const index = beAll.indexOf(value)
    if (index === -1) return beAll.splice(index, 1)
    const reg = new RegExp(returnHighText(value), 'g')
    // 记录搜索之前的长度
    const preLength = length()
    advance.forEach((_, i) => advance[i] = advance[i].replace(reg, value))
    change(preLength)
}
// event
input.addEventListener('input', e => {
    const high = e.data
    if (high === null) {
        testDel(pre[pre.length - 1])
        pre.pop()
        return
    }
    beAll.push(high)
    pre.push(high)
    test(high)
})
复制代码

运行效果

匀速输入

在匀速输入的状态下时,“过桥次数”在我们的控制范围之内,所以我们可以看到DOM的渲染更新还是比较正常的,不会出现卡顿的情况,更不会出现浏览器假死的情况 js1.gif

快速输入

在快速输入的状态下时,“过桥次数”已经超出了我们的控制范围,我们可以明显观察到DOM一直在频繁更新,导致页面卡顿、无反应,甚至还出现了页面崩溃的情况 js2.gif

Vue、React都是采用Diffing算法来对比新旧虚拟DOM节点,从而更新真实DOM。所以本文以React为例来探讨key在此所发挥的重要性

React实现

key与虚拟DOM

我对《React进阶之路》中的这样一句话记忆尤为深刻:“软件开发中遇到的所有问题都可以通过增加一层抽象而得以解决。DOM效率低下的这个问题同样可以通过增加一层抽象解决。虚拟DOM就是这层抽象,建立在真实DOM之上,对真实DOM的抽象”。

既然虚拟DOM是对真实DOM的一层抽象,那么我们只需要操作虚拟DOM即可,而不是去操作真实DOM,将虚拟DOM的操作再映射到真实DOM上,这样不就大大的减少了“过桥次数”了吗,而且也不用担心渲染时间过长而带来的页面卡顿或浏览器假死了。

那么这个虚拟DOM是什么呢?其实就是一个普普通通的JavaScript对象,我们用这个对象来描述真实DOM,只不过这个虚拟DOM比较“轻”,因为无需真实DOM上那么多的属性,有了这层虚拟DOM我们就可以尽可能多的减少与真实DOM的交互(减少“过桥次数”),然后再配合上优秀的Diffing算法,来达到最小化页面重绘重排的目的。Diffing算法我们大概都知道,就是将新虚拟DOM与旧虚拟DOM进行比较,如果每一个虚拟DOM都没有自己的“身份证号”,那么在进行新旧虚拟DOM比较的时候,就很难确定新旧虚拟DOM是否相同、以及新旧虚拟DOM中的内容是否相同,而如果每一个虚拟DOM都有一个属于自己的“身份证号”,那么比较的时候我们只需要根据“身份证号”就可以知道新旧虚拟DOM是否相同了,这样一来,Diffing算法的效率也因此提升了,而这个“身份证号”就是虚拟DOM对象的标识——key

组件介绍

本着怎么复杂怎么来的原则,所以这里使用ContextAPI,而不是选用状态提升等其它组件通信方法。Search、List中的DOM结构更是嵌套了多层,以此来展示key在虚拟DOM中所发挥的重要性

context

Search组件与List组件通过App组件借助ContextAPI进行通信,需要注意的是,使用Context时我们无需为每层组件都手动添加props,而是将需要传递的数据指定给Provider即可,Context会将这些数据统统向组件树下的所有组件进行“广播”,即所有组件都能访问到这些数据,也能访问到后续的数据更新

// key-context.js
import React from 'react'
export default React.createContext()
复制代码

App

App组件存储Search组件中的关键字,当关键字发生变化(updateKeyword被调用)时,List组件就会被重新调用,而List组件的职责就是匹配出对应的关键字并使其高亮

import React, { useState } from 'react'

import contextKey from './key-context'
import Search from './Components/Search'
import List from './Components/List'

import './App.css'

function App() {
  const [keyword, setKeyword] = useState('')
  const [def, setDef] = useState(true)
  const { Provider } = contextKey
  return (
    <section className="app">
      {/** 将所需方法传递给Search组件与List组件 */}
      <Provider value={{ keyword, def, setDef, updateKeyword: v => setKeyword(v) }}>
        <Search />
        <List />
      </Provider>
      {/** 决定当前key为何值 */}
      <button onClick={() => setDef(!def)}>切换为{def ? 'nanoid' : 'index'}</button>
    </section>
  )
}

export default App
复制代码

Search

当Seach组件中的关键字发生变化(onChange被调用)时,调用updateKeyword方法更新App组件中所存储的关键字,然后List组件便可以做出相应的处理了

import { useContext } from 'react'

import contextKey from '../../key-context'

function Search() {
    const { updateKeyword } = useContext(contextKey)
    return (
        <div className="search">
            <div className="search-text">
                <input type="text" onChange={({ target }) => updateKeyword(target.value)} />
            </div>
        </div>
    )
}

export default Search
复制代码

List

Provider中传递给子组件的值发生变化时,List也会被重新渲染,此时List会根据新的关键字来匹配新的字符。Provider会通过新旧值检测来确定是否变化,这使用了与Object.is相同的算法

import { useContext } from 'react'
import { nanoid } from 'nanoid'

import data from './data'
import contextKey from '../../key-context'

function List() {
    const { keyword, def } = useContext(contextKey)
    // 匹配到的文字设置一个随机颜色
    const productColor = () => `#${Math.floor(Math.random() * 0Xffffff).toString(16).padEnd(6, "0")}`
    return (
        <div className="list">
            <ul className="list-ul">
                {
                    data.map((v, i) => (
                        <li className="list-li" key={def ? i : nanoid()} >
                            {
                                v.map((v2, i2) => keyword.split('').find(v3 => v3 === v2) ? <span
                                    className="high"
                                    style={{ color: productColor() }}
                                    key={def ? i2 : nanoid()}
                                >{v2}</span> :
                                    <span key={def ? i2 : nanoid()}>{v2}</span>)
                            }
                        </li>
                    )
                    )
                }
            </ul>
        </div>
    )
}
export default List
复制代码

运行效果

使用index作为key

react1.gif

使用nanoid作为key

react2.gif

文末

原生JS对比React

在原生JS的实现方案中可以看到输入关键字的速度越快,DOM渲染的次数就越频繁、时间就越长,因为每输入一个新的关键字,就会操作一次真实DOM,输入n次就会操作n次,所以是极其浪费时间的,所以说在选用原生JS这种方式时应该尽可能少的操作真实DOM,尽可能多的去合并操作真实DOM的次数,这样就会减少渲染次数。

而在React的实现方案中我们使用切换按钮来决定当前的key是使用index还是nanoid时,在同数量级别下的li,可以看到使用index时无论在搜索框中输入关键字的速度有多快,页面都不会卡顿;而当使用nanoid时,如果输入关键字的速度过快,则页面会造成卡顿,甚至浏览器会假死。后者key的值每更新一次就变化一次,这种情况下所耗费的时间、性能与原生JS的实现是差不多的,因为这样一来key始终都是新的值,所以每次都会产生新的虚拟DOM,然后根据新虚拟DOM产生新的真实DOM

如何选用key的值

我们在使用key时,应该尽量保证key是不会变化的,只有记住这个原则,我们才能享用虚拟DOM以及Diffing算法所带来的优越性,否则性能只会越来越差

index

使用index作为key:如果不存在对数据的逆序添加、逆序删除等破坏顺序的操作,仅渲染列表用于展示,使用index作为key是没有问题的

唯一值

使用唯一的值作为key:最好将每条数据的唯一标识用作key,比如id、学号等唯一值;如果确定只是简单的展示数据,使用index也是可以的

猜你喜欢

转载自juejin.im/post/7106280486896402462