详解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异步编程:异步数据收集的具体方法
Aug 19 Javascript
node.js中的http.createClient方法使用说明
Dec 15 Javascript
Javascript中Array用法实例分析
Jun 13 Javascript
JavaScript验证Email(3种方法)
Sep 21 Javascript
jQuery实现鼠标滑过链接控制图片的滑动展开与隐藏效果
Oct 28 Javascript
AngularJS实现表单手动验证和表单自动验证
Dec 09 Javascript
基于jQuery实现点击弹出层实例代码
Jan 01 Javascript
Bootstrap每天必学之响应式导航、轮播图
Apr 25 Javascript
详解AngularJS 模块化
Jun 14 Javascript
基于jquery日历价格、库存等设置插件
Jul 05 jQuery
js实现文字列表无缝滚动效果
Jun 23 Javascript
Vue实现一种简单的无限循环滚动动画的示例
Jan 10 Vue.js
在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
蝙蝠侠:侠影之谜
2020/03/04 欧美动漫
PHPThumb PHP 图片缩略图库
2012/03/11 PHP
ThinkPHP基于PHPExcel导入Excel文件的方法
2014/10/15 PHP
PHP入门教程之图像处理技巧分析
2016/09/11 PHP
PHP jQuery+Ajax结合写批量删除功能
2017/05/19 PHP
PHP实现阿里大鱼短信验证的实例代码
2017/07/10 PHP
PHP 记录访客的浏览信息方法
2018/01/29 PHP
php解决安全问题的方法实例
2019/09/19 PHP
页面定时刷新(1秒刷新一次)
2013/11/22 Javascript
js获取url中&quot;?&quot;后面的字串方法
2014/05/15 Javascript
PHP+jquery+ajax实现分页
2016/12/09 Javascript
react-router JS 控制路由跳转实例
2017/06/15 Javascript
使用JS实现图片轮播的实例(前后首尾相接)
2017/09/21 Javascript
前端Vue项目详解--初始化及导航栏
2019/06/24 Javascript
vue实现一拉到底的滑动验证
2019/07/25 Javascript
深入浅出vue图片路径的实现
2019/09/04 Javascript
[56:17]NB vs Infamous 2019国际邀请赛淘汰赛 败者组 BO3 第三场 8.22
2019/09/05 DOTA
Python中处理unchecked未捕获异常实例
2015/01/17 Python
python通过pil模块获得图片exif信息的方法
2015/03/16 Python
Matplotlib 生成不同大小的subplots实例
2018/05/25 Python
在Python中给Nan值更改为0的方法
2018/10/30 Python
python pcm音频添加头转成Wav格式文件的方法
2019/01/09 Python
pyqt5之将textBrowser的内容写入txt文档的方法
2019/06/21 Python
ubuntu上安装python的实例方法
2019/09/30 Python
python实现简单贪吃蛇游戏
2020/09/29 Python
加拿大女装网上购物:Reitmans
2016/10/20 全球购物
Cotton On美国网站:澳洲时装连锁品牌
2016/10/25 全球购物
Hunkemöller西班牙:欧洲最大的内衣连锁店
2018/08/15 全球购物
Snapfish爱尔兰:在线照片打印和个性化照片礼品
2018/09/17 全球购物
房地产销售计划书
2014/01/10 职场文书
情人节活动策划方案
2014/02/27 职场文书
艺校音乐专业自我鉴定范文
2014/03/01 职场文书
2014年幼儿园后勤工作总结
2014/11/10 职场文书
个人创业事迹材料
2014/12/30 职场文书
2016年党员公开承诺书范文
2016/03/24 职场文书
Axios取消重复请求的方法实例详解
2021/06/15 Javascript