本章节咱们的主题是update和diff,这一章节可能理论部分会比较多。在开始这一块内容前,我以为有必要先大体看一下Vue和React实现这一部分的流程的:update->diff->patch->updateDOM。在开始更新后,会进行diff算法的比对,比对后会生成一个patch补丁包,而后再根据这个补丁包进行DOM的更新。补丁包中会经过id(或者序号)之类的标识来标识真实DOM的位置,定位到位置后,再经过修改的类型(如:新增节点、删除节点、修改节点等),来对不一样的状况进行DOM更新。vue
何为diff?diff即为比较两颗VNode树之间的差别,对于我目前的开发阶段而言,实际上就是比较更新前和更新后的VNode(你也能够直接拿更新前的DOM和更新后的VNode比较),转化到数据结构来讲,其实就是对两颗树的比较。而传统的DFS(深度优先遍历)算法,其自己的时间复杂度达到O(n2),由于要将树1中每一个节点都与树2中的每一个节点进行比对。而在咱们当前的场景下,即对比两颗VNode树,咱们除了遍历比较之外,还须要选择出一个最优操做,因此时间复杂度从O(n2)上升到了O(n3),因此一个传统的diff过程,它的时间复杂度为O(n3)。node
面对如此高的复杂度,diff算法应运而生。了解过react和vue的diff算法的人应该都知道,他们的时间复杂度为O(n),他们diff的核心基本能够总结为如下几点:react
经过分析以上四点,咱们能够了解到react和vue的diff算法在DOM层面而言,其实并非最优的,可是它经过增大一部分DOM的开销,来使得时间复杂度大大下降,以一种还算过得去的修改DOM的性能(主要体如今一、2两点),来使时间复杂度达到尽可能低的阶段(O(n),只需一次遍历便可)。git
这里插一点与本文主题无关的内容,由于想到了就写一下。在长列表(很长很长的那种)的初次渲染中,咱们常常会遇到性能优化问题(这也是比较常见的面试题)。一个比较经常使用的解决方案是,使用Node.cloneNode
替代document.createElement
。不知道有没有人和我同样,一开始的时候会认为Node.cloneNode
的效率比document.createElement
的效率更高。本着探索未知领域的原则,我特地进行了测试:github
const app = document.querySelector('#app');
console.time('create');
for(let i = 0; i < 1000000; i++) {
const newEl = document.createElement('div');
app.appendChild(newEl);
}
console.timeEnd('create');
// create: 3148.89501953125ms
复制代码
接下来是对cloneNode的测试:面试
const app = document.querySelector('#app');
console.time('clone');
const newEl = document.createElement('div');
app.appendChild(newEl);
for(let i = 0; i < 1000000 - 1; i++) {
// true表示深拷贝
const newEl_CP = newEl.cloneNode(true);
app.appendChild(newEl_CP);
}
console.timeEnd('clone');
// clone: 2832.31982421875ms
复制代码
能够看到,一样是渲染100万个节点,二者的时间差其实并无很大。看到这里,你们应该都有疑惑,实际上,两种方法的效率并相差不了多少,不是吗?一开始,我也抱有一样的疑惑,后来仔细想一想,是否是思路上出现了问题?既然选择了clone,那我为何要一个一个clone呢?一组一组地clone效率不是更高吗?咱们能够先生成一个1000个子节点的元素,而后再循环clone
1000 - 1次不是效率更高吗?为了验证这一猜测,让咱们用代码说话:算法
const app = document.querySelector('#app');
console.time('newClone');
// fragment是一段文档片断,能够将其子节点加入到DOM树中,且不产生额外的节点
// 用过Vue的<template>标签和React的Fragments会很好理解
let fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
const newEl = document.createElement('div');
fragment.appendChild(newEl);
}
// 这里为何要先拷贝一份?
// 由于fragment中的子元素在插入到DOM树后,能够理解为是一个转移的过程,fragment全部的子元素都被移动到DOM树中,
// 因此当调用app.appendChild(fragment)方法后,fragment就只是一个空的文档片断
const sourceFragment = fragment.cloneNode(true);
app.appendChild(fragment);
for(let i = 0; i < 1000 - 1; i++) {
const newEl_CP = sourceFragment.cloneNode(true);
app.appendChild(newEl_CP);
}
console.timeEnd('newClone');
// newClone: 945.964599609375ms
复制代码
能够看到,咱们使用这种fragment+cloneNode
方式能够极大地下降生产插入DOM的这一过程。我想这才是在应对长列表的渲染中,cloneNode
优于createElement
的真正缘由吧。缓存
我这里实现的diff算法,没有那么复杂,我暂时不考虑对key的状况作相应的处理(即第4点),而且,个人diff算法秉承简单粗暴的原则,直接在diff的过程当中就对DOM进行了修改,因此也没有patch补丁包。性能优化
首先回忆一下我以前对初次渲染的时候,涉及到DOM的这一部分实际上都经过VNode类和Element类来实现。因此咱们的diff核心就是围绕着这两个类执行:经过VNode来比对出哪些须要变更,Element则是对这些变更进行执行,Element充当着一个执行者的角色。理清了这一部分关系后,让咱们回到咱们最初等待解决的问题上,咱们须要一个update方法,以供Watcher在更新时调用:数据结构
xm.$watcher = new Watcher(() => {
xm._callHook.call(xm, 'beforeUpdate');
const newVnodeTree = parseJsxObj(xm.$render());
// update方法返回一个新的vnodeTree,
xm.$vnodeTree = update(xm, newVnodeTree, xm.$vnodeTree);
}, 'render');
复制代码
看一下update方法,很简单,就是调用diff,返回处理后的newVNode
export const update = function(xm, newVNode, oldVNode) {
// 差别比对
diff(xm, newVNode, oldVNode);
// 这里返回的newVNode是在diff过程当中通过处理的,添加了每一个VNode对应的Element
return newVNode;
}
复制代码
接下来就是diff的过程,我的感受如下内容可能不大好理解,请慢慢阅读:
/** * @param { Xue } xm Xue实例,用于在事件相关上绑定this * @param { VNode } newVNode 新的vnodeTree * @param { VNode } oldVNode 旧的vnodeTree * @param { VNode } parentVNode newVNode的父级vnode * @param { VNode } nextBroNode 当前newVNode的下一个兄弟节点,主要用在insertBefore的场景 */
export const diff = function(xm, newVNode, oldVNode, parentVNode, nextBroNode) {
// 定义变化的类型
let diffType = '';
// 旧节点不存在
// 或者旧节点为null,新节点不为null
if(!oldVNode || (oldVNode.tag === null && newVNode.tag !== null)) {
// 有节点新增
diffType = 'addNode';
}
// 新节点不存在
// 或者新节点为null,旧节点不为null
else if(!newVNode || (oldVNode.tag !== null && newVNode.tag === null)) {
// 有节点删除
diffType = 'delNode';
}
// 节点标签不同,直接替换
else if(oldVNode.tag !== newVNode.tag) {
// 替换节点
diffType = 'replaceNode';
}
// 文本节点时,直接用新的文本节点替换旧的文本节点
else if(newVNode.tag === '') {
diffType = 'replaceText';
}
// 比较属性和事件
else {
diffType = 'updateAttrsAndEvents';
}
// 根据diffType调用不一样的处理函数
diffUpdateHandler(diffType, xm, newVNode, oldVNode, parentVNode, nextBroNode);
// 递归处理子节点,这里其实就是同一层级结构比较的过程
// 对于条件渲染的状况,为了正确处理,若是当前条件为falsy(虚值),则必须返回一个null节点
// 让VNode知道这里有一个null的占位空节点,此节点不会被渲染
for(let i = 0; i < newVNode.children.length; i++) {
// 下一个兄弟节点,为了在新增节点时,插入至正确的位置
const nextBroNode = (i === newVNode.children.length - 1) ? null : oldVNode.children[i + 1];
// 缓存旧节点
let oldVNodeParam = oldVNode && oldVNode.children[i];
// 对于新增长的节点或者替换后的节点来讲,它们的子节点在oldVNode中都被认为是不存在的值,子节点都被直接插入至新的节点
// 其实就是对应于不一样节点的子节点都是不一样的
if(diffType === 'addNode') oldVNodeParam = undefined;
// 递归
diff(xm, newVNode.children[i], oldVNodeParam, newVNode, nextBroNode);
}
}
复制代码
接下来再看diffUpdateHandler的逻辑,这一块相对来讲比较简单,就是根据不一样的diffType值调用不一样的处理函数:
export const diffUpdateHandler = function(diffType, xm, newVNode, oldVNode, parentVNode, nextBroNode) {
switch(diffType) {
case 'addNode': diffAddNode(xm, newVNode, oldVNode, parentVNode, nextBroNode); break;
case 'delNode': diffDelNode(xm, newVNode, oldVNode, parentVNode); break;
case 'replaceNode': diffReplaceNode(xm, newVNode, oldVNode, parentVNode); break;
case 'replaceText': diffUpdateText(xm, newVNode, oldVNode, parentVNode); break;
case 'updateAttrsAndEvents': diffAttribute(xm, newVNode, oldVNode); diffEvent(xm, newVNode, oldVNode); break;
default: warn(`error diffType: ${ diffType }`);
}
}
复制代码
在看这几天处理函数以前,先来看一下完善以后的Element类,添加了许多处理更新DOM的方法:
class Element {
constructor(vnode, xm) {
this.xm = xm;
// 若是为null的话,则不作任何处理
if(vnode.tag === null) return;
// 非文本节点
if(vnode.tag !== '') {
this.el = document.createElement(vnode.tag);
// 绑定属性
Object.entries(vnode.attrs).forEach(([key, value]) => {
this.setAttribute(key, value);
});
// 绑定事件
Object.keys(vnode.events).forEach(key => {
// 缓存bind后的函数,用于以后的函数移除
vnode.events[key] = vnode.events[key].bind(xm);
this.addEventListener(key, vnode.events[key]);
});
}
// 文本节点
else this.el = document.createTextNode(vnode.text);
}
// 添加子节点
appendChild(element) {
this.el && element.el && this.el.appendChild(element.el);
}
// 移除子节点
removeChild(element) {
this.el && element.el && this.el.removeChild(element.el);
}
// 添加属性,对className和style作特殊处理
// class是保留字,style接受一个对象
setAttribute(name, value) {
if(name === 'className') {
this.el.setAttribute('class', value);
}
else if(name === 'style') {
Object.entries(value).forEach(([styleKey, styleValue]) => {
this.el.style[styleKey] = styleValue;
})
}
else {
this.el.setAttribute(name, value);
}
}
// 添加事件监听
addEventListener(name, handler) {
this.el.addEventListener(name, handler);
}
// 移除事件监听
removeEventListener(name, handler) {
this.el.removeEventListener(name, handler);
}
// 更新文本内容
updateTextContent(text) {
this.el.textContent = text;
}
// 替换子节点
replaceChild(newElement, oldElement) {
this.el.replaceChild(newElement.el, oldElement.el);
}
// 在referenceElement前插入newElement,父节点为this.el
insertBefore(newElement, referenceElement) {
// insertBefore这个方法还有一个巧妙的用法:当须要插入的节点自己就在DOM树中时,这个节点会被移动到插入的位置
// 即在将节点附加到其余节点以前,不须要从其父节点删除该节点
// 能够把这一特性应用到含key值的列表项的处理
this.el.insertBefore(newElement.el, referenceElement && referenceElement.el);
}
}
复制代码
看完了Element类,让咱们接着刚才的流程,重上往下一个个看这几个处理函数:
// 添加节点
export const diffAddNode = function(xm, newVNode, oldVNode, parentVNode, nextBroNode) {
// 建立新的Element,同时也建立了DOM对象
const newElement = new Element(newVNode, xm);
// 父vnode对应的element--即为newElement须要插入到的父节点
// nextBroNode为newElement的下一个兄弟节点,为空时会直接插入到父节点的子列表的末尾
parentVNode.element.insertBefore(newElement, nextBroNode && nextBroNode.element);
// 当前的newVNode指定新的newElement
newVNode.addElement(newElement);
}
复制代码
// 删除旧节点
export const diffDelNode = function(xm, newVNode, oldVNode, parentVNode) {
// 调用父节点的removeChild方法删除当前节点
parentVNode.element.removeChild(oldVNode.element);
// 当前的newVNode指定空的element占位对象
newVNode.addElement(new Element(new VNode(null), xm));
}
复制代码
// 替换旧节点
export const diffReplaceNode = function(xm, newVNode, oldVNode, parentVNode) {
// 新建节点
const newElement = new Element(newVNode, xm);
// 把旧节点替换为新节点
parentVNode.element.replaceChild(newElement, oldVNode.element);
// 为newVNode指定element
newVNode.addElement(newElement);
}
复制代码
// 比较文本节点
export const diffUpdateText = function(xm, newVNode, oldVNode, parentVNode) {
if(newVNode.text !== oldVNode.text) {
// 更新文本的时候不须要建立新的文本节点,直接利用旧节点便可
oldVNode.element.updateTextContent(newVNode.text);
}
// 为newVNode指定element
newVNode.addElement(oldVNode.element);
}
复制代码
// 比较属性
export const diffAttribute = function(xm, newVNode, oldVNode) {
// 经过Set建立须要比较的全部属性,将新旧vnode的属性结合
const attrs = new Set(Object.keys(newVNode.attrs).concat(Object.keys(oldVNode.attrs)));
// 对属性值不一样的更新属性值
attrs.forEach(attr => newVNode.attrs[attr] !== oldVNode.attrs[attr] && oldVNode.element.setAttribute(attr, newVNode.attrs[attr]));
// 为newVNode指定element
newVNode.addElement(oldVNode.element);
}
复制代码
// 比较事件
export const diffEvent = function(xm, newVNode, oldVNode) {
// 拿到须要比较的全部事件
const events = new Set(Object.keys(newVNode.events).concat(Object.keys(oldVNode.events)));
events.forEach(event => {
// 当newVNode和oldVNode事件不一样时
if(newVNode.events[event] !== oldVNode.events[event]) {
// 移除旧事件的响应函数
oldVNode.element.removeEventListener(event, oldVNode.events[event]);
// 若是新事件的响应函数存在,则添加
if(newVNode.events[event]) {
// 保存新的绑定this后的处理函数
const handler = newVNode.events[event] = newVNode.events[event].bind(xm);
oldVNode.element.addEventListener(event, handler);
}
}
});
// 为newVNode指定element
newVNode.addElement(oldVNode.element);
}
复制代码
到此为止update这一块的内容都已经结束了,接下来,让咱们写一个demo试试到目前为止的效果
import Xue from './src/main';
new Xue({
root: '#app',
data() {
return {
test1: 'i am text1',
test2: {
a: 'i am text2 attr a'
}
}
},
methods: {
fn1() {
console.log(this)
console.log('i am fn1')
},
fn2() {
console.log(this)
console.log('i am fn2')
}
},
render() {
return (<div> { this.test1 } <br /> { this.test2.a } <br /> { this.test1 === 'i am text1' ? 'text1 === i am text1' : 'text1 === i am text1 change' } <br /> { this.test1 === 'i am text1' ? null : <div>i have been rendered when test1 !== i am text1 </div> } { this.test1 === 'i am text1' ? <div>i have been rendered when test1 === i am text1 </div>: null } { this.test1 === 'i am text1' ? <a>i am a node when text1 === i am text1<span> i am inner</span></a> : <span>i am a node when text1 === i am text1 change</span> } <br /> { this.test1 === 'i am text1' ? <a>i am a node when text1 === i am text1</a> : <span>i am a node when text1 === i am text1 change<span> i am inner</span></span> } <br /> <div onClick={this.test === 'i am text1' ? this.fn1 : this.fn2} className={this.test === 'i am text1' ? 'cls1' : 'cls2'} id='id1'> my attrs and events will be change </div> </div>);
},
beforeCreate() {
setTimeout(() => {
this.test1 = 'i am text1 change';
this.test2.a = 'i am text2 attr a change';
console.log('settimeout');
}, 3000);
setTimeout(() => {
this.test1 = 'i am text1';
this.test2.a = 'i am text2 attr a';
console.log('settimeout');
}, 5000)
}
});
复制代码
初次渲染
下一章节内容:组件化,敬请期待......另外,下周可能拖更,缘由干这行的都懂,难顶。
github项目地址:点此跳转
第一章:从零开始,采用Vue的思想,开发一个本身的JS框架(一):基本架构的搭建