第七章 Vue框架的渲染器

154 阅读10分钟

渲染器的设计

渲染器的相关概念

  • 渲染器(Renderer):一个拥有将虚拟DOM转换为对应平台的真实元素的方法的对象
  • 渲染函数(Render):实现将虚拟DOM转换为真实元素的方法
  • 虚拟DOM:用于描述HTML标签模板的一种JS树形结构对象
  • 真实DOM:HTML标签元素
  • 容器(Container):可以提供虚拟DOM渲染位置的真实元素
  • 挂载(Mount):将虚拟DOM渲染到真实DOM内的动作
  • 补丁(Patch):将已渲染的虚拟DOM进行更新的动作

渲染器

  • 本质:将使用JavaScript表达的HTML模板(虚拟DOM)转换为对应平台(浏览器)的真实元素(DOM元素)
  • 重要性:是Vue3框架的核心,渲染器的实现直接影响了框架的性能,Vue3独创了快捷路径更新方式,大大提升了性能
  • 实现:识别虚拟DOM,即遍历JS树结构,将每个node使用JavaScript Api创建对应的真实node,即DOM元素

自定义渲染器

  • Renderer:创建渲染器类,在类的内部提供render方法供外部渲染调用,提供mountElement,patch方法供render使用
  • 挂载:虚拟DOM第一次被渲染到容器内,则称为挂载,此时render函数调用renderer内部的mountElement方法进行挂载
  • 打补丁:虚拟DOM已经被挂载到容器内部后,发生了更新,需要重新渲染,为了提升性能,我们只更新发生变化的部分,此时render函数调用renderer内部的patch方法进行更新
  • 跨平台:为了实现渲染不依赖于特定平台,将生成真实DOM,挂载DOM等与平台紧密相关的操作封装成独立的方法,传入renderer类内部,这样我们通过传入方法的不同即可实现跨平台渲染的能力。

渲染器与响应系统结合

  • 响应系统:通过监控对象的属性,收集发生get动作的函数(副作用函数),在set动作发生时取出执行这些函数
  • 渲染器:将渲染render方法作为副作用函数,当虚拟DOM(JavaScript树形结构)发生变化时,会触发set动作,自动取出render执行
  • 渲染器与响应系统结合可以实现通过数据驱动页面更新

挂载与补丁

挂载节点与元素属性

  • 节点类型:标签,文本,属性,注释,Fragment等等
  • 元素属性:节点类型为标签时,一般标签元素内部是存在HTML Attribute的,比如input元素内部的value属性
  • DOM属性:为了渲染出对应的HTML Attribute,需要在Vnode内部新增props来存储属性key-value
  • HTML的Attribute和DOM的Properties:HTML Attribute通过getAttribute和setAttribure方法来设置标签元素的原生属性,而在JS中每个标签对应的DOM对象也拥有与HTML Attribute 对应的Props,直接操作DOM对象对应Properties即可设置
  • 区别:HTML Attribute设置的是HTML属性的初始值,且会一直保留初始值不改变。DOM Properties则始终维护的是标签元素的最新值。
  • 处理:DOM Properties 与 HTML Attribute并不是一一对应的,需要针对某些HTML Attribute来特殊处理
  • 跨平台:为了处理元素属性的设置不依赖特定平台,同样将处理的方法采用renderer配置项的方式传入
  • 卸载:卸载其实是特殊的更新,即新的Vnode为null。我们可以通过innerHtml来设置容器内容为空,但是这种方式不会清除绑定在标签上的事件。因此需要调用原生的DOM方法来移除,因此我们需要保留挂载容器对应的真实DOM。在mountElement时将容器保留在Vnode的el属性上
  • 事件:事件是特殊的属性,在Vnode中通过onClick形式来表示事件属性,以on开头的属性,会取其后的事件名,通过addEventListener方法来添加事件,通过removeEventListener来移除事件,不过为了提高更新性能,我们可以将新的事件作为invoke对象的value,绑定value属性即可,之后更新value的值即可,而不必先移除在添加
  • 补丁:除了节点的属性和事件更新外,对于子节点的更新,也需要分类来处理,比如子节点是文本,更新为标签时该如何处理
    • 如果旧节点是空或者文本节点,我们只需要清空容器内容,然后挂载新的节点即可
    • 如果旧节点是元素节点或者一组子节点,我们需要先卸载旧节点,然后挂载新节点
  • 注释节点和Fragment节点:注释类型(comment)是特殊的文本节点,渲染文本节点时要区分注释文本。Fragment类型是Vue3新增的类型,Fragment用于支持多根节点模板,本身不渲染DOM,只需要渲染其子节点

简单Diff算法

  • 非Diff:当新旧节点都包含一组子节点时,需要将全部分旧节点卸载,将所有的新节点挂载
  • 优化:比较新旧节点数量,如果新节点多则表明需要新增DOM,如果旧节点多则表明要卸载部分DOM。如果相等则比较新旧节点,查找可复用的节点。
  • Diff:为了快速找到新旧节点中可复用的节点,我们为每个节点设置key属性,用来标识节点的唯一性,新旧节点只要key相同,Vnode的type相同就可复用。
  • 规则:双重循环遍历,外层循环遍历新节点,内层遍历旧节点,
    • 旧节点组的节点顺序必须与新节点组顺序保持一致
    • 遍历新节点组时,以递增顺序遍历,因此每个新节点都是当前遍历索引的最大值,且之后的节点索引值一定比当前的大,如果之后的新节点对应旧节点索引比当前新节点对应旧节点索引小,就违背了递增,需要移动。在移动之前必须先patch更新节点内容(因为节点可复用只要求为同类型的标签即可,内容不一致也可复用,这样就避免了创建和删除DOM元素的消耗)
    • 移动:如果新节点需要移动,那么新节点对应的旧DOM应该移动到新节点的前一个节点对应的旧DOM之后,调用insertBefore插入即可,节点对应的真实DOM均存在节点的el中
    • 新增:如果新的Vnode不在旧Vnode组中,则说明其为新挂载的Vnode,我们需要找到插入的锚点(anchor),如果当前的Vnode在新Vnode组中有前一个节点,则锚点为前一个节点对应旧节点组中节点的下一个节点(锚点是插入位置的后一个元素,使用insertBefore)。如果新Vnode组中没有前一个Vnode,则说明当前Vnode是第一个元素,则将旧节点组的第一个元素作为锚点即可。
    • 删除:新节点组遍历完成后,在进行一次双重for循环,以旧节点组作为外层循环,去新节点组找对应的key,如果旧的Vnode不在新Vnode组中,则说明其为需要卸载的旧Vnode,patch新节点为null即可

双端Diff算法

  • 双端Diff:创建四个指针分别指向新旧节点组的首尾,每次都比较四个指针节点,看有无可复用的节点。更新对应的索引继续比较,直到首尾指针不满足条件结束比较

  • 优势:与简单Diff相比,由于是首尾四个节点比较,当节点的顺序完全相反时,循环中新旧节点首尾均可复用,则移动次数只是节点数的一半,而简单Diff由于是按新节点组的顺序依次遍历,那么旧节点就必须按新节点的顺序重新移动,而两组节点顺序正好相反,那么移动次数就与节点数相同了。

  • 规则:创建newStartVnodeIndex,newEndVnodeIndex,oldStartVnodeIndex,oldEndVnodeIndex四个指针,只要开始索引小于结束索引就不断移动指针进行比较

    • 旧的开始与新的开始比较,相等则patch,更新新旧索引
    • 旧的结束与新的结束比较,相等则patch,更新新旧索引
    • 旧的开始与新的结束比较,相等则移动旧节点到旧节点组的结束索引之后,更新新旧索引
    • 旧的结束与新的开始比较,相等则移动旧节点到旧节点组的开始索引之前,更新新旧索引
    • 如果以上都不相等,则使用新的开始节点在旧节点组中进行简单Diff。
      • 如果未找到则说明,新节点是新增的节点,需要挂载,锚点是旧节点组开始索引
      • 如果找到则复用该节点,更新新的开始节点索引到下一个位置。
    • 如果循环结束时,新的开始大于新的结束,说明新的节点组节点遍历完成,而旧节点组还有旧节点需要卸载
    • 如果旧的开始大于旧的结束,说明旧的节点组节点遍历完成,而新节点组还有新节点需要挂载
    • 循环结束后,不管是挂载还是卸载的节点都处在新旧节点不等的索引之间,遍历进行patch即可
  • 总结:与简单Diff相比,在同样的场景中,执行DOM移动的次数更少。(逻辑比较耗费的时间远小于DOM移动的时间)

快速Diff算法

  • 快速Diff:借鉴文本Diff,对新旧节点组进行预处理,将前置节点和后置节点相同部分进行patch,去掉首尾相同部分后,中间的剩余部分进行diff比较,移动或者挂载,卸载。

  • 优势:由于加入了前置节点和后置节点的预处理,其性能与双端Diff和简单Diff相比是最快的

  • 规则:考虑新旧节点组长度可能不一样,但起始索引都是0,创建j指向起始索引,oldend指向旧节点组的尾部索引,newend指向新节点组的尾部索引。

    • 分别比较新旧节点组的起始索引和尾部索引,只要可复用就进行patch
    • 如果不可复用则立即停止循环,保留索引j和两个尾部索引。
    • 起始索引到尾部索引之间的节点是需要进行diff比较的,创建数组保存新节点组的剩余部分对应旧节点组的索引值,求助该索引数组的最长递增子序列,然后对新节点组剩余部分从0开始重新排序。从尾部开始遍历,与递增子序列索引一致的节点即不需要移动的节点,其余部分根据情况进行移动,挂载,卸载即可。
    • 为什么递增子序列节点不需要移动,因为在diff比较时,移动次数越少,性能越好,递增子序列正是表示的新节点组在旧节点组中保持递增趋势的最多的节点。因此这部分节点不需要移动,只移动违反了递增趋势的部分即可。
  • 总结:与双端Diff相比较,在同样的场景中,有预处理,执行的DOM移动次数更少。