Skip to content

Commit 280efe4

Browse files
committed
191
1 parent 9b54c13 commit 280efe4

File tree

2 files changed

+139
-1
lines changed

2 files changed

+139
-1
lines changed

190.精读《DOM diff 原理详解》.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Vue 的 Dom diff 一共 5 步,我们结合下图先看前三步:
6161
如图所示,1、2、3、4 步走完后,Old 和 New 都有剩余,因此走到第五步,第五步分为三小步:
6262

6363
1. 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。
64-
2. 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 里不存在的说明被删除了,直接删除。
64+
2. 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。
6565
3. 不存在的位置补 0,我们拿到 `e:4 d:3 c:2 h:0` 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。
6666

6767
最后一步的优化也很关键,我们不要看见不同就随便移动,为了性能最优,要保证移动次数尽可能的少,那么怎么才能尽可能的少移动呢?假设我们随意移动,如下图所示:

191.精读《高性能表格》.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
每个前端都想做一个完美的表格,业界也在持续探索不同的思路,比如钉钉表格、语雀表格。
2+
3+
笔者所在数据中台团队也对表格有着极高的要求,尤其是自助分析表格,需要兼顾性能与交互功能,本文便是记录自助分析表格高性能的研发思路。
4+
5+
## 精读
6+
7+
要做表格首先要选择基于 DOM 还是 Canvas,这是技术选型的第一步。比如钉钉表格就是 [基于 Canvas 实现的](https://zhuanlan.zhihu.com/p/340423350),当然这不代表 Canvas 实现就比 DOM 实现要好,从技术上各有利弊:
8+
9+
- Canvas 渲染效率比 DOM 高,这是浏览器实现导致的。
10+
- DOM 可拓展性比 Canvas 好,渲染自定义内容首选 DOM 而非 Canvas。
11+
12+
技术选型要看具体的业务场景,钉钉表格其实就是在线 Excel,Excel 这种形态决定了单元格内一定是简单文本加一些简单图标,因此不用考虑渲染自定义内容的场景,所以选择 Canvas 渲染在未来也不会遇到不好拓展的麻烦。
13+
14+
而自助分析表格天然可能拓展图形、图片、操作按钮到单元格中,对轴的拖拽响应交互也非常复杂,为了不让 Canvas 成为以后拓展的瓶颈,还是选择 DOM 实现比较妥当。
15+
16+
那问题来了,既然 DOM 渲染效率天然比 Canvas 低,我们应该如何用 DOM 实现一个高性能表格呢?
17+
18+
其实业界已经有许多 DOM 表格优化方案了,主要以按需渲染、虚拟滚动为主,即预留一些 Buffer 区域用于滑动时填充,表格仅渲染可视区域与 Buffer 区域部分。但这些方案都不可避免的存在快速滑动时白屏问题,笔者通过不断尝试终于发现了一种完美解决的方案,我们一起往下看吧!
19+
20+
### 单元格使用 DIV 绝对定位
21+
22+
即每个单元格都是用绝对定位的 DIV 实现,整个表格都是有独立计算位置的 DIV 拼接而成的:
23+
24+
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01bMSq5o1O6UYaRnq44_!!6000000001656-2-tps-592-498.png">
25+
26+
这样做的前提是:
27+
28+
1. 所有单元格位置都要提前计算,这里可以利用 web worker 做并行计算。
29+
2. 单元格合并仅是产生一个更大的单元格,它的定位方式与小单元格并无差异。
30+
31+
32+
带来的好处是:
33+
34+
1. 滚动时,单元格可以最大程度实现复用。
35+
2. 对于合并的单元格,只会让可视区域渲染的总单元格数更小,更利于性能提升,而不是带来性能负担。
36+
37+
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01jBdpGe1G4Bddh9AX6_!!6000000000568-2-tps-690-520.png">
38+
39+
如图所示有 16 个单元格,当我们向右下滑动一格时,中间 3x3 即 9 个格子的区域是完全不会重新渲染的,这样零散的绝对定位分布可以最大程度维持单元格本来的位置。我们可以认为,任何一格单元格只要自身不超出屏幕范围,就不会随着滚动而重渲染。
40+
41+
如果你采用 React 框架来实现,只要将每个格子的 key 设置为唯一的即可,比如当前行列号。
42+
43+
### 模拟滚动而非原生滚动
44+
45+
一般来说,轴因为逻辑特殊,其渲染逻辑和单元格会分开维护,因此我们将表格分为三个区域:横轴、纵轴、单元格。
46+
47+
显然,常识是横轴只能纵向滚动,纵轴只能横向滚动,单元格可以横纵向滚动,那么横向和纵向滚动条就只能出现在单元格区域:
48+
49+
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01KNbNZd1P2erBdrwto_!!6000000001783-2-tps-1102-666.png">
50+
51+
这样会存在三个问题:
52+
53+
1. 单元格使用原生滚动,横纵轴只能在单元格区域监听滚动后,通过 `.scroll` 模拟滚动,这必然会导致单元格与轴滚动有一定错位,即轴的滚动有几毫秒的滞后感。
54+
2. 鼠标放在轴上时无法滚动,因为只有单元格是 `overflow: auto` 的,而轴区域 `overflow: hidden` 无法触发滚动。
55+
3. 快速滚动出现白屏,即便留了 Buffer 区域,在快速滚动时也无能为力,这是因为渲染速度跟不上滚动导致的。
56+
57+
经过一番思考,我们只要将方案稍作调整,就能同时解决上面三个问题:即不要使用原生的滚动条,而是使用 `.scroll` 代替滚动,用 `mousewheel` 监听滚动的触发:
58+
59+
<img width=300 src="https://img.alicdn.com/imgextra/i2/O1CN01EbHR0X1Zy2dgLaNXl_!!6000000003262-2-tps-688-498.png">
60+
61+
这样做带来什么变化呢?
62+
63+
1. 轴、单元格区域都使用 `.scroll` 触发滚动,使得轴和单元格不会出现错位,因为轴和单元格都是用 `.scroll` 触发的滚动。
64+
2. 任何位置都能监听滚动,使得轴上也能滚动了,我们不再依赖 `overflow` 属性。
65+
3. 快速滚动时惊喜的发现不会白屏了,原因是用 `js` 控制触发的滚动发生在渲染完成之后,所以浏览器会在滚动发生前现完成渲染,这相当有趣。
66+
67+
模拟滚动时,实际上整个表格都是 `overflow: hidden` 的,浏览器就不会给出自带滚动条了,我们需要用 DIV 做出虚拟滚动条代替,这个相对容易。
68+
69+
### 零 buffer 区域
70+
71+
当我们采用模拟滚动方案时,相当于采用了在滚动时 “高频渲染” 的方案,因此不需要使用截留,更不要使用 Buffer 区域,因为更大的 Buffer 区域意味着更大的渲染开销。
72+
73+
当我们把 Buffer 区域移除时,发现整个屏幕内渲染单元格在 1000 个以内时,现代浏览器甚至配合 Windows 都能快速完成滚动前刷新,并不会影响滚动的流畅性。
74+
75+
当然,滚动过快依然不是一件好事,既然滚动是由我们控制的,可以稍许控制下滚动速度,控制在每次触发 `mousewheel` 位移不超过 200 左右最佳。
76+
77+
### 预计算
78+
79+
像单元格合并、行列隐藏、单元格格式化等计算逻辑,最好在滚动前提前算掉,否则在快速滚动时实时计算必然会带来额外的计算成本损耗。
80+
81+
但是这种预计算也有弊端,当单元格数量超过 10w 时,计算耗时一般会超过 1 秒,单元格数量超过 100w 时,计算耗时一般会超过 10 秒,用预计算的牺牲换来滚动的流畅,还是有些遗憾,我们可以再思考以下,能否降低预计算的损耗?
82+
83+
### 局部预计算
84+
85+
局部预计算就是一种解决方案,即便单元格数量有一千万个,但我们如果仅计算前 1w 个单元格呢?那无论数据量有多大,都不会出现丝毫卡顿。
86+
87+
但局部预计算有着明显缺点,即表格渲染过程中,局部计算结果并不总等价于全局计算结果,典型的有列宽、行高、跨行跨列的计算字段。
88+
89+
我们需要针对性解决,对于单元格宽高计算,必须采用局部计算,因为全量计算的损耗非常大。但局部计算肯定是不准确的,如下图所示:
90+
91+
<img width=300 src="https://img.alicdn.com/imgextra/i4/O1CN01wCELBj1LX6j8k9ZJs_!!6000000001308-2-tps-678-476.png">
92+
93+
但出于性能考虑,我们初始化可能仅能计算前三行的高度,此时,我们需要在滚动时做两件事情:
94+
95+
1. 在快速滚动的时候,向 web worker 发送预计要滚动到的位置,增量计算这些位置文字宽度,并实时修正列总宽。(因为列总宽算完只要存储最大值,所以已计算的数量级会被压缩为 O(1))。
96+
2. 宽度计算完毕后,快速刷新当前屏幕单元格宽度,但在宽度校准的同时,维持可视区域内左对齐不变,如下图所示:
97+
98+
<img width=350 src="https://img.alicdn.com/imgextra/i3/O1CN01iId6Sl21tkA9tDTv6_!!6000000007043-2-tps-838-1024.png">
99+
100+
这样滚动过程中虽然单元格会被突然撑开,但位置并不会产生相对移动,与提前全量撑开后视觉内容相同,因此用户体验并不会有实际影响,但计算时间却由 O(row * column) 下降到 O(1),只要计算一个常数量级的单元格数目。
101+
102+
计算字段也是同理,可以在滚动时按片预计算,但要注意仅能在计算涉及局部单元格的情况下进行,如果这个计算是全局性质的,比如排名,那么局部排序的排名肯定是错误的,我们必须进行全量计算。
103+
104+
好在,即便是全量计算,我们也只需要考虑一部分数据,假设行列数量都是 n,可以将计算复杂度由 O(n²) 降低为 O(n):
105+
106+
<img width=800 src="https://img.alicdn.com/imgextra/i2/O1CN015bremD1en7Gnl7dGX_!!6000000003915-2-tps-1706-1310.png">
107+
108+
这种计算字段的处理无法保证支持无限数量级的数据,但可以大大降低计算时间,假设 1000w 单元格计算时间开销是 60s,这是一个几乎不能忍受的时间,假设 1000w 单元格是 1w 行 * 1k 列形成的,我们局部计算的开销是 1w 行(100ms) + 1k 列(10ms) = 0.1s,对用户来说几乎感受不到 1000w 单元格的卡顿。
109+
110+
在 10w 行 * 10w 列的情况下,等待时间是 1+1 = 2s,用户会感受到明显卡顿,但总单元格数量可是惊人的 100 亿,光数据可能就几 TB 了,不可能出现这种规模的聚合数据。
111+
112+
### Map Reduce
113+
114+
前端计算还可以采用多个 web worker 加速,总之不要让用户电脑的 CPU 闲置。我们可以通过 `window.navigator.hardwareConcurrency` 获取硬件并行能支持的最大 web worker 数量,我们就实例化等量的 web worker 并行计算。
115+
116+
拿刚才排名的例子来说,同样 1000w 单元格数量,如果只有一列呢?那行数就是扎扎实实的 1000w,这种情况下,即便 O(n) 复杂度计算耗时也可能突破 60s,此时我们就可以分段计算。我的电脑 `hardwareConcurrency` 值为 8,那么就实例化 8 个 web worker,分别并行计算第 `0 ~ 125w`, `125w ~ 250w` ..., `875w ~ 1000w` 段的数据分别进行排序,最后得到 8 段有序序列,在主 worker 线程中进行合并。
117+
118+
我们可以采用分治合并,即针对依次收到的排序结果 x1, x2, x3, x4...,将收到的结果两两合并成 x12, x34, ...,再次合并为 x1234 直到合并为一个数组为止。
119+
120+
当然,Map Reduce 并不能解决所有问题,假设 1000w 数据计算耗时 60s,我们分为 8 段并行,每一段平均耗时 7.5s,那么第一轮排序总耗时为 7.5s。分治合并时间复杂度为 O(kn logk),其中 k 是分段数,这里是 8 段,logk 约等于 3,每段长度 125w 是 n,那么一个 125w 数量级的二分排序耗时大概是 4.5s,时间复杂度是 O(n logn),所以等价为 logn = 4.5s, k x logk 等于几?这里由于 k 远小于 n,所以时间消耗会远小于 4.5s,加起来耗时不会超过 10s。
121+
122+
## 总结
123+
124+
如果你想打造高性能表格,DIV 性能足够了,只要注意实现的时候稍加技巧即可。你可以用 DIV 实现一个兼顾性能、拓展性的表格,是时候重新相信 DOM 了!
125+
126+
笔者建议读完本文的你,按照这样的思路做一个小 Demo,同时思考,这样的表格有哪些通用功能可以抽象?如何设计 API 才能成为各类业务表格的基座?如何设计功能才能满足业务层表格繁多的拓展诉求?
127+
128+
> 讨论地址是:[精读《高性能表格》· Issue #309 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/309)
129+
130+
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
131+
132+
> 关注 **前端精读微信公众号**
133+
134+
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
135+
136+
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
137+
138+

0 commit comments

Comments
 (0)