2023-01-06
Vue
00

目录

前言
1. 源码阅读:vue3 是如何挂载其他属性的
2. 代码实现:区分处理 ELEMENT 节点的各种属性挂载
3. 源码阅读:style 属性的挂载和更新
4. 代码实现:style 属性的更新和挂载
5. 源码阅读:事件的挂载和更新
6. 代码实现:事件的挂载和更新
7. 渲染器模块的局部总结

前言

在前几章中,我们实现了 ELEMENT 节点的挂载、更新以及删除等操作。

但是我们的代码现在还只能挂载 class 属性,而不能挂载其他属性。本章我们就来实现一下其他属性的挂载( style 属性,事件 属性)

1. 源码阅读:vue3 是如何挂载其他属性的

我们从下面的测试实例开始阅读源码:

html
<script> const { h, render } = Vue const vnode = h('textarea', { class: 'test-class', value: 'textarea value', type: 'text' }) // 挂载 render(vnode, document.querySelector('#app')) </script>

在这个测试实例中,我们为 textarea 挂载了三个属性 classvaluetype,根据之前的源码阅读我们知道,属性的挂载是在 packages/runtime-dom/src/patchProp.ts 中的 patchProp 方法处进行的。

所以我们可以直接在这里进行 debugger,因为我们设置了三个属性,所以会 执行三次,我们一个一个来看:

  1. 第一次进入 patchProp

image.png

  1. 可以看到,代码首先会执行 patchClass,而在 patchClass 中最终会执行 el.className = value。至此 class 设置完成。

  2. 第二次进入 patchClassimage.png

  3. 可以看到代码前三个 if 都会跳过,而在第四个 if 时,会执行 shouldSetAsProp(el, key, nextValue, isSVG),其实这个方法会返回 false,最终还是执行 else 中的代码,在 else 中最终会执行 patchAttr 方法:

image.png

  1. patchAttr 最终执行 el.setAttribute(key, isBoolean ? '' : value) 设置 type

  2. 至此 type 设置完成

  3. 第三次进入 patchProp

image.png

  1. 可以看到第三次进入最后执行的是 patchDOMProp,这个方法最后是通过 执行 el[key] = value 设置 value,完成 value 属性的设置的
  2. 至此 value 设置完成
  3. 至此三个属性全部设置完成。

总结:

由以上代码可知:

  1. 针对于三个属性,vue 通过了 三种不同的方式 来进行了设置:
  2. class 属性:通过 el.className 设定
  3. textareatype 属性:通过 el.setAttribute 设定
  4. textareavalue 属性:通过 el[key] = value 设定

至于 vue 为什么要通过三种不同的形式挂载属性,主要有以下两点原因:

  1. 首先 HTML AttributesDOM Properties 想要成功的进行各种属性的设置,就需要 针对不同属性通过不同方式 完成,例如:
js
// 修改 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']`

  1. 还有出于性能的考虑,比如 classNamesetAttribute('class', '')className 的性能会更高

2. 代码实现:区分处理 ELEMENT 节点的各种属性挂载

  1. packages/runtime-dom/src/patchProp.ts 中,增加新的判断条件:
ts
export const patchProp = (el, key, prevValue, nextValue) => { ... else if (shouldSetAsProp(el, key)) { // 通过 DOM Properties 指定 patchDOMProp(el, key, nextValue) } else { // 其他属性 patchAttr(el, key, nextValue) } }
  1. packages/runtime-dom/src/patchProp.ts 中,创建 shouldSetAsProp 方法:
ts
/** * 判断指定元素的指定属性是否可以通过 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 }
  1. packages/runtime-dom/src/modules/props.ts 中,增加 patchDOMProp 方法:
ts
/** * 通过 DOM Properties 指定属性 */ export function patchDOMProp(el: any, key: string, value: any) { try { el[key] = value } catch (e: any) {} }
  1. packages/runtime-dom/src/modules/attrs.ts 中,增加 patchAttr 方法:
ts
/** * 通过 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

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 属性的挂载和更新

创建测试实例阅读源码:

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>

我们继续在 patchProp 方法中,跟踪源码实现:

  1. 第一次进入 patchProp,执行 挂载 操作:

image.png

  1. 可以看到在 patchProp 方法中会进入 patchStyle 方法,而 patchStyle 经过判断会进入 setStyle ,我们进入 setStyle 方法:

image.png

  1. setStyle 中,最后执行 style[prefixed as any] = val ,直接为 style 对象进行赋值操作,至此 style 属性 挂载完成

  2. 接下来延迟两秒之后就开始 style更新操作

  3. 忽略掉相同的挂载逻辑,代码执行到 patchStyle 方法下:

image.png

  1. 可以看到此时 会执行 setStyle(style,key,''),再次进入 setStyle,此时 val'',最后会执行 style['color'] = '',完成 清理旧样式 操作。

  2. 至此 更新 操作完成

总结:

由以上代码可知:

  1. 整个 style 赋值的逻辑还是比较简单的
  2. 不考虑边缘情况 的前提下,vue 只是对 style 进行了 缓存赋值 两个操作
  3. 缓存是通过 prefixCache = {} 进行
  4. 赋值则是直接通过 style[xxx] = val 进行

4. 代码实现:style 属性的更新和挂载

  1. packages/runtime-dom/src/patchProp.ts 中,处理 style 情况:
ts
/** * 为 prop 进行打补丁操作 */ export const patchProp = (el, key, prevValue, nextValue) => { ...... else if (key === 'style') { // style patchStyle(el, prevValue, nextValue) } ...... }
  1. packages/runtime-dom/src/modules/style.ts 中,新建 patchStyle 方法:
ts
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

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>

效果:

页面刷新,两秒钟后样式更新。

3.gif

5. 源码阅读:事件的挂载和更新

我们通过如下测试用例来阅读 vue 源码:

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>

上面代码很简单就是页面刚渲染时挂载 点击事件,两秒钟之后更新为 双击事件

  1. 我们依然来到 patchProps 方法:

image.png

  1. 此时会进入 patchEvent 方法中:

image.png

  1. patchEvent 中,
    1. 首先创建了一个 invokers 对象并绑定到了 el._wei 上,这是个用于缓存的对象,我们可以不用管,他目前只是一个空对象。
    2. 然后又执行 existingInvoker = invokers[rawName]rawName 此时为 'onClick' 这就是想从缓存中取出之前已经缓存过得 onClick 事件函数,我们目前没缓存过,所以是 undefined,所以程序会执行下面的 else
    3. 可以看到最后会执行 addEventListener 的方法,这个方法就是最终挂载事件的方法。但是我们会有个疑问这个 invoker 是什么东西呢?我们代码进入 82createInvoker:

image.png

  1. 由上图我们知道 invoker 就是一个函数,它的 value 属性是当前 onClick 函数

  2. 创建完 invoker 对象后,会执行 invokers[rawName],也就是缓存下来。

  3. 至此,支持事件 挂载 完成

  4. 等待两秒之后,执行 更新 操作:

  5. 第二次 进入 patchEvent,会再次挂载 onDblclick 事件与 第一次 相同,此时的 invokers 值为:

image.png

  1. 但是,到这还没完,我们知道 属性的挂载 其实是在 packages/runtime-core/src/renderer.ts 中的 patchProps 中进行的,观察内部方法,我们可以发现 内部进行了两次 for 循环

image.png

  1. 所以此时还会执行下面的 for 循环来卸载之前的 onClick 事件,我们 第三次 进入到 patchEvent 方法中:

image.png

  1. 这次因为 nextValuenull 且 存在 existingInvoker,所以会执行最后的 removeEventListener 即卸载 onClick 事件,最后执行 invokers[rawName] = undefined,删除 onClick 事件的缓存。

  2. 至此 卸载旧事件 完成

总结:

  1. 我们一共三次进入 patchEvent 方法
    1. 第一次进入为 挂载 onClick 行为
    2. 第二次进入为 挂载 onDblclick 行为
    3. 第三次进入为 卸载 onClick 行为
  2. 挂载事件,通过 el.addEventListener 完成
  3. 卸载事件,通过 el.removeEventListener 完成
  4. 除此之外,还有一个 _veiinvokers 对象 和 invoker 函数,我们说两个东西需要重点关注,那么这两个对象有什么意义呢?

深入事件更新

patchEvent 方法中有一行代码是我们没有讲到的,那就是:

js
// patch existingInvoker.value = nextValue

这行代码是用来更新事件的,vue 通过这种方式而不是调用 addEventListenerremoveEventListener 解决了频繁的删除、新增事件时非常消耗性能的问题。

6. 代码实现:事件的挂载和更新

  1. packages/runtime-dom/src/patchProp.ts 中,增加 patchEvent 事件处理
ts
} else if (isOn(key)) { // 事件 patchEvent(el, key, prevValue, nextValue) }
  1. packages/runtime-dom/src/modules/events.ts 中,增加 patchEventparseNamecreateInvoker 方法:
ts
/** * 为 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 }
  1. 支持事件的打补丁处理完成。

可以创建如下测试实例 packages/vue/examples/runtime/render-element-event.html

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>

效果:

4.gif

7. 渲染器模块的局部总结

目前我们已经完成了针对于 ELEMENT 的:

  1. 挂载
  2. 更新
  3. 卸载
  4. patch props 打补丁
    1. class
    2. style
    3. event
    4. attr

等行为的处理。

针对于 挂载、更新、卸载 而言,我们主要使用了 packages/runtime-dom/src/nodeOps.ts 中的浏览器兼容方法进行的实现,比如:

  1. doc.createElement
  2. parent.removeChild

等等。

而对于 patch props 的操作而言,因为 HTML AttributesDOM Properties 的不同问题,所以我们需要针对不同的 props 进行分开的处理。

而最后的 event,本身并不复杂,但是 vei 的更新思路也是非常值得学习的一种事件更新方案。

至此,针对于 ELEMENT 的处理终于完成啦~

接下来是 TextComment 以及 Component 的渲染行为。

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:叶继伟

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!