找回密码
 立即注册
首页 业界区 业界 [vue3] vue3初始化渲染流程

[vue3] vue3初始化渲染流程

计海龄 2025-6-6 15:57:38
组件初次渲染流程

组件是对DOM树的抽象,组件的外观由template定义,模板在编译阶段会被转化为一个渲染函数,用于在运行时生成vnode。即组件在运行时的渲染步骤是:
graph LR        A[创建vnode] --> B[渲染vnode] --> C[生成DOM]vnode是一个用于描述视图的结构和属性的JavaScript对象。vnode是对真实DOM的一层抽象。
使用vnode的优点:

  • 相比于直接操作DOM,在需要频繁更新视图的场景下,可以将多次操作应用在vnode上,再一次性地生成真实DOM,可以避免频繁重排重绘导致的性能问题;
  • vnode是抽象的视图层,具有平台无关性,上层代码可移植性强。
应用程序初始化

对于一个vue-app来说,整个组件树由根组件开始渲染。为了找到根组件的渲染入口,从应用程序的初始化过程开始分析。
在Vue2中,初始化应用的代码:
  1. import Vue from 'vue';
  2. import App from './App';
  3. const app = new Vue({
  4.     render: h=>h(App)
  5. });
  6. app.$mount('#app');
复制代码
在Vue3中,初始化应用的代码:
  1. import { createApp } from 'vue';
  2. import App from './App';
  3. const app = createApp(App);
  4. app.mount('#app');
复制代码
对比二者的代码可以看出,本质都是把App组件挂载到了#appDOM节点上。
本文主要关注Vue3。
Vue3的createApp的实现大致如下:
首先,createApp函数由createAppAPI根据对应的render对象构建得到。
  1. export function createAppAPI<HostElement>(
  2.   render: RootRenderFunction<HostElement>,
  3.   hydrate?: RootHydrateFunction,
  4. ): CreateAppFunction<HostElement> {
  5.   return function createApp(rootComponent, rootProps = null) {
  6.       //...
  7.   }
  8. }
复制代码
源码位置:core/packages/runtime-core/src/apiCreateApp.ts at main · vuejs/core (github.com)
render对象由baseCreateRenderer函数创建,根据不同的环境创建不同的render对象(常见的是浏览器环境下用来渲染DOM)。
并由render对象来决定createApp函数的实现:
  1. // baseCreateRenderer函数的返回值
  2. return {
  3.     render,
  4.     hydrate,
  5.     createApp: createAppAPI(render, hydrate),
  6. }
复制代码
这种根据不同环境构建不同render对象的操作是为了实现跨平台
接下来回到createApp内部。
createApp应用工厂模式,在内部创建app对象,实现了mount方法,mount方法就是用来挂载组件的。
  1. function createApp(rootComponent, rootProps = null){
  2.     // ...
  3.     const app: App = {
  4.         // ...
  5.         mount(
  6.                 rootContainer: HostElement,
  7.             isHydrate?: boolean,
  8.             namespace?: boolean | ElementNamespace,
  9.         ): any{
  10.                 // mount的具体实现,这里省略了很多代码...
  11.             // 1. 创建vnode
  12.             const vnode = createVNode(rootComponent, rootProps)
  13.             // 2. 渲染vnode
  14.             render(vnode, rootContainer, namespace)
  15.             }
  16.         // ...
  17.     }
  18.     return app;
  19. }
复制代码
在整个app对象创建过程中,Vue3通过闭包和函数柯里化等技巧实现了参数保留。
例如上面的mount方法内部实际上会使用render函数将vnode挂载到container上。而render由createAppAPI调用时传入。这就是闭包的应用。
graph TB        A["baseCreateRenderer"] --> B["craeteAppAPI [render]"]        B --> C["createApp"]        C --> D["mount"]        D --> |"使用render"|B上面提到的app对象中对mount的实现位于packages/runtime-core,也就是说是与平台无关的,内部都是对抽象的vnode、rootContainer进行操作,不一定是DOM节点。
Vue3将浏览器相关的DOM的实现移到了packages/runtime-dom中,在index.ts中可以看到ensureRenderer函数就调用了runtime-core中上述提到的createRenderer方法,传入了DOM相关的配置,用于获取一个专门用于浏览器环境的renderer。
源码位置:core/packages/runtime-dom/src/index.ts at main · vuejs/core (github.com)
在runtime-dom的index.ts中,我们从createApp函数入手,观察到它调用了ensureRenderer来获取一个适配浏览器环境的renderer,并调用其对应的createApp函数。
  1. export const createApp = ((...args) => {
  2.   const app = ensureRenderer().createApp(...args)
  3.   // ......
  4.   const { mount } = app
  5.   // 重写mount方法
  6.   app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
  7.     // 标准化容器:将字符串选择器转换为DOM对象
  8.     const container = normalizeContainer(containerOrSelector)
  9.     if (!container) return
  10.     const component = app._component
  11.     // 如果组件对象没有定义render函数和template模板,则取容器的innerHTML作为模板内容
  12.     if (!isFunction(component) && !component.render && !component.template) {
  13.       // 使用innerHTML需要注意安全性问题
  14.       component.template = container.innerHTML
  15.       // ......
  16.     }
  17.     // 挂载前删除容器的内容
  18.     container.innerHTML = ''
  19.     // 走runtime-core中实现的标准流程进行挂载
  20.     const proxy = mount(container, false, resolveRootNamespace(container))
  21.     // ......
  22.     return proxy
  23.   }
  24.   return app
  25. }) as CreateAppFunction<Element>
复制代码
阶段性总结

  • 重写mount的原因:

    • runtime-core中的mount:实现标准化挂载流程;
    • runtime-dom中的mount:实现DOM节点相关的预处理,然后调用runtime-core中的mount进行挂载;

  • runtime-dom中mount的流程:

    • 标准化容器:如果传入字符串选择器,那么调用document.querySelector将其转换为DOM对象;
    • 检查组件是否存在render函数和template对象,如果没有则使用容器的innerHTML作为模板;
      使用innerHTML需要注意安全性问题。

    • 删除容器原先的innerHTML内容;
    • 调用runtime-core中实现的mount方法走标准化流程挂载组件到DOM节点上。

从app.mount方法调用后,才真正开始组件的渲染流程。
接下来,回到runtime-core中关注渲染流程。
核心渲染流程

这一流程中主要做了两件事:创建vnode渲染vnode
vnode是用来描述DOM的JavaScript对象,在Vue中既可以描述普通DOM节点,也可以描述组件节点,除此之外还有纯文本vnode和注释vnode。
可以在runtime-core的vnode.ts文件中找到vnode的类型定义:core/packages/runtime-core/src/vnode.ts at main · vuejs/core (github.com)
内容较多,这里不做展示,比较核心的属性有比如:

  • type:组件的标签类型;
  • props:附加信息;
  • children:子节点,vnode数组;
除此之外,Vue3还为vnode打上了各种flag来做标记,在patch阶段根据不同的类型执行相应的处理逻辑。
创建vnode

在mount方法的实现中,通过调用createVNode函数创建根组件的vnode:
  1. const vnode = createVNode(rootComponent, rootProps);
复制代码
在vnode.ts中可以找到createVNode函数的实现:core/packages/runtime-core/src/vnode.ts at main · vuejs/core (github.com)
大致思路如下:
  1. function _createVNode(
  2.   type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  3.   props: (Data & VNodeProps) | null = null,
  4.   children: unknown = null,
  5.   patchFlag: number = 0,
  6.   dynamicProps: string[] | null = null,
  7.   isBlockNode = false,
  8. ): VNode{
  9.   // ...
  10.   // 标准化class和style这些样式属性
  11.   if(props){
  12.     // ...
  13.   }
  14.    
  15.   // 对vnode类型信息编码(二进制)
  16.   const shapeFlag = isString(type)
  17.     ? ShapeFlags.ELEMENT
  18.     : __FEATURE_SUSPENSE__ && isSuspense(type)
  19.       ? ShapeFlags.SUSPENSE
  20.       : isTeleport(type)
  21.         ? ShapeFlags.TELEPORT
  22.         : isObject(type)
  23.           ? ShapeFlags.STATEFUL_COMPONENT
  24.           : isFunction(type)
  25.             ? ShapeFlags.FUNCTIONAL_COMPONENT
  26.             : 0
  27.   // 调用工厂函数构建vnode对象
  28.   return createBaseVNode(
  29.     type,
  30.     props,
  31.     children,
  32.     patchFlag,
  33.     dynamicProps,
  34.     shapeFlag,
  35.     isBlockNode,
  36.     true,
  37.   )
  38. }
复制代码
接下来看一下createBaseVNode的大致实现(这个函数也位于vnode.ts文件内):
  1. function createBaseVNode(
  2.         // vnode部分属性的值
  3. ){
  4.         const vnode = {
  5.         type,
  6.         props,
  7.         // ...很多属性
  8.     } as VNode
  9.    
  10.     // 标准化children:讨论数组或者文本类型
  11.     if (needFullChildrenNormalization) {
  12.             normalizeChildren(vnode, children)
  13.     }
  14.     return vnode
  15. }
复制代码
渲染vnode

创建好vnode之后就是渲染的过程,在mount中使用render函数渲染创建好的vnode。
render的标准化流程的实现位于runtime-core的renderer.ts中:
源码位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
  1. const render: RootRenderFunction = (vnode, container, namespace) => {
  2.     if (vnode == null) {
  3.         // 销毁组件
  4.         if (container._vnode) {
  5.             unmount(container._vnode, null, null, true)
  6.         }
  7.     } else {
  8.         // 创建或者更新组件
  9.         patch(
  10.             container._vnode || null,
  11.             vnode,
  12.             container,
  13.             null,
  14.             null,
  15.             null,
  16.             namespace,
  17.         )
  18.     }
  19.     if (!isFlushing) {
  20.         isFlushing = true
  21.         flushPreFlushCbs()
  22.         flushPostFlushCbs()
  23.         isFlushing = false
  24.     }
  25.     // 缓存vnode节点,表示已经渲染
  26.     container._vnode = vnode
  27. }
复制代码

  • 如果vnode不存在,则调用unmount销毁组件;
  • 如果vnode存在,那么调用patch创建或者更新组件;
  • 将vnode缓存到容器对象上,表示已渲染。
patch函数的前两个参数分别是旧vnode和新vnode。

  • 初次调用,则container._vnode属性返回undefined,短路运算符传入null,则patch内部走创建逻辑;调用过后会将创建的vnode缓存到container._vnode;
  • 后续调用的container._vnode表示上一次创建的vnode,不为null,传入patch后走更新逻辑。
patch的实现

patch本意是打补丁,这个函数有两个功能:

  • 根据vnode挂载DOM;
  • 比较新旧vnode更新DOM。
这里只讨论初始化流程,故只记录如何挂载DOM,更新流程这里不做介绍。
源码位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
  1. const patch: PatchFn = (
  2.     n1,
  3.     n2,
  4.     container,
  5.     anchor = null,
  6.     parentComponent = null,
  7.     parentSuspense = null,
  8.     namespace = undefined,
  9.     slotScopeIds = null,
  10.     optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
  11. ) => {
  12.     // 二者相同,不需要更新
  13.     if (n1 === n2) {
  14.         return
  15.     }
  16.     // vnode类型不同,直接卸载旧节点
  17.     if (n1 && !isSameVNodeType(n1, n2)) {
  18.         anchor = getNextHostNode(n1)
  19.         unmount(n1, parentComponent, parentSuspense, true)
  20.         n1 = null
  21.     }
  22.         // ......
  23.     const { type, ref, shapeFlag } = n2
  24.     switch (type) {
  25.         case Text:
  26.             // 处理文字节点
  27.             break
  28.         case Comment:
  29.             // 处理注释节点
  30.             break
  31.         case Static:
  32.             // 静态节点
  33.             break
  34.         case Fragment:
  35.             // Fragment节点
  36.             break
  37.         default:
  38.             if (shapeFlag & ShapeFlags.ELEMENT) {
  39.                 // 处理普通DOM元素
  40.             } else if (shapeFlag & ShapeFlags.COMPONENT) {
  41.                 // 处理组件
  42.             } else if (shapeFlag & ShapeFlags.TELEPORT) {
  43.                 // 处理teleport
  44.             } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
  45.                 // 处理suspense
  46.             } else if (__DEV__) {
  47.                 // 报错:vnode类型不在可识别范围内
  48.                 warn('Invalid VNode type:', type, `(${typeof type})`)
  49.             }
  50.     }
  51. }
复制代码
这里只关注前三个函数参数:

  • n1:旧vnode,为null则表示初次挂载;
  • n2:新vnode;
  • container:挂载的目标容器。
patch在其内部调用了processXXX处理不同类型的vnode,这里只关注组件类型和普通DOM节点类型。
对组件的处理

处理组件调用的是processComponent函数:
processComponent
  1. const processComponent = (
  2.     n1: VNode | null,
  3.     n2: VNode,
  4.     container: RendererElement,
  5.     // ... 其它参数
  6. ) => {
  7.     if (n1 == null) {
  8.         // 挂载组件
  9.         mountComponent(n2, container, /*...other args*/)
  10.     } else {
  11.         // 更新组件
  12.         updateComponent(n1, n2, optimized)
  13.     }
  14. }
  15. // 这里还有很多其它参数省略了,函数体内还处理了`keep-alive`的情况,具体可以自己看源码。
复制代码

  • 挂载组件使用mountComponent函数;
  • 更新组件使用updateComponent函数。
mountComponent

源码位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
这个函数处理了较多边界情况,这里只展示主要的步骤:
  1. const mountComponent: MountComponentFn = (
  2.     initialVNode,
  3.     container,
  4.     anchor,
  5.     parentComponent,
  6.     parentSuspense,
  7.     namespace: ElementNamespace,
  8.     optimized,
  9. ) => {
  10.      // 创建组件实例
  11.      const instance: ComponentInternalInstance =
  12.            (initialVNode.component = createComponentInstance(
  13.                initialVNode,
  14.                parentComponent,
  15.                parentSuspense,
  16.            ))
  17.        
  18.      // 设置组件实例
  19.      setupComponent(instance, false, optimized)
  20.        
  21.      // 设置并运行带副作用的渲染函数
  22.      setupRenderEffect(
  23.          instance,
  24.          initialVNode,
  25.          container,
  26.          anchor,
  27.          parentSuspense,
  28.          namespace,
  29.          optimized,
  30.      )
  31. }
复制代码

  • 创建组件实例:工厂模式创建组件实例对象;
  • 设置组件实例:instance记录了许多组件相关的数据,setupComponent这一步主要是对props、slots等属性进行初始化。
接下来重点看一下setupRenderEffect函数的实现。
setupRenderEffect

setupRenderEffect 函数的主要工作是设置一个响应式效果 (ReactiveEffect),并创建一个调度任务 (SchedulerJob) 来管理组件的渲染和更新。首次渲染和后续更新的逻辑都封装在 componentUpdateFn 中。
简化后的代码
  1. const setupRenderEffect: SetupRenderEffectFn = (
  2.   instance,
  3.   initialVNode,
  4.   container,
  5.   anchor,
  6.   parentSuspense,
  7.   namespace: ElementNamespace,
  8.   optimized,
  9. ) => {
  10.   // 组件更新函数
  11.   const componentUpdateFn = () => {
  12.     if (!instance.isMounted) {
  13.       // 首次挂载逻辑
  14.       instance.subTree = renderComponentRoot(instance)
  15.       patch(null, instance.subTree, container, anchor, instance, parentSuspense, namespace)
  16.       instance.isMounted = true
  17.     } else {
  18.       // 后续更新逻辑
  19.       const nextTree = renderComponentRoot(instance)
  20.       patch(instance.subTree, nextTree, container, anchor, instance, parentSuspense, namespace)
  21.       instance.subTree = nextTree
  22.     }
  23.   }
  24.   // 创建响应式效果
  25.   const effect = (instance.effect = new ReactiveEffect(componentUpdateFn, NOOP))
  26.   // 创建调度任务
  27.   const update: SchedulerJob = (instance.update = () => {
  28.     if (effect.dirty) {
  29.       effect.run()
  30.     }
  31.   })
  32.   // 立即执行更新函数
  33.   update()
  34. }
复制代码
setupRenderEffect内部主要包含了3个函数:

  • componentUpdateFn 的主要作用是在组件首次挂载和后续更新时执行相应的渲染逻辑,确保组件的虚拟 DOM 树与实际的 DOM 树保持同步,并执行相关的生命周期钩子函数
  • effect 封装了组件的渲染逻辑,负责在响应式依赖变化时触发重新渲染
  • update 是调度任务,负责在适当的时机检查和触发 effect,确保组件的渲染逻辑能够正确执行。
也就是说它们依次为前者的进一步封装。
componentUpdateFn中的初始挂载逻辑:

  • 渲染组件生成subTree;(递归调用patch)
  • 将subTree通过patch挂载到container上。
这里的patch就是一个递归过程。事实上patch对于组件只有渲染过程,没有挂载的操作,因为组件是抽象的,并不能通过DOM API插入到页面上。
也就是说patch只对DOM类型元素进行mount挂载,对于组件类型元素的处理只做递归操作。换个角度描述就是:组件树的叶子节点一定都是DOM类型元素,只有这样才能渲染并挂载到页面上。
接下来开始研究patch对DOM类型元素的处理过程。(可以返回上文看一下patch的实现)。
对DOM的处理

processElement

patch函数使用processElement 函数处理新旧DOM元素,当n1为null时,走挂载流程;否则走更新流程。
源码地址:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
  1. const processElement = (
  2.     n1: VNode | null,
  3.     n2: VNode,
  4.     container: RendererElement,
  5.     // ...other args...
  6.   ) => {
  7.     if (n1 == null) {
  8.       // 挂载
  9.       mountElement(n2, container, /* ...other args... */)
  10.     } else {
  11.       // 更新
  12.       patchElement(n1, n2, parentComponent, /* ...other args... */)
  13.     }
  14.   }
复制代码
mountElement

源码位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
这里省略了很多代码,只保留大致流程:

  • 创建DOM元素;
  • 挂载子节点;

    • 如果子节点只是文字,则设置DOM节点的textContent;
    • 如果子节点是数组,则使用for循环 + 递归调用patch函数渲染子元素;
      这里递归使用的是patch而不是mountElement是因为子元素可能不是DOM元素,而是其它类型的元素。因此还是要用到patch中的switch - case走类型判断的逻辑。

  • 设置DOM元素的属性;
  • 插入DOM元素。
  1. const mountElement = (
  2.   vnode: VNode,
  3.   container: RendererElement,
  4.   /* ...other args... */
  5. ) => {
  6.   const { props, shapeFlag, transition, dirs } = vnode
  7.   // 创建DOM元素
  8.   const el = vnode.el = hostCreateElement(vnode.type as string, namespace, props && props.is, props)
  9.   // 挂载子节点
  10.   if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
  11.     hostSetElementText(el, vnode.children as string)
  12.   } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
  13.     mountChildren(vnode.children as VNodeArrayChildren, el, null, parentComponent, parentSuspense, resolveChildrenNamespace(vnode, namespace), slotScopeIds, optimized)
  14.   }
  15.        
  16.   // 设置属性
  17.   if (props) {
  18.     for (const key in props) {
  19.       if (key !== 'value' && !isReservedProp(key)) {
  20.         hostPatchProp(el, key, null, props[key], namespace, parentComponent)
  21.       }
  22.     }
  23.     // 特殊处理 value 属性
  24.     if ('value' in props) {
  25.       hostPatchProp(el, 'value', null, props.value, namespace)
  26.     }
  27.   }
  28.   // 插入元素
  29.   hostInsert(el, container, anchor)
  30. }
复制代码
其中的hostCreateElement、hostSetElementText、hostPatchProp、hostInsert函数都由runtime-dom中在创建renderer的时候传入对应的实现。
在runtime-dom模块的nodeOps.ts和patchProp.ts文件可以找到这些DOM相关操作的具体实现。
nodeOps.ts源码位置:core/packages/runtime-dom/src/nodeOps.ts at e26fd7b1d15cb3335a4c2230cc49b1008daddca1 · vuejs/core (github.com)
patchProp.ts源码位置:core/packages/runtime-dom/src/patchProp.ts at e26fd7b1d15cb3335a4c2230cc49b1008daddca1 · vuejs/core (github.com)
上述hostXXX对应的DOM方法分别是:

  • hostCreateElement:document.createElement;
  • hostSetElementText:el.textContent = ...;
  • hostPatchProp:直接修改DOM对象上的键值,会对特殊的key做处理;
  • hostInsert:[Node.insertBefore](Node.insertBefore() - Web API | MDN (mozilla.org))
初次渲染流程总结

1.jpeg


来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册