不止响应式:Vue3探秘系列— 虚拟vnode的页面挂载之旅(一)

前言

在浩如烟海的网络世界中,关于Vue3源码的文章比星星还多,但让人哭笑不得的是,大部分文章都在讲数据响应式。这种情况让人误以为,Vue3的全部秘密就藏在响应式里面,仿佛一旦你掌握了这门黑魔法,就可以自称Vue3大师了。但让我们面对现实:Vue3不仅仅是响应式那么简单,它的魅力远不止于此。

因此,从今天开始,我决定带领大家探索Vue3源码的其他秘密。我们要打破常规,不再只盯着响应式不放,而是要深入其它同样闪光的部分。今天我们就从页面渲染的奥秘开始。我们都知道,在Vue的template模板中写下的内容最终会被神秘力量编译成所谓的虚拟DOM,这东西本质上就是一个普通的JavaScript对象。关于虚拟DOM的花里胡哨,我们今天不展开,因为那不是我们的重点。

那么,引人入胜的问题来了:这个虚拟DOM是如何从一个看似普通的JavaScript对象变身成为光鲜亮丽的页面内容的呢?换句话说,怎样将枯燥的JavaScript对象变成让人眼前一亮的HTML元素?这不仅仅是一场魔法表演,而是深奥的技术实践。下面,请紧随我的步伐,我们一起深入本篇的学习,探索虚拟vnode是如何成功挂载到页面上的。

  1. 源码中涉及的方法和分支较多,经常是不同的函数之间调用和跳转,许多同学会出现看着看着就脑子一片乱麻的情况。

  2. 所以本篇中出现的源码片段都是 '裁减'过的主要流程代码,其他的分支处理逻辑我们不多关注,这也是看源码的一种技巧~

  3. 除此之外,每个小节在文字描述过后都会及时贴上思维导图方便大家理解流程。

  4. 思维导图的最终版文件感兴趣的同学可以关注私信我获取~

案例代码

我们用main.ts文件中挂载App组件来当案例

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

这段代码展示了一个Vue应用的基本启动流程。它的工作过程可以分为以下几个关键步骤:

  1. 导入必要的模块和组件 :首先,我们从vue包中导入createApp函数,同时从本地文件导入App组件。这为创建Vue应用奠定了基础。

  2. 创建应用实例 :通过调用createApp函数并传入App组件,我们创建了一个Vue应用实例,这个实例被存储在变量app中。这一步是设置Vue组件层次结构的起点。

  3. 挂载应用到DOM :最后,我们调用实例的mount方法,并指定一个DOM元素(通过其CSS选择器"#app"标识)作为挂载点。这一步将Vue应用实例与页面的实际DOM结构关联起来,使得App组件及其子组件能被渲染在指定的容器内。

通过以上步骤,Vue应用的根组件App被成功挂载到页面上。接下来,我们可以进一步探索这两个关键函数(createAppmount)背后的具体实现,深入了解Vue框架如何处理组件初始化和DOM渲染的细节。

一、createApp的工作原理

createApp函数定义在Vue的core/packages/runtime-dom/src/index.ts文件中。

下面是createApp的基本实现:

javascript 复制代码
export const createApp = ((...args) => {
  // 创建app对象
  const app = ensureRenderer().createApp(...args);
 
  const { mount } = app;

  // 重写mount方法
  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    // 挂载逻辑
  };

  return app;
}) as CreateAppFunction<Element>;

createApp 主要做两件事:

  1. 创建 app 实例

  2. 重写 mount 方法

可以看到先执行ensureRenderer方法,然后得到返回值后,再次执行返回值身上的createApp方法

  • 思维导图流程:

1.1 ensureRenderer函数

javascript 复制代码
// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
  );
}

ensureRenderer的职责是获取或创建一个渲染器实例。如果已有渲染器实例则返回,否则会通过createRenderer函数创建新的渲染器:

可以看到,这里先用 ensureRenderer() 来延时创建渲染器,这样做的好处是当用户只依赖响应式包的时候,就不会创建渲染器,因此可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码。

这里涉及了渲染器的概念,它是为跨平台渲染做准备的,之后我会在自定义渲染器的相关内容中详细说明。在这里,你可以简单地把渲染器理解为包含平台渲染核心逻辑的 JavaScript 对象。

  • 思维导图流程:

1.2 createRenderer函数

javascript 复制代码
export function createRenderer<HostNode, HostElement>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options);
}

createRenderer负责创建一个基于给定选项的渲染器。这个渲染器定义了核心的渲染逻辑

  • 思维导图流程:

1.3 baseCreateRenderer 函数

javascript 复制代码
function baseCreateRenderer(options) {
  function render(vnode, container) {
    // 组件渲染的核心逻辑
  }
  return {
    render,
    createApp: createAppAPI(render),
  };
}

baseCreateRenderer函数里面定义了非常非常多的属性,同学们不要被吓到了,目前我们就只关注其中的一个render函数,这个函数是组件渲染的核心,函数随后返回了一个含有render属性和createApp属性的对象,其中createApp属性的值是createAppApi方法

  • 思维导图流程:

同学们看到这里先暂停一下,稍微理清一下思路和步骤:我们先回到最开始的const app = ensureRenderer().createApp(...args);

这行代码,这行代码可以拆分成下面的格式:

ini 复制代码
const renderResult = ensureRenderer();
const app = renderResult.createApp(...args)

ensureRenderer()的返回值renderResult其实就是下面这个对象:

css 复制代码
{
   render,
   createApp: createAppAPI(render),
 };

所以它能够再次调用createApp方法。那么app对象就相当于是createAppAPI函数的返回值

1.4 createAppAPI 函数

javascript 复制代码
// core/packages/runtime-core/src/apiCreateApp.ts
function createAppAPI(render) {
// createApp createApp 方法接受的两个参数:根组件的对象和 prop,这个rootComponent就是app组件
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;
};
}

createAppAPI返回了一个createApp函数给上层,当执行createApp()时,就会返回一个app对象,这个app对象身上挂了一个mount方法

  • 思维导图流程:

我们回顾一下createApp方法:

javascript 复制代码
export const createApp = ((...args) => {
  const app = ensureRenderer().createApp(...args);
  app.mount =function(){...}
  return app
}

根据前面的分析,我们知道 createApp 返回的 app 对象已经拥有了 mount 方法了,但在入口函数中,接下来的逻辑却是对 app.mount 方法的重写。先思考一下,为什么要重写这个方法,而不把相关逻辑放在 app 对象的 mount 方法内部来实现呢?

这是因为 Vue.js 不仅仅是为 Web 平台服务,它的目标是支持跨平台渲染,而 createApp 函数内部的 app.mount 方法是一个标准的可跨平台的组件渲染流程

我们可以再看看重写之前标准的mount方法实现

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

标准的跨平台渲染流程是先创建 vnode,再渲染 vnode。此外参数 rootContainer 也可以是不同类型的值,比如,在 Web 平台它是一个 DOM 对象,而在其他平台(比如 Weex 和小程序)中可以是其他类型的值。所以这里面的代码不应该包含任何特定平台相关的逻辑,也就是说这些代码的执行逻辑都是与平台无关的。因此我们需要在外部重写这个方法,来完善 Web 平台下的渲染逻辑。

接下来,我们再来看 app.mount 重写都做了哪些事情:

二、 app.mount 的具体实现

2.1 重写mount方法

javascript 复制代码
// core/packages/runtime-dom/src/index.ts
app.mount = (containerOrSelector) => {
  const container = normalizeContainer(containerOrSelector);
  const vnode = createVNode(rootComponent, rootProps);
  render(vnode, container);
};

首先是通过 normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器),然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。

在这里,重写的逻辑都是和 Web 平台相关的,所以要放在外部实现。此外,这么做的目的是既能让用户在使用 API 时可以更加灵活,也兼容了 Vue.js 2.x 的写法,比如 app.mount 的第一个参数就同时支持选择器字符串和 DOM 对象两种类型。

  • 思维导图流程:

从 app.mount 开始,才算真正进入组件渲染流程,那么接下来,我们就重点看一下核心渲染流程做的两件事情:创建 vnode 和渲染 vnode

  • 思维导图流程:

2.2 创建VNode

首先,是创建 vnode 的过程。

vnode 本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。

什么是普通元素节点呢?举个例子,在 HTML 中我们使用 <button> 标签来写一个按钮

javascript 复制代码
<button class="btn" style="width:100px;height:50px">
  click me
</button>
javascript 复制代码
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 可以用字符串表示简单的文本 。

什么是组件节点呢?其实, vnode 除了可以像上面那样用于描述一个真实的 DOM,也可以用来描述组件

javascript 复制代码
const CustomComponent = {
  // 在这里定义组件对象
}
const vnode = {
  type: CustomComponent,
  props: {
    msg: 'test'
  }
}

组件 vnode 其实是对抽象事物的描述,这是因为我们并不会在页面上真正渲染一个 <custom-component> 标签,而是渲染组件内部定义的 HTML 标签。

除了上两种 vnode 类型外,还有纯文本 vnode、注释 vnode 等等,但鉴于我们的主线只需要研究组件 vnode 和普通元素 vnode,所以我在这里就不赘述了。

另外,Vue.js 3.0 内部还针对 vnode 的 type,做了更详尽的分类,包括 Suspense、Teleport 等,且把 vnode 的类型信息做了编码,以便在后面的 patch 阶段,可以根据不同的类型执行相应的处理逻辑:

知道什么是 vnode 后,你可能会好奇,那么 vnode 有什么优势呢?为什么一定要设计 vnode 这样的数据结构呢?

首先是抽象,引入 vnode,可以把渲染过程抽象化,从而使得组件的抽象能力也得到提升。

其次是跨平台,因为 patch vnode 的过程不同平台可以有自己的实现,基于 vnode 再做服务端渲染、Weex 平台、小程序平台的渲染都变得容易了很多。

不过这里要特别注意,使用 vnode 并不意味着不用操作 DOM 了,很多同学会误以为 vnode 的性能一定比手动操作原生 DOM 好,这个其实是不一定的。

因为,首先这种基于 vnode 实现的 MVVM 框架,在每次 render to vnode 的过程中,渲染组件会有一定的 JavaScript 耗时,特别是大组件,比如一个 1000 * 10 的 Table 组件,render to vnode 的过程会遍历 1000 * 10 次去创建内部 cell vnode,整个耗时就会变得比较长,加上 patch vnode 的过程也会有一定的耗时,当我们去更新组件的时候,用户会感觉到明显的卡顿。虽然 diff 算法在减少 DOM 操作方面足够优秀,但最终还是免不了操作 DOM,所以说性能并不是 vnode 的优势。

2.2.1 那么,Vue.js 内部是如何创建这些 vnode 的呢?

回顾 app.mount 函数的实现,内部是通过 createVNode 函数创建了根组件的 vnode

const vnode = createVNode(rootComponent, rootProps)

createVNode 函数的大致实现:

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

通过上述代码可以看到,其实 createVNode 做的事情很简单,就是:对 props 做标准化处理、对 vnode 的类型信息编码、创建 vnode 对象,标准化子节点 children

我们现在拥有了这个 vnode 对象,接下来要做的事情就是把它渲染到页面中去。

2.3 渲染VNode

接下来就是执行render(vnode, container);来实现渲染逻辑,先说明一下,有些同学会疑惑这个render方法从哪里来的?render函数是creatApp方法的参数,在baseCreateRenderer方法里面定义的,同学们翻到上面1.3 baseCreateRenderer 函数章节就回想起来了

2.3.1 render方法的实现

javascript 复制代码
  const render = (vnode, container) => {
 if (vnode == null) {
   // 销毁组件
   if (container._vnode) {
     unmount(container._vnode, null, null, true)
   }
 } else {
   // 创建或者更新组件
   patch(container._vnode || null, vnode, container)
 }
 // 缓存 vnode 节点,表示已经渲染
 container._vnode = vnode
} 

这个渲染函数 render 的实现很简单,如果它的第一个参数 vnode 为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑。

  • 思维导图流程:

接下来我们接着看一下上面渲染 vnode 的代码中涉及的 patch 函数的实现:

2.3.2 patch函数

javascript 复制代码
const patch = (n1, n2, 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 Text:
      // 处理文本节点
      break
    case Comment:
      // 处理注释节点
      break
    case Static:
      // 处理静态节点
      break
    case Fragment:
      // 处理 Fragment 元素
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 6 /* COMPONENT */) {
        // 处理组件
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 64 /* TELEPORT */) {
        // 处理 TELEPORT
      }
      else if (shapeFlag & 128 /* SUSPENSE */) {
        // 处理 SUSPENSE
      }
  }
}

patch 本意是打补丁的意思,这个函数有两个功能,一个是根据 vnode 挂载 DOM,一个是根据新旧 vnode 更新 DOM。对于初次渲染,我们这里只分析创建过程,更新过程在后面的章节分析。

在创建的过程中,patch 函数接受多个参数,这里我们目前只重点关注前三个:

第一个参数 n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次挂载的过程;

第二个参数 n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;

第三个参数 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。

  • 思维导图流程:

对于渲染的节点,我们这里重点关注两种类型节点的渲染逻辑:对组件的处理和对普通 DOM 元素的处理。

2.3.3 先来看对组件的处理:processComponent

由于初始化渲染的是 App 组件,它是一个组件 vnode,所以我们来看一下组件的处理逻辑是怎样的。首先是用来处理组件的 processComponent 函数的实现:

javascript 复制代码
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)
  }
}
  • 思维导图流程:

2.3.4 我们接着来看挂载组件的 mountComponent 函数的实现:

javascript 复制代码
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 主要做三件事情:1. 创建组件实例、2.设置组件实例、3. 设置并运行带副作用的渲染函数。

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

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

创建和设置组件实例这两个流程我们这里不展开讲,会在后面的章节详细分析。

  • 思维导图流程:

最后是运行带副作用的渲染函数 setupRenderEffect,我们重点来看一下这个函数的实现:

2.3.5 setupRenderEffect函数

javascript 复制代码
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 的实现我们后面讲响应式章节会具体说)。副作用,这里你可以简单地理解为,当组件的数据发生变化时,effect 函数包裹的内部渲染函数 componentEffect 会重新执行一遍,从而达到重新渲染组件的目的。

渲染函数内部也会判断这是一次初始渲染还是组件更新。这里我们只分析初始渲染流程。

初始渲染主要做两件事情:渲染组件生成 subTree、把 subTree 挂载到 container 中。

首先,是渲染组件生成 subTree,它也是一个 vnode 对象。这里要注意别把 subTree 和 initialVNode 弄混了(其实在 Vue.js 3.0 中,根据命名我们已经能很好地区分它们了,而在 Vue.js 2.x 中它们分别命名为 _vnode 和 $vnode)。我来举个例子说明,在父组件 App 中里引入了 Hello 组件:

javascript 复制代码
<template>
  <div class="app">
    <p>This is an app.</p>
    <hello></hello>
  </div>
</template>
javascript 复制代码
<template>
  <div class="hello">
    <p>Hello, Vue 3.0!</p>
  </div>
</template>

在 App 组件中, <hello> 节点渲染生成的 vnode ,对应的就是 Hello 组件的 initialVNode ,为了好记,你也可以把它称作"组件 vnode"。而 Hello 组件内部整个 DOM 节点对应的 vnode 就是执行 renderComponentRoot 渲染生成对应的 subTree,我们可以把它称作"子树 vnode"。

我们知道每个组件都会有对应的 render 函数,即使你写 template,也会编译成 render 函数,而 renderComponentRoot 函数就是去执行 render 函数创建整个组件树内部的 vnode,把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:子树 vnode。

渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了。

  • 思维导图流程:

2.3.6 重新回到patch函数,这次是处理普通元素

那么我们又再次回到了 patch 函数,会继续对这个子树 vnode 类型进行判断,对于上述例子,App 组件的根节点是 <div> 标签,那么对应的子树vnode 也是一个普通元素 vnode,那么我们接下来看对普通 DOM 元素的处理流程。

首先我们来看一下处理普通 DOM 元素的 processElement 函数的实现:

javascript 复制代码
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);
  }
};

该函数的逻辑很简单,如果 n1 为 null,走挂载元素节点的逻辑,否则走更新元素节点逻辑

  • 思维导图流程:

我们接着来看挂载元素的 mountElement 函数的实现:

2.3.7 mountElement

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

可以看到,挂载元素函数主要做四件事:创建 DOM 元素节点、处理 props、处理 children、挂载 DOM 元素到 container 上。

首先是创建 DOM 元素节点,通过 hostCreateElement 方法创建,这是一个平台相关的方法,我们来看一下它在 Web 环境下的定义:

javascript 复制代码
function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}

创建完 DOM 节点后,接下来要做的是判断如果有 props 的话,给这个 DOM 节点添加相关的 class、style、event 等属性,并做相关的处理,这些逻辑都是在 hostPatchProp 函数内部做的,这里就不展开讲了。

接下来是对子节点的处理,我们知道 DOM 是一棵树,vnode 同样也是一棵树,并且它和 DOM 结构是一一映射的。

如果子节点是纯文本,则执行 hostSetElementText 方法,它在 Web 环境下通过设置 DOM 元素的 textContent 属性设置文本:

javascript 复制代码
function setElementText(el, text) {
  el.textContent = text
}

如果子节点是数组,则执行 mountChildren 方法:

javascript 复制代码
const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
  for (let i = start; i < children.length; i++) {
    // 预处理 child
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i])
      : normalizeVNode(children[i]))
    // 递归 patch 挂载 child
    patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
}

子节点的挂载逻辑同样很简单,遍历 children 获取到每一个 child,然后递归执行 patch 方法挂载每一个 child 。注意,这里有对 child 做预处理的情况(后面编译优化的章节会详细分析)。

可以看到,mountChildren 函数的第二个参数是 container,而我们调用 mountChildren 方法传入的第二个参数是在 mountElement 时创建的 DOM 节点,这就很好地建立了父子关系。

另外,通过递归 patch 这种深度优先遍历树的方式,我们就可以构造完整的 DOM 树,完成组件的渲染。

处理完所有子节点后,最后通过 hostInsert 方法把创建的 DOM 元素节点挂载到 container 上,它在 Web 环境下这样定义:

javascript 复制代码
function insert(child, parent, anchor) {
  if (anchor) {
    parent.insertBefore(child, anchor)
  }
  else {
    parent.appendChild(child)
  }
}

这里会做一个 if 判断,如果有参考元素 anchor,就执行 parent.insertBefore ,否则执行 parent.appendChild 来把 child 添加到 parent 下,完成节点的挂载。

因为 insert 的执行是在处理子节点后,所以挂载的顺序是先子节点,后父节点,最终挂载到最外层的容器上。

细心的你可能会发现,在 mountChildren 的时候递归执行的是 patch 函数,而不是 mountElement 函数,这是因为子节点可能有其他类型的 vnode,比如组件 vnode。
在真实开发场景中,嵌套组件场景是再正常不过的了,前面我们举的 App 和 Hello 组件的例子就是嵌套组件的场景。组件 vnode 主要维护着组件的定义对象,组件上的各种 props,而组件本身是一个抽象节点,它自身的渲染其实是通过执行组件定义的 render 函数渲染生成的子树 vnode 来完成,然后再 patch 。通过这种递归的方式,无论组件的嵌套层级多深,都可以完成整个组件树的渲染。

三、结语

OK,到这里我们这一节的学习也要结束啦,这节课我们主要分析了组件的渲染流程,从入口开始,一层层分析组件渲染。

你可能发现了,文中提到的很多技术点都是省略过,只关注重要逻辑。这些省略的内容我会放在后面的章节去讲,这样做是为了让我们不跑题,重点放在理解组件的渲染流程上

最后,我用一张流程图和完整的思维导图来带你更加直观地感受下整个组件渲染流程:

相关推荐
疯狂的沙粒几秒前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员16 分钟前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐18 分钟前
前端图像处理(一)
前端
程序猿阿伟25 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒27 分钟前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪36 分钟前
AJAX的基本使用
前端·javascript·ajax
力透键背39 分钟前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M1 小时前
node.js第三方Express 框架
前端·javascript·node.js·express
盛夏绽放1 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
想自律的露西西★1 小时前
用el-scrollbar实现滚动条,拖动滚动条可以滚动,但是通过鼠标滑轮却无效
前端·javascript·css·vue.js·elementui·前端框架·html5