理解渲染函数:先了解下Vue设计思路
这里是专栏的第二篇,主要介绍Vue3的设计思路,其内容参考自书籍Vue.js设计与实现第三章,今天的内容对于我们理解渲染函数极其重要,后续我们会搭建自己的Vue组件库,到时会用到渲染函数。
虚拟 DOM
假如,我们想在浏览器渲染如下DOM结构,思考一下Vue当中提供了哪些实现方案?
<div id="app">hello world</div>
有两种方案,方案一是通过模版语法,方案二是通过渲染函数,示例:
<template>
<div id="app">hello world</div>
</template>
<script>
import { h } from "vue";
export default {
setup() {
return () => h("div", { id: "app" }, "hello world");
},
};
</script>
不知道大家有没有思考过一个问题,这两种方案明明是不同的写法,为什么最后都会渲染成同一个结果?
要回答这个问题,我们来看一下官方提供的渲染管线图。
Vue 模板和渲染函数之间是什么关系?Vue 模板会被编译为渲染函数。
渲染函数和浏览器上真实DOM之间又是什么关系?渲染函数会返回虚拟 DOM,而虚拟 DOM会挂载或更新为真实DOM。
那什么是虚拟 DOM呢?上面示例中的虚拟 DOM结构又是什么样的呢?我们来看下:
const vnode = {
// 标签名称
tag: 'div',
// 标签属性
props: {
id:'app'
},
// 子节点
children:[
"hello world"
]
}
如上所示:虚拟 DOM其实就是JavaScript 对象,它包含了我们创建实际元素所需的所有信息。
示例中无论是通过模版语法,还是通过渲染函数结果都会返回虚拟 DOM,并最终渲染成真实DOM。
那么工作时我们使用模版语法和使用渲染函数有什么区别呢?我们来看个案例,假如我们要表示一个标题,根据标题级别的不同,会分别采用h1~h6这几个标签,如果用渲染函数表示,我们可以根据props传值,灵活的描述h
的值:
<template>
<button id="counter" @click="increment">{{ count }}</button>
</template>
<script>
import { h } from "vue";
export default {
props: ["level"],
setup(props, { slots }) {
return () => [h("h" + props.level, slots.default())];
},
};
</script>
如上所示,当变量level值改变,对应的标签也会在h1和h6之间变化。但如果用模版来描述,就需要穷举:
<h1 v-if="level === 1">
<slot></slot>
</h1>
<h2 v-else-if="level === 2">
<slot></slot>
</h2>
<h3 v-else-if="level === 3">
<slot></slot>
</h3>
<h4 v-else-if="level === 4">
<slot></slot>
</h4>
<h5 v-else-if="level === 5">
<slot></slot>
</h5>
<h6 v-else-if="level === 6">
<slot></slot>
</h6>
从上面的代码中,你应该能感觉到,这样的实现看起来太冗余,远没有渲染函数灵活。
渲染器
现在我们了解了什么是虚拟 DOM,那它是如何变成真实 DOM并渲染到浏览器页面中的呢?答案是:渲染器。
渲染器的作用就是把虚拟 DOM渲染为真实DOM,我们平时编写的Vue.js组件都是依赖渲染器来工作的,假如我们有如下虚拟 DOM:
const vnode = {
tag: 'div',
props: {
id:'app',
onClick: ()=> alert('hello')
},
children:'click test'
}
上面这段代码:
- tag 用来描述标签名称,所以描述的就是一个标签。
- props 是一个对象,用来描述标签的属性,事件等内容。这里我们给绑定一个点击事件。
- children 用来描述标签的子节点。在上面代码中,children是一个字符串值。
接下来,我们来实现一个渲染器,把上面这段虚拟 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)){
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称onClick ---> click
vnode.props[key] // 时间处理函数
)
}else{
el.setAttribute(key, 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 => render(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
这里的 renderer 函数接收如下两个参数:
- vnode:虚拟DOM对象。
- container:真实DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下。
接下来,我们调用 renderer 函数
renderer(vnode, document.body)
在浏览器运行这段代码,会渲染出“click test”文本,点击该文本,会弹出alert('hello'),如图所示:
我们再来看下渲染器renderer的实现思路。
-
根据
vnode.tag
作为标签名称来创建DOM元素。 -
为元素添加属性和事件:遍历
vnode.props
对象,如果key以on字符开头,说明它是一个事件,把字符on截取掉后再调用toLowerCase函数将事件名称小写化,最终得到合法的事件名称,例如onClick会变成click,最后调用addEventListener
绑定事件处理函数。其它属性则调用setAttribute
处理。 -
处理children,如果children是字符串,则使用函数创建一个文本节点,并将其添加到新创建的元素内,如果children是一个数组,就递归地调用继续渲染。
当然这里只考虑了最简单的情况,不过Vue渲染器也都是使用一些我们熟悉的DOM操作API完成渲染工作。
渲染函数
我们已经了解了虚拟 DOM和渲染器,那么大家有没有思考过渲染函数是如何返回虚拟 DOM的?
还是以上述虚拟 DOM为例:
const vnode = {
tag: 'div',
props: {
id:'app',
onClick: ()=> alert('hello')
},
children:'click test'
}
要生成这段虚拟 DOM我们有几个问题需要思考:
- 要生成对应的虚拟 DOM,渲染函数如何写
- 渲染函数如何生成vnode
- 我们日常开发写的
h
函数本质又是什么
接下来我们一起来实现:
// 创建vnode函数
function createVNode(tag, props, children){
const vnode = {
tag,
props,
children
}
return vnode
}
// h函数
function h(tag, propsOrChildren, children) {
const l = arguments.length
if (l === 2) {
return createVNode(tag, null, propsOrChildren)
} else {
return createVNode(tag, propsOrChildren, children)
}
}
// 创建渲染函数,返回虚拟 DOM
const vnode = h('div',
{
id:'app',
onClick: ()=> alert('hello')
},
'click test'
)
以上代码,我们可以看到真正生成vnode的是createVNode函数,但当你需要多次使用渲染函数时,一个简短的名字 h
更省力。
组件的本质
我们知道了渲染函数,知道了虚拟 DOM,那么组件又是什么呢?组件和虚拟 DOM有什么关系?渲染器如何渲染组件?
其实虚拟 DOM除了能够描述真实 DOM之外,还能够描述组件。那具体该如何描述呢?想要弄明白这个问题,就需要先搞清楚组件的本质是什么。一句话总结:组件就是一组DOM元素的封装,这组DOM元素就是组件要渲染的内容,我们可以定义一个对象来代表组件,而对象的渲染函数返回值就代表组件要渲染的内容。
const MyComponent = {
// 在真实的Vue代码中还会有我们熟知的el, key, children等其它属性
render(){
return {
tag: 'div',
props: { id:'app',
onClick: ()=> alert('hello')
},
children:'click test'
} }
}
可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,我们就可以定义虚拟 DOM来描述组件了。很简单,我们可以让虚拟 DOM对象中的tag属性来存储组件对象:
const vnode = {
tag: MyComponent
}
就像tag: 'div'用来描述
function renderer(vnode, container){
// 如果是对象,说明vnode描述的是对象
if(typeof vnode.tag === 'object'){
mountComponent(vnode, container)
}else if(typeof vnode.tag === 'string'){
mountElement(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)){
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称onClick ---> click
vnode.props[key] // 时间处理函数
)
}else{
el.setAttribute(key, 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 => render(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
再来看mountComponent函数式如何实现的:
function mountComponent(vnode, container){
// vnode.tag是组件对象,调用它的render 函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归的调用,renderer渲染subtree
renderer(subtree, container)
}
上述代码中,vnode.tag是表达组件的对象,调用该对象的render函数得到组件要渲染的内容,也就是虚拟 DOM。
模版的工作原理
上文中我们讲解了虚拟 DOM是如何渲染成真实DOM的,那么模版是如何工作的呢?这就要说到编译器了,其作用是将模版编译为渲染函数,以我们熟悉的.vue文件为例,一个.vue文件就是一个组件,如下:
<template>
<div @click='handler'>
click test
</div>
</template>
<script>
export default {
data(){/* ... */}
methods:{
handler:()=>{/* ... */}
}
}
</script>
其中标签里的内容就是模版内容,编译器会把模版内容编译成渲染函数并添加到标签块的组件对象上,所以最终在浏览器运行的代码是:
<script>
export default {
data(){/* ... */}
methods:{
handler:()=>{/* ... */}
},
render(){
return h("div", { onClick: handler }, "click test")
}
}
</script>
所以无论是使用模版还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器在把渲染函数render返回的虚拟 DOM 渲染为真实DOM,这也是Vue.js渲染页面的流程。
总结
这篇文章主要说明了Vue.js渲染页面的流程,了解了虚拟 DOM其实就是JavaScript对象,渲染器会把虚拟 DOM渲染为真实 DOM。
然后我们又认识了组件的本质,原来组件就是一组DOM元素的封装。
最后我们了解了模版会被编译为渲染函数,渲染函数会生产虚拟 DOM,从而贯穿整个Vue.js渲染页面的流程。
专栏的下一篇我们将学习组件数据通信:Vue有哪些数据通信方式?
转载自:https://juejin.cn/post/7239383972769005628