前言
本篇解析参阅 vue3源码、崔大的mini-vue、霍春阳大佬的《Vuejs设计与实现》尽可能记录我的Vue3源码阅读学习过程。我会结合自己的思考,提出问题,找到答案,附在每一篇的底部。希望大家能在我的文章中也能一起学习,一起进步,有 get 到东西的可以给作者一个小小的赞作为鼓励吗?谢谢大家!
手写简易vue3 renderer渲染器 && render渲染 && patch对比更新
以 render 和 renderer 的差别先开个头吧
很多人会把这两者混淆,我们也顺便将这一节会讲到的一些名词来做一个提前声明
render: 渲染
是一个动词,渲染什么。
renderer: 渲染器
是一个名词,它的作用就是把虚拟 DOM 渲染为特定平台的真实元素
(在浏览器上就是渲染为真实 DOM 元素)。
virtual DOM: 虚拟 DOM,简写成vdom
,由一个个节点(vnode)组成的树型结构。
virtual node: 虚拟节点,简写成vnode
,组成树型结构的基本单位,注意任意一个vnode都可以是一棵子树。
mount:挂载,渲染器把虚拟 DOM 节点渲染成真实 DOM 节点的过程就叫做挂载,Vue中也提供了一个 mounted
钩子在这个挂载完成时触发,可以让我们拿到真实的 DOM 节点。
container: 容器,渲染器挂载需要提供一个容器给它,这样它才知道挂载在哪个位置,我们会提供一个 DOM 元素来作为这个容器。
patch:比较更新,调用 render 函数时,如果已经有旧的节点(old vnode)了,那就需要走 patch 来做比较,找到并更新变更的位置,是渲染逻辑关键入口
。patch 除了比较更新也能用来执行挂载(首次渲染,没有old vnode)。
我们通过代码来描述一下他们的一个大致关系:
function createRenderer() { function render(vnode, container) { if (vnode) { // 新的 vnode 存在,和旧的 vnode 一起传递给 patch函数进行更新 patch(container._vnode, vnode, container) } else { // ... } // 新的 vnode 赋值给 container 的 _vnode 属性 container._vnode = vnode } return render}
上述的代码就是用 createRenderer 函数
来创建一个渲染器,调用这个函数就会得到一个 render 函数
,render 函数以 container 作为挂载点
,将 vnode 渲染为真实 DOM
并添加到挂载点下触发 mounted
,render 函数的内部有一个 patch 函数
,它能比较更新新老节点,找到变更位置并更新,也能实现首次的挂载
。
你可能会疑惑为啥要多一个 createRenderer 来包装一层呢 我干嘛不直接定义 render函数呢?
那就带着疑问接着往下看吧
renderer渲染器
实际上 renderer 的作用不仅仅是返回一个 render 函数这么简单,它还包含了一些 patch(比较新旧节点,并更新)、hydrate(服务端渲染用到的) 功能
在 Vue3 中,createRenderer 函数最终除了返回上边提到的 render 以及 hydrate 以外,还会返回一个叫 createAPP 的函数
return { render, hydrate, createApp: createAppAPI(render, hydrate)}
实际上我们看到这个 createApp 实际上是一个 createAppAPI 的东西,必须传入 render,里边实际上就是包装了一层函数叫做 mount,它创建一个 vnode 节点来调用这个 render 函数实现挂载
。hydrate是可选的我们这就不说它了,后边如果有服务端渲染篇的话我们再好好聊这个。
接着呢,我们再强调一下 renderer 渲染器的作用是 把虚拟 DOM 渲染为特定平台的真实元素
,也就是说他是支持个性化配置能力来实现跨平台的。
在代码里,createRenderer 是接收一个 options 参数的,然后它运用解构来拿到对应的操作
export function createRenderer(options) { const { // 创建 element createElement: hostCreateElement, // 对比元素新老属性 patchProp: hostPatchProp, // 插入 element insert: hostInsert, // 删除 element remove: hostRemove, // 设置 text setElementText: hostSetElementText } = options}
createRenderer 实际上就是包含了诸多的处理函数,具体的我们看一下以下图片
render函数
render 函数在最初的时候我们也看到了其实它就是调用 patch,由 patch 来根据是否是首次渲染来判断直接挂载到真实 DOM 或是对比新旧节点找到变更点实现更新。
另外 render 函数 还负责了 当传入的 vnode 是空且当前的挂载点存在 vnode 的时候,意味着需要执行卸载操作
简单的,我们可以认为 render 函数的实现如下:
function render(vnode, container) { if (vnode) { // 新的 vnode 存在,和旧的 vnode 一起传递给 patch函数进行更新 patch(container._vnode || null, vnode, container, null, null) } else { if (container._vnode) { // 卸载,清空容器 container.innerHTML = "" } } // 新的 vnode 赋值给 container 的 _vnode 属性 container._vnode = vnode}
总的来说,在 Vue3 中,render函数更复杂的逻辑其实是交给了 patch,它只是作为一个中间函数,去调用 patch。 注意,render 是被返回出去了,也就是说我们可以通过
const { render } = createRenderer()render(vnode, container)// orconst renderer = createRenderer()renderer.render(vnode, container)
来直接调用它
它也在 createAppAPI -> mount -> createVnode + render() 中被使用
关于 render 的关联关系如下图:
关于 patch 的具体接收参数
另外,上边卸载容器的方式使用了 container.innerHTML = ""
,这是不严谨的,因为:
容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数
即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数
使用 innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数
正确的操作应该是根据 vnode 对象获取与其相关联的真实 DOM 元素
,然后使用原生 DOM 操作方法将该 DOM 元素移除。所以我们在 mountElement
中给 vnode.el 引用了真实的 DOM 元素
,让 vnode 与真实 DOM 建立起了联系,卸载的时候就通过 vnode.el.parentNode 来执行 removeChild 方法实现卸载
。
我们用代码来解释这一段话:
// 省略其它代码,这里 vnode 是 null,但是 挂载点存在 _vnode,说明需要执行卸载操作if (container._vnode) { // 根据 vnode 获取要卸载的真实 DOM 元素 const el = container._vnode.el // 获取 el 的父元素 const parent = el.parentNode // 调用 removeChild 移除元素 if (parent) parent.removeChild(el)}
自定义渲染器
上面我们提到了 renderer 渲染器是支持个性化配置能力来实现跨平台
的,也就是说别想当然的理解成只能在浏览器里去做渲染。
事实上我们可以这么使用 renderer 渲染器来实现我们自己的自定义渲染,这里我们演示一个打印渲染器操作流程的自定义渲染器:
创建渲染器:
const renderer = createRenderer({createElement(tag) { console.log(`创建了元素 ${tag}`) return { tag }},setElementText(el, text) { console.log(`设置 ${JSON.stringify(el)} 的文本内容: ${text}`)},insert(el, parent, anchor = null) { console.log(`将 ${JSON>stringify(el)} 添加到 ${JSON.stringify(parent)} 下`) parent.children = el}})
验证这个渲染器的代码:
const vnode = {type: "h1",shapeFlag: 9, // 标识当前节点是 element 且 children 也是 elementchildren: "Hello Btrya"}// 使用一个对象模拟挂载点const container = { type: "app" }renderer.render(vnode, container)
然后我们就能在浏览器看到我们的提示出现了:
在 codesandbox 中尝试
这里的 shapeFlag 的机制在 Vue3 中特别有意思,我们后边会讲到。
patch函数
这是本章的重点函数,patch函数,再次强调一下它的作用:
首次渲染,执行挂载
新旧节点比较变更内容并更新
其主要接收参数如下:
/** * @param n1 old vnode * @param n2 new vnode * @param container 挂载点 * @param parentComponent * @param anchor */ patch(n1, n2, container, parentComponent, anchor)
shapeFlag的机制
shapeFlags 是 Vue3 用于判断当前虚拟节点的一个类型
。
文件的位置在 package/shared/shapeFlags.ts
中
详情如下:
export const enum ShapeFlags { ELEMENT = 1, FUNCTIONAL_COMPONENT = 1 << 1, STATEFUL_COMPONENT = 1 << 2, TEXT_CHILDREN = 1 << 3, ARRAY_CHILDREN = 1 << 4, SLOTS_CHILDREN = 1 << 5, TELEPORT = 1 << 6, SUSPENSE = 1 << 7, COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, COMPONENT_KEPT_ALIVE = 1 << 9, COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT}
我们能看到实际上是使用了一个枚举来标识不同的类型,利用了位运算符 <<
来让 1 向左位移 n 位。
其中 ELEMENT
表示的就是元素,它的值是 1 我们还能看到 TEXT_CHILDREN
表示的其实就是 子元素是 文本类型, 它的值是 1 << 3 = 1000(2进制) = 8
那么一个元素它的子元素是文本类型它就可以被表示为 1001(2进制) = 9
在判断的时候使用位运算符 & 就可以知道当前的元素是否具备对应的类型了,比如:
return { render, hydrate, createApp: createAppAPI(render, hydrate)}0
在 Vue3 中就是利用这样的判断来知道这个虚拟节点是否具备一个或多个类型。
那么一个 vnode 是怎么计算它的 shapeFlag 的呢?
问题:vnode 是怎么计算它的 shapeFlag 的呢?
在 createVnode 的时候,其实会有一个初始化的操作,判断当前的这个 vnode 的一个基本类型,具体如下:
return { render, hydrate, createApp: createAppAPI(render, hydrate)}1
那么分别就可以得到 ELEMENT、SUSPENSE、TELEPORT、STATEFUL_COMPONENT、FUNCTIONAL_COMPONENT
这五种基本类型中的其中一种
接着就会根据当前 vnode 的 children 进一步判断 children 的类型,通过位运算符来 |=
进行合并,比如:
return { render, hydrate, createApp: createAppAPI(render, hydrate)}2
比如现在的 children 是 TEXT_CHILDREN
,vnode 的基本类型是 ELEMENT
,那么根据枚举我们可以知道它的 shapeFlag 最终就是 1001 = 9