Vue.js 3 的设计思路

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函数接收两个参数:

  1. vnode:虚拟DOM对象。
  2. 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. 总结

  1. Vue.js是一个声明式框架,声明式的好处在于它直接描述结果,用户不关注过程。Vue.js采用模板的方式来描述UI,但它同样支持使用虚拟DOM来描述UI。虚拟DOM比模板更灵活,模板比虚拟DOM更加直观。
  2. 渲染器的作用是,把虚拟DOM渲染为真实DOM元素。它的工作原理是递归地遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM元素的创建。渲染器的精髓在于更新,它会通过Diff算法找出变更点,并且只会更新需要更新的内容。
  3. 组件是一组虚拟DOM元素的封装,它可以是一个返回虚拟DOM的函数,也可以是一个对象,但这个对象下必须有一个函数用来产出组件要渲染的虚拟DOM,渲染器在渲染组件时,会现获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值(虚拟DOM),我们称之为subtree,最后递归地调用渲染器将subtree渲染出来即可。
  4. Vue.js模板会被编译器的程序编译为渲染函数,编译器,渲染器都是Vue.js的核心组成部分,它们共同构成一个有机整体,不同模块之间互相配合,进一步提升框架新能。
相关推荐
丨我是张先生丨1 小时前
英语单词 Web Page
前端·css·css3
随风一样自由2 小时前
【前端+Vitest测试框架】Vitest | Playwright | WebdriverIO | Preview
前端·前端框架·可用性测试
前端之虎陈随易11 小时前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·vue.js·人工智能·typescript·node.js
一路向北he11 小时前
字节钢铁军团--“提供情境,而非控制”
java·开发语言·前端
kyriewen11 小时前
豆包和千问同时关了智能体,我用它们搭的 3 个自动化全废了——迁移方案整理
前端·javascript·ai编程
前端一小卒11 小时前
我用 TypeScript 从零手写了一个 Claude Code,然后发现它的核心只有 30 行
前端·agent
铁皮饭盒12 小时前
用 Bun.cron 定时 7 月 7 日,为啥? 看图1
javascript
大圣编程13 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang13 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试