React学习第五天---Virtual DOM 及 Diff 算法(diff算法完成DOM更新)(四)

981 阅读11分钟

这是我参与更文挑战的第12天,活动详情查看: 更文挑战

接下来学习是关于更新DOM元素,VirtualDOM的对比。前面完成了虚拟DOM到真实DOM的渲染工作,接下来进入Diff算法阶段。

节点类型相同的情况

我们准备两段JSX,然后前后使用我们前面写好的TinyReact.render方法将他们完成渲染,并且将后面一段JSX覆盖前面的JSX生成的DOM。第二段JSX与第一段JSX有属性和内容节点的不同。我们完成Virtual的对比来实现DOM的最小更新

const virtualDOM = (
  <div className="container">
    <h1>你好 Tiny React</h1>
    <h2 data-test="test">(编码必杀技)</h2>
    <div>
      嵌套1 <div>嵌套 1.1</div>
    </div>
    <h3>(观察: 这个将会被改变)</h3>
    {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
    {2 == 2 && <div>2</div>}
    <span>这是一段内容</span>
    <button onClick={() => alert("你好")}>点击我</button>
    <h3>这个将会被删除</h3>
    2, 3
    <input type="text" value="13" />
  </div>
)

有属性的不同,有节点内容不同,有处理时间的响应的函数不同,但是节点类型保持不变

const modifyDOM = (
  <div className="container">
    <h1>你好 Tiny React</h1>
    <h2 data-test="test123">(编码必杀技)</h2>
    <div>
      嵌套1 <div>嵌套 1.1</div>
    </div>
    <h3>(观察: 这个将会被改变)</h3>
    {2 == 1 && <div>如果2和1相等修改了当前内容</div>}
    {2 == 2 && <div>2</div>}
    <span>这是一段内容</span>
    <button onClick={() => alert("你好,我已经更新")}>点击我</button>
    <h3>这个将会被删除</h3>
    2, 3
    <input type="text" value="13" />
  </div>
);
TinyReact.render(virtualDOM, root)

setTimeout(() => {
  TinyReact.render(modifyDOM, root)
}, 2000)

Virtual DOM比对

问题:

我们即将面对到第一个问题,在进行VirtualDOM比对时,需要用到更新后的VirtualDOM和更新前的VirtualDOM,更新后的VirtualDOM目前我们可以通过Render方法进行传递,但是更新前的旧VirtualDOM要如何获取呢?

分析:

其实对于更新前的VirtualDOM,对应的其实就是已经在页面中渲染的真实DOM对象,既然是这样,那么我们在创建真实DOM对象时,就可以将VirtualDOM存储到真实DOM对象属性中。在进行VirtualDOM对比之前,就可以通过真实DOM对象获取其对应的VirtualDOM对象了,其实就是通过render方法的第三个参数获取的.

在我们编写JSX代码的时候,在每一段JSX的代码中都必须有一个父亲根节点,这个container.firstChild就是指的每一段JSX代码的那个父亲

<div id="app"></div> // container
// 一段JSX
<div> // container.firstChild
    <span>js</span>
</div>

解决:

在创建真实DOM对象时为其添加对应的VirtualDOM对象

关键代码

// createDOMElement.js
import mountElement from "./mountElement"

export default function mountNativeElement(virtualDOM, container) {
    // 将VirtualDOM挂载到真实DOM对象的属性中,方便在对比时获取其 VirtualDOM
    newELement._virtualDOM = virtualDOM
}

上面给使用当前页面渲染真实DOM对象存储旧的VirtualDOM,现在我们来对比在VirtualDOM类型相同的情况下,我们完成文本节点和节点属性的更新。

1. 如果是文本节点,我们就更新其内容,我们创建updateTextNode方法进行处理、

    export default function updateTextNode(virtualDOM, oldVirtualDOM, oldDOM) {
  if (virtualDOM.props.textContent !== oldVirtualDOM.props.textContent) {
  // 更新对应DOM的内容
    oldDOM.textContent = virtualDOM.props.textContent
    // 将最新的
    oldDOM._virtualDOM = virtualDOM
  }

}

上面代码就是文本节点的对比,但是现在只取了DOM的第一层,我们还需要对比下面的子节点才行。我们 就需要遍历递归子元素,调用diff函数进行子元素的比对

2. 如果是元素节点,我们就更新其属性值,我们创建updateNodeElement方法进行处理

updateNodeElement方法在设置元素属性的时候我们就创建过,由于设置和更新都是类似于元素节点属性的操作,所以都使用这个方法进行封装,第一个参数需要更新的DOM元素,第二个参数要更新的virtualDOM,第三个为oldVirtualDOM。注意: oldVirtualDOM是不传递为设置元素节点属性,传是更新元素节点属性。


    export default function updateNodeElement(newElement, virtualDOM, oldVirtualDOM = {}) {
  // 获取节点对应的属性对象
  const newProps = virtualDOM.props || {}
  const oldProps = oldVirtualDOM.props || {}

  Object.keys(newProps).forEach(propName => {
    // 获取属性值
    const newPropsValue = newProps[propName]
    const oldPropsValue = oldProps[propName]
    if (newPropsValue !== oldPropsValue) {
      if (propName.slice(0, 2) === "on") {
        // 判断属性是否是事件属性 onClick => click

        const eventName = propName.toLowerCase().slice(2);

        // 为元素添加事件
        newElement.addEventListener(eventName, newPropsValue);

        // 删除原有的事件的事件处理函数
        if(oldPropsValue) {
          newElement.removeEventListener(eventName, oldPropsValue)
        }
      } else if (propName === "value" || propName === "checked") {
        newElement[propName] = newPropsValue;
      } else if (propName !== "children") {
        if (propName === "className") {
          newElement.setAttribute("class", newPropsValue);
        } else {
          newElement.setAttribute(propName, newPropsValue);
        }
      }
    }
  })
}

newPropsValue !== oldPropsValue 这段代码再不传递oldVirtualDOM时候,oldPropsValue为空值一样成立,会执行条件里面的方法完成元素节点属性的设置。

除了更新元素节点属性,我们还有一种是节点属性被删除,这个我们怎么知道元素节点属性被删如果新节点属性值为空,则该属性已经被删除。分两种情况,当是事件被删除我们对事件使用removeEventListener方法进行注销,如果是其他属性我们直接使用removeAttribute方法对属性进行删除.

export default function diff (virtualDOM, container, oldDOM) {
  ...
  // 清除被删除的节点
  Object.keys(oldProps).forEach(propName => {
    const newPropsValue  = newProps[propName]
    const oldPropsValue = oldProps[propName]
    if(!newPropsValue) {
      // 属性被删除
      if(propName.slice(0,2) === "on") {
        const eventName = propName.toLowerCase().slice(2);
        newElement.removeEventListener(eventName, oldPropsValue);
      } else if(propName !== "children") {
        newElement.removeAttribute(propName)
      }
    }
  })
}

好了我们就完成了在节点相同的情况下,最小范围更新DOM的文本节点和节点元素的属性。

3 小结:

我们最开始在src/index准备了两段JSX代码,在页面加载的时候我们执行并渲染第一段JSX代码,过了两秒之后我们又执行第二段JSX代码。

由于页面当中已经有旧的DOM对象,那我们就不能将第二段代码直接渲染在页面当中了,这个时候我们需要拿着新的VirtualDOM与旧的VirtualDOM来比对,在对比的过程中,找出差异,从而把差异的部分更新到页面当中,这样就可以实现最小化DOM更新。

既然要完成对比,我们就要获取新的和旧的VirtualDOM,新的VirtualDOM就是延迟两秒之后执行JSX代码modifyDOM,现在问题是我们怎么获取旧的VirtualDOM呢?我们知道现在页面渲染的DOM是我们的就的DOM,新的DOM肯定可以获取到的,我们能否用页面的真实DOM获取到对应的旧VirtualDOM呢,这时候就找到对应的createDOMElement方法,因为在这个方法是将VirtualDOM转化成真实DOM对象的,在这里我们可以把对应的virtualDOM对象挂载在对应的真实DOM对象上newElement._virtualDOM = virtualDOM,在对比的时候就会通过对应的真实DOM找到对应的virtualDOM对象,所以render使用第三个参数oldDOM,这个oldDOM指向的就是页面中旧的真实DOM对象,使用oldDOM就可以通过container.firstChild对象来获得,因为container是id为root的div元素对象,我们渲染之后的真实DOM对象都被添加到了这个div当中了,又由于每个JSX都会有一个父级标签,所以就可以通过container的firstChild来获取旧的DOM对象

在把新的VirtualDOM对象container容器oldDOM传递给diff方法,然后找到diff方法,1. 我们先通过oldDOM是否存在,如果存在我们就获取oldDOM对应的VirtualDOM,如果存在oldDOM就不走我们上一节说的直接渲染的方法,而是执行比对DOM更新的代码, 2. 在执行更新代码,对比的时候分为多种情况,一种是文本节点,一种是元素节点。如果是文本就更新文本内容,如果是节点我们就更新元素属性(更新分为两种属性值发生变化,或者属性被删除)。 3. 步骤二我们需要不断递归子节点才能完成所有子节点的文本更新和元素节点属性的更新。child就是要更新的子元素,容器就是对应的oldDOM,对比的子节点就是oldDOM.childNodes[i].

这样就完成了页面DOM的最小更新。

看图比对

我们可以看张图,更清楚的明白两个VirtualDOM是怎么完成比对的。

image.png

这张图分为左右两部分,左边代表未更新的VirtualDOM,右边代表已经更新的VirtualDOM。可以从图中看出VirtualDOM在比对的过程中,进行的是同级比对,子元素和子元素比对,父元素和父元素进行比对。不会发生跨级比对的。如果两个节点类型相同,就看看这个节点是什么类型,如果是文本类型就比较这两个文本节点是否相同,如果不同新的文本节点替换旧的文本节点, 如果相同不作处理.如果是元素节点,就需要比对新节点属性和旧节点的属性是否相同,如果相同不做处理,如果不相同就是用新节点属性值替换旧节点属性值。在看看新节点是否有被删除的属性,如何知道哪个节点被删除了呢,使用旧节点属性名称去获取新节点的属性值,如果获取不到则该属性被删除。

我们来查看第二张图:

image.png

新旧DOM在比对时,采用的是深度优先,即子节点对比优先于同级节点对比。看上图会先比对UL节点,比对完成之后会去比对第一个li子节点,比对完之后查看li还有子节点,会继续比对子节点第一个p节点,发现p节点没有子节点之后就会去比对第二个li节点。对应我们代码中就是在循环体中递归调用diff方法,当某一个节点的所有子节点都比对完成之后,就跳回同级节点进行比对,如果这个子节点还有子节点的话,执行一样的过程递归对子节点进行比对,比对完跳回下调一个同级节点

比对更新:只会进行同级比对和深度优先比对,完成在节点类型相同的情况下完成DOM最小化更新。

节点类型不相同的情况

当节点类型不相同的情况就不想需要进行比对了,只需要生成新的不同节点,然后用新的DOM对象去替换旧的DOM对象就可以了。

export default function diff (virtualDOM, container, oldDOM) {
  ···
  } else if (
    virtualDOM.type !== oldVirtualDOM.type &&
    typeof virtualDOM !== "function") {
      const newElement = createDOMElement(virtualDOM)
      oldDOM.parentNode.replaceChild(newElement, oldDOM)

  } else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
  ···

删除节点的情况

我们需要知道亮点:

  1. 删除节点发生在节点更新之后,
    • 在节点对比更新完DOM之后,我们才回去分析哪些节点是要被删除的
  2. 删除节点发生在同一个父节点的所有子节点身上
    • 就是说删除节点是发生在某一个范围之内的

我们怎么知道哪一个节点被删除掉了呢? 在节点更新完以后,如果这个旧节点对象的数量多于新节点的VirtualDOM的数量,就说明有节点需要被删除。我们可以看下一张图:

image.png

在图中有两个UL,左边的UL是旧DOM对象,右边的UL是新的UL对象,要知道哪些被删除了,我们需要通过对比UL相面的所有li的子节点。比较UL没有问题,再去比对相面的子节点li,第一个li子节点,发现节点类型相同且而且文本节点都是1,没问题。发现第二个节点的文本是不一样的,旧的是2,新的是3,这时候就会用和这个三去替换旧的2,同理接下来比较第三个,发现还是内容不一样,于是将文本节点进行替换。这是后新节点比对完成,旧节点还多一个节点对象,那最后一个节点没有被用到是一个被删除的节点。我们先使用索引去比对,到后面我们使用key进行比对。

代码:

    //  获取旧节点的数量

    let oldChildNodes = oldDOM.childNodes

    // 如果纠结点的数量多于要渲染的新节点的长度
    if(oldChildNodes.length > virtualDOM.children.length) {
        for(let i = oldChildNodes.length -1; i> virtualDOM.children。length - 1;i--) {
            oldDOM.removeChild(oldChildNodes[i]) // 这段代码提取成一个删除方法unmountNode方法
        }
    }

image.png

image.png

完成删除操作,删除发生在更新节点之后,删除发生在同一个父节点的所有子节点身上,怎样才能知道节点被删除了呢?就是旧的节点数量大于准备渲染的新节点数量。

今天我们完成了节点类型相同和不同的DOM比对,节点类型相同的时候,我们会使用新旧节点使用深度优先同级比对方式完成文本节点和节点属性的更新,节点不同的时候完成节点。后面还了解到了节点删除的情况。在第六天我们学习组件更新,使用setState方法完成类组件的更新,在不是同一个组件和是同一个组件怎么完成更新的,敬请期待!!!

----古川俊太郎

夜晚
古老的记忆
编织着我的梦

于是梦坠入深渊

很久
雨下个不停

在小小的挫折里
我寻找简单的语言