一、对读取源码的认识
1.1 带着问题去看源码
一开始拿到这套代码的时候,兴高采烈地去看,发现,有点难懂。
1.2 修改源代码,提高可读性
- 源代码中有部分代码有注释,但这不足以我去学习,因此,需要将注释补全。【注意:加上
JsDoc
规范,这样能更清楚每个函数负责的是什么职责】
其实,在补全注释的过程,也就是你理解代码的过程,你的注释写对了,也就证明你看懂了这段代码是做什么的。
- 源代码中是
cmd
语法,不能直接在浏览器中运行,需要编译,这也太麻烦了。所以,我们可以将cmd
语法改为es6
语法,便于代码在浏览器执行。
// cmd
var xxx = require("")
module.exports = xx
// 改为es6
import xx from 'nnn'
export xxx
注意:如果要在浏览器中运用es6
的语法,那么在用script
标签引入某文件的时候,必须添加type: module
<script type="module" src="main.js"></script>
- 有些代码结构看起来较复杂,比如三目运算符中嵌套三目运算。下面的例子,一看就有点懵了
// patch.js
var insertNode = maps[move.item.key]
? maps[move.item.key] : (typeof move.item === 'object')
? move.item.render() : document.createTextNode(move.item);
// 复杂的三目嵌套修改为简单的if...else结构
if (maps[move.item.key]) {
insertNode = maps[move.item.key]
} else if (typeof move.item === 'object') {
insertNode = move.item.render()
} else {
insertNode = document.createTextNode(move.item)
}
二、看看代码流程
整个源码有三大核心板块,分别是:
VElement
:创建虚拟DOM
,每个虚拟DOM
都有一个render
方法(核心方法)diff
:比较新旧虚拟DOM
,找出两棵树有什么不同,并记录到一个对象中。patch
:根据diff
返回的结果,将真正的差异运用到真正的DOM
树中
三、讲讲虚拟DOM
3.1 、虚拟DOM发展
我们都知道,原生的 DOM
操作十分耗性能,操作起来也十分麻烦,兼容性也要注意,所以jQuery
就出来了。
jQuery
强大的选择器及高度封装的 API
,使得 DOM
操作非常简单。但技术是发展的,MVVM
出现了。
比如 angularjs
,vue.js
,使用数据双向绑定,让我们脱离了对 DOM
的操作,开发效率大大提升,但大量的事件绑定使得在复杂的场景中出现了性能问题。
后来 ReactJs
出现了,既保证了开发效率有保证了性能,其中让HTML
代码和JS
代码混合的 虚拟 DOM
以及 diff
算法起了很大的作用。
3.2 虚拟DOM核心
虚拟的DOM的核心思想是:对复杂的文档 DOM
结构,提供一种方便的工具(开发效率),进行最小化地 DOM
操作(性能)
3.3 先看看虚拟DOM究竟是什么
这是html
结构 - div
有 4 个直系子节点
转化为虚拟DOM
后,则是,可看到 children
里有 4 个 VElement
(虚拟DOM
元素)
可以看到,虚拟 DOM
其实就是一个 js
对象,主要有以下属性
children
- 子节点集合count
- 子节点数key
- 提高性能的关键属性值props
- 属性值tagName
- 标签名
大家都知道,直接操作 js
对象绝对比操作 DOM
快,所以,既然直接操作 DOM
那么耗性能那么麻烦,那么我们就将 真实 DOM
转化为虚拟 DOM
,对真实 DOM
的所有操作先在虚拟 DOM
上执行(包括增加、删除、移动、替换节点),以下是创建虚拟DOM
的代码:
/**
* 虚拟DOM
* @param {string} tagName 标签名
* @param {object} props 属性对象
* @param {array} children 子DOM列表
* @return {VElement}
* @constructor
*/
var VElement = function(tagName, props, children) {
// 保证只能通过如下方式调用:new VElement
if (!(this instanceof VElement)) {
return new VElement(tagName, props, children);
}
// 可以通过只传递tagName和children参数
// 参数prop的可选处理,如果第二个参数传的是数组,证明不需要props
if (util.isArray(props)) {
children = props;
props = {};
}
//设置虚拟dom的相关属性
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
// void 666 表示 undefined
this.key = props ? props.key : void 666;
// 后代元素的数量(包括文本节点)
var count = 0;
util.each(this.children, function(child, i) {
if (child instanceof VElement) {
// 若子元素是VElment对象,则证明有count属性,直接加上即可
count += child.count;
} else {
// 不然,则是文本节点
children[i] = '' + child;
}
// 加上此次遍历的元素
count++;
});
this.count = count;
}
整个构造函数做了4件事情:
VElement
对象类型的判断:其实就是无new
操作的实现,这与jQuery
的无new
操作的 本质是一样的,都是把new
的过程放在了构造函数内部来处理。- 参数
prop
的可选处理:如果第二个参数传的是数组,证明不需要props
- 虚拟
DOM
属性的相关设定:这个不多说【注意的是void 666
,其实就是undefined
】 - 对后代元素的处理:分了两类 - 要么是
String
类型,表示子元素是文本,没有后代元素;要么是VElement
对象,表示是虚拟DOM
对象,有后代元素
3.4 再来看看VElement的render方法
/**
* 根据虚拟DOM创建真实DOM
* 具体思路:根据虚拟DOM节点的属性和子节点递归地构建出真实的DOM树
* @return {Element}
*/
VElement.prototype.render = function() {
//创建标签
var el = document.createElement(this.tagName);
//设置标签的属性
var props = this.props;
for (var propName in props) {
var propValue = props[propName]
util.setAttr(el, propName, propValue);
}
// 依次创建子节点的标签
util.each(this.children, function(child) {
// 如果子节点仍然为velement,则递归的创建子节点,否则直接创建文本类型节点
var childEl =
(child instanceof VElement) ?
child.render() :
document.createTextNode(child)
// 添加子节点
el.appendChild(childEl);
});
return el;
}
render
方法的本质:还是操作 DOM
,这个函数就做了三件事:
- 创建元素
- 设置元素属性
- 添加子节点 - 文本节点或者元素节点
四、diff 算法
在研究 diff
算法之前,我先通过 JSON.stringify()
将新旧虚拟DOM
的结构 console.log
到控制台中,并将其值存储到 .answer.js
文件中,便于分析。
4.1 diff算法三大策略
- 策略一:Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。 ====》
tree diff
- 策略二:拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。====》
component diff
- 策略三:对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。 ====》
element diff
4.2 tree diff - 深度优先遍历 - 比较两棵虚拟DOM树的差异
传统 diff
算法通过循环递归对节点进行依次对比,效率低下,算法复杂度达到 O(n^3)
,其中 n
是树中节点的总数。如果要展示 1000 个节点,就要依次执行上十亿次的比较。
但是在前端当中,很少会跨越层级地移动 DOM
元素。所以 Virtual DOM
只会对同一个层级的元素进行对比!【同一个层级指:同一个父节点的所有子节点】,如下图所示:
看看下面代码:
/**
* 避免了diff算法的复杂性,对同级别节点进行比较的常用方法是深度优先遍历
* @param {Object} oldTree - 旧DOM树
* @param {Object} newTree - 新DOM树
* @return
*/
function diff(oldTree, newTree) {
// 节点的遍历顺序
var index = 0;
// 在遍历过程中记录节点的差异
var patches = {};
// 深度优先遍历两棵树
dfsWalk(oldTree, newTree, index, patches);
return patches;
}
在实际操作中,我们只需要调用diff
函数即可,而函数返回的是一个patch
对象,这个对象就是记录两棵树差异的对象。
{
"1": [{
"type": 0,
"node": {
"tagName": "h5",
"props": {
"style": "color:blue"
},
"children": ["simple virtual dom"],
"count": 1
}
}],
"4": [{
"type": 3,
"content": "hello world2"
}],
"5": [{
"type": 1,
"moves": [{
"index": 2,
"item": {
"tagName": "li",
"props": {
"key": 3
},
"children": ["item #3"],
"key": 3,
"count": 1
},
"type": 1
}]
}],
"10": [{
"type": 1,
"moves": [{
"index": 1,
"type": 0
}]
}]
}
4.3 element diff - 差异类型
看到上面一大段的json
数据结构,或许会有点懵。其实上面就已经提前告诉我们有那4中差异类型。
先看看,这是新旧 DOM
结构:
这是新旧虚拟DOM
结构转化为树形结构 - 属性值的变化没有在下图展示,而是在上图展示了【具体虚拟DOM
结构可查看 .answer.js
文件】
总的来说,diff
算法就有4种类型:
REPLACE
- 0 - 替换原来的节点REORDER
- 1 - 移动、删除、新增子节点PROPS
- 2 - 修改节点的属性TEXT
- 3 - 修改文本节点内容
上图也就可以解析diff
返回的json
对象的每一项代表的是什么意思了!取“5”为例子
patch[5] = []
中的5表示这个节点在整棵虚拟DOM
树上存在的位置type = 1
表示这个节点的差异类型为REORDER
,其实这里就是新增了一个li
节点moves
属性表示的就是对节点进行什么操作index
的值表示在基于整棵树去计算的第5个节点ul
的子节点的基础上去数数,到第2个位置,新增一个li
元素。count
表示这个新增的li
元素有多少个子节点,这里是1个
4.4 diff算法的优化 - key值
在使用 react
的时候,每当使用 array.map()
这个函数,就要为元素添加 key
值,否则就会有警告。就只是按照要求去添加key
值,但不是很懂为什么要添加。
在看了这个源码后,自己对 key
值有了一些理解。
4.5 如果出现了 DOM 节点跨层级的移动操作,diff 会有怎样的表现
比如:A
节点(包括其子节点)整个被移动到 D
节点下
React
只会简单地考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。- 当根节点发现子节点中
A
消失,直接销毁A
;当D
发现多了一个子节点A
,则则会创建新的A
(包括子节点)作为其子节点 - 综述:
diff
的执行情况:create A → create B → create C → delete A
五、patch
patch
将 tree diff
计算出来的 DOM
差异队列更新到真实的 DOM
节点上,最终让浏览器能够渲染出更新的数据。