buxuku / react-handwritten

手写实现React
0 stars 1 forks source link

8.高级dom-diff,利用key进行优化 #8

Open buxuku opened 3 years ago

buxuku commented 3 years ago

在上一节中, 我们没有考虑元素移动的问题,比如子元素没有变更,只是移动了位置,那么是没办法来复用这个元素的.我们知道,在React中,处理这样的场景,是通过key来实现的.我们可以通过key来判断是否是同一个元素.在处理大量的子元素时,React也正是通过这样一个key来提高dom-diff的性能.

假如有这个一个组件

class Counter extends React.Component {
    constructor(props) {
        super(props);
        this.state = {show: true};

    }

    handleClick = () => {
        this.setState((state) => ({show: !state.show}));
    }

    render() {
        return this.state.show ? (
                <ul onClick={this.handleClick}>
                    <li key="A" id="1">A</li>
                    <li key="B">B</li>
                    <li key="C">C</li>
                    <li key="D">D</li>
                </ul>
            )
            : (
                <ul onClick={this.handleClick}>
                    <li key="A" id="2">A</li>
                    <li key="C">C</li>
                    <li key="B">B</li>
                    <li key="E">E</li>
                    <li key="F">F</li>
                </ul>
            )
    }
}

在点击的时候,这个列表元素会由A,B,C,D变成A,C,B,E,F.在执行这一变化的时,Ract会尽可能复用老的元素,并且遵守一个原则就是,以新元素的基准,对老的元素仍做向右移动.

对于上面这个组件,A元素不变,直接复用,B,C元素只是进行了位置的移动,这个移动我们可以移动C元素到B元素前面,也可以移动B元素到C元素后面,那么根据向右移动的原则,所以我们应该移动B元素的.当然,这并一定是最优的移动方法,比如元素ABCD变成DABC,最优的移动方法就是把D向左移动到最前面的就可以,但遵循React的向右原则的话,需要保持D不动,而要移动ABC三次.所以这种方案并不比Vue里面的两边向中间比较的方案高效.

后面的元素就比较简单了,删除D元素,插入E,F新元素.

image-20210605235027881

为了判断元素是否需要移动,我们需要维护一个游标lastPlaceholder,每个元素对应有一个index的序号来标识是属于第几个元素,我们遍历新元素,如果在老元素中找到有相同的key,并且元素的序号大于lastPlaceholder的话,就将lastPlaceholder向右移动到老元素对应的序号,证明该元素可以复用且不需要进行移动,如果小于lastPlaceholder,那么就证明该元素是需要进行移动操作的.因为元素只能向右移动,那么lastPlaceholder也永远是只能向右移动的.通过这个游标,我们就可以遍历出哪些元素是可以不需要移动的,哪些是需要进行移动的.

image-20210606000638287

lastPlaceholder默认为0,比如在遍历新元素过程中

1:第一个元素A,序号为0,lastPlaceholder也为0,不需要做任何操作.

2.第二个元素C,对应老元素的序号为2,大于lastPlaceholder,则将lastPlaceholder更新为2,C元素不需要移动操作

3.第三个元素B,对应老元素的序号为1,小于lastPlaceholder,则证明B元素是需要进行向右移动操作的,同时lastPlaceholder保持不变.

4.第四,五个元素E,F没有对应的老元素,属于新增操作.

5.最后遍历老元素,发现D元素在新元素里面没有,当属于删除操作.

这一轮对比下来之后,我们就可以生成一个补丁包,通过这个补丁包记录下来本次对比下来需要执行的哪些操作,比如哪些是需要删除的,哪些是需要从某个位置移动到另一个位置的,哪些是新增的.最后在应用这个补丁包来完成整个更新操作.

开始Codeing

首先createElement需要把key加上了

const createElement = (type, config = {}, ...children) => {
-    const {ref, __source, __self, ...props} = config || {};
+    const {ref, __source, __self, key, ...props} = config || {};
    if (children.length) {
        props.children = children.length > 1 ? children.map(wrapToVdom) : wrapToVdom(children[0]);
    }
    return {
        $$typeof: Symbol.for('react.element'),
        type,
        props,
-        key: null,
+        key,
        ref,
    }
}

src/react-dom/index.js里面的方法,给子元素增加一个_mountIndex的属性,用来记录元素处于第几个位置.

function reconcileChildren(childrenVdom, parentDOM) {
    for (let i = 0; i < childrenVdom.length; i++) {
+        childrenVdom[i]._mountIndex = i;
        let childVdom = childrenVdom[i];
        render(childVdom, parentDOM);
    }
}

为了能用key进行对比,我们需要把新旧虚拟Dom里面的key提取出来,做成一张映射表,来方便查看是否存在相同的key.就好比如下

{
  A: AoldVdom,
  B: BoldVdom,
  C: ColdVdom,
  D: DoldVdom,
}

新元素的map表,除了不仅仅提取出所有的key,如果有可复用的老节点,就key对应的值就直接复用老节点的.就好比如下

{
  A: AoldVdom,
  C: ColdVdom,
  B: BoldVdom,
  E: EnewVdom,
  F: FnewVdom,
}

这里需要注意两个点:

1.key相同并不一定代表就可以直接复用,如果元素类型变了,也是不可以复用的.

2.key相同,元素类型也相同,也还需要考虑props和子元素更新了的情况.

基于以上,我们在src/react-dom/index.js里面新增一个diff方法,来替代以前updateChildren里面的粗暴逻辑

function updateChildren(parentDom, oldChildren, newChildren) {
    oldChildren = Array.isArray(oldChildren) ? oldChildren : [oldChildren];
    newChildren = Array.isArray(newChildren) ? newChildren : [newChildren];
    diff(parentDom, oldChildren, newChildren);
-    const maxLength = Math.max(oldChildren.length, newChildren.length);
-    for (let i = 0; i < maxLength; i++) {
-       const nextDom = oldChildren.find((item, index) => index > i && item && findDom(item));
-       compareTwoVdoms(oldChildren[i], newChildren[i], parentDom, findDom(nextDom));
-    }
}

+function diff(parentDom, oldChildren, newChildren){
+    const oldChildrenMap = getOldChildrenMap(oldChildren);
+    const newChildrenMap = getNewChildrenMap(oldChildrenMap, newChildren);
+}
+/**
+ * 利用子元素生成一张key => vdom的映射表
+ * @param elements
+ * @returns {{}}
+ */
+function getOldChildrenMap(elements){
+    let map = {};
+    elements.forEach((item, index) => {
+        const key = item.key || index.toString();
+        map[key] = item;
+    });
+    return map;
+}
+
+/**
+ * 新元素的map表,如果有可复用的老元素,其key对应的值直接就是旧元素.
+ * @param oldChildrenMap
+ * @param elements
+ * @returns {{}}
+ */
+function getNewChildrenMap(oldChildrenMap, elements){
+    let map = {};
+    elements.forEach((item, index) => {
+        if(!isNotNeedRender(item)){ // 新节点不需要渲染
+            const key = item.key || index.toString();
+            let oldElement = oldChildrenMap[key];
+            // 判断是否可以复用
+            if(canDeepCompare(oldElement, item)){
+                updateElement(oldElement, item); // 直接复用老的DOM节点,更新节点属性和子元素.
+                elements[index] = oldElement;
+            }
+            map[key] = elements[index];
+        }
+    })
+    return map;
+}
+
+/**
+ * key相同,同时类型也相同才能进行复用
+ * @param oldElement
+ * @param newElement
+ * @returns {boolean}
+ */
+function canDeepCompare(oldElement, newElement){
+    if(oldElement && newElement){
+        return oldElement.type === newElement.type;
+    }
+    return;
+}

这样就得到了两张新旧元素的map表.接下来遍历这两张表,来生成我们需要的补丁包.

src/constant/index.js里面创建三种不同类型的操作项来代表移动,删除,新增

+export const MOVE = 'MOVE';
+export const REMOVE = 'REMOVE';
+export const INSERT = 'INSERT';

src/react-dom/index.js里面导入,并维护一个diffQueue的补丁包.

-import {REACT_FORWARD_COMPONENT, REACT_TEXT} from '../constants';
+import {REACT_FORWARD_COMPONENT, REACT_TEXT, MOVE, REMOVE, INSERT} from '../constants';

+const diffQueue = [];

在之前的diff方法里面来遍历新旧两张表,生成这个补丁包,移动的元素记录下需要从哪个位置移动到哪个位置,插入的元素记录插入到哪个位置,删除的元素则记录需要从第几个删除.

function diff(parentDom, oldChildren, newChildren){
    const oldChildrenMap = getOldChildrenMap(oldChildren);
    const newChildrenMap = getNewChildrenMap(oldChildrenMap, newChildren);
+    let lastPlaceholder = 0;
+    newChildren.forEach((item, index) => {
+        if(!isNotNeedRender(item)){
+            const key = item.key || index.toString();
+            const oldElement = oldChildrenMap[key];
+            if(item === oldElement){ // 是相同节点
+                if(oldElement._mountIndex < lastPlaceholder){ // 判断老元素是否需要移动
+                    diffQueue.push({
+                        parentDom,
+                        type: MOVE,
+                        fromIndex: oldElement._mountIndex,
+                        toIndex: index,
+                    })
+                }
+                lastPlaceholder = Math.max(oldElement._mountIndex, lastPlaceholder);
+            }else{ // 属于新元素,直接插入
+                diffQueue.push({
+                    parentDom,
+                    type: INSERT,
+                    toIndex: index,
+                    dom: createDom(item)
+                })
+            }
+            item._mountIndex = index; // 更新挂载索引
+        }
+    })
+    for(let key in oldChildrenMap){
+        if(!newChildrenMap.hasOwnProperty(key)){
+            const oldElement = oldChildrenMap[key];
+            diffQueue.push({
+                parentDom,
+                type: REMOVE,
+                fromIndex: oldElement._mountIndex
+            })
+        }
+    }
+}

在前面getNewChildrenMap方法里面,如果元素下面继续挂载着子元素,则会递归再次执行到diff方法里面去.为了确保整个子节点递归对比完成了,我们需要维护一个变量updateDepth,来代表当前对比所处在的层级,每次进入diff方法刚加1,执行完一轮出来,刚减1,当updateDepth等于0的时候,刚代表当前元素及其子元素已经对比完成了.

const diffQueue = [];
+let updateDepth = 0;

function updateChildren(parentDom, oldChildren, newChildren) {
    oldChildren = Array.isArray(oldChildren) ? oldChildren : [oldChildren];
    newChildren = Array.isArray(newChildren) ? newChildren : [newChildren];
+    updateDepth ++;
    diff(parentDom, oldChildren, newChildren);
+    updateDepth --;
+    if(updateDepth === 0){
+        console.log('diffQueue',diffQueue);
+    }
}

打印看一下我们获取到的这个补丁包:

image-20210606145439914

这个补丁包符合我们的预期,接下来就是来应用这个补丁包.

首先,找出所有需移动和删除的元素,,把它们从原来的位置上面删除掉,并把老元素缓存一份,因为如果是移动的元素,后面在插入的需要进行利用.

在这里,我们需要删除B,D元素.剩下AC元素.然后在索引2的位置移入原来的B元素,在索引3,4的位置插入新增的元素.

image-20210606152754827


function updateChildren(parentDom, oldChildren, newChildren) {
    oldChildren = Array.isArray(oldChildren) ? oldChildren : [oldChildren];
    newChildren = Array.isArray(newChildren) ? newChildren : [newChildren];
    updateDepth ++;
    diff(parentDom, oldChildren, newChildren);
    updateDepth --;
    if(updateDepth === 0){
-        console.log('diffQueue',diffQueue);
+                path(diffQueue);
+        diffQueue.length = 0;
    }
}

+function path(diffQueue){
+    // 1.找出全部需要删除要删除的
+    let deleteMap = {}; // 缓存删掉的元素,方便元素的移动
+    let deleteChildren = []; // 储存需要删除的元素节点
+    diffQueue.forEach((item) => {
+        const {type, fromIndex, toIndex} = item;
+        if(type === MOVE || type === REMOVE){
+            const oldChild = item.parentDom.children[fromIndex];
+            deleteMap[fromIndex] = oldChild;
+            deleteChildren.push(oldChild);
+        }
+    });
+    // 2.从老的节点里面删除需要删除的元素
+    deleteChildren.forEach(item => {
+        item.parentNode.removeChild(item);
+    });
+    // 3.插入需要移动的元素和新元素
+    diffQueue.forEach((item) => {
+        const { type, fromIndex, toIndex, parentDom, dom} = item;
+        if(type === INSERT){
+            insertChildAt(parentDom, dom, toIndex)
+        }
+        if(type === MOVE){
+            insertChildAt(parentDom, deleteMap[fromIndex], toIndex)
+        }
+    })
+}
+
+function insertChildAt(parentDom,dom, toIndex){
+    let oldChild = parentDom.children[toIndex];
+    oldChild ? parentDom.insertBefore(dom, oldChild) : parentDom.appendChild(dom);
+}

打开控制台,查看最开始我们写的那个组件,试着点击一下,看看A,C元素是否成功复用了.并且元素的属性也有了更新.

QQ20210606-155030

buxuku commented 3 years ago

注意

在前面的insertChildAttdiff方法里面,我们取的是children值,这里有一个问题,children取的是父元素的子元素,是被HTML标签包裹的元素,不包含文本节点的,所以对于如<div>hello:!<div>这样的标签,是没办法在hello:后面插入内容的.正确的应该采用childNodes来获取.

function path(diffQueue){
    // 1.删除要删除的
    let deleteMap = {};
    let deleteChildren = [];
    diffQueue.forEach((item) => {
        const {type, fromIndex, toIndex} = item;
        if(type === MOVE || type === REMOVE){
-            const oldChild = item.parentDom.children[fromIndex];
+            const oldChild = item.parentDom.childNodes[fromIndex];
            deleteMap[fromIndex] = oldChild;
            deleteChildren.push(oldChild);
        }
    });
    deleteChildren.forEach(item => {
        item.parentNode.removeChild(item);
    });
    diffQueue.forEach((item) => {
        const { type, fromIndex, toIndex, parentDom, dom} = item;
        if(type === INSERT){
            insertChildAt(parentDom, dom, toIndex)
        }
        if(type === MOVE){
            insertChildAt(parentDom, deleteMap[fromIndex], toIndex)
        }
    })
}

function insertChildAt(parentDom,dom, toIndex){
-    let oldChild = parentDom.children[toIndex];
+    let oldChild = parentDom.childNodes[toIndex];
    oldChild ? parentDom.insertBefore(dom, oldChild) : parentDom.appendChild(dom);
}

在正常情况下它工作是良好的,但如果节点是一个null,undefined或者说是布尔值,那么它是不会渲染到真实dom节点上面的.这就会导致我们之前的_mountIndex不一定是准确的.

image-20210607142517932

所以对于mountIndex是应该按照真实的需要渲染的节点来设置的.这里单独维护一个mountIndex数据.

首先调整一下createDom里面对children的处理

    if (props) {
        const {children} = props;
        if (Array.isArray(children)) {
            reconcileChildren(children, dom);
        } else {
-                render(children, dom);
+            reconcileChildren([children], dom);
        }
    }

reconcileChildren单独维护一个mountIndex

 function reconcileChildren(childrenVdom, parentDOM) {
+    let mountIndex = 0;
     for (let i = 0; i < childrenVdom.length; i++) {
-        childrenVdom[i]._mountIndex = i;
-        let childVdom = childrenVdom[i];
-        render(childVdom, parentDOM);
+        if(!isNotNeedRender(childrenVdom[i])){
+            childrenVdom[i]._mountIndex = mountIndex ++;
+            let childVdom = childrenVdom[i];
+            render(childVdom, parentDOM);
+        }
     }
 }

getOldChildrenMapgetNewChildrenMap进行调整,取key的时候判断item是否有值.

function getOldChildrenMap(elements){
    let map = {};
    elements.forEach((item, index) => {
-        const key = item.key || index.toString();
+        const key = (item && item.key) || index.toString();
-        map[key] = item;
    });
    return map;
}

function getNewChildrenMap(oldChildrenMap, elements){
    let map = {};
    elements.forEach((item, index) => {
-        const key = item.key || index.toString();
+        const key = (item && item.key) || index.toString();
        if(!isNotNeedRender(item)){ // 新节点不需要渲染
            let oldElement = oldChildrenMap[key];
            // 判断是否可以复用
            if(canDeepCompare(oldElement, item)){
                updateElement(oldElement, item); // 直接复用老的DOM节点,更新节点属性和子元素.
                elements[index] = oldElement;
            }
-            map[key] = elements[index];
        }
+        map[key] = elements[index];
    })
    return map;
}

最后对diff方法也进行调整,并且之前在判断需要删除的旧元素时,只判断了key,少了存在key,但类型变了的情况.

function diff(parentDom, oldChildren, newChildren){
    const oldChildrenMap = getOldChildrenMap(oldChildren);
    const newChildrenMap = getNewChildrenMap(oldChildrenMap, newChildren);
    let lastIndex = 0;
+    let mountIndex = 0;
    newChildren.forEach((item, index) => {
        if(!isNotNeedRender(item)){
            const key = item.key || index.toString();
            const oldElement = oldChildrenMap[key];
            if(item === oldElement){ // 是相同节点
                if(oldElement._mountIndex < lastIndex){ // 判断老元素是否需要移动
                    diffQueue.push({
                        parentDom,
                        type: MOVE,
                        fromIndex: oldElement._mountIndex,
-                        toIndex: index,
+                        toIndex: mountIndex,
                    })
                }
                lastIndex = Math.max(oldElement._mountIndex, lastIndex);
            }else{ // 属于新元素,直接插入
                diffQueue.push({
                    parentDom,
                    type: INSERT,
-                    toIndex: index,
+                    toIndex: mountIndex,
                    dom: createDom(item)
                })
            }
-            item._mountIndex = index; // 更新挂载索引
+            item._mountIndex = mountIndex ++; // 更新挂载索引
        }
    })
    for(let key in oldChildrenMap){
-        if(!newChildrenMap.hasOwnProperty(key)){
-                const oldElement = oldChildrenMap[key];
+        const oldElement = oldChildrenMap[key];
+        const notWithNew = !newChildrenMap.hasOwnProperty(key); // 新节点里面不存在该老元素
+        const notSame = newChildrenMap[key] !== oldElement; // 新节点该元素的类型变了
+        if(!isNotNeedRender(oldElement) && (notWithNew || notSame)){
            diffQueue.push({
                parentDom,
                type: REMOVE,
                fromIndex: oldElement._mountIndex
            })
        }
    }
}

对之前的组件调整一下,看看是否能够正常渲染

  render() {
      return this.state.show ? (
              <ul onClick={this.handleClick}>
                  <li key="A" id="1">A</li>
+                    {true}
                  <li key="B">B</li>
                  <li key="C">C</li>
                  <li key="D">D</li>
              </ul>
          )
          : (
              <ul onClick={this.handleClick}>
                  <li key="A" id="2">A</li>
+                    {null}
                  <li key="C">C</li>
                  <li key="B">B</li>
+                    {undefined}
                  <li key="E">E</li>
                  <li key="F">F</li>
              </ul>
          )
  }
buxuku commented 3 years ago

另外,在前面对key的取值时,并不严谨,没有考虑到keynull0的情况,这将会导致如下情况的key出现错乱.

<ul>
  <li key={1}>1<li>
  <li key={0}>0<lii>
</ul>

因此,我们在src/utils.js里面写一个getVdomKey的方法

/**
 * 获取vdom的key属性
 * @param vdom
 * @param index
 * @returns {*|string}
 */
export function getVomKey(vdom, index){
    return vdom && vdom.key !== undefined ? vdom.key : index.toString();
}

然后修改src/react-dom/index.js文件里面的getOldChildrenMap,getNewChildrenMap,diff三个方法里面的const key=...的方法,统一修改为

const key = getVomKey(item, index);
buxuku commented 3 years ago

另外,在之前的updateElement方法里面,以于文本节点更新完成之后,需要将其的props属性也更新,以避免每次更新oldVdom上面的props永远是最开始的值,另外对于原生的HTML元素,我们进行了节点属性和子元素的更新,它虽然可能复用了老的节点和子节点,但并没有把新的vdom里面的props属性更新过去,这会导致,在下一次更新的时候,还是会拿最原始的props属性去对比更新.包括props里面的children属性.所以,在完成更新后,同时也需要把新的porps属性也更新过去.我们在方法里面加上一句.

function updateElement(oldVdom, newVdom) {
    if (oldVdom.type === REACT_TEXT && newVdom.type === REACT_TEXT ) {
        const dom = newVdom.dom = findDom(oldVdom);
        if(oldVdom.props.content !== newVdom.props.content){ // 当文本内容有变化才进行更新
            dom.textContent = newVdom.props.content;
+           oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
        }
    }
    if (typeof oldVdom.type === 'string') { // 原生的HTML元素
        const currentDom = newVdom.dom = findDom(oldVdom); // 把老的Dom节点直接复制过来
        renderAttributes(currentDom, newVdom.props, oldVdom.props); // 更新节点属性
        updateChildren(currentDom, oldVdom.props.children, newVdom.props.children);
+        oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
    } else if(typeof oldVdom.type === 'function'){
        if (oldVdom.type.isReactComponent) {
            updateClassComponent(oldVdom, newVdom)
        }else{
            updateFunctionComponent(oldVdom, newVdom);
        }
    }
}
buxuku commented 3 years ago

假如有这样一个组件,它最后返回的虚拟Dom会长什么样子呢?

const NumberList = () => {
    const numbers = [1, 2, 3, 4, 5];
    return (<div>
        <p>NumberList: </p>
        {numbers.map(item => <p key={item}>{item}</p>)}
        {numbers}
    </div>)
}

我们知道,在jsx里面可以直接放一个数组的.而它返回的虚拟Dom是长这个样子的.

image

可以看到,在divprops.children实际上只有三个顶级子元素,而第二和第三个子元素是包含子多个子元素的数组.因此,在这种情况下,我们似乎意识到了前面我们标记的_mountIndex又可能不准确了.因为按照前面的标识来算的话,这里面的第二个children_mountIndex只有一个1,但它其实是在div下面渲染了5个子元素的.

为了让前面的_mountIndex能够正常工作,这里我们将这种数组类型的子元素进行打平处理,因为打平之后的渲染顺序和之前的渲染顺序依然还是一致的.所以我们可以放心地把它们给打平出来.

src/utils/index.js里面增加一个flatten方法

/**
 * 对于[].map类型的节点,进行扁平化处理
 * @param children
 * @returns {*[]}
 */
export function flatten(children){
    const result = [];
    (function loop(array){
        array.forEach(child => {
            if(Array.isArray(child)){
                loop(child);
            }else{
                result.push(child);
            }
        })
    })(children)
    return result;
}

src/react/index.js里面导入这个方法,对并createElement这个方法里面的children应用上面的方法进行处理.

import {Component} from "./Component";
-import {wrapToVdom} from '../utils';
+import {wrapToVdom, flatten} from '../utils';
import {REACT_FORWARD_COMPONENT} from "../constants";

/**
 * 生成虚拟DOM
 * @param type
 * @param props
 * @param children
 * @returns {{ref: null, $$typeof: symbol, text: null, type, key: null, props: {}}}
 */
const createElement = (type, config = {}, ...children) => {
    const {ref, __source, __self, key, ...props} = config || {};
    if (children.length) {
+       children = flatten(children);
        props.children = children.length > 1 ? children.map(wrapToVdom) : wrapToVdom(children[0]);
    }
    return {
        $$typeof: Symbol.for('react.element'),
        type,
        props,
        key,
        ref,
    }
}

如此以来,我们便可以正常渲染前面的那个NumberList组件了.

buxuku commented 3 years ago

另外,在前面对key的取值时,并不严谨,没有考虑到keynull0的情况,这将会导致如下情况的key出现错乱.

<ul>
  <li key={1}>1<li>
  <li key={0}>0<lii>
</ul>

因此,我们在src/utils.js里面写一个getVdomKey的方法

/**
 * 获取vdom的key属性
 * @param vdom
 * @param index
 * @returns {*|string}
 */
export function getVomKey(vdom, index){
    return vdom && vdom.key !== undefined ? vdom.key : index.toString();
}

然后修改src/react-dom/index.js文件里面的getOldChildrenMap,getNewChildrenMap,diff三个方法里面的const key=...的方法,统一修改为

const key = getVomKey(item, index);

这里面的getVomKey还是会存在一个问题,假如key的值刚好有和index值一样的话,那就会有冲突,导致在getOldChildrenMapgetNewChildrenMap里面生成 的map丢失数据.所以需要对index添加一个前缀来避免.

export function getVomKey(vdom, index){
-    return vdom && vdom.key !== undefined ? vdom.key : index.toString();
+    return vdom && vdom.key !== undefined ? vdom.key : `$$REACT_KEY_${index}`; // 添加前缀防止index和key冲突
}
buxuku commented 3 years ago

另外,在之前的updateElement方法里面,以于文本节点更新完成之后,需要将其的props属性也更新,以避免每次更新oldVdom上面的props永远是最开始的值,另外对于原生的HTML元素,我们进行了节点属性和子元素的更新,它虽然可能复用了老的节点和子节点,但并没有把新的vdom里面的props属性更新过去,这会导致,在下一次更新的时候,还是会拿最原始的props属性去对比更新.包括props里面的children属性.所以,在完成更新后,同时也需要把新的porps属性也更新过去.我们在方法里面加上一句.

function updateElement(oldVdom, newVdom) {
 if (oldVdom.type === REACT_TEXT && newVdom.type === REACT_TEXT ) {
     const dom = newVdom.dom = findDom(oldVdom);
     if(oldVdom.props.content !== newVdom.props.content){ // 当文本内容有变化才进行更新
         dom.textContent = newVdom.props.content;
+           oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
        }
    }
    if (typeof oldVdom.type === 'string') { // 原生的HTML元素
        const currentDom = newVdom.dom = findDom(oldVdom); // 把老的Dom节点直接复制过来
        renderAttributes(currentDom, newVdom.props, oldVdom.props); // 更新节点属性
        updateChildren(currentDom, oldVdom.props.children, newVdom.props.children);
+        oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
    } else if(typeof oldVdom.type === 'function'){
        if (oldVdom.type.isReactComponent) {
            updateClassComponent(oldVdom, newVdom)
        }else{
            updateFunctionComponent(oldVdom, newVdom);
        }
    }
}

这个地方的修改其实应该放在getNewChildrenMap这个方法里面.在这个方法里面,如果节点可以复用,就用老的虚拟Dom来替换新的虚拟Dom,对应这一句代码elements[index] = oldElement;,所以应该是在这个地方把新的虚拟Dom的props属性更新过来.

function updateElement(oldVdom, newVdom) {
 if (oldVdom.type === REACT_TEXT && newVdom.type === REACT_TEXT ) {
     const dom = newVdom.dom = findDom(oldVdom);
     if(oldVdom.props.content !== newVdom.props.content){ // 当文本内容有变化才进行更新
         dom.textContent = newVdom.props.content;
-           oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
        }
    }
    if (typeof oldVdom.type === 'string') { // 原生的HTML元素
        const currentDom = newVdom.dom = findDom(oldVdom); // 把老的Dom节点直接复制过来
        renderAttributes(currentDom, newVdom.props, oldVdom.props); // 更新节点属性
        updateChildren(currentDom, oldVdom.props.children, newVdom.props.children);
-        oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
    } else if(typeof oldVdom.type === 'function'){
        if (oldVdom.type.isReactComponent) {
            updateClassComponent(oldVdom, newVdom)
        }else{
            updateFunctionComponent(oldVdom, newVdom);
        }
    }
}

function getNewChildrenMap(oldChildrenMap, elements){
    let map = {};
    elements.forEach((item, index) => {
        const key = getVomKey(item, index);
        if(!isNotNeedRender(item)){ // 新节点不需要渲染
            let oldElement = oldChildrenMap[key];
            // 判断是否可以复用
            if(canDeepCompare(oldElement, item)){
                updateElement(oldElement, item); // 直接复用老的DOM节点,更新节点属性和子元素.
+                oldElement.props = item.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
                elements[index] = oldElement;
            }
        }
        map[key] = elements[index];
    })
    return map;
}