Vue3源码学习-3 | 设计思路
Vue3源码学习-3 | 设计思路
在第一节中,我们阐述了框架设计是权衡的艺术;在第二节中,详细讨论了框架设计的几个核心要素。而框架设计讲究全局视角的把控,存在着一条核心思路
,并围绕核心展开。在这一节中:
- 从全局视觉了解Vue3的
设计思路
、工作机制
及其重要的组成部分
- 当我们把组成部分看作是
独立
的功能模块,看看他们之间是如何相互配合
的
3.1 虚拟 DOM
引出虚拟DOM
UI
可以简单的理解成页面- 想了解 UI 请去往第一节
从第一节中我们知道Vue3
是一个声明式的 UI 框架,也就是使用声明式的代码来编写页面。
我们先来了解编写前端页面涉及到的内容:
- DOM元素 例如 div 标签。
- 属性 如id、class等通用属性。
- 事件 如click、keydown等。
- 元素的层级结构 DOM树的层级结构,既有子节点,又有父节点
现在有一个问题:
如果让你设计一个声明式的UI框架,你会怎么设计?让一步来说,如何用声明式的代码来描述上述的内容?
这里我就不卖关子了,直接给出 Vue3 中相应的解决方案:
- 使用与
HTML
标签一致的方式来描述 DOM 元素,例如使用<div></div>
来描述一个div
标签。 - 使用与
HTML
标签一致的方式来描述属性,例如<div id='app'></div>
- 使用
:
或v-bind
来描述动态绑定的属性,例如<div :id='Tshe'></div>
- 使用
@
或v-on
来描述事件,例如点击事件<div @click="e"></div>
- 使用与
HTML
标签一致的方式来描述层级结构,例如一个具有span
子节点的div
标签<div><span></span></div>
可以看出在Vue3中,用户不需要手写任何命令式代码,这就是所谓的声明式
地描述 UI。
看到这里 你可能会说:小标题不是虚拟DOM吗?你在这里讲啥声明式?接着看!
除了上面这种声明式地描述UI之外,还可以使用js对象
来描述,上代码!
const title = {
// 标签名称
tag:'h1',
// 标签属性
props:{
onClick:handler
},
// 子节点
children:[
{tag:'span'}
]
}
对应的Vue3模板就是:
<h1 @click="handler"><span></span></div>
看到这里,又有一个问题在你脑海中浮现:
使用Vue3模板和用js对象描述UI有何不同呢?
这里先不给予答案,你先看下面的代码:举个例子,分别用Vue3模板和js对象来分别描述h1~h6
- 用
js对象
描述:
// h 标签的级别
let level = 3
const title = {
tag:`h${level}`,// h3 标签
}
可以看出,只需要一个变量
来代表h标签即可,只要控制level
的值,对应的标签也会在h1和h6之间变化。
- 使用
Vue3
模板来描述:
// 不得不穷举
<h1 v-if="level === 1"><h1>
<h1 v-else-if="level === 2"><h1>
<h1 v-else-if="level === 3"><h1>
<h1 v-else-if="level === 4"><h1>
<h1 v-else-if="level === 5"><h1>
<h1 v-else-if="level === 6"><h1>
是不是略显笨重。看好我接下来要说的话!
而!使用js对象
来描述UI的方式,其实就是所谓的 虚拟DOM。现在大家是不是觉得 虚拟DOM 并没有想象中的那么神秘。
虚拟DOM 的运用
正是因为虚拟DOM的这种灵活性,Vue3除了支持使用模板描述UI外,还支持使用虚拟DOM描述UI。
在Vue组件中手写渲染函数
就是使用 虚拟DOM
来描述 UI,代码如下:
import { h } from 'vue'
export default {
render(){
return h('h1',{ onClick:handler }) // 虚拟DOM
}
}
看到上面的代码,你是不是觉得我在骗你:“这里是h函数调用哇!哪里来的js对象”。嗷!其实h函数的返回值就是一个对象
,这是Vue3内部
编写的,提高用户的效率!
将上面h函数调用的代码改成js对象
,那么内容就是:
export default {
render() {
return {
tag:'h1',
props:{ onClick:handler }
}
}
}
所以h函数就是一个辅助创建虚拟DOM的工具函数。
好!咱接着写流水账!这个时候!是不是就会想到如何渲染!也就是说什么是组件的渲染函数
,就是上面的render
函数。
有请收看下面的内容!
3.2 初始渲染器
正片
渲染器的作用:
就是把虚拟DOM渲染为真实DOM。
想必!大家现在已经了解什么是虚拟DOM了吧,它其实就是用 js对象
来描述真实
的DOM节点。
那么:
虚拟DOM
是如何变成真实DOM
并渲染到浏览器页面中的呢?
渲染器! 闪亮登场! 来先给个图
!
这里初步认识渲染器
,以便更加地理解Vue.js
的工作原理。
下面看看,渲染器
是如何工作的吧!
有如下虚拟DOM:
const vnode = {
tag:'div',
props:{
onClick:() => alert('hello')
},
children:'click me'
}
简单解释一下上面的这段代码:
tag
用来描述标签名称- `props`` 对象,用来描述标签的属性、事件等内容
children
用来描述标签的子节点
说明:上面的vnode
本身只是一个js对象
,并没有特殊含义。
编写一个渲染器
,把上面的这段虚拟DOM渲染为真实DOM:
function renderer(vnode,container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for(const key in vnode.props) {
if(/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(),// 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if(typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if(Array.isArray(vnode.children)) {
// 递归地调用,renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child,el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
上面 renderer
函数接收两
个参数:
vnode
虚拟DOM对象container
一个真实DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下
接下来,调用 renderer
函数即可:
renderer(vnode,document.body) // body 作为挂载点
分析渲染器renderer
的实现思路,分三步:
创建元素
为元素添加属性和事件
- `处理children
看到这里,是不是觉得 渲染器 并没有我们想象中的那么神秘。
其实不然,别忘了我们这里现在所做的还仅仅只是创建节点,渲染器的精髓都在于更新节点阶段(敲黑板)。但无论如何,渲染器的工作原理其实很简单,归根到底,都是使用一些我们熟悉的DOM操作API来完成渲染工作
3.3 组件的本质
来到这里,我们已经初步了解了虚拟DOM
和渲染器
。在有的时候,我们或多或少有听到一个词:渲染组件
那么问题来了:
- 组件又是什么呢?
- 组件和虚拟DOM有什么关系?
- 渲染器如何渲染组件?
大家请记住一句话:组件就是一组DOM元素的封装
这组DOM元素就是组件要渲染的内容,因此可以定义一个函数
来代表组件,而函数的返回值
就代表组件要渲染的内容:
const MyComponent = function() {
return {
tag:'div',
props: {
onClick: () => alert('hello')
},
children:'click me'
}
}
可以看到,组件的返回值
也是虚拟DOM,它代表组件要渲染的内容。搞清楚了组件的本质,我们就可以定义用虚拟DOM来描述组件了。很简单,可以让虚拟DOM对象
中的tag
属性来存储组件函数:
const vnode = {
tag:MyComponent
}
就像tag:'div'
用来描述 div 标签一样,tag:MyComponent
用来描述组件,只不过此时的tag属性不是标签名称,而是组件函数
。为了能够渲染组件,需要渲染器的支持。修改前面提到的renderer
函数,如下所示:
function renderer(vnode,container) {
if(typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode,container)
}else if (typeof vnode.tag === 'function') {
//说明 vnode 描述的是组件
mountComponent(vnode,container)
}
}
可以看到,上面的代码中出现了mountElement
和mountComponent
:
- 其中
mountComponent
的代码与上文中的 renderer 函数一致:
function mountComponent(vnode,container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for(const key in vnode.props) {
if(/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(),// 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if(typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if(Array.isArray(vnode.children)) {
// 递归地调用,renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child,el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
- 再来看
mountComponent
函数是如何实现的:
function mountComponent(vnode,container) {
// 调用组件函数,获取组件要渲染的内容(虚拟DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree,container)
}
上面的代码中。首先调用vnode.tag
函数,它其实就是组件函数,其返回值是虚拟DOM,即组件要渲染的内容,这里我们称之为subtree
。即subtree
就是虚拟DOM,好!直接 调用 renderer 函数完成渲染即可
番外:如果 组件
变心,成了 “对象”
这里希望大家能够举一反三,例如组件一定得是函数吗?当然不是,它完全可以雨露均沾
,变成一个js对象
来表达组件:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag:'div',
props: {
onClick: () => alert('hello')
},
children:'click me'
}
}
}
为了完成对象组件的渲染,我们需要修改renderer渲染器
以及mountComponent函数
。
- 修改渲染器的
判断条件
:
function renderer(vnode,container) {
if(typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode,container)
}else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
//说明 vnode 描述的是组件
mountComponent(vnode,container)
}
}
- 修改
mountComponent函数
:
function mountComponent(vnode,container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree,container)
}
在上述代码中,vnode.tag
是表达组件的对象,调用该对象的 renderer 函数得到组件要渲染的内容,也就是 虚拟DOM。
其实Vue.js中的有状态组件就是使用对象结构来表达的
3.4 模板的工作原理
无论是手写 虚拟DOM(渲染函数)还是使用 模板,都属于声明式地描述 UI,并且Vue.js同时支持这两种UI的方式
模板
使用过 Vue 的小伙伴都敲过"模板"
吧:
<div @click="handler">
click me
</div>
上文我们说了虚拟DOM是如何渲染成真实DOM的,那么现在让我们想想
模板
是如何工作的?
回答:这就要提到 Vue 框架中另外一个重要的组成部分:编译器
。
编译器
对于编译器
来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数
:
// 上面的模板代码转换而成的
render() {
return h('div',{onClick:handler},'click me')
}
实例: .vue
文件为例
代码:
<template>
<div @click="handler">
click me
</div>
</template>
<script setup>
const handler = () => {...}
</script>
其中<template>
标签里的内容就是模板内容,编译器
会把模板内容编译成渲染函数并添加到<script>
标签快的组件对象上,所以最终在浏览器里运行的代码就是:
export default {
const handler = () => {...}
render(){
return h('div',{onClick:handler},'click me')
}
}
所以,无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数
产生的。然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM,这就是模板的工作原理,也是Vue.js渲染页面的流程
特别说明:这里只说明了编辑器
的作用及角色即可。
3.5 Vue.js是各个模块组成的有机整体
如上面所说的,组件
的实现依赖于渲染器
,模板的编译依赖于编译器
,并且编译后产生的代码是根据渲染器
和虚拟DOM
的设计决定的,因此Vue.js的各个模块之间是相互关联、相互制约的,共同构成了一个有机整体。
因此,我们在学习Vue原理的时候,应该把各个模块结合到一起去看,才能明白到底是怎么回事
以渲染器
和编译器
为例
- 如下模板
<div id="foo" :class="cls"></div>
- 编译器会把上面模板编译成渲染函数
render(){
// 为了效果更加直观,这里没有使用 h 函数,而直接采用虚拟DOM对象
// 下面的代码等价于:
// return h('div',{id:"foo",class:cls})
return {
tag:'div',
props:{
id:'id',
class:'cls'
}
}
}
可以发现,在这段代码中。cls
是一个变量,它可能会发生变化。我们知道渲染器
的作用之一就是寻找并且只更新变化的内容,所以当变量cls
发生变化时,渲染器会自行寻找变更点
。对于渲染器来说,这个寻找
的过程需要花费一些力气。
那么!从编译器
的角度来看,它能否知道哪些内容会发生变化呢?如果编译器有能力分析动态内容,并在编译阶段把这些信息提取出来,然后再交给渲染器,这样渲染器不就不需要花费大力气去寻找变更点了吗?这个好想法并且能够实现。
Vue.js的模板是有特点的,拿上面的模板来说,我们一眼就能看到其中id='foo'
是永远不会变化的,而:class=""cls
是一个v-bind
绑定,它是可能变化的。所以编译器能够识别出哪些是静态属性
,哪些是动态组件
,在生成代码的时候完全可以附带这些信息:
render() {
return {
tag:'div',
props: {
id:'foo',
class:'cls'
},
patchFlags:1 // 假设数字1代表class是动态的
}
}
如上面的代码所示,在生成的虚拟DOM对象中多出了一个patchFlags
属性,我们假设数字1
代表‘calss是动态的’
,这样渲染器看到这个标志时就知道:“哦豁!只要class属性会发生变化。”对于渲染器来说,就相当于省去了寻找变更点的工作量,性能自然就提升了。
我们知道编译器和渲染器之间存在信息交流,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟DOM对象
总结
- 先说出 虚拟DOM
- 然后通过虚拟DOM,引出 渲染器
- 通过Vue模板拿出 编译器
- 最后说明 渲染器和 编译器是如何配合的,体现出Vue中各个模块相互作用的优点和重要性!!!
转载自:https://juejin.cn/post/7154346302032052254