likes
comments
collection
share

理解渲染函数:先了解下Vue设计思路

作者站长头像
站长
· 阅读数 14

这里是专栏的第二篇,主要介绍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 模板和渲染函数之间是什么关系?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'
}

上面这段代码:

  1. tag  用来描述标签名称,所以描述的就是一个
    标签。
  2. props  是一个对象,用来描述
    标签的属性,事件等内容。这里我们给
    绑定一个点击事件。
  3.  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'),如图所示:

理解渲染函数:先了解下Vue设计思路

我们再来看下渲染器renderer的实现思路。

  1. 根据vnode.tag作为标签名称来创建DOM元素。

  2. 为元素添加属性和事件:遍历vnode.props对象,如果key以on字符开头,说明它是一个事件,把字符on截取掉后再调用toLowerCase函数将事件名称小写化,最终得到合法的事件名称,例如onClick会变成click,最后调用addEventListener绑定事件处理函数。其它属性则调用setAttribute处理。

  3. 处理children,如果children是字符串,则使用函数创建一个文本节点,并将其添加到新创建的元素内,如果children是一个数组,就递归地调用继续渲染。

当然这里只考虑了最简单的情况,不过Vue渲染器也都是使用一些我们熟悉的DOM操作API完成渲染工作。

渲染函数

我们已经了解了虚拟 DOM和渲染器,那么大家有没有思考过渲染函数是如何返回虚拟 DOM的?

还是以上述虚拟 DOM为例:

const vnode = {
	tag: 'div',
	props: {
                id:'app',
		onClick: ()=> alert('hello')
	},
	children:'click test'
}

要生成这段虚拟 DOM我们有几个问题需要思考:

  1. 要生成对应的虚拟 DOM,渲染函数如何写
  2. 渲染函数如何生成vnode
  3. 我们日常开发写的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'用来描述

标签一样,tag: MyComponent 用来描述组件,只不过此时的tag属性不是标签名称,而是组件对象。为了能够渲染组件,需要渲染器的支持。修改前面的renderer函数,如下所示:

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
评论
请登录