Vue的视图渲染逻辑

12/18/2023 Vue

在 Vue 的渲染逻辑中,主流程分为以下几个步骤:

  1. 初始化 Mixin
  2. 响应式代理
  3. 将 template 模板转换为 render 函数
  4. 虚拟 DOM 渲染
  5. 真实 DOM 挂载
  6. Watcher 和 Dep 的监听
  7. Diff 算法
  8. 真实 DOM 的挂载

# 初始化 mixin

Vue 通过 Mixin 的方式来扩展其类的功能。这样的设计使得 Vue 更好地进行模块化管理,而不需要将所有功能都写在同一个文件中。初始化 Mixin 包括以下主要模块:

  • init: 用于 Vue 类的初始化以及其模块的初始化。
  • state: 处理属性和响应式等。
  • render: 处理虚拟 DOM 渲染。
  • lifecycle: 处理生命周期以及真实 DOM 挂载,父子节点的关系。
  • event: 处理 Vue 的事件系统。
  • inject, provide: 处理 Vue 对 Provide 和 Inject API。

这样的模块划分使得 Vue 的功能更好地组织和扩展。

根据重要性,挑选一些模块从源码层面看看 Vue 是如何实现,如何精妙设计此框架。

# 响应式代理

在处理 state 模块中,Vue 对 datawatchcomputed 等属性进行响应式处理,通过 WatcherDep 对属性进行监听。Vue3 的响应式代理是通过 Proxy 实现的, Vue2 则是通过 Object.defineProperty 。在代理中,当属性被访问或修改时,会触发相应的 getset 操作。

响应式代理可以看这篇文章

Watcher 和 Dep 可以看这篇文章

有了这两个概念,接下来重点讲讲渲染和挂载

# Vue 的渲染与挂载

Vue 的 Root 实例初始化完了之后,就会进入渲染与挂载的流程。

# 将 template 模板转换为 render 函数

在这个步骤中,Vue 会将 HTML 通过正则表达式转换为 AST(Abstract Syntax Tree)抽象语法树,然后将 AST 转换为 render 函数。这个过程包括将 HTML 解析为 AST、优化 AST 以提高渲染性能,最后生成 render 函数。

这块代码在编辑版的 vue 是通过 vue-loader 去帮忙转换的,而 browser 版本则会把这段转换器写到 vue.js 中。

Vue 的视图渲染中,最重要的两个函数,也是接下来核心流程。

  1. render — 虚拟 DOM 的渲染
  2. update — 真实 DOM 的挂载

# 虚拟 DOM 渲染

因为提前的转换,得到 render 函数。在 Render 函数中,主要是把其中的函数创建成一个一个虚拟 DOM。下面的虚拟 DOM 的部分属性定义

export default class VNode {
  tag?: string; // html的标签,例如div, span
  data: VNodeData | undefined; // vue相关的数据,例如key, class, style等
  children?: Array<VNode> | null; // 孩子数据
  text?: string; // 文本标签
  elm: Node | undefined; // HTML的Node节点
  ns?: string; // 节点的namespace
  context?: Component; // 函数化组件的作用域
  key: string | number | undefined; // 节点的key属性
  componentOptions?: VNodeComponentOptions; // 创建组件实例时会用到的选项信息
  componentInstance?: Component; // 组件实例
  parent: VNode | undefined | null; // 组件的父亲节点
}

再转换成虚拟 DOM 中,有一些特别的类型会做特殊操作。

  1. 自定义组件
  2. slot

# 自定义组件

对于自定义组件,会创建一个 hook,这个 hook 用于初始化组件,在挂载的时候会执行。在定义组件的时候会传入 components 属性,Vue 会给它包装 Vue.extend 函数。 extend 的作用是创建一个 Sub 类,并将其 options 属性跟全局 Vue 的 options 属性给融合起来,并继承所有 Vue 的所有方法,包括创建 vnode 方法和挂载等。

const Sub = function VueComponent(this: any, options: any) {
  this._init(options);
} as unknown as typeof Component;
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.cid = cid++;
Sub.options = mergeOptions(Super.options, extendOptions);
Sub['super'] = Super;

回到 render 方法,如果判断到是自定义组件会执行 installComponentHooks 和把构造器传入 componentOptions 属性, installComponentHooks 会生成 init 的 hooks。这个 init hook 的作用会到挂载的时候说。

function installComponentHooks(data: VNodeData) {
  const hooks = data.hook || (data.hook = {});
  for (let i = 0; i < hooksToMerge.length; i++) {
    const key = hooksToMerge[i];
    const existing = hooks[key];
    const toMerge = componentVNodeHooks[key];
    // @ts-expect-error
    if (existing !== toMerge && !(existing && existing._merged)) {
      hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge;
    }
  }
}

const componentVNodeHooks = {
  init(vnode: VNodeWithData, hydrating: boolean): boolean | void {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode; // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode);
    } else {
      const child = (vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      ));
      child.$mount(hydrating ? vnode.elm : undefined, hydrating);
    }
  },
};

# Slot

Vue 初始化阶段,将组件 Slot 当成 children 放到 componentOptions 上作为虚拟节点的属性,并放到组件的 $options._renderChildren 中。

在初始化 render 的时候( initRender ),会将 _renderChildren 做处理,做出一个映射表放到 $slot 属性上,

在 render 阶段的时候结果放到 $scopeSlots 中,而且 $scopeSlots 中都是一个一个函数,对应的 Map 就是 name: fn

export function initInternalComponent(
  vm: Component,
  options: InternalComponentOptions
) {
  const opts = (vm.$options = Object.create((vm.constructor as any).options))
  // doing this because it's faster than dynamic enumeration.
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode

  const vnodeComponentOptions = parentVnode.componentOptions!
  opts.propsData = vnodeComponentOptions.propsData
  opts._parentListeners = vnodeComponentOptions.listeners
  **opts._renderChildren = vnodeComponentOptions.children**
  opts._componentTag = vnodeComponentOptions.tag

  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

function normalizeScopedSlot(vm, normalSlots, key, fn) {
  const normalized = function () {
    const cur = currentInstance
    setCurrentInstance(vm)
    let res = arguments.length ? fn.apply(null, arguments) : fn({})
    res =
      res && typeof res === 'object' && !isArray(res)
        ? [res] // single vnode
        : normalizeChildren(res)
    const vnode: VNode | null = res && res[0]
    setCurrentInstance(cur)
    return res &&
      (!vnode ||
        (res.length === 1 && vnode.isComment && !isAsyncPlaceholder(vnode))) // #9658, #10391
      ? undefined
      : res
  }
  // this is a slot using the new v-slot syntax without scope. although it is
  // compiled as a scoped slot, render fn users would expect it to be present
  // on this.$slots because the usage is semantically a normal slot.
  if (fn.proxy) {
    Object.defineProperty(normalSlots, key, {
      get: normalized,
      enumerable: true,
      configurable: true
    })
  }
  return normalized
}

# Vue update 函数

update 函数的作用就是挂载,将虚拟 DOM 渲染成真实 DOM。

先判断是否首次渲染,如果是,通过递归执行挂载,否则进入更新流程,执行diff 算法

挂载就是根据虚拟 DOM 的类型去处理,原生 DOM 和文本最后还是会执行浏览器的 createElement 函数。

对于一些特殊类型做特殊操作。

# 自定义组件

在 render 操作的时候,已经把 hook 和构造器传入到虚拟 DOM 里,在 update 函数中,就会执行 init hook。在该 hook 里,会直接执行 $mount 的方法, $mount 的方法最后还会再执行 render 和 update 方法,把这个自定义组件的 Vue 实例化。

# Slots

在 render 操作中,已经把所有的 slot 改造成 $scopeSlots ,最后在 update 函数进行挂载的时候,就执行这些方法就可以了

export function renderSlot(
  name: string,
  fallbackRender: ((() => Array<VNode>) | Array<VNode>) | null,
  props: Record<string, any> | null,
  bindObject: object | null
): Array<VNode> | null {
  const scopedSlotFn = this.$scopedSlots[name];
  let nodes;
  if (scopedSlotFn) {
    // scoped slot
    props = props || {};
    if (bindObject) {
      if (__DEV__ && !isObject(bindObject)) {
        warn('slot v-bind without argument expects an Object', this);
      }
      props = extend(extend({}, bindObject), props);
    }
    nodes =
      scopedSlotFn(props) ||
      (isFunction(fallbackRender) ? fallbackRender() : fallbackRender);
  } else {
    nodes =
      this.$slots[name] ||
      (isFunction(fallbackRender) ? fallbackRender() : fallbackRender);
  }

  const target = props && props.slot;
  if (target) {
    return this.$createElement('template', { slot: target }, nodes);
  } else {
    return nodes;
  }
}