详解Vue.js3.0 组件是如何渲染为DOM的


Posted in Javascript onNovember 10, 2020

本文主要是讲述 Vue.js 3.0 中一个组件是如何转变为页面中真实 DOM 节点的。对于任何一个基于 Vue.js 的应用来说,一切的故事都要从应用初始化「根组件(通常会命名为 APP)挂载到 HTML 页面 DOM 节点(根组件容器)上」说起。所以,我们可以从应用的根组件为切入点。

主线思路:聚焦于一个组件是如何转变为 DOM 的。

辅助思路:

  • 涉及到源代码的地方,需要明确标记源码所在文件,同时将 TS 简化为 JS 以便于直观理解
  • 思路每前进一步要能够得出结论
  • 尽量总结归纳出流程图

应用初始化

在 Vue.js 3.0 中,初始化一个应用的方式和 Vue.js 2.x 有差别但是差别不大(本质上都是把 App 组件挂载到 id 为 app 的 DOM 节点上),在 Vue.js 3.0 中用法如下:

import { createApp } from 'vue'
import App from './app'

const app = createApp(App)

app.mount('#app')
 

createApp 简化版源码

// packages/runtime-dom/src/index.ts
// 创建应用
const createApp = ((...args) => {
 // 1. 创建 app 对象
 const app = ensureRenderer().createApp(...args)
 
 const { mount } = app
 // 2. 重写 mount 方法
 app.mount = (containerOrSelector) => {
  // ...
 }
 
 return app
})

createApp 方法中主要做了两件事:

  • 创建 app 对象
  • 重写 app.mount 方法

接下来会分别看一下这两个过程都做了什么事情。

创建 app 对象

从 ensureRenderer() 着手。在 Vue.js 3.0 中有一个「渲染器」的概念,我们先对渲染器有一个初步的印象:**渲染器可以用于跨平台渲染,是一个包含了平台渲染核心逻辑的 JavaScript 对象。**接下来,我们通过简化版源码来验证这个结论:

// packages/runtime-dom/src/index.ts
// 定义渲染器变量
let renderer

// 创建一个渲染器对象
// 惰性创建渲染器(当用户只依赖响应式包的时候可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码)
function ensureRenderer() {
 return renderer || (renderer = createRenderer(rendererOptions))
}

// packages/runtime-core/src/renderer.ts
export function createRenderer(options) {
 return baseCreateRenderer(options)
}

// 创建不同平台渲染器的函数,在其内部都会调用 baseCreateRenderer
function baseCreateRenderer(options, createHydrationFns) {
 // 一系列内部函数
 const render = (vnode, container) => {
  // 组件渲染的核心逻辑
 }
 
 // 返回渲染器对象
 return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
 }
}

可以看出渲染器最终由 baseCreateRenderer 函数生成,是一个包含 render 和createApp 函数的 JS 对象。其中 createApp 函数是由 createAppAPI 函数返回的。那 createApp 接收的参数有哪些呢?为了寻求答案,我们需要看一下 createAppAPI  做了什么事情。

// packages/runtime-core/src/apiCreateApp.ts
// 接收一个渲染器 render 作为参数,接收一个可选参数 hydrate,返回一个用于创建 app 的函数
export function createAppAPI(render, hydrate) {
 // createApp 接收两个参数:根组件对象和根组件的prop
 return function createApp(rootComponent, rootProps = null) {
  const context = createAppContext()
  const app: App = (context.app = {
   _uid: uid++,
   _component: rootComponent,
   _props: rootProps,
   _container: null,
   _context: context,
   version,
   get config() {},
   set config(v) {},
   use(plugin: Plugin, ...options: any[]) {},
   mixin(mixin: ComponentOptions) {},
   component(name: string, component?: Component): any {},
   directive(name: string, directive?: Directive) {},
   mount(rootContainer: HostElement, isHydrate?: boolean): any {
    // 创建根组件的 vnode
    const vnode = createVNode(rootComponent, rootProps)
    // 利用函数参数传入的渲染器渲染 vnode
    render(vnode, rootContainer)
    app._container = rootContainer
    return vnode.component.proxy
   },
   unmount() {},
   provide(key, value) {}
  }
 return app
 }
}

渲染器对象的 createApp 方法接收两个参数:根组件对象和根组件的prop。这和应用初始化 demo 中 createApp(App) 的使用方式是吻合的。还可以看到的是:createApp 返回的 app 对象在最初定义时包含了 _uid 、 use 、 mixin 、 component 、mount 等属性。

此时,我们可以得出结论:在应用层调用的 createApp 方法内部,首先会生成一个渲染器,然后调用渲染器的 createApp 方法创建 app 对象。app 对象中具有一系列我们在日常开发应用时已经很熟悉的属性。

在应用层调用的 createApp 方法内部创建好 app 对象后,接下来便是对 app.mount 方法重写。

重写 app.mount 方法

先看一下简化版的 app.mount  源码:

// packages/runtime-dom/src/index.ts
const { mount } = app
app.mount = (containerOrSelector): any => {
 // 1. 标准化容器(将传入的 DOM 对象或者节点选择器统一为 DOM 对象)
 const container = normalizeContainer(containerOrSelector)
 if (!container) return
 
 const component = app._component
 // 2. 标准化组件(如果根组件不是函数,并且没有 render 函数和 template 模板,则把根组件 innerHTML 作为 template)
 if (!isFunction(component) && !component.render && !component.template) {
  component.template = container.innerHTML
 }

 // 3. 挂载前清空容器的内容
 container.innerHTML = ''
 
 // 4. 执行渲染器创建 app 对象时定义的 mount 方法(在后文中称之为「标准 mount 函数」)来渲染根组件
 const proxy = mount(container)
 
 return proxy
}

浏览器平台 app.mount 方法重写主要做了 4 件事情:

  1. 标准化容器
  2. 标准化组件
  3. 挂载前清空容器的内容
  4. 执行标准 mount 函数渲染组件

此时可能会有人思考一个问题:为什么要重写app.mount 呢?答案是因为 Vue.js 需要支持跨平台渲染。
支持跨平台渲染的思路:不同的平台具有不同的渲染器,不同的渲染器中会调用标准的 baseCreateRenderer 来保证核心(标准)的渲染流程是一致的。

以浏览器端和服务端渲染的代码实现为例:

createApp 流程图

在分别了解了 创建 app 对象和重写 app.mount 过程后,我们来以整体的视角看一下 createApp 函数的实现:

目前为止,只是对应用的初始化有了一个初步的印象,但是还没有涉及到具体的组件渲染过程。可以看到根组件的渲染是在标准 mount 函数中进行的。所以接下来需要去深入了解标准 mount 函数。

标准 mount 函数

简化版源码

// packages/runtime-core/src/apiCreateApp.ts
// createAppAPI 函数内部返回的 createApp 函数中定义了 app 对象,mount 函数是 app 对象的方法之一
mount(rootContainer, isHydrate) {
 // 1. 创建根组件的 vnode
 const vnode = createVNode(rootComponent, rootProps)
 // 2. 利用函数参数传入的渲染器渲染 vnode
 render(vnode, rootContainer)
 
 app._container = rootContainer
 
 return vnode.component.proxy
},

createVNode 方法做了两件事:

  1. 基于根组件「创建 vnode」
  2. 在根组件容器中「渲染 vnode」

vnode 大致可以理解为 Virtual DOM(虚拟 DOM)概念的一个具体实现,是用普通的 JS 对象来描述 DOM 对象。因为不是真实的 DOM 对象,所以叫做 Virtual DOM。

我们来一起看一下创建 vnode 和渲染 vnode 的具体过程。

创建 vnode:createVNode(rootComponent, rootProps)

简化版源码(已经把分支逻辑拿掉)

// packages/runtime-core/src/vnode.ts
function _createVNode(type, props, children, 
            patchFlag, dynamicProps, isBlockNode = false) {
 // 1. 对 VNodeTypes 或 ClassComponent 类型的 type 进行各种标准化处理:规范化 vnode、规范化 component、规范化 CSS 类和样式
 
 // 2. 将 vnode 类型信息编码为位图
 const shapeFlag = isString(type)
  ? ShapeFlags.ELEMENT
  : __FEATURE_SUSPENSE__ && isSuspense(type)
   ? ShapeFlags.SUSPENSE
   : isTeleport(type)
    ? ShapeFlags.TELEPORT
    : isObject(type)
     ? ShapeFlags.STATEFUL_COMPONENT
     : isFunction(type)
      ? ShapeFlags.FUNCTIONAL_COMPONENT
      : 0

 // 3. 创建 vnode 对象
 const vnode = {
  __v_isVNode: true,
  [ReactiveFlags.SKIP]: true,
  type, // 把函数入参 type 赋值给 vnode 
  props,
  children: null,
  component: null,
  staticCount: 0,
  shapeFlag, // 把 vnode 类型信息赋值给 vnode
  // 还有很多属性
 }

 // 4. 标准化子节点 children
 normalizeChildren(vnode, children)

 return vnode
}

createVNode 做了 4 件事

  1. 对 VNodeTypes 或 ClassComponent 类型的 type 进行各种标准化处理
  2. 将 vnode 类型信息编码为位图
  3. 创建 vnode 对象
  4. 标准化子节点 children

细心的同学会发现:在标准 mount 函数中执行 createVNode(rootComponent, rootProps) 时,参数是根组件 rootComponent 和根组件属性 rootProps,但是在 _createVNode 在定义时函数签名的前两个参数确实 type 和 props。rootComponent 与 type 的关系是什么呢?函数名为什么差了一个 _ 呢?

首先函数名的差异,是由于在定义函数时,基于代码运行环境做了一个判断:

export const createVNode = (__DEV__
 ? createVNodeWithArgsTransform
 : _createVNode) as typeof _createVNode

其次,rootComponent 与 type 的关系我们可以从 type 的类型定义中得到答案:

function _createVNode(
 type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
 props: (Data & VNodeProps) | null = null
): VNode { }

当 createVNode把这 4 件事情做好后,会返回已经创建好 vnode,接下来做的事情是渲染 vnode。

渲染 vnode:render(vnode, rootContainer)

即使不看具体源码实现,我们其实大致可以用一句话总结出渲染 vnode 过程做了什么事情:把 vnode 转化为真实 DOM。

前文我们提过,**渲染器是一个包含了平台渲染核心逻辑的 JavaScript 对象。**渲染 vnode 正是通过调用渲染器的 render 方法做的。

// 返回渲染器对象
return {
 render,
 hydrate,
 createApp: createAppAPI(render, hydrate)
}

我们来看一下 render 函数的定义(简化版源码):**

// packages/runtime-core/src/renderer.ts
const render = (vnode, container) => {
 if (vnode == null) {
  // 如果 vnode 为 null,但是容器中有 vnode,则销毁组件
  if (container._vnode) {
   unmount(container._vnode, null, null, true)
  }
 } else {
  // 创建或更新组件
  patch(container._vnode || null, vnode, container)
 }

 // packages/runtime-core/src/scheduler.ts
 flushPostFlushCbs()
 
 // 缓存 vnode 节点(标识该 vnode 已经完成渲染)
 container._vnode = vnode
}

抽象来看, render 做的事情是:如果传入的 vnode 为空,则销毁组件,否则就创建或者更新组件。其中有两个关键函数:patch 和 unmount(patch、unmount 和 render 都是在baseCreateRenderer函数内部的方法)。

可以从 patch 着手,看一下是如何将 vnode 转化为 DOM 的。

patch

// packages/runtime-core/src/renderer.ts
const patch = (
 n1,
 n2,
 container,
 anchor = null,
 parentComponent = null,
 parentSuspense = null,
 isSVG = false,
 optimized = false
) => {
 // 1. 如果是更新 vnode 并且新旧 vnode 类型不一致,则销毁旧的 vnode
 if (n1 && !isSameVNodeType(n1, n2)) {
  anchor = getNextHostNode(n1)
  unmount(n1, parentComponent, parentSuspense, true)
  n1 = null
 }

 // 2. 处理不同类型节点的渲染
 const { type, ref, shapeFlag } = n2
 switch (type) {
  case Text:
   // 处理文本节点
   processText(n1, n2, container, anchor)
   break
  case Comment:
   // 处理注释节点
   break
  case Static:
   // 处理静态节点
   break
  case Fragment:
   // 处理 Fragment 元素(https://v3.vuejs.org/guide/migration/fragments.html#fragments)
   break
  default:
   if (shapeFlag & ShapeFlags.ELEMENT) {
    // 处理普通 DOM 元素
   } else if (shapeFlag & ShapeFlags.COMPONENT) {
    // 处理组件
   } else if (shapeFlag & ShapeFlags.TELEPORT) {
    // 处理 TELEPORT
   } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
    // 处理 SUSPENSE
   } else if (__DEV__) {
    warn('Invalid VNode type:', type, `(${typeof type})`)
   }
 }
}

patch 函数做了 2 件事情:

  1.  如果是更新 vnode 并且新旧 vnode 类型不一致,则销毁旧的 vnode
  2. 处理不同类型节点的渲染

在 patch 函数的多个参数中,我们优先关注前 3 个参数:

  1. n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次新建(挂载)的过程
  2. n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑
  3. container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面

以新建文本 DOM 节点为例,此时 n1 为 null,n2 类型为 Text,所以会走分支逻辑:processText(n1, n2, container, anchor)。processText 内部会去调用 hostCreateText 和 hostSetText。

hostCreateText 和 hostSetText 是从 baseCreateRenderer 函数入参 options 中解析出来的方法:

// packages/runtime-core/src/renderer.ts
const {
 insert: hostInsert,
 remove: hostRemove,
 patchProp: hostPatchProp,
 forcePatchProp: hostForcePatchProp,
 createElement: hostCreateElement,
 createText: hostCreateText,
 createComment: hostCreateComment,
 setText: hostSetText,
 setElementText: hostSetElementText,
 parentNode: hostParentNode,
 nextSibling: hostNextSibling,
 setScopeId: hostSetScopeId = NOOP,
 cloneNode: hostCloneNode,
 insertStaticContent: hostInsertStaticContent
} = options

来看看 options 是怎么来的:

// packages/runtime-core/src/renderer.ts
// 在调用 baseCreateRenderer 时,传入了渲染参数
function baseCreateRenderer(options: RendererOptions) { }

还记得前文提到的我们在哪里调用了 baseCreateRenderer 吗?

// packages/runtime-dom/src/index.ts
// 创建应用
const createApp = ((...args) => {
 // 1. 创建 app 对象
 const app = ensureRenderer().createApp(...args)
 
 return app
})

// packages/runtime-dom/src/index.ts
const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)

function ensureRenderer() {
 return renderer || (renderer = createRenderer<Node, Element>(rendererOptions))
}

// packages/runtime-core/src/renderer.ts
export function createRenderer<
 HostNode = RendererNode,
 HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
 return baseCreateRenderer<HostNode, HostElement>(options)
}

可以看到在创建渲染器时,我们调用了 baseCreateRenderer 并传入了 rendererOptions。rendererOptions 的值为extend({ patchProp, forcePatchProp }, nodeOps)。

我们如果知道了 nodeOps  中的 createText、setText 等方法做了什么事情,就清楚了某一个确定类型的 vnode 是如何转变为 DOM 的。先看一下 nodeOps 的定义:

// packages/runtime-dom/src/nodeOps.ts
export const nodeOps = {
 createText: text => doc.createTextNode(text),
 setText: (node, text) => {},
 // 其他方法
}

此时已经非常接近问题的答案了,关键是看一下 doc 变量是什么:

const doc = (typeof document !== 'undefined' ? document : null) as Document

详解Vue.js3.0 组件是如何渲染为DOM的

至此,我们知道了答案:先把组件转化为 vnode,针对特定类型的 vnode 执行不同的渲染逻辑,最终调用 document 上的方法将 vnode 渲染成 DOM。**抽象一下,从组件到渲染生成 DOM 需要经历 3 个过程:创建 vnode - 渲染 vnode - 生成 DOM。

在渲染 vnode 部分,我们以一个简单的 Text 类型的 vnode 为例来找到了答案。其实在 baseCreateRenderer 中有 30+ 个函数来处理不同类型的 vnode 的渲染。 比如:用来处理组件类型的 processComponent 函数、用来处理普通 DOM 元素类型的processElement 函数等。由于 vnode 是一个树形数据结构,在处理过程中还应用到了递归思想。建议感兴趣的同学自行查看。

总结

最后,我们来做个总结:

  • 在 Vue.js 中, vnode 是对抽象事物的描述。
  • 从组件到渲染生成 DOM 需要经历 3 个过程:创建 vnode - 渲染 vnode - 生成 DOM。
  • 组件是如何转变为 DOM 的:先把组件转化为 vnode,针对特定类型的 vnode 执行不同的渲染逻辑,最终调用 document 上的方法将 vnode 渲染成 DOM。
  • 渲染器是一个包含了平台渲染核心逻辑的 JavaScript 对象,可以用于跨平台渲染。
  • 渲染器对象中的 createApp 方法,创建了一个具有 mount 方法的 app 实例。app.mount 方法中先是用根组件创建了 vnode,然后调用渲染器对象中的 render 方法去渲染 vnode,最终通过 DOM API 将 vnode 转化为 DOM。

附录

Vue.js 中使用了哪些 DOM 的方法:

  • createElement
  • createElementNS
  • createTextNode
  • createComment
  • querySelector
  • insertBefore
  • insert
  • removeChild
  • setAttribute
  • cloneNode

 到此这篇关于详解Vue.js3.0 组件是如何渲染为DOM的 的文章就介绍到这了,更多相关Vue.js3.0 组件渲染为DOM 内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Javascript 相关文章推荐
JavaScript 图像动画的小demo
May 23 Javascript
JavaScript中几个重要的属性(this、constructor、prototype)介绍
May 19 Javascript
jquery实现的一个导航滚动效果具体代码
May 27 Javascript
深入理解javascript作用域和闭包
Sep 23 Javascript
js+html5获取用户地理位置信息并在Google地图上显示的方法
Jun 05 Javascript
基于jquery实现日历签到功能
Sep 11 Javascript
使用mint-ui开发项目的一些心得(分享)
Sep 07 Javascript
对vue中v-on绑定自定事件的实例讲解
Sep 06 Javascript
微信小程序环境下将文件上传到OSS的方法步骤
May 31 Javascript
flexible.js实现移动端rem适配方案
Apr 07 Javascript
微信小程序中使用 async/await的方法实例分析
May 06 Javascript
Bootstrap FileInput实现图片上传功能
Jan 28 Javascript
在vs code 中如何创建一个自己的 Vue 模板代码
Nov 10 #Javascript
JavaScript中常用的3种弹出提示框(alert、confirm、prompt)
Nov 10 #Javascript
原生JS实现弹幕效果的简单操作指南
Nov 10 #Javascript
vue解决跨域问题(推荐)
Nov 10 #Javascript
关于vue 项目中浏览器跨域的配置问题
Nov 10 #Javascript
如何在vue 中引入使用jquery
Nov 10 #jQuery
Vue + ts实现轮播插件的示例
Nov 10 #Javascript
You might like
PHP 删除文件与文件夹操作 unlink()与rmdir()这两个函数的使用
2011/07/17 PHP
PHP文件操作实现代码分享
2011/09/01 PHP
ThinkPHP CURD方法之order方法详解
2014/06/18 PHP
PHP+redis实现添加处理投票的方法
2015/11/14 PHP
PHP pear安装配置教程
2016/05/14 PHP
php字符串比较函数用法小结(strcmp,strcasecmp,strnatcmp及strnatcasecmp)
2016/07/18 PHP
文本框中禁止非数字字符输入比如手机号码、邮编
2013/08/19 Javascript
js判断选择时间不能小于当前时间的示例代码
2013/09/24 Javascript
在线一元二次方程计算器实例(方程计算器在线计算)
2013/12/22 Javascript
对Jquery中的ajax再封装,简化操作示例
2014/02/12 Javascript
jquery插件jquery.dragscale.js实现拖拽改变元素大小的方法(附demo源码下载)
2016/02/25 Javascript
搞定immutable.js详细说明
2016/05/02 Javascript
jQuery+CSS3文字跑马灯特效的简单实现
2016/06/25 Javascript
js实现table添加行tr、删除行tr、清空行tr的简单实例
2016/10/15 Javascript
js实现登录验证码
2016/12/22 Javascript
浅析BootStrap中Modal(模态框)使用心得
2016/12/24 Javascript
js数组与字符串常用方法总结
2017/01/13 Javascript
JS+html5制作简单音乐播放器
2020/09/13 Javascript
JS基于正则表达式的替换操作(replace)用法示例
2017/04/28 Javascript
浅谈AngularJS中$http服务的简单用法
2018/05/15 Javascript
vue 标签属性数据绑定和拼接的实现方法
2018/05/17 Javascript
详解基于Vue2.0实现的移动端弹窗(Alert, Confirm, Toast)组件
2018/08/02 Javascript
js实现选项卡效果
2020/03/07 Javascript
详解Vue3 Teleport 的实践及原理
2020/12/02 Vue.js
python通过pip更新所有已安装的包实现方法
2017/05/19 Python
详解python实现线程安全的单例模式
2018/03/05 Python
浅谈Django的缓存机制
2018/08/23 Python
Python装饰器使用你可能不知道的几种姿势
2019/10/25 Python
python爬虫模拟浏览器的两种方法实例分析
2019/12/09 Python
如何利用pygame实现简单的五子棋游戏
2019/12/29 Python
Python函数递归调用实现原理实例解析
2020/08/11 Python
美国工业用品采购网站:Zoro.com
2020/10/27 全球购物
2014年公司庆元旦活动方案
2014/03/05 职场文书
花木兰观后感
2015/06/10 职场文书
Python基础详解之描述符
2021/04/28 Python
Python实现8种常用抽样方法
2021/06/27 Python