vue是一个声明式的ui框架,如果让你设计一个声明式的ui框架,你会怎么设计呢,下面我们将站在框架的设计者角度来一起探讨一下vue3的设计思路
为了让vue的设计思路更加清晰、易懂,我们可以将声明式UI与命令式UI的区别,用类似"行为主义"和"联结主义"这样的概念来形象化地解释。行为主义者认为人的行为是由外界的刺激所引起的,联结主义者则认为人的行为是由大脑内部的神经网络相互作用而形成的。vue的设计正是借鉴了这两种理论,将UI的设计视为一种外界的刺激,通过模版、对象、虚拟DOM、渲染器等一系列手段,使得UI的实现能够顺应行为主义的逻辑。同时,Vue还融入了联结主义的思想,将虚拟DOM看作是一个由神经元组成的网络,通过不断的学习和更新,最终实现对真实DOM的准确渲染。这种行为主义和联结主义的结合,让Vue的UI设计更加符合人类的认知和学习方式,从而达到了更高的效率和更好的用户体验。
声明式 VS 命令式
声明式编程和命令式编程是两种不同的编程范式,它们在处理计算和编程逻辑的方式上有着显著的区别。以下是它们的基本特点和区别
特点 | 声明式编程 | 命令式编程 |
---|---|---|
侧重点 | "做什么"(What to do) | "如何做"(How to do) |
控制流 | 通常使用表达式和描述性语言 | 通常使用显式指令和控制结构 |
数据可变性 | 数据通常不可变 | 数据通常可变 |
状态管理 | 通常避免显式状态管理 | 通常需要显式状态管理 |
编程范式 | 常与函数式编程范式结合使用 | 常与面向对象编程结合使用 |
循环 | 通常避免显式循环 | 常使用循环和迭代控制结构 |
数据转换 | 重点在数据变换和操作 | 重点在程序状态和控制流 |
示例语言 | SQL、HTML、CSS、React等 | C、C++、Java、Python等 |
代码可读性 | 代码通常更易读和易懂 | 代码中可能包含较多细节和控制逻辑 |
应用领域 | 数据查询、声明性描述 | 通用应用、系统编程、面向对象编程 |
在设计一个声明式的ui框架之前我们需要搞清楚编写ui时会涉及到哪些问题,具体涉及到的问题如下
- DOM元素: div元素还是span元素或者其他元素
- 属性: 比如元素的通用属性id,class等,元素的特有属性,比如a标签的herf,img的scr属性等
- 事件: 比如鼠标事件click等以及键盘事件keydown等
- 元素的结构: DOM的层级结构,比如父节点,子节点,兄弟节点,祖先节点等
如果我们采用命令式的方法去描述以上内容,具体代码如下
javascript
const divElm = document.createElement('div');
divElm.textContent = 'this is recommand demo';
divElm.addEventListener('click', () => console.log('click me'), false);
假如我们需要使用声明式的方式去实现,应当如何去实现呢,其实解决方案也比较多,我们拿vue3来说采用模版的方法去描述
- 使用与html标签一致的方式来描述dom元素,例如一个div标签可以使用
或者
来描述 - 使用v-bind来描述动态绑定的属性,例如
- 使用v-on来描述绑定的事件,例如
- 使用与html一样的结构来描述层级结构,例如
hello vue3
当然我们也可以通过javascript对象的形式来描述
javascript
const vNode = {
// 标签名称
tag: 'div',
// 标签属性
props: {
onClick: () => {
console.log('click');
},
},
// 子节点
children: [
{
tag: 'span',
children: 'hello, world'
},
],
};
虚拟dom
那么使用模版和对象来描述UI时有什么不同呢,使用javscript来描述相对来说更加灵活,我们以需要渲染h1-h5为例,并且分情况展示
模版描述
html
<templete>
<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></h5>
</templete>
javascript对象描述
javascript
const vnode = {
type: `h${level}`,
};
从上面的结果来看,模版没有对象来得更加例灵活,其实这就是所谓的虚拟dom,其实虚拟dom没什么神秘的了吧,正是因为虚拟dom的这种灵活性,其实我们在vuejs组件中手写的render函数就是使用虚拟dom来描述ui,如下代码所示
javascript
import { h } from 'vue';
export default {
render(){
return h('div', { onClick: onClick });
};
};
渲染器
现在我们已经了解什么是虚拟dom,它的本质其实就是用javascript对象来描述真实的dom结构,那么虚拟dom是如何变成真实dom渲染到页面中呢,这其实涉及到了渲染器
渲染器的作用就是把虚拟dom渲染为真实dom,如图所示
渲染器是非常重要的角色,大家平时编写的vue组件都是通过渲染器来进行工作的,
我们假设有如下虚拟dom
javascript
const vnode = {
tag: 'div', // 标签名称
props: {
calssName: 'div'
on: {
click: () => console.log('click me'); // 点击事件
},
},
children: 'hello vue', //标签的子节点
};
接下来我们需要编写一个渲染器,将上面这段虚拟dom代码渲染为真实dom
javascript
* @description 将虚拟dom渲染为真实的dom
* @param {VNode} vNode 虚拟dom对象
* @param { HTMLElement } container 挂载点
*/
function renderer(vNode, container) {
const { tag, props = {} children } = vNode;
const ons = props.on || {};
// 创建真实dom
const el = document.createElement(tag);
// 处理属性,将dom事件进行添加
for (const key in props) {
if (key === 'on') {
// 将事件进行绑定
for (const eventName in ons) {
el.addEventListener(eventName, ons[eventName]);
}
}
}
// 递归处理子节点
if (children) {
// 单个文本节点
if (typeof children === 'string') {
el.appendChild(document.createTextNode(children));
// 多个节点
} else if(Array.isArray(children){
children.forEach(child => renderer(child, el));
}
}
// 将元素挂载到挂载点
container.appendChild(el)
};
render(vnode, document.body);
现在我们来分析 渲染器做了什么事
- 创建元素:根据标签名来创建dom元素
- 为元素添加属性和事件:遍历vnode的props对象,如果key是on,说明on里面绑定的全是对象
- 处理children: 如果children是一个数组,就递归的调用render进行渲染,如果是文本节点调用createTextNode创建文本节点
是不是突然感觉渲染器没有那么神秘了呢,其实我们别忘了我们目前仅涉及到创建节点,还未涉及到更新节点,渲染器的精髓其实在更新节点的阶段,渲染器的工作原理其实是利用我们所熟悉的api来完成渲染工作
组件的本质
其实虚拟dom除了能够描述真实dom之外,还能够描述组件,那么如何使用虚拟dom来描述组件呢,想要明白这个问题,我们就需要知道组件的本质是什么, 组件其实就是一组dom元素的封装,这组元素就是组件要渲染的内容
javascript
// 函数式组件
function functionComponent(){
return {
tag: 'div', // 标签名称
props: {
calssName: 'div'
on: {
click: () => console.log('click me'); // 点击事件
},
},
children: 'hello vue', //标签的子节点
}
}
// 类组件
class ClassComponent {
render() {
return {
tag: 'div', // 标签名称
props: {
calssName: 'div'
on: {
click: () => console.log('click me'); // 点击事件
},
},
children: 'hello vue', //标签的子节点
}
}
}
const componentVnode = {
tag: functionComponent,
};
我们发现此时组件的tag属性不再是标签名称,而是一个函数或者类
javascript
function render(vnode, container){
if (typeof vnode.tag ==== 'object' && vnode.tag !== null) {
mountComponent(vnode, container);
} else if (typeof vnode.tag === 'string') {
mountElement(vnode, container);
}
};
function mountElement(vNode, container) {
const { tag, props = {} children } = vNode;
const ons = props.on || {};
// 创建真实dom
const el = document.createElement(tag);
// 处理属性,将dom事件进行添加
for (const key in props) {
if (key === 'on') {
// 将事件进行绑定
for (const eventName in ons) {
el.addEventListener(eventName, ons[eventName]);
}
}
}
// 递归处理子节点
if (children) {
// 单个文本节点
if (typeof children === 'string') {
el.appendChild(document.createTextNode(children));
// 多个节点
} else if(Array.isArray(children){
children.forEach(child => renderer(child, el));
}
}
// 将元素挂载到挂载点
container.appendChild(el)
};
function mountComponent(vnode, container) {
const subTree = 'render' in vnode.tag ? vnode.tag.render() : vnode.tag();
// 递归调用渲染sutree
render(subTeree, container);
}
可以看到,如果tag是函数,那么我们就知道它其实是组件本身的函数
模版的工作原理
无论是手写虚拟dom还是使用模版,都属于声明式ui,并且vue同时支持2种声明式的描述ui,上面我们了解到了虚拟dom是如何渲染成真实dom的,那么模版是如何工作的 这其实是vue的另一个重要组成部分: 编译器
编译器和渲染器其实一样,只是一段程序而已,不过他们的工作内容不同,编译器的作用其实就是将模版编译成渲染函数
html
<div @click="onClick"> click me </div>
对于编译器来说,模版就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数
javascript
render(h){
return h('div',{ onClick: onClick }, 'click me');
};
以我们熟悉的.vue文件为例,一个.vue文件就是一个组件
vue
<templete>
<div @clik='onClick'> click me </div>
</templete>
<script setup lang="ts">
const onClick = () => {};
</script>
其中templete标签的内容就是模版内容,编译器会把模版内容编译成渲染函数并添加到script标签中
javascript
export default {
methods: {
onClick(){},
},
render(){
return h('div', { onClick: onClick }, 'click me')
}
}
所以无论是使用模版还是直接手写渲染函数,对于一个组件来说,它最终的内容都是通过渲染函数产生的, 然后渲染器再把渲染函数返回的虚拟dom渲染为真实dom
总结
- vue是一个声明式的框架,声明式的好处在于直接描述结果
- vue采用模版的方式来描述ui,但它同样支持使用虚拟dom来描述ui
- 渲染器的作用: 将虚拟dom对象渲染为真实dom对象,工作原理本质就是通过递归遍历递归虚拟dom对象利用dom相关的api来完成真实dom的创建
- 组件本质是一组虚拟dom元素的封装
- vue的模版会被一个叫做编译器的程序进行编译为渲染函数