一. 什么是渲染器
1.1 渲染器基本概念
在浏览器中,用其渲染真实DOM元素、执行渲染任务。
function renderer(domString, container) {
container.innerHTML = domString
}
如上代码,通过一个renderer函数,把渲染内容创建到对应的节点中,这个过程我们称之为渲染。这里要注意一点,不仅仅是节点的改变,创建、删除、更新等操作都可以称为渲染。
同时在开发渲染器的时候,做好渲染器的自定义能力,可以让渲染器跨平台使用。
1.2 官方名称
在Vue3.0的官方说法中
render表示“渲染”, 是一个动作。 renderer表示“渲染器”。在浏览器中就是通过渲染器把虚拟dom转化为真实dom的。
virtual DOM 表示虚拟DOM。简称为vdom
virtual node 表示虚拟节点,简称为vnode
vdom和vnode是相同的,都是树形结构。
挂载简称为mount,是指渲染器把vdom渲染为真实DOM节点的过程,叫做挂载。所以在vue中mounted阶段中我们可以访问到真实dom。
1.3 渲染的真实含义
上面我们提到一点,渲染其实分为很多种类型。把vnode渲染成真实DOM的render函数只是其中的一部分。所以我们需要定义一个createRenderer函数。
function createRenderer() {
render() {
}
otherFunc() {
}
return {
render,
otherFunc
}
}
1.3.1 挂载动作
const renderer = createRenderer()
renderer.render(vnode, doucment.querySelector('#app'))
上面代码中,我们先调用createRender函数,创建一个渲染器,然后调用render函数执行渲染。因为是首次渲染,这里只需要创建新的节点就好。这个阶段就是挂载阶段。
1.3.2 更新动作
如果我们多次对上面的“app”容器修改的话,如下代码:
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode1, doucment.querySelector('#app'))
// 二次渲染
renderer.render(vnode2, doucment.querySelector('#app'))
这种情况下,不能简单的执行挂载动作,我们需要对vnode1,vnode2,也就是旧节点、新节点,进行比较,找到修改的地方,并更新到新节点里面。这个过程我们称为更新、也叫做“打补丁”。英文就是“patch”。其实挂载算一种特殊的patch(旧节点为空)。在代码中,我们可以对两者进行简单的处理,如下所示。
function createRenderer() {
render(vnode, container) {
if (vnode) {
// 存在新节点
// 把旧节点、新节点、一起给patch函数,进行操作。
patch(container._vnode, vnode, container)
}
else {
// 不存在新节点,说明没有需要渲染的vnode
if (container._vnode) {
// 存在旧节点、不存在新节点、说明是卸载
container.innerHTML = ''
}
}
// 每次执行后,把新节点,设置为旧节点
container._vnode = vnode
}
return {
render
}
}
1.3.3 patch函数
渲染的核心内容其实是这个patch函数,因为它包含着整个渲染逻辑。后面是重点内容。从上面的代码中可以看出,patch函数至少需要3个参数
patch(oldVnode, newVnode, container) {
// 新节点
// 旧节点
// 容器
}
首次渲染的时候,oldVnode为undefined,这时候,patch函数会忽略oldVnode,直接渲染newVnode。这也是挂载动作。
二. 渲染器的跨平台能力
一个合格的渲染器,不仅仅是可以在浏览器上完成渲染,而应该可以通过配置实现在多端平台都可以完成渲染。难点在于,如何暴露、暴露哪些可以抽离的API。下面以一个浏览器渲染为主,一步步实现一下。
2.1 渲染
先实现渲染一个简单的文本节点。
需要先用vnode对象描述一个。
一个简单的vnode对象,我们通过type来描述它的node类型。一般为字符串的值作为标签类型,非字符串作为节点。
// 一个h1标签内容为hello呀
const vnode = {
type: 'h1',
children: 'hello 呀'
}
// 创建渲染器
const renderer = createRenderer()
// 调用渲染函数
renderer.render(vnode, document.querySelector('#app'))
具体化render函数:
function createRender() {
function patch(n1, n2, container) {
// 核心的渲染逻辑
}
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
}
else {
if (container._vnode) {
container.innerHTML = ''
}
}
}
return {
render
}
}
2.2 patch函数编写
现在只考虑挂载阶段的patch,打补丁阶段先不考虑。
function patch(n1, n2, container) {
// n1旧节点
// n2新节点
if (!n1) {
// 如果旧节点不存在,直接走挂载
mountElement(n2, container)
}
else {
// 如果都有,打补丁,先省略
}
}
function mountElement(vnode, container) {
// 创建dom,通过type创建
const el = document.createElement(vnode.type)
if (typeof vnode.children === 'string') {
// vnode的子元素为string,代表元素具有文本节点
// 设置元素的textContent
el.textContent = vnode.children
}
container.appendChild(el)
}
这样就完成了一个文本节点的挂载。现在考虑一个问题?类似于document.createElement、 el.textContent、appendChild等都是依赖于浏览器的API。离开浏览器环境,肯定不行。所以,在设计通用渲染器的时候就要考虑到抽离特有API,把这些API变成配置项。
具体要如何实现?也不难。
就是在创建createRenderer的时候,把这些配置项传人。
function createRenderer(options) {
const { api1, api2, .... api3 } = options
}
如下,传入配置项:
const render = createRenderer({
// 创建元素
createElement(tag) {
return document.createElement(tag)
},
// 设置文本节点
setElementText(el, text) {
el.textContent = text
},
// 在给定parent下面添加元素
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
}
})
function createRenderer(options) {
const { createElement, setElementText, insert } = options
...
}
完整代码如下:
function createRenderer(options) {
const { createElement, setElementText, insert, removeAllElement } = options
function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
}
insert(el, container)
}
function patch(n1, n2, container) {
if (!n1) {
mountElement(n2, container)
}
else {
// 之后完善
}
}
function render(vnode, container) {
debugger
if (vnode) {
patch(container._vnode, vnode, container)
}
else {
if (container._vnode) {
removeAllElement(container)
}
}
container._vnode = vnode
}
return {
render
}
}
const vnode = {
type: 'h1',
children: 'hello'
}
const container = document.querySelector('#app')
const renderer = createRenderer({
// 创建元素
createElement(tag) {
return document.createElement(tag)
},
// 设置文本节点
setElementText(el, text) {
el.textContent = text
},
// 在给定parent下面添加元素
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
// 元素移除全部
removeAllElement(container) {
container.innerHTML = ''
}
})
renderer.render(vnode, container)
在HTML中试一下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
</div>
</body>
</html>
<script>
// 上面的代码copy一下
</script>
运行结果如下图:
三. 与响应式数据结合
具体响应式数据怎么实现的,我会放到后面的篇幅中。现在就考虑一下通过响应式数据和我们的renderer结合起来。
3.1 接入响应式api
unpkg.com/@vue/reacti… vue官方给出的单独响应式数据包。提供响应式api。暴露一个全局的API叫VueReactivity。
只要在script中引入即可
<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>
3.2 具体实现
通过VueReactivity暴露出两个api effect, ref, 把vnode定义为响应式数据。通过调用effect副作用函数,完成响应式数据的渲染。具体如下:
const { effect, ref } = VueReactivity
// 修改vnode为响应式数据
const vnode = ref({
type: 'h1',
children: 'hello'
})
effect(
() => {
renderer.render(vnode.value, container)
}
)
setTimeout(() => {
vnode.value.children = 'okok'
}, 1000)
有一点要注意,当我们在定时器里面修改vnode.value.children的时候,会出现一个问题,之前已经渲染一次,相当于执行过一次挂载阶段。这时候新、旧节点都存在,会在patch函数中走另一段代码。所以我们需要修改一下patch代码:
function patch(n1, n2, container) {
mountElement(n2, container)
// 去掉下面的判断,直接全部走挂载。
// if (!n1) {
// mountElement(n2, container)
// }
// else {
// }
}
OK,我们在页面上看一下
OK,简单的渲染器基本完成。后续将对渲染器细化,以及patch函数的实现完善。