React渲染框架
MrShu 人气:0在学习React源码之前,我们先搞清楚框架的范式都有哪些。框架范式主要有两种:命令式和声明式,目前大部份流行框架都采用声明式渲染,为什么都选择声明式渲染呢?对比命令式它有什么优势呢?为了搞清楚这些问题,我们先从动态渲染页面的三种方式:纯JS运算,innerHTML,虚拟DOM,分别比较他们的性能、可维护性和心智负担,来阐明基于虚拟DOM声明式渲染的优势。然后会说到与声明式框架密切相关的运行时和编译时。相信看完你会对React、Vue这一类采用虚拟DOM的声明式框架有自己的理解。
1. 命令式和声明式
在对比之前,我们先了解一下什么是声明式,什么是命令式,它们各有什么优缺点。作为框架学习者,了解这两种范式的框架对学习框架思想很有帮助。 我们先看看命令式和声明式框架的概念和具体形式。
1.1 命令式
什么是命令式?早年间大范围流行的JQuery
就是典型的命令式框架,命令式框架最大的特点是关注过程,例如我要做如下DOM操作:
- 获取id为app的div元素
- 把元素的显示文本设置为 hello world
- 给他绑定点击事件
- 事件内容是弹窗提示ok
用jQuery可以写出如下代码:
$("#app") // 1 .text("hello world") // 2 .on('click', function(){ // 3 alert("ok") // 4 })
可以看到自然语言描述能够跟实际写代码一一对应起来,写代码本身就是在描 述做事的过程,这很符合日常生活的直觉和逻辑。而且整个过程没有任何其他的性能开销,因此命令式框架的性能一搬都非常不错。
1.2 声明式
什么是声明式?与命令式框架不同关注过程不同,声明式框架更 关注结果 ,所见即所得。按照框架的规范,声明出用户想要的结果,具体怎么实现无需关心,都交给框架处理。 同样上面的那个例子里面,用React这种声明式的框架可以这样写:
<div id="app" onClick={()=>alert("ok")}>hello world</div>
在react里面一般都是用JSX描述页面的dom结构。可以看到我们只需提供一个最终的“结果”,至于具体怎么实现这个结果的过程,我们并不需要关心。换句话说React框架帮我们封装了实现的过程,因此应该能够猜到React框架内部实现一定是命令式的,但是暴露给用户的是更加直观的声明式。
1.3 两种范式的性能和易维护性
我们首先抛出一个结论:声明式代码的性能不优于命令式代码的新能。为什么呢?还是拿上面的例子来说,如果要把文本内容改为:“react”,用命令式代码就很简单,因为用户明确的知道要修改的是什么,直接调用相关api即可:
app.textContent = 'react'
试想一下,有没有其他的实现方式比这个代码性能更好的?答案是没有,因为我们明确的知道是哪些地方发生了变化,直接修改变化的地方就行, 因此命令式的代码能做到极致的性能优化。但是声明式的代码目前还做不到这一点,因为它表述的是结果:
// 之前
<div id="app" onClick={()=>alert("ok")}>hello world</div>
// 之后
<div id="app" onClick={()=>alert("ok")}>react</div>
对于框架来说,为了实现最好的更新性能,框架需要找到新旧DOM的差异,并且只更新有差异的地方,最终还是用命令式的代码完成这次变更:
app.textContent = 'react'
如果把修改文本的性能消耗为A,找出新旧内容差异的性能损耗为B,那么会有如下公式:
- 命令式的代码更新性能为:A
- 声明式的代码更新性能为:B + A
可以看出,声明式代码比命令式代码多了找出差异的性能消耗,最理想的情况是查找差异性能的消耗为0,此时命令式代码和声明式代码的性能相同,但是无法超过命令式代码。因为框架本身封装了命令式的代码才实现了面向用户的声明式,这也侧面印证了之前的结论:声明式代码的性能不优于命令式代码的新能。
既然命令式代码性能这么好,又直接,为啥还有类似React,Vue这样的声明是框架呢?原因是声明式代码的可维护性更强。从之前的例子可以看出,采用命令式代码实现的时候,我们需要关注整个实现过程的每一步,包括DOM元素的创建、获取、更新、删除等操作,过程繁琐而且抽象,心智负担高。而声明式代码展示就是最终我们想要的结果,更加直观,只关注结果效率高,而实现结果的命令式的代码框架内部已经实现,不需要用户关心。
但是声明式代码在提升可读性和维护性的同时,面临的问题是性能上有一部分损耗,所以框架要做的是:保持可维护性的同时让性能损耗最小。在这种前提下,就有人提出了 虚拟节点(Virtual DOM) 这种找出新旧差异的方案,并被广泛运用于React,Vue这类框架之中。那么虚拟DOM的性能到底如何呢?
2. 虚拟DOM的性能如何
说到这里相信大家有一个基本的了解,那就是采用虚拟DOM的框架更新新时,理论上性能不会比原生JS操作dom性能更好,理论上是指用户写的命令式代码是绝对优化的。在实际场景中这很难,可能需要投入巨大的精力,所以投入产出比不高,目前只谈理论性能。
那么有没有一种办法既不需要投入太大的精力,又能保证代码程序的性能下限,不至于让应用程序性能太差。甚至经过一定的优化处理,接近命令式代码的性能呢?其实这就是虚拟DOM要解决的问题。
上文说的原生JS操作dom的命令式代码,指的是document.createElement等方法,不包括innerHTML这个方法,它比较特殊,需要单独探讨它。在早年使用JQuery或直接写原生JS代码的时候,innerHTML操作dom是非常常见的。那么我们可以考虑以下几个问题:
- innerHTML的渲染流程是什么样的?
- innerHTML的性能相比较虚拟DOM谁的性能好?
首先对于第一个问题,对于innerHTML创建页面,需要先构造一段HTML字符串:
let htmlStr = '<ul>' for(let i=0; i<data.length; i++) { htmlStr += `<li>${data[i].name}</li>` } htmlStr += '</ul>'
然后把这个字符串赋值给dom元素的innerHTML属性:
app.innerHTML = htmlStr
在赋值之后,由于要渲染出页面,首先要吧字符串解析成DOM树,这一步是DOM层面的计算。然而,涉及DOM的运算性能要远比JS层面的计算性能差很多,我们可以在jsbench.me这个网站上给它们跑个分,比较创建10000个js对象和10000个dom元素的性能,结果如下:
我们可以看出,纯JS运算要比操作DOM快得多,他们不在一个数量级上。基于这个前提,我们可以得出innerHTML创建页面的性能为:拼接HTML字符串的计算量 + innerHTML 的 DOM计算量。
我们再看第二个问题,innerHTML的性能相比较虚拟DOM谁的性能好?我们再看一下虚拟dom创建页面的过程。第一步,先创建JS对象,这个对象是对真实DOM的描述,也就是大家说的虚拟DOM,第二部是递归JS对象并创建所有对应的真实DOM。我们也可以用一个公式来表述他们的性能消耗:创建JS对象的计算量 + 创建真实DOM的计算量。
1.比方说有这样一个虚拟dom对象:
const vdom = { type:'ul', children: { type: 'li', } }
2.递归对象创建真实DOM渲染到页面
function render(vdom, anchor){ const el = document.createElement(vdom.type) anchor.appendChild(el) if(vdom.children){ render(children, el) } } render(vdom)
我们列一个表格对比一下纯JS运算、虚拟DOM和innerHTML创建页面时所消耗的性能:
纯JS运算 | 虚拟DOM | innerHTML |
---|---|---|
创建js对象(vdom) | 渲染HTML字符串 | |
DOM运算 | 创建所有DOM元素 | 创建所有DOM元素 |
我们可以看出虚拟DOM和innerHTML创建页面时流程差不多,性能两者差别不大。在相同数量级上面,基本上没有什么区别,因为都要新建所有的DOM元素。
看到这里可能有人会说,性能都差不多那还要虚拟DOM干嘛,这不是多此一举嘛。别急,上面说的的新创建DOM。在都是新创建所有的DOM元素来说虚拟DOM对比innerHTML在性能上确实没有任何优势可言。但是在我们更新页面的时候,哪怕我们只改了一个字,用innerHTML这种方式更新页面时,要先销毁之前所有的DOM元素,然后根据新的html字符串重新创建所有的DOM。我们再看看虚拟DOM是怎么更新页面的,它需要重新创建js对象(vdom),然后比较新旧虚拟DOM,找到变化的元素然后更新它。如下面这个表格所示:
纯JS运算 | 虚拟DOM | innerHTML |
---|---|---|
1. 创建js对象(vdom) 2. Diff找出变化的部分 | 渲染HTML字符串 | |
DOM运算 | 只更新变化的部分DOM | 1. 创建所有的新DOM元素 2. 创建所有的新DOM元素 |
在页面更新的时候,采用虚拟DOM更新页面,由于经过JS计算出哪些DOM元素需要更新,只需要更新对应的DOM元素即可。而采用innerHTML这种方式需要先销毁所有的DOM元素,然后又创建所有DOM。综合之前的JS运算比DOM运算的性能快的多的结论下,这时候虚拟DOM的优势就提现出来了。
此外,当页面页面更新时,影响虚拟DOM的性能因素与影响innerHTML的性能因素补贴。对于虚拟DOM来说,无论页面多大,只更新变化的内容,所以性能跟变化内容的大小有关。对innerHTML这种方式来说,就不关系变化内容的大小了,只关心要渲染性能跟html字符串的大小有关。
纯JS运算 | 虚拟DOM | innerHTML |
---|---|---|
1. 创建js对象(vdom) 2. Diff找出变化的部分 | 渲染HTML字符串 | |
DOM运算 性能因素 | 1. 只更新变化的部分DOM 2. 与数据变化量相关 | 1. 创建所有的新DOM元素 2. 创建所有的新DOM元素 3. 与模板大小相关 |
基于上面的描述,我们可以总结一下原生JS(指createElement等方法)、虚拟DOM、innerHTML这三个方法在更新页面时候的性能,如下表所示:
纯JS运算 | 虚拟DOM | innerHTML |
---|---|---|
心智负担大 | 心智负担小 | 心智负担小中等 |
性能最好 | 性能不错 | 性能差 |
可维护性差 | 可维护性强 | 可维护性一版 |
我们分了一个维度去考量:心智负担、可维护性、性能:
- 对于纯JS运算,毫无疑问原生JS的DOM操作这种方式心智负担最大,因为需要手动增删改查大量的DOM元素。但它的性能是最好的,不过要承受巨大的心智负担,而且代码可能读性很差,不便于后期维护。
- 对于innerHTML,由于有一部分是拼接字符串来实现的,有点类似于声明式的代码了,但也存在着一定的心智负担,而且其他的DOM操作(绑事件,增加属性等)还是得通过原生JS来处理。此外如果html字符串如果很大的话还可能有性能问题。
- 对于虚拟DOM:由于虚拟DOM是声明式的,心智负担比较小,可维护性强,性能虽然比不上极致优化的原生JS,但是在页面更新的时候也有着不错的性能。
一番权衡之后,发现虚拟 DOM 是个还不错的选择。这也是大部份流行框架采用虚拟DOM的原因。
可能有的人要问了,有没有一种方法能做到:既可以声明式的描述UI结构,同时又具备原生JS的性能呢?这些问题在下一节讨论。
3. 运行时和编译时
我们先来说一下纯运行时的框架。假如我们设计了一个框架,它提供了一个Render函数,用户只要传入虚拟DOM,Render函数就会递归创建真实DOM把它插入到对应的节点,还是拿之前的代码为例:
3.1 运行时
1.虚拟dom对象:
const vdom = { type:'ul', children: { type: 'li', } }
2.创建真实DOM:
function render(vdom, anchor){ const el = document.createElement(vdom.type) anchor.appendChild(el) if(vdom.children){ render(children, el) } } render(vdom)
3.2 运行时 + 编译时
在浏览器上运行这段代码可以看到预期的结果。但是有人会说,写这样的dom描述对象太不直观了,而且手写起来很麻烦。有没有一种方式能够支持写HTML就能得到dom描述对象呢?答案是有的,我们可以引入编译手段,将写好的HTML编译成dom描述对象,再把这个对象交给Render函数,将他渲染到页面上。流程如下:
- 写了一个Compiler程序,将HTML声明式的代码变成了产出dom描述对象的函数:
const el = <div id="app" onClick={()=>alert("ok")}>react</div> const vdom = Compiler(el)
- 上面的
vdom
就会编译成如下结果:
{ type: 'div', props: { click: () => alert("ok"), chilren: ['react'] } }
- Render函数传入编译得到的dom描述对象就可以把之前声明式的dom节点渲染到页面上了。
Render(vdom, container)
实际上面就是 运行时 + 编译时 框架的基本工作流程。用户可以选择提供dom描述对象或者写HTML片段,来描述UI界面,如果是dom描述对象就直接渲染,如果是HTML片段就先编译再渲染。代码运行起来才进行编译,叫做运行编译时,这会产生一定的性能开销,所以有些框架可以在构建的时候执行Compiler将提供的内容 提前编译好,运行的时候就无需编译了,这对程序性能也有一部分提升。像React,Vue就是这么做的。
3.3 编译时
有人可能会问了,既然能把能把HTML片段编译成dom描述对象,那为啥不直接HTML片段编译成命令式的代码呢?答案是可以的,这样就不支持任何运行时内容,用户的代码需要编译才能运行,它就是纯编译时框架了。目前就有一些框架把声明式的代码编译成命令式代码,例如:sveltejs、solidjs等框架,它既保持了声明式的易维护特性,又保证了程序的性能。
4. 总结
我们先讨论了命令式和声明式这两种范式的差异,其中命令式更加关注过程,而 声明式更加关注结果。命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担;而 声明式能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架要想办法尽量使性 能损耗最小化。
后面,我们讨论了虚拟 DOM 的性能,并给出了一个公式:声明式的更新性能消耗 = 找出 差异的性能消耗 + 直接修改的性能消耗。虚拟DOM 的意义就在于使找出差异的性能消耗最小 化。我们发现,用原生JavaSoript操作DOM 的方法(如 document.createElement )、虚拟 DOM 和 tnnerHTML 三者操作页面的性能,不可以简单地下定论,这与页面大小、变更部分的大小都有关 系,除此之外,与创建页面还是更新页面也有关系,选择哪种更新策略,需要我们结合心智负担、 可维护性等因素综合考虑。
再后面了解了运行时和编译时的相关知识和各自的特点。
下一节我们着重来说一下React声明式框架是如何将JSX创建虚拟DOM,以及虚拟DOM是怎么渲染到页面上的。
加载全部内容