浅入浅出图解domDIff【实践】(節點虛擬代碼)

admin admin
2024-07-17
作者:寒东设计师转发链接:https://juejin.im/post/5ad550f06fb9a028b4118d99虚拟DOM/domDiff 我们常说的虚拟DOM是通过JS对象模拟出来的DOM节点,domDiff是通过特定算法计算出来一次操作所带来的DOM变化。 react和vue中都使用了虚拟DOM,v……

作者:寒东设计师

转发链接:https://juejin.im/post/5ad550f06fb9a028b4118d99

虚拟DOM/domDiff

我们常说的虚拟DOM是通过JS对象模拟出来的DOM节点,domDiff是通过特定算法计算出来一次操作所带来的DOM变化。
react和vue中都使用了虚拟DOM,vue我只停留在使用层面就不多说了,react了解多一些,就借着react聊聊虚拟DOM。
react中涉及到虚拟DOM的代码主要分为以下三部分,其中核心是第二步的domDiff算法:

浅入浅出图解domDIff【实践】(節點虛擬代碼)
(图片来源网络,侵删)
把render中的JSX(或者createElement这个API)转化成虚拟DOM状态或属性改变后重新计算虚拟DOM并生成一个补丁对象(domDiff)通过这个补丁对象更新视图中的DOM节点虚拟DOM不一定更快

干前端的都知道DOM操作是性能杀手,因为操作DOM会引起页面的回流或者重绘。
相比起来,通过多一些预先计算来减少DOM的操作要划算的多。
但是,“使用虚拟DOM会更快”这句话并不一定适用于所有场景。
例如:一个页面就有一个按钮,点击一下,数字加一,那肯定是直接操作DOM更快。
使用虚拟DOM无非白白增加了计算量和代码量。
即使是复杂情况,浏览器也会对我们的DOM操作进行优化,大部分浏览器会根据我们操作的时间和次数进行批量处理,所以直接操作DOM也未必很慢。
那么为什么现在的框架都使用虚拟DOM呢?因为使用虚拟DOM可以提高代码的性能下限,并极大的优化大量操作DOM时产生的性能损耗。
同时这些框架也保证了,即使在少数虚拟DOM不太给力的场景下,性能也在我们接受的范围内。
而且,我们之所以喜欢react、vue等使用了虚拟DOM框架,不光是因为他们快,还有很多其他更重要的原因。
例如react对函数式编程的友好,vue优秀的开发体验等,目前社区也有好多比较这两个框架并打口水战的,我觉着还是在两个都懂的情况下多探究一下原理更有意义一些。

实现domDiff的思路

实现domDiff分为以下四步:

用JS模拟真实DOM节点把虚拟DOM转换成真实DOM插入页面中发生变化时,比较两棵树的差异,生成差异对象根据差异对象更新真实DOM

设计师的老本行不能忘,看我画张图:

解释一下这张图: 首先看第一个红色色块,这里说的是把真实DOM映射为虚拟DOM,其实在react中没有这个过程,我们直接写的就是虚拟DOM(JSX),只是这个虚拟DOM代表着真实DOM。
当虚拟DOM变化时,例如上图,它的第三个p和第二个p中的son2被删除了。
这个时候我们会根据前后的变化计算出一个差异对象patches。
这个差异对象的key值就是老的DOM节点遍历时的索引,用这个索引我们可以找到那个节点。
属性值是记录的变化,这里是remove,代表删除。
最后,根据patches中每一项的索引去对应的位置修改老的DOM节点。

代码如何实现呢?通过虚拟DOM创建真实DOM

下面这段代码是入口文件,我们模拟了一个虚拟DOM叫oldEle,我们这里是写死的。
而在react中,是通过babel解析JSX语法得到一个抽象语法树(AST),进而生成虚拟DOM。
如果对babel转换感兴趣,可以看看另一篇文章入门babel--实现一个es6的class转换器。

import { createElement } from './createElement'let oldEle = createElement('div', { class: 'father' }, [ createElement('h1', { style:'color:red' }, ['son1']), createElement('h2', { style:'color:blue' }, ['son2']), createElement('h3', { style:'color:red' }, ['son3'])])document.body.appendChild(oldEle.render())复制代码

下面这个文件导出了createElement方法。
它其实就是new了一个Element类,调用这个类的render方法可以把虚拟DOM转换为真实DOM。

class Element { constructor(tagName, attrs, childs) { this.tagName = tagName this.attrs = attrs this.childs = childs } render() { let element = document.createElement(this.tagName) let attrs = this.attrs let childs = this.childs //设置属性 for (let attr in attrs) { setAttr(element, attr, attrs[attr]) } //先序深度优先遍历子创建并插入子节点 for (let i = 0; i < childs.length; i++) { let child = childs[i] console.log(111, child instanceof Element) let childElement = child instanceof Element ? child.render() : document.createTextNode(child) element.appendChild(childElement) } return element }}function setAttr(ele, attr, value) { switch (attr) { case 'style': ele.style.cssText = value break; case 'value': let tageName = ele.tagName.toLowerCase() if (tagName == 'input' || tagName == 'textarea') { ele.value = value } else { ele.setAttribute(attr, value) } break; default: ele.setAttribute(attr, value) break; }}function createElement(tagName, props, child) { return new Element(tagName, props, child)}module.exports = { createElement }复制代码

现在这段代码已经可以跑起来了,执行以后的结果如下图:

继续看domDIff算法

//keyIndex记录遍历顺序let keyIndex = 0function diff(oldEle, newEle) { let patches = {} keyIndex = 0 walk(patches, 0, oldEle, newEle) return patches}//分析变化function walk(patches, index, oldEle, newEle) { let currentPatches = [] //这里应该有很多的判断类型,这里只处理了删除的情况... if (!newEle) { currentPatches.push({ type: 'remove' }) } else if (oldEle.tagName == newEle.tagName) { //比较儿子们 walkChild(patches, currentPatches, oldEle.childs, newEle.childs) } //判断当前节点是否有改变,有的话把补丁放入补丁集合中 if (currentPatches.length) { patches[index] = currentPatches }}function walkChild(patches, currentPatches, oldChilds, newChilds) { if (oldChilds) { for (let i = 0; i < oldChilds.length; i++) { let oldChild = oldChilds[i] let newChild = newChilds[i] walk(patches, ++keyIndex, oldChild, newChild) } }}module.exports = { diff }复制代码

上面这段代码就是domDiff算法的超级简化版本:

首先声明一个变量记录遍历的顺序执行walk方法分析变化,如果两个元素tagName相同,递归遍历子节点

其实walk中应该有大量的逻辑,我只处理了一种情况,就是元素被删除。
其实还应该有添加、替换等各种情况,同时涉及到大量的边界检查。
真正的domDiff算法很复杂,它的复杂度应该是O(n3),react为了把复杂度降低到线性而做了一系列的妥协。
我这里只是选取一种情况做了演示,有兴趣的可以看看源码或者搜索一些相关的文章。
这篇文章毕竟叫“浅入浅出”,非常浅……

好,那我们执行这个算法看看效果:

import { createElement } from './createElement'import { diff } from './diff'let oldEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:blue' }, ['son2']), createElement('h3', { style: 'color:red' }, ['son3'])])let newEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:blue' }, [])])console.log(diff(oldEle, newEle))复制代码

我在入口文件中新创建了一个元素,用来代表被更改之后的虚拟DOM,它有两个元素被删除了,一个h3、一个文本节点son2,理论上应该有两条记录,执行代码我们看下:

我们看到,输出的patches对象里有两个属性,属性名是这个元素的遍历序号、属性值是记录的信息,我们就是通过序号去遍历找到老的DOM节点,通过属性值里的信息来做相应的更新。

更新视图

下面我们看如何通过得到的patches对象更新视图:

let index = 0;let allPatches;function patch(root, patches) { allPatches = patches walk(root)}function walk(root) { let currentPatches = allPatches[index] index++ (root.childNodes || []) && root.childNodes.forEach(child => { walk(child) }) if (currentPatches) { doPatch(root, currentPatches) }}function doPatch(ele, currentPatches) { currentPatches.forEach(currentPatch => { if (currentPatch.type == 'remove') { ele.parentNode.removeChild(ele) } })}module.exports = { patch }复制代码

文件导出的patch方法有两个参数,root是真实的DOM节点,patches是补丁对象,我们用和遍历虚拟DOM同样的手段(先序深度优先)去遍历真实的节点,这很重要,因为我们是通过patches对象的key属性记录哪个节点发生了变化,相同的遍历手段可以保证我们的对应关系是正确的。
doPatch方法很简单,判断如果type是“remove”,直接删掉该DOM节点。
其实这个方法也不应该这么简单,它也应该处理很多事情,比如说删除、互换等,其实还应该判断属性的变化并做相应的处理。
浅入浅出嘛,所以这些都没处理,我当然不会说我根本写不出来…… 现在我们应用一下这个patch方法:

import { createElement } from './createElement'import { diff } from './diff'import { patch } from './patch'let oldEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:blue' }, ['son2']), createElement('h3', { style: 'color:green' }, ['son3'])])let newEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:green' }, [])])//这里应用了patch方法,给原始的root节点打了补丁,更新成了新的节点let root = oldEle.render()let patches = diff(oldEle, newEle)patch(root, patches)document.body.appendChild(root) 复制代码

好,我们执行代码,看一下视图的变化:

我们看到,h3标签不见了,h2标签还在,但是里面的文本节点son2不见了,这跟我们的预期是一样的。
到这里,这个算法就已经写完了,上面贴出来的代码都是按模块贴出来的,并且是完整可以运行的。

未处理的问题

这个算法还有很多没有处理的问题,例如:

没有处理属性变化只处理了删除的情况,添加和替换都没有处理如果你删除了第一个元素,那么因为索引错位,后面的元素都会被认为是不同的而被替换掉,react中使用了key属性解决了这个问题,同时为了性能也做了妥协。
当然还有很多很多优化最后

上面的代码只是把react中的核心思路简单实现了一下,只是供大家了解一下domDiff算法的思路,如我我的描述让你对domDiff产生了一点兴趣或者对你有一点帮助,我很高兴。

作者:寒东设计师转发链接:https://juejin.im/post/5ad550f06fb9a028b4118d99

其他相关 RELEVANT MATERIAL
【颜文字】小狗颜字合集(文案短句朋友圈)

【颜文字】小狗颜字合集(文案短句朋友圈)

admin admin
3
2024-07-23
✨小狗颜文字合集……...
基于微信小程序的美容院管理系统-计算机毕业设计源码+LW文档(服務產品分類畢業設計)

基于微信小程序的美容院管理系统-计算机毕业设计源码+LW文档(服務產品分類畢業設計)

admin admin
4
2024-07-21
开发语言:Java框架:ssmJDK版本:JDK1.8服务器:tomcat7数据库:mysql 5.7(一定要5.7版本)数据库工具:Navicat11开发软件:eclipse/myeclipse/ideaMaven包:Maven3.3.9浏览器:谷歌浏览器小程序框架:uniapp小程序开发软件:HBuilde……...
评论区炸评的句子_幽默风趣_勤勤恳恳护肤_孜孜不倦熬夜(熬夜護膚句子)

评论区炸评的句子_幽默风趣_勤勤恳恳护肤_孜孜不倦熬夜(熬夜護膚句子)

admin admin
3
2024-07-19
The best way to keep people thinking about you is to owe money二、量体温的日子即将过去,量腰围的日子即将来临The days of measuring body temperature are about to pass, and the days……...
GRE英语词汇大全B/C(的人分詞動物)

GRE英语词汇大全B/C(的人分詞動物)

admin admin
3
2024-07-18
bacchanal英 [&#39;bækənl]adj. 酒神的;酒神节的;狂欢闹饮的n. 酒神崇拜者;酒徒;酒神节backslide英 [&#39;bækslaɪd]v. 倒退;故态复萌;退步;堕落backwater美 [&#39;bæk.wɔtər] 英 [&#39;bæk……...
英语听力口语对话100篇之19: In the Beauty Salon(拉皮塗抹聽懂)

英语听力口语对话100篇之19: In the Beauty Salon(拉皮塗抹聽懂)

admin admin
2
2024-07-12
B: Well, I have oily skin and there&#39;re always small bumps here and there on my face. They are really irritating. How can I get rid of them?A: Well……...
面部v雕仪器的功效原理(聲納面部肌膚)

面部v雕仪器的功效原理(聲納面部肌膚)

admin admin
2
2024-07-10
那么面部V雕的工作原理是什么呢?今天 美莱宝声纳V雕仪一、什么是声纳V雕仪?声纳V雕仪利用聚能超声波良好的组织穿透性,精准地将65°C的热能量传送到皮肤的SMAS层(约为3.5mm~4.5mm) ,有效刺激纤维母细胞以每秒40位速分裂新生细胞,溶解面部脂肪细胞,收紧肌肤轮廓、修复受损纤维组织,刺激胶原细胞再生……...
最新评论
年度爆文