前言
现有框架几乎都引入了虚拟 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. 获取容器 elementif (container.nodeType !== 1) {container = document.querySelector(container)}// 2. vnode ——> elementconst { type, props, children } = vnodeconst el = document.createElement(type)vnode.el = el// 3. 处理 propsif (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. 处理 childrenif (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.elparent.removeChild(n1.el)// 渲染 newVnode.elmount(n2, parent)} else {// 2. 类型一致// 2.1 统一 el 对象,因为最终修改的是 oldVnode.el,因此,使用 n1.el 作为最终值const el = n2.el = n1.el// 2.2 处理 propsconst oldProps = n1.propsconst 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)}}}// 删除旧的 propsfor (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 处理 childrenconst oldChildren = n1.childrenconst 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 = nullfunction watchEffect(effect) {activeEffect = effecteffect() // 目的:初始化调用 + 依赖收集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 = nullfunction watchEffect(effect) {activeEffect = effecteffect() // 目的:初始化调用 + 依赖收集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 = falselet oldVnode = nulllet newVnode = nullwatchEffect(() => {if (!isMounted) {isMounted = trueoldVnode = 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,当然很多场景还是没有进行一一处理,现在只能实现最简单的测试案例.