在 Vue 中,是通过虚拟 DOM 来创建真实 DOM 的。产生原因就是通过平台配置完成渲染。浏览器平台对应的模块就是 runtime-dom。
官方描述: 👀
import { createRenderer } from '@vue/runtime-core'
const { render, createApp } = createRenderer({
patchProp,
insert,
remove,
createElement
// ...
})
// `render` 是底层 API
// `createApp` 返回一个应用实例
export { createApp, render }
// 重新导出 Vue 的核心 API
export * from '@vue/runtime-core'
在 runtime-dom 中大概分为两部分
nodeOps:封装创建、插入、删除、查询等 DOM 节点操作patchProp:处理元素属性更新,像class、style、事件和普通属性
createRenderer 接收的 options,就是浏览器平台提供渲染逻辑的能力。
渲染器内部只关心“我要创建一个元素”“我要插入一个节点”“我要更新一个属性”,但具体怎么操作 DOM,由 runtime-dom 传进去。
这样做之后,核心渲染逻辑就可以和平台解耦。浏览器里可以渲染 DOM,其他平台也可以提供自己的节点操作。
nodeOps
nodeOps 负责把常用的原生 DOM API 包一层,提供给渲染器使用。
// packages/runtime-dom/src/nodeOps.ts
export const nodeOps = {
insert(el, parent, anchor) {
parent.insertBefore(el, anchor || null)
},
createElement(type) {
return document.createElement(type)
},
remove(el) {
const parentNode = el.parentNode
if (parentNode) {
parentNode.removeChild(el)
}
},
setElementText(el, text) {
el.textContent = text
},
createText(text) {
return document.createTextNode(text)
},
setText(node, text) {
node.nodeValue = text
},
parentNode(el) {
return el.parentNode
},
nextSibling(el) {
return el.nextSibling
},
querySelector(selector) {
return document.querySelector(selector)
}
}
patchProp
patchProp 负责 props 更新,一个元素的 props 里可能出现很多类型:
classstyleonClick事件id、title、data-*属性
不同类型的属性,更新方式并不一样。所以 patchProp 要做一次分发:
import { isOn } from '@vue/shared'
import { patchAttr } from './modules/attr'
import { patchClass } from './modules/class'
import { patchEvent } from './modules/events'
import { patchStyle } from './modules/style'
export function patchProp(el, key, prevValue, nextValue) {
if (key === 'class') {
return patchClass(el, nextValue)
}
if (key === 'style') {
return patchStyle(el, prevValue, nextValue)
}
if (isOn(key)) {
return patchEvent(el, key, nextValue)
}
patchAttr(el, key, nextValue)
}
更新 class
const vnode = h('div', { class: 'container' }, 'hello world')
const vnode2 = h('div', {}, 'hello world')
renderer.render(vnode, app)
setTimeout(() => {
renderer.render(vnode2, app)
}, 2000)
第一次渲染时,元素上有 class="container"。
第二次渲染时,新的 vnode 没有传 class,所以旧的 class 应该被移除。
export function patchClass(el, value) {
if (value == null) {
el.removeAttribute('class')
}
else {
el.className = value
}
}
更新 style
const vnode = h('div', { style: { color: 'red' } }, 'hello world')
const vnode2 = h(
'div',
{ style: { background: 'green', color: 'pink' } },
'hello world'
)
renderer.render(vnode, app)
setTimeout(() => {
renderer.render(vnode2, app)
}, 2000)
style 不能只把新值写进去,还要处理旧样式的删除。
第二次渲染时:
color从red更新为pink- 新增
background: green - 如果旧样式里有新样式不存在的字段,删除
export function patchStyle(el, prevValue, nextValue) {
const style = el.style
// 先把新的样式全部设置到元素上。
if (nextValue) {
for (const key in nextValue) {
style[key] = nextValue[key]
}
}
// 遍历旧样式。如果某个旧字段在新样式里不存在,就把它清空
if (prevValue) {
for (const key in prevValue) {
if (!nextValue || !(key in nextValue)) {
style[key] = null
}
}
}
}
更新事件
事件的属性名一般是 onClick、onInput 这种形式。
模板里的 @click,最终会被编译成类似 onClick 的属性,所以可以通过正则识别事件:
export const isOn = key => /^on[A-Z]/.test(key)
const vnode = h('div', {
onClick() {
console.log('click')
}
}, 'hello world')
const vnode2 = h('div', {
onClick() {
console.log('update')
}
}, 'hello world')
renderer.render(vnode, app)
setTimeout(() => {
renderer.render(vnode2, app)
}, 2000)
如果每次更新都先 removeEventListener,再 addEventListener,也能工作。
Vue 用一个 invoker 包一层事件函数。事件真正绑定到 DOM 上的是 invoker,后续更新时只替换 invoker.value。
const veiKey: unique symbol = Symbol('_vei')
export function patchEvent(el, rawName, nextValue) {
const name = rawName.slice(2).toLowerCase()
const invokers = el[veiKey] || (el[veiKey] = {})
const existingInvoker = invokers[rawName]
if (nextValue) {
if (existingInvoker) {
existingInvoker.value = nextValue
}
else {
const invoker = createInvoker(nextValue)
invokers[rawName] = invoker
el.addEventListener(name, invoker)
}
}
else if (existingInvoker) {
el.removeEventListener(name, existingInvoker)
invokers[rawName] = undefined
}
}
function createInvoker(value) {
const invoker = (e) => {
invoker.value(e)
}
invoker.value = value
return invoker
}
- 之前有事件,现在也有事件:只更新
existingInvoker.value - 之前没有事件,现在有事件:创建
invoker并绑定到 DOM - 之前有事件,现在没有事件:移除 DOM 事件监听
更新普通属性
剩下的属性走 patchAttr。
比如 id、title、data-* 这类属性,都可以通过 setAttribute 和 removeAttribute 处理。
逻辑和 class 类似:
- 新值存在,就设置属性
- 新值是
null或undefined,就删除属性
export function patchAttr(el, key, value) {
if (value == null) {
el.removeAttribute(key)
}
else {
el.setAttribute(key, value)
}
}
组合 renderOptions
最后把节点操作和属性更新能力合并起来:
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'
export const renderOptions = {
patchProp,
...nodeOps
}
这样核心渲染器拿到 renderOptions 后,就可以完成浏览器 DOM 的创建、插入、删除、文本更新和属性更新。
这一层做完之后,渲染器不再依赖具体的 DOM API。只需要调用传入的配置项,就能把虚拟 DOM 渲染到真实页面上。