10bet彻底搞懂虚拟Dom到真实Dom的生成过程

来源:http://www.chinese-glasses.com 作者:Web前端 人气:122 发布时间:2020-04-22
摘要:执行createComponent方法,如果是元素节点不会返回任何东西,所以是undefined,会继续走接下来的创建元素节点的逻辑。现在是组件,我们看下createComponent的实现: function normalizeChildren(ch

执行createComponent方法,如果是元素节点不会返回任何东西,所以是undefined,会继续走接下来的创建元素节点的逻辑。现在是组件,我们看下createComponent的实现:

function normalizeChildren(children) { // 手写`render`的处理函数 return isPrimitive(children) //原始类型 typeof为string/number/symbol/boolean之一 ? [createTextVNode(children)] // 转为数组的文本节点 : Array.isArray(children) // 如果是数组 ? normalizeArrayChildren(children) : undefined}
function createElm(vnode, insertedVnodeQueue, parentElm, refElm) { ... if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { // 组件分支 return } ...

这是VNode类定义的地方,挺吓人的,它支持一共最多八个参数,其实经常用到的并不多。如tag是元素节点的名称,children为它的子节点,text是文本节点内的文本。实例化后的对象就有二十三个属性作为在vue的内部一个节点的描述,它描述的是将它创建为一个怎样的真实Dom。大部分属性默认是false或undefined,而通过这些属性有效的值就可以组装出不同的描述,如真实的Dom中会有元素节点、文本节点、注释节点等。而通过这样一个VNode类,也可以描述出相应的节点,部分节点vue内部还做了相应的封装:

开始创建Dom,我们来看下它的定义:

将一个现有的VNode节点拷贝一份,只是被拷贝节点的isCloned属性为false,而拷贝得到的节点的isCloned属性为true,除此之外它们完全相同。元素节点

再回过头来看这张图,相信会好理解很多~我们再将本章最初的mountComponent之后的逻辑补充完整:

创建一个空的VNode,有效属性只有text和isComment来表示一个注释节点。

再有一棵树形结构的JavaScript对象后,我们现在需要做的就是将这棵树跟真实的Dom树形成映射关系,首先简单回顾之前遇到的mountComponent方法:

这里是对传入的参数处理,如果第三个参数传入的是数组(子元素)或者是基础类型的值,就将参数位置改变。然后对传入的最后一个参数是true还是false做处理,这会决定之后对children属性的处理方式。这里又是对_createElement做的封装,所以我们还要继续看它的定义:

这里大家记住一句话即可,无论VNode是什么类型的节点,只有三种类型的节点会被创建并插入到的Dom中:元素节点、注释节点、和文本节点。

组件节点

面试官微笑而又不失礼貌的问道:父子两个组件同时定义了beforeCreate、created、beforeMounte、mounted四个钩子,它们的执行顺序是怎么样的?怼回去:如果大家看完前面的章节,相信这个问题已经了然于胸了。首先会执行父组件的初始化过程,所以会依次执行beforeCreate、created、在执行挂载前又会执行beforeMount钩子,不过在生成真实dom的__patch__过程中遇到嵌套子组件后又会转为去执行子组件的初始化钩子beforeCreate、created,子组件在挂载前会执行beforeMounte,再完成子组件的Dom创建后执行mounted。这个父组件的__patch__过程才算完成,最后执行父组件的mounted钩子,这就是它们的执行顺序。执行顺序如下:

if(typeof tag === 'string'){ tag为h1标签 if(config.isReservedTag(tag)) { // 是html标签 vnode = new VNode( tag, // h1 data, // undefined children, 转为了 [{text: 'title h1'}] undefined, undefined, context ) }}...return vnode返回的vnode结构为:{ tag: h1, children: [ { text: title h1 } ]}
parent beforeCreateparent createdparent beforeMounte child beforeCreate child created child beforeMounte child mountedparent mounted
Vue.cid = 0let cid = 1Vue.extend = function (extendOptions = {}) { const Super = this // Vue基类构造函数 const name = extendOptions.name || Super.options.name const Sub = function (options) { // 定义构造函数 this._init(options) // _init继承而来 } Sub.prototype = Object.create(Super.prototype) // 继承基类Vue初始化定义的原型方法 Sub.prototype.constructor = Sub // 构造函数指向子类 Sub.cid = cid++ Sub.options = mergeOptions( // 子类合并options Super.options, // components, directives, filters, _base extendOptions // 传入的组件对象 ) Sub['super'] = Super // Vue基类 // 将基类的静态方法赋值给子类 Sub.extend = Super.extend Sub.mixin = Super.mixin Sub.use = Super.use ASSET_TYPES.forEach(function (type) { // ['component', 'directive', 'filter'] Sub[type] = Super[type] }) if (name) { 让组件可以递归调用自己,所以一定要定义name属性 Sub.options.components[name] = Sub // 将子类挂载到自己的components属性下 } Sub.superOptions = Super.options Sub.extendOptions = extendOptions return Sub}
export function mountComponent(vm, el) { ... const updateComponent = () = { vm._update(vm._render()) } new Watcher(vm, updateComponent, noop, { before() { if(vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true) ... callHook(vm, 'mounted') return vm}

首先我们会看到针对最后一个参数的布尔值对children做不同的处理,如果是编译的render函数,就将children格式化为一维数组:

new Vue == vm._init() == vm.$mount(el) == vm._render() == vm.update(vnode) 
export function createTextVNode (val) { return new VNode(undefined, undefined, undefined, String(val))}

nodeOps属性:封装了操作原生Dom的一些方法的集合,如创建、插入、移除这些,再使用到的地方再详解。

如果看到tag属性是vue-component开头就是组件了,以上就组件VNode的初始化。简单理解就是如果h函数的参数是组件对象,就将它转为一个Vue的子类,虽然组件VNode的children,text,ele为undefined,但它的独有属性componentOptions保存了组件需要的相关信息。它们的VNode生成了,接下来的章节我们将使用它们,将它们变为真实的Dom~。

template // app组件内模板 divapp text/div/template-------------------------{ // app内元素vnode tag: 'div', children: [ {text: app text} ], parent: { // 子组件_init时执行initLifecycle建立的关系 tag: 'vue-component-1-app', componentOptions: {...} }}
export function _createElement( context, tag, data, children, normalizationType ) { if (normalizationType === ALWAYS_NORMALIZE) { // 手写render函数 children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { //编译render函数 children = simpleNormalizeChildren(children) } if(typeof tag === 'string') { // 标签 let vnode, Ctor if(config.isReservedTag(tag)) { // 如果是html标签 vnode = new VNode(tag, data, children, undefined, undefined, context) } ... } else { // 就是组件了 vnode = createComponent(tag, data, context, children) } ... return vnode}
Vue.prototype._update = function(vnode) { ... 首次渲染 vm.$el = vm.__patch__(vm.$el, vnode) // 覆盖原来的vm.$el ...}
div id='app' class='wrap' h2 hello /h2/div
import activeInstance // 全局变量const init = vnode = { const child = vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance) ...}
运行时版本:Vue.prototype.$mount = function(el) { // 最初的定义 return mountComponent(this, query(el));}完整版:const mount = Vue.prototype.$mountVue.prototype.$mount = function(el) { // 拓展编译后的 if(!this.$options.render) { ---| if(this.$options.template) { ---| ...经过编译器转换后得到render函数 ---| 编译阶段 } ---| } ---| return mount.call(this, query(el))}-----------------------------------------------export function query(el) { // 获取挂载的节点 if(typeof el === 'string') { // 比如#app const selected = document.querySelector(el) if(!selected) { return document.createElement('div') } return selected } else { return el }}
export createComponentInstanceForVnode(vnode, parent) { // parent为全局变量activeInstance const options = { // 组件的options _isComponent: true, // 设置一个标记位,表明是组件 _parentVnode: vnode, parent // 子组件的父vm实例,让初始化initLifecycle可以建立父子关系 } return new vnode.componentOptions.Ctor(options) // 子组件的构造函数定义为Ctor}

1. 普通的元素节点转化为VNode

简单来说就是由里向外的挨个创建出真实的Dom,然后插入到它的父节点内,最后将创建好的Dom插入到body内,完成创建的过程,元素节点的创建还是比较简单的,我们接下来看下组件是怎么创建的。

export default class VNode { constructor ( tag data children text elm context componentOptions asyncFactory ) { this.tag = tag // 标签名 this.data = data // 属性 如id/class this.children = children // 子节点 this.text = text // 文本内容 this.elm = elm // 该VNode对应的真实节点 this.ns = undefined // 节点的namespace this.context = context // 该VNode对应实例 this.fnContext = undefined // 函数组件的上下文 this.fnOptions = undefined // 函数组件的配置 this.fnScopeId = undefined // 函数组件的ScopeId this.key = data  data.key // 节点绑定的key 如v-for this.componentOptions = componentOptions // 组件VNode的options this.componentInstance = undefined // 组件的实例 this.parent = undefined // vnode组件的占位符节点 this.raw = false // 是否为平台标签或文本 this.isStatic = false // 静态节点 this.isRootInsert = true // 是否作为根节点插入 this.isComment = false // 是否是注释节点 this.isCloned = false // 是否是克隆节点 this.isOnce = false // 是否是v-noce节点 this.asyncFactory = asyncFactory // 异步工厂方法 this.asyncMeta = undefined // 异步meta this.isAsyncPlaceholder = false // 是否为异步占位符 } get child () { // 别名 return this.componentInstance }}

再看一遍流程图,相信大家疑惑已经减少很多:

渲染App组件:new Vue({ render(h) { return h(App) }})VNode描述:{ tag: 'vue-component-2', componentInstance: {...}, componentOptions: {...}, context: {...}, data: {...}}
function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) { ... const children = vnode.children // [VNode, VNode, VNode] const tag = vnode.tag // div if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return // 如果是组件结果返回true,不会继续,之后详解createComponent } if(isDef(tag)) { // 元素节点 vnode.elm = nodeOps.createElement(tag) // 创建父节点 createChildren(vnode, children, insertedVnodeQueue) // 创建子节点 insert(parentElm, vnode.elm, refElm) // 插入 } else if(isTrue(vnode.isComment)) { // 注释节点 vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点 insert(parentElm, vnode.elm, refElm); // 插入到父节点 } else { // 文本节点 vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点 insert(parentElm, vnode.elm, refElm) // 插入到父节点 } ...}------------------------------------------------------------------nodeOps:export function createElement(tagName) { // 创建节点 return document.createElement(tagName)}export function createComment(text) { //创建注释节点 return document.createComment(text)}export function createTextNode(text) { // 创建文本节点 return document.createTextNode(text)}function insert (parent, elm, ref) { //插入dom操作 if (isDef(parent)) { // 有父节点 if (isDef(ref)) { // 有参考节点 if (ref.parentNode === parent) { // 参考节点的父节点等于传入的父节点 nodeOps.insertBefore(parent, elm, ref) // 在父节点内的参考节点之前插入elm } } else { nodeOps.appendChild(parent, elm) // 添加elm到parent内 } } // 没有父节点什么都不做}这算一个比较重要的方法,因为很多地方会用到。

我们可以在render函数内这样描述它:

我们已经执行完了vm._render方法拿到了VNode,现在将它作为参数传给vm._update方法并执行。vm._update这个方法的作用就是就是将VNode转为真实的Dom,不过它有两个执行的时机:

export function _createElement( context, tag, data, children, normalizationType ) { ... if(typeof tag === 'string') { // 标签 ... } else { // 就是组件了 vnode = createComponent( tag, // 组件对象 data, // undefined context, // 当前vm实例 children // undefined ) } ... return vnode}
const init = vnode = { const child = vnode.componentInstance = // 得到组件的实例 createComponentInstanceForVnode(vnode, activeInstance) child.$mount(undefined) // 那就手动挂载呗}

接着会满足_createElement方法内的这个条件:

activeInstance是一个全局的变量,再update方法内赋值为当前实例,再当前实例做__patch__的过程中作为子组件的父实例传入,在子组件的initLifecycle时构建组件关系。将createComponentInstanceForVnode执行的结果赋值给了vnode.componentInstance,所以看下它的返回的结果是什么:

首先将传入的el赋值给vm.$el,这个时候el是一个真实dom,接着会执行用户自己定义的beforeMount钩子。接下来会定义一个重要的函数变量updateComponent,它的内部首先会执行vm._render()方法,将返回的结果传入vm._update()内再执行。我们这章主要就来分析这个vm._render()方法做了什么事情,来看下它的定义:

很明显这个时候不是组件了,即使是组件也没关系,大不了还是执行一遍createComponent创建组件的逻辑,因为总会有组件是由元素节点组成的。这个时候我们执行一遍创建元素节点的逻辑,因为没有第三个参数父节点,所以组件的Dom虽然创建好了,并不会在这里插入。请注意这个时候组件的init已经完成,但是组件的createComponent方法并没有完成,我们补全它的逻辑:

main.jsnew Vue({ render(h) { return h(App) }})app.vueimport Child from '@/pages/child'export default { name: 'app', components: { Child }}

Ps: 这里modules属性内的钩子方法是区分平台的,web、weex以及SSR它们调用VNode方法方式并不相同,所以vue在这里又使用了函数柯里化这个骚操作,在createPatchFunction内将平台的差异化抹平,从而__patch__方法只用接收新旧node即可。生成Dom

{ beforeCreate: [ƒ] beforeDestroy: [ƒ] components: {Child: {…}} name: "app" render: ƒ () staticRenderFns: [] __file: "src/App.vue" _compiled: true}

大家可以先看下这个流程图有一个印象即可,接下来再看具体实现时相信思路会清晰很多:

组件的VNode会和元素节点相比会有两个特有的属性componentInstance和componentOptions。VNode的类型有很多,它们都是从这个VNode类中实例化出来的,只是属性不同。开始挂载阶段

无论是嵌套多么深的组件,遇到组件的后就执行init,在init的__patch__过程中又遇到嵌套组件,那就再执行嵌套组件的init,嵌套组件完成__patch__后将真实的Dom插入到它的父节点内,接着执行完外层组件的__patch__又插入到它的父节点内,最后插入到body内,完成嵌套组件的创建过程,总之还是一个由里及外的过程。

然后依次处理h('h2', "title h2"),h('h3', 'title h3')会得到三个VNode实例的节点。接着会执行最外层的h(div, [[VNode,VNode],[VNode]])方法,注意它的结构是二维数组,这个时候它就满足normalizeChildren方法内的Array.isArray(children)这个条件了,会执行normalizeArrayChildren这个方法:

我们现在先来看下vm._update方法的定义:

真实的注释节点:!-- 注释节点 --VNode描述:createEmptyVNode ('注释节点'){ text: '注释节点', isComment: true}

开始创建子节点,遍历VNode的每一项,每一项还是使用之前的createElm方法创建Dom。如果某一项又是数组,继续调用createChild创建某一项的子节点;如果某一项不是数组,创建文本节点并将它添加到父节点内。像这样使用递归的形式将嵌套的VNode全部创建为真实的Dom。

VNode描述:createTextVNode('文本节点'){ text: '文本节点'}

接下来会将updateComponent传入到一个Watcher的类中,这个类是干嘛的,我们下一章再说明,接下来执行mounted钩子方法。至此new Vue的整个流程就全部走完了。我们回顾下从new Vue开始它的执行顺序:

克隆节点

前面都还执行的好好的,最后却因为没有el属性,所以没有挂载,createComponentInstanceForVnode方法执行完毕。这个时候我们回到组件的init方法,补全剩下的逻辑:

export function initGlobalAPI(Vue) { ... Vue.options._base = Vue Vue.extend = function(extendOptions){...}}

__patch__是createPatchFunction方法内部返回的一个方法,它接受一个对象:

this._init() 方法的最后:... 初始化if (vm.$options.el) { vm.$mount(vm.$options.el)}

首次渲染时没有oldVnode,oldVnode就是$el,一个真实的dom,经过emptyNodeAt(oldVnode)方法包装:

我们只是定义了name和components属性,打印出来为什么会多了这么多属性?这是vue-loader解析后添加的,例如render: ƒ ()就是将App组件的template模板转换而来的,我们记住这个一个组件对象即可。

再将传入的$el属性转为了VNode格式之后,我们继续:

在经过初始化阶段之后,即将开始组件的挂载,不过在挂载之前很有必要提一下虚拟Dom的概念。这个想必大家有所耳闻,我们知道vue@2.0开始引入了虚拟Dom,主要解决的问题是,大部分情况下可以降低使用JavaScript去操作跨线程的庞大Dom所需要的昂贵性能,让Dom操作的性能更高;以及虚拟Dom可以用于SSR以及跨端使用。虚拟Dom,顾名思义并不是真实的Dom,而是使用JavaScript的对象来对真实Dom的一个描述。一个真实的Dom也无非是有标签名,属性,子节点等这些来描述它,如页面中的真实Dom是这样的:

我们接着来看下createPatchFunction它究竟返回一个什么样的方法:

Vue.prototype._render = function() { const vm = this const { render } = vm.$options const vnode = render.call(vm, vm.$createElement) return vnode}
export function createPatchFunction(backend) { ... return function (oldVnode, vnode) { // 接收新旧vnode const insertedVnodeQueue = [] ... const oldElm = oldVnode.elm //包装后的真实Dom div id='app'/div const parentElm = nodeOps.parentNode(oldElm) // 首次父节点为body/body createElm( // 创建真实Dom vnode, // 第二个参数 insertedVnodeQueue, // 空数组 parentElm, // body/body nodeOps.nextSibling(oldElm) // 下一个节点 ) return vnode.elm // 返回真实Dom覆盖vm.$el }} ------------------------------------------------------nodeOps:export function parentNode (node) { // 获取父节点 return node.parentNode }export function nextSibling(node) { // 获取下一个节点 return node.nextSibing }

时间: 2019-10-24阅读: 49标签: dom

return function patch(oldVnode, vnode) { ... if (isUndef(oldVnode)) { createElm(vnode, insertedVnodeQueue) } ...}
export function createComponent ( // 中 Ctor, data = {}, context, children, tag) { ... const listeners = data.on // 父组件v-on传递的事件对象格式 data.on = data.nativeOn // 组件的原生事件 installComponentHooks(data) // 为组件添加钩子方法 ...}

我们使用上一章组件生成的VNode,看下在createElm内创建组件Dom分支逻辑是怎么样的:

仔细观察extend这个方法不难发现,我们传入的组件对象相当于就是之前new Vue(options)里面的options,也就是用户自定义的配置,然后和vue之前就定义的原型方法以及全局API合并,然后返回一个新的构造函数,它拥有Vue完整的功能。让我们继续createComponent的其他逻辑:

Vue.prototype.__patch__ = createPatchFunction({ nodeOps, modules }) 
vm._c = (a, b, c, d) = createElement(vm, a, b, c, d, false) // 编译vm.$createElement = (a, b, c, d) = createElement(vm, a, b, c, d, true) // 手写

modules属性:创建真实Dom也需要生成它的如class/attrs/style等属性。modules是一个数组集合,数组的每一项都是这些属性对应的钩子方法,这些属性的创建、更新、销毁等都有对应钩子方法,当某一时刻需要做某件事,执行对应的钩子即可。比如它们都有create这个钩子方法,如将这些create钩子收集到一个数组内,需要在真实Dom上创建这些属性时,依次执行数组的每一项,也就是依次创建了它们。

只是设置了text属性,描述的是标签内的文本

本文由10bet发布于Web前端,转载请注明出处:10bet彻底搞懂虚拟Dom到真实Dom的生成过程

关键词:

最火资讯