domdiff中,前后两次如果只是位置做了移动,对象本身没有变,则应该也以移动的方式更新,以保证更新的性能,这种情况就要用到key属性
dom更新的形式主要有以下4种:
思路:
对于旧的节点,react会以节点的key属性作为key,以节点对象作为值,维护一个对象:
let oldChildrenMap = {
'A':'A对应虚拟DOM',
'B':'B对应虚拟DOM',
'C':'C对应虚拟DOM',
'D':'D对应虚拟DOM',
'E':'E对应虚拟DOM',
'F':'F对应虚拟DOM'
};
复制代码
定义lastPlacedIndex变量,用来跟踪上一个不需要移动的老节点的索引,这个值会在接下来要介绍的遍历新节点的过程中被更新:
let newChildren=[];
for(let i=0;i<newChildren.length;i++){
let newChild = newChildren[i];//A的虚拟DOM节点
let newKey = newChild.key;//A
let oldChild = oldChildrenMap[newKey];
if(oldChild){
//先更新oldChild A 只是更新自己的属性 id className
//如果找到的可以复用的老的DOM节点它原来的挂载索引要比lastPlacedIndex要小,就需要移动,否则 不需要移动
if(oldChild._mountIndex<lastPlacedIndex){
//可以复用老节点,但是老节点需要移动到当前索引位置
}
lastPlacedIndex=Math.max(lastPlacedIndex,oldChild._mountIndex);
}
}
复制代码
原则:在新数组中,索引较小的不移动
对于上图来说,在DOM更新时,ACE三个节点没有移动,B做了移动,然后再删除DF,增加G
举例来说:
import React from './react';
import ReactDOM from './react-dom';
console.log(<React.Fragment><span>hello</span></React.Fragment>);
class Counter extends React.Component{
constructor(props){
super(props);
this.state = {
list:['A','B','C','D','E','F']
}
}
handleClick = ()=>{
this.setState({
list:['A',"C","E","B","G"]
});
}
render(){
return (
<React.Fragment>
<ul>
{
this.state.list.map(item=><li key={item}>{item}</li>)
}
</ul>
<button onClick={this.handleClick}>+</button>
</React.Fragment>
)
}
}
ReactDOM.render(<Counter />, document.getElementById('root'));
复制代码
首先需要定义一些常量:
export const REACT_TEXT = Symbol('REACT_TEXT');
export const REACT_ELEMENT = Symbol('react.element');
export const REACT_FORWARD_REF = Symbol('react.forward_ref');
//片断
export const REACT_FRAGMENT = Symbol('react.fragment');
//插入元素
export const PLACEMENT = 'PLACEMENT';
//位置的移动
export const MOVE = 'MOVE';
export const DELETION = 'DELETION';
复制代码
遇到type为FRAGMENT类型的节点时的处理:
function updateElement(oldVdom, newVdom) {
//如果新老节点都是纯文本节点的
if (oldVdom.type === REACT_TEXT) {
let currentDOM = newVdom.dom = findDOM(oldVdom);
if (oldVdom.props.content !== newVdom.props.content) {
currentDOM.textContent = newVdom.props.content;//更新文本节点的内容为新的文本内容
}
} else if(oldVdom.type === REACT_FRAGMENT){
let currentDOM = newVdom.dom = findDOM(oldVdom);
updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children);
//此节点是下原生组件 span div而且 类型一样,说明可以复用老的dom节点
}
复制代码
FRAGMENT类型的节点更新时的处理:
function updateElement(oldVdom, newVdom) {
//如果新老节点都是纯文本节点的
if (oldVdom.type === REACT_TEXT) {
let currentDOM = newVdom.dom = findDOM(oldVdom);
if (oldVdom.props.content !== newVdom.props.content) {
currentDOM.textContent = newVdom.props.content;//更新文本节点的内容为新的文本内容
}
} else if (oldVdom.type === REACT_FRAGMENT){
let currentDOM = newVdom.dom = findDOM(oldVdom);
updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children);
复制代码
可以看到,因为FRAGMENT类型的节点没有属性,所以只需要操作children即可
在updateChildren中,原来挨个对比的逻辑就不要了,需要用我们上述的算法来替代:
首先是准备老节点的key和节点对象组成的映射:
function updateChildren(parentDOM, oldVChildren, newVChildren) {
oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : oldVChildren ? [oldVChildren] : [];
newVChildren = Array.isArray(newVChildren) ? newVChildren : newVChildren ? [newVChildren] : [];
let keyedOldMap = {};
let lastPlacedIndex = 0;//上一个不需要移动的老DOM节点的索引
oldVChildren.forEach((oldVChild,index)=>{
let oldKey = oldVChild.key||index;//如果提供了key,会使用key作为唯一标识,如果没有提供,会使用索引
keyedOldMap[oldKey]=oldVChild;
});
复制代码
接下来,把所有的要移动、删除、添加的操作用一个对象描述出来,并放到patch数组中去:
描述移动、删除、添加这些操作的对象是通过遍历newVChildren,然后再和keyedOldMap中各项做对比得到的
对于没有找到可以复用的老节点的情况:
function updateChildren(parentDOM, oldVChildren, newVChildren) {
oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : oldVChildren ? [oldVChildren] : [];
newVChildren = Array.isArray(newVChildren) ? newVChildren : newVChildren ? [newVChildren] : [];
let keyedOldMap = {};
let lastPlacedIndex = 0;//上一个不需要移动的老DOM节点的索引
oldVChildren.forEach((oldVChild,index)=>{
let oldKey = oldVChild.key||index;//如果提供了key,会使用key作为唯一标识,如果没有提供,会使用索引
keyedOldMap[oldKey]=oldVChild;
});
//存着将要进行的操作
let patch = [];
//循环新数组
newVChildren.forEach((newVChild,index)=>{
newVChild._mountIndex = index;//设置虚拟DOM的挂载索引为index
let newKey = newVChild.key||index;
let oldVChild = keyedOldMap[newKey];
if(oldVChild){
}else{//没有找到可复用老节点
patch.push({
type:PLACEMENT,
newVChild,
toIndex:index
});
}
复制代码
对于能找到可以复用的老节点的情况,此处就需要用到上面我们介绍的lastPlacedIndex:
function updateChildren(parentDOM, oldVChildren, newVChildren) {
oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : oldVChildren ? [oldVChildren] : [];
newVChildren = Array.isArray(newVChildren) ? newVChildren : newVChildren ? [newVChildren] : [];
let keyedOldMap = {};
let lastPlacedIndex = 0;//上一个不需要移动的老DOM节点的索引
oldVChildren.forEach((oldVChild,index)=>{
let oldKey = oldVChild.key||index;//如果提供了key,会使用key作为唯一标识,如果没有提供,会使用索引
keyedOldMap[oldKey]=oldVChild;
});
//存着将要进行的操作
let patch = [];
//循环新数组
newVChildren.forEach((newVChild,index)=>{
newVChild._mountIndex = index;//设置虚拟DOM的挂载索引为index
let newKey = newVChild.key||index;
let oldVChild = keyedOldMap[newKey];
if(oldVChild){
updateElement(oldVChild,newVChild);
if(oldVChild._mountIndex < lastPlacedIndex){
patch.push({
type:MOVE,
oldVChild,
newVChild,
fromIndex:oldVChild._mountIndex,
toIndex:index
});
}
//如果此节点被复用了,把它从map中删除
delete keyedOldMap[newKey];
lastPlacedIndex = Math.max(lastPlacedIndex,oldVChild._mountIndex);
复制代码
注:在React15里 DOM的更新和DOM-DIFF放在一起进行的
我们还需要在组件初次渲染时,把_mountIndex属性也加上
export function createDOM(vdom) {
...
//处理属性
if (props) {
updateProps(dom, {}, props);
if (props.children) {
let children = props.children;
if (typeof children === 'object' && children.type) {//说明这是一个React元素
children._mountIndex=0;
render(children, dom);
} else if (Array.isArray(children)) {
reconcileChildren(props.children, dom);
}
}
}
...
}
复制代码
function reconcileChildren(childrenVdom, parentDOM) {
childrenVdom.forEach((childVdom,index) => {
childVdom._mountIndex=index;
复制代码
需要注意,遍历每个新节点newVChild时,会删掉keyedOldMap中映射的旧节点oldVChild,到最后新节点都遍历完了以后,如果keyedOldMap中还有没有删掉的项,这些项就是需要从oldVChildren中删掉的
function updateChildren(parentDOM, oldVChildren, newVChildren) {
...
newVChildren.forEach((newVChild,index)=>{
...
if(oldVChild){
...
}else{//没有找到可复用老节点
...
}
});
Object.values(keyedOldMap).forEach(oldVChild=>{
patch.push({
type:DELETION,
oldVChild,
fromIndex:oldVChild._mountIndex
});
});
复制代码
接下来我们把keyedOldMap中还有没有删掉的项(即需要从oldVChildren中删掉的)和需要移动的项合并起来,一起删除
function updateChildren(parentDOM, oldVChildren, newVChildren) {
...
newVChildren.forEach((newVChild,index)=>{
...
if(oldVChild){
...
}else{//没有找到可复用老节点
...
}
});
Object.values(keyedOldMap).forEach(oldVChild=>{
...
});
//获取要移动 的元素 这里面只有B
//此处我只是把B从界面中移动了,但是B还在是内存里的,B 这个DOM元素并没有被 销毁
const moveChilds = patch.filter(action=>action.type === MOVE).map(action=>action.oldVChild);
//现在keyedOldMap放着所有的剩下的元素
Object.values(keyedOldMap).concat(moveChilds).forEach(oldVChild=>{
let currentDOM = findDOM(oldVChild);
//获取到B D F三个真实DOM元素,然后从界面中删除
currentDOM.parentNode.removeChild(currentDOM);
});
复制代码
注:此处我只是把B从界面中移动了,但是B还在是内存里的,B 这个DOM元素并没有被 销毁
接下来在处理MOVE和PLACEMENT的时候,找到老的真实DOM 还可以把内存中的B取到,插入到指定的位置 B
function updateChildren(parentDOM, oldVChildren, newVChildren) {
...
newVChildren.forEach((newVChild,index)=>{
...
if(oldVChild){
...
}else{//没有找到可复用老节点
...
}
});
Object.values(keyedOldMap).forEach(oldVChild=>{
...
});
const moveChilds = patch.filter(action=>action.type === MOVE).map(action=>action.oldVChild);
Object.values(keyedOldMap).concat(moveChilds).forEach(oldVChild=>{
...
});
patch.forEach(action=>{
let {type,oldVChild,newVChild,fromIndex,toIndex} = action;
let childNodes = parentDOM.childNodes;//获取真实的子DOM元素的集合[A,C,E]
if(type === PLACEMENT){
let newDOM = createDOM(newVChild);//根据虚拟DOM创建真实DOM
let childDOMNode = childNodes[toIndex];//找一下目标索引现在对应的真实DOM元素
if(childDOMNode){//如果此位置 上已经 有DOM元素的,插入到它前面是
parentDOM.insertBefore(newDOM,childDOMNode);
}else{
parentDOM.appendChild(newDOM);//添加到最后就可以了
}
}else if(type === MOVE){
let oldDOM = findDOM(oldVChild);//找到老的真实DOM 还可以把内存中的B取到,插入到指定的位置 B
let childDOMNode = childNodes[toIndex];//找一下目标索引现在对应的真实DOM元素
if(childDOMNode){//如果此位置 上已经 有DOM元素的,插入到它前面是
parentDOM.insertBefore(oldDOM,childDOMNode);
}else{
parentDOM.appendChild(oldDOM);//添加到最后就可以了
}
}
});
复制代码