Compare commits

...
38 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
ascoders 4f783678da 186 2021-03-08 09:36:35 +08:00
ascoders 299a60a881 185 2021-03-01 08:59:43 +08:00
ascoders 7e651219b0 184 2021-02-22 10:11:00 +08:00
ascoders e1e7bc6f57 183 2021-02-01 09:05:50 +08:00
ascoders beed8434ec 182 2021-01-25 10:32:08 +08:00
ascoders ab13166c0a Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2021-01-20 15:12:50 +08:00
ascoders 71a48cde3b fix type 2021-01-20 15:12:30 +08:00
黄子毅 c603ac0d1d Merge pull request #297 from sosamuel/patch-1
Fix 校正笔误
2021-01-19 21:10:59 +08:00
Samuel Chia 316d3d98aa Fix 校正笔误 2021-01-18 10:43:42 +08:00
ascoders 0465cf43db 181 2021-01-18 10:14:59 +08:00
ascoders 65de19ddb6 181 2021-01-18 10:08:02 +08:00
ascoders 9c0f11cc55 181 2021-01-18 10:07:20 +08:00
ascoders 754ceb3d5a fix: typo 2021-01-10 11:28:15 +08:00
ascoders dcc5247ba0 fix typo 2021-01-10 11:23:26 +08:00
ascoders 02de198a8b 180 2021-01-10 11:21:15 +08:00
ascoders b7cfc4c6e3 179 2020-12-26 10:50:01 +08:00
ascoders 46a094e711 178 2020-12-20 23:22:19 +08:00
ascoders b5cd1b2379 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-12-13 21:45:29 +08:00
ascoders cfe94e6aa7 177 2020-12-13 21:44:46 +08:00
黄子毅 3362f25775 Merge pull request #289 from wiolem/patch-1
docs: typo onDropOver
2020-12-09 17:59:49 +08:00
William 87f9704831 Update 140.精读《结合 React 使用原生 Drag Drop API》.md 2020-12-09 10:40:49 +08:00
ascoders 6c7084dfe1 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-12-06 21:50:03 +08:00
ascoders 164590159d 176 2020-12-06 21:49:38 +08:00
黄子毅 b8ab46d26b Merge pull request #287 from spiritree/patch-3
Update 168.精读《设计模式 - Builder 生成器》.md
2020-12-03 20:19:48 +08:00
深樹 183b4fc41a Update 168.精读《设计模式 - Builder 生成器》.md
maybe misspell `Persion`->`Person`
2020-12-01 15:28:53 +08:00
ascoders 2b7ab34978 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-11-29 18:04:36 +08:00
ascoders 2ca7132569 175 2020-11-29 18:04:27 +08:00
黄子毅 aac9aa34f8 Merge pull request #283 from JeromeLin/patch-3
Update 003.精读前后端渲染之争.md
2020-11-29 11:29:25 +08:00
黄子毅 ad8a645462 Merge pull request #285 from spiritree/patch-1
Update 171.精读《设计模式 - Singleton 单例模式》.md
2020-11-26 21:46:06 +08:00
深樹 2dbe4d6368 Update 171.精读《设计模式 - Singleton 单例模式》.md
class method: `instance` -> `getInstance`
2020-11-25 19:14:31 +08:00
JeromeLin 8aab27bc3f Update 003.精读前后端渲染之争.md 2020-11-20 09:51:01 +08:00
23 changed files with 2125 additions and 9 deletions
+2 -2
View File
@@ -27,7 +27,7 @@
- 服务端渲染不需要先下载一堆 js 和 css 后才能看到页面(首屏性能)
- SEO
- 服务端渲染不用关心浏览器兼容性问题(随浏览器发展,这个优点逐渐消失)
- 服务端渲染不用关心浏览器兼容性问题(随浏览器发展,这个优点逐渐消失)
- 对于电量不给力的手机或平板,减少在客户端的电量消耗很重要
以上服务端优势其实只有首屏性能和 SEO 两点比较突出。但现在这两点也慢慢变得微不足道了。React 这类支持同构的框架已经能解决这个问题,尤其是 Next.js 让同构开发变得非常容易。还有静态站点的渲染,但这类应用本身复杂度低,很多前端框架已经能完全囊括。
@@ -142,4 +142,4 @@ Next.js 是时下非常流行的基于 React 的同构开发框架。作者之
> 讨论地址是:[前后端渲染之争 · Issue #5 · dt-fe/weekly](http://link.zhihu.com/?target=https%3A//github.com/dt-fe/weekly/issues/5)
> 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。
> 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。
@@ -179,7 +179,7 @@ const dragProps = {
```jsx
const dropProps = {
onDropOver: ev => {
onDragOver: ev => {
// 做一些样式处理,提示用户此时松手会将元素防止在何处
},
onDrop: ev => {
+3 -3
View File
@@ -267,9 +267,9 @@ class Square {
针对以下三种短路语法提供了快捷赋值语法:
```typescript
a &&= b; // a = a && b
a ||= b; // a = a || b
a ??= b; // a = a ?? b
a &&= b; // a && (a = b)
a ||= b; // a || (a = b)
a ??= b; // a ?? (a = b)
```
### catch error unknown 类型
@@ -622,7 +622,7 @@ const App = () => (
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldAddComponents={({deletedComponentInstancesArray, pageSchema}) => {
shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => {
// 阻止删除
return false
}}
@@ -38,7 +38,7 @@ Builder(生成器)属于创建型模式,针对的是单个复杂对象的
**意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。**
我们再理解一次意图,所谓构建与表示分离,就是指一个对象 `Persion` 并不是简单的 `new Persion()` 就可以实例化出来的,如果可以,那就是构建与表示一体。**所谓构建与表示分离,就是指 `Persion` 只能描述,而不能通过 `new Persion()` 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 `Persion` 实例。**
我们再理解一次意图,所谓构建与表示分离,就是指一个对象 `Person` 并不是简单的 `new Person()` 就可以实例化出来的,如果可以,那就是构建与表示一体。**所谓构建与表示分离,就是指 `Person` 只能描述,而不能通过 `new Person()` 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 `Person` 实例。**
在乐高积木的例子中,通过乐高创建的房子并不是 `new House()` 出来,而是将构建与表示分离了,工厂流水线中我们创建一个黄桃罐头,不是通过 `new 黄桃罐头()`,而是通过流水线不同拼装方式来完成,在数据库例子中,我们没有通过 `new DB()` 的方式创建数据库,而是通过 Builder 来创建,这都体现了构建与表示的分离。
@@ -56,7 +56,7 @@ class Ball {
// 构造函数申明为 private,就可以阻止 new Ball() 行为
private constructor() {}
public static instance = () => {
public static getInstance = () => {
if (this._instance === undefined) {
this._instance = new Ball()
}
@@ -0,0 +1,104 @@
# Decorator(装饰器模式)
Decorator(装饰器模式)属于结构型模式,是一种拓展对象额外功能的设计模式,别名 `wrapper`
**意图:动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 相框
照片 + 相框 = 带相框的照片,这背后就是一种装饰器模式:照片具有看的功能,相框具有装饰功能,在你看照片的基础上,还能看到精心设计的相框,增加了美感,同时相框还可以增加照片的保存时间与安全性。
相框与照片是一种组合关系,任何照片都可以放到相框中,而不是每个照片生成一个特定的相框,显然,组合的方式更加灵活。
### 带有缓存的文件读写
假设我们有一个类 `FileIO` 用来读写文件,但是没有缓存能力,此时是新建一个 `CachedFileIO` 子类好,还是创建一个 `CachedIO`?
一眼看上去好像 `CachedFileIO` 用起来更方便,而 `CachedIO` 的用法是 `new CachedIO(new FileIO())` 稍微麻烦一些,但如果我们增加一个网络读写类 `NetworkIO`,一个数据库读写类 `DBIO` 呢?
显然,继承的方式会使子类数量极速膨胀,而组合的方式则非常灵活,生成一个支持缓存的网络读写器,只需要 `new CachedIO(new NetworkIO())` 即可,这就是组合灵活的地方。
当然,为了实现这个能力,`CachedIO` 需要与 `FileIO``CachedFileIO``CachedIO` 继承自同一个类,具备相同的接口。
### 搭建平台的组件 wrapper
装饰器模式别名也叫 `wrapper``wrapper` 也经常在前端搭建场景中遇到,当搭建平台加载一个组件时,希望拓展其基础能力,一般会使用 `wrapper` 层对组件进行嵌套,`wrapper` 层就是在不改变 API 的基础上,对第三方组件进行增强。
## 意图解释
**意图:动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活。**
不同于继承,组合可以在运行时进行,所以称之为 “动态添加”,这里的 “额外职责” 泛指一切功能,比如在按钮点击时进行一些 log 日志的打印,在绘制 text 文本框时,额外绘制一个滚动条和边框等等。
“就增加功能来说,Decorator 模式相比生成子类更为灵活” 这句话的含义是,组合比继承更灵活,当可拓展的功能很多时,继承方案会产生大量的子类,而组合可以提前写好处理函数,在需要时动态构造,显然是更灵活的。
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB1cmhe3FY7gK0jSZKzXXaikpXa-1624-688.png">
`ConcreteComponent` 指的是需要被装饰的组件,可以看到,装饰器 `Decorator` 与他都继承同一个类,这样能保证 API 的一致,才保证无论装饰多少层,始终符合 `Component` 类型。
装饰器如果有多种,就要将 `Decorator` 申明为抽象类,`ConcreteDecoratorA``ConcreteDecoratorB` 分别实现它们,如果只有一种装饰器,可以退化到 `Decorator` 自身就是一种实现。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class Component {
// 具有点击事件
public onClick = () => {}
}
class Decorator extends Component {
private _component
constructor(component) {
this._component = component
}
public onClick = () => {
log('打点')
this._component.onClick()
}
}
const component = new Component()
// 一个普通的点击
component.onClick()
const wrapperComponent = new Decorator(component)
// 一个具有打点功能的点击
wrapperComponent.onClick()
```
其实方法很简单,通过组合,我们得到了一个能力更强的组件,而实现的方式就是利用构造函数保存组件实例,并在复写函数时,增加一些增强实现。
## 弊端
装饰器的问题也是组合的问题,过多的组合会导致:
- 组合过程的复杂,要生成过多的对象。
- 包装器层次增多,会增加调试成本,我们比较难追溯到一个 bug 是在哪一层包装导致的。
## 总结
装饰器模式是非常常用的模式,Decorator 是一个透明的包装,只要保证包装的透明性,就可以最大限度发挥装饰器模式的优势。
最后总结一个装饰器应用图:
<img width=500 src="https://img.alicdn.com/tfs/TB1wlpgqPMZ7e4jSZFOXXX7epXa-1232-478.png">
> 讨论地址是:[精读《设计模式 - Decorator 装饰器模式》· Issue #286 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/286)
**如果你想参与讨论,请 [点击这里](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,99 @@
# Facade(外观模式)
Facade(外观模式)属于结构型模式,是一种日常开发中经常被使用到的设计模式。
**意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
## 意图解释
### 图书管理员
图书馆是一个非常复杂的系统,虽然图书按照一定规则摆放,但也只有内部人员比较清楚,作为一位初次来的访客,想要快速找到一本书,最好的办法是直接问图书管理员,而不是先了解这个图书馆的设计,因为你可能要来回在各个楼宇间奔走,借书的流程可能也比较长。
图书管理员就起到了简化图书馆子系统复杂度的作用,我们只要凡事询问图书管理员即可,而不需要关心他是如何与图书馆内部系统打交道的。
### 最多跑一次便民服务
浙江省推出的最多跑一次服务非常方便,很多办事流程都简化了,无论是证件办理还是业务受理,几乎只要跑一次,而必须要持续几天的流程也会通过手机短信或者 App 操作完成后续流程。
这就相当于外观模式,因为政府系统内部的办事流程可能没有太大变化,但通过抽象出 Facade(外观),让普通市民可以直接与便民办事处连接,而不需要在车管所与驾校之间来回奔波,背后的事情没有少,只是便民办事处帮你做了。
### Iphone 快捷指令功能
手机的 App 非常多,而我们需要了解每个功能在哪个 App 上才能运用自如,而快捷指令功能可以将 App 的某些功能单独提取出来,形成一套新的功能组,我们可以只接触到 “拍照” “付款” “计算”,而不用管背后是调用了支付宝还是微信、系统内置摄像机还是其他摄像 App,也不用关心这个 App 内部功能的入口在哪里,这些对接都在快接指令中自动完成。
快捷指令也是一种外观模式。
## 意图解释
**意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。**
为降低一个拥有多个接口的子系统内部复杂性,我们需要一个外观来屏蔽内部的复杂性,因此外观模式就是定义一个高层接口,这个接口直连子系统的内部实现,但调用这个高层接口的人不需要关心子系统内部的实现,这样,对于不想了解子系统内部实现的人来说,提高了易用度。
当然如果想要深度定制,就可以绕过外观模式,直接使用子系统提供的类,所以说并不是有了外观模式就必须通过外观调用,而是根据实际需要判断使用哪种调用方式。
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB1j9gZ3.T1gK0jSZFrXXcNCXXa-1082-412.png">
可以看到,Facade 直接指向子系统中的类,**而子系统的类不会反向指向 Facade**。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 假设一个子系统是三个类结合使用的,为了抽象而解耦开了
class A {
constructor(b: B) {
this.b = b
}
}
class B {
constructor(c: C) {
this.c = c
}
}
class C {
}
// 它们组合成了一种常用功能,我们可以使用外观模式屏蔽子类的细节直接使用
class Compile {
public run() {
const parser = new A(new B(new C))
parser.run()
}
}
const compile = new Compile()
compile.run()
```
这样我们只要知道 `Compile` 类就可以了,而不需要了解背后的 `A` `B` `C` 以及其组合关系。
## 弊端
外观模式并不适合于所有场景,当子系统足够易用时,再使用外观模式就是画蛇添足。
另外,当系统难以抽象出通用功能时,外观模式的设计可能也无所适从,因为设计的高层接口可能适用范围很窄,此时外观模式的意义就比较小。
## 总结
其实抽象工厂模式也可以代替外观模式,来实现隐藏子类具体实现的效果,但外观模式描述更具有通用性。
> 讨论地址是:[精读《设计模式 - Facade 外观模式》· Issue #288 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/288)
**如果你想参与讨论,请 [点击这里](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,110 @@
# Flyweight(享元模式)
Flyweight(享元模式)属于结构型模式,是一种共享对象的设计模式。
**意图:运用共享技术有效地支持大量细粒度的对象。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 富文本编辑器的字母对象
富文本编辑器在英文环境下,其中的文本由大量字母组成,为了便于做统一的格式化、计算等处理,需要将每个字母都存储为对象,但这样存储的代价太大了。
已知英文字母一共 26 个,所以文档中存在大量重复使用的字母,而每个字母除了位置信息外,其它信息都是相同且只读的,那么有办法降低富文本场景巨大的字母对象数量吗?
### 网盘存储
当我们上传一部电影时,有时候几十 GB 的内容不到一秒就上传完了,这是网盘提示你,“已采用极速技术秒传”,你会不会心生疑惑,这么厉害的技术为什么不能每次都生效?
另外,网盘存储时,同一部电影可能都会存放在不同用户的不同文件夹中,而且电影文件又特别巨大,和富文本类似,电影文件也只有存放位置是不同的,而其余内容都特别巨大且只读,有什么办法能优化存储呢?
### 大型多人游戏
玩多人游戏时,为了防止外挂,一般对象的创建与计算是在服务器完成的,那如何保证一个玩家拾取物品后,另一个玩家看到的物品会消失?
其实道理已经不言而喻了,虽然在不同客户端之间,游戏对象是相互独立的,但在一局游戏中,所有玩家的对象在服务器是共享的。
## 意图解释
“共享” 就是享元模式的精髓,将那些大量的,具有很多内部状态而外部状态很少的对象进行共享,就是享元模式的使用方式。
**意图:运用共享技术有效地支持大量细粒度的对象。**
共享技术可以理解为缓存,当一个对象创建后,再次访问相同对象时,就不再创建新的对象了,而只有在访问没有被缓存过的对象时,才创建新对象,并立即缓存起来。
这样做可以有效支持大量细粒度的对象,在富文本例子中,**无数的字母就是大量细粒度对象**,在网盘存储中,**电影文件就是大量细粒度对象**,在大型多人游戏中,**每局游戏内存在大量细粒度对象**。
这些细粒度对象都拥有相同的特征:
- 量特别大,这个很容易理解。
- 具有大量内部状态,且不随着客户端的不同而改变。
- 富文本的字母,不因为展示到不同语句中而发生变化,变化的只有状态;电影文件,不因为放在不同用户的文件夹中而对电影内容产生变化,变化的只有属于哪些用户,放在哪些文件夹里;多人游戏中,同一把武器对象,不因为有多个人的电脑独立运行而拥有更多的弹药,变化的只有在哪些客户端被访问。
- 具有少量外部状态,甚至没有外部状态。在上面已经解释了,字母的位置、电影的位置、游戏对象的客户端都是外部状态,这些外部状态相比于其内部状态来说,大小微乎其微,且方便分离存储。
遇到这种情况,我们就可以将对象内部状态共享,外部状态独立存储,从而节省大量空间。
尤其是对于网盘的场景,承诺给用户 2 TB 的存储空间,这个用户看到其他人分享了 100 个电影,就点击 “下载到我的网盘”,**此时虽然占用了自己 1 TB 的网盘空间,但实际上网盘运营商并没有增加 1 TB 的存储空间,实际可能增加了 1kb 的存储空间,记录了存储位置**,这就是网盘鸡贼的地方,并不占用空间的内容,却占用了用户真金白银购买的存储空间。
当然,这就是享元模式的价值,对网盘公司来说,价值巨大,对用户来说,没有价值。所以享元模式的价值体现在全局,比如对整个富文本编辑器来说,减少了巨量字母对象数量,但对于每一个字母对象而言,并没有任何优化。
## 结构图
<img width=800 src="https://img.alicdn.com/tfs/TB1KMTY4UY1gK0jSZFMXXaWcVXa-1420-886.png">
对于 Client 而言,下图描述了如何共享 Flyweight:
<img width=800 src="https://img.alicdn.com/tfs/TB1JwLL4QL0gK0jSZFtXXXQCXXa-1460-542.png">
- Flyweight: 共享接口,通过这个接口可以操作对象的外部状态。
- ConcreteFlyweight: 实现 Flyweight 接口的对象,这个对象是可被共享的。
- UnsharedConcreteFlyweight: 不被共享的对象,因为在享元模式中,实际上并不是所有对象都可以被共享。
- FlyweightFactory: 创建并管理 Flyweight 对象,通过其返回的 Flyweight 对象,如果已创建,则会返回之前创建的那个,没有的话才会创建一个新的。
- Client: 使用 Flyweight 的客户端。
通过第二个图可以明显看到,两个不同的 Client 持有了相同 `aConcreteFlyweight` 引用。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class FlyweightFactory {
public getFlyWeight(key) {
if (this.flyweight[key]) {
return this.flyweight[key]
}
const flyweight = new Flyweight()
this.flyweight[key] = flyweight
return flyweight
}
}
```
`FlyweightFactory` 提供的 `getFlyWeight` 方法,实际上是按照 `key``flyweight` 实例进行缓存,相同 `key` 下只存储一个 `flyweight` 实例。
## 弊端
如果细粒度对象不多,则没必要使用享元模式。
另外,就算细粒度对象很多,如果对象内部状态并不多,主要都是外部状态,那么享元模式就起不到什么作用了,**因为享元模式通过共享对象,只能节省内部状态,而不能节省外部状态。**
另外,如果享元模式映射到的共享对象数量并没有比原始对象少出数量级关系,使用的意义也不大。比如富文本编辑器的例子,对于英文来说,一共就 26 个字母,那么 1 万字的文章优化比例是 10000:26,但对于中文文章而言,文字实例本身就很多,可能 1 万字的文章中,汉字去重后依然有 3000 个,那么优化比例就是 10000:3000,此时享元模式的意义就没那么大了。
## 总结
享元模式的本质就是尽可能的共享对象,特别适用于存在大量细粒度对象,而这些对象内部状态特别多,外部状态较少的场景。
对于云存储来说,享元模式是必须使用的,因为云存储的场景决定了,存在大量细粒度文件对象,而存在大量只读的文件,就非常适合共享一个对象,每个用户存储的只是引用。
> 讨论地址是:[精读《设计模式 - Flyweight 享元模式》· Issue #290 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/290)
**如果你想参与讨论,请 [点击这里](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,100 @@
# Proxy(代理模式)
Proxy(代理模式)属于结构型模式,通过访问代理对象代替访问原始对象,以获得一些设计上的便捷。
**意图:为其他对象提供一种代理以控制这个对象的访问。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 获得文本对象长度
获得一个文本对象长度,必须要真正渲染出来,而渲染是比较耗时的,我们可能只在某些场景下需要访问文本对象长度,而更多时候只需要读取文本内容,这两种操作耗时是完全不同的,如何做到业务层调用无感知,来优化执行耗时呢?
代理模式可以解决这个问题,我们将业务层使用的文本对象替换为代理对象,这个代理对象初始化并不渲染文本,而是在调用文本长度时才渲染。
### 对象访问保护
某个大型系统开发完了,突然要求增加代码访问权限体系,不同模块对相同的底层对象拥有不同访问权限,此时这个权限控制逻辑如果写入底层对象,就违背了开闭原则,而对象本身的实现也不再纯粹,增加了维护成本,如何做到不修改对象本身,实现权限控制呢?
代理模式也能解决,将底层对象导出替换为代理对象,由代理对象控制访问权限即可。
### 对象与视图双向绑定
Angular 或 Vue 这类前端框架采用双向绑定视图更新技术,即对象修改后,使用到的视图会自动刷新,这就需要做到以下两点:
1. 在对象被访问时,记录调用的视图绑定。
2. 在对象被修改时,刷新调用它的视图。
问题是,在业务代码使用对象与修改对象的地方插入这段逻辑,显然会增加巨大的维护成本,如何做到业务层无感知呢?
代理模式可以很好的解决这个问题,其实业务层拿到的对象已经是代理对象了,它在被访问与被修改时,都会执行固定的钩子做视图绑定与视图刷新。
## 意图解释
**意图:为其他对象提供一种代理以控制这个对象的访问。**
代理模式的意图很容易理解,就是通过代理对象代替原始对象的访问。
这只是代理模式的实现方式,代理模式真正的难点不在于理解它是如何工作的,而是理解哪些场景适合用代理,或者说创建了代理对象,怎么用才能发挥它的价值。
在上面例子中,已经举出了几种常见代理使用场景:
1. 对开销大的对象使用代理,以按需使用。
2. 对需要保护的对象进行代理,在代理层做权限控制。
3. 在对象访问与修改时要执行一些其他逻辑,适合在代理层做。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i3/O1CN01eZHGHQ28t0oeHYzas_!!6000000007989-2-tps-1262-522.png">
使用时关系如下:
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01iwyMKQ1KbOnR0N2AP_!!6000000001182-2-tps-1270-206.png">
Subject 定义的是 RealSubject 与 Proxy 共用的接口,这样任何使用 RealSubject 的地方都可以使用 Proxy。
RealSubject 指的是原始对象,Proxy 是一个代理实体。
关系图中可以看出,当客户端要访问 subject 时,第一层访问的是 Proxy 代理,由这个代理将 realSubject 转发给客户端。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 对象 obj
const proxy = new Proxy(obj, {
get(target,key) {}
set(target,key,value) {}
})
```
JS 创建代理还是蛮简单的,代理可以控制对象的所有成员属性,包括成员变量与成员方法的访问(get)与修改(set)。
## 弊端
代理模式会增加微弱的开销,因此请不要将所有对象都变成代理,没有意义的代理只会徒增程序开销。
另外代理对象过多,也会导致调试困难,因为代理层的存在,我们往往可能忽略这一层带来的影响,导致忘记这个对象其实是一个代理。
## 总结
代理和继承有足够多的相似之处,继承中,子类几乎可以人为是对父类的代理,子类可以重写父类的方法。但代理和继承还是有区别的:
如果你没有采用 `new Proxy` 这种 API 创建代理,而是采用继承的方式实现,你会一下子继承这个类的所有方法,而做不到按需控制访问权限的灵活效果,所以代理比继承更加灵活。
JS 的 `new Proxy` 对应了 Java 动态代理模式,一般认为动态代理比静态代理更强大。
最后,还要重申那句话,代理模式理解与运用并不难,难就难在能否在恰当的场合想到它,双向绑定几乎是代理模式最好的例子。
> 讨论地址是:[精读《设计模式 - Proxy 代理模式》· Issue #291 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/291)
**如果你想参与讨论,请 [点击这里](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,97 @@
# Chain of Responsibility(职责链模式)
Chain of Responsibility(职责链模式)属于行为型模式。行为型模式不仅描述对象或类的模式,还描述它们之间的通信模式,比如对操作的处理应该如何传递等等。
**意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。**
> 几乎所有设计模式,在了解到它之前,笔者就已经在实战中遇到过了,因此设计模式的确是从实践中得出的真知。但另一方面,如果没有实战的理解,单看设计模式是枯燥的,而且难以理解的,因此大家学习设计模式时,要结合实际问题思考。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 中间件机制
设想我们要为一个后端框架实现中间件(知道 Koa 的同学可以理解为 Koa 的洋葱模型),在代码中可以插入任意多个中间件,每个中间件都可以对请求与响应进行处理。
由于每个中间件只响应自己感兴趣的请求,因此只有运行时才知道这个中间件是否会处理请求,那么中间件机制应该如何设计,才能保证其功能和灵活性呢?
### 通用帮助文案
如果一个大型系统中,任何一个模块点击都会弹出帮助文案,但并不是每个模块都有帮助文案的,如果一个模块没有帮助文案,则显示其父级的帮助文案,如果再没有,就继续冒泡到整个应用,展示应用级别的兜底帮助文案。这种系统应该如何设计?
### JS 事件冒泡机制
其实 JS 事件冒泡机制就是个典型的职责链模式,因为任何 DOM 元素都可以监听比如 `onClick`,不仅可以自己响应事件,还可以使用 `event.stopPropagation()` 阻止继续冒泡。
## 意图解释
JS 事件冒泡机制对前端来说太常见了,但我们换个角度,站在点击事件的角度理解,就能重新发现其设计的精妙之处:
点击事件是叠加在每层 dom 上的,由于 dom 对事件的处理和绑定是动态的,浏览器本身不知道哪些地方会处理点击事件,但又要让每层 dom 拥有对点击事件的 “平等处理权”,所以就产生了冒泡机制,与事件阻止冒泡功能。
通用帮助文案和 JS 事件冒泡很类似,只是把点击事件换成了弹出帮助文案罢了,其场景机理是一样的。
说到这,我们可以再重新理解一下职责链模式的意图:
**意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。**
请求指的是某个触发机制产生的请求,是一个通用概念。“避免请求的发送者和接收者之间的耦合关系”,指的是如果我们只有一个对象有处理请求的机会,那接收者就与发送者之间耦合了,其他接收者必须通过这个接收者才能继续处理,这种模式不够灵活。
后半句描述的是如何设计,可以实现这个灵活的模式,即将对象连成一条链,沿着链条传递该请求,直到有一个对象处理它为止。还要理解到,任何一个对象都拥有阻断请求继续传递的能力。
在中间件机制的例子中,后端 Web 框架对 Http 请求的处理就是个运用职责链模式的典型案例,因为后端框架要处理的请求是平行关系,任何请求都可能要求被响应,但对请求的处理是通过插件机制拓展的,且对每个请求的处理都是一个链条,存在处理、加工、再处理的逻辑关系。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01PUN6ed1FnhM2CeZDn_!!6000000000532-2-tps-1126-500.png">
Handler 就是对请求的处理,可以看到这里是一条环路,只要处理完之后就可以交给下一个 Handler 进行处理,可以在中途拦截后中断,也可以穿透整条链路。
`ConcreteHandler` 是具体 Handler 的实现,他们都需要继承 Handler 以具备相同的 `HandleRequest` 方法,这样每一个处理中间件就都拥有了处理能力,使得这些对象连成的链条可以对请求进行传递。
## 代码例子
职责链实现方式非常多,比如 Koa 的洋葱模型实现原理就值得再写一篇文章,感兴趣的同学可以阅读 [co 源码](https://github.com/tj/co)。这里仅介绍最简单场景的实现方案。
职责链的简单实现模式也分为两种,一种是每个对象本身维护到下一个对象的引用,另一种是由 Handler 维护后继者。
下面例子使用 typescript 编写。
```typescript
public class Handler {
private nextHandler: Handler
public handle() {
if(nextHandler) {
nextHandler.handle()
}
}
}
```
每个 Handler 的默认行为就是触发下一个链条的 `handle`,因此什么都不做的话,这个链条是完全打通的,因此我们可以在链条的任何一环进行处理。
处理的方式就是重写 `handle` 函数,我们在重写时,可以维持对 `nextHandler.handle()` 的调用,以使得链条继续向后传递,也可以不调用,从而终止链条向后传递。
## 弊端
职责链模式不保证每个中间件都有机会处理请求,因为中间件顺序的问题,后面中间件可能被前面的中间件阻断,因此当中间件之间存在不信任关系时,职责链模式并不能保证中间件调用的可靠性。
另外就是不要扩大设计模式的使用范围,对一堆对象的连续调用就没必要使用职责链模式,因为职责链适合处理对象数量不确定、是否处理请求由每个对象灵活决定的场景,而确定了对象数量以及是否调用的场景,就没必要使用职责链模式了。
## 总结
职责链模式是插件机制常用的设计模式,在事件机制、请求处理中有广泛的应用。
职责链模式还可以与组合模式组合使用,因为组合模式描述的是一种统一管理的树形结构,每个节点都可以把自己的父节点作为后继节点。实际上 dom 结构就是一种组合模式,事件冒泡就是在其基础上拓展的职责链模式。
> 讨论地址是:[精读《设计模式 - Chain of Responsibility(职责链模式)》· Issue #292 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/292)
**如果你想参与讨论,请 [点击这里](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,119 @@
# Command(命令模式)
Command(命令模式)属于行为型模式。
**意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 点菜是命令模式
为什么顾客会找服务员点菜,而不是直接冲到后厨盯着厨师做菜?因为做菜比较慢,肯定会出现排队的现象,而且有些菜可能是一起做效率更高,所以将点菜和做菜分离比较容易控制整体效率。
其实这个社会现象就对应编程领域的命令模式:点菜就是一个个请求,点菜员记录的菜单就是将请求生成的对象,点菜员不需要关心怎么做菜、谁来做,他只要把菜单传到后厨即可,由后厨统一调度。
### 大型软件系统的操作菜单
大型软件操作系统都有一个特点,即软件非常复杂,菜单按钮非常多。但由于菜单按钮本身并没有业务逻辑,所以通过菜单按钮点击后触发的业务行为不适合由菜单按钮完成,此时可利用命令模式生成一个或一系列指令,由软件系统的实现部分来真正执行。
### 浏览器请求排队
浏览器的请求不仅会排队,还会取消、重试,因此是个典型的命令模式场景。如果不能将 `window.fetch` 序列化为一个个指令放入到队列中,是无法实现请求排队、取消、重试的。
## 意图解释
**意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。**
一个请求指的是来自客户端的一个操作,比如菜单按钮点击。重点在点击后并不直接实现,而是将请求封装为一个对象,可以理解为从直接实现:
```typescript
function onClick() {
// ... balabala 实现逻辑
}
```
改为生成一个对象,序列化这个请求:
```typescript
function onClick() {
concreteCommand.push({
// ... 描述这个请求
})
// 执行所有命令队列
concreteCommand.executeAll()
}
```
看上去繁琐了一些,但得到了后面所说的好处:“从而使你可用不同的请求对客户进行参数化”,**也就是可以对任何请求进行参数化存储,我们可以在任意时刻调用。** 这相当于掌握了执行时机,可以在任意时刻调用,以实现排队或记录日志,如果再记录下反向操作信息,就可以实现撤销重做了。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i2/O1CN01preTih1iRMuH3oQYY_!!6000000004409-2-tps-1846-620.png">
Command 是命令的接口,一般固定有一个 `execute` 方法。
ConcreteCommand 是命令接口的实现,它会注入具体执行者 `Receiver`,它实现的 `execute` 方法会调用 `receiver.execute` 来具体执行。
`Invoker` 是执行请求的命令,其实上面都在推入命令,并没有真正执行,如果排队结束或点击撤销重做时,就触发了 Invoker 实际,就该调用对应的 Command 执行啦。
## 代码例子
下面例子使用 typescript 编写。
首先看最终执行态,最终执行需要先添加命令,再执行命令:
```typescript
const command1 = new Command('balabala1')
const command2 = new Command('balabala2')
const invoker = new Invoker()
invoker.push(command1)
invoker.push(command2)
invoker.execute()
```
`Invoker` 内部用一个队列维护,执行的时候其实是 `for` 循环执行了每个 `command.execute()`:
```typescript
class Invoker {
push(command) {
// 队列里推入命令
this.commands.push(command)
}
execute() {
this.commands.forEach(command => command.execute())
// 别忘了清空 this.commands
}
}
```
## 弊端
命令模式需要注意序列化大小,一般分为:
1. 仅记录操作。
2. 记录全量快照。
3. 全量快照共享内存。
记录操作是较为精细的管理方式,并且可以延伸出协同编辑功能。记录快照要注意尽量共享内存,防止快照过大,而且协同编辑场景因为快照无法做冲突处理,所以快照模式在协同编辑场景无法应用。
另外要识别没必要使用命令模式的场景,对于没有撤销重做的前端大部分场景来说,都无需改为命令模式。
## 总结
命令模式本质上就是将操作抽象为可序列化的命令,使操作可以在合适的时间执行,这种设计带来了许多额外好处。
利用命令模式可以达到高内聚低耦合的效果,提升代码可维护性,也可以实现撤销重做、协同编辑等功能性需求。
> 讨论地址是:[精读《设计模式 - Command(命令模式)》· Issue #295 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/295)
**如果你想参与讨论,请 [点击这里](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,125 @@
# Interpreter(解释器模式)
Interpreter(解释器模式)属于行为型模式。
**意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。**
任何一门语言,无论是日常语言还是编程语言都有明确的语法,只要有语法就可以用文法描述,并通过语法解释器将字符串的语言结构化。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### SQL 解释器
SQL 是一种描述语言,所以也适用于解释器模式。不同的 SQL 方言有不同的语法,我们可以根据某种特定的 SQL 方言定制一套适配它的文法表达式,再利用 antlr 解析为一颗语法书。在这个例子中,antlr 就是解释器。
### 代码编译器
程序语言也因为其天然是字符串的原因,和 SQL、日常语言都类似,需要一种模式解析后才能工作。不同的语言有不同的文法表示,我们只需要一个类似 antlr 的通用解释器,通过传入不同的文法表示,返回不同的对象结构。
### 自然语言处理
自然语言处理也是解释器的一种,首先自然语言处理一般只能处理日常语言的子集,因此先定义好支持的范围,再定义一套分词系统与文法表达式,并将分词后的结果传入灌入了此文法表达式的解释器,这样解释器可以返回结构化数据,根据结构化数据再进行分析与加工。
## 意图解释
**意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。**
对于给定的语言,可以是 SQL、代码或自然语言,“定义它的文法的一种表示” 即文法可以有多种表示,只需定义一种。要注意的是,不同文法执行效率会有差异。
“并定义一个解释器”,这个解释器就是类似 antlr 的东西,传给它一个文法表达式,就可以解析句子了。即:解释器(语言, 文法) = 抽象语法树。
我们可以直接把文法定义耦合到解释器里,但这样做会导致语法复杂时,解释器难以维护。比较好的方式是定义一套与解释器解耦的文法表达式,通过预处理器最终生成解释器。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN019y6R201yinq7xjJEK_!!6000000006613-2-tps-1530-776.png">
Context 是其他上下文变量,AbstractExpression 是抽象语法表达式。
可以看到,TerminalExpression(终结符)与 NonterminalExpression(非终结符) 都继承于 AbstractExpression,终结符指的是没有后续展开的符号,非终结符相反,所以非终结符又指向了 AbstractExpression,如此递归。
## 代码例子
下面例子使用 typescript 编写。
假设我们要实现以下文法:
```text
sum ::= number + number
number ::= 1 | 2
```
表达一个最简单的加法文法,其中加法表达式 sum 和 number 都是非终结符,而 +、1、2 是终结符。这个例子只能做到 1 与 2 的加法,通过这个简单例子,了解一下解释器模式的精髓吧:
```typescript
// 抽象表达式
class AbstractExpression {
interpret (text: string) {}
}
// 终结符表达式
class TerminalExpression extends AbstractExpression {
constructor(values: string[]) {
this.values = values
}
interpret(value: string) {
// 值必须是其中之一
return this.values.includes(value)
}
}
// 非终结符表达式
class NonterminalExpression extends AbstractExpression {
constructor(left: TerminalExpression, right: TerminalExpression) {
this.left = left
this.right = right
}
interpret(value: string) {
if (value.indexOf("+") === -1) {
// 必须包含 + 号
return false
}
const splitValue = value.split('+')
return this.left.interpret(splitValue[0])
&& this.right.interpret(splitValue[1])
}
}
// 调用
const context = new Context()
const terminal = new TerminalExpression(["1", "2"])
const add = new AddExpression(terminal, terminal)
add.interpreter("1 + 1") // true
add.interpreter("1 + 2") // true
add.interpreter("1 + 3") // false
add.interpreter("2 - 1") // false
```
遇到非终结符则继续调用,只有终结符才能直接判断,原理很简单。
## 弊端
上面的例子是比较低效场景,因为当语法复杂后,类的数目会明显增多,难以维护,此时需要用一个通用语法解析器,了解更多可以看笔者之前的文章:[精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列。
## 总结
解释器是一种思维,将复杂语法解析抽象为一个个独立的终结符与非终结符各自判断,只要每个文法自己的判断做好了,剩下的工作就是组装文法。
这种将单个逻辑判断与文法组装解耦的做法,可以使逻辑判断与文法组装独立变换,使复杂语法解析转化为一个个具体的简单问题。
> 讨论地址是:[精读《设计模式 - Interpreter 解释器模式》· Issue #296 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/296)
**如果你想参与讨论,请 [点击这里](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 @@
# Iterator(迭代器模式)
Iterator(迭代器模式)属于行为型模式。
**意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。**
这种设计模式要解决的根本问题是,聚合的种类有很多,比如对象、链表、数组、甚至自定义结构,但遍历这些结构时,不同结构的遍历方式又不同,所以我们必须了解每种结构的内部定义才能遍历。
比如数组我们可以利用 length + for 循环,对象我们可以 Object.keys,链表比较麻烦,需要内部暴露出元素的 `next` 以操作指向下一个元素。
迭代器模式可以做到用同一种 API 遍历任意类型聚合对象,且不用关心聚合对象的内部结构。
这种模式和 `Array.from` 有点像,但其实真正的迭代器在 JS 里是 `obj[Symbol.iterator]()`,也就是一个对象实现了 `[Symbol.interator]`,就认为是可遍历的。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
迭代器的例子非常简单,我们平时工作中有大量使用到。
### generator
`generator` 天生为迭代器的 API
```typescript
function* func () {
yield 'a';
yield 'b';
return 'c';
}
var run = func();
run.next() // {value: "a", done: false}
run.next() // {value: "b", done: false}
run.next() // {value: "c", done: true}
```
我们无需关心 generator 内部是何种存储结构,只需要调用 `.next()`,并根据返回的 `done` 来判断是否遍历完即可。在 generator 的场景中,迭代器不仅用来遍历聚合,还用于执行代码。
### 数组迭代器
我们可以用迭代器的方式遍历数组:
```typescript
const arr = [1, 2, 3]
const run = arr[Symbol.iterator]()
run.next() // {value: 1, done: false}
run.next() // {value: 2, done: false}
run.next() // {value: 2, done: false}
run.next() // {value: undefined, done: true}
```
可能有人觉得这是画蛇添足,因为毕竟遍历数组用 for 循环更方便,但这就是设计模式与非设计模式思维的区别,重要的不是用熟悉简单的 API 快速满足需求,**设计模式关注的是如何统一、抽象、低耦合的编码**。
### Map 迭代器
Map 对象也可以用迭代器方式遍历:
```typescript
const citys = new Map([['北京', 1], ['上海', 2], ['杭州', 3]])
const run = citys.entries()
run.next() // {value: ['北京', 1], done: false}
run.next() // {value: ['上海', 2], done: false}
run.next() // {value: ['杭州', 3], done: false}
run.next() // {value: undefined, done: true}
```
## 意图解释
从上面的例子可以看出,虽然用迭代器遍历数组看上去比 for 循环麻烦一点,但当我们把所有聚合类型放到一起看时,可以发现只有迭代器的 API 是最统一的,是唯一一个不需要关心聚合类型就可以完成遍历的方案。
**意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。**
再来看意图,就非常好理解了,我们无需关心 数组、generator、Map 内部是如何存储的,就可以进行遍历。实际上,深究 generator 内部的存储结构也没有意义,如果我们不用迭代器进行遍历,那么对于复杂结构的遍历成本是非常高的。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01lYSkni1DgFh6V3P85_!!6000000000245-2-tps-1658-754.png">
- `Aggregate`: 聚合,需要定义创建迭代器的接口。比如前端规范的 `[Symbol.iterator]()`,或者这里定义的 `CreateIterator()`
- `Iterator`: 迭代器,定义了访问与遍历的 API。
迭代器的定义很简单,实现时要考虑的因素可不少,包括:
- 健壮性。即迭代过程中增加、删除元素后,还能正常遍历。或者遍历空聚合时也要能正常工作。
- 外部控制迭代还是内部。即类似 KOA 由插件调用 `next()` 控制迭代,还是由外层统一控制迭代。
- 如何定义遍历算法。即便对于对象这种简单场景,也存在深度优先和广度优先、冒泡与捕获这几种遍历顺序,迭代器可以提供选择或者拓展的方式,自定义遍历算法。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 定义聚合接口
interface Aggregate{
getIterator: () => Iterator
}
// 定义迭代器接口
interface Iterator {
// 指向下一个
next: () => void
}
// 定义一个聚合
class List implements Aggregate {
// 存储元素
public values: string[]
// 游标
public index: number
getIterator() {
return new ConcreteIterator(this);
}
}
// List 的迭代器
class ConcreteIterator implements Iterator {
constructor(list: List) {
this.list = list
}
next() {
return this.list.values[this.list.index] // 注意边界情况,这里就不展开
this.list.index++
}
}
```
## 弊端
如果你只是遍历数组,直接用 for 循环会比迭代器方便很多,没必要为了用设计模式而用设计模式。迭代器仅在以下情况可以考虑用于数组:
1. 这个数组比较特殊,是 N 维数组,需要一次性遍历完,那么可以用迭代器。
2. 同时遍历数组和其他类型的聚合,则不论数组还是其他聚合,都用相同的迭代器模式遍历最好。
## 总结
迭代器模式比较好理解,这里补充几个相关设计模式:
- 迭代器可以和组合模式配合,在组合结构内进行递归,这样一个迭代器就可以遍历完所有组合。
- 可以用工厂模式 + 多态模式,实例化不同的迭代器的实例。
- 迭代器模式还可以与备忘录模式配合使用,当我们要还原迭代器状态时,适合在迭代器内部使用备忘录模式进行状态存储。
> 讨论地址是:[精读《设计模式 - Iterator 迭代器模式》· Issue #298 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/298)
**如果你想参与讨论,请 [点击这里](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,108 @@
# Mediator(中介者模式)
Mediator(中介者模式)属于行为型模式。
**意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。**
前端开发中,最常用的 “数据驱动” 其实就最好的诠释了中介者模式。
想一个这样的场景:
1. 按钮点击后,表单提交。按钮需要调用所有表单项获取表单值。
2. 表单关联,当勾选了城市后,才出现满意度 Input 框,此时城市勾选按钮需要引用满意度 Input 框。
3. 甚至会出现循环引用,两个输入框是互斥的,输入了一个,另一个输入框就要 Disable。
4. 当新增加一个表单项时,需要重新建立所有引用关系。
以上过程式编程方式,维护大型项目几乎是不可能的。然而数据驱动可以很好的解决这个问题,所有表单项都依赖数据,并修改数据,这样当 Input 框联动 Check 时,Input 并不需要感知 Checkbox 的存在,他只要关联数据、修改数据就行了,Checkbox 也只要关联数据和修改数据,这样不但逻辑可以独立完成,甚至可以解决循环引用的问题。
**在数据驱动的例子中,数据就是中介。** 所有 UI 之间都不会相互引用,而是通过数据这个中介来协同工作,这样做带来的明显好处是可以处理复杂项目,且易于维护。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 数据驱动
正如开篇说的,数据驱动是中介者非常经典的例子,正是因为引入了 “数据中介者”,才让前端项目的复杂度可以呈几何倍数递增,而代码的逻辑复杂度仅线性递增。因为 UI 是杂乱的且动态的,UI 间依赖会导致关系网非常复杂,且关系网一旦形成,增加一个新元素或修改就变得异常困难。
中介者模式则避开了 UI 间依赖的关系网,通过数据层统一调度,UI 受控响应,可以大大减少逻辑复杂度。
### 解决循环依赖
循环依赖几乎只能利用中介者模式解决:
```typescript
import { b } from './b'
export const a = 'a'
```
```typescript
import { a } from './a'
export const b = 'b'
```
当双方相互引用时,构成循环依赖,不仅对于模块化来说是有问题的,从逻辑上也是讲不通的,因为一定存在递归调用的问题。这是,引入第三方中介者就不仅仅是一种设计模式思维了,而是 a、b 模块中原本就有一些内容是两边公用的,一定需要提出来,而统一提出来的地方就是中介者模式的中介者部分。
### 企业组织架构
一个树状企业组织架构中,每个非叶子结点都是中介者,需要给他的子节点分配任务,并协调他们的工作,这样一来,叶子结点不需要有全局观即可工作,因为他们只需负责 “去做自己的事情”,而不需要关心 “是如何协同的”。
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01BPn79I1mzUt7yWRjB_!!6000000005025-2-tps-582-291.png">
如图所示,环境部不需要关心人事部做了什么,只要专注做好环境事物即可,他们之间的协调由总经理处理,这是一种分工协作的体现。
而只存在于理论中的网状企业管理模型,则是没有中介者的例子,所有节点都是非叶子结点,并相互引用,这样一来每个人既要做自己的工作,又要处理自己与公司里其他几万人的协同,几乎是一件不可能完成的事情,所以从设计模式角度来看,也更倾向于使用树状而不是网状模式管理企业。
## 意图解释
**意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。**
中介者模式非常好理解,直接看字面意思即可。所谓的对象交互,指的是对象之间是如何协同的,中介者做的是处理对象间协同的工作,而不是 “替每个对象干活”。
最后一句 “可以独立地改变他们之间的交互”,指的是对象之间协同方式不是一成不变的,比如一个输入框组件,只要实现自己的输入功能就行了,而不需要关心是如何与外界交互的。外界可以通过将其嵌入到表单中,成为表单项的一部分,也可以将其包裹一层符号后缀,成为一个专门输入金额的金额输入框。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01FLHDqJ1c1MjH3fM4k_!!6000000003540-2-tps-1602-440.png">
- Mediator:中介者接口,定义一些通信 API。
- ConcreteMediator:具体的中介者,继承 Mediator,协调各个对象。
- Colleague:同事类,比如之前提到的输入框、文本框,每个同事之间只要知道中介者即可,他们之间不需要知道对方的存在。
## 代码例子
下面例子使用 typescript 编写。
```typescript
const memberA = new Member('美术')
const memberB = new Member('程序')
const picture = memberA.draw() // 美术画出图
const product = memberB.code(picture) // 程序按照美术画的图做产品
```
这个例子中,完成了程序与美术的协同,他们各自不需要知道对方的存在。如果后续又引入了产品、测试工种,他们之间不需要做复杂的关联,只需要在中介者增加对应协同逻辑即可。
## 弊端
中介者模式虽然好,但过度使用可能使中介者逻辑非常复杂。
我们常说管理者直接管理人数最好不要超过二十人,原因是协调本身也非常耗费精力,一个中介者节点如果管理的对象过多,可能会导致中介者本身难以维护,甚至出现 BUG。
另外则是不要过度解耦,当两个对象本身可以构成依赖关系时,使用中介者模式使其强行解耦,带来的只会是更重的理解负担。
## 总结
当一个系统对象很多,且之间关联关系很复杂,交叉引用容易产生混乱时,就可能适用中介者模式。
中介者模式也符合迪米特法则,做到了每个对象了解最少的内容,这样做对于大型程序来说是非常有益的。
> 讨论地址是:[精读《设计模式 - Mediator 中介者模式》· Issue #299 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/299)
**如果你想参与讨论,请 [点击这里](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,178 @@
# Memento(备忘录模式)
Memento(备忘录模式)属于行为型模式,是针对如何捕获与恢复对象内部状态的设计模式。
**意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。**
其实备忘录模式思想非常简单,其核心是定义了一个 Memoto(备忘录) 封装对象,由这个对象处理原始对象的状态捕获与还原,其他地方不需要感知其内部数据结构和实现原理,而且 Memoto 对象本身结构也非常简单,只有 `getState``setState` 一存一取两个方法,后面会详细讲解。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 撤销重做
如果撤销重做涉及到大量复杂对象,每个对象内部状态的存储结构都不同,如果一个一个处理,很容易写出 case by case 的冗余代码,而且在拓展一种新对象结构时(如嵌入 ppt),还需要在撤销重做时对相应结构做处理。备忘录思维相当于一种统一封装思维,不管这个对象结构如何,都可以保存在一个 Memoto 对象中,通过 `setState` 设置对象状态与 `getState` 获取对象状态,这样对于任何类型的对象,画布都可以通过统一的 API 操作进行存取了。
### 游戏保存
玩过游戏的同学都知道,许多游戏支持设置与读取多种存档,如果转换为代码模式,我们可能希望有这样一种 API 进行多存档管理:
```typescript
// 创建一盘游戏。
const game = new Game()
// 玩一会。
game.play()
// 设置一个存档(archive) 1。
const gameArchive1 = game.createArchive()
// 再玩一会。
game.play()
// 设置一个存档(archive) 2。
const gameArchive2 = game.createArchive()
// 再玩一会。
game.play()
// 这个时候角色挂了,提示 “请读取存档”,玩家此时选择了存档 1。
game.loadArchive(gameArchive1)
// 此时游戏恢复存档 1 状态,又可以愉快的玩耍了。
```
其实在游戏保存的例子中,存档就是备忘录(Memoto),而主进程管理游戏状态时,只是简单调用了 `createArchive` 创建存档,与 `load` 读取存档,即可实现复杂的游戏保存与读取功能,全程是不需要关心游戏内部状态到底有多少,以及这么多状态需要如何一一恢复的,这就是得益于备忘录模式的设计。
### 文章草稿保存
富文本编辑器的文档草稿保存也是一样的原理,简单一点只需要一个 Memoto 对象即可,如果要实现复杂一点的多版本状态管理,只需要类似游戏保存机制,存储多个 Memoto 存档即可。
## 意图解释
看到这里,会发现备忘录模式与前端状态管理的保存与恢复很像。以 Redux 类比:
`setState` 就像 `reducer` 处理的最终 `state` 状态一样,对 redux 全局状态来说,它不用关心业务逻辑(有多少 `reducer`,以及每个 `reducer` 做了什么),它只需要知道任何 `reducer` 最后处理完后都是一个 `state` 对象,将其生成出来并存下来即可。
恢复也是一样,`initState` 就类似 `getState`,只要将上一次生成的 `state` 灌进来,就可以完全还原某个时刻的状态,而不需要关心这个状态内部是怎样的。
所以其实备忘录模式早已得到广泛的应用,仔细去理解后,会发现没必要去扣的太细,以及原始设计模式是如何定义的,因为经过几十年的演化,这些设计模式思路早已融入了编程框架的方方面面。
但依照惯例,我们还是再咬文嚼字解释一下意图:
**意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。**
重点在于 “不破坏封装性” 这几个字上,程序的可维护性永远是设计模式关注的重点,无论是游戏存档的例子,还是 Redux 的例子,上层框架使用状态时,都不需要知道具体对象状态的细节,而实现这一点的就是 Memoto 这个抽象的备忘录类。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i2/O1CN01ByabMq1W05wDvuVYo_!!6000000002725-2-tps-1604-478.png">
- `Originator`:创建、读取备忘录的发起者。
- `Memento`:备忘录,专门存储原始对象状态,并且防止 Originator 之外的对象读取。
- `Caretaker`:备忘录管理者,一般用数组或链表管理一堆备忘录,在撤销重做或者版本管理时会用到。
## 代码例子
下面例子使用 typescript 编写。
下面是备忘录模式三剑客的定义:
```typescript
// 备忘录
class Memento {
public state: any
constructor(state: any) {
this.state = state
}
public getState() {
return this.state
}
}
// 备忘录管理者
class Caretaker {
private stack: Memento[] = []
public getMemento(){
return this.stack.pop()
}
public addMemento(memoto: Memento){
this.stack.push(memoto)
}
}
// 发起者
class Originator {
private state: any
public getState() {
return this.state
}
public setState(state: any) {
this.state = state
}
public createMemoto() {
return new Memoto(this.state)
}
public setMemoto(memoto: Memoto) {
this.state = memoto.getState()
}
public void setMemento(Memento memento) {
state = memento.getState();
}
}
```
下面是一个简化版客户端使用的例子:
```typescript
// 实例化发起者,比如画布、文章管理器、游戏管理器
const originator = new Originator()
// 实例化备忘录管理者
const caretaker = new Caretaker()
// 设置状态,分别对应:
// 画布的组件操作。
// 文章的输入。
// 游戏的 .play()
originator.setState('hello world')
// 备忘录管理者记录一次状态,分别对应:
// 画布的保存。
// 文章的保存。
// 游戏的保存。
caretaker.setMemento(originator.createMento())
// 从备忘录管理者还原状态,分别对应:
// 画布的还原。
// 文章的读取。
// 游戏读取存档。
originator.setMemento(caretaker.getMemento())
```
在上面例子中,备忘录管理者存储状态是数组,所以可以实现撤销重做,如果要实现任意读档,可以将备忘录变为 `Map` 结构,按照 `key` 来读取,如果没有这些要求,存一个单一的 `Memoto` 也够用了。
## 弊端
备忘录模式存储的是完整状态而非 Diff,所以可能会在运行时消耗大量内存(当然在 Immutable 模式下,通过引用共享可以极大程度缓解这个问题)。
另外就是,备忘录模式已经很大程度上被融合到现代框架中,你在使用状态管理工具时就已经使用了备忘录模式了,所以很多情况下,不需要机械的按照上面的代码例子使用。设计模式重点在于利用它优化了程序的可维护性,而不用强求使用方式和官方描述一模一样。
## 总结
备忘录模式通过备忘录对象,将对象内部状态封装了起来,简化了程序复杂度,这符合设计模式一贯遵循的 “高内聚、低耦合” 原则。
其实践行备忘录模式最好的例子就是 Redux,当项目所有状态都使用 Redux 管理时,你会发现无论是撤销重做,还是保存读取,都可以非常轻松完成,这时候,不要质疑为什么备忘录模式还在解决这种 “遇不到的问题”,因为 Redux 本身就包含了备忘录设计模式的理念。
> 讨论地址是:[精读《设计模式 - Memento 备忘录模式》· Issue #301 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/301)
**如果你想参与讨论,请 [点击这里](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,140 @@
# Observer(观察者模式)
Observer(观察者模式)属于行为型模式。
**意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。**
拿项目的 npm 依赖举例子:npm 包与项目是一对多的关系(一个 npm 包被多个项目使用),当 npm 包发布新版本时,如果所有依赖于它的项目都能得到通知,并自动更新这个包的版本号,那么就解决了包版本更新的问题,这就是观察者模式要解决的基本问题。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 对象与视图双向绑定
在 [精读《设计模式 - Proxy 代理模式》](https://github.com/dt-fe/weekly/blob/v2/178.%E7%B2%BE%E8%AF%BB%E3%80%8A%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%20-%20Proxy%20%E4%BB%A3%E7%90%86%E6%A8%A1%E5%BC%8F%E3%80%8B.md) 中我们也提到了双向绑定概念,只不过代理是实现双向绑定的一个具体方案,而观察者模式才是在描述双向绑定这个概念。
观察者模式在最初提出的时候,就举了数据与 UI 相互绑定的例子。即同一份数据可以同时渲染为表格与柱状图,那么当操作表格更新数据时,如何让柱状图的数据也刷新?从这个场景引出了对观察者模式的定义,即 “数据” 与 “UI” 是一对多的关系,我们需要一种设计模式实现当 “数据” 变化时,所有依赖于它的 “UI” 都得到通知并自动更新。
### 拍卖
拍卖由一个拍卖员与多为拍卖者组成。拍卖时,由 A 同学喊出的竞价(我出 100)就是观察者向目标发出的 `setState` 同时,此时拍卖员喊出(有人出价 100,还有更高的吗?)就是一个 `notify` 通知行为,拍卖员通知了现场竞价全员,刷新了他们对当前最高价的信息。
### 聊天室
聊天室由一个中央服务器与多个客户端组成。客户端发送消息后,就是向中央服务器发送了 `setState` 更新请求,此时中央服务器通知所有处于同一聊天室的客户端,更新他们的信息,从而完成一次消息的发送。
## 意图解释
数据与 UI 的例子已经详细说明了其意图含义,这里就不赘述了。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN011HxE9E24luDnEQiqA_!!6000000007432-2-tps-1774-670.png">
- Subject: 目标,即例子中的 “数据”。
- Observer: 观察者,即例子中的 “表格”、“柱状图”。
还是以数据与 UI 同步为例,当表格发生操作修改数据时,表格这个 TableObserver 会调用 Subject(数据)的 `setState`,此时数据被更新了。然后数据这个 `Subject` 维护了所有监听(包括表格 `TableObserver` 与柱状图 `ColumnChartObserver`),此时 `setState` 内会调用 `notify` 遍历所有监听,并依次调用 `Update` 方法,每个监听的 `Update` 方法都会调用 `getState` 获取最新数据,从而实现表格更新后 -> 更新数据 -> 表格、柱状图同时刷新。
为了更好的理解,以这张协作图为例:
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01QuF29i1RpKAEcCPrX_!!6000000002160-2-tps-1578-728.png">
- `aConcreteSubject`: 对应例子中的数据。
- `aConcreteObserver`: 对应例子中的表格。
- `anotherConcreteObserver`: 对应例子中的柱状图。
## 代码例子
下面例子使用 typescript 编写。
> PS: 为了简化处理,就不定义 Subject 接口与 ConcreteSubject 了,而是直接用 Subject 类代替。Observer 也同理。
```typescript
// 目标,管理所有观察者
class Subject {
// 观察者数组
private observers: Observer[] = []
// 状态
private state: State
// 通知所有观察者
private notify() {
this.observers.forEach(eachObserver => {
eachObserver.update()
})
}
// 新增观察者
public addObserver(observer: Observer) {
this.observers.push(observer)
}
// 更新状态
public setState(state: State) {
this.state = state
this.notify()
}
// 读取状态
public getState() {
return this.state
}
}
// 观察者
class Observer {
// 维护目标
private subject: Subject
constructor(subject: Subject) {
this.subject = subject
this.subject.addObserver(this)
}
// 更新
public update() {
// 比如渲染表格 or 渲染柱状图
console.log(this.subject.getState())
}
}
// 客户端调用
const subject = new Subject()
// 创建观察者
const observer1 = new Observer(subject)
const observer2 = new Observer(subject)
// 更新状态
subject.setState(10)
```
## 弊端
不要拘泥于实现形式,比如上面代码中的例子,`subject``observer1``observer2` 是一对多的关系,但不一定非要用这种代码组织形式来实现观察者效果。我们也可以利用 Proxy 很轻松的实现:
```typescript
const obj = new Proxy(obj, {
get(target,key) {}
set(target,key,value) {}
})
renderTable(obj)
renderChart(obj)
```
我们可以在 `obj` 被任意一个组件访问时触发 `get`,进而对 UI 与视图进行绑定;被任意一个组件更新时触发 `set`,进而对所有使用到的视图进行刷新。使用设计模式切记不要死板,理解原理就行了,在不同平台有不同的更加优雅的实现方式。
## 总结
观察者模式是非常常用的设计模式,它描述了对象一对多依赖关系下,如何通知并更新的机制,这种机制可以用在前端的 UI 与数据映射、后端的请求与控制器映射,平台间的消息通知等大部分场景,无论现实还是程序中,存在依赖且需要通知的场景非常普遍。
> 讨论地址是:[精读《设计模式 - Observer 观察者模式》· Issue #302 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/302)
**如果你想参与讨论,请 [点击这里](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,151 @@
# State(状态模式)
State(状态模式)属于行为型模式。
**意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。**
简单来说,就是将 “一个大 class + 一堆 if else” 替换为 “一堆小 class”。一堆小 class 就是一堆状态,用一堆状态代替 if else 会更好拓展与维护。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 团队接口人
团队是由很多同学组成的,但有一位接口人 TL,这位 TL 可能一会儿和产品经理谈需求,一会儿和其他 TL 谈规划,一会儿和 HR 谈人事,总之要做很多事情,很显然一个人是忙不过来的。TL 通过将任务分发给团队中每个同学,而不让他们直接和产品经理、其他 TL、HR 接触,那么这位 TL 的办事效率就会相当高,因为每个同学只负责一块具体的业务,而 TL 在不同时刻叫上不同的同学,让他们出面解决他们负责的专业领域问题,那么在外面看,这位 TL 团队能力很广,在内看,每个人负责的事情也比较单一。
### 台灯按钮
我们经常会看到只有一个按钮的台灯,但是可以通过按钮调节亮度,大概是如下一个循环 “关 -> 弱光 -> 亮 -> 强光 -> 关”,那么每次按按钮后,要跳转到什么状态,其实和当前状态有关。我们可以用 if else 解决这个问题,也可以用状态模式解决。
用状态模式解决,就是将这四个状态封装为四个类,每个类都执行按下按钮后要跳转到的状态,这样未来新增一种模式,只要改变部分类即可。
### 数据库连接器
在数据库连接前后,这个连接器的状态显然非常不同,我们如果仅用一个类描述数据库连接器,则内部免不了写大量分支语句进行状态判断。那么此时有更好的方案吗?状态模式告诉我们,可以创建多个不同状态类,比如连接前、连接中、连接后三种状态类,在不同时刻内部会替换为不同的子类,它们都继承同样的父类,所以外面看上去不需要感知内部的状态变化,内部又可以进行状态拆分,进行更好的维护。
## 意图解释
**意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。**
重点在 “内部状态” 的理解,也就是状态改变是由对象内部触发的,而不是外部,所以 **外部根本无需关心对象是否用了状态模式**,拿数据库连接器的例子来说,不管这个类是用 if else 堆砌的,还是用状态模式做的,都完全不妨碍它对外提供的稳定 API(接口问题),所以状态模式实质上是一种内聚的设计模式。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01tbZ0bQ1w8xcUgbWTJ_!!6000000006264-2-tps-1350-486.png">
- State: 状态接口,类比为台灯状态。
- ConcreteState: 具体状态,都继承于 State,类比为台灯的强光、弱光状态。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 定义状态接口
interface State {
// 模拟台灯点亮
show: () => string
}
class Light1 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '关灯'
}
// 按下按钮
public click() {
this.context.setState(new Light2(this.context))
}
}
class Light2 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '弱光'
}
// 按下按钮
public click() {
this.context.setState(new Light3(this.context))
}
}
class Light3 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '亮'
}
// 按下按钮
public click() {
this.context.setState(new Light4(this.context))
}
}
class Light4 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '强光'
}
// 按下按钮
public click() {
this.context.setState(new Light1(this.context))
}
}
// 台灯
public class Lamp {
// 当前状态
private currentState = new Light1(this)
protected setState(state: State) {
this.currentState = state
}
// 按下按钮
public click() {
this.getState().click()
}
}
const lamp = new Lamp() // 关闭
lamp.click() // 弱光
lamp.click() // 亮
lamp.click() // 强光
lamp.click() // 关闭
```
其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。
## 弊端
该用 if else 的时候还是要用,不要但凡遇到 if else 就使用状态模式,那样就是书读傻了。一定要判断,是否各状态间差异很大,且使用状态模式后维护性比 if else 更好,才应该用状态模式。
## 总结
在合适场景下,状态模式可以使代码更符合开闭原则,每个类独立维护时,逻辑也更精简、聚焦,更易维护。
> 讨论地址是:[精读《设计模式 - State 状态模式》· Issue #303 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/303)
**如果你想参与讨论,请 [点击这里](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,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)