Compare commits

...
7 Commits
Author SHA1 Message Date
ascoders 280efe4545 191 2021-04-12 09:51:09 +08:00
ascoders 9b54c138a7 fix bug 2021-04-07 14:01:02 +08:00
ascoders c253b7d60f rename 190 2021-04-06 09:47:32 +08:00
ascoders 367bba2692 189 2021-04-06 09:46:50 +08:00
ascoders e888c4e22c 189 2021-03-29 10:33:40 +08:00
ascoders 8a3425b5e3 188 2021-03-22 07:52:11 +08:00
ascoders 9081bde65e 187 2021-03-15 08:43:49 +08:00
5 changed files with 629 additions and 0 deletions
@@ -0,0 +1,86 @@
# Strategy(策略模式)
Strategy(策略模式)属于行为型模式。
**意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。**
策略是个形象的表述,所谓策略就是方案,我们都知道任何事情都有多种方案,而且不同方案都能解决问题,所以这些方案可以相互替换。我们将方案从问题中抽象出来,这样就可以抛开问题,单独优化方案了,这就是策略模式的核心思想。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 地图导航
我们去任何地方都可以选择步行、骑车、开车、公交,不同的方案都可以帮助我们到达目的地,那么很明显应该将这些方案变成策略封装起来,接收的都是出发点和目的地,输出的都是路线。
### 布局方式
比如我们做一个报表系统,在 PC 使用珊格布局,在移动端使用流式布局,其实内容还是那些,只是布局方式会随着不同终端大小做不同的适配,那么布局的适配就是一种策略,它可以与报表内容无关。
我们可以将布局策略单独抽取出来,以后甚至可以适配电视机、投影仪等等不同尺寸的场景,而不需要对其他代码做任何改动,这就是将布局策略从代码中解耦出来的好处。
### 排序算法
当我们调用 `.sort` 时,使用的是什么排序算法?可能是冒泡、快速、插入排序?其实无论何种排序算法,本质上做的事情都是一样的,我们可以事先将排序算法封装起来,针对不同特性的数组调用不同的排序算法。
## 意图解释
**意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。**
算法可以理解为策略,我们制定许多解决某个场景的策略,这些策略都可以独立的解决这个场景的问题,这样下次遇到这个场景时,我们就可以选择任何策略来解决,而且我们还可以脱离场景,单独优化策略,只要接口不变即可。
这个意图本质上就是解耦,解耦之后才可以分工。想想一个复杂的系统,如果所有策略都耦合在业务逻辑里,那么只有懂业务的人才能小心翼翼的维护,但如果将策略与业务解耦,我们就可以独立维护这些策略,为业务带来更灵活的变化。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01oQ1Vvc1kHPXNk8vzD_!!6000000004658-2-tps-1578-480.png">
- Strategy: 策略公共接口。
- ConcreteStrategy: 具体策略,实现了上面这个接口。
只要你的策略符合接口,就满足策略模式的条件。
## 代码例子
下面例子使用 typescript 编写。
```typescript
interface Strategy {
doSomething: () => void
}
class Strategy1 implements Strategy {
doSomething: () => {
console.log('实现方案1')
}
}
class Strategy2 implements Strategy {
doSomething: () => {
console.log('实现方案2')
}
}
// 使用
new System(new Strategy1()) // 策略1实现的系统
new System(new Strategy2()) // 策略2实现的系统
```
## 弊端
不要走极端,不要每个分支走一个策略模式,这样会导致策略类过多。当分支逻辑简单清晰好维护时,不需要使用策略模式抽象。
## 总结
策略模式是很重要的抽象思维,我们首先要意识到问题有许多种解法,才能意识到策略模式的存在。当一个问题需要采取不同策略,且策略相对较复杂,且未来可能要拓展新策略时,可以考虑使用策略模式。
> 讨论地址是:[精读《设计模式 - Strategy 策略模式》· Issue #304 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/304)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,88 @@
# Template Method(模版模式)
Template Method(模版模式)属于行为型模式。
**意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 模版文件
我们办事打印的文件就是模版文件,只需要写上个人基本信息再签字就可以了,我们不需要做太多的重复劳动,因为某些场景下大部分内容是可以固化下来的。比如买卖房屋,那大部分甲方乙方的条款是固定的,最大的变化是甲方与乙方的不同,我们在模版上签字时,就是利用了模版模式减少了大量写条款的时间。
### 实例化
实例化也可以认为是模版模式的某种表现形式,因为对于工厂方法,我们传入不同的初始值可能给出不同结果,那么实际上就是用很少的代码撬动了很大一块功能,起到了抽象作用。
### Vue 模版
Vue 模版更符合我们对模版直觉的理解。这个场景中,模版指的是 HTML 模版,我们只需要在模版中以 `{}` 形式描述一些变量,就可以生成一块只有局部变量变化的模版 DOM,非常方便。
## 意图解释
**意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。**
这个设计模式初衷是用于面向对象的,所以考虑的是如何在类中运用模版模式。首先定义一个父类,实现了一些算法,再将需要被子类重载的方法提出来,子类重载这些部分方法后,即可利用父类实现好的算法做一些功能。
比如说父类方法 `function a() { b() + c() }`,此时子类只需要重定义 b 与 c 方法,即可复用 a 的算法(b 与 c 相加)。当然这个例子比较简单,当算法较为复杂时,模版模式的好处将凸显出来。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01DLdURm1t90ovmVI1g_!!6000000005858-2-tps-1150-652.png">
- ConcreteClass: 具体的父类。可以看到父类中实现了 TemplateMethod,其调用了 primitiveOperation1 与 primitiveOperation2, 所以子类只需要重载这两个方法,即可享用 TemplateMethod 提供的算法。
假设 TemplateMethod 是 `OpenDocument` 打开文档的作用,那么 primitiveOperation1 可能是 `CanOpen` 校验,`primitiveOperation2` 可能是 `ReadDocument` 读取文档方法。
我们只要专心实现具体的细节方法,而不需要关心他们之间是如何相互作用的,父级会帮我们实现它。之后我们就可以调用子类的 `OpenDocument` 实现打开文档了。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class View {
doDisplay(){}
display() {
this.setFocus()
this.doDisplay()
this.resetFocus()
}
}
class MyView extends View {
doDisplay(){
console.log('myDisplay')
}
}
const myView = new MyView()
myView.display()
```
这个例子中,`doDisplay` 表示父类希望子类重载的方法,一般以 `do` 约定打头。
## 弊端
模版模式用在类中,本质上是固定不可变的结构,进一步缩小重写方法的范围,重写的范围越小,代码可复用度就越高,所以一定要在具有通用算法可提取的情况下使用,而不要为了节省代码行数而过度使用。
另外前端开发中,HTML 本身就很契合模版模式,因为 HTML 中有大量标签描述千变万化的 UI 结构,可复用的地方实在太多太多,所以非常适合模版模式,所以不要认为模版模式仅能在类中使用,模版模式还能在脚手架使用呢,比如填入一些表单自动生成代码。
学习这个设计模式时,注意不要固化思维在其定义的类这个框子中,因为设计模式写于 1994 年,其中提到的模式已经被大量迁移运用,能否识别并做适当的知识迁移,是 20 多年后的今天学习设计模式的关键。
## 总结
模版模式与策略模式有一定相似处,模版模式是改变算法的一部分,而策略模式是将策略完全提取出来,所以可以改变算法的全部。
> 讨论地址是:[精读《设计模式 - Template Method 模版模式》· Issue #305 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/305)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,156 @@
# Visitor(访问者模式)
Visitor(访问者模式)属于行为型模式。
**意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。**
访问者,顾名思义,就是对象访问的一种设计模式,我们可以在不改变要访问对象的前提下,对访问对象的操作做拓展。
## 举例子
由于能应用访问者模式的场景很少,所以这里只举一个例子。
### 建造游戏中的资源设计
假设你制作一款城市建造游戏,游戏的基础资源只有毛皮、木材、铜矿、铁矿。你需要用这些资源建造各种,比如造楼房、做衣服、制作家具、门、空调、甚至锅、健身房、游泳馆等。记住一个前提,就是你想把游戏设计的非常逼真,所以每种资源的不同使用方法都非常定制,不是简单的消耗 N 个数量就能完成,比如制作家具时,需要用到毛皮和木材,此时毛皮和木材对环境、制作人、资金都有不同的要求。
常见的想法是,我们将资源的所有使用方法都枚举在资源类中,这样资源就在用到不同场景时,调用不同方法即可。但问题是资源本身其实较为固定,我们每增加一种用途就修改一次木材、铁矿的类会显得非常麻烦。
能不能在增加新用途时,不修改原始资源类呢?答案是可以用访问者模式。
## 意图解释
**意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。**
第一句话指明了 Visitor 的作用,即 “作用于某对象结构中的各元素的操作”,也就是 Visitor 是用于操作对象元素的。“它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作” 也就是说,你可以只修改 Visitor 本身完成新操作的定义,而不需要修改原本对象。
这看上去比较奇怪,给对象定义新的操作,竟然不用修改对象本身,而通过改另外一个对象就可以?这就是 Visitor 设计的奇妙之处,它将对象的操作权移交给了 Visitor。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i3/O1CN01uUsrEF1LACvBPBs7j_!!6000000001258-2-tps-1738-1346.png">
- Visitor:访问者接口。
- ConcreteVisitor:具体的访问者。
- Element 可以被访问者使用的元素,它必须定义一个 Accept 属性,接收 visitor 对象。这是实现访问者模式的关键。
- ObjectStructure:对象结构,存储了多个 Element,利用 Visitor 进行批量操作。
可以看到,要实现操作权转让到 Visitor,核心是元素必须实现一个 Accept 函数,将这个对象抛给 Visitor:
```typescript
class ConcreteElement implements Element {
public accept(visitor: Visitor) {
visitor.visit(this)
}
}
```
从上面代码可以看出这样一条链路:Element 通过 `accept` 函数接收到 Visitor 对象,并将自己的实例抛给 Visitor 的 `visit` 函数,**这样我们就可以在 Visitor 的 `visit` 方法中拿到对象实例,完成对对象的操作。**
## 代码例子
下面例子使用 typescript 编写。
```typescript
class ConcreteVisitorX implements Visitor{
public visit(element: ELement) {
element.accept(this);
}
public visit(concreteElementA: ConcreteElementA) {
console.log('X 操作 A')
}
public visit(concreteElementB: ConcreteElementB) {
console.log('X 操作 B')
}
}
class ConcreteVisitorY implements Visitor{
public visit(element: ELement) {
element.accept(this);
}
public visit(concreteElementA: ConcreteElementA) {
console.log('Y 操作 A')
}
public visit(concreteElementB: ConcreteElementB) {
console.log('Y 操作 B')
}
}
```
配合上面已经写过的 `Element`,可以看到,经历了如下过程:
```typescript
// 先创建元素
const element = new ConcreteElement()
// 访问者 X
const visitorX = new ConcreteVisitorX()
// 访问者 Y
const visitorY = new ConcreteVisitorY()
// 然后让访问者 visit 观察一下元素
visitorX.visit(element as Element)
visitorY.visit(element as Element)
```
要注意的是,访问者观察的 Element 一定要是通用类型 Element,而不是一个具体类型 ConcreteElement,否则访问者模式抽象性就无法体现了,因为 Visitor 可以访问任何类型的 Element,所以先把接口传进去。
到这里,我们看看下面经历了什么:首先 Visitor 定义的 `visit` 会被调用,由于符合了 Element 这个通用类型,所以会调用 Element 接口定义的 `accept` 函数,这是所有元素都有的方法。
接下来,每个具体元素都重写了 `accept` 方法:
```typescript
public accept(visitor: Visitor) {
visitor.visit(this)
}
```
所以又调用了 Visitor 的 `visit` 函数,不同的是,此时的参数是具体 Element 类型,所以可能调用到的是具体对某个元素处理的 `visit` 方法,比如:
```typescript
public visit(concreteElementA: ConcreteElementA) {
console.log('X 操作 A')
}
```
最终就输出了 “X 操作 A” 这段话。
我们可以看到这样的程序拓展性有这么些:
1. Element 元素的所有子类都不用频繁修改,只要修改 Visitor 即可。
2. 一个 Visitor 可以选择性的操作任何类型的 Element 子类,只要申明了处理函数即可处理,不申明就不会命中,比较方便。在城市建造的例子中,可以提现为锅需要用铁制作,但不需要消耗木材,所以不需要定义木材的 `visit` 方法。
3. 可以定义多种 Visitor,对同一种 Element 子类也可以有不同的操作,这在我们城市建造的例子中,可以体现为门和窗户,对铁矿的使用是不同的。
由此一来,我们就能在城市建造的例子中拓展出任意多种使用资源的场景,而无需让资源感知到这些场景的存在。
## 弊端
访问者模式使用场景非常有限,请确定你的场景满足以上情况再使用。如果资源并不需要频繁修改和拓展,那么就没必要使用访问者模式。
## 总结
访问者模式的精髓,就是在不断拓展的业务场景中,防止基础元素代码不断膨胀。
假设我们这款城市建造游戏有 20 人团队开发,每周发布 2 个版本,每个版本新增了几种资源的组合使用方式,由于资源一共就木材、铁矿、铜矿那么几种,如果你作为团队负责人,任大家随意修改这些资源基础类,过不了半年就会发现,木材类的成员方法突破了 100 种,而且以每天新增 2 种的速度不断增加,你会明显发现自己精心打造的程序即将变成一堆屎山。
更要命的是,你还搞不清楚哪些场景的用法是打包的,当一种使用场景下线时,已存在的成员方法还不敢删除。
假设你用了访问者模式,会发现,每天因为迭代而新增的那几个方法,都会放到一个新 Visitor 文件下,比如一种纳米材料的门板在游戏 V1.5 版本被引进,它对材料的使用会体现在新增一个 Visitor 文件,资源本身的类不会被修改,这既不会引发协同问题,也使功能代码按照场景聚合,不论维护还是删除的心智负担都非常小。
访问者模式背后的思考本质还是,基础的元素数量一般不会随着程序迭代产生太大变化,而对这些基础元素的使用方式或组合使用会随着程序迭代不断更新,我们将变化更快的通过 Visitor 打包提取出来,自然会更利于维护。
> 讨论地址是:[精读《设计模式 - Visitor 访问者模式》· Issue #306 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/306)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+161
View File
@@ -0,0 +1,161 @@
DOM diff 作为工程问题,需要具有一定算法思维,因此经常出现在面试场景中,毕竟这是难得出现在工程领域的算法问题。
无论出于面试目的,还是深入学习目的,都有必要将这个问题搞懂,因此前端精读我们就专门用一个章节说清楚此问题。
## 精读
Dom diff 是所有现在框架必须做的事情,这背后的原因是,由 Jquery 时代的面向操作过程转变为数据驱动视图导致的。
为什么 Jquery 时代不需要 Dom diff?因为 Dom diff 交给业务处理了,我们调用 `.append` 或者 `.move` 之类 Dom 操作函数,就是显式申明了如何做 Dom diff,这种方案是最高效的,因为怎么移动 Dom 只有业务最清楚。
但这样的问题也很明显,就是业务心智负担太重,对于复杂系统,需要做 Dom diff 的地方太多,不仅写起来繁琐,当状态存在交错时,面向过程的手动 Dom diff 容易出现状态遗漏,导致边界错误,就算你没有写出 bug,代码的可维护性也绝对算不上好。
解决方案就是数据驱动,我们只需要关注数据如何映射到 UI,这样无论业务逻辑再复杂,我们永远只需要解决局部状态的映射,这极大降低了复杂系统的维护复杂度,以前需要一个老手写的逻辑,现在新手就能做了,这是非常了不起的变化。
但有利也有弊,这背后 Dom diff 就要交给框架来做了,所以是否能高效的做 Dom diff,是一个数据驱动框架能否应用于生产环境的重要指标,接下来,我们来看看 Dom diff 是如何做的吧。
### 理想的 Dom diff
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01wmLt541W6xt9a1BPm_!!6000000002740-2-tps-1112-814.png">
如图所示,理想的 Dom diff 自然是滴水不漏的复用所有能复用的,实在遇到新增或删除时,才执行插入或删除。这样的操作最贴近 Jquery 时代我们手写的 Dom diff 性能。
可惜程序无法猜到你的想法,想要精确复用就必须付出高昂的代价:时间复杂度 O(n³) 的 diff 算法,这显然是无法接受的,因此理想的 Dom diff 算法无法被使用。
> 关于 O(n³) 的由来。由于左树中任意节点都可能出现在右树,所以必须在对左树深度遍历的同时,对右树进行深度遍历,找到每个节点的对应关系,这里的时间复杂度是 O(n²),之后需要对树的各节点进行增删移的操作,这个过程简单可以理解为加了一层遍历循环,因此再乘一个 n。
### 简化的 Dom diff
<img width=700 src="https://img.alicdn.com/imgextra/i3/O1CN01SNNr8d1EvedrTG4eN_!!6000000000414-2-tps-1252-800.png">
如图所示,只按层比较,就可以将时间复杂度降低为 O(n)。按层比较也不是广度遍历,其实就是判断某个节点的子元素间 diff,跨父节点的兄弟节点也不必比较。
这样做确实非常高效,但代价就是,判断的有点傻,比如 ac 明明是一个移动操作,却被误识别为删除 + 新增。
好在跨 DOM 复用在实际业务场景中很少出现,因此这种笨拙出现的频率实际上非常低,这时候我们就不要太追求学术思维上的严谨了,毕竟框架是给实际项目用的,实际项目中很少出现的场景,算法是可以不考虑的。
下面是同层 diff 可能出现的三种情况,非常简单,看图即可:
<img width=700 src="https://img.alicdn.com/imgextra/i2/O1CN016XhiF01H2BWRGocp8_!!6000000000699-2-tps-1232-1380.png">
那么同层比较是怎么达到 O(n) 时间复杂度的呢?我们来看具体框架的思路。
### Vue 的 Dom diff
Vue 的 Dom diff 一共 5 步,我们结合下图先看前三步:
<img width=700 src="https://img.alicdn.com/imgextra/i3/O1CN01BehuWP22DRBybJJU8_!!6000000007086-2-tps-1288-690.png">
如图所示,第一和第二步分别从首尾两头向中间逼近,尽可能跳过首位相同的元素,因为我们的目的是 **尽量保证不要发生 dom 位移**
这种算法一般采用双指针。如果前两步做完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入即可。很简单吧?那如果反过来呢?如下图所示:
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01hFiUg71uJpnZzCjGh_!!6000000006017-2-tps-1430-582.png">
第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除即可。
当然,如果 1、2、3、4 步走完之后,指针还未处理完,那么就进入一个小小算法时间了,我们需要在 O(n) 时间复杂度内把剩下节点处理完。熟悉算法的同学应该很快能反映出,一个数组做一些检测操作,还得把时间复杂度控制在 O(n),得用一个 Map 空间换一下时间,实际上也是如此,我们看下图具体做法:
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01qRmBBb1VBFzHS1TFR_!!6000000002614-2-tps-1496-1038.png">
如图所示,1、2、3、4 步走完后,Old 和 New 都有剩余,因此走到第五步,第五步分为三小步:
1. 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。
2. 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。
3. 不存在的位置补 0,我们拿到 `e:4 d:3 c:2 h:0` 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。
最后一步的优化也很关键,我们不要看见不同就随便移动,为了性能最优,要保证移动次数尽可能的少,那么怎么才能尽可能的少移动呢?假设我们随意移动,如下图所示:
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01MZ1zs91uuTU8RWhPD_!!6000000006097-2-tps-906-484.png">
但其实最优的移动方式是下面这样:
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01D5MXxE1wvy0PfxUpY_!!6000000006371-2-tps-894-480.png">
为什么呢?因为移动的时候,其他元素的位置也在相对变化,可能做了 A 效果同时,也把 B 效果给满足了,也就是说,找到那些相对位置有序的元素保持不变,让那些位置明显错误的元素挪动即是最优的。
什么是相对有序?`a c e` 这三个字母在 Old 原始顺序 `a b c d e` 中是相对有序的,我们只要把 `b d` 移走,这三个字母的位置自然就正确了。因此我们只需要找到 New 数组中的 **最长子序列**。具体的找法可以当作一个小算法题了,由于知道每个元素的实际下标,比如这个例子中,下标是这样的:
`[b:1, d:3, a:0, c:2, e:4]`
肉眼看上去,连续自增的子串有 `b d``a c e`,由于 `a c e` 更长,所以选择后者。
换成程序去做,可以采用贪心 + 二分法进行查找,详细可以看这道题 [最长递增子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/),时间复杂度 O(nlogn)。由于该算法得出的结果顺序是乱的,Vue 采用提前复制数组的方式辅助找到了正确序列。
### React 的 Dom diff
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01YBUnOF28fjM4qW4v9_!!6000000007960-2-tps-882-158.png">
假设这么一种情况,我们将 a 移到了 c 后,那么框架从最终状态倒推,如何最快的找到这个动机呢?React 采用了 **仅右移策略**,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其他位置也就有序了。
我们看图说明:
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01EeRPFd1Rnwz7WUpiC_!!6000000002157-2-tps-1002-610.png">
遍历 Old 存储 Map 和 Vue 是一样的,然后就到了第二步遍历 New,`b` 下标从原来的 `1` 变成了 `0`,需要左移才行,但我们不左移,我们只右移,因为所有右移做完后,左移就等于自动做掉了(前面的元素右移后,自己自然被顶到前面去了,实现了左移的效果)。
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01kRDF2P1ErX4vjTkGq_!!6000000000405-2-tps-998-412.png">
同理,c 下标从 `2` 变成了 `1`,需要左移才行,但我们继续不动。
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01zsDAtY1DmCrAAag0f_!!6000000000258-2-tps-978-418.png">
a 的下标从 `0` 变成 `2`,终于可以右移了!
<img width=500 src="https://img.alicdn.com/imgextra/i1/O1CN01LCmhdG1TDQdQKEx2E_!!6000000002348-2-tps-980-488.png">
后面的 d、e 下标没变,就不用动。我们纵观整体可以发现,b 和 c 因为前面的 a 被抽走了,自然发生了左移。这就是用一个右移代替两个左移的高效操作。
同时我们发现,这也确实找到了我们开始提到的最佳位移策略。
那这个算法真的有这么聪明吗?显然不是,这个算法只是歪打误撞碰对了而已,**有用右移替代左移的算法,就有用左移替代右移的算法**,既然选择了右移替代左移,那么一定丢失了左移代替右移的效率。
什么时候用左移代替右移效率最高?就是把数组最后一位移到第一位的场景:
<img width=500 src="https://img.alicdn.com/imgextra/i2/O1CN01kAkNxD1YAHzyOTg7I_!!6000000003018-2-tps-904-144.png">
显然左移只要一步,那么右移就是 n-1 步,在这个例子就是 4 步,我们看右移算法图解:
<img width=500 src="https://img.alicdn.com/imgextra/i2/O1CN01UNjJcv1m64yfQz6wS_!!6000000004904-2-tps-974-400.png">
首先找到 e,位置从 `4` 变成了 `0`,但我们不能左移!所以只能保持不动,悲剧从此开始。
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01xmOwVd1TJqB2Hakpk_!!6000000002362-2-tps-1696-374.png">
虽然算法已经不是最优了,但该做的还是要做,其实之前有一个 lastIndex 概念没有说,因为 e 已经在 `4` 的位置了,所以再把 a 从 `0` 挪到 `1` 已经不够了,此时 a 应该从 `0` 挪到 `5`
方法就是记录 `lastIndex = max(oldIndex, newIndex)` => `lastIndex = max(4, 0)`,下一次移动到 `lastIndex + 1` 也就是 `5`
<img width=800 src="https://img.alicdn.com/imgextra/i2/O1CN01ylI1wb1FrowPwcROD_!!6000000000541-2-tps-1704-376.png">
发现 a 从 `0` 变成了 `5`(注意,此时考虑到 lastIndex 因素),所以右移。
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01UZ2Wf71SxrAQfLZ48_!!6000000002314-2-tps-1704-378.png">
同理,b、c、d 也一样。我们最后发现,发生了 4 次右移,e 也因为自然左移了 4 次到达了首位,符合预期。
所以这是一个有利有弊的算法。新增和删除比较简单,和 Vue 差不多。
PS:最新版 React Dom diff 算法如有更新,欢迎在评论区指出,因为这种算法看来不如 Vue 的高效。
## 总结
Dom diff 总结有这么几点考虑:
1. 完全对比 O(n³) 无法接受,故降级为同层对比的 O(n) 方案。
2. 为什么降级可行?因为跨层级很少发生,可以忽略。
3. 同层级也不简单,难点是如何高效位移,即最小步数完成位移。
4. Vue 为了尽量不移动,先左右夹击跳过不变的,再找到最长连续子串保持不动,移动其他元素。
5. React 采用仅右移方案,在大部分从左往右移的业务场景中,得到了较好的性能。
> 讨论地址是:[精读《DOM diff 原理详解》· Issue #308 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/308)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+138
View File
@@ -0,0 +1,138 @@
每个前端都想做一个完美的表格,业界也在持续探索不同的思路,比如钉钉表格、语雀表格。
笔者所在数据中台团队也对表格有着极高的要求,尤其是自助分析表格,需要兼顾性能与交互功能,本文便是记录自助分析表格高性能的研发思路。
## 精读
要做表格首先要选择基于 DOM 还是 Canvas,这是技术选型的第一步。比如钉钉表格就是 [基于 Canvas 实现的](https://zhuanlan.zhihu.com/p/340423350),当然这不代表 Canvas 实现就比 DOM 实现要好,从技术上各有利弊:
- Canvas 渲染效率比 DOM 高,这是浏览器实现导致的。
- DOM 可拓展性比 Canvas 好,渲染自定义内容首选 DOM 而非 Canvas。
技术选型要看具体的业务场景,钉钉表格其实就是在线 Excel,Excel 这种形态决定了单元格内一定是简单文本加一些简单图标,因此不用考虑渲染自定义内容的场景,所以选择 Canvas 渲染在未来也不会遇到不好拓展的麻烦。
而自助分析表格天然可能拓展图形、图片、操作按钮到单元格中,对轴的拖拽响应交互也非常复杂,为了不让 Canvas 成为以后拓展的瓶颈,还是选择 DOM 实现比较妥当。
那问题来了,既然 DOM 渲染效率天然比 Canvas 低,我们应该如何用 DOM 实现一个高性能表格呢?
其实业界已经有许多 DOM 表格优化方案了,主要以按需渲染、虚拟滚动为主,即预留一些 Buffer 区域用于滑动时填充,表格仅渲染可视区域与 Buffer 区域部分。但这些方案都不可避免的存在快速滑动时白屏问题,笔者通过不断尝试终于发现了一种完美解决的方案,我们一起往下看吧!
### 单元格使用 DIV 绝对定位
即每个单元格都是用绝对定位的 DIV 实现,整个表格都是有独立计算位置的 DIV 拼接而成的:
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01bMSq5o1O6UYaRnq44_!!6000000001656-2-tps-592-498.png">
这样做的前提是:
1. 所有单元格位置都要提前计算,这里可以利用 web worker 做并行计算。
2. 单元格合并仅是产生一个更大的单元格,它的定位方式与小单元格并无差异。
带来的好处是:
1. 滚动时,单元格可以最大程度实现复用。
2. 对于合并的单元格,只会让可视区域渲染的总单元格数更小,更利于性能提升,而不是带来性能负担。
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01jBdpGe1G4Bddh9AX6_!!6000000000568-2-tps-690-520.png">
如图所示有 16 个单元格,当我们向右下滑动一格时,中间 3x3 即 9 个格子的区域是完全不会重新渲染的,这样零散的绝对定位分布可以最大程度维持单元格本来的位置。我们可以认为,任何一格单元格只要自身不超出屏幕范围,就不会随着滚动而重渲染。
如果你采用 React 框架来实现,只要将每个格子的 key 设置为唯一的即可,比如当前行列号。
### 模拟滚动而非原生滚动
一般来说,轴因为逻辑特殊,其渲染逻辑和单元格会分开维护,因此我们将表格分为三个区域:横轴、纵轴、单元格。
显然,常识是横轴只能纵向滚动,纵轴只能横向滚动,单元格可以横纵向滚动,那么横向和纵向滚动条就只能出现在单元格区域:
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01KNbNZd1P2erBdrwto_!!6000000001783-2-tps-1102-666.png">
这样会存在三个问题:
1. 单元格使用原生滚动,横纵轴只能在单元格区域监听滚动后,通过 `.scroll` 模拟滚动,这必然会导致单元格与轴滚动有一定错位,即轴的滚动有几毫秒的滞后感。
2. 鼠标放在轴上时无法滚动,因为只有单元格是 `overflow: auto` 的,而轴区域 `overflow: hidden` 无法触发滚动。
3. 快速滚动出现白屏,即便留了 Buffer 区域,在快速滚动时也无能为力,这是因为渲染速度跟不上滚动导致的。
经过一番思考,我们只要将方案稍作调整,就能同时解决上面三个问题:即不要使用原生的滚动条,而是使用 `.scroll` 代替滚动,用 `mousewheel` 监听滚动的触发:
<img width=300 src="https://img.alicdn.com/imgextra/i2/O1CN01EbHR0X1Zy2dgLaNXl_!!6000000003262-2-tps-688-498.png">
这样做带来什么变化呢?
1. 轴、单元格区域都使用 `.scroll` 触发滚动,使得轴和单元格不会出现错位,因为轴和单元格都是用 `.scroll` 触发的滚动。
2. 任何位置都能监听滚动,使得轴上也能滚动了,我们不再依赖 `overflow` 属性。
3. 快速滚动时惊喜的发现不会白屏了,原因是用 `js` 控制触发的滚动发生在渲染完成之后,所以浏览器会在滚动发生前现完成渲染,这相当有趣。
模拟滚动时,实际上整个表格都是 `overflow: hidden` 的,浏览器就不会给出自带滚动条了,我们需要用 DIV 做出虚拟滚动条代替,这个相对容易。
### 零 buffer 区域
当我们采用模拟滚动方案时,相当于采用了在滚动时 “高频渲染” 的方案,因此不需要使用截留,更不要使用 Buffer 区域,因为更大的 Buffer 区域意味着更大的渲染开销。
当我们把 Buffer 区域移除时,发现整个屏幕内渲染单元格在 1000 个以内时,现代浏览器甚至配合 Windows 都能快速完成滚动前刷新,并不会影响滚动的流畅性。
当然,滚动过快依然不是一件好事,既然滚动是由我们控制的,可以稍许控制下滚动速度,控制在每次触发 `mousewheel` 位移不超过 200 左右最佳。
### 预计算
像单元格合并、行列隐藏、单元格格式化等计算逻辑,最好在滚动前提前算掉,否则在快速滚动时实时计算必然会带来额外的计算成本损耗。
但是这种预计算也有弊端,当单元格数量超过 10w 时,计算耗时一般会超过 1 秒,单元格数量超过 100w 时,计算耗时一般会超过 10 秒,用预计算的牺牲换来滚动的流畅,还是有些遗憾,我们可以再思考以下,能否降低预计算的损耗?
### 局部预计算
局部预计算就是一种解决方案,即便单元格数量有一千万个,但我们如果仅计算前 1w 个单元格呢?那无论数据量有多大,都不会出现丝毫卡顿。
但局部预计算有着明显缺点,即表格渲染过程中,局部计算结果并不总等价于全局计算结果,典型的有列宽、行高、跨行跨列的计算字段。
我们需要针对性解决,对于单元格宽高计算,必须采用局部计算,因为全量计算的损耗非常大。但局部计算肯定是不准确的,如下图所示:
<img width=300 src="https://img.alicdn.com/imgextra/i4/O1CN01wCELBj1LX6j8k9ZJs_!!6000000001308-2-tps-678-476.png">
但出于性能考虑,我们初始化可能仅能计算前三行的高度,此时,我们需要在滚动时做两件事情:
1. 在快速滚动的时候,向 web worker 发送预计要滚动到的位置,增量计算这些位置文字宽度,并实时修正列总宽。(因为列总宽算完只要存储最大值,所以已计算的数量级会被压缩为 O(1))。
2. 宽度计算完毕后,快速刷新当前屏幕单元格宽度,但在宽度校准的同时,维持可视区域内左对齐不变,如下图所示:
<img width=350 src="https://img.alicdn.com/imgextra/i3/O1CN01iId6Sl21tkA9tDTv6_!!6000000007043-2-tps-838-1024.png">
这样滚动过程中虽然单元格会被突然撑开,但位置并不会产生相对移动,与提前全量撑开后视觉内容相同,因此用户体验并不会有实际影响,但计算时间却由 O(row * column) 下降到 O(1),只要计算一个常数量级的单元格数目。
计算字段也是同理,可以在滚动时按片预计算,但要注意仅能在计算涉及局部单元格的情况下进行,如果这个计算是全局性质的,比如排名,那么局部排序的排名肯定是错误的,我们必须进行全量计算。
好在,即便是全量计算,我们也只需要考虑一部分数据,假设行列数量都是 n,可以将计算复杂度由 O(n²) 降低为 O(n):
<img width=800 src="https://img.alicdn.com/imgextra/i2/O1CN015bremD1en7Gnl7dGX_!!6000000003915-2-tps-1706-1310.png">
这种计算字段的处理无法保证支持无限数量级的数据,但可以大大降低计算时间,假设 1000w 单元格计算时间开销是 60s,这是一个几乎不能忍受的时间,假设 1000w 单元格是 1w 行 * 1k 列形成的,我们局部计算的开销是 1w 行(100ms + 1k 列(10ms) = 0.1s,对用户来说几乎感受不到 1000w 单元格的卡顿。
在 10w 行 * 10w 列的情况下,等待时间是 1+1 = 2s,用户会感受到明显卡顿,但总单元格数量可是惊人的 100 亿,光数据可能就几 TB 了,不可能出现这种规模的聚合数据。
### Map Reduce
前端计算还可以采用多个 web worker 加速,总之不要让用户电脑的 CPU 闲置。我们可以通过 `window.navigator.hardwareConcurrency` 获取硬件并行能支持的最大 web worker 数量,我们就实例化等量的 web worker 并行计算。
拿刚才排名的例子来说,同样 1000w 单元格数量,如果只有一列呢?那行数就是扎扎实实的 1000w,这种情况下,即便 O(n) 复杂度计算耗时也可能突破 60s,此时我们就可以分段计算。我的电脑 `hardwareConcurrency` 值为 8,那么就实例化 8 个 web worker,分别并行计算第 `0 ~ 125w`, `125w ~ 250w` ..., `875w ~ 1000w` 段的数据分别进行排序,最后得到 8 段有序序列,在主 worker 线程中进行合并。
我们可以采用分治合并,即针对依次收到的排序结果 x1, x2, x3, x4...,将收到的结果两两合并成 x12, x34, ...,再次合并为 x1234 直到合并为一个数组为止。
当然,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。
## 总结
如果你想打造高性能表格,DIV 性能足够了,只要注意实现的时候稍加技巧即可。你可以用 DIV 实现一个兼顾性能、拓展性的表格,是时候重新相信 DOM 了!
笔者建议读完本文的你,按照这样的思路做一个小 Demo,同时思考,这样的表格有哪些通用功能可以抽象?如何设计 API 才能成为各类业务表格的基座?如何设计功能才能满足业务层表格繁多的拓展诉求?
> 讨论地址是:[精读《高性能表格》· Issue #309 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/309)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)