什么是虚拟DOM
简单说就是用js对象来模拟DOM结构
<template>
<div id="app" class="container">
<h1>asherchen</h1>
</div>
</template>
在vue中,在template中所有的内容都会通过编译器转换为虚拟DOM结构
{
tag:'div',
props:{ id:'app', class:'container' },
children: [
{ tag: 'h1', children:'asherchen' }
]
}
- tag用来描述标签名称,tag:'div’就是描述了一个 < div > 标签
- props用来描述标签的属性,事件等内容
- children用来描述标签的子节点
为什么需要虚拟dom
命令式编程与声明式编程对比
从编程范式来看,目前视图层框架通常分为命令式和声明式
命令式编程:更强调做事的过程
声明式编程:更强调做事的结果
//命令式编程
const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => { alert('ok') })
//类似:
//弟弟去拿个杯子
//弟弟去帮我接点水
//弟弟去帮我拿一个吸管
//弟弟帮我把吸管插上
//声明式编程
<div @click ="() => alert('ok')">hello world</div>
//类似
//弟弟我要一杯带吸管的水
可以看出声明式编程对开发人员更友好
BUT:我们先抛出一个结论:声明式的代码不优于命令式代码性能
如何让声明式编程性能更极致呢
对于框架,最优的更新性能需要找到前后差异并只更新变化的地方
当我们把直接修改的性能消耗定义为A,把性能消耗差距定义为B。
命令式更新性能消耗:A
声明式更新性能消耗:A+B
框架设计者要做的就是:在保持可维护性的前提下,让性能损失最小化
diff算法的出现就是为了最小化找出差异这一步的性能消耗而出现的。
js更新和dom更新元素比较
我们来看一下当页面渲染很多内容时,通过DOM直接更新和通过js进行更新的区别
//使用DOM进行更新
let box = document.getElementById("box");
console.time('time');
for (let i = 0; i < 10000; i++) {
box.innerHtml = i;
}
console.timeEnd('time')
//使用js进行更新
console.time('time2')
let num = 0;
for(let i = 0;i<10000;i++){
num = i
}
box.innerHTML += num;
console.timeEnd("time2")
处理时间结果
可以得出结论,直接操作DOM更新在不如js操作更新快
innerHTML性能:HTML字符串拼接的计算量+innerHTML的DOM计算量
接下来我们来看虚拟DOM在更新时的性能
虚拟DOM | innerHTML | |
---|---|---|
纯JavaScript运算 | 创建新的JavaScript对象+Diff | 渲染HTML字符串 |
DOM运算 | 必要的DOM更新 | 销毁所有的旧DOM+新建所有的新DOM |
在更新页面时,虚拟DOM在js层面的运算要比创建页面多处一个diff的性能消耗,但是是属于js层面的运算,不会产生数量级的差异,DOM层面时,虚拟DOM只在更新页面时更新必要的元素,但是innerhtml需要全部销毁更新,虚拟DOM的优势就出来了
虚拟DOM如何转换真实DOM
renderer渲染器
假设我们有如下虚拟DOM
const vnode = {
tag/type: 'div',
props: {
onClick: () => alert('hello'),
class:'red'
},
children: 'click me'
}
我们可以编写一个渲染器,把虚拟DOM转换为真实DOM
function renderer(vnode, container){
//根据传入的vnode.tag创建对应DOM元素
const el = document.createElement(vnode.tag)
//遍历vnode.props将属性,事件添加到DOM元素
for(const key in vnode.props){
//如果是事件
if(/^on/.test(key)){
el.addEventListener(
//onClick ---> click
key.substr(2).toLowerCase(),
//处理函数
vnode.props[key]
)else{
el.setAttribute(key, vnode.props[key])
}
}
}
//如果vnode.chlidren是string,说明他是文本子节点
if(typeof vnode.children === 'string'){
//创建子节点并添加vnode.children
el.appendChild(document.createTextNode(vnode.children))
}else if(Array.isArray(vnode.children)){ //如果为true则说明还有嵌套元素,需要递归调用
//递归的调用renderer函数
vnode.children.forEach(child =>renderer(child,el))
}
container.appendChild(el)
}
- vnode:虚拟DOM对象
- container: 一个真实的DOM元素,作为挂载点
调用render函数
renderer(vnode, document.body)
结果
点击事件
diff算法
对于渲染器来说,它需要更精确的找到vnode对象变更点,并且只更新变更的内容,于是diff算法出现了
如果新老节点不是同一个节点名称,暴力删除旧的,创建新的节点
只能同级比较,不能跨层比较
如果要提升性能,一定要加入key, key是唯一标识,
path
通过path来找出不同,避免每次执行渲染器都进行重新渲染
n1为null为了后续挂载的正确执行,我猜是这里需要释放内存,查询资料解释为:
- 如果定义的变量在将来用于保存对象,那么最好将该变量初始化为null,而不是其他值。换句话说,只要意在保存对象的变量还没有真正保存对象,就应该明确地让该变量保存null值,这样有助于进一步区分null和undefined。
- 当一个数据不再需要使用时,我们最好通过将其值设置为null来释放其引用,这个做法叫做解除引用。不过解除一个值的引用并不意味着自动回收改值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器在下次运行时将其回收。解除引用还有助于消除有可能出现的循环引用的情况。这一做法适用于大多数全局变量和全局对象的属性,局部变量会在它们离开执行环境时(函数执行完时)自动被解除引用。
const patch = (n1, n2, container) => {
if (n1 === n2) return;
//判断n1n2描述的内容是否相同,如果不同则删除节点
if (n1 && n1.type !== n2.type) {
unmount(n1);//删除老的
n1 = null
}
//如果n1与n2描述的内容相同
const { type } = n2
if (typeof type === "string") {
if (!n1) {
// 不存在旧数组则直接渲染
mountElement(n2, container)
} else {
//更新
patchElement(n1, n2)
}
} else if (typeof type == 'object') {
//如果是object则说明描述的是组件
} else if (typeof type === 'xxx') {
// 如果是其它类型vnode
}
}
function unmount(vnode){
const parent = vnode.el.parentNode
if(parent){
parent.removeChild(vnode.el)
}
}
更新方式
节点数相同
新子节点多
旧子节点多
function patchChildren(n1,n2,container){
if(typeof n2.children ==='string'){
//省略
}else if(Array.isArray(n2.children)){
const oldChildren =n1.children
const newChildren = n2.children
//旧的节点长度
const oldLen = oldChildren.length
//新的节点长度
const newLen = newChildren.length
//判断哪个节点长度更短
const commonLength = Max.min(oldLen, newLen)
// 遍历commonLength次
for(let i = 0; i<commonLength; i++){
patch(oldChildren[i], newChildren[i], container)
}
if(oldLen>newLen){
//如果oldLen>newLen 说明需要删除节点
for(let i = commonLength; i<oldLen;i++){
unmount(oldChildren[i])
}
}else if(newLen>oldLen){
//如果newLen>oldLen 说明需要挂载节点
for(let i = commonLength; i<newLen; i++){
patch(null, newChildren[i],container)
}
}else{
省略
}
}
}
上面的代码有一个问题,我们无法判断是否可复用
改进方法
加入key值
lastIndex用来存储寻找具有相同key值的节点的过程中,遇到最大的索引值
- 取第一个新子节点p-3,key为3,在旧子节点中寻找key为3的子节点,发现可以找到,并且该子节点在旧的子节点中索引为2,因为2>0,所以不移动DOM,将lastIndex值变为2
- 取第二个新子节点 p-1, key为1,在旧子节点中寻找key为1的子节点,发现可以找到,并且该子节点在旧的子节点中索引为1,因为1<2,所以p-1对应的真实节点需要移动
- 取第二个新子节点 p-2, key为2,在旧子节点中寻找key为2的子节点,发现可以找到,并且该子节点在旧的子节点中索引为0,因为0<2,所以p-2对应的真实节点需要移动。
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
//省略
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVnode = newChildren[i]
let j = 0//?
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
//如果找到了具有相同key的两个节点,说明可以复用,需要用patch进行函数更新
if (newVnode.key === oldVNode.key) {
patch(oldVNode, newVnode, container)
if (j < lastIndex) {
// 说明需要移动newVNode对应的真实DOM
// 先获取newVNode前一个vnode,即prevVNode
const prevVNnode = newChildren[i - 1]
//如果prevVNode不存在说明是第一个节点,他不需要移动
if(prevVNnode){
//我们需要将newVnode对应的真实DOM欲动刀prevVnode所对应的真实DOM后面
//获取preVNode下一个兄弟节点,并将其作为锚点
const anchor = prevVNnode.el.nextSibling
调用insert方法将newVNode对应的真实DOM的后面
insert(newVnode.el, container, anchor)
}
}else{
lastIndex = j
}
break
}
}
}
} else {
//
}
}
insert(el, parent, anchor = null){
parent.insertBefore(el,anchor)
}
但这不是最佳算法,更好的方式是将3拿到最上面去,一次解决问题,具体算法后面讲
添加节点
思路:当节点在旧的子节点遍历一圈没有发现可以复用的节点时,会被看作新增节点,观察节点在新的一组子节点中的位置,并按位置进行挂载
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
//省略
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0;
for (let i = 0; i < newChildren.length; i++) {
const newVnode = newChildren[i]
let j = 0//?
// 在第一层循环中定义变量 find,代表是否在旧的一层子节点中找到可复用节点,
//初始为false 代表没找到
let find = false
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVnode.key === oldVNode.key) {
// 找到可复用节点find变成true
find= true
patch(oldVNode, newVnode, container)
if (j < lastIndex) {
const prevVNnode = newChildren[i - 1]
if (prevVNnode) {
const anchor = prevVNnode.el.nextSibling
insert(newVnode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
//运行到这里说明没有找到可复用节点,需要挂载
if(!find){
//为了将节点挂载到正确位置上,我们需要先获取锚点元素
// 首先获取当前newVNode前一个vnode节点
const prevVNnode = newChildren[i-1]
let anchor = null
if(prevVNnode){
//如果有前一个vnode节点,则使用他的下一个兄弟节点作为锚点元素
anchor = prevVNnode.el.nextSibling
}else {
anchor = container.firstChild
}
patch(null,newVnode,container,anchor)
}
}
} else {
//
}
}
此时path需要接收四个函数
function path(n1, n2, container, anchor) {
if (n1 === n2) return;
//判断n1n2描述的内容是否相同,如果不同则删除节点
if (n1 && n1.type !== n2.type) {
unmount(n1);//删除老的
n1 = null
}
//如果n1与n2描述的内容相同
const { type } = n2
if (typeof type === "string") {
if (!n1) {
mountElement(n2, container, anchor)
} else {
patchElement(n1, n2)
}
} else if (type === "Text") {
//省略
} else if (type === Fragment) {
}
}
function mountElement(vnode, container, anchor){
insert(el,container, anchor)
}
删除节点
思路:当基本的更新结束时,我们需要遍历旧的一组子节点,去新的一组子节点中找到具有相同key值的节点,如果找不到,就说明应当删除节点
function patchChildren(n1, n2, cintainer, anchor) {
if (typeof n2.children === "string") {
//省略
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
//省略更新部分代码
}
//新增删除部分代码
for (let i = 0; i < oldChildren.length; i++) {
const oldVNode = oldChildren[i]
//拿旧子节点oldVNode去新的一组子节点中寻找具有相同key值的节点
const has = newChildren.find(
vnode => vnode.key === oldVNode.key
)
if(!has){
unmount(oldVNode)
}
}
}else{
//省略
}
}
以上就是一个简单diff算法的实现,后续会讲解双端diff算法等知识