本篇为阅读《Vue.js》所作笔记~ 感受一下渲染器的重要性
- 渲染器是Vue.js中非常重要的一部分,很多功能依赖渲染器来实现,例如Transition组件、Teleport组件、Suspense组件以及template ref 和自定义指令等
渲染器与响应系统的结合
渲染器是用来执行渲染任务的,在浏览器平台上,用它来渲染其中的真实DOM元素。渲染器还是框架跨平台能力的关键,在设计渲染器的时候需要考虑好自定义的能力。本节将渲染器限定在DOM平台,下面的函数就是一个合格的渲染器:
js
function renderer(domString, container) {
container.innerHTML = domString
}
renderer(`<h1>hello</h1>`, document.getElementById('app'))
不仅可以渲染静态的字符串,还可以渲染动态拼接的HTML内容 如果是一个响应式数据,可以联想到副作用函数 利用响应系统 可以让整个渲染过程自动化
js
const count = ref(1)
effect(() => {
renderer(`<h1>${count}</h1>`, document.getElementById('app'))
})
count++
渲染器的基本概念
-
通常用renderer来表示'渲染器',render表示'渲染'。渲染器的作用是把虚拟DOM渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟DOM渲染为真实DOM元素
虚拟DOM通常用 virtual DOM来表达,简写成vdom 虚拟DOM和真实DOM的结构一样,都是由一个个节点组成的树型结构 所以经常能听到'虚拟节点'这样的词,即 virtual node,简写成vnode 虚拟DOM是树型结构,这棵树中的任何一个vnode节点都可以是一颗子树 因此vdom和vnode有时可替换使用,以下统一使用vnode
-
渲染器把虚拟DOM节点渲染为真实DOM节点的过程叫做 挂载(mount),例如Vue.js组件中的mounted钩子就会在挂载完成时触发。这意味着,在mounted钩子中可以访问真实DOM元素。
-
渲染器将真实DOM挂载在哪里呢?渲染器通常需要接收一个挂载点作为参数,用来指定具体的挂载位置('挂载点'其实就是一个DOM元素,渲染器会把该DOM元素作为容器元素,并把内容渲染到其中)
jsfunction createRenderer() { function render(vnode, container) { } function hydrate(vnode, container) { } return { render, hydrate } }
如上代码,createRenderer函数用来创建一个渲染器,为什么需要这个函数,直接定义render不就可以了吗?
渲染器与渲染是不同的,渲染器是更加宽泛的概念,包含渲染, 把vnode渲染为真实DOM的render函数只是其中一部分。渲染器不仅可以用来渲染,还可以用来激活已有的DOM元素,这个过程通常发生在同构渲染的情况下。在Vue.js3中,甚至连创建应用的createApp函数也是渲染器的一部分
-
有了渲染器,我们可以用它来执行渲染任务了,如下:
jsconst renderer = createRenderer() // 首次渲染 renderer.render(vnode, document.querySelector('#app'))
首先调用了createRenderer创建了一个渲染器,接着调用渲染器的render函数执行渲染,首次调用render函数时,只需要创建新的DOM元素即可,这个过程只涉及挂载
当多次在同一个container上调用render函数进行渲染时,渲染器除了要执行挂载动作外,还要执行更新动作
js// 首次渲染 renderer.render(oldVnode, document.querySelector('#app')) // 第二次渲染 renderer.render(newVnode, document.querySelector('#app'))
首次渲染已经将oldVnode渲染到container内了,再次调用render函数并尝试渲染newVnode时,就不能简单地执行挂载动作了,在这种情况下,渲染器会使用newVnode与oldVnode进行比较,试图找到并更新变更点。这个过程叫做'打补丁'(或更新patch),挂载动作本身也可以看作一种特殊的打补丁,特殊之处在于旧的vnode是不存在的,代码如下:
jsfunction createRenderer() { function render(vnode, container) { if (vnode) { // 新vnode存在 将其与旧的vnode一起传递给patch函数 进行打补丁 patch(container._vnode, vnode, container) } else { if (container_vnode) { // 旧vnode存在 且新的vnode不存在 说明是卸载(unmount)操作 只需要将container内的DOM清空即可 container.innerHTML = '' } } // 把vnode存储到container_vnode 下 即后续渲染中的旧vnode container._vnode = vnode } return { render, } } const renderer = createRenderer() // 首次渲染 renderer.render(vnode1, document.querySelector('#app')) // 第二次渲染 renderer.render(vnode2, document.querySelector('#app')) // 第三次渲染 renderer.render(null, document.querySelector('#app'))
- 首次渲染时,渲染器将vnode1渲染为真实DOM。渲染完成后,vnode1会存储到容器元素的
container._vnode
属性中,它会在后续渲染中作为旧vnode使用 - 第二次渲染时,旧vnode存在,此时渲染器会把vnode2作为新vnode,并将新旧vnode一同传递给patch函数进行打补丁
- 第三次渲染时,新vnode的值为null,即什么都不渲染,但此时容器中渲染的是vnode2所描述的内容,所以渲染器需要清空容器,上述代码使用
container.innerHTML = ''
来清空,需要注意这样子清空容器是有问题的,不过暂时使用它来达到目的
- 首次渲染时,渲染器将vnode1渲染为真实DOM。渲染完成后,vnode1会存储到容器元素的
-
patch (container._vnode, vnode, container)
该函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑
jsfunction patch(n1, n2, container) {}
至少接收三个参数:
- n1:旧vnode
- n2:新vnode
- container:容器
在首次渲染时,容器元素的container._vnode属性是不存在的,即undefined。这意味着,在首次渲染时传递给n1也是undefined。这时,patch函数会进行挂载动作,它会忽略n1,并直接将n2描述的内容渲染到容器中。从这点可以看出,patch函数不仅可以用来打补丁,也可以用来执行挂载
自定义渲染器
- 以下以浏览器作为渲染的目标平台,编写一个渲染器,在这个过程中,看看哪些内容是可以抽象的,然后通过抽象,将浏览器特定的API抽离,这样就可以使得渲染器的核心不依赖于浏览器,在此基础上,再为那些被抽离的API提供可配置的接口,即可实现渲染器的跨平台能力
-
从渲染一个普通的
<h1>
标签开始。jsconst vnode = { type: 'h1', children: 'hello' } const renderer = createRenderer() renderer.render(vnode, document.querySelector('#app'))
使用type属性来描述一个vnode的类型,不同类型的type属性值可以描述多种类型的vnode 当type属性是字符串类型值时,可以认为它描述的是普通标签,并使用该type属性的字符串值作为标签的名称。 对于这样一个vnode,我们可以使用render函数渲染它
-
为了完成渲染工作,需要补充patch函数
jsfunction createRenderer() { function patch(n1, n2, container) { if (!n1) { // 如果n1不存在 意味着挂载 则调用mountElement函数完成挂载 mountElement(n2, container) } else { // n1存在 意味着打补丁 } } function mountElement(vnode, container) { // 创建DOM元素 const el = document.createElement(vnode.type) // 处理子节点 如果子节点是字符串 代表元素具有文本节点 if (typeof vnode.children === 'string') { // 只需要设置元素的 textContent 属性即可 el.textContent = vnode.children } // 将元素添加到容器中 container.appendChildren(el) } function render(vnode, container) {} return { render } }
如上,将
patch
函数、mountElement
函数编写在createRenderer
函数内这样子,就完成了一个vnode的挂载
-
分析代码存在问题:mountElement函数内调用了大量依赖于浏览器的API,要设计通用的渲染器,需要将这些浏览器特有的API抽离。可以将这些操作DOM的API作为配置项,该配置项可以作为createRenderer函数的参数,如下:
在mountElement等函数内就可以通过配置项来取得操作DOM的API
重构后的mountElement函数在功能上没有任何变化。不同的是,它不直接依赖于浏览器的特有API
只要传入不同的配置项,就能完成非浏览器环境下的渲染工作
jsconst renderer = createRenderer({ createElement(tag) { // 用于创建元素 return document.createElement(tag) }, setElementText(el, text) { // 用于设置元素的文本节点 el.textContent = text }, insert(el, parent, anchor = null) { // 用于在给定的parent下添加指定元素 parent.insertBefore(el, anchor) } }) function createRenderer(options) { const { createElement, setElementText, insert } = options function mountElement(vnode, container) { const el = createElement(vnode.type) if (typeof vnode.children === 'string') { setElementText(el, vnode.children) } insert(el, container) } return { render } } renderer.render(vnode, document.querySelector('#div'))
-
通过传入不同配置项,可以实现一个用来打印渲染器操作流程的自定义渲染器,如下:
jsconst renderer = createRenderer({ createElement(tag) { console.log(`创建元素${tag}`); console.log({ tag }); return { tag } }, setElementText(el, text) { console.log(`设置${JSON.stringify(el)}的文本内容为${text}`); el.text = text }, insert(el, container, anchor = null) { console.log(`将${JSON.stringify(el)}添加到${JSON.stringify(container)}下`); } }) const container = { type: 'root' } // 设置一个挂载点 renderer.render(vnode, container)
- 在createElement内,不再调用浏览器的API,而是仅仅返回一个对象{tag},并将其作为创建出来的'DOM'元素
- 在setElementText和insert函数内,同样没有调用浏览器相关的API,而是自定义了一些逻辑,并打印信息到控制台
- 上面实现的自定义渲染器不依赖浏览器特有的API,所以这段代码不仅可以在浏览器中运行,也可以在Node.js中运行
自定义渲染器只是通过抽象的手段,让核心代码不再依赖平台特有的API,再通过个性化配置的能力实现跨平台