React, Vue2, Vue3 diff 算法解析

前言

前端的虚拟 DOM 框架,一般是用 js 树结构来对视图层进行抽象表达,通过对树的遍历创建 UI 元素,渲染到视图。
如果想要对视图进行更新,这时候会产生一颗新的树,显然根据新的数据全量重新创建 UI 元素,渲染到视图是极其消耗性能的。
因此需要对新老两颗树进行对比,找到差异点,将差异部分更新到存在的视图。

为了保证性能,我们需要考虑以下下几点:

  1. 尽可能复用现有 UI 元素
  2. 减少对 UI 元素的操作
  3. 减少遍历次数

在实际场景中,其实基本不会遇到子树的层级跃迁移动,往往只会对同一层级的元素进行更新。所以没必要进行跨层级的比较。于是就变成了两个数组之间的比较。

正文

基于以上可以抽象为一道算法题:
存在 oldChildren newChildren 两个数组,数组元素在该数组内都是唯一且不重复的,UIChildren 初始值为 oldChildren 的副本,

  1. 尽可能少地操作读取 UIChildren
  2. 尽可能少地读写 oldChildren 和 newChildren
  3. 优先满足第一点

尽可能利用 UIChildren 中的现有元素(newChildren,oldChildren 中的交集元素在 UIChildren 中 只能被移动不能被删除),
通过对比 oldChildren 和 newChildren 实现将 UIChildren 变为 newChildren 的副本
例如;
oldChildren 为 [1, 3, 4]
newChildren 为 [1, 2, 4]
那么 UIChildren 的初始值为 [1, 3, 4]
最后返回的 UIChildren 为 [1, 2, 4]
值得注意的是,对 UIChildren 进行操作的过程中,1 和 4 是属于可以复用的元素,所以只可以被移动但是不可以被删除的元素。

1
2
3
4
5
function diff(oldChildren, newChildren) {
let UIChildren = oldChildren.map(v => v) // clone
// TODO:
return UIChildren
}

对 UIChildren 进行操作提供了以下几个 API(其实是为了模拟 DOM 的 API,它和数组用法上设计风格差异很大,一个基于 index,一个基于元素)

  • inesertBefore(UIChildren, child, refChild) 讲一个元素插入到某一个元素前面
  • removeChild(UIChildren, child) 移除某一个元素
  • nextSibling(UIChildren, child) 获取某一个元素之后的元素
    具体实现参见

React

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* @desc React diff 算法
* 核心思想:进行双层循环, 一旦遇到相同的节点,
* 与上次记录的 oldChildren 中的最大 index 进行比较,
* 如果本次 index > lastIndex 则说明顺序新旧一致,无需移动,只需记录本次 index 到 lastIndex
* 否则说明需要将本节点移动到上一个节点之后
* 由于始终使用后移的方式来调整顺序,
* 这种场景下 [1, 2, 3, ..., 100] -> [100, 1, 2, ..., 99],需要将 1-99 向后移动,共计需要99次操作。
* 而不是将 100 插入到 1 前面,这样只需一次操作
* 操作指使用提供的 DOM API
* @param {[number]} oldChildren
* @param {[number]} newChildren
*/
export function diff(oldChildren, newChildren) {
let UIChildren = oldChildren.map(v => v);
let lastIndex = 0;

if (newChildren.length === 0) {
UIChildren = []
}

for (let n = 0; n < newChildren.length; n++) {
const newNode = newChildren[n]
// 暂存上一个节点的的最大 index,
// 如果后续节点相同且 index < lastIndex, 则说明该节点需要向后移动
let hasFind = false; // 是否找到元素
let o = 0;
for(o; o < oldChildren.length; o++) {
const oldNode = oldChildren[o]
if (newNode === oldNode) {
hasFind = true
if (o < lastIndex) {
// 当前节点和 n-1 节点顺序错位, 需要将当前节点插入到 n-1 后一位(的前面),
const refNode = nextSibling(UIChildren, newChildren[n - 1])
inesertBefore(UIChildren, oldNode, refNode)
} else {
lastIndex = o
}
// 一旦有节点相同, 中断内层循环, 防止继续更新下去浪费性能
break
}
}

// oldChildren 里面找不到 newChildren 的元素 说明要新增
// i 为 0 ,说明遍历的是第一个元素, 插入到 UIChildren 的第一个节点即可
// i >= 1, 直接插入到该元素前面
if (!hasFind) {
const refNode = n - 1 < 0 ? oldChildren[0] : nextSibling(UIChildren, newChildren[n - 1])
inesertBefore(UIChildren, newChildren[n], refNode)
}

// newChildren 里面找不到 oldChildren 里面的元素说明要移除
for (let i = 0; i < oldChildren.length; i++) {
const item = oldChildren[i];
const hasFind = newChildren.find(v => v === item)
if (!hasFind) {
removeChild(UIChildren, item)
}
}
}
return UIChildren
}

代码参见

Snabddom / Vue2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
* @desc snabbdom & vue2 中采用的双端比较算法
* 一层循环同时遍历两个数组, 通过组合 oldStart, oldEnd 和 newStart,newEnd 进行双端比较
* 一共会有四种情况,直到某一个数组遍历完成
* 然后处理未遍历到的区间
* @param {[number]} oldChildren
* @param {[number]} newChildren
*/
export function diff(oldChildren, newChildren) {
let UIChildren = oldChildren.map(v => v) // 模拟 UI 元素
let oldStartIdx = 0
let newStartIdx = 0

let oldEndIdx = oldChildren.length - 1
let newEndIdx = newChildren.length - 1

let oldStartNode = oldChildren[0]
let newStartNode = newChildren[0]

let oldEndNode = oldChildren[oldEndIdx]
let newEndNode = newChildren[newEndIdx]

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartNode) {
oldStartNode = oldChildren[++oldStartIdx]
} else if(!oldEndNode) {
oldEndNode = oldChildren[--oldEndIdx]
} else if (oldStartNode === newStartNode) {
oldStartNode = oldChildren[++oldStartIdx]
newStartNode = newChildren[++newStartIdx]
} else if (oldEndNode === newEndNode) {
oldEndNode = oldChildren[--oldEndIdx]
newEndNode = newChildren[--newEndIdx]
} else if (oldStartNode === newEndNode) {
// 后移
// 将节点移动到剩余遍历区域最后一个节点之后
inesertBefore(UIChildren, oldStartNode, nextSibling(UIChildren, oldEndNode))
oldStartNode = oldChildren[++oldStartIdx]
newEndNode = newChildren[--newEndIdx]
} else if (oldEndNode === newStartNode) {
// 前移
// 将节点移动到遍历区域最第一个节点之前
inesertBefore(UIChildren, oldEndNode, oldStartNode)
oldEndNode = oldChildren[--oldEndIdx]
newStartNode = newChildren[++newStartIdx]
} else {
// 其他情况
// 拿 newChildren 中的起始节点尝试去 oldChildren 中寻找
const newEle = newChildren[newStartIdx]
const oldIdx = oldChildren.indexOf(newEle)
if (oldIdx === -1) {
// 找不到说明是新元素,插入 oldStartNode 之前
inesertBefore(UIChildren, newEle, oldStartNode)
} else {
// 能找到执行插入操作
inesertBefore(UIChildren, oldChildren[oldIdx], oldStartNode)

// 此时oldChildren[oldIdx] 一定处于非边缘位置
// 而且未通过 while 循环遍历过
// 所以置空, 再结合对空元素的判断,跳过这个已经被移动的节点
oldChildren[oldIdx] = undefined
}
// newChildren 的第一个元素处理过了,向右移动
newStartNode = newChildren[++newStartIdx]
}
}

// 上面循环并不能保证完整遍历两个数组的所有元素
// 当一个数组遍历完之后,另一个数组可能残留一段区间,尚未遍历到
// 当 oldChildren 残留区间未遍历 说明要删除这些元素
// 当 newChildren 残留区间未遍历 说明要新增这些元素
// 这段区间之内不可能存在可以复用的元素
if (oldStartIdx > oldEndIdx) {
// 新增
// 始终插入到 newChildren[newEndIdx + 1] 之前能维持新插入的元素,从前到后的顺序
// null 默认插入到最后面
const before = newChildren[newEndIdx + 1] == null ? null : newChildren[newEndIdx + 1]
for (let i = newStartIdx; i <= newEndIdx; i++) {
inesertBefore(UIChildren, newChildren[i], before)
}
} else if (newEndIdx < newStartIdx) {
// 移除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
removeChild(UIChildren, oldChildren[i])
}
}
return UIChildren
}

代码参见

Inferno / Vue3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/**
* @desc inferno & vue3 中采用的最长子序列算法
* 糅合了前两个算法的一些技巧,再加上最长子序列算法来减少节点的移动操作
* @param {[number]} oldChildren
* @param {[number]} newChildren
*/
export function diff(oldChildren, newChildren) {
const UIChildren = oldChildren.map((v) => v); // clone

let oldEndIndex = oldChildren.length - 1;
let newEndIndex = newChildren.length - 1;

let i = 0;
let oldEndNode = oldChildren[i];
let newEndNode = newChildren[i];

// 类似 vue 2 的算法,进行双向比较
outer: {
// 从数组前面开始比对
while (oldEndNode === newEndNode) {
i++;
// 一旦有一个数组遍历完成,跳出执行块
if (i > oldEndIndex || i > newEndIndex) {
break outer;
}
oldEndNode = oldChildren[i];
newEndNode = newChildren[i];
}

// 从数组后面开始比对
oldEndNode = oldChildren[oldEndIndex];
newEndNode = newChildren[newEndIndex];
while (oldEndNode === newEndNode) {
newEndIndex--;
oldEndIndex--;
// 一旦有一个数组遍历完成,跳出执行块
if (i > oldEndIndex || i > newEndIndex) {
break outer;
}
oldEndNode = oldChildren[oldEndIndex];
newEndNode = newChildren[newEndIndex];
}
}

if (i > oldEndIndex && i <= newEndIndex) {
// i > oldIndex 说明 oldChildren 已经遍历完成
// i <= newIndex 说明 newChildren 尚未遍历完
// 此时,这些遍历完的 newChildren 元素一定是需要新增的
// 新增操作
const refNode = newChildren[newEndIndex + 1] == null ? null: newChildren[newEndIndex + 1]
while(i <= newEndIndex) {
inesertBefore(UIChildren, newChildren[i++] ,refNode)
}
} else if (i > newEndIndex) {
// newChildren 已经遍历完
// oldChildren 已经暂未遍历完成
// 此时,这些未遍历完的oldChildren 元素一定是需要移除的
// 移除操作
while(i <= oldEndIndex) {
removeChild(UIChildren, oldChildren[i++])
}
} else {
// 上边处理了比较简单的情况

const newRemaining = newEndIndex - i + 1 // newChildren 中未处理节点数量
const source = new Array(newRemaining).fill(-1) // 用来保存未处理的 newChildren 区间元素对应的元素在 oldChildren 的 index

const oldStartIdx = i
const newStartIdx = i
let shouldMove = false
let lastIndex = 0
const keyIndexMap = {} // { value/key: index } newChildren未处理区间索引表
for (let k = newStartIdx; k <= newEndIndex; k++) {
keyIndexMap[newChildren[k]] = k
}
let patched = 0

// 遍历 oldChildren 剩余节点
for (let i = oldStartIdx; i <= oldEndIndex; i++) {
const oldNode = oldChildren[i]
if (patched < newRemaining) {
const k = keyIndexMap[oldNode] // 找到 oldNode 在 newChildren 中的 index
if (typeof k !== 'undefined') {
patched ++
source[k - newStartIdx] = i

// 判断是否需要移动,类似 React 的 diff 的思路
if (k < lastIndex) {
shouldMove = true
} else {
lastIndex = k
}
} else {
// 在 newChildren 中找不到了,移除
removeChild(UIChildren, oldNode)
}
} else {
// 此时的 oldChildren 未遍历完成, newChildren 已经遍历完成
// 剩余的oldChildren 中的元素未出现在 newChildren,应当被移除
removeChild(UIChildren, oldNode)
}
}

if (shouldMove) {
const seq = lis(source) // 返回最长增长子序列 index 数组
let j = seq.length - 1

// 处理未处理的 newChildren 区间
for(let i = newRemaining - 1; i>= 0; i --) {
if (source[i] === -1) {
// 不存在说明是新节点,直接插入到上一个节点处理好的节点即可
const curIndex = i + newStartIdx
const newNode = newChildren[curIndex]
const refNode = newChildren[curIndex + 1] == null ? null : newChildren[curIndex + 1]
inesertBefore(UIChildren, newNode, refNode)
} else if (i !== seq[j]) {
// 说明该节点需要移动
// 其实和上面的 if 处理逻辑一致
const curIndex = i + newStartIdx
const newNode = newChildren[curIndex]
const refNode = newChildren[curIndex + 1] == null ? null : newChildren[curIndex + 1]
inesertBefore(UIChildren, newNode, refNode)
} else {
// i === seq[j] 说明当前节点在增长子序列中,无需移动
// j 往前移动
j--
}
}
}
}
return UIChildren;
}

代码参见

参考

TS 一些工具泛型的使用及其实现(续)

之前写了一篇 TS 一些工具泛型的使用及其实现, 但是一直没怎么使用 TS,回首看文章,发现自己都看不懂了。
期间内 TS 也有一些变化,所以这一篇将会承接上篇文章,分析解读更多的工具泛型,主要来自 utility-types项目的源码。
阅读本流水账需要对 TS 中的以下东西有所了解

  • extends
  • keyof
  • in
  • infer
  • &
  • |
  • ?
  • -?
  • +?
  • never
  • unkown
  • any
  • readonly
  • void

正文

ArrayElement

提取数组成员类型,
一个思路是 用 extends 限制数组类型, 然后用数组 key 类型为 number 的特性取出其属性类型

1
type ArrayElement<T extends readonly unknown[]> = T[number];

第二种写法的核心思路就是用 infer 来隐射 数组的属性类型

1
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never

Exclude & Extract vs. Diff & Filter

TS 内置类型定义涵盖了 Exclude & Extract, 但是在它的官方文档又给出了另外的名字

1
2
type Diff<T, U> = T extends U ? never : T;
type Filter<T, U> = T extends U ? T : never;

就类型定义的代码而言,Exclude === Diff, Extract === Filter,蜜汁操作

NonNullable

从类型 T 中排除 null 和 undefined

1
type NonNullable<T> = Exclude<T, null | undefined>

Parameters

拿到函数的参数类型,不定参数的组织形式就是一个数组,参考 ArrayElement的第二种写法,利用infer去取到类型

1
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) =>  any ? P : never

ConstructorParameters

要拿到构造函数参数的类型,参考 Parameters,加上 new 即可

1
type ConstructorParameters<T extends new (...args: any) => any> = T extends new (...args: infer P) =>  any ? P : never

InstanceType

获取实例类型,跟 ParametersConstructorParameters 差不多,不过这次不 infer 参数了,而是 infer 函数返回数据

1
type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) =>  infer R ? R : never

记一次 Content-Security-Policy 请求头的坑

公司一个项目用的 webpack-dev-server 进行代理服务,所有资源包括html,css,js等文件以及接口都是通过它进行访问,我想把本地开发效果给测试同事看,然后我把 webpack-dev-server 的 host 设置成 0.0.0.0,
然后有意思的来了,我通过 localhost:8080,127.0.0.1:8080,0.0.0.0:8080都可以访问,然而神奇的是通过lan_ip:8080却只能访问到html,其他资源请求都转化成 HTTPS 请求了。

Hexo Blog 访问优化备忘小记

之前使用 Netlify 进行自动部署应用,但是发现访问还是有些慢,索性花点心思把整个网站网络访问优化一遍。

CDN 优化

google fontfont-awesome 换成了国内的 CDN,jsdelivr 家的 CDN 国内访问速度还不错就懒得换了。

1
2
3
4
providers:
cdn: jsdelivr
fontcdn: https://fonts.loli.net/${type}?family=${fontname}
iconcdn: https://lib.baomitu.com/font-awesome/5.4.1/css/all.min.css

Lodash get 三个基础实现版本

接上篇,在 Vue 中你可以 这样 a.b.c.d 进行 watch,在 lodash 你也可以以同样的形式进行属性的读取,思索其中中源码实现才有了这篇文章
_.get 基本语法

1
2
3
4
5
6
7
// 基本语法
_.get(obj, path, "defaultValue");

_.get(obj, "a.b[0].c", "defaultValue");

// 或者 path 以数组的形式
_.get(obj, ["a", "b", "0", "c"], "defaultValue");

path 支持两种形式的传值,数组和字符串,我们需要转成统一的数组形式,便于取值

JS 最近进入 Stage 4 的两个提案

今天刷推特的时候看到 TC39 的成员说 js 的两个语法糖提案进入 stage 4,最终板上钉钉,修成正果。

  • Optional Chaining
  • Nullish Coalescing
    其中 Optional Chaining 在这之前的 TS 3.7 中就被引入了

Optional Chaining

在此之前使用我们通常获取一个多层级对象一个比较内层的属性,为了避免为空的情况不得不这样写

1
let prop = a.b && a.b.c && a.b.c.d // 取到值或者赋值为 undefined

习惯了 Lodash 之后

1
let prop = _.get(a, "b.c.d", undefined) // 可以自定义默认值

使用 Optional Chaining 之后

1
let prop = a?.b?.c?.d

Macbook Pro 外接 4K 显示器

双十一忍不住剁手买了台 4K 显示器,于是就有了这篇文章。

步骤一

禁用 SIP,先重启 Mac,摁住 Command + R 直到出现 Apple logo,松开进入 recovery mode,进入菜单栏找到 terminal,输入以下等待重启。

1
csrutil enable; reboot

步骤二

到这个repo中根据自己的 mac OS 版本下载对应的 patch 文件到本地,macOS >= 10.12,下载CoreDisplay Patcher,否则下载IOKit Patcherchmod +x修改成可执行权限后执行。然后重启电脑。

关于 Vue 的一些冷门技巧

watch

有时候我们会写一些这样的代码, 在 created 一个组件之后获取数据, 然后根据某个值得变动来获取数据, 正常套路是按照下面写的,

1
2
3
4
5
6
7
8
created() {
this.fetchUserList()
},
watch: {
searchText () {
this.fetchUserList ()
}
}

但是 watch 的 api 其实很复杂, 语法糖甜的掉牙了. 于是我们可以简化为

译:如何使用纯函数式 JavaScript 处理脏副作用

首先,假定你对函数式编程有所涉猎。用不了多久你就能明白纯函数的概念。随着深入了解,你会发现函数式程序员似乎对纯函数很着迷。他们说:“纯函数让你推敲代码”,“纯函数不太可能引发一场热核战争”,“纯函数提供了引用透明性”。诸如此类。他们说的并没有错,纯函数是个好东西。但是存在一个问题……

纯函数是没有副作用的函数。[1] 但如果你了解编程,你就会知道副作用是关键。如果无法读取 𝜋 值,为什么要在那么多地方计算它?为了把值打印出来,我们需要写入 console 语句,发送到 printer,或其他可以被读取到的地方。如果数据库不能输入任何数据,那么它又有什么用呢?我们需要从输入设备读取数据,通过网络请求信息。这其中任何一件事都不可能没有副作用。然而,函数式编程是建立在纯函数之上的。那么函数式程序员是如何完成任务的呢?

简单来说就是,做数学家做的事情:欺骗

Rxjs Scheduler 下的 eventloop

本文将简单介绍 event loop 在 RxJS 中运用. 偏重于 RxJS Scheduler 而不是 event loop

event loop

event loop 是一种任务调度机制, 用来处理异步任务, 在 JavaScript 单线程语言中, 在同一时间内只能只能处理一件事, 当我们遇到需要处理异步的情况, 不能让异步操作阻塞线程, 我们可以让异步任务先搁置, 先执行同步任务, 等异步时间来临再来执行异步任务, 这就是 event loop.

JavaScript 在执行过程中, 变量与对象会存入对中, 另外还会维护一个任务队列(包括宏任务队列, 微任务队列), 执行函数会被推入栈中, 一旦函数执行完毕就会从函数中推出, 一旦整个执行栈被清空, 就开始处理任务队列, 按照一定逻辑将任务推入执行栈中执行, 执行过程中可能会往任务队列中添加其他任务,微任务具有高优先级, 在单个宏任务的切换中间会检查执行整个微任务队列, 再去执行下一个宏任务

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×