# 设计思路
# 声明式地描述 UI
Vue.js 3 是一个声明式的 UI 框架,用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的。
前端页面涉及到的内容
- DOM 元素:例如是 div 标签还是 a 标签。
- 属性:如 a 标签的 href 属性,再如 id、class 等通用属性。
- 事件:如 click、keydown 等。
- 元素的层级结构:DOM 树的层级结构,既有子节点,又有父节点。 声明式地描述 UI
用户不需要手写任何命令式代码
- 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个div 标签时可以使用
<div></div>
; - 使用与 HTML 标签一致的方式来描述属性,例如
<div id="app"></div>
; - 使用 : 或 v-bind 来描述动态绑定的属性,例如
<div :id="dynamicId"></div>
; - 使用 @ 或 v-on 来描述事件,例如
<div @click="handleClick"></div>
; - 使用模板语法来描述元素的层级结构,例如
<div><span></span></div>
。 除了上面使用模版语法描述元素的层级结构,我们还可以用JavaScript
对象来描述,例如:
const title = {
// 标签名称
tag: 'h1',
// 标签属性
props: {
onClick: handler
},
// 子节点
children: [{
tag: 'span'
}]
}
// 对应的Vuejs 模版就是
// <h1 :click="handler"><span></span></div>
使用 JavaScript
对象描述 UI 更加灵活
假如我们要表示一个标题,根据标题级别的不同,会分别采用 h1~h6 这几个标签,如果用 JavaScript
对象来描述,我们只需要使用一个变量来代表 h 标签即可
// h 标签的级别
let level = 3
const title = {
tag: `h${level}`, // h3 标签
}
当变量 level 值改变,对应的标签名字也会在 h1 和h6 之间变化。但是如果使用模板来描述,就不得不穷举:
<template>
<h1 v-if="level === 1">标题1</h1>
<h2 v-else-if="level === 2">标题2</h2>
<h3 v-else-if="level === 3">标题3</h3>
<h4 v-else-if="level === 4">标题4</h4>
<h5 v-else-if="level === 5">标题5</h5>
<h6 v-else>标题6</h6>
</template>
这远没有 JavaScript
对象灵活。而使用 JavaScript
对象来描述 UI的方式,其实就是所谓的虚拟 DOM。
Vue3
除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。
import {
h
} from 'vue'
export default {
render() {
return h('h1', {
onClick: handler
}) // 虚拟 DOM
}
}
Vue.js
会根据组件的 render
函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。
# 初识渲染器
虚拟 DOM,它其实就是用
JavaScript
对象来描述真实的 DOM 结构。虚拟DOM 是通过渲染器来渲染成真实的 DOM 的。
渲染器的作用就是把虚拟 DOM 渲染为真实 DOM
假设我们有如下虚拟 DOM
const vnode = {
tag: 'div', //用来描述标签名称
// props 是一个对象,用来描述 <div> 标签的属性、事件等内容。可以看到,我们希望给 div 绑定一个点击事件。
props: {
onClick: () => alert('hello')
},
// 用来描述标签的子节点。在上面的代码中,children是一个字符串值,意思是 div 标签有一个文本子节点:
children: 'click me'
}
编写一个 渲染器
, 把上面的虚拟 DOM 渲染为真实的 DOM
/**
* @param {Object} vnode 虚拟 DOM
* @param {Element} container 挂载点
*/
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.tag 作为标签名称来创建 DOM 元素。
- 添加属性:遍历 vnode.props,将属性、事件添加到 DOM 元素。如果 key 以on 字符开头,说明它是一个事件,把字符 on 截取掉后再调用toLowerCase 函数将事件名称小写化,最终得到合法的事件名称,例如 onClick 会变成 click,最后调用addEventListener 绑定事件处理函数。
- 处理 children:如果 children 是一个数组,就递归地调用 renderer 继续渲染,注意,此时我们要把刚刚创建的元素作为 挂载点(父节点);如果 children 是字符串,则使用 createTextNode 函数创建一个文本节点,并将其添加到新创建 的元素内。
# 组件的本质
组件就是一 组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:
// 定义一个组件 返回一个虚拟 DOM
const MyComponent = function() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
//虚拟Dom
const vnode = {
tag: MyComponent, // 组件函数
props: {},
}
为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer
函数。
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
//
function mountElement(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)
}
//这里我们称之为 subtree。既然 subtree 也是虚拟 DOM,
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
# 模板的工作原理
编译器的作用其实就是将模板编译为渲染函数,例如给出如下模板
<template>
<div @click="handler">
click me
</div>
</template>
对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数
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,这就是模板的工作原理,也是 Vue.js 渲染页面的流程
← 权衡的艺术 响应系统的作用与实现 →