Vue3源码解析计划之组件渲染,VNode如何转为真实DOM

开发 前端
在VUE中,组件是一个非常重要的概念,整个应用的页面都是通过组件进行渲染实现的,但是我们在编写组件时,它们内部又是如何进行工作的呢?

[[439941]]

1写在前面

在VUE中,组件是一个非常重要的概念,整个应用的页面都是通过组件进行渲染实现的,但是我们在编写组件时,它们内部又是如何进行工作的呢?从我们开始编写组件,到最终转为真实DOM,是一个怎样的转变过程呢?那么我们应该先来了解vue3中组件时如何渲染的?

2组件

组件是一个抽象概念,它是对一棵DOM树的抽象,在页面写一个组件节点:,它并不会在页面上渲染这个叫的标签。我们在写组件时,应该内部时这样的:

  1. <template> 
  2.     <div class="test"
  3.     <p>hello world</p> 
  4.   </div>   
  5. </template> 

那么,一个组件想要真正渲染成DOM需要以下几个步骤:

  • 创建VNode
  • 渲染VNode
  • 生成真实DOM

这里的VNode是什么,其实就是能够描述组件信息的Javascript对象。

3应用程序初始化

一个组件可以通过"模板+对象描述"的方式创建组件,创建好后又是如何被调用并进行初始化的呢?

因为整个组件树是从根组件开始进行渲染的,要寻找到根组件的渲染入口,需要从应用程序的初始化过程开始分析。

我们分别看下vue2和vue3初始化应用代码有啥区别,但其实没多大区别。

  1. //vue2  
  2. import Vue from "vue"
  3. import App from "./App"
  4.  
  5. const app = new Vue({ 
  6.     render:h=>h(App); 
  7. }) 
  8.  
  9. app.$mount("#app"); 
  10.  
  11. //vue3 
  12. import {createApp} from "vue"
  13. import App from "./app"
  14. const app = createApp(App); 
  15. app.mount("#app"); 

接下来我们看看createApp内部实现:

  1. export const createApp = ((...args) => { 
  2.   //创建app对象 
  3.   const app = ensureRenderer().createApp(...args) 
  4.  
  5.   if (__DEV__) { 
  6.     injectNativeTagCheck(app) 
  7.   } 
  8.  
  9.   const { mount } = app 
  10.     //重写mount方法 
  11.   app.mount = (containerOrSelector: Element | string): any => { 
  12.     const container = normalizeContainer(containerOrSelector) 
  13.     if (!container) return 
  14.     const component = app._component 
  15.     if (!isFunction(component) && !component.render && !component.template) { 
  16.       component.template = container.innerHTML 
  17.     } 
  18.     // clear content before mounting 
  19.     container.innerHTML = '' 
  20.     const proxy = mount(container) 
  21.     container.removeAttribute('v-cloak'
  22.     return proxy 
  23.   } 
  24.  
  25.   return app 
  26. }) as CreateAppFunction<Element> 

我们看到const app = ensureRenderer().createApp(...args)用来创建app对象,那么其内部是如何实现的:

  1. //渲染相关的一些配置,比如:更新属性的方法,操作DOM的方法 
  2. const rendererOptions = { 
  3.   patchProp,  // 处理 props 属性  
  4.   ...nodeOps // 处理 DOM 节点操作 
  5.  
  6. // lazy create the renderer - this makes core renderer logic tree-shakable 
  7. // in case the user only imports reactivity utilities from Vue. 
  8.  
  9. let renderer: Renderer | HydrationRenderer 
  10.  
  11. let enabledHydration = false 
  12. // 我们看到中文翻译就是:延时创建渲染器,当用户只依赖响应式包的时候,不会立即创建渲染器, 
  13. // 可以通过tree-shakable移除核心渲染逻辑相关的代码 
  14. function ensureRenderer() { 
  15.   return renderer || (renderer = createRenderer(rendererOptions)) 

渲染器,这是为了跨平台渲染做准备的,简单理解就是:包含平台渲染逻辑的js对象。我们看到创建渲染器,是通过调用createRenderer来实现的,其通过调用baseCreateRenderer函数进行返回,其中就有我们要找的createApp: createAppAPI(render, hydrate)。

  1. export function createRenderer< 
  2.   HostNode = RendererNode, 
  3.   HostElement = RendererElement 
  4. >(options: RendererOptions<HostNode, HostElement>) { 
  5.   return baseCreateRenderer<HostNode, HostElement>(options) 
  6.  
  7. // 
  8. function baseCreateRenderer( 
  9.   options: RendererOptions, 
  10.   createHydrationFns?: typeof createHydrationFunctions 
  11. ): any { 
  12.   const { 
  13.     insert: hostInsert, 
  14.     remove: hostRemove, 
  15.     patchProp: hostPatchProp, 
  16.     createElement: hostCreateElement, 
  17.     createText: hostCreateText, 
  18.     createComment: hostCreateComment, 
  19.     setText: hostSetText, 
  20.     setElementText: hostSetElementText, 
  21.     parentNode: hostParentNode, 
  22.     nextSibling: hostNextSibling, 
  23.     setScopeId: hostSetScopeId = NOOP, 
  24.     cloneNode: hostCloneNode, 
  25.     insertStaticContent: hostInsertStaticContent 
  26.   } = options 
  27.  
  28.   // ....此处省略两千行,我们先不管 
  29.  
  30.   return { 
  31.     render, 
  32.     hydrate, 
  33.     createApp: createAppAPI(render, hydrate) 
  34.   } 

我们看到createAppAPI(render, hydrate)方法接受两个参数:根组件渲染函数render,可选参数hydrate是在SSR场景下应用的,这里先不关注。

  1. export function createAppAPI<HostElement>( 
  2.   render: RootRenderFunction, 
  3.   hydrate?: RootHydrateFunction 
  4. ): CreateAppFunction<HostElement> { 
  5.   //createApp方法接受的两个参数:根组件的对象和prop 
  6.   return function createApp(rootComponent, rootProps = null) { 
  7.     if (rootProps != null && !isObject(rootProps)) { 
  8.       __DEV__ && warn(`root props passed to app.mount() must be an object.`) 
  9.       rootProps = null 
  10.     } 
  11.  
  12.     // 创建默认APP配置 
  13.     const context = createAppContext() 
  14.     const installedPlugins = new Set() 
  15.  
  16.     let isMounted = false 
  17.  
  18.     const app: App = { 
  19.       _component: rootComponent as Component, 
  20.       _props: rootProps, 
  21.       _container: null
  22.       _context: context, 
  23.  
  24.       get config() { 
  25.         return context.config 
  26.       }, 
  27.  
  28.       set config(v) { 
  29.         if (__DEV__) { 
  30.           warn( 
  31.             `app.config cannot be replaced. Modify individual options instead.` 
  32.           ) 
  33.         } 
  34.       }, 
  35.  
  36.       // 都是一些眼熟的方法 
  37.       use() {}, 
  38.       mixin() {}, 
  39.       component() {}, 
  40.       directive() {}, 
  41.       //用于挂载组件 
  42.       mount(rootContainer){ 
  43.         //创建根组件的VNode 
  44.         const vnode = createVNode(rootComponent,rootProps); 
  45.         //利用渲染器渲染VNode 
  46.         render(vnode,rootContainer); 
  47.         app._container = rootComponent; 
  48.         return vnode.component.proxy; 
  49.       } 
  50.  
  51.       // ... 
  52.     } 
  53.      
  54.      
  55.     return app 
  56.   } 

在整个app对象的创建过程中,vue.js利用 闭包和函数柯里化 的技巧,很好的实现参数保留。如:在执行app.mount的时候,不需要传入渲染器render,因为在执行createAppAPI的时候,渲染器render参数已经被保留下来。

我们知道在vue源码中已经将mount方法已经进行封装,但是在我们使用时为什么还要进行重写,而不是直接把相关逻辑放在app对象的mount方法内部实现呢?

重写的目的是:实现既能让用户在使用API时更加灵活,也可以兼容Vue2的写法。

这是因为vue.js不仅仅是为web平台服务的,其设计的目标是"星辰大海"--实现支持跨平台渲染,内部不能够包含任何指定平台的内容,createApp函数内部的app.mount方法是一个标准的可跨平台的组件渲染流程:先创建VNode,再渲染VNode。

  1. mount(rootContainer){ 
  2.   //创建根组件的VNode 
  3.   const vnode = createVNode(rootComponent,rootProps); 
  4.   //利用渲染器渲染VNode 
  5.   render(vnode,rootContainer); 
  6.   app._container = rootComponent; 
  7.   return vnode.component.proxy; 

我们看到app.mount重写的代码如下:

  1. //重写mount方法 
  2. app.mount = (containerOrSelector: Element | string): any => { 
  3.   //标准化容器 
  4.   const container = normalizeContainer(containerOrSelector) 
  5.   //如果容器为空对象,就直接返回呢 
  6.   if (!container) return 
  7.   const component = app._component 
  8.   //如果组件对象没有定义render函数和template模板,则直接取出容器的innerHTML方法作为组件模板内容 
  9.   if (!isFunction(component) && !component.render && !component.template) { 
  10.     component.template = container.innerHTML 
  11.   } 
  12.   //在挂载前清空容器内容 clear content before mounting 
  13.   container.innerHTML = '' 
  14.   //实现真正的挂载 
  15.   const proxy = mount(container) 
  16.   container.removeAttribute('v-cloak'
  17.   return proxy 

4核心渲染流程:创建VNode和渲染VNode

vnode本质上用来描述DOM的Javascript对象,它在vue中可以描述不同节点,比如:普通元素节点、组件节点等。

我们可以使用vnode来表示button标签:

  • type:标签的类型
  • props:标签的DOM属性信息
  • children:DOM的子节点,vnode数组
  1. const vnode = { 
  2.   //标签的类型 
  3.  type:"button"
  4.   //标签的DOM属性信息 
  5.   props:{ 
  6.    "class":"btn"
  7.     style:{ 
  8.      width:"100px"
  9.       height:"100px" 
  10.     } 
  11.   }, 
  12.   //dom的子节点,vnode数组 
  13.   children:"确认" 

那么,我们可以使用vnode来对抽象事物的描述,比如用来表示组件标签,页面并不会真正渲染一个叫做HelloWorld的标签元素,而是渲染组件内部定义的原生的HTML标签元素。

  1. const HelloWorld = { 
  2.  //定义组件对象信息 
  3.  
  4. const vnode = { 
  5.  type:HelloWorld, 
  6.   props:{ 
  7.    msg:"test" 
  8.   } 

我们在想:vnode到底有什么优势,为什么一定要设计成vnode这样的数据结构?

  • 抽象:引入vnode,可以将渲染过程抽象化,从而使得组件的抽象能力有所提升。
  • 跨平台:因为patch vnode过程不同平台可以有自己的实现,给予vnode再做服务端渲染、weex平台、小程序平台的渲染。

但是呢,注意:使用vnode并不意味着不用操作真实DOM。很多人会误认为vnode的性能一定会比手动操作DOM好,但其实并不是一定的。这是因为:

  • 基于vnode实现的MVVM框架,在每次render to vnode过程中,渲染组件会有一定的javascript耗时,尤其是大组件
  • 当我们去更新组件时,可以感觉到明显的卡顿现象。虽然diff算法在减少DOM操作方面足够优秀,但最终还是免不了操作DOM,所以性能并不能说是绝对优势

创建VNode

我们前面捋了一遍源码,知道vue中是通过createVNode函数创建根组件的vnode的。

  1. const vnode = createVNode(rootComponent,rootProps); 
  2.  
  3. //createVNode函数的大致实现流程 
  4. function createVNode(type,props=null,children=null){ 
  5.  if(props){ 
  6.    //处理props的相关逻辑,标准化class和style 
  7.   } 
  8.   //对于vnode类型信息编码 
  9.   const shapeFlag = isString(type)  
  10.   ? 1/*ELEMENT*/ : isSuspense(type)  
  11.   ? 128 /*SUSPENSE*/ : isTeleport(type) 
  12.   ? 64 /*TELEPORT*/ : isObject(type) 
  13.   ? 4 /*STATEFUL_COMPONENT*/ : isFunction(type) 
  14.   ? 2 /*FUNCTIONAL_COMPONENT*/ : 0 
  15.    
  16.   const vnode = { 
  17.    type, 
  18.     props, 
  19.     shapeFlag, 
  20.     //其他属性 
  21.   } 
  22.    
  23.   //标准化子节点,把不同数据类型的children转成数组或文本类型 
  24.   normalizeChildren(vnode,children) 
  25.   return vnode 

渲染VNode

  1. render(vnode,rootContainer) 
  2. function render(vnode,rootContainer){ 
  3.  //判断是否为空 
  4.   if(vnode == null){ 
  5.     //如果为空,执行销毁组件的逻辑 
  6.    if(container._vnode){ 
  7.      unmount(container._vnode,null,null,true
  8.     } 
  9.   }else
  10.    //创建或更新组件 
  11.     patch(container._vnode||null,vnode,container) 
  12.   } 
  13.   //缓存vnode节点,表示已经渲染 
  14.   container._vnode = vnode 

那么在渲染vnode过程中涉及道到的patch补丁函数是如何实现的:

  1. function patch( 
  2.  n1,//旧的vnode,当n1==null时,表示时一次挂载的过程 
  3.   n2,//新的vnode,后续会根据这个vnode类型执行不同的处理逻辑 
  4.   container,//表示dom容器,在vnode渲染生成DOM后,会挂载到container下面 
  5.   anchor=null
  6.   parentComponent=null
  7.   parentSuspense=null
  8.   isSVG=false
  9.   optimized=false 
  10. ){ 
  11.  //如果存在新旧节点,且新旧节点类型不同,则销毁旧节点 
  12.   if(n1&&!isSameVNodeType(n1,n2)){ 
  13.    anchor = getNextHostNode(n1); 
  14.     unmount(n1,parentComponent,parentSuspense,true); 
  15.     n1 = null
  16.   } 
  17.   const {type,shapeFlag} = n2; 
  18.     switch(type){ 
  19.       case Test: 
  20.         //处理文本节点 
  21.        break 
  22.       case Comment: 
  23.         //处理注释节点 
  24.         break 
  25.       case Static
  26.         //处理静态节点 
  27.         break 
  28.       case Fragment: 
  29.         //处理Fragment元素 
  30.         break 
  31.       default
  32.         if(shapeFlag & 1 /*ELEMENT*/){ 
  33.          //处理普通DOM元素 
  34.           processElemnt( 
  35.             n1, 
  36.             n2, 
  37.             container, 
  38.             anchor, 
  39.             parentComponent, 
  40.             parentSuspense, 
  41.             isSVG, 
  42.             optimized 
  43.           ) 
  44.         }else if(shapeFlag & 64 /*TELEPORT*/){ 
  45.          //处理普通TELEPORT 
  46.           processElemnt( 
  47.             n1, 
  48.             n2, 
  49.             container, 
  50.             anchor, 
  51.             parentComponent, 
  52.             parentSuspense, 
  53.             isSVG, 
  54.             optimized 
  55.           ) 
  56.         }else if(){ 
  57.          
  58.         }else if(){ 
  59.          
  60.         }else if(){ 
  61.          
  62.         } 
  63.     } 

我们看下处理组件的parentComponent函数的实现:

  1. function parentComponent( 
  2.   n1, 
  3.    n2, 
  4.    container, 
  5.    anchor, 
  6.    parentComponent, 
  7.    parentSuspense, 
  8.    isSVG, 
  9.    optimized 
  10. ){ 
  11.  if(n1==null){ 
  12.    //挂载组件 
  13.     mountComponent( 
  14.      n1, 
  15.       n2, 
  16.       container, 
  17.       anchor, 
  18.       parentComponent, 
  19.       parentSuspense, 
  20.       isSVG, 
  21.       optimized 
  22.     ) 
  23.   }else
  24.    //更新组件 
  25.     updateComponent( 
  26.      n1, 
  27.       n2, 
  28.       parentComponent, 
  29.       optimized 
  30.     ) 
  31.   }    

关于组件实例:

  • 创建组件实例:内部通过对象的方式创建了当前渲染的组件实例
  • 设置组件实例:instance保留了很多组件相关的数据,维护了组件的上下文,包括对props、插槽以及其他实例的属性的初始化处理

初始渲染主要做两件事情:

  • 渲染组件生成subTree
  • 把subTree挂载到container中

再回到我们梦开始的地方,我们看到在HelloWorld组件内部,整个DOM节点对应的vnode执行renderComponentRoot渲染生成对应的subTree,我们可以把它成为"子树vnode"。

  1. <template> 
  2.     <div class="test">//test被称为子树vnode 
  3.     <p>hello world</p> 
  4.   </div>   
  5. </template> 

如果是其它平台比如weex等,hostCreateElment方法就不再是操作DOM,而是平台相关的API,这些平台相关的方法是在创建渲染器阶段作为参数传入的。

创建完DOM节点后,要判断如果有props,就给这个DOM节点添加相关的class、style、event等属性,并在hostPatchProp函数内部做相关的处理逻辑。

5嵌套组件

在生产开发中,App和hello组件的例子就是嵌套组件的场景,组件vnode主要维护着组件的定义对象,组件上的各种props,而组件本身是一个抽象节点,它自身的渲染其实是通过执行组件定义的render函数渲染生成的子树vnode完成的,然后再通过patch这种递归方式,无论组件嵌套层级多深,都可以完成整个组件树的渲染。

6参考文章

  • 《Vue3核心源码解析》
  • 《Vue中文社区》
  • 《Vue3中文文档》

7写在最后

本文主要分析总结了组件的渲染流程,从入口开始层层分析组件渲染过程的源码,我们知道了一个组件想要真正渲染成DOM需要以下三个步骤:

 

  • 创建VNode
  • 渲染VNode
  • 生成真实DOM

 

责任编辑:武晓燕 来源: 前端万有引力
相关推荐

2021-12-13 00:54:14

组件Vue3Setup

2021-12-14 21:43:13

Vue3函数computed

2021-02-26 05:19:20

Vue 3.0 VNode虚拟

2022-07-27 08:40:06

父子组件VUE3

2021-09-22 07:57:23

Vue3 插件Vue应用

2023-11-28 09:03:59

Vue.jsJavaScript

2021-11-26 05:59:31

Vue3 插件Vue应用

2022-07-29 11:03:47

VueUni-app

2020-12-01 08:34:31

Vue3组件实践

2024-01-23 09:15:33

Vue3组件拖拽组件内容编辑

2022-01-26 11:00:58

源码层面Vue3

2022-08-08 08:03:44

MySQL数据库CBO

2021-01-18 07:15:22

虚拟DOM真实DOMJavaScript

2021-12-01 08:11:44

Vue3 插件Vue应用

2021-05-18 07:51:37

Suspense组件Vue3

2022-08-26 10:01:48

Vue3TS

2021-05-12 10:25:53

组件验证漏洞

2024-08-13 09:26:07

2023-04-02 10:06:24

组件vue3sign2.

2020-09-17 07:08:04

TypescriptVue3前端
点赞
收藏

51CTO技术栈公众号