本篇文章部分内容来源于霍春阳 《Vue.js设计与实现》这本书的理解, 感兴趣的小伙伴可以自行购买阅读。可以非常明确的感受到作者对 Vue 的深刻理解以及用心, 富含非常全面的 Vue 的知识点, 强烈推荐给大家。
先抛出一个结论, 这个结论对本篇文章的理解很有帮助: Vue是一个保留了运行时+编译时架构的框架, 编译时, 用户可以提供 HTML 字符串, 我们将其编译为数据对象再交给运行时处理; 运行时, 根据提供的数据对象渲染到页面中。这里不太理解没关系, 下文会逐步帮大家理解。
上面提到的这个数据对象其实就是所谓的虚拟DOM, 他是一个 JS 树型结构的数据对象, 通俗易懂的说, 虚拟 DOM 就是一个 JS 普通对象, 这个 JS 对象是真实 DOM 的描述。没有太懂没关系, 看看下面这个例子。
例如, 我们下面这样有一段 HTML 字符串:
const html = `
Hello World
`
假定有这样 Compiler 一个应用程序作为编译器, 它的作用是在将一个 HTML 字符串转换为树形的数据结构。
const obj = Compiler(html)
// obj 结果如下
obj = {
tag: "div",
props: {
id: 'app'
}
children: [
{tag: "span", children: "Hello World"}
]
}
这个转换出来的树形的数据结构 obj 就可以看做是虚拟 DOM, 其中 tag 用来描述标签名称; props 是一个对象, 用来描述标签属性、事件等内容; children 用来描述标签的子节点, 即可以是一个数组, 代表子节点, 也可以是一个字符串, 代表子节点为文本节点。现在我们可以更深刻的感受到, 虚拟 DOM 就是对真实 DOM 的一个描述。
事实上, 你完全可以设计自己设计虚拟 DOM 的结构, 比如使用 tagName 来描述标签名。
上面我们提到过, Vue是一个运行时 + 编译时架构的框架, 我们再来理解一下。上面我们的过程, 就是在编译时进行的, 将 HTML 字符串编译为 JS 数据对象, 也就是虚拟 DOM。那么运行时, 我们就根据这个 JS 数据对象(虚拟 DOM), 渲染元素到页面当中了, 也就是转为真实的 DOM。那么虚拟 DOM 究竟是如何转换为真实 DOM 的? 其实它是通过渲染器实现的。渲染器是非常重要的一个角色, 平时我们使用 Vue.js 就是依赖渲染器进行的工作, 下面我们来简单认识一下渲染器。
我们可以编写一个简单版的渲染器, 将虚拟 DOM 渲染到页面当中。例如我们有下面这样一个虚拟 DOM:
const vnode = {
tag: "button",
props: {"onClick": () => alert("Hello World")},
children: "按钮"
}
接下来我们就需要一个渲染器 renderer, 将上面这段虚拟 DOM, 转换为真实的 DOM:
function renderer (vnode, container){
// 获取标签名, 并创建一个DOM元素
const curEl = document.createElement(vnode.tag)
// 遍历属性, 将事件或属性添加到DOM元素上
for (const key in vnode.props) {
// on开头说明是事件, 则为DOM元素添加事件
if(/^on/.test(key))
curEl.addEventListener(key.slice(2).toLowerCase(), vnode.props[key])
}
// 如果子节点为stirng类型, 说明是文本子节点
if (typeof vnode.children === "string")
curEl.appendChild(document.createTextNode(vnode.children))
// 如果子节点为数组类型, 递归调用渲染函数
else if (Array.isArray(vnode.children))
vnode.children.forEach(child => renderer(child, curEl));
// 将元素添加到挂载点下
container.appendChild(curEl)
}
下面我们传入刚刚的虚拟 DOM, 将它挂载到 body 下, 在浏览器运行代码, 我们就可以在页面中得到一个按钮, 点击按钮就会出现弹出 “Hello World”。
renderer(vnode, document.body)
当然实际上的渲染器 renderer 内部会更为复杂, 这里我们只是做了一个简单实现。更加详情的实现我们可以去查看 Vue 的源码。
对于虚拟 DOM 和渲染器我们都有了初步的理解, 那么组件又是什么呢? 组件和虚拟 DOM 之间的关系是什么? 渲染器又是如何渲染组件的?
事实上, 虚拟 DOM 除了可描述真实的 DOM 之外, 还可以用来描述组件, 但是组件毕竟不是一个真实的 DOM 元素, 那么我们该如何进行描述? 在讲述这个问题之前, 我要在抛出一个问题, 组件的本质是什么? 本质: 组件就是一组 DOM 元素的封装, 这组 DOM 元素就是该组件要渲染的内容。那如果这样的话, 我们就可以定义一个函数来代表组件。
例如上一节的 vnode 我们将其定义到一个组件当中, 用一个函数来代表; 函数的返回值就是要渲染的内容, 也就是虚拟 DOM:
function MyComponent() {
return {
tag: "button",
props: {"onClick": () => alert("Hello World")},
children: "按钮"
}
}
我们已经搞清楚组件的本质, 那么我们就可以使用虚拟 DOM 像描述标签一样来描述组件, 只不过 tag 属性中存放的不再是标签名, 而是组件函数。
const vnode = {
tag: MyComponent,
}
当然, 我们也需要让渲染器 renderer 支持组件, 才能渲染, 所以我们需要对上本中的 renderer 函数进行一些修改为如下所示:
function renderer(vnode, container) {
// 说明描述的是标签
if (typeof vnode.tag === "string") mountElement(vnode, container)
// 说明描述的时组件
else if(typeof vnode.tag === "function") mountComponent(vnode, container)
}
我们先将上文中的 renderer 函数的名称修改为 mountElement, 让渲染器用来处理标签, 如下:
function mountElement (vnode, container){
// 获取标签名, 并创建一个DOM元素
const curEl = document.createElement(vnode.tag)
// 遍历属性, 将事件或属性添加到DOM元素上
for (const key in vnode.props) {
// on开头说明是事件, 则为DOM元素添加事件
if(/^on/.test(key))
curEl.addEventListener(key.slice(2).toLowerCase(), vnode.props[key])
}
// 如果子节点为stirng类型, 说明是文本子节点
if (typeof vnode.children === "string")
curEl.appendChild(document.createTextNode(vnode.children))
// 如果子节点为数组类型, 递归调用渲染函数
else if (Array.isArray(vnode.children))
vnode.children.forEach(child => renderer(child, curEl));
// 将元素添加到挂载点下
container.appendChild(curEl)
}
再实现一个 mountComponent 函数, 让渲染器用来处理组件:
function mountComponent (vnode, container) {
// 获取到组件返回的虚拟DOM
const subtree = vnode.tag()
// 递归调用renderer渲染组件返回的虚拟DOM
renderer(subtree, container)
}
传入vNode 和 body 作为挂载点, 在浏览器中运行, 同样可以实现上文中的效果。
renderer(vNode, document.body)
这样我们就通过虚拟 DOM 描述组件, 转换为真实渲染到页面当中, 但是组件一定是函数吗? 学习过 react 的小伙伴一定知道, 在 react 中有函数组件, 也有类组件。所以我们完全也可以使用一个 JS 对象来表达组件:
const MyComponent = {
render() {
return {
tag: "button",
props: {"onClick": () => alert("Hello World")},
children: "按钮"
}
}
}
当然渲染器 renderer 以及 mountComponent 函数都需要做一些修改, 支持对象组件:
// 渲染器的修改
function renderer(vnode, container) {
const type = vnode.tag
if (typeof type === "string") mountElement(vnode, container)
else if (
typeof type === 'function' ||
Object.prototype.toString.call(type) === '[object Object]'
)
mountComponent(vnode, container)
}
// mountComponent函数的修改
function mountComponent (vnode, container) {
const type = vnode.tag
let subtree
// 获取到组件返回的虚拟DOM
if (typeof type === "function") subtree = vnode.tag()
else if (typeof type === "object") subtree = vnode.tag.render()
// 递归调用renderer渲染组件返回的虚拟DOM
renderer(subtree, container)
}
我们只做了很小的修改, 就能够满足用对象来表达组件的需求。到这里, 我们就完成了虚拟 DOM 将组件转为真实 DOM 的操作, 并且支持函数表达组件和对象表达组件两种方式。
前面我们知道了虚拟 DOM 是如何渲染为真实 DOM 的, 那么下面我们就来探讨一下模板是怎么工作的? 这也是 Vue 中另一个非常重要的角色: 编译器。我们再来回忆一下, 文章开头提到运行时+编译时。我们已经知道运行时, 是通过渲染器将虚拟 DOM 渲染为真实 DOM; 以及编译时将 HTML 字符串编译成虚拟 DOM。
那么 Vue 中的模板就怎样进行工作的呢? 就是通过编译器, 编译器和渲染器一样, 只是一段程序而已, 编译器的作用是将模板编译成渲染函数。对于编译器来说, 模板就是一个普通字符串, 它会对字符串进行分析, 并生成一个功能相同的渲染函数(也就是 h 函数, 不知道 h 函数的可以去网上看看资料, 简单了解一下如何使用即可)。
下面以一个 .vue 文件举个栗子, 有如下所示一个 Vue 文件:
<template>
<div @click="handler">
按钮
</div>
</template>
<script>
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
}
}
</script>
其中 template 标签中就是模板的内容, 编译器会把模板内容编译成一个渲染函数, 并添加到 script 标签中。上面代码经过编译器处理, 最终在浏览器中运行的代码如下:
<script>
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
},
render() {
return h('div', { onClick: handler }, '按钮')
}
}
</script>
所以无论是我们自己手写渲染函数, 还是使用模板, 它最终渲染的内容都是通过渲染函数产生的, 所以模板我们可以看做是手写渲染函数的一个语法糖。
组件的实现依赖于渲染器,模板的编译依赖于编译器。编译器会将模板内容编译成一个渲染函数, 渲染函数的返回值就是虚拟 DOM, 渲染器再根据返回的这个虚拟 DOM 渲染成真实 DOM。这个过程就是模板的工作原理, 也是 Vue 渲染到页面的流程。