vue

Vue.js是如何让数据渲染成最终的DOM?(一)

模板和数据如何渲染成最终的DOM的呢?

Posted by czk on February 9, 2022

前言

文章是对自己学习探究Vue.js的总结和分享,也是以前的笔记,整理了一下发出来与大家一同学习。

时至今日,Vue.js已经被广泛的前端开发者使用,那么去探寻其内部的运行机制也是我们的必修课。模板和数据如何渲染成最终的DOM的呢?

数据驱动

数据驱动是Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。例如在页面中的摸板语法``只要你去改变vue实例中的message值,页面上就能渲染你期待的值。

模版

模板引擎的概念是:字符串 + 数据 => html。这个概念,与我们如今谈的Vue 或 React 并没有什么不同。这其实就是 组件的本质。组件的本质虽然没变,但组件的产出却改变了。在模板引擎的年代,组件的产出是 html 字符串,而 Vue 或 React,它们的组件所产出的内容并不是 html 字符串,而是大家所熟知的 Virtual DOM

Virtual DOM

Virtual DOM建立在 DOM 之上,是基于 DOM 的一层抽象,实际可理解为用更轻量的纯 JavaScript 对象(树)描述 DOM(树)。

操作 JavaScript 对象当然比操作 DOM 快,因为不用更新视图。我们可以随意改变 Virtual DOM ,然后找出改变再更新到 DOM 上。但要保证高效,需要解决以下问题:

  1. 高效的 diff 算法,即两个 Virtual DOM 的比较;
  2. 只更新需要更新的 DOM 节点;
  3. 数据变化检测,batch DOM 读写操作

new Vue

new 关键字在 Javascript 语言中用来表示一个对象的实例化,而在Vue实际上是一个类,

// src/core/instance/index.js
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);
}

可以看到 Vue 只能通过 new 关键字初始化,然后会调用 this._init 方法,简单的看下源码。

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

  let startTag, endTag //性能追踪
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    startTag = `vue-perf-start:${vm._uid}` // 在组件初始化开始时,添加开始标志的`mark`类型实体
    endTag = `vue-perf-end:${vm._uid}`
    mark(startTag)
  }

  // a flag to avoid this being observed
  vm._isVue = true
  // merge options
  if (options && options._isComponent) {
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.function Object() { [native code] }),
      options || {},
      vm
    )
  }
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
    initProxy(vm)
  } else {
    vm._renderProxy = vm
  }
  // expose real self
  vm._self = vm
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')

  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    vm._name = formatComponentName(vm, false)
    mark(endTag)
    measure(`vue ${vm._name} init`, startTag, endTag)
  }
  // 检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

顺带一提vue在代码中开启了性能追踪主要是通过performance.mark()方法在浏览器的性能缓冲区中使用给定名称添加一个timestamp(时间戳),定义的时间戳实体可以通过getEntries(),getEntriesByName()getEntriesByType()等方法获取。

Vue 初始化主要就干了几件事情,合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher ,最后检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM

在经过合并选项、初始化生命周期、初始化事件、初始化事件等组件初始化操作完成后添加结束标志的mark类型实体。最后调用measure方法生成measure类型的实体,该实体的名称为vue name init,其中name为组件名称。这样在浏览器开发工具的性能/时间线面板中的Timings中就能看到名为vue name init的相关信息。

Vue 实例挂载
// public mount method src/platforms/web/runtime/index.js
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined;
  return mountComponent(this, el, hydrating);
};

$mount 这个方法的实现是和平台、构建方式都相关的。(Runtime Only)版本的Vue中,调用的就是这个$mount函数。而在完整版(Compiler)的Vue中的 $mount 实现则是对其进行了重写。

// src/platform/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element, //参数的类型检查表明`el`可以是字符串或`DOM`节点
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`)
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  return mount.call(this, el, hydrating)
}

这里先拿到了Runtime Only版本的$mount方法,然后进行重写,最后又调用了Runtime Only版本的$mount方法。具体流程如下

  • 这段代码首先缓存了$mount方法,再重新定义该方法。
  • 调用query方法,如果el是一个字符串,就调用querySelector获取节点并返回;如果节点不存在就抛出警告并创建一个div节点。如果el是一个节点就直接返回。
  • 它对 el 做了限制,Vue 不能挂载在 body、html 这样的根节点上。如果是就抛出警告并停止挂载。因为挂载实际上就是把el节点替换为组件的模版。
  • 如果没有定义 render 方法,则会把 el 或者 template 字符串转换成 render 方法。所有 Vue 的组件的渲染最终都需要 render 方法
  • 判断template类型做相应处理,转为了字符串模板
  • 调用缓存的 $mount 方法进行挂载,主要是把 el 从字符串转换成了节点然后传给了 mountComponent 函数

mountComponent的调用过程

mountComponent 方法的逻辑也是非常清晰的,它会完成整个渲染工作我们看下他的源码

// src/core/instance/lifecycle.js

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el;
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode;
     ...... //省略部分代码
    }
  }
  callHook(vm, "beforeMount");
    let updateComponent;
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== "production" && config.performance && mark) {
      updateComponent = () => {
        const name = vm._name;
        const id = vm._uid;
        // 性能追踪
        const startTag = `vue-perf-start:${id}`;
        const endTag = `vue-perf-end:${id}`;

        mark(startTag);
        const vnode = vm._render();
        mark(endTag);
        measure(`vue ${name} render`, startTag, endTag);

        mark(startTag);
        vm._update(vnode, hydrating);
        mark(endTag);
        measure(`vue ${name} patch`, startTag, endTag);
      };
    } else {
      updateComponent = () => {
        vm._update(vm._render(), hydrating);
      };
    }
  // 简单描述一下这个过程:初始化这个watcher对象,执行updateComponent方法,收集相关的依赖
  // updateComponent的执行过程:
  // 先执行vm._render方法,根据之前生成的render方法,生成相关的vnode,也就是virtual dom相关的内容
  // 通过生成的vnode,调用vm._update,最终将vnode生成的dom插入到父节点中,完成组件的载入
  new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
  hydrating = false

// `vm.$vnode` 表示 Vue 实例的父虚拟 Node,所以它为 `Null` 则表示当前是根 Vue 的实例
  if (vm.$vnode == null) { 
    vm._isMounted = true
    // 调用mounted钩子,在这个钩子的回调函数中可以访问到真是的dom节点,
    // 因为在上述过程中已经将真实的dom节点插入到父节点
    callHook(vm, 'mounted')
  }
  return vm
}
  • 先将el保存到vm.$el上,然后判断前面的template是否被正确的转换成了render函数。如果转换失败,将createEmptyVNode作为render函数,其会创建一个空的VNode对象。

  • if语句里面是我们熟悉的性能追踪else语句里这里面定义了一个updateComponent函数,涉及到两个函数:
    • _render: 调用 vm.$options.render 函数并返回生成的虚拟节点(VNode
    • _update: 将 VNode 渲染成真实DOM
  • 创建了一个 Watcher 实例,Watcher 会解析表达式,收集依赖关系,并且在表达式的值发生改变时触发回调。Watcher 在这里主要有两个作用: 一个是初始化的时候会执行回调函数;另一个是当 vm 实例中监测的数据发生变化的时候执行回调函数,而回调函数就是传入的 updateComponent 函数。
  • 函数最后判断为根节点的时候设置 vm._isMountedtrue, 表示这个实例已经挂载,并执行 mounted 钩子函数。

    所以其实mountComponent 核心就是先实例化一个渲染Watcher,并在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM