Appearance
渲染器
渲染器是用来执行渲染任务。vue 渲染器在浏览器平台上,会把虚拟 DOM 渲染为真实 DOM 元素。
基本实现
要渲染一个 <p>张三</p>
,可以使用以下虚拟 DOM 来表示:
js
const vNode = { type: 'p', children: '张三' }
渲染时接收两个参数 vNode
与 container
,将 vNode
渲染成真实 dom
并挂载在容器节点上。
js
function renderer(vNode, container) {
const el = document.createElement(vNode.type)
const text = document.createTextNode(vNode.children)
el.appendChild(text)
container.appendChild(el)
}
renderer(vNode, document.body)
以上代码能通过虚拟 DOM 生成 dom 节点并添加到了 body 下,最终页面展示出 张三
。
但一个基本渲染器需要有挂载、更新和卸载功能,我们优化一下代码,部分代码后续补齐,得出结构如下:
js
function createRenderer() {
// 渲染
function render(vNode, container) {
if (vNode) {
// 新节点存在,则打补丁
patch(container._vNode, vNode, container)
} else {
// 新节点不存在,则卸载
unmountElement(container)
}
// 记录上次渲染虚拟 DOM
container._vNode = vNode
}
// 补丁
function patch(oldV, newV, container) {
if (!oldV) {
// 挂载
mountElement(newV, container)
} else {
// 更新
}
}
// 挂载
function mountElement(vNode, container) {
const el = document.createElement(vNode.type)
const text = document.createTextNode(vNode.children)
el.appendChild(text)
container.appendChild(el)
}
// 卸载
function unmountElement(container) {
// 临时这样操作,以清除子节点
container.innerHTML = ''
}
return { render }
}
const renderer = createRenderer()
挂载子节点
现在我们需要渲染包含子节点的虚拟 DOM,注意看结构,一个虚拟节点可能是字符串或对象,子节点可能是字符串或数组,子节点为数组的时候需要递归的去渲染挂载。
js
const vNode = { type: 'p', children: ['张三', { type: 'span', children: ',你好!' }] }
renderer.render(vNode, document.body)
现在我们更改 mountElement
函数,来递归渲染子节点。
js
// 挂载
function mountElement(vNode, container) {
if (typeof vNode === 'string') {
// 虚拟 DOM 为 字符串
const text = document.createTextNode(vNode)
return container.appendChild(text)
}
const el = document.createElement(vNode.type)
if (typeof vNode.children === 'string') {
// 子节点 为 字符串
const text = document.createTextNode(vNode.children)
el.appendChild(text)
} else if (Array.isArray(vNode.children)) {
// 子节点 是 数组,递归挂载
vNode.children.forEach(child => patch(null, child, el))
}
container.appendChild(el)
}
HTML Attributes 与 DOM Properties
HTML Attributes 指的就是定义在 HTML 标签上的属性。
html
<input id="my-input" type="text" value="foo" />
DOM Properties 就是 dom 对象上的属性。
js
const el = document.createElement('div')
el.id
el.type
el.value
很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties。
id="my-input"
对应el.id
;type="text"
对应el.type
;value="foo"
对应el.value
;
但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的,比如:class="box"
与 el.className
。
实际上,HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。
挂载属性
在了解 HTML Attributes 与 DOM Properties 之后,我们新增一个名为 patchProps
函数处理属性问题,在设置属性的时候优先设置 DOM Properties。
js
function patchProps(el, key, oldV, newV) {
if (key in el) {
// 优先设置 DOM Properties
el[key] = newV
} else {
// 设置 HTML Attributes
el.setAttribute(key, newV)
}
}
这时需要渲染一个被禁用的按钮。
js
const vNode = { type: 'button', props: { disabled: true }, children: '按钮' }
接下来我们只需要在挂载的时候调用该函数就行,在 mountElement
函数中添代码。
JS
function mountElement(vNode, container) {
if (typeof vNode === 'string') {
// 虚拟 DOM 为 字符串
const text = document.createTextNode(vNode)
return container.appendChild(text)
}
const el = document.createElement(vNode.type)
if (vNode.props) {
for (const key in vNode.props ) {
patchProps(el, key, null, vNode.props[key])
}
}
if (typeof vNode.children === 'string') {
// 子节点 为 字符串
const text = document.createTextNode(vNode.children)
el.appendChild(text)
} else if (Array.isArray(vNode.children)) {
// 子节点 是 数组,递归挂载
vNode.children.forEach(child => patch(null, child, el))
}
container.appendChild(el)
}
其中一些属性比较特殊,比如表单元素 form
属性,只能通过 setAttribute
设置,针对这些属性只要在 patchProps
中特殊处理就行。
vue 对 class 和 style 做了增强,可以传入对象、数组、字符串,在设置元素 class 或 style 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 或 style 值去设置。
js
function normalizeClass(clas) {
if (typeof clas === 'string') return clas
if (Object.prototype.toString.call(clas) === '[object Object]') {
let classText = ''
for (const key in clas) {
clas[key] && (classText += key + ' ')
}
return classText.trim()
}
if (Array.isArray(clas)) {
return clas.reduce((pre, cur) => pre + ' ' + normalizeClass(cur), '').trim()
}
}
卸载节点
当需要卸载节点时,只需要在渲染时第一个参数穿 null
即可,以下代码会清空 body 节点。
js
renderer.render(null, document.body)
在之前的代码中,我们操作 innerHTML
来清空子节点,这样做有很多弊端,比如无法清除事件,是嵌套结构的组件,还应该递归的卸载。
修改 mountElement
函数,将创建的节点信息记录在虚拟 DOM 上。
js
function mountElement(vNode, container) {
// ...
const el = document.createElement(vNode.type)
vNode._el = el
// ...
}
修改 unmountElement
函数接收一个虚拟 DOM,获取其对应的真实 DOM,调用原生方法进行卸载,在此函数中,我们可以调用卸载的生命周期函数,清除事件等。
js
function unmountElement(vNode) {
const parentNode = vNode._el.parentNode
parentNode && parentNode.removeChild(vNode._el)
}
在 render 函数中调用 unmountElement 并传入上次渲染虚拟 DOM。
js
function render(vNode, container) {
if (vNode) {
// 新节点存在,则打补丁
patch(container._vNode, vNode, container)
} else {
// 新节点不存在,则卸载
unmountElement(container)
container._vNode && unmountElement(container._vNode)
}
// 记录上次渲染虚拟 DOM
container._vNode = vNode
}
绑定事件
对于事件处理是用 addEventListener
和 removeEventListener
来完成,以下虚拟 DOM 描述了一个按钮和按钮的事件。
js
const vNode = {
type: 'button',
props: { onClick: () => console.log(111) },
children: '按钮'
}
在 patchProps
新增一个处理事件逻辑,并调用 patchEvent
方法。
JS
function patchProps(el, key, oldV, newV) {
// 以 on 开头的属性被认为是事件
if (/^on/.test(key)) {
patchEvent(el, key, oldV, newV)
}
// ...
}
在 patchEvent
函数中对元素事件进绑定和卸载。
js
function patchEvent(el, key, oldV, newV) {
const eventName = key.slice(2).toLowerCase()
oldV && el.removeEventListener(eventName, newV)
el.addEventListener(eventName, newV)
}
以上代码能实现功能,但是不够好,因为频繁的调用 addEventListener
和 removeEventListener
其实会有性能问题。
可以使用代理模式来优化,实际元素只绑定一个事件,在该事件内部处理原来应该绑定在元素的事件。
js
function patchEvent(el, key, oldV, newV) {
// 事件名
const eventName = key.slice(2).toLowerCase()
// 代理事件对象
const proxyEvents = el._proxyEvents || (el._proxyEvents = {})
// 代理事件
let proxyEvent = proxyEvents[key]
if (newV) {
if (proxyEvent) {
// 在这里修改每次更新的事件
proxyEvent.value = newV
} else {
proxyEvent = proxyEvents[key] = function (e) {
// 触发原来事件
proxyEvent.value(e)
}
proxyEvent.value = newV
// 绑定代理事件
el.addEventListener(eventName, proxyEvent)
}
} else if (proxyEvent) {
// 移除代理事件
el.removeEventListener(eventName, proxyEvent)
}
}
当绑定多个事件时 onClick
为数组,只需要在执行时判断一下即可。
JS
function patchEvent(el, key, oldV, newV) {
// ...
proxyEvent = proxyEvents[key] = function (e) {
// 触发原来事件
proxyEvent.value(e)
if (Array.isArray(proxyEvent.value)) {
proxyEvent.value.forEach(fn => fn(e))
} else {
proxyEvent.value(e)
}
}
// ...
}
更新节点
在更新时需要确认新旧虚拟 DOM 类型是否一致,不一致直接卸载旧 DOM,否则进行打补丁,节点补丁操作放在 patchElement
中处理。
JS
function patch(oldV, newV, container) {
if (oldV && oldV.type !== newV.type) {
// 当旧节点 与 新节点 类型不一致时,直接卸载旧节点
unmountElement(oldV)
oldV = null
}
if (!oldV) {
// 挂载
mountElement(newV, container)
} else {
// 更新
patchElement(oldV, newV)
}
}
patchElement
中第一步更新属性 props
,第二步更新子节点 children
,更新子节点放在 pathChildren
中处理。
js
function patchElement(oldV, newV) {
const el = (newV._el = oldV._el)
const oldProps = oldV.props
const newProps = newV.props
// 第一步:更新 props
// 更新属性
for (const key in newProps) {
if (oldProps[key] === newProps[key]) continue
patchProps(el, key, oldV.props[key], newV.props[key])
}
// 删除上次存在本次不存在的属性
for (const key in oldProps) {
if (key in newProps) continue
patchProps(el, key, oldV.props[key], null)
}
// 第二步:更新 children
patchChildren(oldV, newV, el)
}
一个虚拟 DOM 的子节点可能没有,或者是一个子节点,或者是一组节点,新旧节点三种情况进行对比来更新节点。其中二者都为一组节点的情况下会进行 diff
操作,有关 diff
另作说明,这里先用最基础的办法,旧节点全部卸载后挂载新节点。
js
function patchChildren(oldV, newV, container) {
if (typeof newV.children === 'string') {
/* 新节点 为 字符串 */
if (Array.isArray(oldV.children)) {
// 循环卸载旧节点
oldV.children.forEach(i => unmountElement(i))
}
container.textContent = newV.children
} else if (Array.isArray(newV.children)) {
/* 新节点 为 数组 */
if (Array.isArray(oldV.children)) {
// 这里应该是核心 diff,暂时全部卸载再挂载
oldV.children.forEach(i => unmountElement(i))
newV.children.forEach(i => patch(null, i, container))
} else {
container.textContent = ''
newV.children.forEach(i => patch(null, i, container))
}
} else {
/* 新节点 为 不存在 */
if (Array.isArray(oldV.children)) {
// 循环卸载旧节点
oldV.children.forEach(i => unmountElement(i))
} else if (typeof oldV.children === 'string') {
container.textContent = ''
}
}
}
与响应式系统结合
利用响应式系统,在副作用函数中调用渲染器渲染页面,数据发生变化时会自动重新渲染。vue 中 render 函数就是这样的。
js
const obj = reactive({ name: '张三' })
effect(() => {
renderer.render({ tag: 'p', children: obj.name }, document.body)
})
提示
事件属于动态绑定时,会有冒泡问题,需要特殊处理,这里暂时留个坑。