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

·  阅读 194
Vue3源码解析计划(一):组件渲染,VNode如何转为真实DOM

写在前面

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

组件

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

<template>
	<div class="test">
    <p>hello world</p>
  </div>  
</template>
复制代码

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

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

未命名文件 (1).png 这里的VNode是什么,其实就是能够描述组件信息的Javascript对象。

应用程序初始化

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

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

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

//vue2 
import Vue from "vue";
import App from "./App";

const app = new Vue({
	render:h=>h(App);
})

app.$mount("#app");

//vue3
import {createApp} from "vue";
import App from "./app";
const app = createApp(App);
app.mount("#app");
复制代码

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

export const createApp = ((...args) => {
  //创建app对象
  const app = ensureRenderer().createApp(...args)

  if (__DEV__) {
    injectNativeTagCheck(app)
  }

  const { mount } = app
	//重写mount方法
  app.mount = (containerOrSelector: Element | string): any => {
    const container = normalizeContainer(containerOrSelector)
    if (!container) return
    const component = app._component
    if (!isFunction(component) && !component.render && !component.template) {
      component.template = container.innerHTML
    }
    // clear content before mounting
    container.innerHTML = ''
    const proxy = mount(container)
    container.removeAttribute('v-cloak')
    return proxy
  }

  return app
}) as CreateAppFunction<Element>
复制代码

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

//渲染相关的一些配置,比如:更新属性的方法,操作DOM的方法
const rendererOptions = {
  patchProp,  // 处理 props 属性 
  ...nodeOps // 处理 DOM 节点操作
}

// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.

let renderer: Renderer | HydrationRenderer

let enabledHydration = false
// 我们看到中文翻译就是:延时创建渲染器,当用户只依赖响应式包的时候,不会立即创建渲染器,
// 可以通过tree-shakable移除核心渲染逻辑相关的代码
function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}
复制代码

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

export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

//
function baseCreateRenderer(
  options: RendererOptions,
  createHydrationFns?: typeof createHydrationFunctions
): any {
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    createComment: hostCreateComment,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    setScopeId: hostSetScopeId = NOOP,
    cloneNode: hostCloneNode,
    insertStaticContent: hostInsertStaticContent
  } = options

  // ....此处省略两千行,我们先不管

  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate)
  }
}
复制代码

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

export function createAppAPI<HostElement>(
  render: RootRenderFunction,
  hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
  //createApp方法接受的两个参数:根组件的对象和prop
  return function createApp(rootComponent, rootProps = null) {
    if (rootProps != null && !isObject(rootProps)) {
      __DEV__ && warn(`root props passed to app.mount() must be an object.`)
      rootProps = null
    }

    // 创建默认APP配置
    const context = createAppContext()
    const installedPlugins = new Set()

    let isMounted = false

    const app: App = {
      _component: rootComponent as Component,
      _props: rootProps,
      _container: null,
      _context: context,

      get config() {
        return context.config
      },

      set config(v) {
        if (__DEV__) {
          warn(
            `app.config cannot be replaced. Modify individual options instead.`
          )
        }
      },

      // 都是一些眼熟的方法
      use() {},
      mixin() {},
      component() {},
      directive() {},
      //用于挂载组件
      mount(rootContainer){
        //创建根组件的VNode
        const vnode = createVNode(rootComponent,rootProps);
        //利用渲染器渲染VNode
        render(vnode,rootContainer);
        app._container = rootComponent;
        return vnode.component.proxy;
      }

      // ...
    }
    
    
    return app
  }
}
复制代码

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

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

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

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

mount(rootContainer){
  //创建根组件的VNode
  const vnode = createVNode(rootComponent,rootProps);
  //利用渲染器渲染VNode
  render(vnode,rootContainer);
  app._container = rootComponent;
  return vnode.component.proxy;
}
复制代码

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

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

复制代码

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

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

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

  • type:标签的类型
  • props:标签的DOM属性信息
  • children:DOM的子节点,vnode数组
const vnode = {
  //标签的类型
	type:"button",
  //标签的DOM属性信息
  props:{
  	"class":"btn",
    style:{
    	width:"100px",
      height:"100px"
    }
  },
  //dom的子节点,vnode数组
  children:"确认"
}
复制代码

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

const HelloWorld = {
	//定义组件对象信息
}

const vnode = {
	type:HelloWorld,
  props:{
  	msg:"test"
  }
}
复制代码

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

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

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

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

创建VNode

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

const vnode = createVNode(rootComponent,rootProps);

//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
}
复制代码

渲染VNode

render(vnode,rootContainer)
function render(vnode,rootContainer){
	//判断是否为空
  if(vnode == null){
    //如果为空,执行销毁组件的逻辑
  	if(container._vnode){
    	unmount(container._vnode,null,null,true)
    }
  }else{
  	//创建或更新组件
    patch(container._vnode||null,vnode,container)
  }
  //缓存vnode节点,表示已经渲染
  container._vnode = vnode
}
复制代码

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

function patch(
	n1,//旧的vnode,当n1==null时,表示时一次挂载的过程
 	n2,//新的vnode,后续会根据这个vnode类型执行不同的处理逻辑
  container,//表示dom容器,在vnode渲染生成DOM后,会挂载到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 Test:
        //处理文本节点
      	break
      case Comment:
        //处理注释节点
        break
      case Static:
        //处理静态节点
        break
      case Fragment:
        //处理Fragment元素
        break
      default:
        if(shapeFlag & 1 /*ELEMENT*/){
        	//处理普通DOM元素
          processElemnt(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
        }else if(shapeFlag & 64 /*TELEPORT*/){
        	//处理普通TELEPORT
          processElemnt(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
        }else if(){
        
        }else if(){
        
        }else if(){
        
        }
    }
}
复制代码

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

function parentComponent(
	 n1,
   n2,
   container,
   anchor,
   parentComponent,
   parentSuspense,
   isSVG,
   optimized
){
	if(n1==null){
  	//挂载组件
    mountComponent(
    	n1,
      n2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      optimized
    )
  }else{
  	//更新组件
    updateComponent(
    	n1,
      n2,
      parentComponent,
      optimized
    )
  }   
}
复制代码

关于组件实例:

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

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

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

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

<template>
	<div class="test">//test被称为子树vnode
    <p>hello world</p>
  </div>  
</template>
复制代码

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

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

嵌套组件

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

参考文章

写在最后

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

  • 创建VNode
  • 渲染VNode
  • 生成真实DOM
分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改