虚拟DOM&diff 源码
为什么使用虚拟dom
有效地减少对真实DOM的操作, 虚拟DOM就是目前较为流行的一个解决方案,
虚拟DOM带来了一个重要的优势,那就是我们可以在完全不访问真实DOM的情况下,掌握DOM的结构,这为框架自动优化DOM操作提供了可能。举例来说,如果我们本打算手动进行三次真实DOM操作,而框架在分析了虚拟DOM的结构后,把这三次DOM操作简化成了一次,这不就带来了性能上的提升吗?
虚拟DOM设计的核心就是用高效的 js 操作,来减少低性能的DOM操作,以此来提升网页性能。
通过patch 操作对 真实的dom 进行少次精准的操作
虚拟DOM
节点
虚拟DOM(Virtual DOM) 是对DOM的JS抽象表示,它们是JS对象,能够描述DOM的结构和关系。应用的各种状态变化会体现虚拟DOM上,最终映射到真实DOM。
在渲染虚拟DOM之前,我们要做一些准备工作,通过观察真实DOM和组件我们可以知道:
-
虚拟DOM需要有自己的类型,例如:HTML标签、纯本文、组件(组件又分为class组件和function组件)等,因为我们是实现一个简单的虚拟DOM,所以我们只实现纯文本和HTML标签
-
我们知道真实DOM类似一个树形结构,所以我们需要知道DOM子元素的类型,是单个子元素、多个子元素还是空(纯文本或者为空)
-
同时,虚拟DOM是对真实DOM的描述,那么对于单个元素我们可以简单的概括为:标签、属性(属性包括样式、id、class、点击事件等)
所以我们首先要定义一些常量对上述进行区分
//虚拟DOM的类型
const vnodeType={
HTML:'HTML',//html标签
TEXT:'TEXT',//纯文本
COMPONENT:'COMPONENT',//function组件
CLASS_COMPONENT:'CLASS_COMPONENT',//class组件
}
// 子元素的类型
const childType = {
EMPTY:'EMPTY',//子元素为空(这里单纯指纯文本的情况)
SINGLE:'SINGLE',//单个子元素
MULTIPLE:'MULTIPLE'//存在多个子元素
}
因为我们以后做更新操作的时候需要经常用到vnode对应的真实dom,所以我们定义一个字段用来存储dom。在后续更新操作中,我们需要用到key这个属性,以便新旧dom更新的时候进行diff算法
/**
* 新建虚拟DOM
* @param {string} tag 因为是实现简单的虚拟DOM,所以这个tag是简单的元素标签名
* @param {obj} data 属性
* @param {obj} children 子元素
*/
function createElement(tag, data, children = null) {
//新建虚拟DOM
..........................................
//返回虚拟DOM:vnode
return {
flag,//用来标记当前vnode的类型
tag,//标签:div, 文本(为空),组件(一个函数,暂时不涉及)
data,//数据
children,//子元素
childrenFlag,//children的类型
el:null//存储真实dom,开始默认为null
}
}
/**
* 处理children变为文本类型的vnode
* @param {*} text children元素
*/
function createTextVnode(text) {
return {
flag: vnodeType.TEXT,
tag: null,
key:data&&data.key,
children: text,
childrenFlag: childType.EMPTY,
el: null
}
}
渲染
有结点之后,开始渲染节点
function render(){
//首先我们需要区分首次渲染和再次渲染
mount(vnode,container)
}
/**
* 首次渲染虚拟DOM,挂载
* @param {obj} vnode 虚拟DOM
* @param {*} container 渲染容器
* @param {} flagNode 用来判断时是否进行插入操作
*/
function mount(vnode,container,flagNode){
//首先,根据vnode的flag进行渲染
let {flag} = vnode
//这里根据vnode的类型判断执行方式的挂载
if(flag === vnodeType.HTML){
//如果为html标签
mountElement(vnode,container,flagNode)
}else if(flag ===vnodeType.TEXT){
//如果为text纯文本
mountText(vnode,container)
}
}
/**
* 挂载html类型的虚拟DOM
* @param {obj} vnode 虚拟DOM
* @param {*} container 容器
* @param {} flagNode 用来判断时是否进行插入操作
*/
function mountElement(vnode,container,flagNode){
//html类型的vnode要根据标签名称创建dom
let dom = document.createElement(vnode.tag)
//在初次挂载的时候就将当前vnode对应的真实dom挂载到当前vnode上,以便后面挂载子元素的时候使用
vnode.el =dom
let {data,children,childrenFlag} = vnode
//挂载属性
if (data) {
for (let key in data) {
//挂载data
patchData(vnode.el, key, null, data[key])
}
}
//开始挂载子元素
if(childrenFlag !==childType.EMPTY){
//如果子元素不为空
if(childrenFlag===childType.SINGLE){
//挂载子元素
mount(children,vnode.el)
}else if(childrenFlag===childType.MULTIPLE){
for(let i=0;i<children.length;i++){
mount(children[i],vnode.el)
}
}
}
//挂载dom
flagNode?container.insertBefore(el, flagNode):container.appendChild(dom)
}
/**
* 挂载纯文本类型的虚拟DOM
* @param {odj} vnode 虚拟DOM
* @param {*} container 容器
*/
function mountText(vnode, container) {
//纯文本类型的vnode,子元素就是文本,所以直接执行
let dom = document.createTextNode(vnode.children)
vnode.el = dom
container.appendChild(vnode.el)
}
挂载属性
渲染虚拟DOM的时候我们还需要一个方法来渲染其中的data属性,也就是属性的挂载。挂载属性的时候我们需要对其进行区分,不同的属性进行不同的处理。
/**
* 挂载属性
* @param {*} dom 节点真实dom
* @param {string} key data对应的key
* @param {obj} preData data老值
* @param {obj} newData data新值
*/
function patchData(dom, key, preData, newData) {
//根据不同类型的属性实现不同方式的渲染
switch (key) {
case 'style':
for (let k in newData) {
//挂载style相应的属性
dom.style[k] = newData[k]
}
//patch的时候需要删除某些属性
break;
case 'class':
dom.className = newData
break;
default:
if (key[0] === '@') {
//存在@符号我们认为是点击事件
if (newData) {
dom.addEventListener(key.split(1), newData)
}
} else {
//否则,这里我们用粗暴的方式处理一下
dom.setAttribute(key, newData)
}
break;
}
}
虚拟DOM更新
之前的代码实现了虚拟DOM的初次挂载。但是当我们对虚拟DOM进行更改时需要的是更新操作。同时更新操作也是render过程中比较复杂的部分。
更改渲染方法
function render(vnode, container) {
//首先我们需要区分首次渲染和再次渲染
if(container.vnode){
patch(container.vnode,vnode,container)
}else{
mount(vnode, container)
}
//挂载完毕后将vnode挂载到container中,以此判断,是第一次渲染还是后续更新渲染
container.vnode = vnode
}
更新方法:patch
该函数主要的作用是根据新老vnode中的flag属性区分实现何种更新操作,其中替换操作和更新text操作比较简单
无论是初始化还是更新都是靠patch来完成的
function patch(prev,next,container){
let nextFlag = next.flag
let prevFlag = prev.flag
//根据新老虚拟DOM的类型进行不同的处理
if(nextFlag!==prevFlag){
//如果flag类型不同,我们直接执行替换操作
replaceVnode(prev,next,container);
}else if(nextFlag==vnodeType.HTML){
//更新element
patchElement(prev,next,container)
}else if(nextFlag==vnodeType.TEXT){
//更新text
patchText(prev,next)
}
}
/**
* 更新Text
* @param {ovj} prev 旧的vnode
* @param {*} next 新的vnode
*/
function patchText(prev,next){
let el = (next.el = prev.el)
if(next.children!==prev.children){
//直接更改dom中的text
el.nodeValue = next.children
}
}
/**
* 更新虚拟DOM的替换操作
* @param {obj} prev 旧的vnode
* @param {obj} next 新的vnode
* @param {*} container 容器
*/
function replaceVnode(prev,next,container){
//直接进行替换操作
container.removeChild(prev.el)
mount(next,container)
}
const patchVNode = (oldVNode, newVNode) => {
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
// 元素类型相同,那么旧元素肯定是进行复用的
let el = newVNode.el = oldVNode.el
// 新节点的子节点是文本节点
if (newVNode.text) {
// 移除旧节点的子节点
if (oldVNode.children) {
oldVNode.children.forEach((item) => {
el.removeChild(item.el)
})
}
// 文本内容不相同则更新文本
if (oldVNode.text !== newVNode.text) {
el.textContent = newVNode.text
}
} else {
// ...
}
} else { // 不同使用newNode替换oldNode
// ...
}
}
1、获取子节点和子节点的类型,新节点 n2 和 旧节点 n1 中,一共有三种类型的子节点:文本节点,数组节点,空节点
2、如果 新节点是 文本节点,而 老节点 是数组节点的话,就删除老姐点,这样老节点就是空,然后插入文本节点
3、如果 新节点是 文本节点,老节点 是文本节点,比较新旧节点 文本 是否一致,否就 替换文本内容
4、如果 老节点是数组节点,而新节点也是数组节点的话,进入 patchKeyedChildren ,也就是 diff 的过程
5、如果 老节点是数组节点,而新节点是空节点 的话,进入 删除老节点
6、这样比较下来,剩下的情况就是 : 之前的节点 要么是 文本节点,或者为空,而新的节点 要么是 数组,要么是空
7、所以如果之前的节点是文本节点,删除 老节点的文本内容
8、如果 新节点是数组,就把新节点添加到 dom 树上去
9、最后就剩下新节点是空节点,不做任何操作(在第7点已经把老节点删除了)