当前位置:Gxlcms > JavaScript > 虚拟dom原理流程的分析与实现

虚拟dom原理流程的分析与实现

时间:2021-07-01 10:21:17 帮助过:6人阅读

本篇文章给大家带来的内容是关于虚拟dom原理流程的分析与实现,有一定的参考价值,有需要的朋友可以参考一下,希望对你有所帮助。

背景

大家都知道,在网页中浏览器资源开销最大便是DOM节点了,DOM很慢并且非常庞大,网页性能问题大多数都是有JavaScript修改DOM所引起的。我们使用Javascript来操纵DOM,操作效率往往很低,由于DOM被表示为树结构,每次DOM中的某些内容都会发生变化,因此对DOM的更改非常快,但更改后的元素,并且它的子项必须经过Reflow / Layout阶段,然后浏览器必须重新绘制更改,这很慢的。因此,回流/重绘的次数越多,您的应用程序就越卡顿。但是,Javascript运行速度很快,虚拟DOM是放在JS 和 HTML中间的一个层。它可以通过新旧DOM的对比,来获取对比之后的差异对象,然后有针对性的把差异部分真正地渲染到页面上,从而减少实际DOM操作,最终达到性能优化的目的。

虚拟dom原理流程

简单概括有三点:

  1. 用JavaScript模拟DOM树,并渲染这个DOM树

  2. 比较新老DOM树,得到比较的差异对象

  3. 把差异对象应用到渲染的DOM树。

下面是流程图:

1749373634-5bbec7416c02f_articlex.png

下面我们用代码一步步去实现一个流程图

用JavaScript模拟DOM树并渲染到页面上

其实虚拟DOM,就是用JS对象结构的一种映射,下面我们一步步实现这个过程。

我们用JS很容易模拟一个DOM树的结构,例如用这样的一个函数createEl(tagName, props, children)来创建DOM结构。

tagName标签名、props是属性的对象、children是子节点。

然后渲染到页面上,代码如下:

  1. const createEl = (tagName, props, children) => new CreactEl(tagName, props, children)
  2. const vdom = createEl('p', { 'id': 'box' }, [
  3. createEl('h1', { style: 'color: pink' }, ['I am H1']),
  4. createEl('ul', {class: 'list'}, [createEl('li', ['#list1']), createEl('li', ['#list2'])]),
  5. createEl('p', ['I am p'])
  6. ])
  7. const rootnode = vdom.render()
  8. document.body.appendChild(rootnode)

通过上面的函数,调用vdom.render()这样子我们就很好的构建了如下所示的一个DOM树,然后渲染到页面上

  1. <div id="box">
  2. <h1 style="color: pink;">I am H1</h1>
  3. <ul class="list">
  4. <li>#list1</li>
  5. <li>#list2</li>
  6. </ul>
  7. <p>I am p</p>
  8. </div>

下面我们看看CreactEl.js代码流程:

  1. import { setAttr } from './utils'
  2. class CreateEl {
  3. constructor (tagName, props, children) {
  4. // 当只有两个参数的时候 例如 celement(el, [123])
  5. if (Array.isArray(props)) {
  6. children = props
  7. props = {}
  8. }
  9. // tagName, props, children数据保存到this对象上
  10. this.tagName = tagName
  11. this.props = props || {}
  12. this.children = children || []
  13. this.key = props ? props.key : undefined
  14. let count = 0
  15. this.children.forEach(child => {
  16. if (child instanceof CreateEl) {
  17. count += child.count
  18. } else {
  19. child = '' + child
  20. }
  21. count++
  22. })
  23. // 给每一个节点设置一个count
  24. this.count = count
  25. }
  26. // 构建一个 dom 树
  27. render () {
  28. // 创建dom
  29. const el = document.createElement(this.tagName)
  30. const props = this.props
  31. // 循环所有属性,然后设置属性
  32. for (let [key, val] of Object.entries(props)) {
  33. setAttr(el, key, val)
  34. }
  35. this.children.forEach(child => {
  36. // 递归循环 构建tree
  37. let childEl = (child instanceof CreateEl) ? child.render() : document.createTextNode(child)
  38. el.appendChild(childEl)
  39. })
  40. return el
  41. }
  42. }

上面render函数的功能是把节点创建好,然后设置节点属性,最后递归创建。这样子我们就得到一个DOM树,然后插入(appendChild)到页面上。

比较新老dom树,得到比较的差异对象

上面,我们已经创建了一个DOM树,然后在创建一个不同的DOM树,然后做比较,得到比较的差异对象。

比较两棵DOM树的差异,是虚拟DOM的最核心部分,这也是人们常说的虚拟DOM的diff算法,两颗完全的树差异比较一个时间复杂度为 O(n^3)。但是在我们的web中很少用到跨层级DOM树的比较,所以一个层级跟一个层级对比,这样算法复杂度就可以达到 O(n)。如下图

2911090658-5bbec77207936_articlex.png

其实在代码中,我们会从根节点开始标志遍历,遍历的时候把每个节点的差异(包括文本不同,属性不同,节点不同)记录保存起来。如下图:

1716867079-5bbec77e85bd1_articlex.png

两个节点之间的差异有总结起来有下面4种

  1. 0 直接替换原有节点
  2. 1 调整子节点,包括移动、删除等
  3. 2 修改节点属性
  4. 3 修改节点文本内容

如下面两棵树比较,把差异记录下来。

502563988-5bbec78e08001_articlex.png

主要是简历一个遍历index(看图3),然后从根节点开始比较,比较万之后记录差异对象,继续从左子树比较,记录差异,一直遍历下去。主要流程如下

  1. // 这是比较两个树找到最小移动量的算法是Levenshtein距离,即O(n * m)
  2. // 具体请看 https://www.npmjs.com/package/list-diff2
  3. import listDiff from 'list-diff2'
  4. // 比较两棵树
  5. function diff (oldTree, newTree) {
  6. // 节点的遍历顺序
  7. let index = 0
  8. // 在遍历过程中记录节点的差异
  9. let patches = {}
  10. // 深度优先遍历两棵树
  11. deepTraversal(oldTree, newTree, index, patches)
  12. // 得到的差异对象返回出去
  13. return patches
  14. }
  15. function deepTraversal(oldNode, newNode, index, patches) {
  16. let currentPatch = []
  17. // ...中间有很多对patches的处理
  18. // 递归比较子节点是否相同
  19. diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  20. if (currentPatch.length) {
  21. // 那个index节点的差异记录下来
  22. patches[index] = currentPatch
  23. }
  24. }
  25. // 子数的diff
  26. function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  27. const diffs = listDiff(oldChildren, newChildren)
  28. newChildren = diffs.children
  29. // ...省略记录差异对象
  30. let leftNode = null
  31. let currentNodeIndex = index
  32. oldChildren.forEach((child, i) => {
  33. const newChild = newChildren[i]
  34. // index相加
  35. currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1
  36. // 深度遍历,递归
  37. deepTraversal(child, newChild, currentNodeIndex, patches)
  38. // 从左树开始
  39. leftNode = child
  40. })
  41. }

然后我们调用完diff(tree, newTree)等到最后的差异对象是这样子的。

  1. {
  2. "1": [
  3. {
  4. "type": 0,
  5. "node": {
  6. "tagName": "h3",
  7. "props": {
  8. "style": "color: green"
  9. },
  10. "children": [
  11. "I am H1"
  12. ],
  13. "count": 1
  14. }
  15. }
  16. ]
  17. ...
  18. }

key是代表那个节点,这里我们是第二个,也就是h1会改变成h3,还有省略的两个差异对象代码没有贴出来~~

然后看下diff.js的完整代码,如下

  1. import listDiff from 'list-diff2'
  2. // 每个节点有四种变动
  3. export const REPLACE = 0 // 替换原有节点
  4. export const REORDER = 1 // 调整子节点,包括移动、删除等
  5. export const PROPS = 2 // 修改节点属性
  6. export const TEXT = 3 // 修改节点文本内容
  7. export function diff (oldTree, newTree) {
  8. // 节点的遍历顺序
  9. let index = 0
  10. // 在遍历过程中记录节点的差异
  11. let patches = {}
  12. // 深度优先遍历两棵树
  13. deepTraversal(oldTree, newTree, index, patches)
  14. // 得到的差异对象返回出去
  15. return patches
  16. }
  17. function deepTraversal(oldNode, newNode, index, patches) {
  18. let currentPatch = []
  19. if (newNode === null) { // 如果新节点没有的话直接不用比较了
  20. return
  21. }
  22. if (typeof oldNode === 'string' && typeof newNode === 'string') {
  23. // 比较文本节点
  24. if (oldNode !== newNode) {
  25. currentPatch.push({
  26. type: TEXT,
  27. content: newNode
  28. })
  29. }
  30. } else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
  31. // 节点类型相同
  32. // 比较节点的属性是否相同
  33. let propasPatches = diffProps(oldNode, newNode)
  34. if (propasPatches) {
  35. currentPatch.push({
  36. type: PROPS,
  37. props: propsPatches
  38. })
  39. }
  40. // 递归比较子节点是否相同
  41. diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  42. } else {
  43. // 节点不一样,直接替换
  44. currentPatch.push({ type: REPLACE, node: newNode })
  45. }
  46. if (currentPatch.length) {
  47. // 那个index节点的差异记录下来
  48. patches[index] = currentPatch
  49. }
  50. }
  51. // 子数的diff
  52. function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  53. var diffs = listDiff(oldChildren, newChildren)
  54. newChildren = diffs.children
  55. // 如果调整子节点,包括移动、删除等的话
  56. if (diffs.moves.length) {
  57. var reorderPatch = {
  58. type: REORDER,
  59. moves: diffs.moves
  60. }
  61. currentPatch.push(reorderPatch)
  62. }
  63. var leftNode = null
  64. var currentNodeIndex = index
  65. oldChildren.forEach((child, i) => {
  66. var newChild = newChildren[i]
  67. // index相加
  68. currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1
  69. // 深度遍历,从左树开始
  70. deepTraversal(child, newChild, currentNodeIndex, patches)
  71. // 从左树开始
  72. leftNode = child
  73. })
  74. }
  75. // 记录属性的差异
  76. function diffProps (oldNode, newNode) {
  77. let count = 0 // 声明一个有没没有属性变更的标志
  78. const oldProps = oldNode.props
  79. const newProps = newNode.props
  80. const propsPatches = {}
  81. // 找出不同的属性
  82. for (let [key, val] of Object.entries(oldProps)) {
  83. // 新的不等于旧的
  84. if (newProps[key] !== val) {
  85. count++
  86. propsPatches[key] = newProps[key]
  87. }
  88. }
  89. // 找出新增的属性
  90. for (let [key, val] of Object.entries(newProps)) {
  91. if (!oldProps.hasOwnProperty(key)) {
  92. count++
  93. propsPatches[key] = val
  94. }
  95. }
  96. // 没有新增 也没有不同的属性 直接返回null
  97. if (count === 0) {
  98. return null
  99. }
  100. return propsPatches
  101. }

得到差异对象之后,剩下就是把差异对象应用到我们的dom节点上面了。

把差异对象应用到渲染的dom树

到了这里其实就简单多了。我们上面得到的差异对象之后,然后选择同样的深度遍历,如果那个节点有差异的话,判断是上面4种中的哪一种,根据差异对象直接修改这个节点就可以了。

  1. function patch (node, patches) {
  2. // 也是从0开始
  3. const step = {
  4. index: 0
  5. }
  6. // 深度遍历
  7. deepTraversal(node, step, patches)
  8. }
  9. // 深度优先遍历dom结构
  10. function deepTraversal(node, step, patches) {
  11. // 拿到当前差异对象
  12. const currentPatches = patches[step.index]
  13. const len = node.childNodes ? node.childNodes.length : 0
  14. for (let i = 0; i < len; i++) {
  15. const child = node.childNodes[i]
  16. step.index++
  17. deepTraversal(child, step, patches)
  18. }
  19. //如果当前节点存在差异
  20. if (currentPatches) {
  21. // 把差异对象应用到当前节点上
  22. applyPatches(node, currentPatches)
  23. }
  24. }

这样子,调用patch(rootnode, patches)就直接有针对性的改变有差异的节点了。

path.js完整代码如下:

  1. import {REPLACE, REORDER, PROPS, TEXT} from './diff'
  2. import { setAttr } from './utils'
  3. export function patch (node, patches) {
  4. // 也是从0开始
  5. const step = {
  6. index: 0
  7. }
  8. // 深度遍历
  9. deepTraversal(node, step, patches)
  10. }
  11. // 深度优先遍历dom结构
  12. function deepTraversal(node, step, patches) {
  13. // 拿到当前差异对象
  14. const currentPatches = patches[step.index]
  15. const len = node.childNodes ? node.childNodes.length : 0
  16. for (let i = 0; i < len; i++) {
  17. const child = node.childNodes[i]
  18. step.index++
  19. deepTraversal(child, step, patches)
  20. }
  21. //如果当前节点存在差异
  22. if (currentPatches) {
  23. // 把差异对象应用到当前节点上
  24. applyPatches(node, currentPatches)
  25. }
  26. }
  27. // 把差异对象应用到当前节点上
  28. function applyPatches(node, currentPatches) {
  29. currentPatches.forEach(currentPatch => {
  30. switch (currentPatch.type) {
  31. // 0: 替换原有节点
  32. case REPLACE:
  33. var newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render()
  34. node.parentNode.replaceChild(newNode, node)
  35. break
  36. // 1: 调整子节点,包括移动、删除等
  37. case REORDER:
  38. moveChildren(node, currentPatch.moves)
  39. break
  40. // 2: 修改节点属性
  41. case PROPS:
  42. for (let [key, val] of Object.entries(currentPatch.props)) {
  43. if (val === undefined) {
  44. node.removeAttribute(key)
  45. } else {
  46. setAttr(node, key, val)
  47. }
  48. }
  49. break;
  50. // 3:修改节点文本内容
  51. case TEXT:
  52. if (node.textContent) {
  53. node.textContent = currentPatch.content
  54. } else {
  55. node.nodeValue = currentPatch.content
  56. }
  57. break;
  58. default:
  59. throw new Error('Unknow patch type ' + currentPatch.type);
  60. }
  61. })
  62. }
  63. // 调整子节点,包括移动、删除等
  64. function moveChildren (node, moves) {
  65. let staticNodelist = Array.from(node.childNodes)
  66. const maps = {}
  67. staticNodelist.forEach(node => {
  68. if (node.nodeType === 1) {
  69. const key = node.getAttribute('key')
  70. if (key) {
  71. maps[key] = node
  72. }
  73. }
  74. })
  75. moves.forEach(move => {
  76. const index = move.index
  77. if (move.type === 0) { // 变动类型为删除的节点
  78. if (staticNodeList[index] === node.childNodes[index]) {
  79. node.removeChild(node.childNodes[index]);
  80. }
  81. staticNodeList.splice(index, 1);
  82. } else {
  83. let insertNode = maps[move.item.key]
  84. ? maps[move.item.key] : (typeof move.item === 'object')
  85. ? move.item.render() : document.createTextNode(move.item)
  86. staticNodelist.splice(index, 0, insertNode);
  87. node.insertBefore(insertNode, node.childNodes[index] || null)
  88. }
  89. })
  90. }

到这里,最基本的虚拟DOM原理已经讲完了,也简单了实现了一个虚拟DOM.

以上就是虚拟dom原理流程的分析与实现的详细内容,更多请关注Gxl网其它相关文章!

人气教程排行