vue2源码学习 (13) 虚拟DOM-3.从new Vue() 到 修改真实 DOM

40 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 17 天,点击查看活动详情

13. new Vue() 到 修改真实 DOM

start

虚拟 DOM 主要包含:

  1. 创建 vnode;
  2. 依据 vnode 修改现有的真实 DOM

修改真实 DOM

上一个章节,介绍了第一条创建不同类型的 vnode 的方式了。那么开始思考第二条,如何 依据 vnode 修改现有的真实 DOM。

修改真实 DOM,主要做了以下三件事:

  1. 创建新增的节点;
  2. 删除已经废弃的节点;
  3. 修改需要更新的节点;

vnode 和 oldVnode

在后续的源码阅读,可能会涉及到这两个变量,先来解释一下

  • vnode: 根据最新的状态创建的最新的 vnode(新的);
  • oldVnode: 上一次渲染 DOM 创建的 vnode(旧的);

我们修改真实 DOM,依据的是 最新的 vnode。而 oldVnode 主要是用于对比新旧 vnode 的差异。

从 new Vue() 到 修改真实 DOM

在学习 Vue 是如何 修改真实 DOM 之前,很有必要学习一下,从 new Vue()修改真实 DOM之间的过程。

1. new Vue()

  • 之前在学习响应式的时候,我们知道new Vue()后,在 this._init 中会初始化 props methods data computed watch
  • 当然除此之外,还有其他的初始化逻辑,本节暂不拓展这里;
  • 本节主要关注$mount方法
// \src\core\instance\init.js

// _init方法尾部,会有下列代码
if (vm.$options.el) {
  // 如果元素存在,就开始挂载
  vm.$mount(vm.$options.el)
}

2. $mount

$mount英文释义:挂载。(可以理解把我们的组件挂载到页面上 ) $mount的目的,主要是为了将模板渲染成真实的 DOM。

$mount本身在很多文件都有定义,例如

  • src/platform/web/entry-runtime-with-compiler.js
  • src/platform/web/runtime/index.js
  • src/platform/weex/runtime/index.js

$mount之所以有多处定义,是因为$mount本身和平台,构建方式有关,所以会有很多版本与之对应。

本次学习web平台,带编译版本的$mount。(因为这个版本常用)

web平台-带编译版本的$mount

// src\platform\web\entry-runtime-with-compiler.js
// 包含编译模板的js文件中的 $mount


// 首先存储一下 原型上的 mount()
const mount = Vue.prototype.$mount

// 定义一个新的 $mount  在新的 $mount 中再去调用旧的 $mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 元素存在然后 query
  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
  }

  // options其实就是配置,但是配置可以访问到template !!!后续看到这个template在options如何初始化的,研究一下!
  const options = this.$options

  // resolve template/el and convert to render function
  // 解析模板/el并转换为渲染函数
  // 没有渲染函数我们再走后续逻辑
  if (!options.render) {
    // 将模板编译成渲染函数,并且赋值给 options.render
    let template = options.template

    /* 这里其实就是对 template为字符串且开头是#  template为DOM元素的情况进行了处理 */
    if (template) {
      //1.  如果模板是字符串
      if (typeof template === 'string') {
        // 首字母是 #  ;  charAt() 方法从一个字符串中返回指定的字符。
        if (template.charAt(0) === '#') {
          // 通过这个template名称,获取缓存中的模板
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }

        // template是字符串而且开头不是 # ,正常的template直接使用即可。

        // 2.判断是不是DOM元素
      } else if (template.nodeType) {
        // 直接拿到DOM元素的innerHTML作为模板
        template = template.innerHTML
      } else {
        // 不是字符串也不是DOM元素, template会报错. 然后退出
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }

        return this
      }
    } else if (el) {
      // 没有模板,有元素,返回的是 DOM元素的 html 字符串
      template = getOuterHTML(el)
    }

    /* 这里才是正式的渲染逻辑!!!   当然没有template就不渲染 */
    if (template) {
      /* istanbul ignore if */
      // 性能标记
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      // 核心逻辑 compileToFunctions 编译成函数  `\src\compiler\to-function.js`
      const { render, staticRenderFns } = compileToFunctions(
        template,
        {
          outputSourceRange: process.env.NODE_ENV !== 'production',
          shouldDecodeNewlines,
          shouldDecodeNewlinesForHref,
          delimiters: options.delimiters,
          comments: options.comments,
        },
        this
      )
      options.render = render
      options.staticRenderFns = staticRenderFns

      // 此时 options.render 就有渲染函数啦!

      /* istanbul ignore if */
      // 性能截止
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }

  // 执行原本真实的挂载。 (这个地方我们可以发现,包装后的挂载函数$mount,其实就是判断了一下是否有渲染函数,没有才会走 template 。)
  // 所以只要我们提供了 render函数 ,template非必须
  return mount.call(this, el, hydrating)
}

我梳理一下上述示例代码的逻辑。

首先上述代码版本是, web 平台-带编译版本的$mount

它主要做了这么几个事情:

  1. 保存了 Vue 原型上原本的 $mount

  2. 限制了 Vue实例 不能挂载在 body、html 这样的根节点上;

  3. 如果没有定义 render 方法,则会把 el 或者 template 字符串转换成 render 方法

    vue2 中所有的渲染操作,都需要借助 render 方法

    compileToFunctions 是我们后面要学习的模板编译,后续继续学习。

  4. 调用 Vue 原型上原本的 $mount

原本的 $mount

// src/platform/web/runtime/index.js

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

原本的$mount,主要作用,执行了 mountComponent

这里为什么会有 原本的$mount 的说法。

其实是因为可以复用的操作,放在了 原本的$mount,需要额外拓展的操作,再在这个的基础上编写代码。

就导致了会有多个 $mount

mountComponent

// \src\core\instance\lifecycle.js

// 挂载组件
export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el

  // 处理下没有 render 函数的报错;
  if (!vm.$options.render) {
    // 为了防止出错,默认创建一个注释节点
    vm.$options.render = createEmptyVNode

    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      if (
        (vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el ||
        el
      ) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
            'compiler is not available. Either pre-compile the templates into ' +
            'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }

  // 触发 beforeMount 钩子
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */

  // 性能标记
  // 定义了 updateComponent
  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)
    }
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  // 我们将其设置为vm。_watcher在watcher的构造函数中
  // 因为观察者的初始补丁可能会调用$forceUpdate(例如inside child
  // 组件挂载的钩子),它依赖于vm。_watcher已经定义
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, 'beforeUpdate')
        }
      },
    },
    true /* isRenderWatcher */
  )

  /* 所以挂载的核心逻辑,其实就是这个 new Watcher 中的 vm._update(vm._render(), hydrating);*/

  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  // 调用挂载在self上的实例
  // 挂载被调用为渲染创建的子组件在其插入的钩子
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

mountComponent做了哪些操作呢?

  1. 做了一些异常情况的处理;

  2. 触发了生命周期钩子;

  3. new Watcher()

    这里的 Watcher 是渲染 Watcher。 之前学习 Watcher 的时候也有了解到这里。

    1. 首次初始化的时候,会执行 updateComponent
    2. 当实例状态改变的时候,本质也会执行 updateComponent
  4. 实际执行的是vm._update(vm._render(), hydrating)

    vm 其实就是 Vue实例

_render

// \src\core\instance\render.js

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    )
  }

  // set parent vnode. this allows render functions to have access
  // to the data on the placeholder node.
  vm.$vnode = _parentVnode
  // render self
  let vnode
  try {
    // There's no need to maintain a stack because all render fns are called
    // separately from one another. Nested component's render fns are called
    // when parent component is patched.
    currentRenderingInstance = vm
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    handleError(e, vm, `render`)
    // return error render result,
    // or previous vnode to prevent render error causing blank component
    /* istanbul ignore else */
    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
  }
  // if the returned array contains only a single node, allow it
  if (Array.isArray(vnode) && vnode.length === 1) {
    vnode = vnode[0]
  }
  // return empty vnode in case the render function errored out
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
      warn(
        'Multiple root nodes returned from render function. Render function ' +
          'should return a single root node.',
        vm
      )
    }
    vnode = createEmptyVNode()
  }
  // set parent
  vnode.parent = _parentVnode
  return vnode
}

整个 _render 函数的逻辑看下来,主要作用:利用 vm.$options.render,初始化最新的 vnode。

// 核心逻辑
vnode = render.call(vm._renderProxy, vm.$createElement);

// 这个地方其实就是依靠 runder 初始化 vnode, 由于涉及到的编译的部分逻辑,后续编译的模块仔细研究。这里暂时略过。

_update

// \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
  const restoreActiveInstance = setActiveInstance(vm)
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

上述代码整体逻辑:

  1. 存储旧的vnode
  2. 开始 vm.__patch__()

通过 _render 生成最新的 vnode。

通过 _update 开始进入有关操作真实 DOM 的逻辑 vm.__patch__

end

  • 回顾一下虚拟 DOM 的主要内容:

    1. 创建新的 vnode
    2. 修改真实 DOM
  • 梳理了从 new Vue() 到 修改真实 DOM 的前置逻辑。