1. 声明式地描述UI
Vue.js 3是一个声明式UI框架,编写前端页面涉及到以下内容:
bash
1. DOM元素:例如是div还是a标签。
2. 属性:乳a标签的href属性,或id,class等通用属性。
3. 事件:如click,keydown等。
4. 元素的层级结构:DOM树的层级结构,既有子节点又有父节点。
怎样声明式的描述上述内容?Vue.js 3的解决方案是:
bash
1. 使用与HTML标签一致的方式描述DOM元素,如描述一个div标签时使用<div><div>;
2. 使用与HTML标签一致的方式描述属性,如<div id="app"><div>;
3. 使用:或v-bind描述动态绑定的属性,如<div :id="dynamicId"><div>;
4. 使用@或v-on描述事件,如<div @click="handler"><div>;
5. 使用与HTML标签一致的方式描述层级结构,如一个具有span子节点的div标签<div><span><span><div>;
可以看到每个内容都有与之对应的描述方式。用户不需要手写任何命令式代码。这就是声明式地描述UI。
除了使用模板来声明式地描述UI外还可以javaScript对象来描述UI:
bash
const title = {
//标签名称
tag: 'h1',
// 标签属性
props: {
onclick: handler
},
//子节点
children: [
{tag: 'span'}
]
}
对应到Vue.js 模板
bash
<h1 @click="handler"><span> </span></h1>
使用javaScript对象描述UI比使用模板描述更灵活。
bash
let level = 3
const title = {
tag: 'h${level }'
}
可以看到当level 值改变时,对应的标签名字也会在h1和h6之间变化。如果用
模板描述,则需要穷举:
bash
<h1 v-if="level==1"> </h1>
<h2 v-if="level==2"> </h2>
<h3 v-if="level==3"> </h3>
<h4 v-if="level==4"> </h4>
<h5 v-if="level==5"> </h5>
<h6 v-if="level==6"> </h6>
使用javaScript对象描述UIde 方式就是虚拟DOM。在Vue.js组件中手写的渲染函数就是使用虚拟DOM来描述UI的。
bash
import {h} from 'vue'
export default {
render(){
retutn h('h1', {onClick: handler}) //虚拟DOM
}
}
h函数是一个辅助创建虚拟DOM的工具函数,Vue.js会根据组件的render函数的返回值拿到虚拟DOM,然后把组件内容渲染出来。虚拟DOM 就是用javaScript对象来描述真实的DOM 结构,虚拟DOM本身就是一个javaScript对象没有特殊含义,你完全可以自己设计虚拟DOM结构如使用tagName代替tag。
2. 初识渲染器
虚拟DOM怎样变为真实DOM并渲染到浏览器页面中呢?这就是渲染器要做的工作将虚拟DOM渲染为真实DOM。

声明一个虚拟DOM:
bash
const vnpde = {
tag: 'div',
props: {
onClick: ()=>alert('hello')
},
children: 'click me'
}
编写一个渲染器将上面的虚拟DOM渲染为真实DOM:
bash
function renderer(vnode,container){
//使用vnode.tag作为标签名称床架DOM元素
const el = document.createElement(vnode.tag)
//遍历props将属性,事件添加到DOM元素
for(const key in vnode.props){
if(/^on/.test(key)){
//如果key以on开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), //事件名称 onCleck--->click
vnode.props[key] //事件处理函数
)
}
}
//处理子节点
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的实现思路:
bash
1. 创建元素:把vnode,tag作为标签名称来创建DOM元素。
2. 为元素添加属性和事件:遍历vnode.props对象,如果key以on字符开头,说明它是一个
事件,把字符on截取掉后再调用toLowerCase函数将事件名称小写化,最终得到合法的事件名称,
例如onClick会变成click,最后调用addEventListener绑定事件处理函数。
4. 处理children:如果children是一个数组,就递归地调用renderer继续渲染,注意,此
时我们要把刚刚创建的元素作为挂载点(父节点);如果children是字符串。则使用
createTextNode函数创建一个文本节点,并将其添加到新创建的元素内。
这个渲染器函数仅做了创建节点的工作,渲染器的精髓都在更新节点的阶段。
对于渲染器来说,它需要精确地找到vnode对象的变更点并且只更新变更的内容,不需要再走一遍创建元素的流程。
3. 组件的本质
组件不是真实的DOM元素,它是是一组DOM元素的封装,这组DOM元素就是组件要渲染的内容,因此我们可以用虚拟DOM来描述组件。
因此可以定义一个函数来代表组件,而函数返回值就代表组件要渲染的内容:
bash
const MyComponent = function(){
return {
tag: 'div',
props: {
onClick: ()=>alert('hello')
},
children: 'click me'
}
}
可以看到组件的返回值也是虚拟DOM,它代表组件要渲染的内容。现在就可以用虚拟来描述组件了,即让虚拟DOM对象中的tag属性来存储组件函数:
bash
const vnode = {
tag: myComponent
}
此时tag: myComponent用来描述组件,tag不再是标签名称而是组件函数,未了支持渲染组件需要修改前面的renderer函数,如下:
bash
function renderer(vnode,container){
if(typrof vnode,tag==='string'){
//说明vnode描述的是标签元素
mountElement(vnode,container)
}
else if(typeof vnode.tag==='function'){
//说明vnode描述的是组件
mountComponent(vnode,container)
}
}
mountElement函数内容与rendere修改之前的内容完全一致:
bash
function mountElement(vnode,container){
//使用vnode.tag作为标签名称床架DOM元素
const el = document.createElement(vnode.tag)
//遍历props将属性,事件添加到DOM元素
for(const key in vnode.props){
if(/^on/.test(key)){
//如果key以on开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), //事件名称 onCleck--->click
vnode.props[key] //事件处理函数
)
}
}
//处理子节点
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函数实现如下:
bash
function mountComponent(vnode,container){
//调用组件函数,获取组件要渲染的内容(虚拟DOM)
const subtree = vnode.tag()
//递归调用renderer渲染subtree
renderer(subtree,container)
}
可以看到首先调用vnode.tag函数返回虚拟DOM,这个虚拟DOM就是组件要渲染的内容,我们称之为subtree,既然subtree是虚拟DOM那么直接调用renderer函数完成渲染即可。
组件不一定是函数形式的,也可以用javaScript对象来表达组件,
bash
//MyComponent 是一个对象
const MyComponent = {
render(){
return {
tag: 'div',
props: {
onClick: ()=>alert('hello')
},
children: 'click me'
}
}
}
接着修改渲染器renderer和mountElement函数
bash
function renderer(vnode,container){
if(typrof vnode,tag==='string'){
//说明vnode描述的是标签元素
mountElement(vnode,container)
}
else if(typeof vnode.tag==='object'){
//如果是对象,说明vnode描述的是组件
mountComponent(vnode,container)
}
}
修改mountElement函数
bash
function mountComponent(vnode,container){
//vnode.tag是组件对象,调用它的的render函数得到组件要渲染的内容(虚拟DOM)
const subtree = vnode.tag.render()
//递归调用renderer渲染subtree
renderer(subtree,container)
}
在上述代码中vnode.tag是表达组件的对象,调用该对象的render函数得到组件要渲染的内容,也就是虚拟DOM。
4. 模板的工作原理
模板的工作涉及到Vue.js框架中另一个重要的组成部分:编译器。
编译器和渲染器一样只是一段程序,它的工作就是将模板编译为渲染函数。
如一下模板
bash
<div @click="handler">click me</div>
对于编译器来说模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:
bash
render(){
return h('div',{onClick: handler},'click me')
}
以.vue文件为例,一个.vue文件就是一个组件,如下所示:
bash
<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default{
data(){/* ... */},
methods:{
handler: ()=>{/* ... */}
}
}
</script>
其中标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到
bash
export default{
data(){/* ... */},
methods:{
handler: ()=>{/* ... */}
},
render(){
return h('div',{onClick: handler},'click me')
}
}
所以,无论是使用模板还是手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器(renderer函数)再把渲染函数(render函数)返回的虚拟DOM渲染为真实的DOM,这就是模板的工作原理,也是Vue.js渲染页面的流程。
5. Vue.js是由各个模块组成的有机整体
组件的实现依赖于渲染器(渲染器调用渲染函数,生成虚拟DOM,并最终把它变成屏幕上真实的DOM),模板的编译依赖于编译器(编译器将模板编译为渲染函数render),Vue.js各个模块之间是相互关联,互相制约的,共同构成一个有机整体。下面以渲染器和编译器两个关键模块为例理解它们如何配合工作,并实现性能提升。
有以下模板:
bash
<div id="foo" :class="cls"></div>
根据之前的介绍,可知编译器会将这段代码编译为渲染函数:
bash
render(){
//为了效果更加直观,这里没有使用h函数而是直接采用了虚拟DOM对象
//下面的代码等价于:
//return h('div',{id: 'foo',class: cls})
return {
tag: 'div',
props:{
id: 'foo',
class: cls
}
}
}
可以发现在这段代码中,cls是一个变量,它可能会发生变化。渲染器的工作之一是寻找并且只更新变化的内容。所以当cls值变化时,渲染器会自动寻找变更点。这个"寻找"会花费一些力气。
从编译器视角来看,如果编译器有能力分析动态内容,并在编译阶段将这些信息提取出来,直接交给渲染器,这样渲染器就不用寻找变更点了。
Vue.js的实现方案如下:
以Vue.js模板来说,它的模板是有特点的,能一眼看出id="foo"是静态数据永远不会变化,:class=cls是一个v-bind绑定,它是会变化的。所以编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码时完全可以附带这些信息:
bash
render(){
return {
tag: 'div',
props:{
id: 'foo',
class: cls
},
patchFlag: 1 //假设数字1代表class是动态的
}
}
虚拟DOM对象中多出一个patchFlag属性,假设数字1代表class是动态的,这样渲染器知道只有class属性会发生改变。对于渲染器来说相当于省去寻找变更点的工作量,性能自燃就提升了。
可以看到编译器和渲染器之间是存在信息交流的。它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟DOM对象。虚拟DOM对象中会包含多种数据字段,每个字段都代表一定的含义。
6. 总结
- Vue.js是一个声明式框架,声明式的好处在于它直接描述结果,用户不关注过程。Vue.js采用模板的方式来描述UI,但它同样支持使用虚拟DOM来描述UI。虚拟DOM比模板更灵活,模板比虚拟DOM更加直观。
- 渲染器的作用是,把虚拟DOM渲染为真实DOM元素。它的工作原理是递归地遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM元素的创建。渲染器的精髓在于更新,它会通过Diff算法找出变更点,并且只会更新需要更新的内容。
- 组件是一组虚拟DOM元素的封装,它可以是一个返回虚拟DOM的函数,也可以是一个对象,但这个对象下必须有一个函数用来产出组件要渲染的虚拟DOM,渲染器在渲染组件时,会现获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值(虚拟DOM),我们称之为subtree,最后递归地调用渲染器将subtree渲染出来即可。
- Vue.js模板会被编译器的程序编译为渲染函数,编译器,渲染器都是Vue.js的核心组成部分,它们共同构成一个有机整体,不同模块之间互相配合,进一步提升框架新能。