|
|
51CTO旗下网站
|
|
移动端

一张图轻松理解模版和数据是如何被渲染成DOM的

Vue.js 一个核心思想是数据驱动。也就是说视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。

作者:二十七刻来源:掘金|2020-05-22 09:40

前言

Vue.js 一个核心思想是数据驱动。也就是说视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。当交互复杂的时候,只关心数据的修改会让代码的逻辑变的非常清晰,因为 DOM 变成了数据的映射,我们所有的逻辑都是对数据的修改,而不用碰触 DOM,这样的代码非常利于维护。

在 Vue.js 中我们可以采用简洁的模板语法来声明式的将数据渲染为 DOM:

  1. <div id="app"
  2.   {{ msg }} 
  3. </div> 
  1. var app = new Vue({ 
  2.   el: '#app'
  3.   data: { 
  4.     msg: 'Hello world!' 
  5.   } 
  6. }) 

结果页面上会展示出Hello world!。这是入门vue.js的时候就知道的知识。那么现在要问vue.js的源码到底做了什么,才能让模版和数据最终被渲染成了DOM???

从 new Vue() 开始

在写vue 项目的时候,会在项目的入口文件 main.js文件里实例化一个vue 。如下:

  1. var app = new Vue({ 
  2.   el: '#app'
  3.   data: { 
  4.     msg: 'Hello world!' 
  5.   }, 
  6. }) 

Vue 就是一个用 Function 实现的类。源码如下:在src/core/instance/index.js中

  1. // _init 方法所在的位置 
  2. import { initMixin } from './init'  
  3. // Vue就是一个用 Function 实现的类,所以才通过 new Vue 去实例化它。 
  4. function Vue (options) { 
  5.  if (process.env.NODE_ENV !== 'production' && 
  6.    !(this instanceof Vue) 
  7.  ) { 
  8.    warn('Vue is a constructor and should be called with the `new` keyword'
  9.  } 
  10.  this._init(options) 

当我们在项目中 new Vue({})传入一个对象的时候,其实就是执行的上面的方法,并传入参数为 options ,然后调用了this._init(options)方法。该方法在src/core/instance/init.js文件中。代码如下:

  1. import { initState } from './state' 
  2. Vue.prototype._init = function (options?: Object) { 
  3.     const vm: Component = this 
  4.     // 定义了uid 
  5.     vm._uid = uid++ 
  6.  
  7.     let startTag, endTag 
  8.     if (process.env.NODE_ENV !== 'production' && config.performance && mark) { 
  9.       startTag = `vue-perf-start:${vm._uid}` 
  10.       endTag = `vue-perf-end:${vm._uid}` 
  11.       mark(startTag) 
  12.     } 
  13.  
  14.     vm._isVue = true 
  15.     // 合并options  
  16.     if (options && options._isComponent) { 
  17.       initInternalComponent(vm, options) 
  18.     } else { 
  19.       // 这里将传入的options全部合并在$options上。 
  20.       // 因此我们可以通过$el访问到 vue 项目中new Vue 中的el 
  21.       // 通过$options.data 访问到 vue 项目中new Vue 中的data 
  22.       vm.$options = mergeOptions( 
  23.         resolveConstructorOptions(vm.constructor), 
  24.         options || {}, 
  25.         vm 
  26.       ) 
  27.     } 
  28.     if (process.env.NODE_ENV !== 'production') { 
  29.       initProxy(vm) 
  30.     } else { 
  31.       vm._renderProxy = vm 
  32.     } 
  33.     // 初始化函数 
  34.     vm._self = vm 
  35.     initLifecycle(vm) // 生命周期函数 
  36.     initEvents(vm) // 初始化事件链 
  37.     initRender(vm) 
  38.     callHook(vm, 'beforeCreate'
  39.     initInjections(vm) // resolve injections before data/props 
  40.     initState(vm) 
  41.     initProvide(vm) // resolve provide after data/props 
  42.     callHook(vm, 'created'
  43.  
  44.     if (process.env.NODE_ENV !== 'production' && config.performance && mark) { 
  45.       vm._name = formatComponentName(vm, false
  46.       mark(endTag) 
  47.       measure(`vue ${vm._name} init`, startTag, endTag) 
  48.     } 
  49.     // 判断当前的$options.el是否有el 也就是说是否传入挂载的DOM对象 
  50.     if (vm.$options.el) { 
  51.       vm.$mount(vm.$options.el) 
  52.     } 
  53.   } 

由以上代码可知 this._init(options)主要是合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher 等等。重要的部分在代码里做里注释。

那么接下来依然从其中一个功能为例进行分析:以initState(vm)为例:

为什么在钩子函数里可以访问到 data 里定义的数据?

vue 项目中,当定义了 data 就可以在组件的钩子函数 或者 在 methods 函数里都可以访问到data 里定义的属性。这是为什么??

  1. var app = new Vue({ 
  2.   el: '#app'
  3.   data:(){ 
  4.       return
  5.           msg: 'Hello world!' 
  6.       } 
  7.   }, 
  8.   mounted(){ 
  9.     console.log(this.msg) // logs 'Hello world!' 
  10.   }, 

分析源码:可以看到this._init(options)方法,在初始化函数部分有一个 initState(vm)函数。该方法实在./state.js中:具体代码如下:

  1. export function initState (vm: Component) { 
  2.   vm._watchers = [] 
  3.   const opts = vm.$options 
  4.   // 如果定义了 props 就初始化props; 
  5.   if (opts.props) initProps(vm, opts.props) 
  6.   // 如果定义了methods 就初始化methods; 
  7.   if (opts.methods) initMethods(vm, opts.methods) 
  8.   if (opts.data) { 
  9.     // 如果定义了data,就初始化data;(要分析的内容从这里开始) 
  10.     initData(vm) 
  11.   } else { 
  12.     observe(vm._data = {}, true /* asRootData */) 
  13.   } 
  14.   if (opts.computed) initComputed(vm, opts.computed) 
  15.   if (opts.watch && opts.watch !== nativeWatch) { 
  16.     initWatch(vm, opts.watch) 
  17.   } 

在initState方法中判断:如果定义了data,就初始化data;继续看初始化data 的函数:initData(vm)。代码如下:

  1. function initData (vm: Component) { 
  2.  /*  
  3.   这个data 就是 我们vue 项目中定义的data。也就是上面例子中的  
  4.   data(){ 
  5.     return { 
  6.       msg: 'Hello world!' 
  7.     } 
  8.   } 
  9.   */ 
  10.   let data = vm.$options.data 
  11.   // 拿到data 后,做了判断,判断它是不是一个function 
  12.   data = vm._data = typeof data === 'function' 
  13.     ? getData(data, vm) // 如果是 执行了getData()方法 ,这个方法就是返回data 
  14.     : data || {} 
  15.   // 如果不是一个对象则在开发环境报出一个警告 
  16.   if (!isPlainObject(data)) { 
  17.     data = {} 
  18.     process.env.NODE_ENV !== 'production' && warn( 
  19.       'data functions should return an object:\n' + 
  20.       'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function'
  21.       vm 
  22.     ) 
  23.   } 
  24.   // 拿到data 定义的属性 
  25.   const keys = Object.keys(data)  
  26.   // 拿到props 
  27.   const props = vm.$options.props 
  28.   // 拿到 methods 
  29.   const methods = vm.$options.methods 
  30.   let i = keys.length 
  31.   // 做了一个循环对比,如果在data 上定义的属性,就不能在props与methods在定义该属性。因为不管是data里定义的,在props里定义的,还是在medthods里定义的,最终都挂载在vm实例上了。见proxy(vm, `_data`, key
  32.   while (i--) { 
  33.     const key = keys[i] 
  34.     if (process.env.NODE_ENV !== 'production') { 
  35.       if (methods && hasOwn(methods, key)) { 
  36.         warn( 
  37.           `Method "${key}" has already been defined as a data property.`, 
  38.           vm 
  39.         ) 
  40.       } 
  41.     } 
  42.     if (props && hasOwn(props, key)) { 
  43.       process.env.NODE_ENV !== 'production' && warn( 
  44.         `The data property "${key}" is already declared as a prop. ` + 
  45.         `Use prop default value instead.`, 
  46.         vm 
  47.       ) 
  48.     } else if (!isReserved(key)) { 
  49.       proxy(vm, `_data`, key) // 代理 定义了Getter 和 Setter 
  50.     } 
  51.   } 
  52.   // observe data 
  53.   observe(data, true /* asRootData */) 
  1. // proxy 代理 
  2. const sharedPropertyDefinition = { 
  3.   enumerable: true
  4.   configurable: true
  5.   get: noop, 
  6.   set: noop 
  7. export function proxy (target: Object, sourceKey: string, key: string) { 
  8.   // 通过对象 sharedPropertyDefinition  定义了Getter 和 Setter 
  9.   sharedPropertyDefinition.get = function proxyGetter () { 
  10.     return this[sourceKey][key
  11.     // 当访问vm.key 的时候其实访问的是 vm[sourceKey][key
  12.     // 以上述开始的问题,当访问this.msg 实际是访问 this._data.msg 
  13.   } 
  14.   sharedPropertyDefinition.set = function proxySetter (val) { 
  15.     this[sourceKey][key] = val 
  16.   } 
  17.   // 对vm 的 key 做了一次Getter 和 Setter 
  18.   Object.defineProperty(target, key, sharedPropertyDefinition) 
  19.    

综上:初始化 data 实在./state.js文件里。执行initState() 方法,该方法判断如果定义了data,就初始化data。

如果data 是一个function,就执行了getData()方法return data.call(vm, vm)。然后对 vm 上的 data 里定义的属性、vm上的 props 、vm上的methods里的属性进行循环比对,如果在data 上定义的属性,就不能在props与methods在定义该属性。因为不管是data里定义的,在props里定义的,还是在medthods里定义的,最终都挂载在vm实例上了。见proxy(vm, _data, key)。

然后通过proxy 方法给vm 上的属性做了Getter 和 Setter 方法的绑定。回到上述的问题,当访问this.msg 实际是访问 vm._data.msg。因此在钩子函数里确实可以访问到 data 里定义的数据了。

不得不在说一遍,Vue 的初始化逻辑写的非常清楚,把不同的功能逻辑拆成一些单独的函数执行,让主线逻辑一目了然,这样的编程思想是非常值得借鉴和学习的。

其它初始化的内容大家可以自己补充,接下来看挂载vm。在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM,那么接下来探究 Vue 的挂载过程吧

Vue 实例挂载的实现

Vue 中我们是通过 $mount 实例方法去挂载 vm 的。接下来要探究执行$mount('#app')的时候,源码都干了什么???

  1. new Vue({ 
  2.   render: h => h(App), 
  3. }).$mount('#app'

$mount 方法在多个文件中都有定义,如 src/platform/web/entry-runtime-with-compiler.js、src/platform/web/runtime/index.js、src/platform/weex/runtime/index.js。因为 $mount 这个方法的实现是和平台、构建方式都有关系。

就选取 compiler 版本的 $mount 分析吧,文件地址在src/platform/web/entry-runtime-with-compiler.js,代码如下:

  1. // 获取vue 原型上的 $mount 方法, 存在变量 mount 上。 
  2. const mount = Vue.prototype.$mount 
  3. Vue.prototype.$mount = function ( 
  4.   el?: string | Element, 
  5.   hydrating?: boolean 
  6. ): Component { 
  7.   // query 定义在 './util/index'文件中 
  8.   // 调用原生的DOM api querySelector() 方法。最后将el转化为一个DOM 对象。 
  9.   el = el && query(el) 
  10.   ... 
  11.   return mount.call(this, el, hydrating) 

读代码可知,代码首先获取了 vue 原型上的 $mount 方法,将其存在变量mount中,然后重新定义了该方法。该方法对传入的el做了处理,el 可以是个字符串,也可以是DOM 对象。然后调用了 query()方法,该方法在./util/index文件中。主要是调用原生的DOM api querySelector() 方法。最后将el转化为一个DOM 对象返回。上述只贴出了主要的代码部分。

源码了还对el进行了判断,判断传入的el 是否为body 或者 html ,如果是,就会在开发环境报一个警告。vue 不可以直接挂载到body 和html上 ,因为会被覆盖,当覆盖了 html 或 body 整个文档就会报错。

源码还获取到 $options 判断是否定义render方法。如果没有定义 render 方法,则会把 el 或者 template 字符串最终将编译为render()函数。

最后 return mount.call(this, el, hydrating)。此处的mount是vue 原型上的 $mount 方法。在文件./runtime/index。代码如下:

  1. Vue.prototype.$mount = function ( 
  2.   el?: string | Element, 
  3.   hydrating?: boolean 
  4. ): Component { 
  5.   el = el && inBrowser ? query(el) : undefined 
  6.   return mountComponent(this, el, hydrating) 

其中参数 el 表示挂载的元素,它可以是字符串,也可以是一个DOM 对象。如果是字符串在浏览器环境下会调用 query() 方法转换成 DOM 对象。第二个参数是和服务端渲染相关,在浏览器环境下我们不需要传第二个参数。最后return 的时候调用了mountComponent()方法。该方法定义在src/core/instance/lifecycle.js,代码如下:

  1. export function mountComponent ( 
  2.   vm: Component, 
  3.   el: ?Element, 
  4.   hydrating?: boolean 
  5. ): Component { 
  6.     vm.$el = el 
  7.     ... 
  8.     let updateComponent 
  9.     /* istanbul ignore if */ 
  10.     if (process.env.NODE_ENV !== 'production' && config.performance && mark) { 
  11.       updateComponent = () => { 
  12.         const name = vm._name 
  13.         const id = vm._uid 
  14.         const startTag = `vue-perf-start:${id}` 
  15.         const endTag = `vue-perf-end:${id}` 
  16.      
  17.         mark(startTag) 
  18.         const vnode = vm._render() 
  19.         mark(endTag) 
  20.         measure(`vue ${name} render`, startTag, endTag) 
  21.      
  22.         mark(startTag) 
  23.         vm._update(vnode, hydrating) 
  24.         mark(endTag) 
  25.         measure(`vue ${name} patch`, startTag, endTag) 
  26.       } 
  27.     } else { 
  28.       updateComponent = () => { 
  29.         vm._update(vm._render(), hydrating) 
  30.       } 
  31.     } 
  32.    
  33.     new Watcher(vm, updateComponent, noop, { 
  34.     before () { 
  35.       if (vm._isMounted && !vm._isDestroyed) { 
  36.         callHook(vm, 'beforeUpdate'
  37.       } 
  38.     } 
  39.   }, true /* isRenderWatcher */) 
  40.   hydrating = false 
  41.  
  42.   // manually mounted instance, call mounted on self 
  43.   // mounted is called for render-created child components in its inserted hook 
  44.   if (vm.$vnode == null) { 
  45.     vm._isMounted = true 
  46.     callHook(vm, 'mounted'
  47.   } 
  48.   return vm 

读代码可知,该方法首先实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render() 方法先生成虚拟DOM节点,最终调用 vm._update 更新 DOM。

最后判断为根节点的时候设置 vm._isMounted 为 true, 表示这个实例已经挂载了,同时执行 mounted 钩子函数。 vm.$vnode 表示 Vue 实例的父虚拟节点,所以它为 Null 则表示当前是根 Vue 的实例。

那么vm._render()是怎样生成虚拟DOM节点的呢?

_render()渲染虚拟DOM 节点

在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render()。Vue 的 _render() 是实例的一个私有方法,它用来把实例渲染成一个虚拟DOM节点。它的定义在 src/core/instance/render.js 文件中,代码如下:

  1. Vue.prototype._render = function (): VNode { 
  2.     const vm: Component = this 
  3.     const { render, _parentVnode } = vm.$options 
  4.      
  5.     ... 
  6.      
  7.     let vnode 
  8.     try { 
  9.       currentRenderingInstance = vm 
  10.       vnode = render.call(vm._renderProxy, vm.$createElement) 
  11.     } 
  12.   } 

上述代码 从vue实例的 $options 上获取到 render 函数。通过call()调用了_renderProxy和 createElement()方法,先来探索createElement()方法。

createElement()

createElement()是在initRender()中。如下:

  1. // 该函数是在 _init() 过程中执行 initRender() 
  2. // 见 './init.js' 文件中的 initRender(vm) 传入vm。就执行到下面的方法。 
  3. export function initRender (vm: Component) { 
  4.     // 被编译后生成的render函数 
  5.     vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)  
  6.     // 手写render函数 创建 vnode 的方法。 
  7.     vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)  

initRender()是在 _init过程中执行了initRender()见 ./init.js 文件中的 initRender(vm)传入vm。

在 vue 项目实际开发中,手写 render 函数 案例如下:

  1. new Vue({ 
  2.   render(createElement){ 
  3.     return createElement('div',{ 
  4.       style:{color:'red'
  5.     },this.msg) 
  6.   }, 
  7.   data(){ 
  8.     return
  9.       msg:"hello world" 
  10.     } 
  11.   } 
  12. }).$mount('#app'

因为是手写的render函数省去了将 template 编译为 render函数的过程,因此性能更好。

接下来看_renderProxy方法:

_renderProxy

_renderProxy方法,也是在 init 过程中执行的。见文件./init.js中,代码如下:

  1. import { initProxy } from './proxy' 
  2.  
  3. if (process.env.NODE_ENV !== 'production') { 
  4.     initProxy(vm) 
  5. else { 
  6.     vm._renderProxy = vm 

如果当前环境为生产环境 就将 vm 直接赋值给 vm._renderProxy;

如果当前环境为开发环境,则执行initProxy()。

该函数在./proxy.js文件中,代码如下:

  1. initProxy = function initProxy (vm) { 
  2.     // 判断浏览器是否支持 proxy 。 
  3.     if (hasProxy) { 
  4.       // determine which proxy handler to use 
  5.       const options = vm.$options 
  6.       const handlers = options.render && options.render._withStripped 
  7.         ? getHandler 
  8.         : hasHandler 
  9.       vm._renderProxy = new Proxy(vm, handlers) 
  10.     } else { 
  11.       vm._renderProxy = vm 
  12.     } 
  13.   }  

首先判断浏览器是否支持 proxy。它是ES6 新增的,用于给目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

如果浏览器不支持 proxy, 就将 vm 直接赋值给 vm._renderProxy;

如果浏览器支持 proxy,就执行new Proxy()。

综上所述:vm._render 是通过执行 createElement 方法并返回虚拟的DOM 节点。那么什么是虚拟的DOM呢???

虚拟的DOM

在探究vue 的虚拟DOM 之前,先推荐一个虚拟DOM开源库。有时间,有兴趣的朋友可以去深入了解。它是用一个函数去表示一个应用程序的视图层。view.js 是借鉴它实现了虚拟DOM。从而大大的提升了程序的性能。接下来我们就来看vue.js是怎么做的。

vnode 的定义在 src/core/vdom/vnode.js文件中,如下:

  1. export default class VNode { 
  2.  tag: string | void; 
  3.  data: VNodeData | void; 
  4.  children: ?Array<VNode>; 
  5.  text: string | void; 
  6.  elm: Node | void; 
  7.  ... 

虚拟DOM 是个js对象,是对真实DOM 的一种抽象描述,比如标签名、数据、子节点名等。因为虚拟DOM只是用来映射真实DOM的渲染,所以不包含操作DOM的方法操作DOM的方法。因此更加的轻量,更加的简单。因为虚拟DOM 的创建是通过createElement方法,那这个环节又是如何实现的呢???

createElement

Vue.js 利用 createElement 方法创建 DOM节点,它定义在 src/core/vdom/create-elemenet.js文件中,代码如下:

  1. export function createElement ( 
  2.  context: Component, // vm 实例 
  3.  tag: any, // 标签 
  4.  data: any, // 数据 
  5.  children: any,// 子节点 可以构造DOM 树 
  6.  normalizationType: any
  7.  alwaysNormalize: boolean 
  8. ): VNode | Array<VNode> { 
  9.  // 对参数不一致的处理 
  10.  if (Array.isArray(data) || isPrimitive(data)) { 
  11.    normalizationType = children 
  12.    children = data 
  13.    data = undefined 
  14.  } 
  15.  if (isTrue(alwaysNormalize)) { 
  16.    normalizationType = ALWAYS_NORMALIZE 
  17.  } 
  18.  // 处理好参数,则调用 _createElement() 去真正的创建节点。 
  19.  return _createElement(context, tag, data, children, normalizationType) 

createElement 方法是对 _createElement 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 DOM 节点的函数_createElement,代码如下:

  1. export function _createElement ( 
  2.  context: Component, 
  3.  tag?: string | Class<Component> | Function | Object, 
  4.  data?: VNodeData, 
  5.  children?: any
  6.  normalizationType?: number 
  7. ): VNode | Array<VNode> { 
  8.    ... 
  9.    if (normalizationType === ALWAYS_NORMALIZE) { 
  10.        children = normalizeChildren(children) 
  11.    } else if (normalizationType === SIMPLE_NORMALIZE) { 
  12.        children = simpleNormalizeChildren(children) 
  13.    } 
  14.    ... 

_createElement 方法提供 5 个参数如下:

  • context 表示DOM节点的上下文环境,它是 Component 类型;
  • tag 表示标签,它可以是一个字符串,也可以是一个 Component;
  • data 表示 DOM节点上的数据,它是一个 VNodeData 类型,可以在 flow/vnode.js 中找到它的定义;
  • children 表示当前DOM节点的子节点,它是任意类型的,它接下来需要被规范为标准的 VNode 数组;
  • normalizationType 表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 render 函数是编译生成的还是手写的 render 函数。

createElement 函数的流程略微有点多,本文将重点探究 children 的规范化以及 VNode 的创建。

children 的规范化

虚拟DOM(Virtual DOM)实际上是一个树状结构,每一个DOM 节点都可能会有若干个子节点,这些子节点应该也是 VNode 的类型。

_createElement 接收的第 4 个参数 children 是任意类型的,因此我们需要把它们规范成 VNode 类型。

它是根据 normalizationType 的不同,调用了 normalizeChildren(children) 和 simpleNormalizeChildren(children) 方法,它们的定义都在 src/core/vdom/helpers/normalzie-children.js文件 中,代码如下:

  1. // render 函数是编译生成的时候调用 
  2. // 拍平数组为一维数组 
  3. export function simpleNormalizeChildren (children: any) { 
  4.   for (let i = 0; i < children.length; i++) { 
  5.     if (Array.isArray(children[i])) { 
  6.       return Array.prototype.concat.apply([], children) 
  7.     } 
  8.   } 
  9.   return children 
  10. // 返回一维数组 
  11. export function normalizeChildren (children: any): ?Array<VNode> { 
  12.   return isPrimitive(children) 
  13.     ? [createTextVNode(children)] 
  14.     : Array.isArray(children) 
  15.       ? normalizeArrayChildren(children) 
  16.       : undefined 

simpleNormalizeChildren 方法调用场景是 render 函数是编译生成的。但是当子节点为一个组件的时候,函数式组件返回的是一个数组而不是一个根节点,所以会通过 Array.prototype.concat 方法把整个 children 数组拍平,让它的深度只有一层。

normalizeChildren 方法的调用场景有 2 种,一个场景是手写 render 函数,当 children 只有一个节点的时候,Vue.js 从接口层面允许用户把 children 写成基础类型用来创建单个简单的文本节点,这种情况会调用 createTextVNode 创建一个文本节点的DOM 节点;另一个场景是当编译 slot、v-for 的时候会产生嵌套数组的情况,会调用 normalizeArrayChildren 方法,代码如下:

  1. function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> { 
  2.   const res = [] 
  3.   let i, c, lastIndex, last 
  4.   for (i = 0; i < children.length; i++) { 
  5.     c = children[i] 
  6.     if (isUndef(c) || typeof c === 'boolean'continue 
  7.     lastIndex = res.length - 1 
  8.     last = res[lastIndex] 
  9.     //  nested 
  10.     if (Array.isArray(c)) { 
  11.       if (c.length > 0) { 
  12.         c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`) 
  13.         // merge adjacent text nodes 
  14.         if (isTextNode(c[0]) && isTextNode(last)) { 
  15.           res[lastIndex] = createTextVNode(last.text + (c[0]: any).text) 
  16.           c.shift() 
  17.         } 
  18.         res.push.apply(res, c) 
  19.       } 
  20.     } else if (isPrimitive(c)) { 
  21.       if (isTextNode(last)) { 
  22.         res[lastIndex] = createTextVNode(last.text + c) 
  23.       } else if (c !== '') { 
  24.         res.push(createTextVNode(c)) 
  25.       } 
  26.     } else { 
  27.       // 如果两个节点都为文本节点,则合并他们。 
  28.       if (isTextNode(c) && isTextNode(last)) { 
  29.         res[lastIndex] = createTextVNode(last.text + c.text) 
  30.       } else { 
  31.         if (isTrue(children._isVList) && 
  32.           isDef(c.tag) && 
  33.           isUndef(c.key) && 
  34.           isDef(nestedIndex)) { 
  35.           c.key = `__vlist${nestedIndex}_${i}__` 
  36.         } 
  37.         res.push(c) 
  38.       } 
  39.     } 
  40.   } 
  41.   return res 

normalizeArrayChildren 接收 2 个参数。

  • children 表示要规范的子节点;
  • nestedIndex 表示嵌套的索引; 因为单个 child可能是一个数组类型。 normalizeArrayChildren 主要是遍历 children,获得单个节点 c,然后对 c 的类型判断,如果是一个数组类型,则递归调用 normalizeArrayChildren; 如果是基础类型,则通过 createTextVNode 方法转换成 VNode 类型;否则就已经是 VNode 类型了,如果 children 是一个列表并且列表还存在嵌套的情况,则根据 nestedIndex 去更新它的 key。

在遍历的过程中,对这 3 种情况都做了如下处理:如果存在两个连续的 text 节点,会把它们合并成一个 text 节点。

到此,children 变成了一个类型为 VNode 的 Array。这就是children 的规范化。

虚拟的DOM节点的创建

回到 createElement 函数,规范化 children 后,接下来就要创建一个DOM实例,代码如下:

  1. let vnode, ns 
  2. if (typeof tag === 'string') { 
  3.   let Ctor 
  4.   ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) 
  5.   if (config.isReservedTag(tag)) { 
  6.     // platform built-in elements 
  7.     vnode = new VNode( 
  8.       config.parsePlatformTagName(tag), data, children, 
  9.       undefined, undefined, context 
  10.     ) 
  11.   } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { 
  12.     // component 
  13.     vnode = createComponent(Ctor, data, context, children, tag) 
  14.   } else { 
  15.     // 不认识的节点的处理 
  16.     vnode = new VNode( 
  17.       tag, data, children, 
  18.       undefined, undefined, context 
  19.     ) 
  20.   } 
  21. else { 
  22.   // direct component options / constructor 
  23.   vnode = createComponent(tag, data, context, children) 

这里先对 tag 做判断,如果是 string 类型,则接着判断如果是内置的一些节点,则直接创建一个普通 VNode,如果是为已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。 如果 tag是一个 Component 类型,则直接调用 createComponent 创建一个组件类型的 VNode 节点。

到这一步,createElement方法就创建好了一个虚拟DOM树的实例,它用来描述了真实DOM 树,那么如何渲染为真实的DOM 树呢???其实它是由 vm._update 完成的。

update把虚拟DOM 渲染为真实DOM

_update 方法是如何把虚拟DOM 渲染为真实DOM 的。这部分代码在 src/core/instance/lifecycle.js文件中,代码如下:

  1. _update 方法是如何把虚拟DOM 渲染为真实DOM 的。这部分代码在 src/core/instance/lifecycle.js文件中,代码如下: 
  2. Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { 
  3.     const vm: Component = this 
  4.     const prevEl = vm.$el 
  5.     const prevVnode = vm._vnode 
  6.     const restoreActiveInstance = setActiveInstance(vm) 
  7.     vm._vnode = vnode 
  8.     if (!prevVnode) { 
  9.       // 数据的首次渲染时候执行 
  10.       vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) 
  11.     } 
  12.    ... 
  13.   } 

读代码可知,当数据首次渲染的时候,调用了vm.__patch__()的方法,他接收了四个参数,结合我们实际vue项目的开发过程。vm.$el就是 id 为 app 的 DOM 对象,即:

 

;vnode 对应的是调用 render 函数的返回值;hydrating 在非服务端渲染情况下为 false,removeOnly 为 false。

vm.__patch__ 方法在不同的平台的定义是不一样的,对 web 平台的定义在 src/platforms/web/runtime/index.js 中,代码如下:

  1. // 是否在浏览器环境 
  2. Vue.prototype.__patch__ = inBrowser ? patch : noop 

在 web 平台上,是否是服务端渲染也会对这个方法产生影响。因为在服务端渲染中,没有真实的浏览器 DOM 环境,所以不需要把 VNode 最终转换成 DOM,因此是一个空函数,而在浏览器端渲染中,它指向了 patch 方法,它的定义在 src/platforms/web/runtime/patch.js文件中,代码如下:

  1. import * as nodeOps from 'web/runtime/node-ops' 
  2. import { createPatchFunction } from 'core/vdom/patch' 
  3. import baseModules from 'core/vdom/modules/index' 
  4. import platformModules from 'web/runtime/modules/index' 
  5.  
  6. const modules = platformModules.concat(baseModules) 
  7.  
  8. export const patch: Function = createPatchFunction({ nodeOps, modules }) 

读代码可知 createPatchFunction 方法的返回值被传入了一个对象,其中,

  • nodeOps 封装了一系列 DOM 操作的方法;
  • modules 定义了模块的钩子函数的实现; createPatchFunction方法的定义在 src/core/vdom/patch.js文件中,代码如下:
  1. const hooks = ['create''activate''update''remove''destroy'
  2.  
  3. export function createPatchFunction (backend) { 
  4.   let i, j 
  5.   const cbs = {} 
  6.  
  7.   const { modules, nodeOps } = backend 
  8.  
  9.   for (i = 0; i < hooks.length; ++i) { 
  10.     cbs[hooks[i]] = [] 
  11.     for (j = 0; j < modules.length; ++j) { 
  12.       if (isDef(modules[j][hooks[i]])) { 
  13.         cbs[hooks[i]].push(modules[j][hooks[i]]) 
  14.       } 
  15.     } 
  16.   } 
  17.      
  18.   // ... 
  19.   // 定义了一些辅助函数 
  20.    
  21.    
  22.   // 当调用 vm.__dispatch__时,其实就是调用下面的 patch 方法 
  23.   // 这块应用了函数柯理化的技巧 
  24.   return function patch (oldVnode, vnode, hydrating, removeOnly) { 
  25.     // ... 
  26.     return vnode.elm 
  27.   } 

createPatchFunction 内部定义了一系列的辅助方法,最终返回了一个 patch 方法,这个方法就赋值给了 vm._update函数里调用的 vm.__patch__。也就是说当调用 vm.__dispatch__时,其实就是调用patch (oldVnode, vnode, hydrating, removeOnly) 方法,这块其实是应用了函数柯理化的技巧。

patch 方法接收 4个参数,如下:

  • oldVnode 表示旧的 VNode 节点,它也可以不存在或者是一个 DOM 对象;
  • vnode 表示执行 _render 后返回的 VNode 的节点;
  • hydrating 表示是否是服务端渲染;
  • removeOnly 是给 transition-group 用的。

分析patch方法,因为传入的oldVnode实际上是一个 DOM container,所以 isRealElement 为 true,然后调用 emptyNodeAt 方法把 oldVnode 转换成 虚拟DOM节点(一个js对象),然后再调用 createElm 方法。代码如下:

  1. if (isRealElement) { 
  2.     oldVnode = emptyNodeAt(oldVnode) 
  1. function createElm ( 
  2.   vnode, 
  3.   insertedVnodeQueue, 
  4.   parentElm, 
  5.   refElm, 
  6.   nested, 
  7.   ownerArray, 
  8.   index 
  9. ) { 
  10.   if (isDef(vnode.elm) && isDef(ownerArray)) { 
  11.     vnode = ownerArray[index] = cloneVNode(vnode) 
  12.   } 
  13.  
  14.   vnode.isRootInsert = !nested // for transition enter check 
  15.  
  16.   const data = vnode.data 
  17.   const children = vnode.children 
  18.   const tag = vnode.tag 
  19.   // 接下来判断 vnode 是否包含 tag, 
  20.   // 如果包含,先对tag的合法性在非生产环境下做校验,看是否是一个合法标签; 
  21.   // 然后再去调用平台 DOM 的操作去创建一个占位符元素。 
  22.   if (isDef(tag)) { 
  23.     if (process.env.NODE_ENV !== 'production') { 
  24.       if (data && data.pre) { 
  25.         creatingElmInVPre++ 
  26.       } 
  27.       if (isUnknownElement(vnode, creatingElmInVPre)) { 
  28.         warn( 
  29.           'Unknown custom element: <' + tag + '> - did you ' + 
  30.           'register the component correctly? For recursive components, ' + 
  31.           'make sure to provide the "name" option.'
  32.           vnode.context 
  33.         ) 
  34.       } 
  35.     } 
  36.      // 调用 createChildren 方法去创建子元素: 
  37.     vnode.elm = vnode.ns 
  38.       ? nodeOps.createElementNS(vnode.ns, tag) 
  39.       : nodeOps.createElement(tag, vnode) 
  40.     setScope(vnode) 
  41.  
  42.     /* istanbul ignore if */ 
  43.     if (__WEEX__) { 
  44.       // ... 
  45.     } else { 
  46.       // 调用 createChildren 方法去创建子元素 
  47.       // 用 createChildren 方法遍历子虚拟节点,递归调用 createElm 
  48.       // 在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。 
  49.       createChildren(vnode, children, insertedVnodeQueue) 
  50.       if (isDef(data)) { 
  51.         invokeCreateHooks(vnode, insertedVnodeQueue) 
  52.       } 
  53.       insert(parentElm, vnode.elm, refElm) 
  54.     } 
  55.  
  56.     if (process.env.NODE_ENV !== 'production' && data && data.pre) { 
  57.       creatingElmInVPre-- 
  58.     } 
  59.   } else if (isTrue(vnode.isComment)) { 
  60.     vnode.elm = nodeOps.createComment(vnode.text) 
  61.     insert(parentElm, vnode.elm, refElm) 
  62.   } else { 
  63.     vnode.elm = nodeOps.createTextNode(vnode.text) 
  64.     insert(parentElm, vnode.elm, refElm) 
  65.   } 

createElm方法的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。判断 vnode 是否包含 tag,如果包含,先对 tag 的合法性在非生产环境下做验证,看是否是一个合法标签;然后再去调用平台 DOM 的操作去创建一个占位符元素。然后调用 createChildren 方法去创建子元素,createChildren方法代码如下:

  1. createChildren(vnode, children, insertedVnodeQueue) 
  2.  
  3. function createChildren (vnode, children, insertedVnodeQueue) { 
  4.   if (Array.isArray(children)) { 
  5.     if (process.env.NODE_ENV !== 'production') { 
  6.       checkDuplicateKeys(children) 
  7.     } 
  8.     for (let i = 0; i < children.length; ++i) { 
  9.       createElm(children[i], insertedVnodeQueue, vnode.elm, nulltrue, children, i) 
  10.     } 
  11.   } else if (isPrimitive(vnode.text)) { 
  12.     nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text))) 
  13.   } 

createChildren方法遍历子虚拟节点,递归调用 createElm,在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。然后调用 invokeCreateHooks方法执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue 中。最后调用 insert 方法把 DOM 插入到父节点中,因为是递归调用,子元素会优先调用 insert,所以整个 vnode 树节点的插入顺序是先子后父。insert 方法定义在 src/core/vdom/patch.js 文件中,代码如下:

  1. insert(parentElm, vnode.elm, refElm) 
  2.  
  3. function insert (parent, elm, ref) { 
  4.   if (isDef(parent)) { 
  5.     if (isDef(ref)) { 
  6.       if (ref.parentNode === parent) { 
  7.         nodeOps.insertBefore(parent, elm, ref) 
  8.       } 
  9.     } else { 
  10.       nodeOps.appendChild(parent, elm) 
  11.     } 
  12.   } 

读代码可知,insert方法调用一些辅助方法把子节点插入到父节点中(其实就是调用原生 DOM 的 API 进行 DOM 操作),这些辅助方法定义在 src/platforms/web/runtime/node-ops.js 文件中。到此,Vue 动态创建的 DOM节点就完成了。emm~~ 回头在看看这个图。

结束

最近一段时间都会认真的去看vue.js的源码。【读vue 源码】会按照一个系列去更新。分享自己学习的同时,也希望与更多的同行交流所得,如此而已。

作者:二十七刻

链接:https://juejin.im/post/5eb51a9d5188256d8d606859

来源:掘金

【编辑推荐】

  1. 从MySQL优化的角度来看:数据库回表与索引
  2. 阿里P7也不过如此,被一个简单的SQL查询难住!
  3. 11倍增长!支付宝自研数据库OceanBase再次刷新世界纪录
  4. 面试官:不会看 Explain执行计划,简历敢写 SQL 优化?
  5. 中国数据库告别卡脖子之忧:阿里OceanBase霸气卫冕全球第一
【责任编辑:武晓燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

订阅专栏+更多

思科交换网络安全指南

思科交换网络安全指南

安全才能无忧
共5章 | 思科小牛

74人订阅学习

云计算从入门到上瘾

云计算从入门到上瘾

传统IT工程师的转型
共26章 | 51CTO阿森

239人订阅学习

从头解锁Python运维

从头解锁Python运维

多维度详解
共19章 | 叱诧少帅

352人订阅学习

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊

51CTO服务号

51CTO官微