手写react8-domdiff

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);//添加到最后就可以了
            }
        }
    });
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享