Vue原理: Vue是怎么初始化第一个标签的?

1,989 阅读6分钟

Hello, 这里是link😋, 上期讲完了data, 这期我们来看看一个原生节点是怎么挂载的. 也就是我们封面的这个节点.

流程图

这是本文内容的总图, 可以对照这个图看文章, 如果这里看不清楚的话, 可以到我的源码项目vue-core-analyse自行下载,欢迎star哦~✨

Vue原生节点的挂载.png

初始化函数

在我们所有的data, props等状态初始完毕后, _init函数最终会执行一个$mount函数

// /src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    // 用于标识当前的实例是 Vue 实例
    vm._isVue = true
    // 合并配置项
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate') // 生命周期函数
    initInjections(vm)
    initState(vm) // 这里就是我们上期讲的 data 等属性的初始化处
    initProvide(vm)
    callHook(vm, 'created')
    
    // ---------- 在所有属性初始化完毕后, 我们开始挂载节点
    if (vm.$options.el) {
      vm.$mount(vm.$options.el) // 👈 这里是我们本期的关注点
    }

编译版本的区别

编译版本的不同, 会让Vue在节点的挂载上做的事情有些不同, 我们先了解一下这两个版本的异同

Vue-cli3 脚手架在我们生成项目的时候, 就会提供这样的选择, 提示我们需要选择哪个版本的Vue, 这里可以这样总结:

  • runtime-with-complier 版本: 是一个完整版的Vue, 它能够提供从模板(template) => 真实dom 的生成所需的全部功能, 一般源码调试我们选择这个版本.

  • runtime-only 版本: 将编译部分的工作抽离了出来, 作为一个 vue-loader 去单独工作. 这样能够缩减vue的大小, 在性能上也会更好, 所以业务开发我们通常都是选择这个版本.

runtime-with-complier

执行流程: template => ast(抽象语法树) => 生成render() => 生成VDom => 真实Dom

new Vue({
    template: '<div id="app"> Hello Vue <div>'
}).$mount('#app')

在这个版本的Vue中, 当项目运行时, 也就是runtime阶段, Vue需要自行完成上述流程的编译, 这样显然运行需要的时间就更多了.

runtime-only

生成VDom => 真实Dom

src
├─ App.vue
└─ main.js
new Vue({
  render: h => h(App),
}).$mount('#app')
  • onlyApp.vue 转换成 render函数的过程发生在构建阶段, 也就是这些工作由 Vue-Loader 完成.

不难看出从业务的角度出发, only版本的性能更加优越, 因为在 runtime 的时候需要做的事情变少了. 模板编译工作, 我们可以在构建阶段(丢给webpack去做)就完成了

$mount 函数

以下是两个版本的$mount函数的两个定义处

  • 我们先把这个$mount 称为 only👇
// src/platforms/web/runtime/index.js

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 判断 是不是 dom
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}
  • 把这个$mount 称为 compiler👇
// /src/platforms/web/entry-runtime-with-compiler.js

// 将原先的 mount 拿出来
const mount = Vue.prototype.$mount
// 定义一个新的, 由于在 runtime-compiler 版本我们需要做额外的处理
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 将 el 属性 转化为 dom 对象
  el = el && query(el)
  if(template) {

      // 编译工作在这里执行, 最后会生成一个render函数赋值给 this.$options
      // 目前先理解为 走过这一步, 我们就有 render 函数了
      
  }
  return mount.call(this, el, hydrating) // 这里执行
}
  • 这里Vue做了什么事情呢? 其实很简单, 首先定义only, 然后在 entry-runtime-with-compiler.js 中把 only 拿出来保存在变量 mount 中. 然后complier的最后 return 的时候 做一个尾回调执行 only

  • 梳理完他们两者的区别, 我们就来看看他们究竟做了什么事情. 我们先来看看流程图, 这里蓝色部分是complier$mount函数做的事情, 而红色部分是only版本$mount函数做的事情.

截屏2021-12-22 上午11.34.01.png


组件挂载

那么接下来, 我们来看一下mountComponent函数做了哪些事情.

截屏2021-12-22 下午4.59.17.png

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 挂载 dom
  vm.$el = el
  
  // vue 初始化的时候,无论是template还是 render 最终都会转化为 render函数 进行渲染
  if (!vm.options.render) { /* 如果到这里了还没有 render 函数 那就会报错了 */ }
  // 周期钩子
  callHook(vm, 'beforeMount') // beforeMount 就是在这个节点触发的

  let updateComponent
  // 创建 updateComponent 函数, 之后更新数据 updateComponent 会被触发执行
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  
  // 创建一个观察者实例
  vm._watcher = new Watcher(vm, updateComponent, noop)

  return vm
}

这个函数做了两件事情:

  1. 定义了一个 updateComponent

  2. 创建了一个观察者实例 Watcher, 并传入 updateComponent

这里你可以理解为, Vue 挂载了第一个组件, 就是我们定义的 new Vue() 本质上它也是一个组件. 也就是说, 每一次我们创建组件, 就是在实例化一个Vue, 并且创建一个 watcher

这里我们先不去理解观察者实例内部的逻辑, 只需要知道发生变化的时候, 在实例内部会去调用updateComponent


updateComponent函数内部会执行两个函数:

  1. vm.render(): 负责生成虚拟节点

  2. _update(): 负责更新操作, 将虚拟节点转化为真实节点, 并插入到DOM树中

我们先来看 vm._render()函数的工作, 他最终会返回一个 vnode 给我们的 _update

  // /src/core/instance/render.js
  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render } = vm.$options

    let vnode
    try {
      // 标记当前渲染的实例
      currentRenderingInstance = vm
      vnode = render.call(vm._renderProxy, vm.$createElement) // 👈 我们需要关注这里
    } catch (e) {
      // ... 这一部分我们无需关注
    } finally {
      // 取消当前实例的标记
      currentRenderingInstance = null
    }
   
    return vnode
  }

请注意, 这个render函数, 就是由vue-loader 或者 Vue 本身生成的, 当然还有一种方式, 就是我们自己定义的, 他默认会传入一个实例, 还有一个函数, 我相信很多小伙伴在文档中也有看到过. 我这里指个路Vue - 渲染函数 & JSX

例如:

  new Vue({
    el: '#app',
    data:{
      msg: 'parent-Vue'
    },
    render: function ($createElement) { // 创建一个 <div id="foo"></div>
      // 这里参数, 就是对应上面的 a, b, c, d
      return $createElement(
        'p',
        { attrs: { id: 'foo' } }, 
        [ h('div', '111') ]
      )
    }
  })

也就是说在 render函数 内部, vm.$createElement 函数会被默认执行, 我们来看看定义

  • 这是 $createElement 👇 的定义, 返回了一个默认执行的createElement
vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };

createElement最终会返回_createElement, 其实Vue这么做, 只不过是在 createElement, 做一些参数的处理工作, 因为我们的createElement配置是比较灵活的

// /src/core/instance/render.js
var SIMPLE_NORMALIZE = 1;
var ALWAYS_NORMALIZE = 2;
function createElement (
    context,           // 当前实例
    tag,               // 标签
    data,              // 当前节点的属性  id 等
    children,          // 子节点
    normalizationType, // 针对子节点操作类型
    alwaysNormalize    // 用于约束上一个参数 具体看下面代码
) {
    if (Array.isArray(data) || isPrimitive(data)) {
      normalizationType = children;
      children = data;
      data = undefined;
    }
    // 看图可以知道, alwaysNormalize 在当前流程下总是被置为 true
    if (isTrue(alwaysNormalize)) {
      // 这里主要是对子节点做操作不同的标识
      normalizationType = ALWAYS_NORMALIZE;
    }
    return _createElement(context, tag, data, children, normalizationType)
}

为了防止参数看起来很混乱, 我们以上面那个demo为例, 画一个图对照着看😝

截屏2021-12-22 下午4.23.20.png


创建虚拟节点

_createElement会判断当前tag是否为一个string类型, 是的话 通过new VNode(tag)去描述一个原生节点, 否则如果是一个组件就要做额外的处理

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object, // tag 也有可能是组件, 函数 等
  data?: VNodeData,          // 这里的data 是 key id 这种定义在标签里的属性
  children?: any,            // 子节点
  normalizationType?: number // 操作类型类型
): VNode {
  
  if (isDef(data) && isDef(data.is)) {
    // 存在 is 属性 则证明要将某个标签指定为 is 中的这个标签
    tag = data.is
  }
  
  // 递归子节点, 请注意我们上面 demo 中的子节点数组, 就是在这一部分做了操作
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  
  let vnode, ns
  // 标签
  if (typeof tag === 'string') {
      // html 原生保留标签
      // platform built-in elements
      vnode = new VNode(
        // 创建平台保留标签 这里理解成 (tag) => tag 就好了
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )  
  } else {
    // direct component options / constructor
    // 如果是一个组件, (这个是下一章节的内容)
    vnode = createComponent(tag, data, context, children)
  }
  return vnode
}

这个函数主要做了三件事:

  1. 通过 normalizeChildren 获取子节点.

    • 这里实际上是对子节点做了一些优化操作, 比如两个相邻的子节点都是文本, 那就合并它, 由于不是本次重点, 我们就不介绍了
  2. 如果 tag 是一个字符串 则创建一个 Vnode

  3. 否则创建一个组件

截屏2021-12-22 下午4.59.50.png

new VNode()其实就是一个对象, 相较于用一个原生的DOM做为节点, VNode需要的内存要小的的多, 因为DOM上有很多我们不需要的属性

由于 Vnode() 的定义比较长, 十分占用篇幅, 大家可以通过这里看vnode.js

好的, 直到这里一个虚拟节点的产生逻辑, 我们就看完了, 接下来我们来看一下. 一个标签是怎样被挂载到Dom树上的


节点的挂载

vm._update() 中, 它负责将这个节点转化为真实的Dom节点, 并且插入到Dom树上

  // src/core/instance/lifecycle.js
  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this  // 当前组件实例
    const prevEl = vm.$el       // 上个真实节点
    const prevVnode = vm._vnode // 取出上一个虚拟节点(上次更新), 第一次的时候这里为空
    vm._vnode = vnode
    
    if (!prevVnode) { // 不存在前虚拟节点证明是初始化
      // 初始化
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 更新节点
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
  }

其中有一个很重要的函数vm.__patch__根据名字我们就可以大概猜到它可能是一个"补丁"

我们先来看看 patch函数 做了哪些事情

  1. 将旧的节点转化为 空虚拟节点 (为了方便卸载旧的节点)
  2. createElm() 创建一个新节点
  3. diff新旧节点 ( 我们现在处于初始化阶段, 显然这一步不会执行 )
  4. 卸载旧节点
 // /src/core/vdom/patch.js
 return function patch (
     oldVnode,  // 本流程中是 vm.$el 也就是真实的根节点
     vnode,     // 我们上面创建的 vnode
     hydrating, // 用不到
     removeOnly // 用不到
 ) {
 
    let isInitialPatch = false
    const insertedVnodeQueue = []

    // 将真实 dom 转化为 VNode
    oldVnode = emptyNodeAt(oldVnode)
    // oldElm 是一个真实节点
    const oldElm = oldVnode.elm
    // 拿到当前dom的父节点
    const parentElm = nodeOps.parentNode(oldElm)
    
    // 👇 创建节点
    createElm(
      vnode,                      // 当前虚拟节点
      insertedVnodeQueue,         // 这里是空数组
      parentElm,                  // 初始化的时候 这里是 body标签
      nodeOps.nextSibling(oldElm) // 兄弟节点(最后插入父元素要用到)
    )
    
    // 显然初始化的时候不会走到这里
    // 👇 更新节点
    if (isDef(vnode.parent)) { /* 这是更新阶段做的事情, diff也在这里 */ }
    
    // 👇 删除旧节点
    if (isDef(parentElm)) {
      removeVnodes([oldVnode], 0, 0)
    } else if (isDef(oldVnode.tag)) {
      invokeDestroyHook(oldVnode)
    }

    return vnode.elm
  }

截屏2021-12-22 下午6.18.36.png


我们先来看看步骤2

createElm() 首先, 会判断当前虚拟节点是否可以作为一个组件被创建, 再将其作为一个普通的节点(正常的DOM, 或者注释以及文本节点)

  • tag 不存在的情况

    很简单, tag 如果不存在, 也就是不存在标签的情况, 比如: <p></p> 那肯定是文本, 或者注释, 所以在这个函数最后会创建这两种标签 并且通过 insert函数 插入到 parentElm

  • 再来看看 tag 存在的情况

    1. 通过 nodeOps.createElement(tag, vnode) 创建一个真实节点. (nodeOps我们就看成 window 就可以了, 实际上他就是封装了一个针对节点操作的工具箱.)

    2. 执行 createChildren(vnode, children, insertedVnodeQueue) 递归创建子节点

    3. 执行 insert(parentElm, vnode.elm, refElm) 插入当前创建的节点

function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
    vnode.isRootInsert = !nested 
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      // 前虚拟节点是否可以作为一个组件被创建
      // 但这是下篇的内容, 我们先跳过
      return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag // tag 可能是一个组件对象
    // 是否有定义? 
    if (isDef(tag)) {
        vnode.elm  = nodeOps.createElement(tag, vnode)

        // 创建子节点, 内部递归调用了 createElm
        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)
    }
  }

  • createElm() 执行流程

截屏2021-12-23 上午9.40.11.png


接下来我们研究一下createChildren

createChildren函数主要的作用就是递归创建子节点, 可以仔细看上图的流程👆

  function createChildren (vnode, children, insertedVnodeQueue) {
    if (Array.isArray(children)) {
      for (let i = 0; i < children.length; ++i) {
        // 创建所有子节点
        createElm(children[i], insertedVnodeQueue, vnode.elm, null, true)
      }
    } else if (isPrimitive(vnode.text)) {
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(vnode.text))
    }
  }

createChildren() 执行完毕后就会执行 insert() 负责将节点插入到父元素中.

  function insert (parent, elm, ref) {
    if (isDef(parent)) {
      if (isDef(ref)) {
        // ref元素 存在的话, 就将其插入到 ref元素 之前
        // 这个元素是真实节点的下一个真实兄弟节点
        // 我猜这么做, 插入位置会更准确?
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        // 否则是直接添加到 父元素下
        nodeOps.appendChild(parent, elm)
      }
    }
  }

好了, 到这里节点的生成就算是结束了. 但是请注意:

createChildren() 的执行是在 insert(), 之前则意味着, 子节点的createElm()完毕后才会进行当前节点的插入. 所以他们的插入顺序应该是这样的:

当前节点创建 => 创建子节点 => 子节点插入到当前节点 => 当前节点插入到它的父节点

当然这是递归调用的特性, 实际上Vue的组件也是这样的特性, 从而规定了生命周期的执行顺序. 请务必理解这一点, 对于后续理解组件的挂载有益.


旧节点的卸载

patch() 函数的最后一步, 有一串这样的代码:

// 👇 删除旧节点
if (isDef(parentElm)) {
  removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
  invokeDestroyHook(oldVnode)
}

removeVnodes 内部就是执行了节点的移除. 为什么需要这么呢, 如果打断点你就会发现, 在createElm()执行后, 会有两个节点存在. 随后这段代码执行, 才会消失

我们来看一个demo

 <div id="app" ref="app">{{ msg }}</div>
 
 new Vue({
    el: '#app',
    data:{
      msg: 'parent-Vue'
    },
  })

然后在createElm()前打一个断点, 当createElm执行完毕后, 但是旧节点卸载还没执行前, 就会出现👇这种情况

image.png

image.png

感谢😘


如果觉得文章内容对你有帮助: