React之Virtual DOM 及 Diff 算法概述

React之Virtual DOM 及 Diff 算法概述

  • 1. React 相关知识导读
    • 1.1 JSX 到底是什么
    • 1.2 什么是 Virtual DOM
    • 1.3 Virtual DOM 如何提升效率
  • 2. 实现 Virtual DOM
    • 2.1 项目目录分析
    • 2.2 项目配置
    • 2.3 创建Virtual DOM
    • 2.4 渲染 Virtual DOM 对象为 DOM 对象
      • 2.4.1 render.js
      • 2.4.2 diff.js
        • 2.4.2.1 不存在旧的 Virtual DOM
          • 2.4.2.1.1 mountElement.js
          • 2.4.2.1.2 mountNativeElement.js
          • 2.4.2.1.3 createDOMElement.js
          • 2.4.2.1.4 updateElementNode.js
          • 2.4.2.1.5 mountComponent.js
        • 2.4.2.2 对比的两个节点类型不同
        • 2.4.2.3 更新的是组件
          • 2.4.2.3.1 diffComponent.js
          • 2.4.2.3.2 updateComponent.js 组件更新
        • 2.4.2.4 对比的两个节点类型相同
      • 2.4.3 Conponent.js
    • 注意:

React的 Virtual DOMDiff 算法的源码解构复杂,直接根据源码学习会比较吃力.这边文章依据React的Virtual DOMDiff 算法的原理,自己去实现了相关的功能,大大的简化了相关代码,可以更好帮助我们了解整个流程,对工作原理有了一个大致的认识,之后在去阅读学习源码也会更加简单清晰。

实现代码

1. React 相关知识导读

1.1 JSX 到底是什么

使用 React 就一定会写 JSX,JSX 到底是什么呢?它是一种 JavaScript 语法的扩展,React 使用它来描述用户界面长成什么样子。虽然它看起来非常像 HTML,但它确实是 JavaScript 。在 React 代码执行之前,Babel 会对将 JSX 编译为 React API.

事例:

<div className="container">
  <h3>Hello Reacth3>
  <p>React is great p>
div>

编译为:

React.createElement(
  "div",
  {
    className: "container"
  },
  React.createElement("h3", null, "Hello React"),
  React.createElement("p", null, "React is great")
);

从两种语法对比来看,JSX 语法的出现是为了让 React 开发人员编写用户界面代码更加轻松。

Babel REPL

1.2 什么是 Virtual DOM

React 中,每个 DOM 对象都有一个对应的 Virtual DOM 对象,它是 DOM 对象的 JavaScript 对象表现形式,其实就是使用 JavaScript 对象来描述 DOM 对象信息,比如 DOM 对象的类型是什么,它身上有哪些属性,它拥有哪些子元素。

可以把 Virtual DOM对象理解为 DOM 对象的副本,但是它不能直接显示在屏幕上。

<div className="container">
  <h3>Hello Reacth3>
  <p>React is great p>
div>

对应的Virtual DOM

{
  type: "div",
  props: { className: "container" },
  children: [
    {
      type: "h3",
      props: null,
      children: [
        {
          type: "text",
          props: {
            textContent: "Hello React"
          }
        }
      ]
    },
    {
      type: "p",
      props: null,
      children: [
        {
          type: "text",
          props: {
            textContent: "React is great"
          }
        }
      ]
    }
  ]
}

1.3 Virtual DOM 如何提升效率

精准找出发生变化的 DOM 对象,只更新发生变化的部分。

React 第一次创建 DOM 对象后,会为每个 DOM 对象创建其对应的 Virtual DOM 对象,在 DOM 对象发生更新之前,React 会先更新所有的 Virtual DOM 对象,然后 React 会将更新后的 Virtual DOM 和 更新前的 Virtual DOM 进行比较,从而找出发生变化的部分,React 会将发生变化的部分更新到真实的 DOM 对象中,React 仅更新必要更新的部分。

Virtual DOM 对象的更新和比较仅发生在内存中,不会在视图中渲染任何内容,所以这一部分的性能损耗成本是微不足道的。

2. 实现 Virtual DOM

2.1 项目目录分析

├── package.json 
├── webpack.config.js 
├── .babelrc 
└── src/
    ├── index.html
    ├── index.js.............................项目入口文件
    ├── TinyReact/...........................核心代码目录
    │   ├── index.js.........................出口
    │   ├── render.js........................渲染函数
    │   ├── diff.js..........................diff函数
    │   ├── createElement.js.................创建virtual dom
    │   ├── createDOMElement.js..............创建DOM元素
    │   ├── diffComponent.js.................组件对比
    │   ├── mountComponent.js................将 组件虚拟DOM 转换为真实 DOM
    │   ├── mountElement.js..................将 Virtual DOM 转换为真实 DOM
    │   ├── mountNativeElement.js............将 Native Element 转换为真实 DOM
    │   ├── updateComponent.js...............组件更新
    │   ├── updateElementNode.js.............对比元素属性是否发生变化
    │   ├── updateTextNode.js................对比文本内容是否发生变化
    │   ├── utils.js.........................工具函数
    │   ├── Component.js.....................组件函数
   

2.2 项目配置

在 React 代码执行前,JSX 会被 Babel 转换为 React.createElement 方法的调用,现在我们希望能够使用我们自己的代码替代 React.createElement,因此在.babelrc中需要这样配置:

{
  "presets": [
    "@babel/preset-env",
    [
      "@babel/preset-react",
      {
        "pragma": "TinyReact.createElement"
      }
    ]
  ]
}

这里面的TinyReact就是我们自定义的React项目

2.3 创建Virtual DOM

在 React 代码执行前,JSX 会被 Babel 转换为 React.createElement 方法的调用,在调用 createElement 方法时会传入元素的类型,元素的属性,以及元素的子元素,createElement 方法的返回值为构建好的 Virtual DOM 对象。

Virtual DOM 对象:

{
  type: "div",
  props: null,
  children: [{type: "text", props: {textContent: "Hello"}}]
}

根据得到的Virtual DOM 对象我们可以定义一个createElement函数返回一个Virtual DOM 对象:

/TinyReact/createElement.js

function createElement (type, props, ...children) {
	return {
    type,
    props,
    children
  } 
}

从 createElement 方法的第三个参数开始就都是子元素了,在定义 createElement 方法时,通过 ...children 将所有的子元素放置到 children 数组中。

const virtualDOM = ( //会自动调用 TinyReact.createElement 创建虚拟DOM
  <div className="container">
    <h1>你好 Tiny React</h1>
    <h2>(编码必杀技)</h2>
    <div>
      嵌套1 <div>嵌套 1.1</div>
    </div>
    <h3>(观察: 这个将会被改变)</h3>
    {2 == 1 && <div>如果21相等渲染当前内容</div>}
    {2 == 2 && <div>2</div>}
    <span>这是一段内容</span>
    <button onClick={() => alert("你好")}>点击我</button>
    <h3>这个将会被删除</h3>
    2, 3
  </div>
)

console.log(virtualDOM)

通过以上代码测试,发现返回的 Virtual DOM 存在一些问题,第一个问题是文本节点被直接放入到了数组中

React之Virtual DOM 及 Diff 算法概述_第1张图片

而我们期望是文本节点应该是这样的

children: [
  {
    type: "text",
    props: {
      textContent: "React is great"
    }
  }
]

因此在通过以下代码对 Virtual DOM 进行改造,重新构建 Virtual DOM。

// 将原有 children 拷贝一份 不要在原有数组上进行操作
const childElements = [].concat(...children).map(child => {
  // 判断 child 是否是对象类型
  if (child instanceof Object) {
    // 如果是 什么都不需要做 直接返回即可
    return child
  } else {
    // 如果不是对象就是文本 手动调用 createElement 方法将文本转换为 Virtual DOM
    return createElement("text", { textContent: child })
  }
})
return {
  type,
  props,
  children: childElements
}

React之Virtual DOM 及 Diff 算法概述_第2张图片

通过观察返回的 Virtual DOM,文本节点已经被转化成了对象类型的 Virtual DOM,但是布尔值也被当做文本节点被转化了,在 JSX 中,如果 Virtual DOM 被转化为了布尔值或者null,是不应该被更新到真实 DOM 中的,所以接下来要做的事情就是清除 Virtual DOM 中的布尔值和null。

// 由于 map 方法无法从数据中刨除元素, 所以此处将 map 方法更改为 reduce 方法
const childElements = [].concat(...children).reduce((result, child) => {
  // 判断子元素类型 刨除 null true false
  if (child != null && child != false && child != true) {
    if (child instanceof Object) {
      result.push(child)
    } else {
      result.push(createElement("text", { textContent: child }))
    }
  }
  // 将需要保留的 Virtual DOM 放入 result 数组
  return result
}, [])

在 React 组件中,可以通过 props.children 获取子元素,所以还需要将子元素存储在 props 对象中。

return {
  type,
  props: Object.assign({ children: childElements }, props),
  children: childElements
}

最后在 /TinyReact/createElement.js中的结果就是这样的:

// createElement 创建 VirtualDOM 对象
export default function createElement(type, props, ...children) {
  // children 是当前的子元素
  const childElements = [].concat(...children).reduce((result, child) => {
    if (child !== false && child !== true && child !== null) {
      if (child instanceof Object) { // 判断子元素是否是对象也就是元素节点
        result.push(child)
      } else { //子元素是文本节点
        result.push(createElement('text', { textContent: child}))
      }
    }
    return result
  }, [])
  return {
    type,
    props: Object.assign({children: childElements}, props), // props中应该包含children属性存储所有的子元素节点集合
    children: childElements
  }
}

2.4 渲染 Virtual DOM 对象为 DOM 对象

现在我们已经可以正常创建Virtual DOM 对象,现在我们需要将其转换为真实DOM对象。

在React中我们是通过调用 render 方法可以将 Virtual DOM 对象更新为真实 DOM 对象。

同时在更新之前需要确定是否存在旧的 Virtual DOM,如果存在需要比对差异,如果不存在可以直接将 Virtual DOM 转换为 DOM 对象。

因此我们在TinyReact文件夹下创建一个render.js文件在里面定义render函数用于渲染Virtual DOM 对象

2.4.1 render.js

render函数中我们通过diff方法内部判断是否需要对比 对比也好 不对比也好 都在 diff 方法中进行操作

// render.js
import diff from './diff'
export default function render(virtualDOM, container, oldDOM = container.firstChild) {
  // 在 diff 方法内部判断是否需要对比 对比也好 不对比也好 都在 diff 方法中进行操作  
  diff(virtualDOM, container, oldDOM)
}

2.4.2 diff.js

diff函数中我们需要考虑多种情况:

  1. 不存在旧的 Virtual DOM 的情况,就是说先直接将 Virtual DOM 对象更新为真实 DOM 对象;
  2. 对比的两个节点类型不同,并且节点的类型不是组件,因为组件要单独维护,我们就生成新的 DOM 对象替换老的 DOM 对象;
  3. 更新的是组件,因为也需要考虑多个情况,这里我们定义一个diffComponent函数去专门处理组件的对比操作;
  4. 对比的两个节点类型相同(如果存在老的真实节点,并能够获取到未更新前的 Virtual DOM):
    • 判断节点类型:
      • 文本节点 对比文本内容是否发生变化 updateTextNode(virtualDOM, oldVirtualDOM, oldDOM)
      • 元素节点 对比元素属性是否发生变化 updateElementNode(oldDOM, virtualDOM, oldVirtualDOM)
    • 将拥有key属性的子元素放置在一个单独的对象中
    • 根据key值处理新增或者位置更换操作
    • 删除节点
// diff.js
import mountElement from './mountElement.js'
import diffComponent from './diffComponent.js'
import createDOMElement from './createDOMElement'
import updateTextNode from './updateTextNode'
import updateElementNode from './updateElementNode'
import {unmount} from "./utils"
export default function diff (virtualDOM, container, oldDOM) {
    // 获取未更新前的 Virtual DOM 这个_virtualDOM是在创建元素时(mountNativeElement)就定义在元素节点上的,只要是老的真实节点都会有这个属性
    const oldVirtualDOM = oldDOM && oldDOM._virtualDOM
    // 获取未更新前的 组件
    const oldComponent = oldVirtualDOM && oldVirtualDOM.component
    // 判断 oldDOM(老的真实节点) 是否存在
    if (!oldDOM) {
        // 如果不存在 不需要对比 直接将 Virtual DOM 转换为真实 DOM
        mountElement(virtualDOM, container)
    } else if (
        // 如果要对比的两个节点类型不同
        virtualDOM.type !== oldVirtualDOM.type &&
        // 并且节点的类型不是组件,因为组件要单独维护
        typeof virtualDOM.type !== 'function'
    ) {
        // 生成新的 DOM 对象替换老的 DOM 对象
        const newElement = createDOMElement(virtualDOM)
        oldDOM.parentNode.replaceChild(newElement, oldDOM)
    } else if (typeof virtualDOM.type === 'function') {
        // 要更新的是组件
        // 1) 组件本身的 virtualDOM 对象 通过它可以获取到组件最新的 props
        // 2) 要更新的组件的实例对象 通过它可以调用组件的生命周期函数 可以更新组件的 props 属性 可以获取到组件返回的最新的 Virtual DOM
        // 3) 要更新的 DOM 象 在更新组件时 需要在已有DOM对象的身上进行修改 实现DOM最小化操作 获取旧的 Virtual DOM 对象
        // 4) 如果要更新的组件和旧组件不是同一个组件 要直接将组件返回的 Virtual DOM 显示在页面中 此时需要 container 做为父级容器
        diffComponent(virtualDOM, oldComponent, oldDOM, container)
    } else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
        if (virtualDOM.type === "text") {
            // 文本节点 对比文本内容是否发生变化
            updateTextNode(virtualDOM, oldVirtualDOM, oldDOM)
        } else {
            // 元素节点 对比元素属性是否发生变化
            updateElementNode(oldDOM, virtualDOM, oldVirtualDOM)
        }
        // 1. 将拥有key属性的子元素放置在一个单独的对象中
        let keyedElements = {}
        for (let i = 0, len = oldDOM.childNodes.length; i < len; i++) {
            let domElement = oldDOM.childNodes[i]
            if (domElement.nodeType === 1) {
                let key = domElement.getAttribute("key")
                if (key) {
                    keyedElements[key] = domElement
                }
            }
        }

        let hasNoKey = Object.keys(keyedElements).length === 0

        if (hasNoKey) {
            // 对比子节点
            virtualDOM.children.forEach((child, i) => {
                diff(child, oldDOM, oldDOM.childNodes[i])
            })
        } else {
            // 2. 循环 virtualDOM 的子元素 获取子元素的 key 属性
            virtualDOM.children.forEach((child, i) => {
                let key = child.props.key
                if (key) {
                    let domElement = keyedElements[key]
                    if (domElement) {
                        // 3. 看看当前位置的元素是不是我们期望的元素
                        if (oldDOM.childNodes[i] && oldDOM.childNodes[i] !== domElement) {
                            oldDOM.insertBefore(domElement, oldDOM.childNodes[i])
                        }
                    } else {
                        // 新增元素
                        const childElement = createDOMElement(child)
                        oldDOM.insertBefore(childElement, oldDOM.childNodes[i])
                    }
                }
            })
        }
        // 删除节点
        // 获取旧节点
        let oldChildNodes = oldDOM.childNodes
        // 判断旧节点的数量
        if (oldChildNodes.length > virtualDOM.children.length) {
            if (hasNoKey) {
                // 有节点需要被删除
                for (
                    let i = oldChildNodes.length - 1;
                    i > virtualDOM.children.length - 1;
                    i--
                ) {
                    unmount(oldChildNodes[i])
                }
            } else {
                // 通过key属性删除节点
                for (let i = 0; i < oldChildNodes.length; i++) {
                    let oldChild = oldChildNodes[i]
                    let oldChildKey = oldChild._virtualDOM.props.key
                    let found = false
                    for (let n = 0; n < virtualDOM.children.length; n++) {
                        if (oldChildKey === virtualDOM.children[n].props.key) {
                            found = true
                            break
                        }
                    }
                    if (!found) {
                        unmount(oldChild)
                    }
                }
            }
        }
    }
}

在分析完diff中做了哪些事情之后,我们根据diff中情况的不同进行进一步分析,首先是第一种情况 不存在旧的 Virtual DOM

2.4.2.1 不存在旧的 Virtual DOM

mountElementdiff中不存在旧的 Virtual DOM 的情况,也就是直接将 Virtual DOM 对象更新为真实 DOM 对象的函数,

2.4.2.1.1 mountElement.js

在进行 virtual DOM 转换之前还需要确定 Virtual DOM 的类 是Component组件 还是 Native Element。

类型不同需要做不同的处理 如果是 Native Element 直接转换。

如果是组件 还需要得到组件实例对象 通过组件实例对象获取组件返回的 virtual DOM 然后再进行转换。

因此我们在这里也创建了mountNativeElementmountComponent两个函数进行不同的处理

import mountNativeElement from './mountNativeElement.js'
import mountComponent from './mountComponent.js'
import {isFunction} from './utils'
export default function mountElement(virtualDOM, container, oldDOM) {
    // 在这里需要判断是原生元素节点还是 组件
    if (!isFunction(virtualDOM)) {
        // 通过调用 mountNativeElement 方法转换 Native Element
        mountNativeElement(virtualDOM, container, oldDOM)
    } else {
        // 如果是组件 调用 mountComponent 方法进行组件渲染
        mountComponent(virtualDOM, container, oldDOM)
    }
}
2.4.2.1.2 mountNativeElement.js

我们先看看mountNativeElement做了什么处理:

  1. 使用createDOMElement根据 virtualDOM 加载了原生元素 newElement
  2. 根据传入的oldDOM参数判断创建的原生元素 newElement 是插入特定位置还是放在容器的最后,并删除就得DOM对象
  3. 将 Virtual DOM 挂载到真实 DOM 对象的属性中 方便在对比时获取oldDOM的 Virtual DOM
  4. 如果是组件渲染出来的 获取组件实例对象,然后调用setDOM方法将创建的原生元素newElement传给组件实例
import createDOMElement from './createDOMElement.js'
import {unmount} from "./utils"
export default function mountNativeElement (virtualDOM, container, oldDOM) {
    // 加载原生元素
    const newElement = createDOMElement(virtualDOM)
    // 将转换之后的DOm对象放置在页面中
    if (oldDOM) {
        container.insertBefore(newElement, oldDOM)
    } else {
        container.appendChild(newElement)
    }
    // 如果旧的DOM对象存在 删除
    if (oldDOM) {
        unmount(oldDOM)
    }

    // 将 Virtual DOM 挂载到真实 DOM 对象的属性中 方便在对比时获取其 Virtual DOM
    newElement._virtualDOM = virtualDOM

    // 如果是组件渲染出来的 获取组件实例对象
    const component = virtualDOM.component
    // 如果组件实例对象存在
    if (component) {
        // 保存 DOM 对象
        component.setDOM(newElement)
    }
    
}
2.4.2.1.3 createDOMElement.js

在执行mountNativeElement函数时我们又创建了一个createDOMElement函数用于创建原生元素节点:
createDOMElement函数中主要实现了:

  1. 根据virtualDOM.type的不同做不同的处理:
    • virtualDOM.type === "text" 建文本节点
    • virtualDOM.type !== "text" 建元素节点,更新元素属性 updateElementNode(newElement, virtualDOM)
  2. 处理完顶级节点后我们还需要递归渲染子节点 mountElement(child, newElement)
  3. 如果virtualDOM.props.ref存在,则将当前创建的元素节点传入
  4. 返回创建的节点
import mountElement from "./mountElement"
import updateElementNode from "./updateElementNode"

export default function createDOMElement(virtualDOM) {
  let newElement = null
 
  if (virtualDOM.type === "text") {
    // 创建文本节点
    newElement = document.createTextNode(virtualDOM.props.textContent)
  } else {
    // 创建元素节点
    newElement = document.createElement(virtualDOM.type)
    // 更新元素属性
    updateElementNode(newElement, virtualDOM)
  }
  // 递归渲染子节点
  virtualDOM.children.forEach(child => {
    // 因为不确定子元素是 NativeElement 还是 Component 所以调用 mountElement 方法进行确定
    mountElement(child, newElement)
  })
  if (virtualDOM.props && virtualDOM.props.ref) {
    virtualDOM.props.ref(newElement)
  }
  return newElement
}

virtualDOM.type !== "text"时,我们在创建了元素节点后还调用了updateElementNode去更新元素属性,
接下来我们看看updateElementNode中做了什么

2.4.2.1.4 updateElementNode.js

在updateElementNode中:

  1. 获取新旧节点对应的属性对象
  2. 根据virtualDOM的属性对象遍历,当同一属性的属性值不同:
    • 判断属性是否是否事件属性 onClick -> click,为元素添加事件,删除原有的事件的事件处理函数
    • 属性是valuechecked,直接进行赋值
    • 其他情况且属性不为children,通过setAttribute赋值,classname特殊处理
  3. 判断属性被删除的情况
export default function updateElementNode(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) {
      // 判断属性是否是否事件属性 onClick -> click
      if (propName.slice(0, 2) === "on") {
        // 事件名称
        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)
        }
      }
    }
  })
  // 判断属性被删除的情况
  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)
      }
    }
  })
}

这处理完成之后我们回到 2.4.2.1.1,去进行mountComponent的处理:

2.4.2.1.5 mountComponent.js

当需要转换的Virtual DOM 的类 是Component组件时:

  1. 区分函数型组件和类组件,调用不同的方法处理组件;
  2. 判断处理后得到的 Virtual Dom 是否是组件,如果是组件需要使用mountComponent继续处理,直到得到的是Navtive Element在使用mountNativeElement进行渲染
  3. 最后如果组件实例对象存在,执行组件的componentDidMount钩子,并判断组件实例对象身上是否有 props 属性 props 属性中是否有 ref 属性,有就调用 ref 方法并传递组件实例对象
  4. buildFunctionalComponentbuildStatefulComponent分别是用于函数型组件和类组件的函数,返回需要渲染的Virtual DOM
import mountNativeElement from "./mountNativeElement"
import {isFunctionalComponent, isFunction} from './utils'
export default function mountComponent(virtualDOM, container, oldDOM) {
  // 存放组件调用后返回的 Virtual DOM 的容器
  let nextVirtualDOM = null
  let component = null
  // 区分函数型组件和类组件
  if (isFunctionalComponent(virtualDOM)) {
    // 函数组件 调用 buildFunctionalComponent 方法处理函数组件
    nextVirtualDOM = buildFunctionalComponent(virtualDOM)
  } else {
    // 类组件
    nextVirtualDOM = buildStatefulComponent(virtualDOM)
    component = nextVirtualDOM.component
  }
  // 判断得到的 Virtual Dom 是否是组件
  if (isFunction(nextVirtualDOM)) {
    // 如果是组件 继续调用 mountComponent 解剖组件
    mountComponent(nextVirtualDOM, container, oldDOM)
  } else {
    // 如果是 Navtive Element 就去渲染
    mountNativeElement(nextVirtualDOM, container, oldDOM)
  }
  // 如果组件实例对象存在的话
	if (component) {
    component.componentDidMount()
    // 判断组件实例对象身上是否有 props 属性 props 属性中是否有 ref 属性
     if (component.props && component.props.ref) {
       // 调用 ref 方法并传递组件实例对象
       component.props.ref(component)
     }
   }
}

// 函数组件处理 
function buildFunctionalComponent(virtualDOM) {
    // 通过 Virtual DOM 中的 type 属性获取到组件函数并调用
    // 调用组件函数时将 Virtual DOM 对象中的 props 属性传递给组件函数 这样在组件中就可以通过 props 属性获取数据了
    // 组件返回要渲染的 Virtual DOM
    // 返回的是一个虚拟DOM
    return virtualDOM && virtualDOM.type(virtualDOM.props || {})
}

  // 处理类组件
function buildStatefulComponent(virtualDOM) {
    // 实例化类组件 得到类组件实例对象 并将 props 属性传递进类组件
    const component = new virtualDOM.type(virtualDOM.props)
    // 调用类组件中的render方法得到要渲染的 Virtual DOM
    const nextVirtualDOM = component.render()
    nextVirtualDOM.component = component
    // 返回要渲染的 Virtual DOM
    return nextVirtualDOM   
}

这样 diff中第一种不存在旧的 Virtual DOM,直接将 Virtual DOM 对象更新为真实 DOM 对象的情况我们就处理完毕。
接下来我们开始处理第二种情况 对比的两个节点类型不同,并且节点的类型不是组件,因为组件要单独维护,我们就生成新的 DOM 对象替换老的 DOM 对象

2.4.2.2 对比的两个节点类型不同
// 生成新的 DOM 对象替换老的 DOM 对象
const newElement = createDOMElement(virtualDOM)
oldDOM.parentNode.replaceChild(newElement, oldDOM)

这里我们可以看见就是直接调用了createDOMElement创建元素,然后使用replaceChild替换了老的DOM对象
这部分比较简单,我们继续开始处理第三种情况 更新的是组件

2.4.2.3 更新的是组件

更新组件我们用到的是 diffComponent方法

2.4.2.3.1 diffComponent.js

判断要更新的组件和未更新的组件是否是同一个组件(只需要确定两者使用的是否是同一个构造函数就可以了):

  1. 同一个组件 做组件更新updateComponent(virtualDOM, oldComponent, oldDOM, container)
  2. 不是同一个组件 直接将组件内容显示在页面中mountElement(virtualDOM, container, oldDOM)
// diffComponent.js
import mountElement from './mountElement'
import updateComponent from './updateComponent'

export default function diffComponent(virtualDOM, oldComponent, oldDOM, container) {
    // 判断要更新的组件和未更新的组件是否是同一个组件 只需要确定两者使用的是否是同一个构造函数就可以了
    if (isSameComponent(virtualDOM, oldComponent)) {
       // 属同一个组件 做组件更新
        updateComponent(virtualDOM, oldComponent, oldDOM, container)
    } else {
       // 不是同一个组件 直接将组件内容显示在页面中
        // 这里为 mountElement 方法新增了一个参数 oldDOM 
        // 作用是在将 DOM 对象插入到页面前 将页面中已存在的 DOM 对象删除 否则无论是旧DOM对象还是新DOM对象都会显示在页面中
        mountElement(virtualDOM, container, oldDOM)
    }
  }
  // virtualDOM.type 更新后的组件构造函数
  // oldComponent.constructor 未更新前的组件构造函数
  // 两者等价就表示是同一组件
  function isSameComponent(virtualDOM, oldComponent) {
    return oldComponent && virtualDOM.type === oldComponent.constructor
  }
2.4.2.3.2 updateComponent.js 组件更新

组件更新涉及到了TinyReact.Component因为所有的类组件都是继承于它,组件的实现我们最后再去说明,这里我们先说明当前组件更新做了什么事情

  1. 执行组件componentWillReceiveProps钩子
  2. 调用 shouldComponentUpdate 生命周期函数判断是否要执行更新操作
  3. 执行组件componentWillUpdate钩子
  4. 更新组件的 props 属性,调用组件的 updateProps 方法,updateProps是定义在 Component 类上的
  5. 因为组件的 props 已经更新 所以调用 render 方法获取最新的 Virtual DOM
  6. 将组件实例对象挂载到 Virtual DOM 身上
  7. 调用diff方法更新视图
  8. 执行组件componentDidUpdate钩子
import diff from "./diff"

export default function updateComponent(
  virtualDOM,
  oldComponent,
  oldDOM,
  container
) {
  // 生命周期函数
  oldComponent.componentWillReceiveProps(virtualDOM.props)
  if (
    // 调用 shouldComponentUpdate 生命周期函数判断是否要执行更新操作
    oldComponent.shouldComponentUpdate(virtualDOM.props)
  ) {
    // 将未更新的 props 保存一份
    let prevProps = oldComponent.props
    // 生命周期函数
    oldComponent.componentWillUpdate(virtualDOM.props)
    // 更新组件的 props 属性 updateProps 方法定义在 Component 类型
    oldComponent.updateProps(virtualDOM.props)
    // 因为组件的 props 已经更新 所以调用 render 方法获取最新的 Virtual DOM
    const nextVirtualDOM = oldComponent.render()
    // 将组件实例对象挂载到 Virtual DOM 身上
    nextVirtualDOM.component = oldComponent
    // 调用diff方法更新视图
    diff(nextVirtualDOM, container, oldDOM)
    // 生命周期函数
    oldComponent.componentDidUpdate(prevProps)
  }
}

第三种情况处理完成,我们继续开始处理最后一种情况 对比的两个节点类型相同

2.4.2.4 对比的两个节点类型相同

这部分主要做了:

  1. 判断节点类型:
    • 文本节点 对比文本内容是否发生变化 updateTextNode(virtualDOM, oldVirtualDOM, oldDOM)
    • 元素节点 对比元素属性是否发生变化 updateElementNode(oldDOM, virtualDOM, oldVirtualDOM)
  2. 将拥有key属性的子元素放置在一个单独的对象中
  3. 根据key值处理新增或者位置更换操作
  4. 删除节点
if (virtualDOM.type === "text") {
    // 文本节点 对比文本内容是否发生变化
    updateTextNode(virtualDOM, oldVirtualDOM, oldDOM)
} else {
    // 元素节点 对比元素属性是否发生变化
    updateElementNode(oldDOM, virtualDOM, oldVirtualDOM)
}
// 1. 将拥有key属性的子元素放置在一个单独的对象中
let keyedElements = {}
for (let i = 0, len = oldDOM.childNodes.length; i < len; i++) {
    let domElement = oldDOM.childNodes[i]
    if (domElement.nodeType === 1) {
        let key = domElement.getAttribute("key")
        if (key) {
            keyedElements[key] = domElement
        }
    }
}

let hasNoKey = Object.keys(keyedElements).length === 0

if (hasNoKey) {
    // 对比子节点
    virtualDOM.children.forEach((child, i) => {
        diff(child, oldDOM, oldDOM.childNodes[i])
    })
} else {
    // 2. 循环 virtualDOM 的子元素 获取子元素的 key 属性
    virtualDOM.children.forEach((child, i) => {
        let key = child.props.key
        if (key) {
            let domElement = keyedElements[key]
            if (domElement) {
                // 3. 看看当前位置的元素是不是我们期望的元素
                if (oldDOM.childNodes[i] && oldDOM.childNodes[i] !== domElement) {
                    oldDOM.insertBefore(domElement, oldDOM.childNodes[i])
                }
            } else {
                // 新增元素
                const childElement = createDOMElement(child)
                oldDOM.insertBefore(childElement, oldDOM.childNodes[i])
            }
        }
    })
}
// 删除节点
// 获取旧节点
let oldChildNodes = oldDOM.childNodes
// 判断旧节点的数量
if (oldChildNodes.length > virtualDOM.children.length) {
    if (hasNoKey) {
        // 有节点需要被删除
        for (
            let i = oldChildNodes.length - 1;
            i > virtualDOM.children.length - 1;
            i--
        ) {
            unmount(oldChildNodes[i])
        }
    } else {
        // 通过key属性删除节点
        for (let i = 0; i < oldChildNodes.length; i++) {
            let oldChild = oldChildNodes[i]
            let oldChildKey = oldChild._virtualDOM.props.key
            let found = false
            for (let n = 0; n < virtualDOM.children.length; n++) {
                if (oldChildKey === virtualDOM.children[n].props.key) {
                    found = true
                    break
                }
            }
            if (!found) {
                unmount(oldChild)
            }
        }
    }
}

到这整个React的 virtual DOMdiff我们就简单的实现了。

2.4.3 Conponent.js

这部分主要是 Component的实现

// Component.js 父类 Component 实现
import diff from './diff'
export default class Component {
    constructor(props) {
      this.props = props
    }
    setState (state) {
        // setState 方法被子类调用 此处this指向子类
        // 所以改变的是子类的 state
        this.state = Object.assign({}, this.state, state)
        // 通过调用 render 方法获取最新的 Virtual DOM
        let virtualDOM = this.render()
        // 获取页面中正在显示的 DOM 对象 通过它可以获取其对象的 Virtual DOM 对象 也就是旧的Virtual DOM
        let oldDOM = this.getDOM()
        // 获取真实 DOM 对象父级容器对象
        let container = oldDOM.parentNode
        // 比对 更新视图
        diff(virtualDOM, container, oldDOM)
    }
    // 保存 DOM 对象的方法 这是因为我们需要上一次旧的Virtual DOM与这次进行对比
    setDOM(dom) {
        this._dom = dom
    }
    // 获取 DOM 对象的方法
    getDOM() {
        return this._dom
    }
    // 当我们需要更新时通过这里获取新的Virtual DOM的props
    updateProps(props) {
        this.props = props
    }
    // 生命周期函数
    componentWillMount() {}
    componentDidMount() {}
    componentWillReceiveProps(nextProps) {}
    shouldComponentUpdate(nextProps, nextState) {
        return nextProps != this.props || nextState != this.state
    }
    componentWillUpdate(nextProps, nextState) {}
    componentDidUpdate(prevProps, preState) {}
    componentWillUnmount() {}
}

注意:

  • 我们在创建类组件时会在 virtual DOM上存储 component(组件实例),这是因为:
    1. diff中我们更新的是组件时,我们需要获取到上一次的组件实例进行对比更新
    2. mountNativeElement中,因为如果当前virtual DOM是组件渲染出来的我们需要获取组件实例对象,通过组件实例对象上的setDOM方法将创建的原生元素传递给组件,这样当组件实例的setState方法被调用,触发diff时,我们就可以将其作为oldDOM参数传递进去

你可能感兴趣的:(react,源码,1024程序员节,javascript,reactjs,es6)