组件是如何渲染成DOM的

准备工作

首先我们来初始化一个vue项目:

xml 复制代码
<template>
  <div class="helloWorld">
    hello world
  </div>
</template>
<script>
export default {
  setup() {
    // ...
  }
}
</script>
javascript 复制代码
import { createApp } from 'vue' import App from './App.vue' 
createApp(App).mount('#app')

根组件模板编译

我们知道 .vue 类型的文件无法在 Web 端直接加载,我们通常会通过 vue-loader 编译生成组件相关的 JavaScriptCSS,并把 template 部分编译转换成 render 函数添加到组件对象的属性中。 上述的 App.vue 文件内的模板其实是会被编译工具在编译时转成一个渲染函数,大致如下:

javascript 复制代码
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" 

const _hoisted_1 = { class: "helloWorld" } 

export function render(_ctx, _cache, $props, $setup, $data, $options) { 
    return (_openBlock(), _createElementBlock("div", _hoisted_1, " hello world ")) 
}

<script> 中的对象内容最终会和编译后的模板内容一起,生成一个 App 对象传入 createApp 函数中:

对象组件渲染成真实的 DOM

接着回到 main.js 的入口文件,整个初始化的过程只剩下如下部分了:

scss 复制代码
createApp(App).mount('#app')

接下来我们来看一下这一行代码到底做了什么。

由于流程比较复杂,先给一张流程图吧:

先可以看一下 createApp 的过程:

ini 复制代码
// packages/runtime-dom/src/index.ts 
export const createApp = (...args) => { 
    const app = ensureRenderer().createApp(...args); 
        // ... 
        return app; 
    };

ensureRenderer().createApp(...args) 这个链式函数执行完成后肯定返回了带有 mount 函数的一个对象。

csharp 复制代码
// packages/runtime-dom/src/index.ts
function ensureRenderer() {
  // 如果 renderer 有值的话,那么以后都不会初始化了
  return (
    renderer ||
    (renderer = createRenderer(rendererOptions)
  )
}

// renderOptions 包含以下函数:
const renderOptions = {
  createElement,
  createText,
  setText,
  setElementText,
  patchProp,
  insert,
  remove,
}

这里返回的 renderer 对象,可以认为是一个跨平台的渲染器对象,针对不同的平台,会创建出不同的 renderer 对象

再来看一下 createRenderer 返回的对象:

javascript 复制代码
// packages/runtime-core/src/renderer.ts
export function createRenderer(options) {
  // ...
  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate),
  }
}

可以看到,renderer 对象上包含了 createApprender 方法。其中createApp方法其实就是调用了createAppAPI方法返回的,再来看一下 createAppAPI 方法:

javascript 复制代码
// packages/runtime-core/src/apiCreateApp.ts
function createAppAPI(render, hydrate) {
  // createApp createApp 方法接收的两个参数:根组件的对象和 prop,
  // 入口文件调用的createApp方法真正执行的是这个方法。
  return function createApp(rootComponent, rootProps = null) {
    const app = {
      // ... 省略很多不需要在这里介绍的属性
      _component: rootComponent,
      _props: rootProps,
      mount(rootContainer, isHydrate, isSVG) {
        // ...
      }
    }
    return app
  }
}

到了这里才是vue3初始化根组件时真正调用的方法。也就是入口文件 createApp 真正执行的内容就是这里的 createAppAPI 函数中的 createApp 函数,该函数接收了 <App /> 组件作为根组件 rootComponent,返回了一个包含 mount 方法的 app 对象。

接下来再深入地看一下 mount 的内部实现:

scss 复制代码
// packages/runtime-core/src/apiCreateApp.ts
mount(rootContainer, isHydrate, isSVG) {
  if (!isMounted) {
    // ... 省略部分不重要的代码
    // 1. 创建根组件的 vnode
    const vnode = createVNode(
      rootComponent,
      rootProps
    )
    
    // 2. 渲染根组件
    render(vnode, rootContainer, isSVG)
    isMounted = true
  }
}

1. 创建根组件的 vnode

什么是 vnode 节点呢?其实它就是 Virtual DOM ,就是将真实的 DOM 以普通JavaScript对象的形式进行描述,简化了许多真实DOM中的不必要内容。

JS 直接操作 DOM 由于涉及到跨线程操作,往往会带来许多性能负担,所以 vnode 提供了对真实 DOM 上的一层虚拟映射,我们只需要操作这个虚拟DOM,真正将这个虚拟DOM映射到真实DOM的工作就交给框架来操作就好了,框架会在这中间帮我们做很多性能优化的事情。这样我们就可以从繁琐的DOM操作中解脱出来,专心于我们自己的业务逻辑代码了,这也是 vnode 带来的最大的优势之一。

其次,因为 vnode 只是一种与平台无关的数据结构而已,所以理论上我们也可以将它渲染到不同平台上从而达到跨平台渲染的目的。

上述例子中的 template 中的内容用 vnode 可以表示为:

css 复制代码
const vnode = {
  type: 'div',
  props: { 
    'class': 'helloWorld'
  },
  children: 'helloWorld'
}

那么根节点是如何被创建成一个 vnode 的呢?核心也就在 createVNode 函数中:

typescript 复制代码
// packages/runtime-core/src/vnode.ts
function createBaseVNode(...) {
  const vnode = {
    type,
    props,
    key: props && normalizeKey(props),
    children,
    component: null,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildren: null,
    // ... 一些其他属性
  }
  // ...
  return vnode
}
function createVNode(type, props = null, children = null) {
  if (props) {
    // 如果存在 props 则需要对 props 进行一些处理,这里先省略
  }
  // ...
  // 处理 shapeFlag 类型
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
    ? ShapeFlags.SUSPENSE
    : isTeleport(type)
    ? ShapeFlags.TELEPORT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0
  
  // ...
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    isBlockNode,
    true
  )
}

当进行根组件渲染的时候,createVNode 的第一个入参 type 是我们的 App 对象,也就是一个 Object,所以得到的 shapeFlag 的值是 STATEFUL_COMPONENT,代表的是一个有状态组件对象。到这里,Vue 完成了对根组件的 Vnode 对象的创建,接下来要做的就是将该组件渲染到页面中。

2. VNode 渲染成真实的组件

回到 mount 函数中,mount函数的第二步就是对 vnode 的渲染工作

scss 复制代码
render(vnode, rootContainer);

接下来看看 render 函数的实现:

javascript 复制代码
// packages/runtime-core/src/renderer.ts
const render = (vnode, container) => {
  if (vnode == null) {
    // 如果 vnode 不存在,表示需要卸载组件
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    // 否则进入更新流程(初始化创建也可以看作是一种全量的更新)
    patch(container._vnode || null, vnode, container)
  }
  // 缓存 vnode
  container._vnode = vnode
}

对于初始化根组件的过程中,传入了一个根组件的 vnode 对象,所以这里会执行 patch 相关的动作。patch 本意是补丁的意思。初始的过程也可以看作是一个全量补丁。 我们再来看一下patch函数的实现:

php 复制代码
// packages/runtime-core/src/renderer.ts
function patch(n1,n2,container = null,anchor = null,parentComponent = null) {
  // 对于类型不同的新老节点,直接进行卸载
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
  // 基于 n2 的类型来判断
  // 因为 n2 是新的 vnode
  const { type, shapeFlag } = n2;
  switch (type) {
    case Text:
       // 处理文本节点
      break;
    // 其中还有几个类型比如: static fragment comment
    default:
      // 这里就基于 shapeFlag 来处理
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent);
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 处理 component
        processComponent(n1, n2, container, parentComponent);
      } else if {
        // ... 处理其他元素
      }
  }
}

patch 函数主要接收的参数说明如下:

  1. n1 表示老的 vnode 节点;
  2. n2 表示新的 vnode 节点;
  3. container 表示需要挂载的 dom 容器;
  4. anchor 挂载的参考元素;
  5. parentComponent 父组件。

这里我们主要关注前 3 个参数,因为是初始化的过程,所以 n1 本次值为空,核心看 n2 的值,n2 有一个 typeshapeFlag。当前 n2typeApp 组件对象,所以逻辑会进入 Switchdefault 中。再比较 shapeFlag 属性,前面提到 shapeFlag 的值是 STATEFUL_COMPONENT

接着也就进入了 processComponent 的逻辑了:

scss 复制代码
// packages/runtime-core/src/renderer.ts
function processComponent(n1, n2, container, parentComponent) {
  // 如果 n1 没有值的话,那么就是 mount
  if (!n1) {
    // 初始化 component
    mountComponent(n2, container, parentComponent);
  } else {
    updateComponent(n1, n2, container);
  }
}

这里我们只看初始化的逻辑,n1 此时还是个空值,那么就会进入 mountComponent 函数对组件进行挂载过程。

scss 复制代码
// packages/runtime-core/src/renderer.ts
function mountComponent(initialVNode, container, parentComponent) {
  // 1. 先创建一个 component instance
  const instance = (initialVNode.component = createComponentInstance(
    initialVNode,
    parentComponent
  ));
  
  // 2. 初始化 instance 上的 props, slots, 执行组件的 setup 函数...
  setupComponent(instance);

  // 3. 设置并运行带副作用的渲染函数
  setupRenderEffect(instance, initialVNode, container);
}

该函数实现过程还是非常清晰的 第一步是组件实例化,在 Vue 3 中通过 createComponentInstance 的方法创建组件实例,返回的是一个组件实例的对象,大致包含以下属性:

scala 复制代码
// packages/runtime-core/src/component.ts
const instance = {
  // 这里是组件对象
  type: vnode.type, 
  // 组件 vnode
  vnode,
  // 新的组件 vnode
  next: null, 
  // props 相关
  props: {}, 
  // 指向父组件
  parent,
  // 依赖注入相关
  provides: parent ? parent.provides : {},
  // 渲染上下文代理
  proxy: null,
  // 标记是否被挂载
  isMounted: false,
  // attrs 相关
  attrs: {}, 
  // slots 相关
  slots: {}, 
  // context 相关
  ctx: {},
  // setup return 的状态数据
  setupState: {}, 
  // ...
};

这里先大概了解一下即可。

然后是对实例化后的组件中的属性做一些优化、处理、赋值等操作,这里主要是初始化了 propsslots,并执行组件的 setup 函数

scss 复制代码
// packages/runtime-core/src/component.ts
export function setupComponent(instance) {
  // 1. 处理 props
  // 取出存在 vnode 里面的 props
  const { props, children } = instance.vnode;
  initProps(instance, props);
  // 2. 处理 slots
  initSlots(instance, children);

  // 3. 调用 setup 并处理 setupResult
  setupStatefulComponent(instance);
}

最后是把组件实例的 render 函数执行一遍,这里是通过 setupRenderEffect 来执行的。我们再看一下这个函数的实现:

scss 复制代码
// packages/runtime-core/src/renderer.ts
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
  function componentUpdateFn() {
    if (!instance.isMounted) {
      // 渲染子树的 vnode
      const subTree = (instance.subTree = renderComponentRoot(instance))
      // 挂载子树 vnode 到 container 中
      patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
      // 把渲染生成的子树根 DOM 节点存储到 el 属性上
      initialVNode.el = subTree.el
      instance.isMounted = true
    }
    else {
      // 更新相关
    }
  }
  // 创建副作用渲染函数
  instance.update = effect(componentUpdateFn, prodEffectOptions)
}

这里我们再看一下 componentUpdateFn 这个函数,核心是调用了 renderComponentRoot 来生成 subTree,然后再把 subTree 挂载到 container 中。其实 renderComponentRoot 的核心工作就是执行 instance.render 方法。组件在编译时会生成组件对象,包含了 render 函数,该函数内部是一系列的渲染函数的执行

javascript 复制代码
import { openBlock, createElementBlock } from "vue"

const _hoisted_1 = { class: "helloWorld" }

export function render(...) {
  return (openBlock(), createElementBlock("div", _hoisted_1, " hello world "))
}

那么看一下 createElementBlock 函数的实现:

typescript 复制代码
// packages/runtime-core/src/vnode.ts
export const createElementBlock = (...) => {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */
    )
  )
};

可以看到本质还是调用了 createBaseVNode 创新 vnode。所以,我们可以推导出 subtree 就是调用 render 函数而生产的 vnode 节点。这里需要注意的一点是,因为 subtree 调用的 createBaseVNode 创建时,传入的 type = div 在这里是个 string,所以返回的 shapeFlags 的值是 ELEMENT

渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了,前面说过了 patch 的实现,再来简单看一下当传入的 vnodeshapeFlags 是个 ELEMENT 时,会调用 processElement 这个函数:

scss 复制代码
if (shapeFlag & ShapeFlags.ELEMENT) {
  processElement(n1, n2, container, anchor, parentComponent);
}

我们来看一下 processElement 的实现:

scss 复制代码
// packages/runtime-core/src/renderer.ts
function processElement(n1, n2, container, anchor, parentComponent) {
  if (!n1) {
    // 挂载元素节点
    mountElement(n2, container, anchor);
  } else {
    // 更新元素节点
    updateElement(n1, n2, container, anchor, parentComponent);
  }
}

因为在初始化的过程中,n1null,所以这里执行的是 mountElement 进行元素的初始化挂载。

scss 复制代码
// packages/runtime-core/src/renderer.ts
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let el
  const { type, props, shapeFlag, transition, patchFlag, dirs } = vnode
  // ...
  // 根据 vnode 创建 DOM 节点
  el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
  if (props) {
    // 处理 props 属性
    for (const key in props) {
      if (!isReservedProp(key)) {
        hostPatchProp(el, key, null, props[key], isSVG)
      }
    }
  }
  // 文本节点处理
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    hostSetElementText(el, vnode.children)
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 如果节点是个数据类型,则递归子节点
    mountChildren(vnode.children, el)
  }
  // 把创建好的 el 元素挂载到容器中
  hostInsert(el, container, anchor)
}

mountElemet 首先是通过 hostCreateElement 创建了一个 DOM 节点,然后处理一下 props 属性,接着根据 shapeFlag 判断子节点的类型,如果节点是个文本节点,则直接创建文本节点,如果子节点是个数组。它的 shapeFlag 将是一个数组类型 ARRAY_CHILDREN。此时会对该 vnode 节点的子节点调用 mountChildren 进行递归的 patch 渲染。

最后,处理完所有子节点后,通过 hostInsert 方法把缓存在内存中的 DOM el 映射渲染到真实的 DOM Container 当中。

csharp 复制代码
// packages/runtime-dom/src/nodeOps.ts
insert: (child, parent, anchor) {
  parent.insertBefore(child, anchor || null)
}

到这里,我们已经完成了从入口文件开始,分析根组件如何挂载渲染到真实 DOM 的流程

相关推荐
m0_7482402543 分钟前
前端如何检测用户登录状态是否过期
前端
black^sugar44 分钟前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人1 小时前
前端知识补充—CSS
前端·css
GISer_Jing2 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245522 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v2 小时前
webpack最基础的配置
前端·webpack·node.js
pubuzhixing2 小时前
开源白板新方案:Plait 同时支持 Angular 和 React 啦!
前端·开源·github
2401_857600952 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js
2401_857600952 小时前
数字时代的医疗挂号变革:SSM+Vue 系统设计与实现之道
前端·javascript·vue.js
GDAL2 小时前
vue入门教程:组件透传 Attributes
前端·javascript·vue.js