详解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使用prototype定义对象类型(转)[
Dec 22 Javascript
js获取单选框或复选框值及操作
Dec 18 Javascript
轻松学习jQuery插件EasyUI EasyUI表单验证
Dec 01 Javascript
探讨AngularJs中ui.route的简单应用
Nov 16 Javascript
谈谈JavaScript数组常用方法总结
Jan 24 Javascript
jQuery实现鼠标经过显示动画边框特效
Mar 24 jQuery
微信小程序实现锚点定位楼层跳跃的实例
May 18 Javascript
Vue axios 中提交表单数据(含上传文件)
Jul 06 Javascript
JavaScript编程设计模式之观察者模式(Observer Pattern)实例详解
Oct 25 Javascript
angularJs中orderBy筛选以及filter过滤数据的方法
Sep 30 Javascript
vue3.0实现点击切换验证码(组件)及校验
Nov 18 Vue.js
详解前端任务构建利器Gulp.js使用指南
Apr 30 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
图片存储与浏览一例(Linux+Apache+PHP+MySQL)
2006/10/09 PHP
ubuntu下配置nginx+php+mysql详解
2015/09/10 PHP
php生成验证码,缩略图及水印图的类分享
2016/04/07 PHP
Yii2中使用asset压缩js,css文件的方法
2016/11/24 PHP
PHP利用pdo_odbc实现连接数据库示例【基于ThinkPHP5.1搭建的项目】
2019/05/13 PHP
php swoft框架实例用法
2020/12/22 PHP
尽可能写&quot;友好&quot;的&quot;Javascript&quot;代码
2007/01/09 Javascript
jQuery 性能优化指南(3)
2009/05/21 Javascript
js 创建快捷方式的代码(fso)
2010/11/19 Javascript
新鲜出炉的js tips提示效果
2011/04/03 Javascript
Javascript中自动切换焦点实现代码
2012/12/15 Javascript
js Calender控件使用详解
2015/01/05 Javascript
jQuery实现可高亮显示的二级CSS菜单效果
2015/09/01 Javascript
用JS实现图片轮播效果代码(一)
2016/06/26 Javascript
js实现自动图片轮播代码
2017/03/22 Javascript
angular.js中解决跨域问题的三种方式
2017/07/12 Javascript
Vue CLI 2.x搭建vue(目录最全分析)
2019/02/27 Javascript
Vue使用vue-recoure + http-proxy-middleware + vuex配合promise实现基本的跨域请求封装
2019/10/21 Javascript
Vue watch响应数据实现方法解析
2020/07/10 Javascript
js前端传json后台接收‘‘被转为quot的问题解决
2020/11/12 Javascript
Vue——前端生成二维码的示例
2020/12/19 Vue.js
[46:49]完美世界DOTA2联赛PWL S3 access vs Rebirth 第二场 12.19
2020/12/24 DOTA
python在windows和linux下获得本机本地ip地址方法小结
2015/03/20 Python
python 中if else 语句的作用及示例代码
2018/03/05 Python
Python 面试中 8 个必考问题
2018/11/16 Python
Django错误:TypeError at / 'bool' object is not callable解决
2019/08/16 Python
简单介绍一下pyinstaller打包以及安全性的实现
2020/06/02 Python
纽约家具、家居装饰和地毯店:ABC Carpet & Home
2017/06/21 全球购物
Expedia韩国官网:亚洲发展最快的在线旅游门户网站
2018/02/26 全球购物
在C中是否有模拟继承等面向对象程序设计特性的好方法
2012/05/22 面试题
工程招投标邀请书
2014/01/26 职场文书
小学教师办公室制度
2014/02/03 职场文书
个人欠款担保书
2014/05/20 职场文书
道歉的话怎么说
2015/05/12 职场文书
《领导干部从政道德启示录》学习心得体会
2016/01/20 职场文书
原生Javascript+HTML5一步步实现拖拽排序
2021/06/12 Javascript