3.1 声明式地描述 UI
Vue.js3是一个声明式的UI框架,意思说用户在使用 Vue.js3开发页面时是声明式地描述UI的。 我们需要了解编写前端页面都设计哪些内容,具体如下。
- DOM元素:例如是 div 标签还是 a 标签
- 属性:如 a 标签的 href 属性,再如 id、class等通用属性
- 事件:如 click、keydown等
- 元素的层级结构:DOM树的层级结构,既有字节点,又有父节点
如何声明式的描述上述内容呢?这是框架设计者需要思考的问题。其实方案有很多。拿 Vue.j 来说:
- 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个 div 标签时可以使用
<div></div> - 使用与 HTML 标签一致的方式来描述属性,例如
<div id="app"></div> - 使用
: 或者 v-bind 来描述动态绑定的属性,例如 <div id="dynamicId"></div> - 使用
@ 或者 v-on 来描述事件,例如点击事件<div @click="hanlder"></div> - 使用与 HTML 标签一致的方式来描述层级结构,例如
<div><span></span></div>
可以看到在 vue.js 中,哪怕是事件都有与之对应的描述方式。用户不需要手写任何命令式代码,这就是所谓的声明式地描述UI
除了上面这种使用模板来声明式地描述UI之外,还可以使用JavaScript对象来描述,代码如下:
const title = {
tag: 'h1'
props: {
children: handler
},
children: [
{ tag: 'span' }
]
}
对应到 Vue.js 模板就是
<h1 @click="handler"><span></span></h1>
那么使用模板和使用JavaScript对象描述UI有何不同呢?答案是:使用JavaScript对象描述UI更加灵活。举个例子,我们要表示一个标题,根据标题级别的不同,分别采用 h1~h6 这几个标签,如果用 JavaScript 对象来描述,我们只需要使用一个变量来代表 h 标签即可
let level = 3
const title = {
tag: `h{level}`
}
可以看到当 level 值改变,对应的标签名字也会在 h1 和 h6之间变化,但是如果使用模板来描述,就不得不穷举:
<h1 v-if="level === 1"></h1>
<h2 v-else-if="level === 2"></h2>
<h3 v-else-if="level === 3"></h3>
<h4 v-else-if="level === 4"></h4>
<h5 v-else-if="level === 5"></h5>
<h6 v-else-if="level === 6"></h6>
这远没有 JavaScript 对象灵活。而使用 JavaScript对象来描述UI的方式,其实就是所谓的虚拟DOM。其实在Vue.js组件中手写的渲染函数就是使用虚拟DOM来描述UI的,如一下代码:
import { h } from 'vue'
export default {
render() {
return h('h1', { onClick: handler })
}
}
这里的 h 函数的返回值就是一个对象,其作用是让我们编写虚拟DOM变得更加轻松。如果把上面 h 函数调用的代码改成JavaScript对象,就需要写更多内容:
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}
如果还有子节点,那么需要编写更多的内容,所以 h 函数就是一个辅助创建虚拟DOM的工具函数。一个组件要渲染的内容是通过渲染函数来描述的,也就是上述代码中的 render 函数,Vue.js 会根据组件的 render 函数的返回值拿到虚拟DOM,也后就可以把组件中的内容渲染出来了。
3.2 初识渲染器
虚拟DOM是如何变成真实的 DOM 并渲染到浏览器页面中的呢?这就用到了我们接下来要介绍的:渲染器 渲染器的作用就是把虚拟DOM渲染为真实DOM 假设我们有如下虚拟DOM:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello world')
},
children: 'click me'
}
首先简单解释下上面这段代码
- tag:用来描述标签名称,所以
tag:'div' 描述的就是一个 <div> 标签 - props:是一个对象,用来描述
<div> 标签的属性、事件等内容。可以看到这里希望给<div> 一个点击事件 - children:用来描述标签的子节点。在上面代码中,children是一个字符串值,意思是
<div> 标签有一个文本子节点:<div>click me</div>
接下来我们 需要编写一个渲染器,把上面这段虚拟DOM渲染为真实DOM:
function renderer(vnode, container) {
const el = document.createElement(vnode.tag)
for(const key in vnode.props) {
if(/^on/.test(key)) {
el.addEventListener(
key.substr(2).toLowerCase(),
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)
}
这里的 renderer 函数接收如下两个参数
- vnode:虚拟DOM对象
- container:一个真实DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下。
接下来,我们就可以调用 renderer 函数
renderer(vnode, document.body)
在浏览器中运行这段代码,会渲染出'click me' 文本,点击文本会弹出 alert('hello world') 现在我们回过头来分析渲染器 renderer 的实现思路,总体来熟分为三步。
- 创建元素:把
vnode.tag 作为标签名称来创建 DOM 元素 - 为元素添加属性和事件:遍历
vnode.props 对象,如果 key 以 on 字符开头,说明它是一个事件,把字符 on 截掉后再调用 toLowerCase 函数将事件名称小写化,最终得到合法的事件名称,例如 onClick 会变成click ,最后调用addEventListener 绑定事件处理函数。 - 处理
children :如果children 是一个数组,就递归调用 renderer 继续渲染,注意,此时要把刚刚创建的元素作为挂载点(父节点):如果children 是字符串,则使用createTextNode 函数创建一个文本节点,并将其添加到新创建的元素内
是不是感觉渲染器并没有想象的那么神秘?其实不然,我们所做的还仅仅是创建节点,渲染器的精髓都在更新节点。假设我们对 vnode 做一些小小的修改:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children" 'click again'
}
对于渲染器来说,需要精确的找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要走一遍完整的创建元素的流程。
3.3 组件的本质
虚拟DOM除了能够描述真实 DOM 之外还能描述组件,但是 组件并不是真实的 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
}
为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数。
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
mountComponent(vnode, container)
}
}
如果 vnode.tag 的类型是字符串,说明它描述的是普通标签元素,此时调用 mountElement 函数完成渲染;如果 vnode.tag 的类型是函数,则说明它描述的是组件,此时调用mountComponent 函数完成渲染。其中mountElement 函数与上下文中的 renderer函数内容一致。
function mountElement(vnode, container) {
const el = document.createElement(vnode.tag)
for(const key in vnode.props) {
if(/^on/.test(key)) {
el.addEventListener(
key.substr(2).toLowerCase(),
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)
}
再来看看 mountComponent 函数是如何实现的
function mountComponent(vnode, container) {
const subtree = vnode.tag()
renderer(subtree, container)
}
组件一定是函数吗?当然不是,我们完全可以使用一个JavaScript对象来表达组件,例如:
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
这里我们使用一个对象来代表组件,该对象有一个函数,叫做 render,其返回值代表组件渲染的内容。为了完成组件的渲染,我们需要修改 renderer 渲染器以及 mountComponent 函数 首先,修改渲染器的判断条件:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') {
mountComponent(vnode, container)
}
}
现在我们使用对象而不是函数来表达组件,因此要将 typeof vnode.tag === 'function' 修改为 typeof vnode.tag === 'object' ,接着修改mountComponent 函数:
function mountComponent(vnode, container) {
const subtree = vnode.tag.render()
renderer(subtree, container)
}
在上述代码中,vnode.tag 是表达组件的对象,调用该对象的 render 函数得到组件要渲染的内容,也就是虚拟DOM
3.4 模板的工作原理
3.5 Vue.js 是各个模块组成的有机体
|