我们已经初步了解了虚拟 DOM 和渲染器,知道了虚拟 DOM 其实就是用来描述真实 DOM 的普通 JS 对象,渲染器会把这个对象渲染为真实 DOM 元素。 那么组件又是什么呢?组件和虚拟 DOM 有什么关系?渲染器如何渲染组件?接下来,我们就来讨论这些问题。
其实虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。例如使用 { tag: 'div' } 来描述 <div> 标签,但是组件并不是真实的 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
}就像 tag: 'div' 用来描述 <div> 标签一样,tag: MyComponent 用来描述组件,只不过此时的 tag 属性不是标签名称,而是组件函数。 为了能够渲染组件,需要渲染器的支持。修改前面提到的 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)
}可以看到,非常简单。首先调用 vnode.tag 函数,我们知道它其实就是组件函数本身,其返回值是虚拟 DOM,即组件要渲染的内容,这里我们称之为 subtree。 既然 subtree 也是虚拟 DOM,那么直接调用 renderer 函数完成渲染即可。
这里希望大家能够做到举一反三,例如组件一定得是函数吗?当然不是,我们完全可以使用一个 JS 对象来表达组件,例如:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
const vnode = {
tag: MyComponent // tag 不是一个函数,而是一个对象
}这里我们使用一个对象来代表组件,该对象有一个函数,叫作 render,其返回值代表组件要渲染的内容。 为了完成组件的渲染,我们需要修改 renderer 渲染器以及 mountComponent 函数。
首先,修改渲染器的判断条件:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
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。
可以发现,我们只做了很小的修改,就能够满足用对象来表达组件的需求。那么大家可以继续发挥想象力,看看能否创造出其他的组件表达方式。 其实 Vue.js 中的有状态组件就是使用对象结构来表达的。
