关于React中的声明式渲染框架问题
作者:MrShu
在学习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是怎么渲染到页面上的。
到此这篇关于React中的声明式渲染框架的文章就介绍到这了,更多相关React渲染框架内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!