Vnode到真实DOM是如何转变的?

x33g5p2x  于2022-03-17 转载在 其他  
字(9.5k)|赞(0)|评价(0)|浏览(143)

一、写在前面
在vue开发中,组件是非常重要的概念,但是我们在编写组件的时候,是否知道其内部是如何进行运转的。本文将总结一下vue3.0中组件是如何进行渲染的?
二、内容
在我们编写组件代码时,会经常编写如下所示模板代码。

<template>
	<div>
		<p>hello world</p>
	</div>
</template>

从上述表现上看,组件的模板决定了组件生成的DOM标签,而在vuejs内部,一个组件如果想要生成真正的DOM,需要经过如下几个步骤。

如上图所示,需要经过创建vnode,渲染vnode,以及生成DOM的三个过程。接下来我们将从程序入口开始,一步一步看真实DOM是如何生成的。
1、应用程序初始化

// 在 Vue.js 3.0 中,初始化一个应用的方式如下
import { createApp } from "vue";
import App from "./app";
const app = createApp(App);
app.mount("#app");

如上图所示,我们可以看到入口函数是createApp

const createApp = (...args) => {
  // 创建 app 对象
  const app = ensureRenderer().createApp(...args);
  const { mount } = app;
  // 重写 mount 方法
  app.mount = (containerOrSelector) => {
    // ...
  };
  return app;
};

上述就是createApp主要做的事,一个是创建app对象,另一个是重写app.mount方法。
2、创建app对象
首先我们首先执行代码:

const app = ensureRenderer().createApp(...args);

其中ensureRenderer()来创建一个渲染器对象,它内部代码为:

// 渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法
const rendererOptions = {
  patchProp,
  ...nodeOps,
};
let renderer;
// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions));
}
function createRenderer(options) {
  return baseCreateRenderer(options);
}
function baseCreateRenderer(options) {
  function render(vnode, container) {
    // 组件渲染的核心逻辑
  }

  return {
    render,
    createApp: createAppAPI(render),
  };
}
function createAppAPI(render) {
  // createApp createApp 方法接受的两个参数:根组件的对象和 prop
  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;
  };
}

首先ensureRenderer()来延时创建渲染器。
好处:

当用户值依赖响应式包的时候,就不会创建渲染器。
可以通过tree-shaking的方式来移除核心渲染逻辑相关的代码。
我都其理解是:因为我们在调用createApp才会执行`ensureRenderer()`方法,如果我们只使用响应式的包的时候,并不使用渲染器,此时我们就可以在打包的时候,使用tree-shaking来将没有使用到的函数取消。

其次通过createRenderer创建一个渲染器,这个渲染器内部存在一个createApp方法,接收了rootComponentrootProps两个参数。
我们在应用层面执行createAp(App)方法时 ,会把App组件对象作为跟组件传递给rootComponet,这样createApp内部就会创建一个App对象。他会提供mount方法,这个方法是用来挂载组件的。
值得注意的是:app在创建对象时,vue利用闭包和函数的柯里化的技巧,很好的实现了参数保留。
3、重写app.mount方法
createApp返回的app兑现已经拥有了mount方法,那为什么还要重写?

1、为了支持跨平台。
createApp函数内部的app.mount方法是一个标准的可跨平台的组件渲染流程。代码如下所示。
mount(rootContainer) {
	//创建跟组件的vnode
	const vnode = createVNode(rootComponent, rootProps)
	//利用渲染器渲染vnode
	render(vnode, rootContainer)
	app._container = rootContainer
	return vnode.component.proxy
}

主要流程为:先创建vnode,再渲染vnode
参数rootContainer根据平台不同而不同。
这里的代码不应该包含任何特定平台的相关逻辑,所以我们需要在外部重写。
4、app.mount重写做了哪些事情

app.mount = (containerOrSelector) => {
  // 标准化容器
  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
  }
  // 挂载前清空容器内容
  container.innerHTML = ''
  // 真正的挂载
  return mount(container)
}

首先是通过normalizeContainer表转化容器(这里可以传入字符串选择器或者DOM对象,但是如果是字符串渲染器,会将其转化为DOM对象,作为最终挂载的容器。)
然后做一个if判断,如果组件对象没有定义render函数或者没有定义render函数或者template模板,则取容器的innterHTML作为组件模板的内容。
在挂载前将容器中的内容清空。
最终再进行挂载。
优势:

1、跨平台的实现
2、兼容vue2.0写法
3、app.mount既可以传dom,也可以传字符串选择器。

三、核心渲染流程:创建Vnode和渲染vnode
1、创建vnode

1、vnode的本质是用来描述DOM的javascript对象。

如果我们想要描述一个button标签可以通过下面代码所示进行描述。

// vnode 这样表示<button>标签
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可以用字符串表示简答的文本。
2、vnode除了可以用来描述真实的ODM外,也可以用来描述组件

<CustomComponent></CustomComponent>

const vnode ={
	type: CustomCompoent,
	props: {
		msg: 'test'
	}
}

3、其他的,还有纯文本vnode,注释vnode
4、vue3.x中,vnode的type,做了更详尽的分类,包括suspense, teleport等,且把vnode的类型信息做了编码,一遍在后面的patch阶段,可以根据不同的类型执行相关的处理。
Vnode的优势

1、抽象
2、跨平台
3、但是和手动修改DOM对比,并不一定存在优势。

如何创建Vnode
app.mount函数的实现,内部是通过createVnode函数来创建跟组件的Vnode

const vnode = createVNode(
            rootComponent as ConcreteComponent,
            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
}

上述做了的事情是:1、对Props做标准化处理2、对vnode的类型信息编码创建vnode对象,标准化子节点children

渲染vnode

// 渲染vnode
  const render: RootRenderFunction = (vnode, container, isSVG) => {
    // 如果vnode为空
    if (vnode == null) {
      // 但是缓存vnode节点存在
      if (container._vnode) {
        // 销毁vnode
        unmount(container._vnode, null, null, true)
      }
    } else {
      // 否则进行挂载或者更新
      patch(container._vnode || null, vnode, container, null, null, null, isSVG)
    }
    flushPostFlushCbs()
    // 将vnode缓存下来
    container._vnode = vnode
  }

如果vnode为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑。
patch函数实现

const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = false
  ) => {
    // 如果存在新旧节点,并且节点类型不相同,则销毁旧节点
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }
    const { type, ref, shapeFlag } = n2
    switch (type) {
      case Text://处理文本节点类型
        processText(n1, n2, container, anchor)
        break
      case Comment:  //处理注释类型
        processCommentNode(n1, n2, container, anchor)
        break
      case Static:  //处理静态文本类型
        if (n1 == null) {
          mountStaticNode(n2, container, anchor, isSVG)
        } else if (__DEV__) {
          patchStaticNode(n1, n2, container, isSVG)
        }
        break
      case Fragment: //处理Fragment类型
        processFragment(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(  //处理DOM类型
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent( //处理组件类型
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          ;(type as typeof TeleportImpl).process( //处理teleport类型
            n1 as TeleportVNode,
            n2 as TeleportVNode,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized,
            internals
          )
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          ;(type as typeof SuspenseImpl).process(  //处理suspense类型
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized,
            internals
          )
        } else if (__DEV__) {
          warn('Invalid VNode type:', type, `(${typeof type})`)
        }
    }

    // set ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2)
    }
  }

这个函数有两个功能:
一个是根据vnode挂载DOM
一个是根据旧节点更新DOM。
patch函数入参。
第一个参数n1表示旧的vnode,当n1为null的时候,表示是一次挂载的过程。
第二个参数n2表示新的vnode节点,后续会根据这个vnode进行相关的处理。
第三个参数container表示DOM容器,也就是vnode渲染生成DOM后,会挂载到container下面。
对组件进行处理
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)
  }
}

如果n1null,则执行挂载组件的逻辑。
如果n1不为null,则执行更新组件的逻辑。
mountCompoent挂载组件的实现

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)
}

mountComponent主要做了三件事。
第一件:创建组件实例

Vue.js 3.0虽然不像Vue.js 2.x那样通过类的方式去实例化组件,但内部也通过对象的方式去创建了当前渲染的组件实例

第二件:设置组件实例

instance保留了很多组件相关的数据,维护了组件的上下文,包括对props、插槽,以及其他实例的属性的初始化处理

第三件:设置并运行带副作用的渲染函数(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)
}

该函数利用响应式库的effect函数创建一个副作用渲染函数componentEffect,我们可以把它理解为组件的数据发生改变后,effect包裹的内部componentEffect函数会重新执行一遍,从而达到重新渲染组件的目的。
对DOM元素进行处理

const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  isSVG = isSVG || n2.type === 'svg'
  if (n1 == null) {
    //挂载元素节点
    mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
  else {
    //更新元素节点
    patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
  }
}

如果n1null, 走挂载元素节点的逻辑
否则走更新节点的逻辑。
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)
}

主要做了四件事:
1、创建DOM元素节点
通过hostCreateElement方法创建,这是一个平台相关的方法,在web端的实现。

// 调用了底层的 DOM API document.createElement 创建元素
function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}

2、处理props
给这个DOM节点添加相关的class,style,event等属性,并做相关的处理。
3、处理children
子节点是纯文本,则执行hostSetElementText方法,它在 Web环境下通过设置DOM元素的textContent属性设置文本。
4、 挂载DOM元素到container上

function insert(child, parent, anchor) {
  if (anchor) {
    parent.insertBefore(child, anchor)
  }
  else {
    parent.appendChild(child)
  }
}

相关文章

微信公众号

最新文章

更多