React-我们村刚通网之虚拟 DOM(一)

4,212 阅读5分钟

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

标题灵感来源评论区:

image.png

一、什么是虚拟 DOM

什么是虚拟 DOM?简单来说,虚拟 DOM 就是一个模拟真实 DOM 的树形结构,这个树结构包含了整个 DOM 结构的信息。

正常我们看到的真实 DOM 是这样的:

image.png

而虚拟 DOM 则是这样的,包含了标签名称、标签属性、子节点等真实 DOM 信息:

image.png

二、为什么使用虚拟 DOM

虚拟 DOM 既然是模拟真实 DOM 的树形结构,那么为什么要用虚拟 DOM 呢?直接操作 DOM 有什么缺点吗?

直接操作 DOM 没有缺点,但是频繁的操作 DOM 就缺点很大,因为操作 DOM 会引起重排,频繁操作 DOM 时,浏览器会频繁重排,导致页面卡顿。

浏览器渲染的大致流程如下:

  1. 解析 HTML 文档,构建 DOM 树;
  2. 解析 CSS 属性,构建 CSSOM 树;
  3. 结合 DOM 树和 CSSOM 树,构建 render 树;
  4. 在 render 树的基础上进行布局, 计算每个节点的几何结构(重排);
  5. 把每个节点绘制在屏幕上(重绘);

image.png

重排(也叫回流、reflow)就是当涉及到 DOM 节点的布局属性发生变化时,就会重新计算该属性,浏览器会重新描绘相应的元素(上述第 4 步)。

DOM Tree 里的每个节点都会有 reflow 方法,一个节点的 reflow 很有可能导致子节点,甚至父点以及同级节点的 reflow。

因此,为了提升性能,我们应该尽量减少 DOM 操作。

1. 减少 DOM 操作

当有一个表格需要做排序功能时,有出生年月、性别等排序方式可选,当选择某排序方式时,表格将按该方式重新排序。

  • 真实 DOM:排序操作需要将表格内的所有 DOM 树删除后新建;
  • 虚拟 DOM:使用 diff 算法得到需要修改的部分,仅更新需要发生修改的 DOM 节点;

从上可知,虚拟 DOM 通过 diff 算法,帮助我们大量的减少 DOM 操作。

2. 函数式的 UI 编程方式

从另一个角度看,虚拟 DOM 为我们提供了函数式的编程方式,使代码可读性和可维护性更高。

image.png

三、虚拟 DOM 的实现原理

注:该章节的虚拟 DOM 实现原理并不是参比 React 源码,而是参比 simple-virtual-dom,可通过该章节简单了解虚拟 DOM 实现原理,React 中的虚拟 DOM 实现可查看 React 官网 Virtual DOM 及内核

虚拟 DOM 通过以下步骤实现:

  1. 构建虚拟 DOM 树;
  2. 比较新旧虚拟 DOM 树差异;
  3. 更新真实 DOM;

1. 构建虚拟 DOM

模拟真实 DOM 树,构建虚拟 DOM 树结构,包含标签名 tagName、属性对象 props、子节点 children、子节点数 count 等属性。

function Element (tagName, props = {}, children = []) {
  // 标签名
  this.tagName = tagName
  // 属性对象
  this.props = props
  // 子节点
  this.children = children
  // key标志
  const { key = void 666 } = this.props
  this.key = key

  // 子节点数量
  let count = 0
  this.children.forEach((child, index) => {
    if (child instanceof Element) {
      count += child.count
    }
    count++
  })
  this.count = count
}

创建虚拟 DOM 对象:

console.log(el('div', {'id': 'container'}, [
  el('h1', {style: 'color: red'}, ['simple virtal dom'])
  ]))

生成的虚拟 DOM 对象如图:

image.png

将虚拟 DOM 转换为真实 DOM:

Element.prototype.render = function () {
  const el = document.createElement(this.tagName)
  const props = this.props

  for (const propName in props) {
    const propValue = props[propName]
    _.setAttr(el, propName, propValue)
  }

  this.children.forEach((child) => {
    let childEl

    if (child instanceof Element) {
      childEl = child.render()
    } else {
      childEl = document.createTextNode(child)
    }
    el.appendChild(childEl)
  })

  return el
}

填充进页面:

document.body.appendChild(el('div', {'id': 'container'}, [
  el('h1', {style: 'color: red'}, ['simple virtal dom'])
  ]).render())

效果如图:

image.png

2. 比较两棵虚拟 DOM 树的差异

当数据更新时,需要对新旧虚拟 DOM 树进行对比。

  1. 当新旧节点都是字符串类型时,直接替换;
if (_.isString(oldNode) && _.isString(newNode)) {
    if (newNode !== oldNode) {
      currentPatch.push({ type: patch.TEXT, content: newNode })
    }
  // Nodes are the same, diff old node's props and children
  }
  1. 当新旧节点的标签名、key 值相等时,对比属性 Props 以及子节点 children;
if (
  oldNode.tagName === newNode.tagName &&
  oldNode.key === newNode.key
) {
    // Diff props
    var propsPatches = diffProps(oldNode, newNode)
    if (propsPatches) {
      currentPatch.push({ type: patch.PROPS, props: propsPatches })
    }
    // Diff children. If the node has a `ignore` property, do not diff children
    if (!isIgnoreChildren(newNode)) {
      diffChildren(
        oldNode.children,
        newNode.children,
        index,
        patches,
        currentPatch
      )
    }
}
  1. 如果新节点存在,且和旧节点标签名不同,或者 key 不同,则直接将新节点替换为旧节点。
currentPatch.push({
    type: PATCH_KEY.REPLACE, 
    node: newNode
})

总结一下,虚拟 DOM 只在同层级间 Diff,如果标签不同则直接替换该节点及其子节点。

尝试对比虚拟 DOM 如下:

function renderTree () {
  return el('div', {'id': 'container'}, [
          el('h1', {style: 'color: red'}, ['simple virtal dom']),
          el('p', ['the count is :' + Math.random()])
        ])
}

let tree = renderTree()

setTimeout(() => {
    const newTree = renderTree()
    const patches = diff(tree, newTree)
    console.log(patches)
}, 2000)

对比差异为 p 标签的文本节点发生改变,输出结果如图:

image.png

3. 对真实 DOM 进行最小化修改

最后一步是根据 diff 结果,对真实 DOM 进行修改。

遍历真实 DOM 树,如果该 DOM 节点有 diff,则根据 diff 类型,处理 DOM 节点,如果该 DOM 节点无 diff,则遍历其子节点,直至遍历完成。

注:React 实现更优,具体请见 React fiber

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
  var currentPatches = patches[walker.index]

  var len = node.childNodes
    ? node.childNodes.length
    : 0
  for (var i = 0; i < len; i++) {
    var child = node.childNodes[i]
    walker.index++
    dfsWalk(child, walker, patches)
  }

  if (currentPatches) {
    applyPatches(node, currentPatches)
  }
}

尝试更新真实 DOM,代码如下:

function renderTree () {
  return el('div', {'id': 'container'}, [
          el('h1', {style: 'color: red'}, ['simple virtal dom']),
          el('p', ['the count is :' + Math.random()])
        ])
}

let tree = renderTree()
const root = tree.render()
document.body.appendChild(root)

setTimeout(() => {
  const newTree = renderTree()
  const patches = diff(tree, newTree)
  patch(root, patches)
  tree = newTree
}, 2000)

效果如图:

1.gif

上图可见,成功更新真实 DOM。

四、总结

本文从什么是虚拟 DOM、为什么使用虚拟 DOM、虚拟 DOM 的实现原理等 3 个角度对虚拟 DOM 进行讲述。

虚拟 DOM 通过模拟真实 DOM 的树结构,收集大量 DOM 操作,通过 diff 算法对真实 DOM 进行最小化修改,减少浏览器重排,提升加载速度,达到优化网站性能的作用。

虚拟 DOM 采用函数式编程,让我们码得更好看更快乐。

可通过 github源码 进行实操练习。

希望能对你有所帮助,感谢阅读~

别忘了点个赞鼓励一下我哦,笔芯❤️

参考资料