key 有什么作用, 可以省略吗?
在 react 组件开发的过程中, key是一个常用的属性值, 多用于列表开发. 本文从源码的角度, 分析key在react内部是如何使用的, key是否可以省略.
ReactElement 对象
我们在编程时直接书写的jsx代码, 实际上是会被编译成 ReactElement 对象, 所以key是ReactElement对象的一个属性.
构造函数
在把jsx转换成ReactElement对象的语法时, 有一个兼容问题. 会根据编译器的不同策略, 编译成 2 种方案.
最新的转译策略: 会将
jsx语法的代码, 转译成jsx()函数包裹jsx函数: 只保留与key相关的代码(其余源码本节不讨论)/*** https://github.com/reactjs/rfcs/pull/107* @param {*} type* @param {object} props* @param {string} key*/export function jsx(type, config, maybeKey) {let propName;// 1. key的默认值是nulllet key = null;// Currently, key can be spread in as a prop. This causes a potential// issue if key is also explicitly declared (ie. <div {...props} key="Hi" />// or <div key="Hi" {...props} /> ). We want to deprecate key spread,// but as an intermediary step, we will use jsxDEV for everything except// <div {...props} key="Hi" />, because we aren't currently able to tell if// key is explicitly declared to be undefined or not.if (maybeKey !== undefined) {key = '' + maybeKey;}if (hasValidKey(config)) {// 2. 将key转换成字符串key = '' + config.key;}// 3. 将key传入构造函数return ReactElement(type,key,ref,undefined,undefined,ReactCurrentOwner.current,props,);}
传统的转译策略: 会将
jsx语法的代码, 转译成React.createElement()函数包裹React.createElement()函数: 只保留与key相关的代码(其余源码本节不讨论)/*** Create and return a new ReactElement of the given type.* See https://reactjs.org/docs/react-api.html#createelement*/export function createElement(type, config, children) {let propName;// Reserved names are extractedconst props = {};let key = null;let ref = null;let self = null;let source = null;if (config != null) {if (hasValidKey(config)) {key = '' + config.key; // key转换成字符串}}return ReactElement(type,key,ref,self,source,ReactCurrentOwner.current,props,);}
可以看到无论采取哪种编译方式, 核心逻辑都是一致的:
key的默认值是null- 如果外界有显式指定的
key, 则将key转换成字符串类型. - 调用
ReactElement这个构造函数, 并且将key传入.
// ReactElement的构造函数: 本节就先只关注其中的key属性const ReactElement = function(type, key, ref, self, source, owner, props) {const element = {$$typeof: REACT_ELEMENT_TYPE,type: type,key: key,ref: ref,props: props,_owner: owner,};return element;};
源码看到这里, 虽然还只是个皮毛, 但是起码知道了key的默认值是null. 所以任何一个reactElement对象, 内部都是有key值的, 只是一般情况下(对于单节点)很少显式去传入一个 key.
Fiber 对象
react的核心运行逻辑, 是一个从输入到输出的过程(回顾reconciler 运作流程). 编程直接操作的jsx是reactElement对象,我们(程序员)的数据模型是jsx, 而react内核的数据模型是fiber树形结构. 所以要深入认识key还需要从fiber的视角继续来看.
fiber对象是在fiber树构造循环过程中构造的, 其构造函数如下:
function FiberNode(tag: WorkTag,pendingProps: mixed,key: null | string,mode: TypeOfMode,) {this.tag = tag;this.key = key; // 重点: key也是`fiber`对象的一个属性// ...this.elementType = null;this.type = null;this.stateNode = null;// ... 省略无关代码}
可以看到, key也是fiber对象的一个属性. 这里和reactElement的情况有所不同:
reactElement中的key是由jsx编译而来,key是由程序员直接控制的(即使是动态生成, 那也是直接控制)fiber对象是由react内核在运行时创建的, 所以fiber.key也是react内核进行设置的, 程序员没有直接控制.
注意: fiber.key是reactElement.key的拷贝, 他们是完全相等的(包括null默认值).
接下来分析fiber创建, 剖析key在这个过程中的具体使用情况.
fiber对象的创建发生在fiber树构造循环阶段中, 具体来讲, 是在reconcileChildren调和函数中进行创建.
reconcileChildren 调和函数
reconcileChildren是react中的一个明星函数, 最热点的问题就是diff算法原理, 事实上, key的作用完全就是为了diff算法服务的.
注意: 本节只分析 key 相关的逻辑, 对于调和函数的算法原理, 请回顾算法章节React 算法之调和算法
调和函数源码(本节示例, 只摘取了部分代码):
function ChildReconciler(shouldTrackSideEffects) {function reconcileChildFibers(returnFiber: Fiber,currentFirstChild: Fiber | null,newChild: any,lanes: Lanes,): Fiber | null {// Handle object typesconst isObject = typeof newChild === 'object' && newChild !== null;if (isObject) {switch (newChild.$$typeof) {case REACT_ELEMENT_TYPE:// newChild是单节点return placeSingleChild(reconcileSingleElement(returnFiber,currentFirstChild,newChild,lanes,),);}}// newChild是多节点if (isArray(newChild)) {return reconcileChildrenArray(returnFiber,currentFirstChild,newChild,lanes,);}// ...}return reconcileChildFibers;}
单节点
这里先看单节点的情况reconcileSingleElement(只保留与key有关的逻辑):
function reconcileSingleElement(returnFiber: Fiber,currentFirstChild: Fiber | null,element: ReactElement,lanes: Lanes,): Fiber {const key = element.key;let child = currentFirstChild;while (child !== null) {//重点1: key是单节点是否复用的第一判断条件if (child.key === key) {switch (child.tag) {default: {if (child.elementType === element.type) {// 第二判断条件deleteRemainingChildren(returnFiber, child.sibling);// 节点复用: 调用useFiberconst existing = useFiber(child, element.props);existing.ref = coerceRef(returnFiber, child, element);existing.return = returnFiber;return existing;}break;}}// Didn't match.deleteRemainingChildren(returnFiber, child);break;}child = child.sibling;}// 重点2: fiber节点创建, `key`是随着`element`对象被传入`fiber`的构造函数const created = createFiberFromElement(element, returnFiber.mode, lanes);created.ref = coerceRef(returnFiber, currentFirstChild, element);created.return = returnFiber;return created;}
可以看到, 对于单节点来讲, 有 2 个重点:
key是单节点是否复用的第一判断条件(第二判断条件是type是否改变).- 如果
key不同, 其他条件是完全不看的
- 如果
- 在新建节点时,
key随着element对象被传入fiber的构造函数.
所以到这里才是key的最核心作用, 是调和函数中, 针对单节点是否可以复用的第一判断条件.
对于单节点来讲, key是可以省略的, react内部会设置成默认值null. 在进行diff时, 由于null===null为true, 前后render的key是一致的, 可以进行复用比较.
如果单节点显式设置了key, 且两次render时的key如果不一致, 则无法复用.
多节点
继续查看多节点相关的逻辑:
function reconcileChildrenArray(returnFiber: Fiber,currentFirstChild: Fiber | null,newChildren: Array<*>,lanes: Lanes,): Fiber | null {if (__DEV__) {// First, validate keys.let knownKeys = null;for (let i = 0; i < newChildren.length; i++) {const child = newChildren[i];// 1. 在dev环境下, 执行warnOnInvalidKey.// - 如果没有设置key, 会警告提示, 希望能显式设置key// - 如果key重复, 会错误提示.knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);}}let resultingFirstChild: Fiber | null = null;let previousNewFiber: Fiber | null = null;let oldFiber = currentFirstChild;let lastPlacedIndex = 0;let newIdx = 0;let nextOldFiber = null;// 第一次循环: 只会在更新阶段发生for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {if (oldFiber.index > newIdx) {nextOldFiber = oldFiber;oldFiber = null;} else {nextOldFiber = oldFiber.sibling;}// 1. 调用updateSlot, 处理公共序列中的fiberconst newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes,);if (newFiber === null) {// 如果无法复用, 则退出公共序列的遍历if (oldFiber === null) {oldFiber = nextOldFiber;}break;}}// 第二次循环if (oldFiber === null) {for (; newIdx < newChildren.length; newIdx++) {// 2. 调用createChild直接创建新fiberconst newFiber = createChild(returnFiber, newChildren[newIdx], lanes);}return resultingFirstChild;}for (; newIdx < newChildren.length; newIdx++) {// 3. 调用updateFromMap处理非公共序列中的fiberconst newFiber = updateFromMap(existingChildren,returnFiber,newIdx,newChildren[newIdx],lanes,);}return resultingFirstChild;}
在reconcileChildrenArray中, 有 3 处调用与fiber有关(当然顺便就和key有关了), 它们分别是:
updateSlotfunction updateSlot(returnFiber: Fiber,oldFiber: Fiber | null,newChild: any,lanes: Lanes,): Fiber | null {const key = oldFiber !== null ? oldFiber.key : null;if (typeof newChild === 'object' && newChild !== null) {switch (newChild.$$typeof) {case REACT_ELEMENT_TYPE: {//重点: key用于是否复用的第一判断条件if (newChild.key === key) {return updateElement(returnFiber, oldFiber, newChild, lanes);} else {return null;}}}}return null;}
createChildfunction createChild(returnFiber: Fiber,newChild: any,lanes: Lanes,): Fiber | null {if (typeof newChild === 'object' && newChild !== null) {switch (newChild.$$typeof) {case REACT_ELEMENT_TYPE: {// 重点: 调用构造函数进行创建const created = createFiberFromElement(newChild,returnFiber.mode,lanes,);return created;}}}return null;}
updateFromMapfunction updateFromMap(existingChildren: Map<string | number, Fiber>,returnFiber: Fiber,newIdx: number,newChild: any,lanes: Lanes,): Fiber | null {if (typeof newChild === 'object' && newChild !== null) {switch (newChild.$$typeof) {case REACT_ELEMENT_TYPE: {//重点: key用于是否复用的第一判断条件const matchedFiber =existingChildren.get(newChild.key === null ? newIdx : newChild.key,) || null;return updateElement(returnFiber, matchedFiber, newChild, lanes);}}return null;}}
针对多节点的
diff算法可以分为3步骤(请回顾算法章节React 算法之调和算法):- 第一次循环: 比较公共序列
- 从左到右逐一遍历, 遇到一个无法复用的节点则退出循环.
- 第二次循环: 比较非公共序列
- 在第一次循环的基础上, 如果
oldFiber队列遍历完了, 证明newChildren队列中剩余的对象全部都是新增.- 此时继续遍历剩余的
newChildren队列即可, 没有额外的diff比较.
- 此时继续遍历剩余的
- 在第一次循环的基础上, 如果
oldFiber队列没有遍历完, 需要将oldFiber队列中剩余的对象都添加到一个map集合中, 以oldFiber.key作为键.- 此时则在遍历剩余的
newChildren队列时, 需要用newChild.key到map集合中进行查找, 如果匹配上了, 就将oldFiber从map中取出来, 同newChild进行diff比较.
- 此时则在遍历剩余的
- 在第一次循环的基础上, 如果
- 清理工作
- 在第二次循环结束后, 如果
map集合中还有剩余的oldFiber,则可以证明这些oldFiber都是被删除的节点, 需要打上删除标记.
- 在第二次循环结束后, 如果
通过回顾diff算法的原理, 可以得到key在多节点情况下的特性:
- 新队列
newChildren中的每一个对象(即reactElement对象)都需要同旧队列oldFiber中有相同key值的对象(即oldFiber对象)进行是否可复用的比较.key就是新旧对象能够对应起来的唯一标识. - 如果省略
key或者直接使用列表index作为key, 表现是一样的(key=null时, 会采用index代替key进行比较). 在新旧对象比较时, 只能按照index顺序进行比较, 复用的成功率大大降低, 大列表会出现性能问题.- 例如一个排序的场景:
oldFiber队列有100个,newChildren队列有100个(但是打乱了顺序). 由于没有设置key, 就会导致newChildren中的第n个必然要和oldFiber队列中的第n个进行比较, 这时它们的key完全一致(都是null), 由于顺序变了导致props不同, 所以新的fiber完全要走更新逻辑(理论上比新创建一个的性能还要耗). - 同样是排序场景可以出现的bug: 上面的场景只是性能差(又不是不能用),
key使用不当还会造成bug- 还是上述排序场景, 只是列表中的每一个
item内部又是一个组件, 且其中某一个item使用了局部状态(比如class组件里面的state). 当第二次render时,fiber对象不会delete只会update导致新组件的state还沿用了上一次相同位置的旧组件的state, 造成了状态混乱.
- 还是上述排序场景, 只是列表中的每一个
- 例如一个排序的场景:
总结
在react中key是服务于diff算法, 它的默认值是null, 在diff算法过程中, 新旧节点是否可以复用, 首先就会判定key是否相同, 其后才会进行其他条件的判定. 在源码中, 针对多节点(即列表组件)如果直接将key设置成index和不设置任何值的处理方案是一样的, 如果使用不当, 轻则造成性能损耗, 重则引起状态混乱造成bug.
