likes
comments
collection
share

通过手写一个简单版 React 学习其实现原理

作者站长头像
站长
· 阅读数 33

前情提要


各位好,今天写一篇关于 React 的学习笔记。

React 框架相信各位已经非常熟悉了,那么它的实现原理是什么呢?它和 Vue 的设计逻辑又有什么区别呢?今天就让我们通过实现一个简单版 React 来学习其实现原理。

本篇文章将不涉及到 Fiber 结构,React 的 Fiber 结构由于比较复杂的同时又很重要,所以我们将在下篇单独介绍。

完整项目地址:github.com/zhtzhtx/Ter…

初始化项目


我们都知道 React 使用的是 JSX 语法,那么是谁帮 React 将 JSX 转化成虚拟 DOM (Virtual DOM)的呢?答案是 Babel 。

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

所以,初始化项目的第一步当然是安装 Babel 和 Webpack:

npm install @babel/cli @babel/core @babel/preset-env @babel/preset-react babel-loader clean-webpack-plugin 
html-webpack-plugin webpack webpack-cli webpack-dev-server --save-dev

接下来,我们需要对 Webpack 进行配置,因为在开发环境下我们需要 Webpack 帮我们启动一个开发服务器。先创建一个 webpack.config.js,然后在其中进行配置:

const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
const { CleanWebpackPlugin } = require("clean-webpack-plugin")

module.exports = {
  // 入口文件
  entry: "./src/index.js",
  // 开发环境
  mode: "development",
  // 输出配置
  output: {
    // 输出路径
    path: path.resolve("dist"),
    // 输出文件名
    filename: "bundle.js"
  },
  // source-map
  devtool: "inline-source-map",
  // 使用 babel 编译 js 文件
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: "babel-loader"
      }
    ]
  },
  plugins: [
    // 在构建之前将dist文件夹清理掉
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ["./dist"]
    }),
    // 指定HTML模板, 插件会将构建好的js文件自动插入到HTML文件中
    new HtmlWebpackPlugin({
      template: "./src/index.html"
    })
  ],
  devServer: {
    // 指定开发环境应用运行的根据目录
    static: "./dist",
    // 不启动压缩
    compress: false,
    host: "localhost",
    port: 5000
  }
}

接着,还需要对 Babel 进行配置,因为 Babel 默认是调用 React.createElement 方法,而我们希望的是调用我们自己实现的 React (TerReact)进行 JSX 语法转义。先创建 Babel 的配置文件 .babelrc,在其中编写:

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

好了,这样项目就初始化完成了,下面我们将正式编写 TerReact。

创建 Virtual DOM 对象


上面讲了 Babel 是通过调用我们 TerReact 的 createElement 方法来创建虚拟 DOM 的,那么当然我们应该先编写 createElement 方法。

createElement

上面讲了 Babel 是通过调用我们 TerReact 的 createElement 方法来创建虚拟 DOM 的,那么当然我们应该先编写 createElement 方法。

我们先在 src 文件夹下创建应该 TerReact 文件夹,然后在其中创建一个 createElement.js 文件,在其中编写 createElement 方法。

createElement 方法接受的第一个参数是节点的标签名(如:div),第二个参数是节点属性(如:className),之后的参数是节点的所有子节点,我们可以用剩余运算符进行收集。

需要注意的是,JSX 语法中有可能出现布尔类型和 null

通过手写一个简单版 React 学习其实现原理

这在我们收集子节点时是不应该被更新到真实 DOM 中的,所以需要去除布尔类型和 null。然后我们需要判断子节点是否为文本节点,因为 Babel 会将文本节点直接以字符串的形式传给我们,我们需要自己将其包装成虚拟 DOM 对象。

export default function createElement(type, props, ...children) {
    // 判断是否为文本节点,如果是文本节点则手动创建VNode
    const childElements = [].concat(...children).reduce((result, child) => {
        // 判断子节点不为布尔类型或 null
        if (child !== false && child !== true && child !== null) {
            // 判断是否文本节点
            if (child instanceof Object) {
                result.push(child)
            } else {
                // 将其封装成文本节点的 Virtual DOM
                result.push(createElement("text", { textContent: child }))
            }
        }
        return result
    }, [])

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

将普通 Virtual DOM 对象转化成真实 DOM 对象


上文我们已经通过 createElement 方法创建了 Virtual DOM 对象,接下来就让我们了解如何将 Virtual DOM 对象转化为真实 DOM 对象。

在 React 中,我们需要通过 render 方法将 Virtual DOM 对象挂载到真实 DOM 容器中:

通过手写一个简单版 React 学习其实现原理

render

render 方法的作用就是将 Virtual DOM 对象更新为真实 DOM 对象。

它具有三个参数:虚拟 DOM 对象、DOM 容器(就是转化成真实 DOM 对象后挂载的位置)以及旧 DOM 对象,当初次加载时,旧 DOM 对象为 undefined。

在 render 方法中将传入参数传给了 diff 方法

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

diff

diff 方法是 React 中的核心方法,也因此它会比较复杂,我们将它拆分开来看。

首先,我们考虑的是初次加载时的情况,在 diff 方法中通过是否有 oldDOM 参数判断是否为初次加载,如果是,则通过 mountElement 方法将 Virtual DOM 转换为真实 DOM。

import mountElement from "./mountElement"

export default function diff(virtualDOM, container, oldDOM) {
  // 判断 oldDOM 是否存在
  if (!oldDOM) {
    // 如果不存在 不需要对比 直接将 Virtual DOM 转换为真实 DOM
    mountElement(virtualDOM, container)
  }
}

mountElement

在 mountElement 方法中,我们需要考虑传入的 Virtual DOM 是 React 的组件还是普通 Virtual DOM 对象,这里我们先只考虑普通 Virtual DOM 对象情况。

我们通过 mountNativeElement 方法将普通 Virtual DOM 对象转化成真实 DOM。

// mountElement.js
import mountNativeElement from "./mountNativeElement"

export default function mountElement(virtualDOM, container) {
  // 通过调用 mountNativeElement 方法转换 Native Element
  mountNativeElement(virtualDOM, container)
}

mountNativeElement

在 mountNativeElement 方法中,我们 createDOMElement 方法根据 Virtual DOM 对象创建一个真实 DOM 并将其挂载到 DOM 容器中

import createDOMElement from "./createDOMElement"

export default function mountNativeElement(virtualDOM, container) {
  const newElement = createDOMElement(virtualDOM)
  // 将 createDOMElement 方法返回的 DOM 节点挂载到 DOM 容器中
  container.appendChild(newElement)
}

createDOMElement

在 createDOMElement 方法中,根据 Virtual DOM 对象的 type 属性判断是元素节点还是文本节点来创建 DOM 节点,然后递归循环渲染子节点

import mountElement from "./mountElement"

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

好了,这样将普通 Virtual DOM 对象就转化成真实 DOM 对象。

为元素节点添加属性


需要注意的是元素节点是有属性的,如:className、id等,这些属性存储在 Virtual DOM 对象的 props 属性中,所以在创建元素节点时需要将它们更新到元素节点上。

createDOMElement

在 createDOMElement 方法中,当传入的 Virtual DOM 对象是元素节点时,需要通过 updateElementNode 方法更新元素属性。

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)
  })
  return newElement
}

updateElementNode

在 updateElementNode 方法中,我们先遍历 Virtual DOM 对象中的属性对象,根据不同的属性名将其赋值到新创建的 DOM 对象上

export default function updateElementNode(element, virtualDOM) {
  // 获取要解析的 VirtualDOM 对象中的属性对象
  const newProps = virtualDOM.props
  // 将属性对象中的属性名称放到一个数组中并循环数组
  Object.keys(newProps).forEach(propName => {
    const newPropsValue = newProps[propName]
    // 考虑属性名称是否以 on 开头 如果是就表示是个事件属性 onClick -> click
    if (propName.slice(0, 2) === "on") {
      const eventName = propName.toLowerCase().slice(2)
      element.addEventListener(eventName, newPropsValue)
      // 如果属性名称是 value 或者 checked 需要通过 [] 的形式添加
    } else if (propName === "value" || propName === "checked") {
      element[propName] = newPropsValue
      // 刨除 children 因为它是子元素 不是属性
    } else if (propName !== "children") {
      // className 属性单独处理 不直接在元素上添加 class 属性是因为 class 是 JavaScript 中的关键字
      if (propName === "className") {
        element.setAttribute("class", newPropsValue)
      } else {
        // 普通属性
        element.setAttribute(propName, newPropsValue)
      }
    }
  })
}

渲染函数组件


下面我们开始渲染 React 组件相关的逻辑编写,在转化 Virtual DOM 对象时需要区分组件和普通虚拟 DOM。在 React 组件中,又有函数组件和类组件,我们先从函数组件开始。

首先,来一个函数组件的例子:

// 原始组件
const Heart = () => <span>&hearts;</span>
<Heart />

这个例子的 Virtual DOM 对象是:

// 组件的 Virtual DOM
{
  type: f function() {},
  props: {}
  children: []
}

可以看到组件的 Virtual DOM 对象的 type 属性是 function,我们可以通过它来区分组件和普通虚拟 DOM。

mountElement

在 mountElement 方法中,通过 isFunction 方法判断是否为组件,如果是,则通过 mountComponent 方法进行组件渲染。

export default function mountElement(virtualDOM, container) {
  // 无论是类组件还是函数组件 其实本质上都是函数 
  // 如果 Virtual DOM 的 type 属性值为函数 就说明当前这个 Virtual DOM 为组件
  if (isFunction(virtualDOM)) {
    // 如果是组件 调用 mountComponent 方法进行组件渲染
    mountComponent(virtualDOM, container)
  } else {
    mountNativeElement(virtualDOM, container)
  }
}

isFunction

在 isFunction 方法中,通过 Virtual DOM 对象的 type 属性是否为 function来判断是否为组件。

export function isFunction(virtualDOM) {
  return virtualDOM && typeof virtualDOM.type === "function"
}

mountComponent

在 mountComponent 方法中,通过 isFunctionalComponent 方法来判断是函数组件还是类组件,这里我们先只考虑函数组件,如果是函数组件则通过 buildFunctionalComponent 方法处理函数组件,然后判断该方法返回的 Virtual DOM 对象是否为组件,如果是组件继续调用 mountComponent 解剖组件,否则使用 mountNativeElement 去渲染 DOM。

import mountNativeElement from "./mountNativeElement"

export default function mountComponent(virtualDOM, container) {
  // 存放组件调用后返回的 Virtual DOM 的容器
  let nextVirtualDOM = null
  // 区分函数型组件和类组件
  if (isFunctionalComponent(virtualDOM)) {
    // 函数组件 调用 buildFunctionalComponent 方法处理函数组件
    nextVirtualDOM = buildFunctionalComponent(virtualDOM)
  } else {
    // 类组件
  }
  // 判断得到的 Virtual Dom 是否是组件
  if (isFunction(nextVirtualDOM)) {
    // 如果是组件 继续调用 mountComponent 解剖组件
    mountComponent(nextVirtualDOM, container)
  } else {
    // 如果是 Navtive Element 就去渲染
    mountNativeElement(nextVirtualDOM, container)
  }
}

isFunctionalComponent

在 isFunctionalComponent 方法中,如果 Virtual DOM 对象的 type 属性是 function,同时函数的原型对象中没有有 render 方法,那么就判定这是个函数组件

// 条件有两个: 1. Virtual DOM 的 type 属性值为函数 2. 函数的原型对象中不能有render方法
// 只有类组件的原型对象中有render方法 
export function isFunctionalComponent(virtualDOM) {
  const type = virtualDOM && virtualDOM.type
  return (
    type && isFunction(virtualDOM) && !(type.prototype && type.prototype.render)
  )
}

buildFunctionalComponent

在 buildFunctionalComponent 方法中,通过调用函数组件的 type 属性对应的 function 获取该组件对应的 Virtual DOM 对象。

在调用 type 属性对应的 function时,需要将函数组件的 props 属性作为参数传入。

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

好了,这样函数组件的渲染逻辑就完成了。

渲染类组件


类组件的渲染逻辑和函数组件差不多,在确定当前要渲染的组件为类组件以后,需要实例化类组件得到类组件实例对象,通过类组件实例对象调用类组件中的 render 方法,获取组件要渲染的 Virtual DOM。

mountComponent

在 mountComponent 方法中通过调用 buildStatefulComponent 方法得到类组件要渲染的 Virtual DOM。

export default function mountComponent(virtualDOM, container) {
  let nextVirtualDOM = null
  // 区分函数型组件和类组件
  if (isFunctionalComponent(virtualDOM)) {
    // 函数组件
    nextVirtualDOM = buildFunctionalComponent(virtualDOM)
  } else {
    // 类组件
    nextVirtualDOM = buildStatefulComponent(virtualDOM)
  }
  // 判断得到的 Virtual Dom 是否是组件
  if (isFunction(nextVirtualDOM)) {
    mountComponent(nextVirtualDOM, container)
  } else {
    mountNativeElement(nextVirtualDOM, container)
  }
}

buildStatefulComponent

在 buildStatefulComponent 方法中得到类组件实例对象 并将 props 属性传递进类组件,然后调用类组件中的render方法得到要渲染的 Virtual DOM

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

Virtual DOM 比对


上面我们已经完成了初次加载时 Virtual DOM 的渲染逻辑,那么接下来我们来考虑当 Virtual DOM 发生更新时,如何对 Virtual DOM 进行更新。

更新 Virtual DOM 毫无疑问需要对比更新后的 Virtual DOM 和更新前的 Virtual DOM,更新后的 Virtual DOM 目前我们可以通过 render 方法进行传递,现在的问题是更新前的 Virtual DOM 要如何获取呢?

createDOMElement

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

export default function createDOMElement(virtualDOM) {
    // ...其它内容
    
    // 将VNode添加到DOM节点上
    newElement._virtualDOM = virtualDOM
}

当 Virtual DOM 类型相同

我们先来考虑当 Virtual DOM 类型相同情况,在这种情况下可以对已经生成的节点进行复用,然后修改更新后的内容。如果是元素节点,就对比元素节点属性是否发生变化,如果是文本节点就对比文本节点内容是否发生变化。

diff


要实现对比,需要先从已存在 DOM 对象中获取其对应的 Virtual DOM 对象。

在 diff.js 文件中,获取旧 DOM节点,判断 oldVirtualDOM 是否存在, 如果存在则继续判断要对比的 Virtual DOM 类型是否相同,如果类型相同判断节点类型是否是文本,如果是文本节点对比,就调用 updateTextNode 方法,如果是元素节点对比就调用 updateNodeElement 方法,上层元素对比完成以后还需要递归对比子元素。

export default function diff(virtualDOM, container, oldDOM) {
    // 如果是初次加载,即使在Root节点中有子节点,子节点上也没有_virtualDOM属性
    const oldVirtualDOM = oldDOM && oldDOM._virtualDOM
    // 判断 oldDOM 是否存在,如果存在说明是更新操作,不存在说明是初次加载
    if (!oldDOM) {
        // 将虚拟DOM节点转化为真实DOM,并挂载到父节点上
        mountElement(virtualDOM, container)
    } else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
        // 如果新旧节点的类型相同
        if (virtualDOM.type === "text") {
            // 更新内容
            updateTextNode(virtualDOM, oldVirtualDOM, oldDOM)
        } else {
            // 更新元素属性
            updateNodeElement(oldDOM, virtualDOM, oldVirtualDOM)
        }
        // 递归对比 Virtual DOM 的子元素
        virtualDOM.children.forEach((child, i) => {
          diff(child, oldDOM, oldDOM.childNodes[i])
        })
    }
}

updateTextNode


updateTextNode 方法用于对比文本节点内容是否发生变化,如果发生变化则更新真实 DOM 对象中的内容,既然真实 DOM 对象发生了变化,还要将最新的 Virtual DOM 同步给真实 DOM 对象。

function updateTextNode(virtualDOM, oldVirtualDOM, oldDOM) {
  // 如果文本节点内容不同
  if (virtualDOM.props.textContent !== oldVirtualDOM.props.textContent) {
    // 更新真实 DOM 对象中的内容
    oldDOM.textContent = virtualDOM.props.textContent
  }
  // 同步真实 DOM 对应的 Virtual DOM
  oldDOM._virtualDOM = virtualDOM
}

updateNodeElement


updateNodeElement 方法用于对比元素节点是否发生变化,思路是先分别获取更新后的和更新前的 Virtual DOM 中的 props 属性,循环新 Virtual DOM 中的 props 属性,通过对比看一下新 Virtual DOM 中的属性值是否发生了变化,如果发生变化 需要将变化的值更新到真实 DOM 对象中。

再循环未更新前的 Virtual DOM 对象,通过对比看看新的 Virtual DOM 中是否有被删除的属性,如果存在删除的属性 需要将 DOM 对象中对应的属性也删除掉。

// updateNodeElement.js
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) {
      // 判断属性是否是否事件属性 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)
      }
    }
  })
}

当 Virtual DOM 类型不同

当对比的元素节点类型不同时,就不需要继续对比了,直接使用新的 Virtual DOM 创建 DOM 对象,用新的 DOM 对象直接替换旧的 DOM 对象。当前这种情况要将组件刨除,组件要被单独处理。

diff


export default function diff(virtualDOM, container, oldDOM) {
    // 如果是初次加载,即使在Root节点中有子节点,子节点上也没有_virtualDOM属性
    const oldVirtualDOM = oldDOM && oldDOM._virtualDOM
    // 判断 oldDOM 是否存在,如果存在说明是更新操作,不存在说明是初次加载
    if (!oldDOM) {
        // 将虚拟DOM节点转化为真实DOM,并挂载到父节点上
        mountElement(virtualDOM, container)
    } else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
        // 如果新旧节点的类型相同
        // ...
    } else else if (
      // 如果 Virtual DOM 类型不一样
      virtualDOM.type !== oldVirtualDOM.type &&
      // 并且 Virtual DOM 不是组件 因为组件要单独进行处理
      typeof virtualDOM.type !== "function"
    ) {
      // 根据 Virtual DOM 创建真实 DOM 元素
      const newDOMElement = createDOMElement(virtualDOM)
      // 用创建出来的真实 DOM 元素 替换旧的 DOM 元素
      oldDOM.parentNode.replaceChild(newDOMElement, oldDOM)
    } 
}

删除节点

下面我们来删除节点的逻辑,删除节点发生在节点更新以后并且发生在同一个父节点下的所有子节点身上。在节点更新完成以后,如果旧节点对象的数量多于新 VirtualDOM 节点的数量,就说明有节点需要被删除。

diff


在 diff 方法中,如果旧节点的数量多于要渲染的新节点的长度,则删除多余节点。

export default function diff(virtualDOM, container, oldDOM) {
    if (!oldDOM) {
        // ...
    } else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
        // 如果新旧节点的类型相同
        // ...
    } else else if (
      // 如果 Virtual DOM 类型不一样
      virtualDOM.type !== oldVirtualDOM.type &&
      // 并且 Virtual DOM 不是组件 因为组件要单独进行处理
      typeof virtualDOM.type !== "function"
    ) {
        // ...
        // 获取就节点的数量
        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])
          }
        }
    } 
}

类组件状态更新

下面来看类组件的状态更新,类组件的状态更新是通过 setState 方法实现更新,所以我们先实现 setState 方法,setState 方法是定义在父类 Component 中的,该方法的作用是更改子类的 state,产生一个全新的 state 对象。

Component


先来看 Component 的实现,在这个 Class 中定义 setState 方法。当组件的 state 对象发生更改时,要调用 render 方法更新组件视图。

在更新组件之前,要使用更新的 Virtual DOM 对象和未更新的 Virtual DOM 进行对比找出更新的部分,达到 DOM 最小化操作的目的。

在 setState 方法中可以通过调用 this.render 方法获取更新后的 Virtual DOM,由于 setState 方法被子类调用,this 指向子类,所以此处调用的是子类的 render 方法。

要实现对比,还需要获取未更新前的 Virtual DOM,按照之前的经验,我们可以从 DOM 对象中获取其对应的 Virtual DOM 对象,未更新前的 DOM 对象实际上就是现在在页面中显示的 DOM 对象,我们只要能获取到这个 DOM 对象就可以获取到其对应的 Virtual DOM 对象了。

页面中的 DOM 对象要怎样获取呢?页面中的 DOM 对象是通过 mountNativeElement 方法挂载到页面中的,所以我们只需要在这个方法中调用 Component 类中的方法就可以将 DOM 对象保存在 Component 类中了。在子类调用 setState 方法的时候,在 setState 方法中再调用另一个获取 DOM 对象的方法就可以获取到之前保存的 DOM 对象了。

import diff from "./diff"

export default class Component {
    constructor(props) {
        this.props = props
    }
    setState(state) {
        this.state = Object.assign({}, this.state, state)
        // 获取最新的要渲染的 virtualDOM 对象
        let virtualDOM = this.render()
        // 获取旧的 virtualDOM 对象,进行比对
        let oldDOM = this.getDOM()
        // 获取容器
        let container = oldDOM.parentNode
        // 实现对比
        diff(virtualDOM, container, oldDOM)
    }
    // 在 mountNativeElement 方法中获取组件实例对象,通过实例调用调用 setDOM 方法保存 DOM 对象
    setDOM(dom) {
        this._dom = dom
    }
    getDOM() {
        return this._dom
    }
}

mountNativeElement


接下来我们要研究一下在 mountNativeElement 方法中如何才能调用到 setDOM 方法,要调用 setDOM 方法,必须要得到类的实例对象,所以目前的问题就是如何在 mountNativeElement 方法中得到类的实例对象,这个类指的不是Component类,因为我们在代码中并不是直接实例化的Component类,而是实例化的它的子类,由于子类继承了父类,所以在子类的实例对象中也是可以调用到 setDOM 方法的。

mountNativeElement 方法接收最新的 Virtual DOM 对象,如果这个 Virtual DOM 对象是类组件产生的,在产生这个 Virtual DOM 对象时一定会先得到这个类的实例对象,然后再调用实例对象下面的 render 方法进行获取。我们可以在那个时候将类组件实例对象添加到 Virtual DOM 对象的属性中,而这个 Virtual DOM 对象最终会传递给 mountNativeElement 方法,这样我们就可以在 mountNativeElement 方法中获取到组件的实例对象了,既然类组件的实例对象获取到了,我们就可以调用 setDOM 方法了。

export default function mountNativeElement(virtualDOM, container, oldDOM) {
    // ...

    // 获取组件的实例对象,在mountComponent.js中挂载的该属性
    let component = virtualDOM.component
    if (component) {
        component.setDOM(newElement)
    }
}

mountComponent 中的 buildClassComponent 方法


在 buildClassComponent 方法中为 Virtual DOM 对象添加 component 属性, 值为类组件的实例对象。

function buildClassComponent(virtualDOM) {
  const component = new virtualDOM.type(virtualDOM.props)
  const nextVirtualDOM = component.render()
  // 获取组件的实例对象
  nextVirtualDOM.component = component
  return nextVirtualDOM
}

这样类组件的状态更新我们就实现了。

组件更新

上面我们实现了类组件的状态更新,现在我们来实现将状态更新后组件渲染到页面上的逻辑。

diff


在 diff 方法中判断要更新的 Virtual DOM 是否是组件,如果是,则调用 diffComponent 方法更新组件。

export default function diff(virtualDOM, container, oldDOM) {
    if (!oldDOM) {
        // ...
    } else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
        // 如果新旧节点的类型相同
        // ...
    } else else if (
      // 如果 Virtual DOM 类型不一样
      virtualDOM.type !== oldVirtualDOM.type &&
      // 并且 Virtual DOM 不是组件 因为组件要单独进行处理
      typeof virtualDOM.type !== "function"
    ) {
        // ...
    } else if (typeof virtualDOM.type === "function") {
        // 如果新旧节点都是组件
        diffComponent(virtualDOM, oldComponent, oldDOM, container)
    }
}

diffComponent


在 diffComponent 方法中,使用 isSameComponent 方法判断要更新的组件和未更新前的组件是否是同一个组件,如果不是同一个组件就不需要做组件更新操作,直接调用 mountElement 方法将组件返回的 Virtual DOM 添加到页面中,否则调用 updateComponent 方法更新组件。

import mountElement from "./mountElement"
import updateComponent from "./updateComponent"

// 判断是否是同一个类组件
function isSameComponent(virtualDOM, oldComponent) {
    return oldComponent && virtualDOM.type === oldComponent.constructor
}

export default function diffComponent(virtualDOM, oldComponent, oldDOM, container) {
    if (isSameComponent(virtualDOM, oldComponent)) {
        // 同一个组件,做组件更新操作
        updateComponent(virtualDOM, oldComponent, oldDOM, container)
    } else {
        // 不是同一个组件
        mountElement(virtualDOM, container, oldDOM)
    }
}

updateComponent


在 updateComponent 方法中,将最新的 props 传递到组件中,再调用组件的render方法获取组件返回的最新的 Virtual DOM 对象,再将 Virtual DOM 对象传递给 diff 方法,让 diff 方法找出差异,从而将差异更新到真实 DOM 对象中。

import diff from "./diff"

export default function updateComponent(virtualDOM, oldComponent, oldDOM, container) {
    // 未更前前的props
    let prevProps = oldComponent.props
    // 组件更新
    oldComponent.updateProps(virtualDOM.props)
    let nextVirtualDOM = oldComponent.render()
    nextVirtualDOM.component = oldComponent
    diff(nextVirtualDOM, container, oldDOM)
}

mountNativeElement


在 mountNativeElement 方法中,需要使用 unmountNode 方法删除原有的旧 DOM。

export default function mountNativeElement(virtualDOM, container, oldDOM) {
    let newElement = createDOMElement(virtualDOM)
    if (oldDOM) {
          // 判断旧的DOM对象是否存在,如果存在,将新DOM插入到旧DOM之前,再删除旧DOM
        container.insertBefore(newElement, oldDOM)
        unmountNode(oldDOM)
    } else {
        // 将真实DOM元素放在父元素中
        container.appendChild(newElement)
    }
  

    // 获取组件的实例对象,在mountComponent.js中挂载的该属性
    let component = virtualDOM.component

    if (component) {
        component.setDOM(newElement)
    }
}

unmountNode


在 unmountNode 方法中,判断节点类型,如果是文本节点可以直接删除。否则要节点的属性中是否有事件属性,然后递归删除子节点,最后再删除节点。

export default function unmountNode(node) {
    // 获取节点的 _virtualDOM 对象
    const virtualDOM = node._virtualDOM
    // 1. 文本节点可以直接删除
    if (virtualDOM.type === "text") {
      // 删除直接
      node.remove()
      // 阻止程序向下执行
      return
    }
    // 2. 看一下节点的属性中是否有事件属性
    Object.keys(virtualDOM.props).forEach(propName => {
      if (propName.slice(0, 2) === "on") {
        const eventName = propName.toLowerCase().slice(0, 2)
        const eventHandler = virtualDOM.props[propName]
        node.removeEventListener(eventName, eventHandler)
      }
    })
  
    // 3. 递归删除子节点
    if (node.childNodes.length > 0) {
      for (let i = 0; i < node.childNodes.length; i++) {
        unmountNode(node.childNodes[i])
        i--
      }
    }
    // 删除节点
    node.remove()
}

总结


好了,这样我们就实现了一个简单的 React 框架,当然这其中并没有包括 React 中非常重要的 Fiber 结构,就让我们下篇来详细讲解一下 Fiber 结构吧。