上一篇文章我们介绍了 Vue2模版编译原理,这一章我们的目标是弄清楚模版 template和响应式数据是如何渲染成最终的DOM。数据更新驱动视图变化这部分后期会单独讲解
我们先看一下模版和响应式数据是如何渲染成最终DOM 的流程
Vue初始化
new Vue发生了什么
Vue入口构造函数
highlighter- reasonml
function Vue(options) { this._init(options) // options就是用户的选项 ... } initMixin(Vue) // 在Vue原型上扩展初始化相关的方法,_init、$mount 等 initLifeCycle(Vue) // 在Vue原型上扩展渲染相关的方法,_render、_c、_v、_s、_update 等 export default Vue
initMixin、initLifeCycle方法
highlighter- reasonml
export function initMixin(Vue) { Vue.prototype._init = function (options) { const vm = this vm.$options = options // 将用户的选项挂载到实例上 // 初始化数据 initState(vm) if (options.el) { vm.$mount(options.el) } } Vue.prototype.$mount = function (el) { const vm = this el = document.querySelector(el) let ops = vm.$options // 这里需要对模板进行编译 const render = compileToFunction(template) ops.render = render // 实例挂载 mountComponent(vm, el) } } export function initLifeCycle(Vue) { Vue.prototype._render = function () {} // 渲染方法 Vue.prototype._c = function () {} // 创建节点虚拟节点 Vue.prototype._v = function () {} // 创建文本虚拟节点 Vue.prototype._s = function () {} // 处理变量 Vue.prototype._update = function () {} // 初始化元素 和 更新元素 }
在 initMixin 方法中,我们重点关注 compileToFunction模版编译 和 mountComponent实例挂载 2个方法。我们已经在上一篇文章详细介绍过 compileToFunction 编译过程,接下来我们就把重心放在 mountComponent 方法上,它会用到在 initLifeCycle 方法给Vue原型上扩展的方法,在 render 和 update章节会做详细讲解
实例挂载
mountComponent 方法主要是 实例化了一个渲染 watcher,updateComponent 作为回调会立即执行一次。watcher 还有一个其他作用,就是当响应式数据发生变化时,也会通过内部的 update方法执行updateComponent 回调。
现在我们先无需了解 watcher 的内部实现及其原理,后面会作详细介绍
vm._render 方法会创建一个虚拟DOM(即以 VNode节点作为基础的树),vm._update 方法则是把这个虚拟DOM 渲染成一个真实的 DOM 并渲染出来
highlighter- reasonml
export function mountComponent(vm, el) { // 这里的el 是通过querySelector获取的 vm.$el = el const updateComponent = () => { // 1.调用render方法创建虚拟DOM,即以 VNode节点作为基础的树 const vnode = vm._render() // 内部调用 vm.$options.render() // 2.根据虚拟DOM 产生真实DOM,插入到el元素中 vm._update(vnode) } // 实例化一个渲染watcher,true用于标识是一个渲染watche const watcher = new Watcher(vm, updateComponent, true) }
接下来我们会重点分析最核心的 2 个方法:vm._render
和 vm._update
render
我们需要在Vue原型上扩展 _render 方法
highlighter- vim
Vue.prototype._render = function () { // 当渲染的时候会去实例中取值,我们就可以将属性和视图绑定在一起 const vm = this return vm.$options.render.call(vm) // 模版编译后生成的render方法 }
在之前的 Vue $mount过程中,我们已通过 compileToFunction方法将模版template 编译成 render方法,其返回一个 虚拟DOM。template转化成render函数的结果如下
highlighter- xquery
<div id="app" style="color: red; background: yellow"> hello {{name}} world <span></span> </div> ƒ anonymous( ) { with(this){ return _c('div',{id:"app",style:{"color":"red","background":"yellow"}}, _v("hello"+_s(name)+"world"), _c('span',null)) } }
render 方法内部使用了 _c、_v、_s 方法,我们也需要在Vue原型上扩展它们
- _c: 创建节点虚拟节点(VNode)
- _v: 创建文本虚拟节点(VNode)
- _s: 处理变量
highlighter- ada
// _c('div',{},...children) // _c('div',{id:"app",style:{"color":"red"," background":"yellow"}},_v("hello"+_s(name)+"world"),_c('span',null)) Vue.prototype._c = function () { return createElementVNode(this, ...arguments) } // _v(text) Vue.prototype._v = function () { return createTextVNode(this, ...arguments) } Vue.prototype._s = function (value) { if (typeof value !== 'object') return value return JSON.stringify(value) }
接下来我们看一下 createElementVNode 和 createTextVNode 是如何创建 VNode 的
createElement
每个 VNode 有 children,children 每个元素也是一个 VNode,这样就形成了一个虚拟树结构,用于描述真实的DOM树结构,即我们的虚拟DOM
highlighter- actionscript
// h() _c() 创建元素的虚拟节点 VNode export function createElementVNode(vm, tag, data, ...children) { if (data == null) { data = {} } let key = data.key if (key) { delete data.key } return vnode(vm, tag, key, data, children) } // _v() 创建文本虚拟节点 export function createTextVNode(vm, text) { return vnode(vm, undefined, undefined, undefined, undefined, text) } // 虚拟节点 function vnode(vm, tag, key, data, children, text) { return { vm, tag, key, data, children, text, // .... } }
VNode 和 AST一样吗?
我们的 VNode 描述的是 DOM元素
AST 做的是语法层面的转化,它描述的是语法本身 ,可以描述 js css html
虚拟DOM
DOM是很慢的,其元素非常庞大,当我们频繁的去做 DOM更新,会产生一定的性能问题,我们可以直观感受一下div元素包含的海量属性
在Javascript对象中,Virtual DOM 表现为一个 Object对象。并且最少包含标签名 (tag)、属性 (attrs) 和子元素对象 (children) 三个属性,不同框架对这三个属性的名命可能会有差别。
实际上它只是一层对真实DOM的抽象,以JavaScript 对象 (VNode 节点) 作为基础的树,用对象的属性来描述节点,最终可以通过一系列操作使这棵树映射到真实环境上
vue中 VNode结构如下
highlighter- arduino
export default class VNode { tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope functionalContext: Component | void; // only for functional component root nodes key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node? constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions ) { /*当前节点的标签名*/ this.tag = tag /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/ this.data = data /*当前节点的子节点,是一个数组*/ this.children = children /*当前节点的文本*/ this.text = text /*当前虚拟节点对应的真实dom节点*/ this.elm = elm /*当前节点的名字空间*/ this.ns = undefined /*编译作用域*/ this.context = context /*函数化组件作用域*/ this.functionalContext = undefined /*节点的key属性,被当作节点的标志,用以优化*/ this.key = data && data.key /*组件的option选项*/ this.componentOptions = componentOptions /*当前节点对应的组件的实例*/ this.componentInstance = undefined /*当前节点的父节点*/ this.parent = undefined /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/ this.raw = false /*静态节点标志*/ this.isStatic = false /*是否作为跟节点插入*/ this.isRootInsert = true /*是否为注释节点*/ this.isComment = false /*是否为克隆节点*/ this.isCloned = false /*是否有v-once指令*/ this.isOnce = false } // DEPRECATED: alias for componentInstance for backwards compat. /* istanbul ignore next https://github.com/answershuto/learnVue*/ get child (): Component | void { return this.componentInstance } }
- 提升效率。操作 DOM的代价是昂贵的,使用 diff算法,可以减少 JavaScript操作真实DOM 带来的性能消耗
通过 Virtual DOM 改变真正的 DOM并不比直接操作 DOM效率更高。恰恰相反,Virtual DOM 仍需要调用 DOM API 去操作 DOM,并且还会额外占用内存。but!!!我们可以通过 diff算法,找到需要更新的最小单位,最大限度地减少DOM操作。而且在大量频繁数据更新后,并不会立即重流重绘,而是批量操作真实的 DOM,最大限度的减少DOM操作,从而提升性能
- 跨平台。抽象了原本的渲染过程,提供了一个中间抽象层(runtime-dom/src/nodeOps),使我们可以在不接触真实DOM 的情况下操作 DOM,实现了跨平台的能力。而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,也可以是近期很火热的小程序。
runtime-dom/src/nodeOps 这里存放常见 DOM操作API,不同运行时(浏览器、小程序......)提供的具体实现不一样,最终将操作方法传递到 runtime-core中,所以 runtime-core不需要关心平台相关代码
update
vm._update 的作用就是把 VNode 渲染成真实的DOM
vm._update 被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候。我们暂时先不考虑数据更新部分
highlighter- vim
Vue.prototype._update = function (vnode) { // 将vnode转化成真实dom const vm = this const el = vm.$el // patch既有初始化元素的功能 ,又有更新元素的功能 vm.$el = patch(el, vnode) }
vm._update 核心就是调用 patch 方法,parentElm 就是 oldVNode 的父元素,即我们的 body 节点,通过 createElm 递归创建一个完整的 DOM树 并 插入到 body 节点中,然后删除老节点
highlighter- javascript
// 利用vnode创建真实元素 function createElm(vnode) { let { tag, data, children, text } = vnode if (typeof tag === 'string') { // 标签 vnode.el = document.createElement(tag) // 这里将真实节点和虚拟节点对应起来,后续如果修改属性了 patchProps(vnode.el, data) children.forEach(child => { vnode.el.appendChild(createElm(child)) }) } else { vnode.el = document.createTextNode(text) } return vnode.el } // 对比属性打补丁 function patchProps(el, props) { for (let key in props) { if (key === 'style') { // { color: 'red', "background": 'yellow' } for (let styleName in props.style) { console.log(styleName, props.style[styleName]) el.style[styleName] = props.style[styleName] } } else { el.setAttribute(key, props[key]) } } } // patch既有初始化元素的功能 ,又有更新元素的功能 function patch(oldVNode, vnode) { // 写的是初渲染流程 const isRealElement = oldVNode.nodeType if (isRealElement) { const elm = oldVNode // 获取真实元素 const parentElm = elm.parentNode // 拿到父元素 let newElm = createElm(vnode) parentElm.insertBefore(newElm, elm.nextSibling) parentElm.removeChild(elm) // 删除老节点 return newElm } else { // diff算法,暂时先不考虑 } }