当内容即dom tree发生变化时,如何基于Virtual DOM来进行局部刷新渲染,而不是整棵树都重新渲染=>最小程度话的操作dom,而不是推倒重来。
一、环境
本次的代码实现,用到了ES6,所以需要使用babel,具体如下:
package.json:
{
"scripts": {
"cleanbuild": "rm ./public/vm_bundle.js && babel ./src/index.js --out-file ./public/vm_bundle.js",
"build": "babel ./src/index.js -o ./public/vm_bundle.js",
"clean": "rm ./public/vm_bundle.js"
},
"devDependencies": {
"@babel/cli": "^7.14.3",
"@babel/core": "^7.14.3",
"@babel/preset-env": "^7.14.2",
"babel-plugin-transform-react-jsx": "^6.24.1"
}
}
.babelrc:
{
"presets": ["@babel/preset-env"],
"plugins": [
[
"transform-react-jsx",
{
"pragma": "h" //默认是React.createElement
}
]
]
}
二、前期准备:
实现步骤:
- state 变化,生成新的 Virtual DOM;
- 比较新 Virtual DOM 与之前 Virtual DOM 的差异;
- 生成差异对象(patch);
- 遍历差异对象并更新 DOM;
差异对象如下:
{
type,
vdom,
props: [{
type,
key,
value
}],
children
}
type说明:
- DOM 元素对应的变化类型:新建、删除、替换和更新。
- props 变化的类型 :更新和删除。
const TYPES = {
CREATE: "create", //新增加的节点
DELETE: "delete", //要删除的属性或节点
UPDATE: "update", //节点类型一致但是children或props不一致、新增属性或属性变化
REPLACE: "replace", //节点类型不一致、文本节点(number/string))内容不一致
};
三、代码实现
1、view函数:修改上一篇view层,每3秒,新增一个 div 块元素,并修改根元素的属性(data-number):
let preVdom; //上一次渲染的vdom
//状态对象
const state = {
number: 0,
};
//jsx结构
function view() {
return (
<div data-number={state.number}>
Hello World!
{[...Array(state.number).keys()].map((id) => {
return (
<div id={`div${id}`} data-idx={id}>
{"->"}
{id}
</div>
);
})}
</div>
);
}
//真实dom插入页面
function renderDom(container) {
var vdom = view();
if (!preVdom) {
container.appendChild(createElement(vdom));
} else {
patch(diff(preVdom, vdom), container);
}
preVdom = vdom;
setTimeout(() => {
state.number += 1;
renderDom(container);
}, 3000);
}
renderDom(document.getElementById("root"));
2、diff算法--简洁版(未考虑性能优化)
const TYPES = {
CREATE: "create", //新增加的节点
DELETE: "delete", //要删除的属性或节点
UPDATE: "update", //节点类型一致但是children或props不一致、新增属性或属性变化
REPLACE: "replace", //节点类型不一致、文本节点(number/string))内容不一致
};
/**
* diff渲染前后的vdom,递归找出差异对象
* @param preVdom 上一次渲染的vdom
* @param postVdom 本次渲染的vdom
*
* 1. diff根元素;
* 2. diff根元素的props;
* 3. diff根元素的children;
* 4. 每个child的diff又是调用diff方法
*/
function diff(preVdom, postVdom) {
//preVdom不存在,postVdom存在=》插入新增的节点
if (preVdom === undefined) {
return {
type: TYPES.CREATE,
vdom: postVdom,
};
}
//preVdom存在,postVdom不存在=》删掉旧节点
if (postVdom === undefined) {
return {
type: TYPES.DELETE,
};
}
//文本节点(number/string))内容不一致、节点类型不同
if (
typeof preVdom !== typeof postVdom ||
preVdom.tag !== postVdom.tag ||
((typeof preVdom === "number" || typeof preVdom === "string") &&
preVdom !== preVdom)
) {
return {
type: TYPES.REPLACE,
vdom: postVdom,
};
}
//节点类型相同:对比该节点的属性、children
if (preVdom.tag) {
//非文本节点,才有属性和children
const propsPatches = difProps(preVdom.props, postVdom.props);
const childrenPatches = diffChildren(preVdom.children, postVdom.children);
//属性或children不同
if (propsPatches.length > 0 || childrenPatches > 0) {
return {
type: TYPES.UPDATE,
props: propsPatches,
children: childrenPatches,
};
}
}
}
function difProps(preProps, postProps) {
const patches = [];
const props = { ...preProps, ...postProps }; // 合并属性,相同key的属性后者覆盖前者
Object.keys(props).forEach((key) => {
const preP = preProps[key];
const postP = postProps[key];
//新属性中已没有该属性=》删掉的属性
if (postP === undefined) {
patches.push({
pType: TYPES.DELETE,
key,
});
}
//新增的属性或者属性值变化了
if (preP === undefined || preP !== postP) {
patches.push({
pType: TYPES.UPDATE,
key,
value: postP,
});
}
});
return patches;
}
function diffChildren(preChildren, postChildren) {
const patches = [];
const len = Math.max(preChildren.length, postChildren.length);
let i;
for (i = 0; i < len; i++) {
const diffPatch = diff(preChildren[i], postChildren[i]);
if (diffPatch) {
diffPatch["idx"] = i;
patches.push(diffPatch);
}
}
return patches;
}
3、差异(patch)更新
/**
* diff后的结果 映射到 真实DOM上
* @param patches diff后的差异对象
* @param parent 容器dom节点
*/
function patch(patches, parent, cid = 0) {
if (!patches || Object.keys(patches).length < 1) {
return;
}
const len = parent.childNodes.length;
const child = cid >= len ? null : parent.childNodes[cid];
switch (patches.type) {
case TYPES.CREATE:
return parent.appendChild(createElement(patches.vdom));
case TYPES.DELETE:
return parent.removeChild(child);
case TYPES.REPLACE:
return parent.replaceChild(createElement(patches.vdom), child);
case TYPES.UPDATE:
patchProps(child, patches.props);
patchChilren(child, patches.children);
break;
}
}
function patchProps(element, propsPatches = []) {
if (!propsPatches || propsPatches.length < 1) {
return;
}
propsPatches.forEach((patch) => {
if (patch.type === TYPES.DELETE) {
element.removeAttribute(patch.key);
} else if (patch.type === TYPES.UPDATE) {
element.setAttribute(patch.key, patch.value);
}
});
}
function patchChilren(parent, patchChildren = []) {
if (!patchChildren || patchChildren.length < 1) {
return;
}
patchChildren.forEach((patchChild) => {
patch(patchChild, parent, patch.idx);
});
}
四、简易版diff分析
1、通过diff获取了新、老 Virtual DOM的差异,然后再对现有DOM进行打补丁=》既然,都找出了差异,何不直接修改DOM,还要再多此一举呢?
优化diff,去掉patch=》diff和patch合并
上面renderDom修改如下:
// patch(diff(preVdom, vdom), container);
diff(preVdom, vdom, container);
删掉patch相关方法,内容合并到diff上,修改后的diff如下:
function diff(preVdom, postVdom, container, cid = 0) {
const len = container.childNodes.length;
const child = cid >= len ? null : container.childNodes[cid];
//preVdom不存在,postVdom存在=》插入新增的节点
if (preVdom === undefined) {
// return {
// type: TYPES.CREATE,
// vdom: postVdom,
// };
return container.appendChild(createElement(postVdom));
}
//preVdom存在,postVdom不存在=》删掉旧节点
if (postVdom === undefined) {
// return {
// type: TYPES.DELETE,
// };
return container.removeChild(child);
}
//文本节点(number/string))内容不一致、节点类型不同
if (
typeof preVdom !== typeof postVdom ||
preVdom.tag !== postVdom.tag ||
((typeof preVdom === "number" || typeof preVdom === "string") &&
preVdom !== preVdom)
) {
// return {
// type: TYPES.REPLACE,
// vdom: postVdom,
// };
return container.replaceChild(createElement(postVdom), child);
}
//节点类型相同:对比该节点的属性、children
if (preVdom.tag) {
//非文本节点,才有属性和children
// const propsPatches = difProps(preVdom.props, postVdom.props);
// const childrenPatches = diffChildren(preVdom.children, postVdom.children);
//属性或children不同
// if (propsPatches.length > 0 || childrenPatches > 0) {
// return {
// type: TYPES.UPDATE,
// props: propsPatches,
// children: childrenPatches,
// };
// }
difProps(preVdom.props, postVdom.props, child);
diffChildren(preVdom.children, postVdom.children, child);
}
}
function difProps(preProps, postProps, element) {
// const patches = [];
const props = { ...preProps, ...postProps }; // 合并属性,相同key的属性后者覆盖前者
Object.keys(props).forEach((key) => {
const preP = preProps[key];
const postP = postProps[key];
//新属性中已没有该属性=》删掉的属性
if (postP === undefined) {
// patches.push({
// pType: TYPES.DELETE,
// key,
// });
element.removeAttribute(key);
}
//新增的属性或者属性值变化了
if (preP === undefined || preP !== postP) {
// patches.push({
// pType: TYPES.UPDATE,
// key,
// value: postP,
// });
element.setAttribute(key, postP);
}
});
// return patches;
}
function diffChildren(preChildren, postChildren, parent) {
// const patches = [];
const len = Math.max(preChildren.length, postChildren.length);
let i;
for (i = 0; i < len; i++) {
// const diffPatch = diff(preChildren[i], postChildren[i]);
// if (diffPatch) {
// diffPatch["idx"] = i;
// patches.push(diffPatch);
// }
diff(preChildren[i], postChildren[i], parent, i);
}
// return patches;
}
2、每次都会保存上一次的Virtual DOM,与新的Virtual DOM比较,再转成真实的dom树=》何不将Virtual DOM与真实dom树直接关联,diff差异,然后直接更新呢?
关联真实dom树,直接更新
上面renderDom修改如下:
function renderDom(container) {
// var vdom = view();
// if (!preVdom) {
// container.appendChild(createElement(vdom));
// } else {
// diff(preVdom, vdom, container);
// }
// preVdom = vdom;
diff(view(), container);
setTimeout(() => {
state.number += 1;
renderDom(container);
}, 3000);
}
virtual dom中setProps修改:把属性挂载到元素上element["props"] = props保存起来,用于vdom和真实dom对比使用
function setProps(element, props) {
for (let key in props) {
if (props.hasOwnProperty(key)) {
element.setAttribute(key, props[key]);
}
}
// 保存当前的属性,之后用于新VirtualDOM的属性比较
element["props"] = props;
}
diff、diffProps和diffChildren方法修改如下:
function diff(vdom, container, cid = 0) {
const len = container.childNodes.length;
const child = cid >= len ? undefined : container.childNodes[cid]; //节点不存在一定是undefined,因为child === undefined用的是全等
//preVdom不存在,postVdom存在=》插入新增的节点
if (child === undefined) {
// return {
// type: TYPES.CREATE,
// vdom: postVdom,
// };
return container.appendChild(createElement(vdom));
}
//preVdom存在,postVdom不存在=》删掉旧节点
if (vdom === undefined) {
// return {
// type: TYPES.DELETE,
// };
return container.removeChild(child);
}
//文本节点(number/string))内容不一致、节点类型不同
if (
!isEqual(vdom, child)
// typeof preVdom !== typeof postVdom ||
// preVdom.tag !== postVdom.tag ||
// ((typeof preVdom === "number" || typeof preVdom === "string") &&
// preVdom !== preVdom)
) {
// return {
// type: TYPES.REPLACE,
// vdom: postVdom,
// };
return container.replaceChild(createElement(vdom), child);
}
//节点类型相同:对比该节点的属性、children
// if (preVdom.tag) {
//非文本节点,才有属性和children
// const propsPatches = difProps(preVdom.props, postVdom.props);
// const childrenPatches = diffChildren(preVdom.children, postVdom.children);
//属性或children不同
// if (propsPatches.length > 0 || childrenPatches > 0) {
// return {
// type: TYPES.UPDATE,
// props: propsPatches,
// children: childrenPatches,
// };
// }
// }
//child.nodeType === Node.ELEMENT_NODE ie中没有Node对象,所以用数值
//排除文本节点,文本节点没有props和children
if (child.nodeType === 1) {
difProps(vdom.props, child);
diffChildren(vdom.children, child);
}
}
function difProps(vdomProps, element) {
// const patches = [];
const oldProps = element["props"] || {};
const newProps = {};
const props = { ...oldProps, ...vdomProps }; // 合并属性,相同key的属性后者覆盖前者
Object.keys(props).forEach((key) => {
const preP = oldProps[key];
const postP = vdomProps[key];
//新属性中已没有该属性=》删掉的属性
if (postP === undefined) {
// patches.push({
// pType: TYPES.DELETE,
// key,
// });
return element.removeAttribute(key);
}
//新增的属性或者属性值变化了
if (preP === undefined || preP !== postP) {
// patches.push({
// pType: TYPES.UPDATE,
// key,
// value: postP,
// });
element.setAttribute(key, postP);
}
newProps[key] = props[key];
});
element["props"] = newProps; //更新挂载到element上的props
// return patches;
}
function diffChildren(vdomChildren, parent) {
// const patches = [];
const oldChilrenNodes = parent.childNodes;
const len = Math.max(oldChilrenNodes.length, vdomChildren.length); //还是要计算新旧节点最大的children树,即使旧的节点children多,vdomChildren[i]就是undefined,即要删掉的节点
let i;
for (i = 0; i < len; i++) {
// const diffPatch = diff(preChildren[i], postChildren[i]);
// if (diffPatch) {
// diffPatch["idx"] = i;
// patches.push(diffPatch);
// }
diff(vdomChildren[i], parent, i);
}
// return patches;
}
diff中判断节点类型前后是否变化,之前对比的是vdom,现在对比的是vdom和真实的dom节点,方法如下:
/**
* 判断新的vdom和原先的dom节点类型是否相同
* @param vdom 虚拟dom
* @param element 真实dom节点
*/
function isEqual(vdom, element) {
const eType = element.nodeType;
const vType = typeof vdom;
//文本节点
//3-Node.TEXT_NODE,IE没有Node对象
//注意:文本节点中的数字通过element.nodeValue获取的内容 类型是string,而vdom若为数字则类型为number,所以使用===会导致含有数字文本的节点即使前后没变化都会更新,像是本案例每3s插入一条新的div,但是原先插入的节点因为含有数字文本,导致每次插入之前插入的div需要全更新=》vdom转换为string
if (
eType === 3 &&
(vType === "string" || "number") &&
element.nodeValue === String(vdom)
) {
return true;
}
//1-Node.ELEMENT_NODE,IE没有Node对象
if (eType === 1 && element.tagName.toLowerCase() === vdom.tag.toLowerCase()) {
return true;
}
return false;
}
注意:上面element.nodeValue === String(vdom)中vdom要转换成string,要么就不要用===全等,因为element.nodeValue获取的内容 类型是string,而vdom若为数字则类型为number,不转换,运行代码可以看到:每隔3秒插入一个新的div,之前所有的div都会更新,因为数字文本前后不等,如下:
五、diff算法优化(待更新)
在react里,渲染数组元素的时候,会提醒加上key这个属性,那么key是用来做什么的呢?
key的作用:
看过react源码的都知道,在react里,key的作用主要就是复用之前的节点,没有key的话,数组就要每次全部删除然后重新创建,开销就非常大。key就是暗示某些元素的稳定性。
在子元素列表末尾新增元素时,更新开销比较小:React 会先匹配两个 <li>first</li>
对应的树,然后匹配第二个元素 <li>second</li>
对应的树,最后插入第三个元素的 <li>third</li>
树。
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
但是新增元素插入到表头,那么更新开销会比较大:React 并不会意识到应该保留 <li>Duke</li>
和 <li>Villanova</li>
,而是会重建每一个子元素=》产生性能问题
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
为了解决上述问题,React 引入了 key
属性。有了key,react就知道:和旧树中相同的key 只是发生了位移,旧树中不存在的key元素为新增元素,需要插入dom。
//待更新。。。