Vue3的设计思路
声明式地描述UI
在使用Vue3时开发页面是声明式地描述UI的。描述UI主要涉及以下内容:
- DOM元素:例如使用的标签(div、span、a、img...)
- 属性:如a标签的href属性,或id、class等通用属性
- 事件:如click、keydown等
- 元素的层级结构:DOM树的层级结构,既有子节点,又有父节点
- 使用v-bind描述动态绑定的属性:如
<div :id="dynamicId"></div> - 使用v-on来描述事件:如
<div @click="handlerClick""></div>
具体到事件,都有与之对应的描述方式,不需要写任何命令式的代码,这就是声明式的描述UI。
除了上面这种声明式的描述UI外,还可以用JavaScript对象来描述。
const title = {
tag: 'h1',
props: {
onClick: handleClick
},
children: [{ tag: 'span' }]
}
对应到Vue模板,就是<h1 @click="handleClick""><span><span><h1>。
这两种描述UI的方式有什么不同呢?答案就是使用JavaScript对象描述UI更加灵活。
而使用JavaScript描述UI就是所谓的虚拟DOM。正式这种灵活性,Vue3除了使用模板描述UI外还支持使用
虚拟DOM来描述UI。
import { h } from 'vue'
export default {
render () {
return h('h1', { onClick: handler })
}
}
上面这个h函数的返回值就是一个虚拟DOM对象,它的作用是让我们编写虚拟DOM更加轻松。如果把上面的h函数调用改成JavaScript 对象,就需要写更多内容:
export default {
render () {
return {
tag: 'h1',
props: {
onClick: handleClick
}
}
}
}
如果还有子节点,那么需要编写的内容旧更多了。所以h函数就是一个辅助创建虚拟DOM的工具函数。
渲染器
现在已经了解什么是虚拟DOM了,那么虚拟DOM是如何变成真实DOM并渲染到浏览器页面中呢?答案渲染器。
这是编写的渲染器,将上面的虚拟DOM渲染为真实DOM:
function renderer (vNode, container) {
// 使用vNode.tag作为标签名称创建DOM元素
const el = document.createElement(vNode.tag)
// 通过vNode.props将属性、事件添加到DOM元素上
for (const key in vNode.props) {
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名 onClick => click
vNode.props[key] // 事件处理函数
)
}
if (typeof vNode.children === 'string') {
el.appendChild(document.createTextNode(vNode.children))
} else if (Array.isArray(vNode.children)) {
vNode.children.forEach(child => renderer(child, el))
}
container.appendChild(el)
}
渲染器函数的参数:
- vNode:虚拟DOM对象
- container:真实的DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下
渲染器的执行过程主要分为三个步骤:
- 创建元素
- 为元素添加属性和事件
- 处理children
组件的本质
到这里有一个问题,虚拟DOM处理能够描述真实DOM外,该能够描述组件。但是组件并不是真实的DOM元素,
那么是如何使用虚拟DOM来描述呢?
要明白这个问题首先需要明白组件是什么,组件就是一组DOM元素的封装,这组DOM就是组件要渲染的内容,
所以可以定义一个函数来代表组件,函数的返回值就代表组件需要渲染的内容。
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: handleCLick
},
children: 'click me'
}
}
可以看到上面的函数返回的是虚拟DOM,它代表组件想要渲染的内容。搞清楚组件的本质就可以定义用虚拟DOM来描述组件了。
const vNode = {
tag: MyComponent
}
使用tag: MyComponent来描述组件,只不过此时的tag属性并不是标签名称,而是组件函数。为了渲染组件,当然需要渲染器
的支持。修改renderer函数:
// 将刚才的 renderer 改为 mountElement 函数 - 挂载DOM元素
function mountElement (vNode, container) {
// 使用vNode.tag作为标签名称创建DOM元素
const el = document.createElement(vNode.tag)
// 通过vNode.props将属性、事件添加到DOM元素上
for (const key in vNode.props) {
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名 onClick => click
vNode.props[key] // 事件处理函数
)
}
if (typeof vNode.children === 'string') {
el.appendChild(document.createTextNode(vNode.children))
} else if (Array.isArray(vNode.children)) {
vNode.children.forEach(child => mountElement(child, el))
}
container.appendChild(el)
}
// 挂载组件
function mountComponent (vNode, container) {
const subTree = vNode.tag()
renderer(subTree, container)
}
// 重新定义渲染器函数
function renderer (vNode, container) {
if (typeof vNode.tag === 'string') {
mountElement(vNode, container)
} else if (typeof vNode.tag === 'function') {
mountComponent(vNode, container)
}
}
可以看到,先调用vNode.tag函数,返回值是虚拟DOM,即组件需要渲染的内容(这里称为subTree)。subTree也是 虚拟DOM,那么直接调用renderer函数完成渲染即可。
const MyComponent = {
render () {
return {
tag: 'div',
props: { onClick: handleClick },
children: 'click me'
}
}
}
现在使用一个对象来描述组件,该组件有一个函数名字叫做render,它返回代表组件要渲染的内容(虚拟DOM)。
需要修改渲染器函数和挂载组件的函数:
function renderer (vNode, container) {
if (typeof vNode.tag === 'string') {
mountElement(vNode, container)
} else if (typeof vNode.tag === 'object') { // 如果是对象,说明描述的是组件
mountComponent(vNode, container)
}
}
function mountComponent (vNode, container) {
const subTree = vNode.tag.render()
renderer(subTree, container)
}
在mountComponent中,vNode.tag代表要渲染的组件,调用它的render函数得到组件要渲染的内容(虚拟DOM)。
模板的工作原理
无论手写虚拟DOM还是使用模板,都属于声明式的描述UI,并且Vue同时支持这两种描述UI的方式。那么模板是如何工作的呢? 这就需要提到Vue框架的另一个重要组成部分:编译器。
编译器它的工作内容就是将模板编译为渲染函数.
<div @click="handler">
click me
</div>
对于编译器来说,模板就是一个普通的字符串,他会分析字符串并生成一个功能与之相同的渲染函数:
function render () {
return h('div', { onClick: handler }, 'click me')
}
以vue为例,一个.vue文件就是一个组件
<template>
<div @click="handler">
click me
</div>
</template>
<script >
export default {
data () {},
methods: {
handler () {}
}
}
</script>
template标签中的内容就是模板内容,编译器会把模板内容编译为渲染函数并添加到 script 标签块的组件对象上,所以 最终运行的代码就是:
export default {
data () {},
methods: {
handler () {}
},
render () {
return h('div', { onClick: handler }, 'click me')
}
}
因此,无论使用模板还是渲染函数,对于组件来说,最终渲染的内容都是通过渲染函数产生的,然后渲染器把渲染函数返回的 虚拟DOM渲染为真实DOM,这就是模板的工作原理。
各个模块组成的有机整体
以渲染器和编译器这两个关键的模块为例:
假设有模板<div id="foo" :class="cls"></div>
编译器会把这段代码编译成渲染函数:
render () {
// 为了效果更加直观,没有使用h函数,而是直接采用了虚拟DOM对象
// return h('div', { id: 'foo', class: cls })
return {
tag: 'div',
props: {
id: 'foo',
class: 'cls'
}
}
}
前面说到渲染器的作用之一就是寻找并且只更新变化的内容,所以当cls的值发生变化时,渲染器会自行寻找变更点。但是
寻找的过程也需要用一些时间。
如果编译器分析动态内容,并在编译阶段把信息提取出来并直接交给渲染器,就不需要去需按照变更点了。
拿上面的模板来说,可以看出id时永远不会变化的,而class是用v-bind绑定的,是可能发生变化的。所以编译器能识别出
静态属性和动态属性,生成代码的时候可以附带这些信息:
function render () {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
patchFlags: 1 // 假设 1 代表class是动态的
}
}
生成的虚拟DOM对象多出了一个patchFlags属性,渲染器解析到这个标志 1 的时候就能知道,class属性会发生改变, 就相当于省去了寻找变更点的工作量,性能就会有所提升了。