从零开始创建Virtual DOM

1)Virtual DOM从何说起?

说它之前,我们先回顾一下前端领域最近几年都发生了什么事情。以前的前端开发基本都是静态页面,通过服务器同步数据渲染然后通过http响应内容返回给浏览器解析。页面与页面之间的跳转物理路由直接跳转。所有页面交互通过ajax异步请求数据,再局部刷新。但是这样开发有一些缺点,就是页面与页面之间跳转的时候由于网络不稳定等因素,导致新的响应内容回来到浏览器重新渲染新的页面之前都要经过一段时间的等待,而且跳转期间的空白页面无法自定义,对于用户来说,等待时候这种空白一片让他们十分难受。而且页面在异步更新的时候往往触发了DOM的重新渲染或者大面积更新,这种都会使得CPU重新计算布局,调用系统底层方法渲染,我们都知道浏览器上面JavaScript和UI渲染共用一个线程,而且又是单线程,如果哪个模块在工作,必然会导致浏览器冻结另外的模块的工作。简而言之,对用户来说就是卡顿。作为前端工程师对于这样影响用户体验的事情都是不能接受的。他们在想怎么才可以不用通过页面之间的跳转,然后又可以做到页面的切换,或者怎么在页面跳转时候有一个比较友好的提示。最原始最简单的做法就是将所有html标签都写在同一个页面上面,通过css和js的控制让它们在合适的时候和合适的交互下显示。这个一开始的时候的确很好,页面再也不用跳转,然后在异步请求的时候显示一些loading动画,数据请求回来后,再用回调方法控制css。这个时候比较常用的做法可能是Swiper + Jquery+Transform。在SPA开发流行前,这一切好像没什么问题。


但是随着用户体验要求的越来越高,这种原始的开发方式暴露出来的问题越来越多。比如无法复用组件、各个模块之间维护很麻烦、服务器渲染后响应体太大、浏览器解析慢、耦合度太高等等。随着项目越大,组件越多,页面结构越来越复杂,这时候Jquery的sizzle寻找器选择DOM的时候需要遍历的位置越来越多,导致操作DOM变缓慢。为了解决这一系列的问题,开发者想尽办法制定标准,减少操作DOM、局部渲染、分离模块等,前端开发从“原始时代” 开始进入“封建时代”,为什么说是“封建时代”,封建社会有个特征就是中央集权,国王拥有至高无上的权力。前端的“封建时代”也和这个特征很像,“封建时代”比较有代表性的开发模式有MVC,MVP等。这种模式的阶级关系也很明显,Controler和Presenter几乎拥有最大的“权力”的统治者。所有的数据改动都经过Controler和Presenter,由他们决定渲染哪个地方,是否要渲染等操作。这种虽然一定程度上面提高了渲染的效率,减少重复计算,提高寻找DOM效率,一定程度上降低耦合和解决了复用的问题。但是一旦SPA越来越大,通知Controler和Presenter更改DOM效率并不高,而且会出现Controler和Model的互相污染问题。这个时候出现了一个“工业时代”的过渡产品: Angular。“工业时代”的技术使用为“现代时代”虚拟DOM一种启蒙,它比Controler和Presenter改进的地方是基于脏数据检查的机制来实现必要的更新。这种数据监测的机制将前端操作DOM的原始思维转变到对数据的操作。一旦数据更改,跟它绑定的那个地方的DOM也要局部刷新。这种模式的启发下,我们发现不一定每次操作DOM都要从根或者从父节点活着从兄弟节点开始寻找,它其实开始从一个有变化的节点开始查找,这个节点一开始就在实例生成时候被注册和监听。一旦数据有变化就局部更新这个位置的结构或者属性。


说到专注数据,或者说数据是虚拟DOM的基础。虚拟DOM是一系列的数据结构和数据对应浏览器的真实DOM在内存里面的映射,正是它是内存里面的数据,所以才有“虚拟DOM”这个说法。正如脏数据检查那样子,在虚拟DOM开发过程中,我们重点关心的是数据,数据改变了虚拟DOM的核心库会监听到并找打该数据在数据结构里面的位置从而进行修改。由于真实DOM是一个文档树模型,由文档对象去表现这种树结构,对象固然有属性和值,那么只要我们定义的数据结构用对象表现,然后该对象和文档对象又有一定规则的对应关系。那么修改虚拟DOM对象后,虚拟DOM库查找到和文档对象对应属性的关系,后再通知浏览器的webkit模块修改对应文档对象的某个属性,然后由webkit模块通知系统底层渲染库重新对那个地方进行渲染。


2)为什么虚拟DOM更好?

这里我要借用VUE作者尤雨溪在知乎的一个回答,内容可以从访问以下网址:

https://www.zhihu.com/question/31809713

…如果没有 Virtual DOM,简单来想就是直接重置 innerHTML。很多人都没有意识到,在一个大型列表所有数据都变了的情况下,重置 innerHTML 其实是一个还算合理的操作... 真正的问题是在 “全部重新渲染” 的思维模式下,即使只有一行数据变了,它也需要重置整个 innerHTML,这时候显然就有大量的浪费。

我们可以比较一下 innerHTML vs. Virtual DOM 的重绘性能消耗:

innerHTML: render html string O(template size) + 重新创建所有 DOM 元素 O(DOM size)
Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change)

Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是!它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。可以看到,innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量里面,只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关的。前面说了,和 DOM 操作比起来,js 计算是极其便宜的。这才是为什么要有 Virtual DOM:它保证了 1)不管你的数据变化多少,每次重绘的性能都可以接受;2) 你依然可以用类似 innerHTML 的思路去写你的应用…


3)到底什么是Virtual DOM?

1)首先它跟DOM没什么关系,类似Java和JavaScript的关系,就是混个脸熟而已

2)它是一个跟数据结构紧密联系在一起,具体表现为一个有序的二叉树

3)在JavaScript里面,Virtual DOM表现为一个Object对象

4)在UI映射上面,Virtual DOM 对象的节点跟DOM Tree每个位置的属性一一对应

5)Virtual DOM是某一时刻真实DOM状态的内存映射


4)现在比较流行的Virtual DOM有哪些?

React、React-lite、Cito、Snabbdom等等。具体可以参考以下网址“https://localvoid.github.io/uibench/”

 

5)功能拆分

既然我们是从零开始,那么在开发前我们要拆分模块,具体我们可以拆分为如下:

1)一个生产虚拟DOM对象的JavaScript函数

2)一个将虚拟DOM对象转换成真实DOM的函数

3)一个解析模板节点的函数

4)一个更新虚拟DOM新旧节点函数

5)一个对比虚拟DOM新旧节点的函数


6)一个生产虚拟DOM对象的JavaScript函数

先看一个简单的DOM结构

这个DOM结构有3个标签,父标签是div,div上面有2个属性,一个是id,另外一个是class。Div标签下面有2个子标签,第一个子标签是p,它有一个属性id,并且它下面没有子标签,以字符串“text1”结束。而第二个子标签也是p,它没有属性,并且它下面没有子标签,以字符串“text2”结束。每个标签我们称作这个DOM的一个节点,节点由以下最基本的几个元素组成:1.节点名字、2.节点属性、3节点子元素组成。

为了更加直观描述,我们将其用图来表示:

 这个数据结构是一棵二叉树,每个子节点跟我们上述描述的一直。那么在JavaScript里面我们可以用Object对象来存储这个二叉树信息。我们先简单用对象表示父节点:

这代码描述了父节点名称是“div”,它有2个属性,属性存储在对象props里面,另外它有2个子节点,以对象形式存储在数组children里面。

我们再按照这个规则看看它的两个子节点是如何描述:

规则基本跟父节点没有区别,唯一不同的地方在于它们的children以字符串结束,所以数组不存在Object,取而代之是存放了字符串“text1”和“text2”。

整个父子节点的数据结构可以描述如下:

这样的数据结构对象我们可以用一个工厂函数生成出来,这个函数就是生成虚拟DOM对象的JavaScript函数:

除了type表示节点名字外,后面的全部都是节点属性和子节点的参数。创建的时候我们可以这样写:

最后我们会生成一个如上述所示的虚拟DOM对象。一个最简单的虚拟DOM生成函数就设计完。


7)将虚拟DOM对象转换成真实DOM

一个对象描述的文档对象结构,我们用JavaScript的对象遍历的方法遍历对象的属性,然后通过调用document的api去动态生成新的节点。

遍历所有节点,利用document.createElement这个api去生成真实DOM节点,然后利用生成后的真实Element对象的children属性,动态给里面再插入新的DOM节点。转换函数实现如下:

如果节点的子节点不是以字符串结束,那么我们就生成一个新的element文档对象,然后遍历每个节点的props属性,根据属性的名字调用element的api插入对应的属性和属性值,通过递归函数不断往children属性里面添加子节点,直到我们遇到字符串就直接写入当前节点然后跳出递归函数。

然而$el这个文档对象这时候还处于内存里面,我们要将其用真实的document对象渲染到页面:

这里app通过document.getElementById()获取的。这样就可以往文档里面插入真实DOM节点。

但是这样子对于复杂一点的数据结构,用函数来写页面实在太复杂和麻烦,而且不好维护。作为前端开发者,我们最擅长的还是写html,那有没有办法可以从html的文档对象直接转换为虚拟DOM对象?答案是有的,但是我们需要准备一个模板解析器,解析html文档的节点。


8)一个模板节点解析函数

对于前端工程师,写得最熟悉的、最爽的莫过于html页面莫属。IDE的各种提示,自动补全,错误提示等等。所以这个模板我们的切入点就在html页面。利用ES6的import功能将其引入到模板解析模块里面,将其转换成html字符串,然后再对其进行解析和分离。

模板template如下:

最后渲染的效果:

这里参考了vue里面用{{}}语法绑定变量的语法,也参考了vue里面用v-绑定的方法,这里我们用vf-来说明这个属性是一个模板绑定的方法。

模板解析器分析html字符串函数代码如下:

模板解析器分析标签属性函数代码如下:

分析html字符串函数会将下面html字符串

 

分解为:

<div id="d1" ></div>’

<p id="text1">text1</p>’

‘<input type="text" placeholder="please input" vf-keyup="update(event)" />’

<p>{{name}}</p>’

 

然后将‘<div id="d1" ></div>给标签分析函数分析,将标签解为:div,id,d1, class, d1,>。将符合条件的属性集合放到tagAttrs对象里面,将符合条件的方法集合放到tagFunction对象里面。

然后调用生成虚拟DOM函数生成虚拟DOM对象。通过不断递归判断当前节点是否含有子节点,从而不断调用标签分析函数,生成虚拟DOM对象,最后变成一个生成虚拟DOM函数:

(注:这个截图只是说明原理,最后生成虚拟DOM函数方法添加了很多其他属性,而且这里改变了刚刚开始时候例子的结构,所以没有再手工写一个函数来截图)

生成虚拟DOM对象,调用createRealnode的方法生成真实DOM对象,这样一个简单的从template->vnode->rnode的过程就完成。


9)对比和更新Virtual DOM

经过前面的步骤,相信大家都已经知道Virtual DOM的概念和思想,剩下的事情就是对比新旧Virtual DOM对象的事情,现在可以抽象成对比两个JavaScript对象然后获得差异的地方,再调用document的api局部渲染的问题。

由于我们建立的虚拟DOM的数据结构是基于二叉树,寻找节点和对比的算法也就是围绕着遍历二叉树的算法去展开。对于二叉树,我们熟悉的算法有:深度遍历和广度遍历,我们先来回顾一下这个几个算法的特点:


深度优先分为以下几种模式遍历:

      先序遍历:1,2,4,5,3,6,7

      后序遍历:4,5,2,6,7,3,1

      中序遍历:2,4,5,1,6,3,7

      广度遍历:1,2,3,4,5,6,7

当然,在现在流行的虚拟DOM核心库里面对比算法一般都是经过大量的论证和研究,并不是这么简单的遍历算法。但是我们只是演示说明虚拟DOM的整个运行过程,

这里就采用了深度优先的先序遍历来说明。

我们要一个更新函数来更新节点:

更新函数判断这个节点是否是从无到有,或者从有到无,这2种场景是第一次添加或者全部删除的操作,这时候我们不用对比,直接调用重绘函数painting。

否则我们要调用diff函数对比两个节点之间的不同地方。

我们设置x为锚点,x的位置和新旧节点的长度进行对比,然后那2个长度有不同证明这个位置进行修改;另外一种情况就是长度一样,但是最后到结束字符串时候再判断字符串是否相同,相同的话证明这个节点没有修改,不同的话就调用重绘函数函数painting更新DOM。在更新DOM的时候我们调用了replaceChild这个api,这个api可以比较精确在那个改变的DOM上面替换属性值,然后通知webkit渲染器对那部分做局部渲染,以达到更新必要的DOM而不会影响或者重绘其他DOM节点。


10)总结过程

说了这么多可能大家对这个生成和改变虚拟DOM的过程还不是很清晰,这里我用流程图总结整个过程:


当然文章例子里面生成虚拟DOM和对比虚拟DOM的过程是比较简单,很多情况和用例并没有覆盖到,包括分析html模板函数也是。和商业在用的虚拟DOM库还有很大的差距,但是作为开发者我们除了用其他第三方库外,更希望自己可以开发一个比较牛逼的库。虽然这个虚拟DOM函数还是比较简陋,但是也是一个开始,愿这个库可以在我知识不断增长下而不断完善。


网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者陈偲伟授权发布。