渲染器的大概处理流程,函数名与vue3源码的函数名一致,直接复制就可以去源码搜索
通常使用英文 renderer 来表达渲染器,作用是把虚拟 DOM(VNode) 渲染为特定平台上的真实元素(不只是浏览器).
另外还需要给他指定一个挂载的位置,他是一个DOM元素也就是容器,文章之后都用container 来表达容器
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode1, document.querySelector('#app'))
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'))
// 第三次渲染
renderer.render(null, document.querySelector('#app'))
拿上面的代码解释一下:createRenderer是用来创建渲染器的,它接收参数代表不同平台特有的api,以支持在不同平台
例如,在app上没有document,如果你直接写document.createElement肯定不能在app上正确运行
createElement(tag) {
return document.createElement(tag)
},
接着会得到一个render函数,接收两个参数(VNode,container)
明显可以看出如果第一个参数为空,执行卸载(执行unmount方法),反之执行挂载或者更新(执行patch方法)
patch(container._vnode, vnode, container)这个方法既可以执行挂载也可以进行更新.接收三个参数(旧VNode,新VNode,container),挂载或者更新之后会把现在的VNode存储到container._vnode方便之后做比较.
虚拟DOM:就是用js描述html,不是说使用虚拟DOM一定比真实DOM快,主要是为了它的跨平台能力。
const vnode = {
type: 'button',
props: {
id: 'foo',
disabled: false
},
children: '点击'
}
对于上面props的处理有两种方法设置props setAttribute / DOM 对象直接设置
- 同一属性但是它们的名字不同,例如class className
- HTML Attributes 与 DOM Properties并不是一一对应的
- HTML Attributes的作用是设置与之对应的 DOM Properties 的初始值。
例如对于button有一个disabled属性
- el.setAttribute('disabled', false)= el.setAttribute('disabled', 'false')这是因为使用 setAttribute 函数设置的值总是会被字符串化
- el.disabled = '' === el.disabled = false 由于 el.disabled 是布尔类型的值,所以当我们尝试将它设置为空字符串时,浏览器会将它的值矫正为布尔类型的值,即 false。
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
if (key in el) {
// 获取该 DOM Properties 的类型
const type = typeof el[key]
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
}
else {
el.setAttribute(key, value)
}
}
}
有一些 DOM Properties 是只读的,所以只能用setAttribute
对应其中class的操作,因为vue中可以是数组,对象,字符串.所以需要使用normalizeClass,也就是一个转化数据结构的函数
对于事件来说只需要判断是否以on开头
将设置props也抽离出来作为createRenderer的参数,因为不同平台可能不一致.
patchProps(el, key, prevValue, nextValue) {
// 匹配以 on 开头的属性,视其为事件
if (/^on/.test(key)) {
// 根据属性名称得到对应的事件名称,例如 onClick ---> click
const name = key.slice(2).toLowerCase()
// 绑定事件,nextValue 为事件处理函数
el.addEventListener(name, nextValue)
}
if (key === 'class') { el.className = nextValue || '' }
else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
这里有一个性能优化的问题,当替换事件时要先removeEventListener再addEventListener
if (/^on/.test(key)) {
// 获取为该元素伪造的事件处理函数 invoker
let invoker = el._vei
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (invoker) {
//代表需要替换
invoker.value = nextValue
} else {
// 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
// vei 是 vue event invoker 的首字母缩写
invoker = el._vei = (e) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e)
}
// 将真正的事件处理函数赋值给 invoker.value
invoker.value = nextValue
// 绑定 invoker 作为事件处理函数
el.addEventListener(name, invoker)
}
} else if (invoker) {
// 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
el.removeEventListener(name, invoker)
}
}
invoker的数据结构是有问题的,一个元素同时绑定了多种事件,将会出现事件覆盖的现象,所以invoker改成invokers变成了一个对象
if (/^on/.test(key)) {
// 获取为该元素伪造的事件处理函数 invoker
let invokers = el._vei||( el._vei={})
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (invokers[key]) {
invokers[key].value = nextValue
} else {
invokers[key] = el._vei.key = (e) => {
invokers[key].value(e)
}
invokers[key].value = nextValue
el.addEventListener(name, invokers[key])
}
} else if (invokers[key]) {
el.removeEventListener(name, invokers[key])
}
}
并且对应同一个事件类型也可以绑定多个事件处理函数
if (/^on/.test(key)) {
// 获取为该元素伪造的事件处理函数 invoker
let invokers = el._vei || (el._vei = {})
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (invokers[key]) {
invokers[key].value = nextValue
} else {
invokers[key] = el._vei.key = (e) => {
//新增 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invokers[key].value)) {
invokers[key].value.forEach((fn) => {
fn(e)
})
}
else {
invokers[key].value(e)
}
}
invokers[key].value = nextValue
el.addEventListener(name, invokers[key])
}
} else if (invokers[key]) {
el.removeEventListener(name, invokers[key])
}
}
umount
先说说与patch同级的umount,它是在render函数第一个参数VNode为null时执行,并且现在暂时只考虑普通标签的渲染
它接收一个参数(VNode),不需要传入真实DOM,因为在挂载过程mountElement函数给VNode添加一个属性el
const el = vnode.el = createElement(vnode.type)
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
在这个umount函数中会执行两件重要的事情
- 如果卸载是的一个DOM元素,执行绑定的
指令钩子函数 - 如果卸载的是组件,执行相关的
生命周期函数
patch
来到最重要的patch函数:首先会判断新旧节点的type是否相同,不一致直接卸载旧节点。判断新节点的type类型,例如注释,文本,片段节点,执行不同的操作,这里只考虑普通标签。
接着如果patch传入的第一个参数是null,会执行挂载mountElement,接收两个参数(VNode,container)
因为VNode是树形的,要使用递归挂载,如果children为数组,要循环调用patch(null, item, el)方法,el是新创建的元素
function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === "string") {
setElementText(el, vnode.children)
} else {
vnode.children.forEach(item => {
patch(null, item, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
// 调用 patchProps 函数即可
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
如果type相同执行patchElement函数,接收两个参数(旧VNode,新VNode),对两个虚拟DOM进行比较
对于一个元素来说,它的子节点无非有以下三种情况。
- 没有子节点,此时 vnode.children 的值为 null。
- 具有文本子节点,此时 vnode.children 的值为字符串,代表文本的内容。
- 其他情况,无论是单个元素子节点,还是多个子节点(可能是文 本和元素的混合),都可以用数组来表示。
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 第一步:更新 props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
// 第二步:更新 children
patchChildren(n1, n2, el)
}
处理子节点需要调用patchChildren,要考虑新节点的子节点是什么类型,根据类型进行处理
function patchElement(n1, n2) {
//省略
// 第二步:更新 children
patchChildren(n1, n2, el)
}
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => { unmount(c) })
}
else {
// 最后将新的文本节点内容设置给容器元素
setElementText(container, n2.children)
}
}
else if (Array.isArray(n2.children)) {
// 判断旧子节点是否也是一组子节点
if (Array.isArray(n1.children)) {
// 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的Diff 算法************************************************
// 将旧的一组子节点全部卸载
n1.children.forEach(c => unmount(c))
// 再将新的一组子节点全部挂载到容器中
n2.children.forEach(c => patch(null, c, container))
}
else {
setElementText(container, '')
n2.children.forEach((c) => {
if(typeof c == 'string'){
setElementText(container, c)
}
else{
patch(null, c, container) //因为patch函数有关于不同type类型的处理,所以不能直接用mountElement进行挂载.
}
})
}
}
else {
// 代码运行到这里,说明新子节点不存在
// 旧子节点是一组子节点,只需逐个卸载即可
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
// 旧子节点是文本子节点,清空内容即可
setElementText(container, '')
}
// 如果也没有旧子节点,那么什么都不需要做
}
}
处理文本节点和注释节点
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
mountElement()
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
if (n1.type !== n2.type) {
unmount(n1)
mountElement(n2, container)
}
else {
patchElement(n1, n2)
}
}
} else if (type === Text) {
if (!n1) {
// 使用 createTextNode 创建文本节点
const el = n2.el = document.createTextNode(n2.children)
// 将文本节点插入到容器中
insert(el, container)
}
else {
// 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
const el = n2.el = n1.el
if (n2.children !== n1.children)
el.nodeValue = n2.children
}
}
else if(type === Comment){
if (!n1) {
// 使用 createTextNode 创建文本节点
const el = n2.el = document.createComment(n2.children)
// 将文本节点插入到容器中
insert(el, container)
}
else {
// 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
const el = n2.el = n1.el
if (n2.children !== n1.children)
el.nodeValue = n2.children
}
}
}
createTextNode createComment 和 el.nodeValue为了保证渲染器核心的跨平台能力,我们需要将这两个操作 DOM 的 API 封装到渲染器的选项中
createRenderer({
createText(text) {
return document.createTextNode(text)
},
createComment(comment) {
return document.createComment(comment)
},
setText(el, text) {
el.nodeValue = text
},
//省略其他代码
})
最后还有一种fragment类型,是用 vnode 来描述多根节点模板,例如
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: 'text 1' },
{ type: 'li', children: 'text 2' },
{ type: 'li', children: 'text 3' }
]
}
对于 Fragment 类型的 vnode 的来说,它的 children 存储的内容就是模板中所有根节点,所以children是肯定是一个数组,Fragment 本身并不会渲染任何DOM
// 对应fragment的处理
else if (type === Fragment) { // 处理 Fragment 类型的 vnode
if (!n1) {
// 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
n2.children.forEach(c => patch(null, c, container))
} else {
// 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
patchChildren(n1, n2, container)
}
}
文本节点,注释,片段没有所谓的标签名称,因此我们也需要为他们创建唯一标识
const Text = Symbol()
const Comment = Symbol()
const Fragment = Symbol()
对应片段的卸载需要特殊处理,当卸载 Fragment 类型的虚拟节点时,由于 Fragment 本身并不会渲染任何真实DOM,所以只需要遍历它的children 数组,并将其中的节点逐个卸载即可
function unmount(vnode) {
if (vnode.type === Fragment) {//新增
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
对于新旧DOM的children都是数组的情况,需要进行核心diff操作,以后介绍几种Diff算法.
简单补充一下几点,创造不易,求个赞🤩
Vue渲染器和响应式是如何协同工作
响应式里会暴露一个effect函数,这是一个底层函数,使用Vue的时候会自动调用。它的作用是注册副作用函数。
通过响应式得到一个响应式数据,在effect函数中调用render函数,它的参数VNode里含有这个响应式数据
现在改变响应式数据的值,会重新执行render函数
- renderer渲染器中不止返回了
render函数
还有hydrate(服务端相关),createApp(初始化Vue项目)
- 对于渲染类型为组件的会另外写一篇文章