highlight: vs2015
theme: juejin
前言
原文来自 我的个人博客
在前几章中,我们实现了 ELEMENT
节点的挂载、更新以及删除等操作。
但是我们的代码现在还只能挂载 class
属性,而不能挂载其他属性。本章我们就来实现一下其他属性的挂载( style
属性,事件 属性)
1. 源码阅读:vue3 是如何挂载其他属性的
我们从下面的测试实例开始阅读源码:
<script>
const { h, render } = Vue
const vnode = h('textarea', {
class: 'test-class',
value: 'textarea value',
type: 'text'
})
// 挂载
render(vnode, document.querySelector('#app'))
</script>
在这个测试实例中,我们为 textarea
挂载了三个属性 class
、value
和 type
,根据之前的源码阅读我们知道,属性的挂载是在 packages/runtime-dom/src/patchProp.ts
中的 patchProp
方法处进行的。
所以我们可以直接在这里进行 debugger
,因为我们设置了三个属性,所以会 执行三次,我们一个一个来看:
- 第一次进入
patchProp
:
- 可以看到,代码首先会执行
patchClass
,而在patchClass
中最终会执行el.className = value
。至此class
设置完成。 - 第二次进入
patchProp
:
- 可以看到代码前三个
if
都会跳过,而在第四个if
时,会执行shouldSetAsProp(el, key, nextValue, isSVG)
,其实这个方法会返回false
,最终还是执行else
中的代码,在else
中最终会执行patchAttr
方法:
patchAttr
最终执行el.setAttribute(key, isBoolean ? '' : value)
设置type
- 至此
type
设置完成 - 第三次进入
patchProp
:
- 可以看到第三次进入最后执行的是
patchDOMProp
,这个方法最后是通过 执行el[key] = value
设置value
,完成value
属性的设置的 - 至此
value
设置完成 - 至此三个属性全部设置完成。
总结:
由以上代码可知:
- 针对于三个属性,
vue
通过了 三种不同的方式 来进行了设置: class
属性:通过el.className
设定textarea
的type
属性:通过el.setAttribute
设定textarea
的value
属性:通过el[key] = value
设定
至于 vue 为什么要通过三种不同的形式挂载属性,主要有以下两点原因:
- 首先
HTML Attributes
和DOM Properties
想要成功的进行各种属性的设置,就需要 针对不同属性,通过不同方式 完成,例如:
// 修改 class
el.setAttribute('class', 'm-class') // 成功
el['class'] = 'm-class' // 失败
el.className = 'm-class' // 成功
上面同样是修改 class
,通过 HTML Attributes
的方式使用 setAttribute
就可以成功,通过 el['class']
就会失败,因为在 DOM Properties
中,修改 class
要通过 el['className
']`
- 还有出于性能的考虑,比如
className
和setAttribute('class', '')
,className
的性能会更高
2. 代码实现:区分处理 ELEMENT 节点的各种属性挂载
- 在
packages/runtime-dom/src/patchProp.ts
中,增加新的判断条件:
export const patchProp = (el, key, prevValue, nextValue) => {
...
else if (shouldSetAsProp(el, key)) {
// 通过 DOM Properties 指定
patchDOMProp(el, key, nextValue)
} else {
// 其他属性
patchAttr(el, key, nextValue)
}
}
- 在
packages/runtime-dom/src/patchProp.ts
中,创建shouldSetAsProp
方法:
/**
* 判断指定元素的指定属性是否可以通过 DOM Properties 指定
*/
function shouldSetAsProp(el: Element, key: string) {
// #1787, #2840 表单元素的表单属性是只读的,必须设置为属性 attribute
if (key === 'form') {
return false
}
// #1526 <input list> 必须设置为属性 attribute
if (key === 'list' && el.tagName === 'INPUT') {
return false
}
// #2766 <textarea type> 必须设置为属性 attribute
if (key === 'type' && el.tagName === 'TEXTAREA') {
return false
}
return key in el
}
- 在
packages/runtime-dom/src/modules/props.ts
中,增加patchDOMProp
方法:
/**
* 通过 DOM Properties 指定属性
*/
export function patchDOMProp(el: any, key: string, value: any) {
try {
el[key] = value
} catch (e: any) {}
}
- 在
packages/runtime-dom/src/modules/attrs.ts
中,增加patchAttr
方法:
/**
* 通过 setAttribute 设置属性
*/
export function patchAttr(el: Element, key: string, value: any) {
if (value == null) {
el.removeAttribute(key)
} else {
el.setAttribute(key, value)
}
}
至此,代码完成。
创建测试实例 packages/vue/examples/runtime/render-element-props.html
:
<script>
const { h, render } = Vue
const vnode = h('textarea', {
class: 'test-class',
value: 'textarea value',
type: 'text'
})
// 挂载
render(vnode, document.querySelector('#app'))
</script>
测试渲染成功。
3. 源码阅读:style 属性的挂载和更新
创建测试实例阅读源码:
<script>
const { h, render } = Vue
const vnode = h(
'div',
{
style: {
color: 'red'
}
},
'你好,世界'
)
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(
'div',
{
style: {
fontSize: '32px'
}
},
'你好,世界'
)
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
我们继续在 patchProp
方法中,跟踪源码实现:
- 第一次进入
patchProp
,执行 挂载 操作:
- 可以看到在
patchProp
方法中会进入patchStyle
方法,而patchStyle
经过判断会进入setStyle
,我们进入setStyle
方法:
- 在
setStyle
中,最后执行style[prefixed as any] = val
,直接为style
对象进行赋值操作,至此style
属性 挂载完成 - 接下来延迟两秒之后就开始
style
的 更新操作: - 忽略掉相同的挂载逻辑,代码执行到
patchStyle
方法下:
- 可以看到此时 会执行
setStyle(style,key,'')
,再次进入setStyle
,此时val
为''
,最后会执行style['color'] = ''
,完成 清理旧样式 操作。 - 至此 更新 操作完成
总结:
由以上代码可知:
- 整个
style
赋值的逻辑还是比较简单的 - 在 不考虑边缘情况 的前提下,
vue
只是对style
进行了 缓存 和 赋值 两个操作 - 缓存是通过
prefixCache = {}
进行 - 赋值则是直接通过
style[xxx] = val
进行
4. 代码实现:style 属性的更新和挂载
- 在
packages/runtime-dom/src/patchProp.ts
中,处理style
情况:
/**
* 为 prop 进行打补丁操作
*/
export const patchProp = (el, key, prevValue, nextValue) => {
......
else if (key === 'style') {
// style
patchStyle(el, prevValue, nextValue)
}
......
}
- 在
packages/runtime-dom/src/modules/style.ts
中,新建patchStyle
方法:
import { isString } from '@vue/shared'
/**
* 为 style 属性进行打补丁
*/
export function patchStyle(el: Element, prev, next) {
// 获取 style 对象
const style = (el as HTMLElement).style
// 判断新的样式是否为纯字符串
const isCssString = isString(next)
if (next && !isCssString) {
// 赋值新样式
for (const key in next) {
setStyle(style, key, next[key])
}
// 清理旧样式
if (prev && !isString(prev)) {
for (const key in prev) {
if (next[key] == null) {
setStyle(style, key, '')
}
}
}
}
}
/**
* 赋值样式
*/
function setStyle(
style: CSSStyleDeclaration,
name: string,
val: string | string[]
) {
style[name] = val
}
代码完成
创建测试实例 packages/vue/examples/runtime/render-element-style.html
:
<script>
const { h, render } = Vue
const vnode = h(
'div',
{
style: {
color: 'red'
}
},
'你好,世界'
)
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(
'div',
{
style: {
fontSize: '32px'
}
},
'你好,世界'
)
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
效果:
页面刷新,两秒钟后样式更新。
5. 源码阅读:事件的挂载和更新
我们通过如下测试用例来阅读 vue
源码:
<script>
const { h, render } = Vue
const vnode = h(
'button',
{
onClick() {
alert('点击')
}
},
'点击'
)
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(
'button',
{
onDblclick() {
alert('双击')
}
},
'双击'
)
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
上面代码很简单就是页面刚渲染时挂载 点击事件,两秒钟之后更新为 双击事件
- 我们依然来到
patchProps
方法:
- 此时会进入
patchEvent
方法中:
在
patchEvent
中,- 首先创建了一个
invokers
对象并绑定到了el._wei
上,这是个用于缓存的对象,我们可以不用管,他目前只是一个空对象。 - 然后又执行
existingInvoker = invokers[rawName]
,rawName
此时为'onClick'
这就是想从缓存中取出之前已经缓存过得onClick
事件函数,我们目前没缓存过,所以是undefined
,所以程序会执行下面的else
- 可以看到最后会执行
addEventListener
的方法,这个方法就是最终挂载事件的方法。但是我们会有个疑问这个invoker
是什么东西呢?我们代码进入82
行createInvoker
:
- 首先创建了一个
- 由上图我们知道
invoker
就是一个函数,它的value
属性是当前onClick
函数 - 创建完
invoker
对象后,会执行invokers[rawName]
,也就是缓存下来。 - 至此,支持事件 挂载 完成
- 等待两秒之后,执行 更新 操作:
- 第二次 进入
patchEvent
,会再次挂载onDblclick
事件与 第一次 相同,此时的invokers
值为:
- 但是,到这还没完,我们知道 属性的挂载 其实是在
packages/runtime-core/src/renderer.ts
中的patchProps
中进行的,观察内部方法,我们可以发现 内部进行了两次 for 循环:
- 所以此时还会执行下面的
for
循环来卸载之前的onClick
事件,我们 第三次 进入到patchEvent
方法中:
- 这次因为
nextValue
为null
且 存在existingInvoker
,所以会执行最后的removeEventListener
即卸载onClick
事件,最后执行invokers[rawName] = undefined
,删除onClick
事件的缓存。 - 至此 卸载旧事件 完成
总结:
我们一共三次进入
patchEvent
方法- 第一次进入为 挂载
onClick
行为 - 第二次进入为 挂载
onDblclick
行为 - 第三次进入为 卸载
onClick
行为
- 第一次进入为 挂载
- 挂载事件,通过
el.addEventListener
完成 - 卸载事件,通过
el.removeEventListener
完成 - 除此之外,还有一个
_vei
即invokers
对象 和invoker
函数,我们说两个东西需要重点关注,那么这两个对象有什么意义呢?
深入事件更新
在 patchEvent
方法中有一行代码是我们没有讲到的,那就是:
// patch
existingInvoker.value = nextValue
这行代码是用来更新事件的,vue
通过这种方式而不是调用 addEventListener
和 removeEventListener
解决了频繁的删除、新增事件时非常消耗性能的问题。
6. 代码实现:事件的挂载和更新
- 在
packages/runtime-dom/src/patchProp.ts
中,增加patchEvent
事件处理
} else if (isOn(key)) {
// 事件
patchEvent(el, key, prevValue, nextValue)
}
- 在
packages/runtime-dom/src/modules/events.ts
中,增加patchEvent
、parseName
、createInvoker
方法:
/**
* 为 event 事件进行打补丁
*/
export function patchEvent(
el: Element & { _vei?: object },
rawName: string,
prevValue,
nextValue
) {
// vei = vue event invokers
const invokers = el._vei || (el._vei = {})
// 是否存在缓存事件
const existingInvoker = invokers[rawName]
// 如果当前事件存在缓存,并且存在新的事件行为,则判定为更新操作。直接更新 invoker 的 value 即可
if (nextValue && existingInvoker) {
// patch
existingInvoker.value = nextValue
} else {
// 获取用于 addEventListener || removeEventListener 的事件名
const name = parseName(rawName)
if (nextValue) {
// add
const invoker = (invokers[rawName] = createInvoker(nextValue))
el.addEventListener(name, invoker)
} else if (existingInvoker) {
// remove
el.removeEventListener(name, existingInvoker)
// 删除缓存
invokers[rawName] = undefined
}
}
}
/**
* 直接返回剔除 on,其余转化为小写的事件名即可
*/
function parseName(name: string) {
return name.slice(2).toLowerCase()
}
/**
* 生成 invoker 函数
*/
function createInvoker(initialValue) {
const invoker = (e: Event) => {
invoker.value && invoker.value()
}
// value 为真实的事件行为
invoker.value = initialValue
return invoker
}
- 支持事件的打补丁处理完成。
可以创建如下测试实例 packages/vue/examples/runtime/render-element-event.html
:
<script>
const { h, render } = Vue
const vnode = h(
'button',
{
onClick() {
alert('点击')
}
},
'点击'
)
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(
'button',
{
onDblclick() {
alert('双击')
}
},
'双击'
)
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
效果:
7. 渲染器模块的局部总结
目前我们已经完成了针对于 ELEMENT
的:
- 挂载
- 更新
- 卸载
patch props
打补丁class
style
event
attr
等行为的处理。
针对于 挂载、更新、卸载 而言,我们主要使用了 packages/runtime-dom/src/nodeOps.ts
中的浏览器兼容方法进行的实现,比如:
doc.createElement
parent.removeChild
等等。
而对于 patch props
的操作而言,因为 HTML Attributes
和 DOM Properties
不同的问题,所以我们需要针对不同的 props
进行分开的处理。
而最后的 event
,本身并不复杂,但是 vei
的更新思路也是非常值得学习的一种事件更新方案。
至此,针对于 ELEMENT
的处理终于完成啦~
接下来是 Text
、Comment
以及 Component
的渲染行为。