最终,还是对运行时的render下手了
hello 大家好,🙎🏻♀️🙋🏻♀️🙆🏻♀️
我是一个热爱知识传递,正在学习写作的作者,ClyingDeng
凳凳!
本文主要讲运行时中的runtime-dom
模块渲染时需要用到的API(rendererOptions
)。主要分为两类,一类是针对dom属性操作的API--nodeOps
,另一类就是对于节点类名、样式、属性、事件等变更操作的API--patchProp
。
runtime-dom 使用样例
大家可能对runtime-dom
不是很了解。它是vue中运行时runtime模块的一部分。
runtime分为三部分:
-
runtime-core 内部主要是与平台无关的运行时核心比如生命周期、watch等API;
-
runtime-dom 内部主要是针对浏览器渲染时所需要的API,包括DOM 相关操作的API、属性、事件处理等;
-
runtime-test 主要用于vue内部测试,确保测试的逻辑与DOM无关并且运行速度比JSDOM快。
我们先来用用 runtime-dom 这个库。
使用源码中现有的runtime-dom
,通过pnpm run dev runtime-dom
进行打包。在打包的同级目录下引用打包完成的runtime-dom
。
<div id="app"></div>
<script src="./runtime-dom.global.js"></script>
<script>
let { createApp, h, ref } = VueRuntimeDOM
function useCounter() {
const count = ref(0)
const add = () => {
count.value++
}
return { count, add }
}
let App = {
setup() {
let { count, add } = useCounter()
return { count, add }
},
// 每次更新重新调用render方法
render(proxy) {
return h('h1', { onClick: this.add }, 'hello dy' + this.count)
}
}
let app = createApp(App)
app.mount('#app')
</script>
将我们需要的功能函数提取出来,在setup中获取所需的变量。返回一个新的render函数,这个render函数每次更新的时候都会重新调用。
我们来看下,当我们点击该标签时,会不会执行count自增。
可以看出,确实完美实现点击自增!我们要实现自己的一个runtime-dom
,首先需要知道渲染dom时需要哪些API?!🙇♀️🙇♀️🙇♀️
rendererOptions 中的 nodeOps
首先,dom节点操作的API我们是必须要准备好的。比如节点的插入、删除、元素的创建、文本的创建等。节点操作的功能函数通过nodeOps这个对象传递给runtime-core。
- 插入节点
插入节点我们需要知道插入的节点、父节点、参照物。将当前节点插入指定父节点内,如果有参照物,插入到该节点之前。
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
}
- 删除节点
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child) // 删除子节点
}
}
- 其他节点操作
在此,我们就不展开细说了,相信基本的节点操作,大家还是能够理解的👏👏👏
// 创建元素
createElement: (tag, isSVG, is, props) => {
const el = doc.createElement(tag, is ? { is } : undefined)// is 参数对象
return el
},
// 创建文本
createText: text => doc.createTextNode(text),
// 注释
createComment: text => doc.createComment(text),
// 设置文本中的内容 // 哪个节点中的内容 div中的内容
setText: (node, text) => {
// 元素 内容
node.nodeValue = text
},
// ......(其他:设置文本内容、获取父节点、兄弟节点、克隆节点等等)
rendererOptions 中的 patchProp
针对DOM节点操作的功能是有了,但是光有这些肯定是不足够的。我们最熟悉的属性对比,新老节点的比较情况肯定是少不了的。这不,patchProp
里面就提供了属性对比的方法。
知道节点渲染的,应该多少都听过diff算法。在此,我们就需要简单的实现一个节点新旧属性对比的API。
在实现对比方法的时候,我们需要确定一下入参,简版的对比,我们至少要知道当前的节点el,当前节点的key值,之前的值和新值这四个参数。
对比新老节点,肯定比较它们样式、类、事件、属性、v-html、innerHTML等情况,再根据不同的情况进行不同的比较。
我们可以这样写patchProp
文件:
import { isOn } from "@vue/shared"
import { patchAttr } from "../modules/attr"
import { patchClass } from "../modules/class"
import { patchEvent } from "../modules/event"
import { patchStyle } from "../modules/style"
// 比对属性 diff算法 属性比对前后值
export const patchProp = (el,
key,
prevValue,
nextValue,
isSVG = false,
prevChildren,
parentComponent,
parentSuspense,
unmountChildren) => {
if (key === 'class') {
patchClass(el, nextValue, isSVG)
} else if (key === 'style') {
// 样式
patchStyle(el, prevValue, nextValue)
} else if (isOn(key)) { // 点击事件
// 比对事件
patchEvent(el, key, nextValue)
} else {
// 其他属性
patchAttr(el, key, nextValue)
}
}
在此,我将isOn
判断是否是事件的正则方法,提取到共用函数shared中,逻辑是这样的:
const onRE = /^on[^a-z]/
export const isOn = (key: string) => onRE.test(key)
我将常见的样式、类、事件、属性这四种情况详细阐述一下:
patchClass
patchClass:class 类的节点对比。
简化版本,我们对比样式可以先判断有无新的类。如果新的类存在,我们就需要通过className新增类;没有的话,就是类名值为空,直接删除。
export function patchClass(el: Element, value: string | null, isSVG: boolean) {
if (value == null) {
// 新的没有值
el.removeAttribute('class')
} else {
// 新的有值
el.className = value
}
}
patchStyle
patchStyle:style 样式对比。
在进行样式比较的时候,我们需要先获取当前的节点,在节点的样式进行增改删的操作。需要考虑四种情况:新的有老的没有、新的有老的有、老的有新的没有、老的有新的有。
我们可以归类一下: 情况一:新的有,就新增! 遍历新的值,给当前节点添加上新的style。
情况二:新的没有,老的有,删除!遍历老的值,如果老的值不存在新的上面就需要将其置空。
export function patchStyle(el: Element, prev: any, next: any) {
const style = (el as HTMLElement).style
// 新的有 要全部加上
if (next) {
for (const key in next) {
style[key] = next[key]
}
}
// 新的没有值,老的有值,移除老的
if (prev) {
// 遍历老的节点
for (const key in prev) {
// 新的没有 去除老的
if (next[key] === null) {
style[key] = null
}
}
}
}
在此,我们只是简单的实现的样式的更新对比,源码中还判断了行内样式display、important等情况。有兴趣的可以去vue3中的packages/runtime-dom/src/modules/style.ts
文件debugger玩玩。
patchEvent
patchEvent:event 事件对比。
事件的对比肯能会有点绕,但是不难理解。
初始化
我们在当前节点上添加一个自定义_vei
属性,标识vue内部用于记录绑定的事件;在我们需要绑定事件的时候,源码中是通过一个对象来存储事件,事件的绑定更换通过更改对象内的value
值。
function createInvoker(initialValue: EventValue) {
const invoker = (e: Event) => {
// 事件
invoker.value(e)
}
// 创建一个invoker createInvoker将事件存储到变量上 后续更改只需要更改invoker对象内部的value
invoker.value = initialValue
return invoker
}
这样的话,我们在事件对比的时候,就知道我们需要的一些必要参数,比如:当前的节点el、键值key、老的事件值prevValue
、新的事件值nextValue
。
export function patchEvent(el: Element & { _vei?: Record<string, Invoker | undefined> }, rawName: string,// 事件名 onXXX
nextValue: EventValue) {
const name = rawName.slice(2).toLowerCase()// 事件名称
// vei = vue event invokers
// 在元素上绑定一个自定义属性 用于记录绑定的事件
const invokers: any = el._vei || (el._vei = {})
// 存在的绑定事件
const existingInvoker = invokers[rawName] // 键名是key 是否已经绑定过事件
//事件对比
}
这边的rawName
就是传递过来的事件名称的key(比如:onclick),我们需要获取具体事件名,就需要进行一下处理。我们这只是简单截取了on后面的事件名,而源码中使用的是parseName
这个功能函数处理了事件,考虑了事件的修饰符情况。
开始对比:
新老事件需要对比、之前有无绑定过事件两大类,一共四种情形需要我们去考虑。
-
有新值
nextValue
1.1)绑定过事件 ==> 换绑 1.2)没有绑定过 ==> 新增绑定事件 -
没有新值
nextValue
2.1) 有绑定过 ==> 解绑 2.2) 没有绑定过 ==> 无操作
if (nextValue) {
if (existingInvoker) {
// 1.1 换绑
existingInvoker.value = nextValue
} else {
// 1.2 新增绑定事件 用一个对象存储 每次修改对象内部的value值
// invoker是一个事件 onClick = e => {}
const invoker = invokers[rawName] = createInvoker(nextValue)
el.addEventListener(name, invoker)
}
} else {
// 2.1 remove 解绑
removeEventListener(name, existingInvoker)
invokers[rawName] = undefined
}
第二类没有新值的第二种没有绑定过的情况,没有绑定过我们就可以不去操作,因此可以忽略。
源码中的事件对比是先判断有新值且绑定过事件,再判断有新值(新增事件)和没新值(解绑)的情况。
patchAttr
patchAttr:attr 属性对比。
用接收到的新值value去判断,如果有新属性存在,那么就新增节点的属性;否则,就删除原来的老属性。
export function patchAttr(el: Element, key: string, value: any) {
if (value) {
el.setAttribute(key, value)
} else {
el.removeAttribute(key)
}
}
合成 rendererOptions
将节点操作和对比属性的功能函数合并,就组成了渲染时所需要的rendererOptions
。
import { extend } from '@vue/shared'
const rendererOptions = extend({ patchProp }, nodeOps)
共用功能函数文件 @vue/shared:
export const extend = Object.assign // 属性合并
最后,将这些API传入到runtime-core中。那么,runtime-core中又做了什么呢?咱们下篇再见分晓!!!🤡🤡🤡
转载自:https://juejin.cn/post/7167998066841092127