从认识 VNode VDOM 到实现 mini-vue
前言
现有框架几乎都引入了虚拟 DOM 来对真实 DOM 进行抽象,也就是现在大家所熟知的 VNode 和 VDOM,那么为什么需要引入虚拟 DOM 呢?下面就一起来了解下吧!!!
VNode & VDOM
VNode 和 VDOM 是什么?
直接看 vue3 中关于 VNode 部分的源码,文件位置:packages\runtime-core\src\vnode.ts
通过源码部分,可以很明显的看到 VNode
本身就是一个 JavaScript
对象,只不过它是通过不同的属性去描述一个真实 dom
.
VDOM
其实就是多个 VNode
组成的树结构,这就好比 HTML
元素和 DOM
树之间的关系:多个 HTML
元素能够组成树形结构就称之为 DOM
树.
function _createVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag: number = 0,
dynamicProps: string[] | null = null,
isBlockNode = false
): VNode {
...
return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
isBlockNode,
true
)
}
function createBaseVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag = 0,
dynamicProps: string[] | null = null,
shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
isBlockNode = false,
needFullChildrenNormalization = false
) {
const vnode = {
__v_isVNode: true,
__v_skip: true,
type,
props,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
scopeId: currentScopeId,
slotScopeIds: null,
children,
component: null,
suspense: null,
ssContent: null,
ssFallback: null,
dirs: null,
transition: null,
el: null,
anchor: null,
target: null,
targetAnchor: null,
staticCount: 0,
shapeFlag,
patchFlag,
dynamicProps,
dynamicChildren: null,
appContext: null
} as VNode
...
return vnode
}
为什么要使用 VDOM ?
既然要使用肯定是因为 虚拟 DOM 拥有一些 真实 DOM 没有的优势:
- 对真实元素节点抽象成 VNode,减少直接操作 dom 时的性能问题
- 直接操作 dom 是有限制的,比如:diff、clone 等操作,一个真实元素上有许多的内容,如果直接对其进行 diff 操作,会去额外 diff 一些没有必要的内容;同样的,如果需要进行 clone 那么需要将其全部内容进行复制,这也是没必要的。但是,如果将这些操作转移到 JavaScript 对象上,那么就会变得简单了。
- 直接操作 dom 容易引起页面的重绘和回流,但是通过 VNode 进行中间处理,可以避免一些不要的重绘和回流
- 方便实现跨平台
- 同一 VNode 节点可以渲染成不同平台上的对应的内容,比如:渲染在浏览器是 dom 元素节点,渲染在 Native( iOS、Android) 变为对应的控件、可以实现 SSR 、渲染到 WebGL 中等等
- 而且 Vue 允许开发者基于 VNode 实现自定义渲染器(renderer),以便于针对不同平台上的渲染
虚拟 DOM 的渲染过程
Vue 三大核心系统
Vue 中的三大核心系统如下:
- Compiler 模块:涉及 AST 抽象语法树的内容,再通过 generate 将 AST 生成渲染函数,这里暂不实现
- Runtime 模块:也可称为 Renderer 模块,将虚拟 dom 生成真实 dom 元素,并渲染到浏览器上
- Reactivity 模块:响应式系统
三大系统的关系
实现 Runtime 模块
下面的实现部分只实现最简单、最核心的内容,不涉及各种复杂的边界条件.
createVNode & h
VNode 主要作用就是将外部传入的各种参数组合成一个 JavaScript 对象.
其中 createVNode
就是用于创建 VNode
,而 h
函数(render function)负责将创建好的 VNode
进行返回.
function createVNode(type, props, children) {
// vnode ——> js 对象
return {
type,
props,
children
}
}
function h(type, props, children) {
return createVNode(type, props, children)
}
mount
得到 VNode
之后,接下来就需要将 VNode
变成真实的 dom
元素,并渲染到浏览器上.
-
通过
document.createElement
方法将VNode
变成dom
元素 -
处理传入的
props
对象- 以
on
开头的默认为事件,通过addEventListener
为dom
元素注册事件 - 其他属性默认为
dom
上的属性,通过setAttribute
为dom
元素设置属性
- 以
-
处理
children
,只考虑children
为String
和Array
的情况children
为String
默认为是文本节点,通过textContent
属性进行设置children
为Array
默认为是多个VNode
集合,通过递归调用mount
方法进行挂载
function mount(vnode, container) {
// 1. 获取容器 element
if (container.nodeType !== 1) {
container = document.querySelector(container)
}
// 2. vnode ——> element
const { type, props, children } = vnode
const el = document.createElement(type)
vnode.el = el
// 3. 处理 props
if (props) {
for (const key in props) {
// 事件
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), props[key]);
} else {
// 属性
el.setAttribute(key, props[key])
}
}
}
// 4. 处理 children
if (typeof children === 'string') {
el.textContent = children
} else {
children.forEach(v => {
mount(v, el)
});
}
// 5. 挂载到容器中
container.appendChild(el)
}
patch
实现了能够将 VNode 渲染为真实 DOM 之后,就需要考虑更新时 VNode 间的 diff 比较了,这就属于 patch 的过程.
-
新旧 VNode 类型不一致,先删除旧节点,用新的替换旧的
-
新旧 VNode 类型一致
-
更新 props:更新 dom 属性 & 更新 dom 事件
- 新旧属性或事件存在且不一致,直接更新
- 新属性存在 & 旧属性不存在,直接添加
- 新属性都存在 & 新旧值不一致,直接删除
-
更新 children
- 新 children 是字符串,只要和旧的 children 不相等,直接使用 innerHTML 替换旧的内容
- 新 children 是数组 & 旧 children 是字符串,先清空旧节点的内容,循环调用 mount 新增元素
- 新旧 children 都是数组,取新旧 children 中最小长度,用于减少循环 patch 次数,若 oldLength < newLength 需要通过 mount 新增元素, 若 oldLength > newLength 需要通过 el.removeChild 删除多余旧元素
-
/**
*
* @param {oldVnode} n1
* @param {newVnode} n2
*/
function patch(n1, n2) {
// 1. 类型不一致
if (n1.type !== n2.type) {
const parent = n1.el.parentElement
// 删除 oldVnode.el
parent.removeChild(n1.el)
// 渲染 newVnode.el
mount(n2, parent)
} else {
// 2. 类型一致
// 2.1 统一 el 对象,因为最终修改的是 oldVnode.el,因此,使用 n1.el 作为最终值
const el = n2.el = n1.el
// 2.2 处理 props
const oldProps = n1.props
const newProps = n2.props
// 处理 props 不一致
for (const key in newProps) {
const newValue = newProps[key]
const oldValue = oldProps[key]
// 旧的有值,新的没值,移除该属性
if (newValue !== oldValue) {
// 事件不一致
if (key.startsWith('on')) {
el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key])
el.addEventListener(key.slice(2).toLowerCase(), newProps[key])
} else {
// props 值不一致
el.setAttribute(key, newValue)
}
}
}
// 删除旧的 props
for (const key in oldProps) {
if (!(key in newProps)) {
// 旧事件不存在 newProps 中
if (key.startsWith('on')) {
el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key])
} else {
// oldProps 中的属性不存在 newProps 中
el.removeAttribute(key)
}
}
}
// 2.3 处理 children
const oldChildren = n1.children
const newChildren = n2.children
// 新的子节点是字符串
if (typeof newChildren === 'string') {
// 新旧子节点不一致,直接使用新节点进行替换旧节点
if (newChildren !== oldChildren) el.innerHTML = newChildren
} else {
// 新的子节点为数组
// 旧的子节点为字符串
if (typeof oldChildren === 'string') {
el.innerHTML = ''
newChildren.forEach(v => {
mount(v, el)
})
} else {
// 旧的子节点也为数组
// 取最小的长度进行最少的循环
let commonLength = Math.min(newChildren.length, oldChildren.length)
for (let i = 0; i < commonLength; i++) {
// 递归调用 patch 新老节点
patch(oldChildren[i], newChildren[i])
}
// 循环结束:oldLength < newLength || oldLength > newLength
// oldLength < newLength,需要添加新节点
if(oldChildren.length < newChildren.length){
newChildren.slice(oldChildren.length).forEach(v => {
mount(v, el)
})
}
// oldLength > newLength,需要删除旧节点
if(oldChildren.length > newChildren.length){
oldChildren.slice(newChildren.length).forEach(v => {
el.removeChild(v.el)
})
}
}
}
}
}
实现 Reactivity 模块
基于 Object.defineProperty 实现响应式
Object.defineProperty 的优点:
- 兼容性好,可以兼容到 IE9
Object.defineProperty 的不足: - 不能劫持对象
property
的 添加 或 移除 - 不能劫持数组变化
- 通过数组下标修改数组项
- 修改数组长度
class Dep {
constructor() {
this.subscribers = new Set()
}
depend() {
if (activeEffect) {
this.subscribers.add(activeEffect)
}
}
notify() {
this.subscribers.forEach(effect => {
effect()
})
}
}
// 当前正在执行 effect 函数
let activeEffect = null
function watchEffect(effect) {
activeEffect = effect
effect() // 目的:初始化调用 + 依赖收集
activeEffect = null
}
// 存储依赖副作用
const targetMap = new WeakMap()
// 获取当前的对象的依赖 dep 对象
function getDep(target, key) {
// 1. 根据传入的 target 获取对应的 Map 对象
let depsMap = targetMap.get(target)
// 2. 若 depsMap 不存在,则初始化一个 Map 对象
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 3. 获取具体的 dep 对象
let dep = depsMap.get(key)
// 4. 若 dep 不存在,则实例化一个 Dep 对象
if (!dep) {
dep = new Dep()
depsMap.set(key, dep)
}
// 5. 返回 dep 实例
return dep
}
// 数据劫持
function reactive(raw) {
Object.keys(raw).forEach(key => {
const dep = getDep(raw, key)
let value = raw[key]
Object.defineProperty(raw, key, {
enumerable: true,
configurable: true,
get() {
// 依赖收集
dep.depend()
return value
},
set(newValue) {
if (value !== newValue) {
value = newValue
// 通知依赖更新
dep.notify()
}
return true
}
})
})
return raw
}
基于 Proxy 实现响应式
Proxy 的优点:
Proxy
能监测的类型比Object.defineProperty
更丰富的类型- 能监测对象和数组的变化
has
:in 操作符的捕获器deleteProperty
:delete 操作符的捕获器- …
Proxy
作为新标准,将受到浏览器厂商重点持续性的优化
Proxy 的缺点:
- 不兼容 IE,没有对应的 polyfill
class Dep {
constructor() {
this.subscribers = new Set()
}
depend() {
if (activeEffect) {
this.subscribers.add(activeEffect)
}
}
notify() {
this.subscribers.forEach(effect => {
effect()
})
}
}
// 当前正在执行 effect 函数
let activeEffect = null
function watchEffect(effect) {
activeEffect = effect
effect() // 目的:初始化调用 + 依赖收集
activeEffect = null
}
// 存储依赖副作用
const targetMap = new WeakMap()
// 获取当前的对象的依赖 dep 对象
function getDep(target, key) {
// 1. 根据传入的 target 获取对应的 Map 对象
let depsMap = targetMap.get(target)
// 2. 若 depsMap 不存在,则初始化一个 Map 对象
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 3. 获取具体的 dep 对象
let dep = depsMap.get(key)
// 4. 若 dep 不存在,则实例化一个 Dep 对象
if (!dep) {
dep = new Dep()
depsMap.set(key, dep)
}
// 5. 返回 dep 实例
return dep
}
// 数据劫持
function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const dep = getDep(target, key)
dep.depend()
return Reflect.get(target, key)
},
set(target, key, newValue) {
const dep = getDep(target, key)
const result = Reflect.set(target, key, newValue)
dep.notify()
return result
},
})
}
createApp() —— Runtime 模块 + Reactivity 模块
如果你对 Vue3 中 createApp 的使用比较熟练或者阅读过相关源码,其实不难发现 createApp 其实会返回一个带有 mount 方法的 JavaScript 对象.
在下面的 mount 方法中针对 VNode 的 mount(挂载) 和 patch(更新) 进行了判断,以便于在响应式数据发生变更时渲染不同的内容.
function createApp(rootComponent) {
return {
mount(selector) {
let isMounted = false
let oldVnode = null
let newVnode = null
watchEffect(() => {
if (!isMounted) {
isMounted = true
oldVnode = rootComponent.render()
mount(oldVnode, selector)
} else {
newVnode = rootComponent.render()
patch(oldVnode, newVnode)
oldVnode = newVnode
}
})
}
}
}
下面是一个简单的计数器案例的实现:
测试代码如下
<div id="app"></div>
<script src="./js/renderer.js"></script>
<script src="./js/reactive.js"></script>
<script src="./js/createApp.js"></script>
<script>
const App = {
data: reactive({
count: 0
}),
render() {
return h('div', null, [
h('h1', null, `当前计数:${this.data.count}`),
h('button', {
onClick: () => this.data.count++
}, '+1')
])
}
}
const app = createApp(App)
app.mount('#app')
</script>
最后
通过对 VNode 、VDOM 以及 Vue 三大核心系统的认识和实现,最终又通过 createApp 将这些内容串联在一起,可以说是实现了一个小版本的 vue,当然很多场景还是没有进行一一处理,现在只能实现最简单的测试案例.