渲染模块
在我们开始学习如何编写 Vue 渲染模块代码之前,我们需要先熟悉一个概念 —— Virtual DOM。
Virtual DOM
Virtual DOM(为了方便,后续简写为 VDOM)是指使用 JS 对象去表示真实的 DOM 对象结构和层级关系。VDOM 中的节点称作 VNode。
优势
- 与环境无关 —— 由于使用的是 JS 对象而不是真实的 DOM 对象,所以可以在浏览器之外的环境使用。例如 SSR、canvas/WebGL 以及原生移动端渲染
- 性能更好 —— 通过对 JS 对象进行操作而不是对真实的 DOM 对象进行操作,可以有效减少 DOM API 的调用,减少浏览器重绘、重排的次数
我们的渲染模块就是对 VDOM 进行处理。接下来,我们开始编写代码。
h
首先我们实现 Vue 中用于创建 VNode 的 h 函数。
js
function h(tag, props, children) {
return {
tag,
props,
children,
}
}可以看到该函数很简单,只是把参数放进对象中然后返回。
mount
接下来我们实现 mount 函数,该函数用于将 VDOM 挂载到真实的 DOM 节点上。
首先我们要做的是根据 VNode 的类型创建真实的元素,然后设置元素的 attrs,最后把元素插入到容器中。
js
export function mount(vnode, container) {
const el = document.createElement(vnode.tag)
// props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
el.setAttribute(key, value)
}
}
// children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else {
vnode.children.forEach((e) => {
mount(e, el)
})
}
}
container.appendChild(el)
}patch
最后,我们需要实现更新 DOM 的函数 patch。
patch 函数接受两个参数,分别为旧节点和新节点,然后对这两个节点进行比较,只需更新需要更新的部分。
在实现 patch 之前,我们需要修改一下 mount 函数,让它在创建元素时把元素存储到 VNode 本身,这样我们就可以在 patch 中访问到旧节点的真实 DOM 树。
js
export function mount(vnode, container) {
// const el = document.createElement(vnode.tag)
const el = (vnode.el = document.createElement(vnode.tag))
// props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
el.setAttribute(key, value)
}
}
// children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else {
vnode.children.forEach((e) => {
mount(e, el)
})
}
}
container.appendChild(el)
}js
import { mount } from '../mount/mount2'
export function patch(n1, n2) {
if (n1.tag === n2.tag) {
const el = (n2.el = n1.el)
// props
const oldProps = n1.props || {}
const newProps = n2.props || {}
for (const key in newProps) {
const oldValue = oldProps[key]
const newValue = newProps[key]
if (newValue !== oldValue) {
el.setAttribute(key, newValue)
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
el.removeAttribute(key)
}
}
// children
const oldChildren = n1.children
const newChildren = n2.children
if (typeof newChildren === 'string') {
if (typeof oldChildren === 'string') {
if (newChildren !== oldChildren) {
el.textContent = newChildren
}
} else {
el.textContent = newChildren
}
} else {
if (typeof oldChildren === 'string') {
el.innerHTML = ''
newChildren.forEach((e) => {
mount(e, el)
})
} else {
/**
* 对于数组的比较, Vue 中有两种模式
* 一: 键模式
* 给组件或元素提供一个 key, 该 key 用作节点
* 位置的提示
*
* 二: 不带 key
* 按索引位置比较, 不同就直接替换
*
* 下面只实现第二种, 第一种看源码
*/
const commonLength = Math.min(oldChildren.length, newChildren.length)
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i])
}
if (newChildren.length > oldChildren.length) {
newChildren.slice(oldChildren.length).forEach((e) => {
mount(e, el)
})
} else if (newChildren.length < oldChildren.length) {
oldChildren.slice(newChildren.length).forEach((e) => {
el.removeChild(e.el)
})
}
}
}
} else {
// replace
}
}完整的代码示例
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Renderer Example</title>
<style>
.red {
color: red;
}
.green {
color: green;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>ts
import { h, mount, patch } from './renderer'
const vdom = h('div', { class: 'green' }, [
h('span', undefined, ['Hello ']),
'Vue',
])
mount(vdom, document.getElementById('app')!)
const vdom2 = h('div', { class: 'red' }, [
h('span', undefined, ['Hello ']),
'Vue2222',
])
setTimeout(() => {
patch(vdom, vdom2)
}, 1000)ts
export interface VNode {
// el 的类型与平台无关, 但这里示例方便假定为 Element
el: Element | null
tag: string
props?: Record<string, any>
children?: (VNode | string)[] | string
}
export function h(
tag: string,
props?: Record<string, any>,
children?: (VNode | string)[] | string
): VNode {
return {
el: null,
tag,
props,
children,
}
}
export function mountChildren(
container: Element | string,
children: (VNode | string)[]
) {
const el =
typeof container === 'string'
? document.querySelector(container)!
: container
children.forEach((e) => {
if (typeof e === 'string') {
el.appendChild(document.createTextNode(e))
} else {
mount(e, el)
}
})
}
export type Container = Element | string
export function mount(vnode: VNode, container: Container) {
const el = (vnode.el = document.createElement(vnode.tag))
const root =
typeof container === 'string'
? document.querySelector(container)!
: container
const { props, children } = vnode
if (props) {
/**
* 此处简化代码只用于示例
* 实际还要考虑是设置对象的 props 还是元素的 attrs
* 以及还要考虑 event listener 的处理
*/
for (const key in props) {
if (key.startsWith('on')) {
// event listener
el.addEventListener(key.slice(2).toLowerCase(), props[key])
} else {
// attrs
el.setAttribute(key, props[key])
}
}
}
if (typeof children === 'string') {
el.textContent = children
} else {
mountChildren(el, children || [])
}
root.appendChild(el)
}
export function patch(oldNode: VNode, newNode: VNode) {
if (oldNode.tag === newNode.tag) {
// props
const el = (newNode.el = oldNode.el!)
const { props: oldProps = {}, children: oldChildren } = oldNode
const { props: newProps = {}, children: newChildren } = newNode
/**
* 此处简化代码只用于示例
* 实际上 Compiler 生成的优化过的 Render Fn
* 提供了一些 hints 以优化 props 遍历
*/
for (const key in newProps) {
const oldValue = oldProps[key]
const newValue = newProps[key]
if (newValue !== oldValue) {
el.setAttribute(key, newValue)
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
el.removeAttribute(key)
}
}
// children
if (!newChildren) {
el.innerHTML = ''
} else if (typeof newChildren === 'string') {
if (
(typeof oldChildren === 'string' && newChildren !== oldChildren) ||
Array.isArray(oldChildren)
) {
el.textContent = newChildren
}
} else {
if (!oldChildren) {
mountChildren(el, newChildren)
} else if (typeof oldChildren === 'string') {
el.innerHTML = ''
mountChildren(el, newChildren)
} else {
/**
* 对于数组的比较, Vue 中有两种模式
* 一: 键模式
* 给组件或元素提供一个 key, 该 key 用作节点
* 位置的提示
*
* 二: 不带 key
* 按索引位置比较, 不同就直接替换
*
* 下面只实现第二种, 第一种看源码
*/
const commonLength = Math.min(oldChildren.length, newChildren.length)
for (let i = 0; i < commonLength; i++) {
const oldValue = oldChildren[i]
const newValue = newChildren[i]
if (typeof newValue === 'string') {
if (typeof oldValue === 'string' && newValue === oldValue) continue
el.replaceChild(document.createTextNode(newValue), el.childNodes[i])
} else {
if (typeof oldValue === 'string') {
// replace
} else {
patch(oldValue, newValue)
}
}
}
if (newChildren.length > oldChildren.length) {
mountChildren(el, newChildren.slice(oldChildren.length))
} else if (newChildren.length < oldChildren.length) {
oldChildren
.slice(newChildren.length)
.map((e, i) => el.childNodes[i])
.forEach((e) => {
el.removeChild(e)
})
}
}
}
} else {
// replace
}
}