vue3 源码学习,实现一个 mini-vue(九):构建 renderer 渲染器之 ELEMENT 节点的各种属性挂载

前言

原文来自 我的个人博客

在前几章中,我们实现了 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 挂载了三个属性 classvaluetype,根据之前的源码阅读我们知道,属性的挂载是在 packages/runtime-dom/src/patchProp.ts 中的 patchProp 方法处进行的。

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

  1. 第一次进入 patchProp

image.png

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

  2. 第二次进入 patchClass
    image.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 想要成功的进行各种属性的设置,就需要 针对不同属性通过不同方式 完成,例如:
// 修改 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 中,增加新的判断条件:
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 方法:
/**
 * 判断指定元素的指定属性是否可以通过 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 方法:
/**
 * 通过 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 方法:
/**
 * 通过 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 方法中,跟踪源码实现:

  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 情况:
/**
* 为 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 方法:
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>

效果:

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

3.gif

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>

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

  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 方法中有一行代码是我们没有讲到的,那就是:

// patch
existingInvoker.value = nextValue

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

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

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

<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 的渲染行为。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容