阅读 3371

vue源码阅读之数据渲染过程

一、概述

vue已是目前国内前端web端三分天下之一,也是工作中主要技术栈之一。在日常使用中知其然也好奇着所以然,因此尝试阅读vue源码并进行总结。本文旨在梳理初始化页面时data中的数据是如何渲染到页面上的。本文将带着这个疑问一点点“追究”vue的'思路'。总体来说vue模版渲染大致流程如图1所示:

图1:vue模版渲染流程

从图中可以看到模版渲染过程经历了数据处理(initState)、模版编译(compileToFunctions)生成渲染函数(render)、render函数生成虚拟dom、虚拟dom映射为真实DOM (patch)挂载到页面这几个过程。上述几个函数在数据渲染过程中起到了关键作用。因此本文就从这几个函数出发,深入研究vue数据渲染到页面上的原理。

二、什么是Virtual DOM ?

vue利用虚拟DOM技术来提高页面渲染和更新的速度。因此在正式分析数据渲染过程之前,有必要先了解一下什么是Virtual DOM,以及Virtual DOM的优势。

2.1 virtual dom 产生的原因

Virtual DOM 产生的前提是浏览器中的 DOM操作 是很“昂贵"的,为了更直观的感受,我把一个简单的 div 元素的属性都打印出来,如图2所示:

图2:dom元素属性

可以看到,浏览器把 DOM 设计的非常复杂、非常庞大。在浏览器当中,dom的实现和ECMAScript的实现是分离的。因此当我们频繁的去做 DOM 更新,就是频繁通过js代码调用dom的接口,就相当于两个相互独立的模块发生了交互。这样,相比于在同一个模块当中互相调用,这种跨模块的调用它的性能损耗是非常高的。并且dom操作导致浏览器的重绘(repaint)和重排(reflow)会带来更大的性能损耗。只要在渲染过程中进行一次 DOM 更新,整个渲染流程都会重做一遍。如图3所示:

图3:浏览器渲染流程

而 Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。图4所示为vitrual dom结构:

图4:Virtual DOM实例

上述的virtual dom最后会生成真实的dom结构。如图5所示:

图5:Virtual DOM映射成真实dom

在 Vue.js 中,Virtual DOM 是用 VNode 这么一个 Class 去描述, 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。当数据发生改变时是一次性渲染到页面,同时vue内部通过diff算法减少页面的重绘和重排,从而提高了页面渲染的速度。

2.2 Virtual DOM 主要思想

VirtualDOM的主要思想就是模拟DOM的树状结构,在内存中创建保存映射DOM信息的节点数据,在由于交互等因素需要视图更新时,先通过对节点数据进行diff得到差异结果后,再一次性对DOM进行批量更新操作,这就好比在内存中创建了一个平行世界,浏览器中DOM树的每一个节点与属性数据都在这个平行世界中存在着另一个版本的虚拟DOM树,所有复杂曲折的更新逻辑都在平行世界中的VirtualDOM处理完成,只将最终的更新结果发送给浏览器中的DOM树执行,这样就避免了冗余琐碎的DOM树操作负担,进而有效提高了性能。基于 Virtual DOM 的数据更新与UI同步机制:初始渲染时,首先将数据渲染为 Virtual DOM,然后由 Virtual DOM 生成 DOM。

图6:vitual dom生成dom示意图

比较两个 DOM 树的差异是 Virtual DOM 算法最核心的部分,这也是所谓的 Virtual DOM 的diff 算法。diff算法的核心是比较只会在同层级进行, 不会跨层级比较。而不像逐层逐层搜索遍历的方式,时间复杂度将会达到 O(n^3)的级别,代价非常高,而只比较同层级的方式时间复杂度可以降低到O(n)。可以用一张图示意:

图7:virtual dom更新示意图

数据更新时,渲染得到新的 Virtual DOM,与上一次的 Virtual DOM 进行 diff,得到所有需要在 DOM 上进行的变更,然后在 patch 过程中应用到 DOM 上实现UI的同步更新。因此 Virtual DOM算法主要包括这几步: 初始化视图的时候,用原生JS对象表示DOM树,生成一个对象树,然后根据这个对象树来生成一个真正的DOM树,插入到文档中。 当状态更新的时候,重新生成一个对象树,将新旧两个对象树做对比,记录差异。 把记录的差异应用到第一步生成的真正的DOM树上,视图更新完成。 在vue中也是采用了virtual dom的diff算法如下图,具体diff算法过程在patch函数执行。

图8:vitrual dom在vue中应用

三、数据渲染过程

3.1 数据绑定实现逻辑 ---- initState

本节正式分析从new vue()到数据渲染到页面的过程,在src/core/instance/index.js 中定义了一个Vue的构造函数。当执行new Vue(options)时就会执行this._init(options)这个函数。

图9: vue构造函数

以一个简单的实例开始。定义如下模版和js代码:

图10: 实例

在调用Vue构造函数时候传入el和data。此时传入this._init(options)中的options = { el: '#app', data: { message: '我是一条信息'}}。在_init函数中会执行一系列初始化操作:初始化生命周期、初始化事件、初始化数据等。其中初始化数据是本节关心的内容,跟数据绑定关联最大的是 initState。因此我们现在重点研究一下initState(vm)。入口是src/core/instance/state.js。

图11: initState函数

传入data后会调用initData(vm)函数,对data进行处理。initData函数将当前传入的data赋值给vm._data。vm是当前vue实例。然后会执行代理函数proxy。

图12: proxy函数

proxy函数的原理是通过 Object.defineProperty()函数在实例对象vm上定义与data数据字段同名的访问器属性,并且这些属性代理的值是vm._data上对应属性的值。当我们访问vm[key] 就会通过get方法去访问vm[sourceKey][key] 即vm._data[key]。也就是说vm.message 就会去访问vm._data.message也就是vm.data.message。所以 this.message就是this._data.message,只不过_data是vue内部使用的。这也就是我们通过this.message就能访问到data里面的message对应的值。即'我是一条信息‘。

图13: 数据绑定过程

同理,当我们设置一个属性值时会通过set方法去设置vm._data[key]的值。到这一步我们已经可以获取传入的data里面的数据了。那么message是如何渲染到页面视图层的呢?下一节就深入研究vue的挂载过程。

3.2 渲染函数 ---- render

3.2.1 对el的处理

_init函数执行完上述的初始化过程后会判断是否传入el,若传入就执行挂载函数$mount。

图14: $mount函数

$mount首先会通过query(el)函数对传入的el如下处理:

图15: query函数

如果el是字符串则通过document.querySelector(el)方法查找该字符串对应的dom元素。若没找到,则通过方document.createElement('div')方法动态创建一个div,若传入的el是dom元素。那么就返回该元素。最终都是用一个dom元素来挂载实例。值得注意的是Vue 不能挂载在 body、html 这样的根节点上。原因是vue在挂载是会将对应的dom对象替换成新的div,但body和html是不适合替换的。如果el是body或者html就会抛出警告,这也就是为什么平时我们通常会用“#app“或者“div“挂载实例的原因。

3.2.2 模版内容提取

判断是否传入render函数,如果渲染函数存在会直接调用运行时版 $mount 函数,我们知道运行时版 $mount 仅有两句代码,且真正的挂载是通过调用 mountComponent 函数完成的,所以可想而知 mountComponent 完成挂载所需的必要条件就是:提供渲染函数给 mountComponent。 render分为用户手写和模版编译两种形式,手写render函数格式如下。render函数的好处是,不会有在html中直接使用插值时,在实际挂载前出现{{message}}这样的内容。只有在render函数执行完成后才会把message替换到页面上去。这样会有更好的体验。下面的render函数最终会渲染成一个id为app1,内容为‘我是一条信息'的div元素,替换掉之前的挂载节点el。所以这也是为什么不使用body或者html进行挂载的原因,因为我们不能覆盖到整个body或者html。

图16: 手写render函数

在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render 方法,无论我们是用单文件 .vue 方式开发组件,还是写了 el 或者 template 属性,最终都会转换成 render 方法。我们的例子中没有传入render函数,因此需要来研究一下在没有传入render函数的情况下如何通过模版编译成render函数。

图17: template处理

(1)如果存在template并且传入的template是字符串,且以#开头,如下:

图18: template第一种形式

那么就找到id为#app的元素innerHtml();获取该innerHtml()是在idToTemplate函数中进行的。也是调用query函数获取对应的dom元素的innerHTML。并保存在template变量中。

图19: idToTemplate函数

(2)如果传入的template是dom节点。如下:那么直接将该节点的innerHtml赋值给template 变量。

图20: template第二种形式

(3)如果options中没有template,但是有el,那么就获取el对应元素的所有内容。

图21: el元素内容提取

getOuterHTML 函数的源码如下:

图22: getOuterHTML函数

它接收一个 DOM 元素作为参数,并返回该元素的 outerHTML。该函数首先判断了 el.outerHTML 是否存在,也就是说一个元素的 outerHTML 属性未必存在,实际上在 IE9-11 中 SVG 标签元素是没有 innerHTML 和 outerHTML 这两个属性的,解决这个问题的方案很简单,可以把 SVG 元素放到一个新创建的 div 元素中,这样新 div 元素的 innerHTML 属性的值就等价于 SVG标签 outerHTML 的值。

我们最初提供的实例子符合第(3)类情况。经过以上逻辑的处理之后,理想状态下此时 template 变量应该如下所示的一个模板字符串,将用于渲染函数的生成。

图22: 提取的模版字符串

模版提取过程如图23所示:

图23: 模版提取

3.2.3 模版编译成render函数

拿到模版内容后就会调用compileToFunctions函数将模版编译成render函数。下面看下compileToFunctions生成render方法的具体实现。编译主要有三个过程:

图23: 编译过程

1.解析模版字符串生成AST ---- parse(template.trim(), options)。 parse 会用正则等方式解析 template模板中的指令、class、style等数据,形成AST树。AST是一种用Javascript对象的形式来描述整个模版。parse会调用parseHTML函数,由于 parseHTML 的逻辑也非常复杂,因此我也用了伪代码的方式表达。

图24: parseHTML伪代码

整体来说它的逻辑就是循环解析 template ,用正则做各种匹配,对于不同情况分别进行不同的处理,直到整个 template 被解析完毕。 在匹配的过程中会利用 advance 函数不断前进整个模板字符串,直到字符串末尾。

图25: advance函数

为了更加直观地说明 advance 的作用,可以通过一副图表示:

调用 advance 函数:advance(4) 得到结果:

图26: advance函数执行示意图

所以在整个html循环中会不断调用advance函数,达到把这个html解析完毕的目的。详细过程有兴趣的小伙伴自行去了解。那么至此,parse 的过程就分析完了,看似复杂,但我们可以抛开细节理清它的整体流程。

图27: parse流程图

parse 的目标是把 template 模板字符串转换成 AST 树,它是一种用 JavaScript 对象的形式来描述整个模板。那么整个 parse 的过程是利用正则表达式顺序解析模板,当解析到开始标签、闭合标签、文本的时候都会分别执行对应的回调函数,来达到构造 AST 树的目的。个人理解就是把template(模板)解析成一个对象,该对象是包含这个模板所以信息的一种数据,而这种数据浏览器是不支持的,为Vue后面的处理template提供基础数据。本实例中会生成如下AST树。

图28: ast树形结构

2.优化AST语法树 ---- optimize(ast, options)。 为什么此处会有优化过程?我们知道Vue是数据驱动,是响应式的,但是template模版中并不是所有的数据都是响应式的,也有许多数据是初始化渲染之后就不会有变化的,那么这部分数据对应的DOM也不会发生变化。后面有一个 update 更新界面的过程,在这当中会有一个 patch 的过程, diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。

图29: optimize流程

3.codegen:将优化后的AST树转换成可执行的代码。

图30: codegen流程

template模版经历过parse->optimize->codegen三个过程之后,就可以得到render function函数了。

图31: 编译后生成的render函数

从模版提取到render函数的生成的过程总结如下:

图32: 编译render函数的过程

3.3 render到VNode的生成

调用 render.call(vm._renderProxy, vm.$createElement)函数并返回生成的虚拟节点(vnode)。可以看到,render 函数中 createElement 方法就是 vm.$createElement 方法。

图33: initRender函数

vm.$createElement 方法定义是在执行 initRender 方法的时候,可以看到除了 vm.$createElement 方法,还有一个 vm._c 方法,它是被模板编译成的 render 函数使用,而 vm.$createElement 是用户手写 render 方法使用的,这俩个方法支持的参数相同,并且内部都调用了 createElement 方法。

图34: createElement函数

createElement 方法实际上是对 _createElement 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _crateElement 。_createElement最终实例化VNode,返回vnode或者一个空的vnode。

图35: vnode实例化

本文例子生成如下的虚拟dom:

图36:生成的vnode

简单的梳理了createElement函数流程图,可以参考下图:

图37:createElement函数流程图

3.3虚拟DOM映射为真实DOM ----patch

vm._render 函数的作用是生成的虚拟节点(vnode)。vm._update 函数的作用是把 vm._render 函数生成的虚拟节点渲染成真正的 DOM。

图38:_update函数

update方法会在两种情况下被调用,一是new Vue初始化的时候,还有一种情况就是当我们改变data数据,页面重新渲染时调用。update最终会调用patch方法。patch实际调用的是createPatchFunction({ nodeOps, modules })。这个方法接收两个参数,nodeOps,modules。摘取一部分nodeOps内容

图39:nodeOps

可以看到,里面都是一些原生dom操作的封装,摘取modules一部分内容。

图40:modules

可以看到,是一些对原生dom特性控制的封装,以及一些辅助函数, 下面我们回到createPatchFunction,createPatchFunction 方法中首先定义了好多的辅助函数,最后返回了一个函数,即patch,来看下这个patch。在该函数中,第一个参数是dom元素,第二个参数是vnode。

图41:patch函数

一系列判断过后会执行emptyNodeAt()辅助函数,可以看到,emptyNodeAt()函数的功能是创建一个新的vnode。因此oldVnode = emptyNodeAt(oldNode)创新了新的vnode替换,而原来的oldNode(dom节点)可以在该vnode节点的elm元素中访问到。

图42:patch函数

取到当前的el对应的dom节点和其父节点后,开始利用createElm函数创建新的dom节点。在最初的实例中,oldElm就是id为app的div。而parentElm就是其父元素,即body。接下来调用createElm方法,这个方法定义于传入的modules辅助函数中, 这个方法才是真实dom操作的核心所在,它的作用就是将vnode挂载到真实的dom上。我们进入createElm。

图43:createElm函数

在createElm()函数中,主要完成的功能是将构建dom子节点插入到父节点中,并且一直循环到该节点没有子节点为止。这个过程createElm()函数和createChildren函数一起完成。

创建子节点

图44:创建子节点

可以看到,在createChildren()函数中,如果该vnode的子节点是矩阵的话,就会调用createElm()函数。因此两个函数是相互调用生成dom节点,然后插入到父节点的过程。如果该子节点是最后一个节点,则直接在dom节点后面插入该文本节点。最后调用insert将整个dom树一次性插入到body中。上面从主线上完成模版和数据的渲染。

图45:插入节点

此外,因为新建了一个div来渲染视图,因此应该把原来的就定义的用来挂载的dom节点(一般是个div)删掉。所以,可以看到,在vue的渲染过程中,会创建新的dom节点替换掉以前的节点,因此我们在初始化的时候不能将节点选择挂载在html和body上。

图46:删除原节点

四、总结

回过头来看,数据的渲染逻辑并不是特别复杂,核心关键的几步流程还是非常清晰的:
  1. new Vue,执行初始化,将传入的data数据绑定到当前实例,就可以通过this.message的形式访问传入的数据。这个过程是执行initData()函数完成的。
  2. 挂载$mount方法,通过自定义Render方法、template、el等生成Render函数。如果传入了模版(template)就将模版里面的内容编译成render函数,否则将传入的el对应的元素的内容编译成render函数。编译是调用compileToFunctions函数完成的。也可以自己手写render函数,可以减少编译这一环节。其中render渲染函数的优先级最高,template次之且需编译成渲染函数,而挂载点el属性对应的元素若存在,则在前两者均不存在时,其outerHTML才会用于编译与渲染。
  3. 生成render函数后,调用_createElement函数生成vnode。
  4. 将虚拟DOM映射为真实DOM页面上。
文章分类
前端
文章标签