第 3 章 Vue.js3的设计思路

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 标签即可

// h 标签的级别
let level = 3
const title = {
	tag: `h{level}` // h3 标签
}

可以看到当 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 }) // 虚拟 DOM
	}
}

这里的 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) {
	// 使用 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:虚拟DOM对象
  • container:一个真实DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下。

接下来,我们就可以调用 renderer 函数

renderer(vnode, document.body) // body 作为挂载点

在浏览器中运行这段代码,会渲染出'click me'文本,点击文本会弹出 alert('hello world')
在这里插入图片描述
现在我们回过头来分析渲染器 renderer 的实现思路,总体来熟分为三步。

  1. 创建元素:把 vnode.tag 作为标签名称来创建 DOM 元素
  2. 为元素添加属性和事件:遍历 vnode.props对象,如果 keyon 字符开头,说明它是一个事件,把字符 on 截掉后再调用 toLowerCase函数将事件名称小写化,最终得到合法的事件名称,例如 onClick会变成click,最后调用addEventListener绑定事件处理函数。
  3. 处理children:如果children是一个数组,就递归调用 renderer 继续渲染,注意,此时要把刚刚创建的元素作为挂载点(父节点):如果children是字符串,则使用createTextNode函数创建一个文本节点,并将其添加到新创建的元素内

是不是感觉渲染器并没有想象的那么神秘?其实不然,我们所做的还仅仅是创建节点,渲染器的精髓都在更新节点。假设我们对 vnode 做一些小小的修改:

const vnode = {
	tag: 'div',
	props: {
		onClick: () => alert('hello')
	},
	children" 'click again' // 从 click me 改成 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') {
		// 说明 vnode 描述的是标签元素
		mountElement(vnode, container)
	} else if (typeof vnode.tag === 'function') {
		// 说明 vnode 描述的是组件
		mountComponent(vnode, container)
	}
}

如果 vnode.tag的类型是字符串,说明它描述的是普通标签元素,此时调用 mountElement函数完成渲染;如果 vnode.tag 的类型是函数,则说明它描述的是组件,此时调用mountComponent函数完成渲染。其中mountElement函数与上下文中的 renderer函数内容一致。

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)
}

再来看看 mountComponent 函数是如何实现的

function mountComponent(vnode, container) {
	// 调用组件函数,获取组件要渲染的内容(虚拟DOM)
	const subtree = vnode.tag()
	// 递归调用 renderer 渲染 subtree
	renderer(subtree, container)
}

组件一定是函数吗?当然不是,我们完全可以使用一个JavaScript对象来表达组件,例如:

// myComponent 是一个对象
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') { // 如果是对象,说明 vnode 描述的是组件
		mountComponent(vnode, container)
	} 
}

现在我们使用对象而不是函数来表达组件,因此要将 typeof vnode.tag === 'function' 修改为 typeof vnode.tag === 'object',接着修改mountComponent函数:

function mountComponent(vnode, container) {
	// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟DOM)
	const subtree = vnode.tag.render()
	// 递归调用 renderer 渲染 subtree
	renderer(subtree, container)
}

在上述代码中,vnode.tag 是表达组件的对象,调用该对象的 render 函数得到组件要渲染的内容,也就是虚拟DOM

3.4 模板的工作原理

3.5 Vue.js 是各个模块组成的有机体

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值