虚拟 DOM
-
出现背景及意义
创建DOM tree –> 创建Style Rules -> 构建Render tree -> 布局Layout –> 绘制Painting
-
传统的原生api或jQuery去操作DOM
-
多次操作
DOM,每次都会执行,导致性能浪费 -
操作
DOM的代价昂贵 -
-
DOM 引擎、JS 引擎 相互独立,JS 代码调用 DOM API 必须 挂起 JS 引擎
-
-
现代框架 react / vue : 每次变动重新渲染整个应用
-
性价比低,耗时高
-
任意改动导致重新构造整棵DOM树
-
input&textarea失去原有的焦点
-
全局应用状态变更,需要更新的局部页面很多
-
-
什么是虚拟 DOM
虚拟dom
- JS 对象表示的虚拟DOM
var element = {
tagName: 'ul', // 节点标签名
props: { // DOM的属性,用一个对象存储键值对
id: 'list'
},
children: [ // 该节点的子节点
{tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
]
}
- 对应的HTML:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
- 根据虚拟 DOM 生成真正的 DOM 节点
Element.prototype.render = function () {
var el = document.createElement(this.tagName) // 根据tagName构建
var props = this.props
for (var propName in props) { // 设置节点的DOM属性
var propValue = props[propName]
el.setAttribute(propName, propValue)
}
var children = this.children || []
children.forEach(function (child) {
var childEl = (child instanceof Element)
? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
: document.createTextNode(child) // 如果字符串,只构建文本节点
el.appendChild(childEl)
})
return el
}
var ulRoot = ul.render()
document.body.appendChild(ulRoot)
3. ### 虚拟 DOM 的使用
-
步骤1: 用 JavaScript 对象结构表示
DOM树的结构 -
步骤2: 当状态变更的时候——>对比新旧树——>记录差异
-
步骤3: 将差异(patch)更新到真正的
DOM树上
Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。
虚拟dom与真实dom
Diff算法
-
React & Vue 的 Diff
-
算法复杂度
-
标准Diff算法 复杂度 O(n^3)
-
React / Vue实现了复杂度为O(n) 的Diff算法
-
假设 两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构;
逐层比较
-
diff算法的具体实现
深度优先遍历,记录差异
diff流程图
// diff 函数,对比两棵树
function diff (oldTree, newTree) {
var index = 0 // 当前节点的标志
var patches = {} // 用来记录每个节点差异的对象
dfsWalk(oldTree, newTree, index, patches)
return patches
}
// 对两棵树进行深度优先遍历
function dfsWalk (oldNode, newNode, index, patches) {
// 对比oldNode和newNode的不同,记录下来
patches[index] = [...]
diffChildren(oldNode.children, newNode.children, index, patches)
}
//遍历子节点
function diffChildren (oldChildren, newChildren, index, patches) {
var leftNode = null
var currentNodeIndex = index
oldChildren.forEach(function (child, i) {
var newChild = newChildren[i]
currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍历子节点
leftNode = child
})
}
用patches存储新旧节点的差异
patches[0] = [{difference}, {difference}, ...] //key为节点顺序
3. ### diff算法的差异类型
- (1)节点类型不同 :销毁此节点,插入新节点
renderA: <div />
renderB: <span />
=====>
patches[index] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}]
- (2)节点类型相同:React 会对属性或者文本进行重设从而实现节点的转换
renderA: <div id="before">sally is pretty</div>
renderB: <div id="after">sally is cute</div>
=====>
patches[index] = [
{
type: PROPS,
props: { id: "container" }
},
{
type: TEXT,
content: "sally is cute"
}]
-
(3)列表节点的比较:同一层的节点的比较
- 每个节点都没有唯一的标识,React 无法识别每一个节点,那么更新过程会很低效
- 过程:C ---> F,D ---> C,E ---> D,最后插入E 节点
- 给每个节点唯一的标识(key),那么 React 能够找到正确的位置去插入新的节点
- 过程:插入F 节点
Tips:不要使用index作为key值
const list = [
{ id: 1, name: 'test1'},
{ id: 2, name: 'test2'},
{ id: 3, name: 'test3'},
]
- 情况1: 在最后一条数据后再加一条数据:此时前三条数据直接复用之前的,新渲染最后一条数据,此时用
index作为key,没有任何问题
const list = [
{ id: 1, name: 'test1'},
{ id: 2, name: 'test2'},
{ id: 3, name: 'test3'},
{ id: 4, name: '我是插队的那条数据'},
]
| 之前的数据 | 之后的数据 |
|---|---|
| key: 0 index: 0 name: test1 | key: 0 index: 0 name: test1 |
| key: 1 index: 1 name: test2 | key: 1 index: 1 name: test2 |
| key: 2 index: 2 name: test3 | key: 2 index: 2 name: test3 |
| key: 4 index: 4 name: 我是插队的那条数据 |
- 情况2:在中间插入一条数据
const list = [
{ id: 1, name: '我是插队的那条数据'},
{ id: 2, name: 'test1'},
{ id: 3, name: 'test2'},
{ id: 4, name: 'test3'},
]
- 通过对比,发现除了第一个数据可以复用之前的之外,另外三条数据都需要重新渲染
| 之前的数据 | 之后的数据 |
|---|---|
| key: 0 index: 0 name: test1 | key: 0 index: 0 name: test1 |
| key: 1 index: 1 name: test2 | key: 1 index: 1 name: 我是插队的那条数据 |
| key: 2 index: 2 name: test3 | key: 2 index: 2 name: test2 |
| key: 3 index: 3 name: test3 |
- 通过对比,只有一条数据变化了,即
id为4的那条数据, 因此只需新渲染这一条数据,其他皆可复用之前的
| 之前的数据 | 之后的数据 |
|---|---|
| key: 1 id: 1 index: 0 name: test1 | key: 1 id: 1 index: 0 name: test1 |
| key: 2 id: 2 index: 1 name: test2 | key: 4 id: 4 index: 1 name: 我是插队的那条 |
| key: 3 id: 3 index: 2 name: test3 | key: 2 id: 2 index: 2 name: test2 |
| key: 3 id: 3 index: 3 name: test3 |
-
列表节点的更新算法—Myers Diff算法
从 ****a = 'ABCABBA' 到 b = 'CBABAC' 有无数种路径,但是最优路径只有一种:
-
最优路径的标准:
- 1⃣️ 改动次数最小
- 2⃣️ 在改动次数固定的情况下,删除后新增,比新增后删除要好
- 图1 方法一先减后增比方法二和方法三更加“直观”
暂时无法在飞书文档外展示此内容
-
diff与图搜索:寻找 diff 的过程可以被表示为图搜索
- 向右表示“删除”,向下表示”新增“,对角线则表示“原内容保持不动“
- 定义参数 d 和 k ,d代表路径的长度, k 代表当前坐标 x - y 的值
- d 和 k 固定的情况下,寻找最远路径。
图1 diff图搜索
图2: d和k固定,最远路径坐标分布
-
Diff更新 优于 原生操作 ?
-
Diff + VDom只是 React / Vue 视图更新的一种策略
-
精细化更新 DOM 情况下,React / Vue 毫无性能可言
-
连续操作 DOM 元素,React / Vue优于原生
批处理
Batch Update 即「批量更新」。Batch Update 可以理解为将一段时间内对 model 的修改批量更新到 view 的机制
-
React批处理的实现
引子:问下列代码中 4 次 console.log 打印出来的 val 分别是多少?
- 批处理演示:批处理demo1
addState(){
this.setState({ val: 1 }).
1. console.log(this.state.val) // 第 1 次 log
this.setState({ val: 2 })
2. console.log(this.state.val) // 第 2 次 log
this.setState({ val: 3 })
3. console.log(this.state.val) // 第 3 次 log
}
componentDidUpdate() {
console.log('i am updating')
}
- 批处理演示:批处理demo2
class Example extends React.Component {
constructor() {
super();
this.state = {
val: 0
};
}
addState() {
this.setState({val: this.state.val + 1});
1. console.log(this.state.val); // 第 1 次 log
this.setState({val: this.state.val + 1});
2. console.log(this.state.val); // 第 2 次 log
setTimeout ( () => {
this.setState({val: this.state.val + 1});
3. console.log(this.state.val); // 第 3 次 log
this.setState({val: this.state.val + 1});
4. console.log(this.state.val); // 第 4 次 log
}
render() {
return (
<div className="add" onClick={this.addState>{this.state.val}</div>
);
}
};
setState 干了什么
- 若 isBatchingUpdates 为 true,则把当前组件(即调用了 setState 的组件)放入 dirtyComponents 数组中;否则 batchUpdate 所有队列中的更新。在源码(ReactUpdates.js)中
//setState之后会进入这个函数,传入待更新组件component
function enqueueUpdate(component) {
// ...
//isBatchingUpdates为false 不处于批更新流程中
if (!batchingStrategy. isBatchingUpdates ) {
batchingStrategy. batchedUpdates (enqueueUpdate, component);
return ;
}
//isBatchingUpdates为true 处于批更新流程中
dirtyComponents.push(component);
}
//batchingStrategy对象
var batchingStrategy = {
// 是否正处于创建或更新组件阶段 === 是否处于批更新的流程中
isBatchingUpdates: false,
batchedUpdates: function(callback, a, b, c, d, e) {
// ... batchingStrategy.isBatchingUpdates = true;
transaction.perform(callback, null, a, b, c, d, e);
}};
Transaction事务
Transactioncreates a black box that is able to wrap any method such that certain invariants are maintained before and after the method is invoked事务创建了一个能够包装任何方法的“黑盒子”,以便在调用方法之前和之后维护某些不变量
* wrappers (injected at creation time)
* + +
* | |
* +-----------------|--------|--------------+
* | v | |
* | +---------------+ | |
* | +--| wrapper1 |---|----+ |
* | | +---------------+ v | |
* | | +-------------+ | |
* | | +----| wrapper2 |--------+ |
* | | | +-------------+ | | |
* | | | | | |
* | v v v v | wrapper
* | +---+ +---+ +---------+ +---+ +---+ | invariants
* perform(anyMethod) | | | | | | | | | | | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | +---+ +---+ +---------+ +---+ +---+ |
* | initialize close |
* +-----------------------------------------+
- Transaction 就是将需要执行的 method 使用 wrapper 封装起来,再通过 Transaction 提供的 perform 方法执行
- 在 perform 之前,先执行所有 wrapper 中的 initialize 方法;perform 完成之后(即 method 执行后)再执行所有的 close 方法
- 一组 initialize 及 close 方法称为一个 wrapper, Transaction 支持多个 wrapper 叠加。
思考:setState到底是同步还是异步?
-
Vue批处理的实现
与 React 相比 Vue 实现 Batch Update 的方法就要简单很多:直接借助浏览器的 Event Loop
EventLoop
执行顺序:
-
引擎首先从
macrotask queue(宏任务队列)中取出第一个任务,执行完毕后,将microtask queue(微任务队列)中的所有任务取出,按顺序全部执行; -
然后再从
macrotask queue中取下一个,执行完毕后,再次将microtask queue中的全部取出; -
循环往复,直到两个queue中的任务都取完。
-
每次执行一个宏任务或微任务叫称做一次/个tick
浏览器环境中常见的异步任务种类,按照优先级:
-
Macro task :
同步代码、setImmediate、MessageChannel、setTimeout/setInterval -
Micro task:
Promise.then、MutationObserver
Vue批处理的实现流程
Vue批更新流程图
NextTick的实现
// src/core/util/next-tick.js
export function nextTick(cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
// ...
}
/* 强制使用macrotask的方法 */
export function withMacroTask(fn: Function): Function {
return fn._withTask || (fn._withTask = function() {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
-
cb回调函数用try-catch包裹后放在一个匿名函数中推入callbacks数组中,防止单个cb执行错误让整个JS线程挂掉。 -
然后检查
pending状态,用于判断是否已经处于更新状态 -
最后检查是否传入了
cb,因为nextTick还支持Promise化的调用:nextTick().then(() => {}),所以如果没有传入cb就直接return了一个Promise实例,并且把resolve传递给_resolve,这样后者执行的时候就跳到我们调用的时候传递进then的方法中。
MacroTimerFunc & MicroTimerFunc
MacroTimerFunc & MicroTimerFunc 对浏览器中宏任务/微任务的API进行了一层包装
// src/core/util/next-tick.js
const callbacks = [] // 存放异步执行的回调
let pending = false // 一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送
/* 挨个同步执行callbacks中回调 */
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let microTimerFunc // 微任务执行方法
let macroTimerFunc // 宏任务执行方法
let useMacroTask = false // 是否强制为宏任务,默认使用微任务
// 宏任务
1. if ( typeof setImmediate !== 'undefined' && isNative (setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
}
2. else if ( typeof MessageChannel !== 'undefined' && (
isNative ( MessageChannel ) ||
MessageChannel . toString () === '[object MessageChannelConstructor]' // PhantomJS
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
}
3. else {
macroTimerFunc = () => {
setTimeout (flushCallbacks, 0 )
}
}
// 微任务
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
1. microTimerFunc = () => {
p. then (flushCallbacks)
}
} else {
2. microTimerFunc = macroTimerFunc // fallback to macro
}
-
可以看到上面
macroTimerFunc与microTimerFunc进行了在不同浏览器兼容性下的平稳退化,或者说降级策略:-
宏任务
macroTimerFunc:setImmediate -> ``MessageChannel`` -> setTimeout。 -
微任务
microTimerFunc:Promise.then -> macroTimerFunc
-
-
是因为HTML5规定setTimeout执行的最小延时为4ms,而嵌套的timeout表现为10ms,为了尽可能快的让回调执行,没有最小延时限制的前两者显然要优于
setTimeout:
一个例子:Vue之nextTick实例
<div id="app">
<span id='name' ref='name'>{{ name }}</span>
<button @click='change'>change name</button>
<div id='content'></div></div><script>
new Vue({
el: '#app',
data() {
return {
name: 'Sally'
}
},
methods: {
change() {
const $name = this.$refs.name
1⃣️ this.$nextTick(() => console.log('setter前:' + $name.innerHTML))
this.name = ' name改喽 '
2⃣️ console.log('同步方式:' + this.$refs.name.innerHTML)
3⃣️ setTimeout(() => this.console("setTimeout:" + this.$refs.name.innerHTML),0);
4⃣️ this.$nextTick(() => console.log('setter后:' + $name.innerHTML))
5⃣️ this.$nextTick().then(() => console.log('Promise方式:' + $name.innerHTML))
}
}
})
</script>
在vue2.5之前的版本中,nextTick基本上基于
micro task来实现的,但是在某些情况下micro task具有太高的优先级,并且可能在连续顺序事件之间(例如#4521,#6690)或者甚至在同一事件的事件冒泡过程中之间触发(#6566)。但是如果全部都改成macro task,对一些有重绘和动画的场景也会有性能影响,如 issue #6813。vue2.5之后版本提供的解决办法是默认使用micro task,但在需要时(例如在v-on附加的事件处理程序中)强制使用macro task。
-
React & Vue 批处理总结
-
Vue批处理的原理:Vue本轮事件循环中所有触发的watcher添加到一个队列里交由
Vue.nextTick(),nextTick会把队列更新操作包装成一个宏任务task或者微任务task的回调函数,当前栈(tick)执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。 -
React批处理原理:transaction事务在 initialize 阶段就会创建一个更新队列,所以在 事务 中调用 setState 方法更新被推入到更新队列中。函数执行结束进入 事务的 close 阶段,把更新队列flush。
- 小程序没有diff,每次setData都是重新渲染小程序
- 小程序是没有批处理的,也就是说小程序的setData是一个同步行为,所以在setData的时候尽量一次性更新完成,不要多次调用setData
click() {
this.setData({ count: 1 });
console.log(this.data.count); // 1
this.setData({ count: 2 });
console.log(this.data.count); // 2
this.setData({ count: 3 });
console.log(this.data.count); // 3
}
render() {
return (
<view className="test-page" bindtap={this.click}>
{this.data.count}
</view>
);
}
}
参考文档
深入浅出 React(四):虚拟 DOM Diff 算法解析