virtual Dom 是什么 虚拟 DOM 其实就是js对象,通过对象的方式表示真实的 DOM 结构,将页面的状态抽象成js对象的形式
为什么需要虚拟dom 频繁的操作 DOM 会使得网站的性能下降,为了保证性能,我们需要使得 DOM 的操作尽量精简,我们可以通过操作虚拟 DOM 的方法,去比较新旧节点的差异然后精确的获取最小的,最为必要的 DOM 集合,最终挂载到真实的 DOM 上。因为操作数据结构,远比我们直接修改 DOM 节点来的快,我们真实的 DOM 操作在最好的情况下,其实只需要在最后来那么一下,他们喊是patch补丁一下
保证各框架性能下限,在不进行手动优化的情况下,提供系统过得去的性能
跨平台,例如服务端渲染(这个能力的根本,是 Javascript 代码能低代价 地在各个平台运行(得利于浏览器在各个平台的普及和 NodeJS),也就是常说的 Javascript 的优势之一是跨平台)
虚拟dom优缺点
优点是其抽象能力和常驻内存的特性,让vue/react框架能更容易实现更强大的 diff 算法(由于 Virtual DOM 的存在,diff 算法可以更方便且更强大)
缺点是增加了框架复杂度,也占用了更多的内存
(本想说一下几个优势,后来发现有大佬觉得这不是虚拟dom的优势)
大佬总结的几个关于 Virtual DOM 优势误区:
操作 DOM 太慢,操作 Virtual DOM 对象快 ❌ Virtual DOM 很快,但这并不是它的优势,因你本可以选择不使用 Virtual DOM 。
使用 Virtual DOM 可以避免频繁操作 DOM ,能有效减少回流和重绘次数 ❌ 无论你在一次事件循环中调用多少次的 DOM API ,浏览器也只会触发一次回流与重绘(如果需要),并且如果多次调用并没有修改 DOM 状态,那么回流与重绘一次都不会发生。批量操作也不能减少回流与重绘。
Virtual DOM 有跨平台优势 ❌ 跨平台是 Javascript 的优势,与 Virtual DOM 无关。
virtual dom 实现 主要是这四个步骤,基本上大差不差
用js对象模拟DOM树
render方法生成真实 DOM 节点
计算新老虚拟dom差异-即diff算法
将两个虚拟 DOM 对象的差异应用到真正的 DOM 树
用js对象模拟DOM树
tagName 对应真实的标签类型,比如ul标签p标签等等
attrs 表示节点上的所有属性,比如arrts: {class: 'list', style: "color:green"}
child 表示该节点的孩子节点, 比如['Vue']
1 2 3 4 5 6 7 8 9 10 export class Element { constructor (tagName, attrs = {}, child = []) { this .tagName = tagName this .attrs = attrs this .child = child } } function newElement (tag, attr, child ) { return new Element(tag, attr, child) }
调用 newElement方法即可生成
render方法生成真实 DOM 节点
增加设置对象属性的方法setAttr()
类的内部添加创建真实DOm节点的 render 方法
最后通过一个renderDom方法将dom 渲染到浏览器(appendChild)1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 export const setAttr = function (node, key, value ) { switch (key) { case 'style' : node.style.cssText = `${value} ` break ; case 'value' : let tagName = node.tagName || '' tagName = tagName.toLowerCase() if (tagName === 'input' || tagName === 'textarea' ) { node.value = value } else { node.setAttribute(key, value) } break ; default : node.setAttribute(key, value) break ; } } export class Element { constructor (tagName, attrs = {}, child = []) { this .tagName = tagName this .attrs = attrs this .child = child } render() { let ele = document .createElement(this .tagName) let attrs = this .attrs for (let key in attrs) { setAttr(ele, key, attrs[key]) } let childNodes = this .child childNodes.forEach(function (child ) { let childEle = child instanceof Element ? child.render() : document .createTextNode(child) ele.appendChild(childEle) }) return ele } } ... const RealDom = VdObj1.render()const renderDom = function (element, target ) { target.appendChild(element) } export function start ( ) { renderDom(RealDom, document .body) }
比较新老虚拟dom差异-即diff算法 diff 算法用来比较两棵 Virtual DOM 树的差异,如果需要两棵树的完全比较,那么 diff 算法的时间复杂度为O(n^3)。但是在前端当中,你很少会跨越层级地移动 DOM 元素,所以 Virtual DOM 只会对同一个层级的元素进行对比,如下图所示, div 只会和同一层级的 div 对比,第二层级的只会跟第二层级对比,这样算法复杂度就可以达到 O(n)
深度优先遍历,记录差异
根据差异更改原先真实的dom结构,即更新dom
深度优先遍历 记录俩棵树的差异 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 const diff = (oldNode, newNode ) => { let difference = {} walk(oldNode, newNode, 0 , difference) return difference } let initIndex = 0 const REMOVE = 'remove' const MODIFY_TEXT = 'modify_text' const CHANGE_ATTRS = 'change_attrs' const TACEREPLACE = 'replace' const walk = (oldNode, newNode, index, difference ) => { let diffResult = [] if (!newNode) { diffResult.push({ index, type: REMOVE }) } else if (typeof newNode === 'string' && typeof oldNode === 'string' ) { if (oldNode !== newNode) { diffResult.push({ index, value: newNode, type: MODIFY_TEXT }) } } else if (oldNode.tagName === newNode.tagNam) { let storeAttrs = {} for (let key in oldNode.attrs) { if (oldNode.attrs[key] !== newNode.attrs[key]) { storeAttrs[key] = oldNode.attrs[key] } } for (let key in newNode.attrs) { if (!oldNode.attrs.hasOwnProperty(key)) { storeAttrs[key] = newNode.attrs[key] } } if (Object .keys(storeAttrs).length > 0 ) { diffResult.push({ index, value: storeAttrs, type: CHANGE_ATTRS }) } if (oldNode.child && oldNode.child.length) { oldNode.child.forEach((child, index ) => { getDiff(child, newNode.child[index], ++initIndex, difference) }) } } else if (oldNode.tagName !== newNode.tagName) { diffResult.push({ index, type: TACEREPLACE, newNode }) } if (!oldNode) { diffResult.push({ newNode, type: TACEREPLACE }) } if (diffResult.length) { difference[index] = diffResult } }
根据差异更改原先真实的dom结构,即更新dom 现在我们已经生成了两个虚拟 DOM, 并且将两个 DOM 之间的差异用对象的方式保存了下来,接下来,我们就要通过这些来将差异更新到真实的 DOM 上面去!!下面的代码很长,但确实是这样的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 const patch = (node, difference ) => { let pacer = { index : 0 } patchNode(node, pacer, difference) } const patchNode = (node, pacer, difference ) => { let currentDifference = difference[pacer.index] let childNodes = node.childNodes childNodes.forEach((child ) => { pacer.index++ patchNode(child, pacer, difference) }) if (currentDifference) { doPatch(node, currentDifference) } } const doPatch = (node, difference ) => { difference.forEach(item => { switch (item.type) { case 'change_attrs' : const attrs = item.value for (let key in attrs) { if (node.nodeType !== 1 ) return const value = attrs[key] if (value) { setAttr(node, key, value) } else { node.removeAttribute(key) } } break case 'modify_text' : node.textContent = item.value break case 'replace' : let newNode = (item.newNode instanceof Element) ? item.newNode.render(item.newNode) : document .createTextNode(item.newNode) node.parentNode.replaceChild(newNode, node) break case 'remove' : node.parentNode.removeChild(node) break default : break } }) }
最终调用的时候,这样
1 2 3 4 5 export function start ( ) { renderDom(RealDom, document .body) const diffs = diff(VdObj1, VdObj2) patch(RealDom, diffs) }
dom diff过程 总结来说:
用JS对象模拟DOM(虚拟DOM)
把此虚拟DOM转成真实DOM并插入页面中(render)
如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
把差异对象应用到真正的DOM树上(patch)
vue的虚拟dom 看了这么多别人的关于虚拟dom的文章,今天的主角依然是vue中的虚拟dom和diff算法,不然面试的时候依然直接跪!
模板转换成视图的过程
Vue.js通过编译将template 模板转换成渲染函数(render function) ,执行渲染函数就可以得到一个虚拟节点树
在对数据模型进行操作的时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。这个过程主要是将新旧虚拟节点进行差异对比,然后根据对比结果进行DOM操作来更新视图。
针对上图有几个概念说下:
渲染函数:渲染函数是用来生成Virtual DOM的。Vue推荐使用模板来构建我们的应用界面,在底层实现中Vue会将模板编译成渲染函数,当然我们也可以不写模板,直接写渲染函数,以获得更好的控制。
VNode 虚拟节点:它可以代表一个真实的 dom 节点。通过 createElement 方法能将 VNode 渲染成 dom 节点。简单地说,vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点。
patch(也叫做patching算法):虚拟DOM最核心的部分,它可以将vnode渲染成真实的DOM,这个过程是对比新旧虚拟节点之间有哪些不同,然后根据对比结果找出需要更新的的节点进行更新。Vue的Virtual DOM Patching算法是基于Snabbdom的实现,并在此基础上作了很多的调整和改进
Vue模板编译原理 Vue首先会将template模板进行编译,其中包括parse/optimize/generate三个过程
parse parse会使用正则表达式解析template模板中的指令、class/style等数据,形成AST,及上文的这样
1 2 3 4 5 6 7 8 9 10 11 12 13 var element = { tagName: 'ul' , attrs: { class: 'item', id: 'list' }, children: [ {tagName : 'li' , attrs : {class : 'item1' }, children : "Item 1" }, {tagName : 'li' , attrs : {class : 'item2' }, children : "Item 2" }, {tagName : 'li' , attrs : {class : 'item3' , style : 'font-size: 20px' }, children : "Item 3" }, ] }
optimize optimize过程主要是为了优化后面的diff算法,vue在编译过程中会主动标记static静态节点,后续update更新视图的时候,patch过程中会直接跳过这些标记静态节点
generate 最后通过generate 将 AST 转化成 render function 字符串,得到结果是 render 的字符串以及 staticRenderFns 字符串,render函数字符串可能长这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function _render ( ) { with (this ) { return __h__( 'ul' , {staticClass : "list" }, [ " " , __h__('li' , {class : item}, [String ((msg))]), " " , __h__('li' , {class : item}, [String ((msg))]), "" , __h__('li' , {class : item}, [String ((msg))]), "" ] ) }; }
VNode模拟virtual-dom结构 从虚拟dom到真实DOM要经过vNode定义,diff、patch等过程,总结一下主要的流程
引入编译时版本vue.esm.js,执行initMixin 方法
初始化vue,创建一个Vue实例,执行this._init 方法,然后执行vm.$mount(vm.$options.el) 方法准备挂载,生命周期函数callHook(vm, ‘created’) 之后
Vue实例挂载,调用mountComponent方法 ,该方法核心就是先实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 VNode,最终调用 vm._update 更新 DOM
创建虚拟VNode,vm._render ,会调用createElement 方法,该方法最终会创建一个VNode实例 new VNode()
VNode类解析 在 Vue.js 中,Virtual DOM 是用 VNode 这个 Class 去描述,它定义在 src/core/vdom/vnode.js
中 ,从以下代码块中可以看到 Vue.js 中的 Virtual DOM 的定义较为复杂一些,因为它这里包含了很多 Vue.js 的特性。实际上 Vue.js 中 Virtual DOM 是借鉴了一个开源库 snabbdom 的实现,然后加入了一些 Vue.js 的一些特性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 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 ; key: string | number | void ; componentOptions: VNodeComponentOptions | void ; componentInstance: Component | void ; parent: VNode | void ; raw: boolean ; isStatic: boolean ; isRootInsert: boolean ; isComment: boolean ; isCloned: boolean ; isOnce: boolean ; asyncFactory: Function | void ; asyncMeta: Object | void ; isAsyncPlaceholder: boolean ; ssrContext: Object | void ; fnContext: Component | void ; fnOptions: ?ComponentOptions; devtoolsMeta: ?Object ; fnScopeId: ?string ; constructor ( tag?: string , data?: VNodeData, children?: ?Array <VNode>, text?: string , elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions, asyncFactory?: Function ) { this .tag = tag this .data = data this .children = children this .text = text this .elm = elm this .ns = undefined this .context = context this .fnContext = undefined this .fnOptions = undefined this .fnScopeId = undefined this .key = data && data.key this .componentOptions = componentOptions this .componentInstance = undefined this .parent = undefined this .raw = false this .isStatic = false this .isRootInsert = true this .isComment = false this .isCloned = false this .isOnce = false this .asyncFactory = asyncFactory this .asyncMeta = undefined this .isAsyncPlaceholder = false } }
抓重点,看几个关键的属性定义即可: tag 属性即这个vnode的标签属性,比如h1/span data 属性包含了最后渲染成真实dom节点后,节点上的class,attribute,style以及绑定的事件,比如attrs: {id: "app", class: "rootApp"}
children 属性是vnode的子节点 text 属性是文本属性 elm 属性为这个vnode对应的真实dom节点 key 属性是vnode的标记,在diff 过程中可以提高diff的效率
源码创建 VNode 过程 1.引入编译时版本vue.esm.js,执行initMixin 方法 2.初始化vue 实例化一个Vue实例时,即new Vue
时,执行的src/core/instance/index.js 中定义的 Function 函数
1 2 3 4 5 6 7 8 9 10 11 12 13 function Vue (options ) { if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue) ) { warn('Vue is a constructor and should be called with the `new` keyword' ) } this ._init(options) } initMixin(Vue) stateMixin(Vue) eventsMixin(Vue) lifecycleMixin(Vue) renderMixin(Vue)
通过查看 Vue 的 function,我们知道 Vue 只能通过 new 关键字初始化,然后调用 this._init 方法,该方法在 src/core/instance/init.js 中定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 export function initMixin (Vue: Class<Component> ) { Vue.prototype._init = function (options?: Object ) { const vm: Component = this vm._uid = uid++ let startTag, endTag vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm ) vm._self = vm initLifecycle(vm) initEvents(vm) initRender(vm) callHook(vm, 'beforeCreate' ) initInjections(vm) initState(vm) initProvide(vm) callHook(vm, 'created' ) if (vm.$options.el) { vm.$mount(vm.$options.el) } } }
3.Vue实例挂载 Vue 中是通过 $mount 实例方法去挂载 dom 的,下面我们通过分析 compiler 版本的 mount 实现,相关源码在目录src/platforms/web/runtime/entry-runtime-with-compiler.js
文件中定义:
1 2 3 4 5 6 7 Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && inBrowser ? query(el) : undefined return mountComponent(this , el, hydrating) }
我们发现$mount 方法实际上会去调用 mountComponent 方法,这个方法定义在 src/core/instance/lifecycle.js
文件中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el callHook(vm, 'beforeMount' ) let updateComponent updateComponent = () => { vm._update(vm._render(), hydrating) } new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate' ) } } }, true ) hydrating = false if (vm.$vnode == null ) { vm._isMounted = true callHook(vm, 'mounted' ) } return vm }
从上面的代码可以看到,mountComponent 核心就是先实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent
方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。
4.创建虚拟dom 紧接上面的vm._render ,Vue实例的一个私有方法,它用来把实例渲染成一个虚拟 Node,并返回该虚拟dom。它的定义在 src/core/instance/render.js
文件中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 Vue.prototype._render = function ( ): VNode { const vm: Component = this const { render, _parentVnode } = vm.$options vm.$vnode = _parentVnode let vnode try { currentRenderingInstance = vm vnode = render.call(vm._renderProxy, vm.$createElement) } catch (e) { handleError(e, vm, `render` ) if (process.env.NODE_ENV !== 'production' && vm.$options.renderError) { try { vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e) } catch (e) { handleError(e, vm, `renderError` ) vnode = vm._vnode } } else { vnode = vm._vnode } } finally { currentRenderingInstance = null } vnode.parent = _parentVnode return vnode }
vnode = render.call(vm._renderProxy, vm.$createElement)
生成虚拟dom,调用createElement 方法
1 vm.$createElement = (a, b, c, d ) => createElement(vm, a, b, c, d, true )
接着再调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 export function createElement ( context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean ): VNode | Array <VNode > { return _createElement(context, tag, data, children, normalizationType) } export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array <VNode > { vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined , undefined , context ) }
createElement 方法有 5 个参数, context 表示 VNode 的上下文环境,它是 Component 类型; tag表示标签,它可以是一个字符串,也可以是一个 Component; data 表示 VNode 的数据,它是一个 VNodeData 类型,可以在 flow/vnode.js 中找到它的定义; children 表示当前 VNode 的子节点,它是任意类型的,需要被规范为标准的 VNode 数组;
vue对Dom更新做了哪些标记优化处理 参考