深入探索Vue3系列(一)虚拟结点vnode的页面挂载流程

米阳 2023-4-20 303 4/20

今天就从页面渲染开始聊聊吧,我们都知道在Vue的template模板中写下的内容最终会被编译成所谓的虚拟DOM,这东西本质上就是一个普通的JavaScript对象。关于虚拟DOM的探索咱们今天先不聊,因为那不是我们的重点,等下次再说。

那么问题来了:这个虚拟DOM是如何从一个看似普通的JavaScript对象变身成为页面内容的呢?换句话说,JavaScript对象怎么变成HTML元素?下面,我们一起深入探索虚拟vnode是如何成功挂载到页面上的。

举例

我们用main.ts文件中挂载App组件来当案例
// main.ts
import { createApp } from "vue";
import App from "./app";
const app = createApp(App);
app.mount("#app");

这段代码就展示了VUE的基本启动流程

  1. 导入必要的模块和组件:首先,我们从vue包中导入createApp函数,同时从本地文件导入App组件。这为创建Vue应用奠定了基础。
  2. 创建应用实例:通过调用createApp函数并传入App组件,我们创建了一个Vue应用实例,这个实例被存储在变量app中。这一步是设置Vue组件层次结构的起点。
  3. 挂载应用到DOM:最后,我们调用实例的mount方法,并指定一个DOM元素(通过其CSS选择器"#app"标识)作为挂载点。这一步将Vue应用实例与页面的实际DOM结构关联起来,使得App组件及其子组件能被渲染在指定的容器内。

通过以上步骤,Vue应用的根组件App被成功挂载到页面上。接下来,我们可以进一步探索这两个关键函数(createAppmount)背后的具体实现,深入了解Vue框架如何处理组件初始化和DOM渲染的细节。

createApp的工作原理

createApp函数定义在Vue的core/packages/runtime-dom/src/index.ts文件中。
下面是createApp的基本实现:
export const createApp = ((...args) => {
  // 创建app对象
  const app = ensureRenderer().createApp(...args);
  const { mount } = app;
  // 重写mount方法
  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    // 挂载逻辑
  };
  return app;
}) as CreateAppFunction<Element>;

createApp 主要做两件事:

  1. 创建 app 实例
  2. 重写 mount 方法

1.1 ensureRenderer函数

// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
  );
}

ensureRenderer的职责是获取或创建一个渲染器实例。如果已有渲染器实例则返回,否则会通过createRenderer函数创建新的渲染器:

可以看到,这里先用 ensureRenderer() 来延时创建渲染器,这样做的好处是当用户只依赖响应式包的时候,就不会创建渲染器,因此可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码。

这里涉及了渲染器的概念,它是为跨平台渲染做准备的,之后我会在自定义渲染器的相关内容中详细说明。在这里,你可以简单地把渲染器理解为包含平台渲染核心逻辑的 JavaScript 对象。

1.2 createRenderer函数

export function createRenderer<HostNode, HostElement>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options);
}
//createRenderer负责创建一个基于给定选项的渲染器。这个渲染器定义了核心的渲染逻辑

1.3 baseCreateRenderer函数

function baseCreateRenderer(options) {
  function render(vnode, container) {
    // 组件渲染的核心逻辑
  }
  return {
    render,
    createApp: createAppAPI(render),
  };
}
baseCreateRenderer函数里面定义了非常非常多的属性,但目前就只关注其中的一个render函数,这个函数是组件渲染的核心,函数随后返回了一个含有render属性和createApp属性的对象,其中createApp属性的值是createAppApi方法
1.4 createAppAPI函数
// core/packages/runtime-core/src/apiCreateApp.ts
function createAppAPI(render) {
// createApp createApp 方法接受的两个参数:根组件的对象和 prop,这个rootComponent就是app组件
return function createApp(rootComponent, rootProps = null) {
  const app = {
    _component: rootComponent,
    _props: rootProps,
    mount(rootContainer) {
      // 创建根组件的 vnode
      const vnode = createVNode(rootComponent, rootProps);
      // 利用渲染器渲染 vnode
      render(vnode, rootContainer);
      app._container = rootContainer;
      return vnode.component.proxy;
    },
  };
  return app;
};
}

createAppAPI返回了一个createApp函数给上层,当执行createApp()时,就会返回一个app对象,这个app对象身上挂了一个mount方法

app.mount 的具体实现

2.1 重写mount方法

// core/packages/runtime-dom/src/index.ts
app.mount = (containerOrSelector) => {
  const container = normalizeContainer(containerOrSelector);
  const vnode = createVNode(rootComponent, rootProps);
  render(vnode, container);
};
首先是通过 normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器),然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。

从 app.mount 开始,才算真正进入组件渲染流程,那么接下来,我们就重点看一下核心渲染流程做的两件事情:创建 vnode 和渲染 vnode

2.2 创建VNode

vnode 本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。

什么是普通元素节点呢?举个例子,在 HTML 中我们使用 <button> 标签来写一个按钮

//dom对象
<button class="btn" style="width:100px;height:50px">
  click me
</button>

//js对象
const vnode = {
  type: "button",
  props: {
    class: "btn",
    style: {
      width: "100px",
      height: "50px",
    },
  },
  children: "click me",
};
//其中,type 属性表示DOM的标签类型,props 属性表示 DOM 的一些附加信息,比如 style 、class 等,children 属性表示DOM的子节点,它也可以是一个 vnode 数组,只不过 vnode 可以用字符串表示简单的文本 。

什么是组件节点呢?其实, vnode 除了可以像上面那样用于描述一个真实的 DOM,也可以用来描述组件

const CustomComponent = {
  // 在这里定义组件对象
}
const vnode = {
  type: CustomComponent,
  props: {
    msg: 'test'
  }
}

组件 vnode 其实是对抽象事物的描述,这是因为我们并不会在页面上真正渲染一个 <custom-component> 标签,而是渲染组件内部定义的 HTML 标签。

除了上两种 vnode 类型外,还有纯文本 vnode、注释 vnode 等等,但鉴于我们的主线只需要研究组件 vnode 和普通元素 vnode,所以我在这里就不赘述了。

createVNode 函数的大致实现:
function createVNode(type, props = null
,children = null) {
  if (props) {
    // 处理 props 相关逻辑,标准化 class 和 style
  }
  // 对 vnode 类型信息编码
  const shapeFlag = isString(type)
    ? 1 /* ELEMENT */
    : isSuspense(type)
      ? 128 /* SUSPENSE */
      : isTeleport(type)
        ? 64 /* TELEPORT */
        : isObject(type)
          ? 4 /* STATEFUL_COMPONENT */
          : isFunction(type)
            ? 2 /* FUNCTIONAL_COMPONENT */
            : 0
  const vnode = {
    type,
    props,
    shapeFlag,
    // 一些其他属性
  }
  // 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
  normalizeChildren(vnode, children)
  return vnode
}

通过上述代码可以看到,其实 createVNode 做的事情很简单,就是:对 props 做标准化处理、对 vnode 的类型信息编码、创建 vnode 对象,标准化子节点 children 

2.3 渲染VNode

接下来就是执行render(vnode, container);来实现渲染逻辑,先说明一下,render函数是creatApp方法的参数,在baseCreateRenderer方法里面定义的
2.3.1 render方法的实现
const render = (vnode, container) => {
 if (vnode == null) {
   // 销毁组件
   if (container._vnode) {
     unmount(container._vnode, null, null, true)
   }
 } else {
   // 创建或者更新组件
   patch(container._vnode || null, vnode, container)
 }
 // 缓存 vnode 节点,表示已经渲染
 container._vnode = vnode
} 

这个渲染函数 render 的实现很简单,如果它的第一个参数 vnode 为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑。

2.3.2 patch函数

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
  // 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
  const { type, shapeFlag } = n2
  switch (type) {
    case Text:
      // 处理文本节点
      break
    case Comment:
      // 处理注释节点
      break
    case Static:
      // 处理静态节点
      break
    case Fragment:
      // 处理 Fragment 元素
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 6 /* COMPONENT */) {
        // 处理组件
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 64 /* TELEPORT */) {
        // 处理 TELEPORT
      }
      else if (shapeFlag & 128 /* SUSPENSE */) {
        // 处理 SUSPENSE
      }
  }
}
我们先分析一下初次渲染,就是创建过程,更新过程后面的在单独分析
在创建的过程中,patch 函数接受多个参数,这里我们目前只重点关注前三个:

第一个参数 n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次挂载的过程;
第二个参数 n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;
第三个参数 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。

对于渲染的节点,我们这里重点关注两种类型节点的渲染逻辑:对组件的处理和对普通 DOM 元素的处理。

2.3.3 对普通DOM的处理 processElement

const processElement = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  isSVG = isSVG || n2.type === "svg";
// 如果 n1 为 null,走挂载元素节点的逻辑,否则走更新元素节点逻辑
  if (n1 == null) {
    //挂载元素节点
    mountElement(
      n2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      optimized
    );
  } else {
    //更新元素节点
    patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized);
  }
};

//接着来看挂载元素的 mountElement 函数的实现
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let el
  const { type, props, shapeFlag } = vnode
  // 创建 DOM 元素节点
  el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
  if (props) {
    // 处理 props,比如 class、style、event 等属性
    for (const key in props) {
      if (!isReservedProp(key)) {
        hostPatchProp(el, key, null, props[key], isSVG)
      }
    }
  }
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    // 处理子节点是纯文本的情况
    hostSetElementText(el, vnode.children)
  }
  else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
    // 处理子节点是数组的情况
    mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
  }
  // 把创建的 DOM 元素节点挂载到 container 上
  hostInsert(el, container, anchor)
}

可以看到,挂载元素函数主要做四件事:创建 DOM 元素节点、处理 props、处理 children、挂载 DOM 元素到 container 上。

首先是创建 DOM 元素节点,通过 hostCreateElement 方法创建,这是一个平台相关的方法,我们来看一下它在 Web 环境下的定义:

function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}

创建完 DOM 节点后,接下来要做的是判断如果有 props 的话,给这个 DOM 节点添加相关的 class、style、event 等属性,并做相关的处理,这些逻辑都是在 hostPatchProp 函数内部做的,这里就不展开讲了。

接下来是对子节点的处理,我们知道 DOM 是一棵树,vnode 同样也是一棵树,并且它和 DOM 结构是一一映射的。

如果子节点是纯文本,则执行 hostSetElementText 方法,它在 Web 环境下通过设置 DOM 元素的 textContent 属性设置文本:

function setElementText(el, text) {
  el.textContent = text
}

如果子节点是数组,则执行 mountChildren方法:

const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
  for (let i = start; i < children.length; i++) {
    // 预处理 child
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i])
      : normalizeVNode(children[i]))
    // 递归 patch 挂载 child
    patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
}

 

2.3.4 对组件的处理 processComponent


const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  if (n1 == null) {
   // 挂载组件
   mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
  else {
    // 更新组件
    updateComponent(n1, n2, parentComponent, optimized)
  }
}

再往下看找到挂载组件 mountComponent的实现
const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  // 创建组件实例
  const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
  // 设置组件实例
  setupComponent(instance)
  // 设置并运行带副作用的渲染函数
  setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}

//最后是运行带副作用的渲染函数 setupRenderEffect,我们重点来看一下这个函数的实现:
const setupRenderEffect = (
  instance,
  initialVNode,
  container,
  anchor,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 创建响应式的副作用渲染函数
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      // 渲染组件生成子树 vnode
      const subTree = (instance.subTree = renderComponentRoot(instance));
      // 把子树 vnode 挂载到 container 中
      patch(null, subTree, container, anchor, instance, parentSuspense, isSVG);
      // 保留渲染生成的子树根 DOM 节点
      initialVNode.el = subTree.el;
      instance.isMounted = true;
    } else {
      // 更新组件
    }
  }, prodEffectOptions);
};
//渲染函数内部也会判断这是一次初始渲染还是组件更新。这里我们只分析初始渲染流程。
//初始渲染主要做两件事情:渲染组件生成 subTree、把 subTree 挂载到 container 中。

结束总结

最后咱就用个流程图来概括一下整体流程吧

深入探索Vue3系列(一)虚拟结点vnode的页面挂载流程

 

- THE END -

米阳

2月20日10:36

最后修改:2025年2月20日
0

非特殊说明,本博所有文章均为博主原创。