Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280efe4545 | ||
|
|
9b54c138a7 | ||
|
|
c253b7d60f | ||
|
|
367bba2692 | ||
|
|
e888c4e22c | ||
|
|
8a3425b5e3 | ||
|
|
9081bde65e | ||
|
|
4f783678da | ||
|
|
299a60a881 | ||
|
|
7e651219b0 | ||
|
|
e1e7bc6f57 | ||
|
|
beed8434ec | ||
|
|
ab13166c0a | ||
|
|
71a48cde3b | ||
|
|
c603ac0d1d | ||
|
|
316d3d98aa | ||
|
|
0465cf43db | ||
|
|
65de19ddb6 | ||
|
|
9c0f11cc55 | ||
|
|
754ceb3d5a | ||
|
|
dcc5247ba0 | ||
|
|
02de198a8b | ||
|
|
b7cfc4c6e3 | ||
|
|
46a094e711 | ||
|
|
b5cd1b2379 | ||
|
|
cfe94e6aa7 | ||
|
|
3362f25775 | ||
|
|
87f9704831 | ||
|
|
6c7084dfe1 | ||
|
|
164590159d | ||
|
|
b8ab46d26b | ||
|
|
183b4fc41a | ||
|
|
2b7ab34978 | ||
|
|
2ca7132569 | ||
|
|
aac9aa34f8 | ||
|
|
ad8a645462 | ||
|
|
2dbe4d6368 | ||
|
|
d76253ef75 | ||
|
|
46b68f1933 | ||
|
|
8aab27bc3f | ||
|
|
22f118e1b7 | ||
|
|
922eb6dc59 | ||
|
|
20526273d3 | ||
|
|
f3222c646b | ||
|
|
9ae28c5e85 | ||
|
|
e72318adb4 | ||
|
|
2682dc0504 | ||
|
|
8b4611b47a | ||
|
|
25938c60a6 | ||
|
|
9920f0dbb7 | ||
|
|
90f7d89db7 | ||
|
|
6ddbdbbbb2 | ||
|
|
3c8c9fd5ef | ||
|
|
91a9bf82fd | ||
|
|
4603ac77d8 | ||
|
|
998940af1b | ||
|
|
24ed724eab | ||
|
|
33b71628ab | ||
|
|
2dbf1429ba | ||
|
|
9867c1ee9d | ||
|
|
e8a0539984 | ||
|
|
685e8ba32a | ||
|
|
6c9493df7b | ||
|
|
1994438792 | ||
|
|
60be3e354b | ||
|
|
9d0bb5c996 | ||
|
|
304dc5fc36 | ||
|
|
819b6d7452 | ||
|
|
b066c8a5d7 | ||
|
|
80fb60c6fb | ||
|
|
6a2031899d |
+1
-1
@@ -103,7 +103,7 @@ YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo
|
||||
|
||||
> 看到大家基本都提到了 HTTP/2,对这项技术解决前端模块化及资源打包等工程问题抱有非常大的期待。很多人也认为 HTTP/2 普及后,基本就没有 Webpack 什么事情了。
|
||||
|
||||
不过 Webpack 作者 @sokra 在他的文章 [webpack & HTTP/2](https://medium.com/webpack/webpack-http-2-7083ec3f3ce6#.zdo4juvgo) 里提到了一个新的 Webpack 插件 `AggressiveSplittingPlugin`。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunck 不需要重新下载。
|
||||
不过 Webpack 作者 @sokra 在他的文章 [webpack & HTTP/2](https://medium.com/webpack/webpack-http-2-7083ec3f3ce6#.zdo4juvgo) 里提到了一个新的 Webpack 插件 `AggressiveSplittingPlugin`。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunk 不需要重新下载。
|
||||
|
||||
可见,**即使不断的有新技术出现,也依然需要配套的工具来将前端工程问题解决方案推向极致。**
|
||||
|
||||
|
||||
+2
-2
@@ -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),每周都有新的主题,每周五发布。
|
||||
|
||||
@@ -276,7 +276,7 @@ Hook 函数必须以 "use" 命名开头,因为这样才方便 eslint 做检查
|
||||
|
||||
为什么不能用 condition 包裹 useHook 语句,详情可以见 [官方文档](https://reactjs.org/docs/hooks-rules.html#explanation),这里简单介绍一下。
|
||||
|
||||
React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 [React hooks: not magic, just arrays](https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e)),而是通过数组实现的,每次 `useState` 都会改变下标,如果 `useState` 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 `useState` 导出的 `setter` 更新错数据。
|
||||
React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 [React hooks: not magic, just arrays](https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e)),而是通过链表实现的,每次 `useState` 都会改变下标,如果 `useState` 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 `useState` 导出的 `setter` 更新错数据。
|
||||
|
||||
虽然有 [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) 插件保驾护航,但这第一次将 “约定优先” 理念引入了 React 框架中,带来了前所未有的**代码命名和顺序限制**(函数命名遭到官方限制,JS 自由主义者也许会暴跳如雷),但带来的便利也是前所未有的(没有比 React Hooks 更好的状态共享方案了,约定带来提效,自由的代价就是回到 renderProps or HOC,各团队可以自行评估)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 1 引言
|
||||
|
||||
上周的 [精读《React Hooks》](https://github.com/dt-fe/weekly/blob/master/79.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%E3%80%8B.md) 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(就是数组),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。
|
||||
上周的 [精读《React Hooks》](https://github.com/dt-fe/weekly/blob/master/79.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%E3%80%8B.md) 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(单向链表),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。
|
||||
|
||||
这篇文章将这些知识实践起来,看看广大程序劳动人民是如何发掘 React Hooks 的潜力的(造什么轮子)。
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ const dragProps = {
|
||||
|
||||
```jsx
|
||||
const dropProps = {
|
||||
onDropOver: ev => {
|
||||
onDragOver: ev => {
|
||||
// 做一些样式处理,提示用户此时松手会将元素防止在何处
|
||||
},
|
||||
onDrop: ev => {
|
||||
|
||||
@@ -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 类型
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
## 1 引言
|
||||
|
||||
在说低代码搭建之前,首先要理解什么是搭建(本文搭建指通过 Web 交互搭建一个自定义的新页面)。
|
||||
|
||||
**我认为搭建的本质是提效** ,而提效又分为对研发人员的提效,以及对客户的提效:
|
||||
|
||||
- 对研发人员的提效:相对于 Pro Code 模式,搭建的抽象程度更高,通过牺牲部分定制性换来更高效的开发方式。
|
||||
- 对客户的提效:如果用户有任何搭建 Web 应用的诉求,本质上从阿里云购买服务器自建是最普适的方案,但由于专业性要求高,用户群会很窄,因此需要针对不同用户的诉求开发定制方案,本质上是通过降低通用性换取更低的上手成本,或者针对某个领域降低上手成本,比如 BI 搭建。
|
||||
|
||||
提效虽然被说烂了,但软件工程发展中,几乎大部分工作都能归结到在提效。比如 Vscode、Typescript 提升编码效率;React、Vue 框架提升程序研发效率;工作台、可持续集成提升协同开发效率,等等,连微软都称自己的使命是赋能全球每一人、每个组织成就不凡,很大程度上就是在说提升整个社会的生产效能。
|
||||
|
||||
低代码开发平台(Low-Code Development Platform)则更进一步,允许通过零代码或少量代码就可以快速创建应用。
|
||||
|
||||
从实践结果来看,完全零代码想要覆盖所有领域是不可能的,而 100% 全代码是可以覆盖所有领域,但研发成本太高,所以介于两者之间的低代码模式是值得尝试的,因为许多定制场景往往不需要太多高深的代码就能搞定,很多复杂逻辑可能几个简单的赋值语句、或者条件语句就可以搞定,但如果不允许写代码,其使用成本甚至比写少量代码还要高。
|
||||
|
||||
所以搭建本质解决的是提效问题,考虑提效就要看性价比,是使用者学习几行简单代码后,利用低代码平台效率更高,还是使用者坚持不写代码,使用繁琐的搭建交互成本更高?有人说代码学不会,但简单代码本质和搭建无异,都是对电脑指令的输入。
|
||||
|
||||
还有一些场景将背后复杂度转移到了其他链路,比如数据搭建场景,虽然搭建器没有低代码能力,但却能实现复杂业务逻辑,原因是这个复杂度被 SQL 层吃掉了,既然复杂度无法消除,那么哪一层实现的效率更高,就由哪一层去做才是合理的。
|
||||
|
||||
## 2 精读
|
||||
|
||||
低代码不仅仅包括 “能写代码”,主要具备如下四个特性:物料接入、编排能力、渲染能力、出码能力。
|
||||
|
||||
### 物料接入
|
||||
|
||||
通用搭建引擎要能够接入通用物料,即组件自身不关心搭建环境,就可以被搭建平台所使用。
|
||||
|
||||
这需要搭建平台本身不对组件代码实现有入侵,可以对组件暴露的 props 做完全控制,要做到自动识别组件有哪些 props 变量,并根据类型自动推荐编辑表单类型。
|
||||
|
||||
除了简单的文本、数字、下拉框等编辑器 Setter 之外,还有如下几种复杂编辑器:
|
||||
|
||||
- 回调函数编辑器。
|
||||
- Node 节点编辑器。
|
||||
- 文本国际化编辑器。
|
||||
- 表达式编辑器。
|
||||
|
||||
回调函数编辑器与表达式编辑器都是低代码能力的体现,本质上就是利用代码描述某个变量值或者回调。
|
||||
|
||||
Node 节点编辑器专门处理节点类型 props 参数,比如 `props.header`、`propder.footer`,在代码模式描述为组件,在可视化模式需转化为画布下钻模式进行编辑。
|
||||
|
||||
### 编排能力
|
||||
|
||||
编排能力包含页面编排与逻辑编排,是低代码搭建的核心能力。
|
||||
|
||||
#### 页面编排
|
||||
|
||||
页面编排包含很多交互行为,比如拖拽组件、布局,其中布局大有可为,比如云凤蝶的编辑模式,通过自由拖拽布局,降低了使用者对 DOM 流式布局的理解成本,但通过自适应四周边距模拟出了流式布局自动撑开容器,容器间碰撞挤压的效果。
|
||||
|
||||
组件与组件形成的组合可以形成一个新的物料,一般称为模版,比如一个页面整体也可以称为模版,这个模版组件的 id 就是页面根节点的容器组件。但模版也有不能满足的场景,比如期望组件形成的组合拥有一套全新配置,此时就延伸出低代码业务组件的概念,可以认为将模版当作一个整体编辑,可以为模版设置任意的编辑表单,这个编辑表单的值可以透传到里面每个组件中读取。
|
||||
|
||||
#### 逻辑编排
|
||||
|
||||
逻辑编排是低代码能力的核心,在低代码引擎中,所有组件参数都可以用低代码描述,比如一个 `props.color` 可以通过颜色选择器选一个固定值,也可以转换为表达式模式写一段代码。
|
||||
|
||||
这段代码除了拥有普通 JS 能力外,还拥有基本状态管理的能力,即可以访问当前作用域下的状态 `this.state`,而状态作用域又被容器所分割,容器分为持有状态的容器与不持有状态的,一个持有状态容器内的子组件状态是互通的。
|
||||
|
||||
除了基本状态管理能力外,还拥有访问上下文能力,即调用引擎一些 API 对画布进行操作,一般都用于组件回调,在回调里调用 `this.setState` 设置状态也属于操作上下文的行为。除了上下文外,还有风格化、国际化、取数等能力可以通过 `this` 访问到,其中取数能力专门抽到引擎层做,就是为了让所有组件与取数逻辑解耦,组件只要拿到数据、isFetching,而不需要真正发送取数请求。
|
||||
|
||||
逻辑编排的另一个维度就是可视化,将上述低代码能力通过可视化方式表达为逻辑节点与线条,在描述与维护复杂逻辑时有一定优势。
|
||||
|
||||
### 渲染能力
|
||||
|
||||
搭建特殊之处在于,搭建过程几乎只能在 PC 端完成,但发布后的应用往往有多端渲染的诉求,比如越来越多的公司使用手机查看 BI 报表,甚至报表需要嵌入到微信、支付宝小程序中;PC 搭建的表单往往也有大量手机端填报的诉求。
|
||||
|
||||
所以编辑和渲染端应该是分离的,但为了保证逻辑一致性,核心代码需要复用,所以搭建引擎最好采用 UI 无关的内核 + 业务层拓展 UI 实现方式来做,UI 无关的内核只负责存储、操作画布数据,排除设计器附加的一堆 Panel 后,渲染时可以复用逻辑内核往往就足够了。
|
||||
|
||||
组件的跨端复用也是必须的,现在跨端渲染的技术方案也有不少。
|
||||
|
||||
### 出码能力
|
||||
|
||||
LowCode 与 ProCode 互转也是一大难题,首先互转的好处不必多说,可以自由的在提效与定制间切换,一定是最理想的开发模式,但实现起来有不少阻碍。
|
||||
|
||||
首先是 LowCode 转 ProCode,这个比较简单,原因是 LowCode 本身用 JSON 定义,代码是 JSON 的超集,从子集转换到超集本身没有技术障碍。
|
||||
|
||||
从 ProCode 转换到 LowCode 就麻烦了,一种方式是限定 ProCode 的能力,甚至用一种新的语法替代原生 JS,本质上都是通过将 ProCode 的能力范围限制住,使得 LowCode 可以接住。另一种方式是不对称转换,即从 ProCode 转换为 LowCode 后会存在功能缺失,或者即便功能不缺失,但 LowCode 无法对应的功能无法在搭建平台编辑。
|
||||
|
||||
### 运行时能力
|
||||
|
||||
只拥有上述低代码能力的搭建平台还是太通用了,虽然功能很强大,但在具体的业务场景不一定有多大的提效,具体的业务场景要有具体的解决方案,搭建本质是提效的,如果原子化、低代码的内容太多,就本末倒置,只是用另一种方式写代码罢了,并没有真正做到利用搭建提升开发效率。
|
||||
|
||||
通用的业务定制方式有如下三种:
|
||||
|
||||
- 定制业务组件:比如将某个复杂业务系统 80% 场景都要用到的组件固化为一个业务定制组件,省去了大部分配置时间,让使用者感受到提效。
|
||||
- 定制业务模版和低代码业务组件:更进一步,将业务模版固化下来,本质上类似代码模版,或者利用低代码业务组件,在不开发新组件的前提下,制作一个针对某个业务场景的混合组件。
|
||||
- 定制业务配置项:有些业务场景专业度很高,一方面是用户群不一样,一方面是搭建效率考虑,都应该提供一种基于业务角度出发的配置项,既符合业务思考逻辑,又节省配置步骤。
|
||||
|
||||
以上通用方式都是通过引擎已有的开放能力可以做到的,但对数据场景来说,有一些依赖引擎运行时能力场景,需要将引擎运行时能力抽象出来,配合低代码实现。
|
||||
|
||||
比如让当前页面所有配置相同数据集的组件自动建立筛选联动关联,虽然筛选联动关联可以通过低代码方式配置,但当画布组件数量变化时,或者有组件动态调用 API 新增组件时,静态的配置很难满足动态关联场景,此时我们可以拓展出一些全局运行时能力,让组件实现这些运行时能力时可以拿到画布信息,在引擎实际调用时再动态运行,而不是编辑生成一份静态 JSON 与渲染完全割裂。
|
||||
|
||||
运行时能力在不同平台针对不同垂直场景时会存在差异,如果希望打通底层引擎,可以提供拓展插槽,提供动态注册引擎运行时能力的机制。
|
||||
|
||||
## 3 总结
|
||||
|
||||
一个低代码搭建平台通吃一切场景是不可能的,只要有人愿意为垂直业务场景做 “量身定制”,用户就会立刻觉得搭建效率得到了提升,我们应当站在用户的角度,以用户利益最大化的方式做平台。
|
||||
|
||||
但搭建平台维护成本很高,每个业务场景都单独维护一套肯定不是长久之计,我们需要设计一套有弹性的低代码核心引擎,各个业务都可以基于他为自己的用户群 “量身定制” 一套专属设计器,共享搭建引擎通用的能力与协议,并自由拓展定制能力。
|
||||
|
||||
所以不仅渲染态是多态的,设计器也应该是多态的,其中可以被固化为标准的部分需要沉淀下来,比如物料接入规范、编排能力、出码能力、运行时能力,让各个搭建平台做到合而不同。
|
||||
|
||||
国内外都有非常多做的相当不错的搭建系统,但要不就太通用,具体场景提效不明显,要不就太垂直,换一个业务场景做不了。现在阿里中后台低代码搭建组织就在制定规范,将引擎通用能力固化为标准协议,让不同搭建平台可以对齐规范与功能,未来还会不断收敛核心引擎实现,基于它可以打造出千千万万个垂直领域的搭建平台,贴着业务做搭建提效,同时引擎内核与规范还能保持互通。
|
||||
|
||||
笔者所在阿里数据中台体验技术团队就是中后台低代码搭建组织的一员,将数据搭建领域做到极致。在技术上,我们在打通中后台搭建与数据搭建的技术方案,在产品上,我们正在逐渐统一阿里集团数据搭建平台,对外也携 QuickBI 成为国内唯一一家进入 Gartner 象限的 BI 产品,未来可期。
|
||||
|
||||
阿里数据中台体验技术团队正在火热招人中,如果感兴趣可以联系 ziyi.hzy@alibaba-inc.com 。
|
||||
|
||||
> 讨论地址是:[精读《对低代码搭建的理解》· Issue #260 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/260)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,308 @@
|
||||
## 1 引言
|
||||
|
||||
函数缓存是重要概念,本质上就是用空间(缓存存储)换时间(跳过计算过程)。
|
||||
|
||||
对于无副作用的纯函数,在合适的场景使用函数缓存是非常必要的,让我们跟着 https://whatthefork.is/memoization 这篇文章深入理解一下函数缓存吧!
|
||||
|
||||
## 2 概述
|
||||
|
||||
假设又一个获取天气的函数 `getChanceOfRain`,每次调用都要花 100ms 计算:
|
||||
|
||||
```jsx
|
||||
import { getChanceOfRain } from "magic-weather-calculator";
|
||||
function showWeatherReport() {
|
||||
let result = getChanceOfRain(); // Let the magic happen
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
```
|
||||
|
||||
很显然这样太浪费计算资源了,当已经计算过一次天气后,就没有必要再算一次了,我们期望的是后续调用可以直接拿上一次结果的缓存,这样可以节省大量计算。因此我们可以做一个 `memoizedGetChanceOfRain` 函数缓存计算结果:
|
||||
|
||||
```jsx
|
||||
import { getChanceOfRain } from "magic-weather-calculator";
|
||||
let isCalculated = false;
|
||||
let lastResult;
|
||||
// We added this function!
|
||||
function memoizedGetChanceOfRain() {
|
||||
if (isCalculated) {
|
||||
// No need to calculate it again.
|
||||
return lastResult;
|
||||
}
|
||||
// Gotta calculate it for the first time.
|
||||
let result = getChanceOfRain();
|
||||
// Remember it for the next time.
|
||||
lastResult = result;
|
||||
isCalculated = true;
|
||||
return result;
|
||||
}
|
||||
function showWeatherReport() {
|
||||
// Use the memoized function instead of the original function.
|
||||
let result = memoizedGetChanceOfRain();
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
```
|
||||
|
||||
在每次调用时判断优先用缓存,如果没有缓存则调用原始函数并记录缓存。这样当我们多次调用时,除了第一次之外都会立即从缓存中返回结果:
|
||||
|
||||
```jsx
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
showWeatherReport(); // Uses the calculated result
|
||||
showWeatherReport(); // Uses the calculated result
|
||||
showWeatherReport(); // Uses the calculated result
|
||||
```
|
||||
|
||||
然而对于有参数的场景就不适用了,因为缓存并没有考虑参数:
|
||||
|
||||
```jsx
|
||||
function showWeatherReport(city) {
|
||||
let result = getChanceOfRain(city); // Pass the city
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // Uses the calculated answer
|
||||
```
|
||||
|
||||
由于参数可能性很多,所以有三种解决方案:
|
||||
|
||||
### 1. 仅缓存最后一次结果
|
||||
|
||||
仅缓存最后一次结果是最节省存储空间的,而且不会有计算错误,但带来的问题就是当参数变化时缓存会立即失效:
|
||||
|
||||
```jsx
|
||||
import { getChanceOfRain } from "magic-weather-calculator";
|
||||
let lastCity;
|
||||
let lastResult;
|
||||
function memoizedGetChanceOfRain(city) {
|
||||
if (city === lastCity) {
|
||||
// Notice this check!
|
||||
// Same parameters, so we can reuse the last result.
|
||||
return lastResult;
|
||||
}
|
||||
// Either we're called for the first time,
|
||||
// or we're called with different parameters.
|
||||
// We have to perform the calculation.
|
||||
let result = getChanceOfRain(city);
|
||||
// Remember both the parameters and the result.
|
||||
lastCity = city;
|
||||
lastResult = result;
|
||||
return result;
|
||||
}
|
||||
function showWeatherReport(city) {
|
||||
// Pass the parameters to the memoized function.
|
||||
let result = memoizedGetChanceOfRain(city);
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // Uses the calculated result
|
||||
```
|
||||
|
||||
在极端情况下等同于没有缓存:
|
||||
|
||||
```jsx
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
```
|
||||
|
||||
### 2. 缓存所有结果
|
||||
|
||||
第二种方案是缓存所有结果,使用 Map 存储缓存即可:
|
||||
|
||||
```jsx
|
||||
// Remember the last result *for every city*.
|
||||
let resultsPerCity = new Map();
|
||||
function memoizedGetChanceOfRain(city) {
|
||||
if (resultsPerCity.has(city)) {
|
||||
// We already have a result for this city.
|
||||
return resultsPerCity.get(city);
|
||||
}
|
||||
// We're called for the first time for this city.
|
||||
let result = getChanceOfRain(city);
|
||||
// Remember the result for this city.
|
||||
resultsPerCity.set(city, result);
|
||||
return result;
|
||||
}
|
||||
function showWeatherReport(city) {
|
||||
// Pass the parameters to the memoized function.
|
||||
let result = memoizedGetChanceOfRain(city);
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("London"); // Uses the calculated result
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("Paris"); // (!) Triggers the calculation
|
||||
```
|
||||
|
||||
这么做带来的弊端就是内存溢出,当可能参数过多时会导致内存无限制的上涨,最坏的情况就是触发浏览器限制或者页面崩溃。
|
||||
|
||||
### 3. 其他缓存策略
|
||||
|
||||
介于只缓存最后一项与缓存所有项之间还有这其他选择,比如 LRU(least recently used)只保留最小化最近使用的缓存,或者为了方便浏览器回收,使用 WeakMap 替代 Map。
|
||||
|
||||
最后提到了函数缓存的一个坑,必须是纯函数。比如下面的 CASE:
|
||||
|
||||
```jsx
|
||||
// Inside the magical npm package
|
||||
function getChanceOfRain() {
|
||||
// Show the input box!
|
||||
let city = prompt("Where do you live?");
|
||||
// ... calculation ...
|
||||
}
|
||||
// Our code
|
||||
function showWeatherReport() {
|
||||
let result = getChanceOfRain();
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
```
|
||||
|
||||
`getChanceOfRain` 每次会由用户输入一些数据返回结果,导致缓存错误,原因是 “函数入参一部分由用户输入” 就是副作用,我们不能对有副作用的函数进行缓存。
|
||||
|
||||
这有时候也是拆分函数的意义,将一个有副作用函数的无副作用部分分解出来,这样就能局部做函数缓存了:
|
||||
|
||||
```jsx
|
||||
// If this function only calculates things,
|
||||
// we would call it "pure".
|
||||
// It is safe to memoize this function.
|
||||
function getChanceOfRain(city) {
|
||||
// ... calculation ...
|
||||
}
|
||||
// This function is "impure" because
|
||||
// it shows a prompt to the user.
|
||||
function showWeatherReport() {
|
||||
// The prompt is now here
|
||||
let city = prompt("Where do you live?");
|
||||
let result = getChanceOfRain(city);
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
```
|
||||
|
||||
最后,我们可以将缓存函数抽象为高阶函数:
|
||||
|
||||
```jsx
|
||||
function memoize(fn) {
|
||||
let isCalculated = false;
|
||||
let lastResult;
|
||||
return function memoizedFn() {
|
||||
// Return the generated function!
|
||||
if (isCalculated) {
|
||||
return lastResult;
|
||||
}
|
||||
let result = fn();
|
||||
lastResult = result;
|
||||
isCalculated = true;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
这样生成新的缓存函数就方便啦:
|
||||
|
||||
```jsx
|
||||
let memoizedGetChanceOfRain = memoize(getChanceOfRain);
|
||||
let memoizedGetNextEarthquake = memoize(getNextEarthquake);
|
||||
let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability);
|
||||
```
|
||||
|
||||
`isCalculated` 与 `lastResult` 都存储在 `memoize` 函数生成的闭包内,外部无法访问。
|
||||
|
||||
## 3 精读
|
||||
|
||||
### 通用高阶函数实现函数缓存
|
||||
|
||||
原文的例子还是比较简单,没有考虑函数多个参数如何处理,下面我们分析一下 Lodash `memoize` 函数源码:
|
||||
|
||||
```jsx
|
||||
function memoize(func, resolver) {
|
||||
if (
|
||||
typeof func != "function" ||
|
||||
(resolver != null && typeof resolver != "function")
|
||||
) {
|
||||
throw new TypeError(FUNC_ERROR_TEXT);
|
||||
}
|
||||
var memoized = function () {
|
||||
var args = arguments,
|
||||
key = resolver ? resolver.apply(this, args) : args[0],
|
||||
cache = memoized.cache;
|
||||
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
var result = func.apply(this, args);
|
||||
memoized.cache = cache.set(key, result) || cache;
|
||||
return result;
|
||||
};
|
||||
memoized.cache = new (memoize.Cache || MapCache)();
|
||||
return memoized;
|
||||
}
|
||||
```
|
||||
|
||||
原文有提到缓存策略多种多样,而 Lodash 将缓存策略简化为 key 交给用户自己管理,看这段代码:
|
||||
|
||||
```jsx
|
||||
key = resolver ? resolver.apply(this, args) : args[0];
|
||||
```
|
||||
|
||||
也就是缓存的 key 默认是执行函数时第一个参数,也可以通过 `resolver` 拿到参数处理成新的缓存 key。
|
||||
|
||||
在执行函数时也传入了参数 `func.apply(this, args)`。
|
||||
|
||||
最后 `cache` 也不再使用默认的 Map,而是允许用户自定义 `lodash.memoize.Cache` 自行设置,比如设置为 WeakMap:
|
||||
|
||||
```jsx
|
||||
_.memoize.Cache = WeakMap;
|
||||
```
|
||||
|
||||
### 什么时候不适合用缓存
|
||||
|
||||
以下两种情况不适合用缓存:
|
||||
|
||||
1. 不经常执行的函数。
|
||||
2. 本身执行速度较快的函数。
|
||||
|
||||
对于不经常执行的函数,本身就不需要利用缓存提升执行效率,而缓存反而会长期占用内存。对于本身执行速度较快的函数,其实大部分简单计算速度都很快,使用缓存后对速度没有明显的提升,同时如果计算结果比较大,反而会占用存储资源。
|
||||
|
||||
对于引用的变化尤其重要,比如如下例子:
|
||||
|
||||
```jsx
|
||||
function addName(obj, name){
|
||||
return {
|
||||
...obj,
|
||||
name:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
为 `obj` 添加一个 key,本身执行速度是非常快的,但添加缓存后会带来两个坏处:
|
||||
|
||||
1. 如果 `obj` 非常大,会在闭包存储完整 `obj` 结构,内存占用加倍。
|
||||
2. 如果 `obj` 通过 mutable 方式修改了,则普通缓存函数还会返回原先结果(因为对象引用没有变),造成错误。
|
||||
|
||||
如果要强行进行对象深对比,虽然会避免出现边界问题,但性能反而会大幅下降。
|
||||
|
||||
## 4 总结
|
||||
|
||||
函数缓存非常有用,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存,仅限于计算耗时、可能重复利用多次,且是纯函数的。
|
||||
|
||||
> 讨论地址是:[精读《函数缓存》· Issue #261 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/261)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,109 @@
|
||||
## 1 引言
|
||||
|
||||
[「可视化搭建系统」——从设计到架构,探索前端的领域和意义](https://juejin.im/post/6854573220532748302) 这篇文章主要分析了现阶段可视化搭建的几种表现形式和实现原理,并重点介绍了基于富文本的可视化搭建思路,让人耳目一新。
|
||||
|
||||
基于富文本的可视化搭建看似很新颖,但其实早就被广泛使用了,任何一个富文本编辑器几乎都有插入表格功能,这就是一个典型插入自定义组件的场景。
|
||||
|
||||
使用过 [语雀](https://www.yuque.com/) 的同学应该知道,这个产品的富文本编辑器可以插入各种各样自定义区块,是 “最像搭建” 的富文本编辑器。
|
||||
|
||||
那么积木式搭建和富文本搭建存在哪些差异,除了富文本更倾向于记录静态内容外,还有哪些差异,两者是否可以结合?本文将围绕这两点进行讨论。
|
||||
|
||||
## 2 精读
|
||||
|
||||
还是先顺着原文谈谈对可视化搭建的理解:
|
||||
|
||||
可视化搭建是通过可视化方式代替开发。**前端代码开发主要围绕的是 html + js + css**,那么无论是 markdown 语法,还是创建另一套模版语言亦或 JSON 构成的 DSL,**都是用一种 dsl + 组件 + css 的方式代替 html + js + css**,可视化搭建则更进一步,用 ui 代替了 dsl + 组件,**即精简为 ui 操作 + css**。
|
||||
|
||||
可以看到,这种转换的推演过程存在一定瑕疵,因为每次转换都有部分损耗:
|
||||
|
||||
**用 dsl + 组件 代替 html + js。**
|
||||
|
||||
如果 dsl 拓展得足够好,理论上可以达到 html 的水平,尤其在垂直业务场景是不需要那么多特殊 html 标签的。
|
||||
|
||||
但用组件代替 js 就有点奇怪了,首先并不是所有 js 逻辑都沉淀在组件里,一定有组件间的联动逻辑是无法通过一个组件 js 完成的,另一方面如果将 js 逻辑寄托在组件代码里,本质上是没有提效的,用源码开发项目与开发搭建平台的组件都是 pro code,更极端一点来说,无论是组件间联动还是整个应用都可以用一个组件来写,那搭建平台就无事可做了,这个组件也成了整个应用,game over。
|
||||
|
||||
为了弥补这块缺憾,低代码能力的呼声越来越高,而低代码能力的核心在于设计是否合理,比如暴露哪些 API 可以覆盖大部分需求?写多少代码合适,如何以最小 API 透出最大弥补组件间缺失的 js 能力?目前来看,以状态数据驱动的低代码是相对优雅的。
|
||||
|
||||
**用 ui 操作 代替 dsl + 组件。**
|
||||
|
||||
UI 操作并不是标准的,相比直接操作模版或者 JSON DSL,UI 化后就仁者见仁智者见智了,但 UI 化带来的效率提升是巨大的,因为所见即所得是生产力的源泉,从直观的 UI 布局来看,就比维护代码更轻松。但 UI 化也存在两个问题,一个是可能有人觉得不如 markdown 效率高,另一个是功能有丢失。
|
||||
|
||||
对于第一点 UI 操作效率不如 markdown 高,可能很多程序员都崇尚用 markdown 维护文档而不是富文本,原因是觉得程序员维护代码的效率反而比所见即所得高,但那可能是错觉,原因是还没有遇到好用的富文本编辑器,体验过语雀富文本编辑器后,相信大部分程序员都不会再想回头写 markdown。当然语雀富文本战胜 markdown 的原因有很多,我觉得主要两点是吸收并兼容了 markdown 操作习惯,与支持了更多仅 UI 能做到的拓展能力,对 markdown 形成降维打击。
|
||||
|
||||
第二点功能丢失很好理解,markdown 有一套标准语法和解析器可以验证,但 UI 操作并没有标准化,也没有独立验证系统,如果无法回退到源码模式,UI 没有实现的功能就做不到。
|
||||
|
||||
回到富文本搭建上,其实富文本搭建和普通网页构建并没有本质区别。html 是超文本标记语言,富文本是跨平台文档格式,从逻辑上这两个格式是可以互转的,只要富文本规则作出足够多的拓展,就可以大致覆盖 html 的能力。
|
||||
|
||||
但富文本搭建有着显著的特征,就是光标。
|
||||
|
||||
### 积木式搭建和富文本搭建的区别
|
||||
|
||||
富文本以文本为中心,因此编辑文字的光标会常驻,编辑的核心逻辑是排版文字,并考虑如何在文字周围添加一些自定义区块。
|
||||
|
||||
有了光标后,圈选也非常重要,因为大家编辑文字时有一种很自然的想法是,任何文字圈选后复制,可以粘贴到任何地方,那么所有插入到富文本中的自定义组件也要支持被圈选,被复制。
|
||||
|
||||
实际上富文本内插入自定义区块也可以转换为积木式搭建方案解决,比如下面的场景:
|
||||
|
||||
```text
|
||||
文本 A
|
||||
图表 B
|
||||
文本 C
|
||||
```
|
||||
|
||||
我们在文本 A 与 文本 C 之间插入图表 B,也可以理解为拖拽了三个组件:文本组件 A + 图表组件 B + 文本组件 C,然后分别编辑这三个组件,微调样式后可以达到与富文本一样的编辑效果,甚至加上自由布局后,在布局能力上会超越富文本。
|
||||
|
||||
虽然功能层面上富文本略有输给积木式搭建,但富文本在编辑体验上是胜出的,对于文字较多的场景,我们还是会选择富文本方式编辑而不是积木式搭建拖拽 N 个文本组件。
|
||||
|
||||
所以微软 OneNote 也吸取了这个经验,毕竟笔记本主要还是记录文字,因此还是采用富文本的编辑模式,但创造性的加入了一个个独立区块,点击任何区域都会创造一个区块,整个文档可以由一个区块构成,也可以是多个区块组合而成,这样对于连贯性的文字场景可以采用一个富文本区块,对于自定义区块较多,比如大部分是图片和表格的,还可以回到积木式搭建的体验。由于 OneNote 采用绝对定位模拟流式布局的思路,当区块重叠时还可以自动挤压底部区块,因此多区块模式下编辑体验还是相对顺畅的。
|
||||
|
||||
可以看出来这是一种结合的尝试,从前端角度来看,富文本本质上是对一个 div 进行 contenteditable 申明,那么一个应用可以整体是 contenteditable 的,也可以局部几个区块是,这种代码层面的自由度体现在搭建上就是积木式搭建可以与富文本搭建自由结合。
|
||||
|
||||
### 积木式搭建与富文本搭建如何结合
|
||||
|
||||
对于积木式搭建来说,富文本只是其中一个组件,在不考虑有富文本组件时是完全没有富文本能力的。比如一个搭建平台只提供了几个图表和基础控件,你是不可能在其基础上使用富文本能力的,甚至连写静态文本都做不到。
|
||||
|
||||
所以富文本只是搭建中一个组件,就像 contenteditable 也只能依附于一个标签,整个网页还是由标签组成的。但对于一个提供了富文本组件的积木式搭建系统来说,文字与控件混排又是一个痛点,毕竟要以一个个区块组件的方式去拖拽文本节点,成本比富文本模式大得多。
|
||||
|
||||
所以理想情况是富文本与整个搭建系统使用同一套 DSL 描述结构,富文本只是在布局上有所简化,简化为简单的平铺模式即可,但因为 DSL 描述打通,富文本也可以描述使用搭建提供的任意组件嵌套在内,所以只要用户愿意,可以将富文本组件拉到最大,整个页面都基于富文本模式去搭建,这就变成了富文本搭建,也可以将富文本缩小,将普通控件以积木方式拖拽到画布中,走积木式搭建路线。
|
||||
|
||||
用代码方式描述积木式搭建:
|
||||
|
||||
```html
|
||||
<bar-chart />
|
||||
<div>
|
||||
<p>header</p>
|
||||
<line-chart />
|
||||
<p>footer</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
上述模式需要拖拽 `bar-chart`、`div`、`p`、`line-chart`、`p` 共 5 个组件。富文本模式则类似下面的结构:
|
||||
|
||||
```html
|
||||
<bar-chart />
|
||||
<div contenteditable>
|
||||
<p>header</p>
|
||||
<line-chart />
|
||||
<p>footer</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
只要拖拽 `bar-chart`、`div` 两个组件即可,`div` 内部的文字通过光标输入,`line-chart` 通过富文本某个按钮或者键盘快捷键添加。
|
||||
|
||||
可以看到虽然操作方式不同,但本质上描述协议并没有本质区别,我们理论上可以将任何容器标签切换为富文本模式。
|
||||
|
||||
## 3 总结
|
||||
|
||||
富文本是一种重要的交互模式,可以基于富文本模式做搭建,也可以在搭建系统中嵌入富文本组件,甚至还可以追求搭建与富文本的结合。
|
||||
|
||||
富文本组件既可以是搭建系统中一个组件,又可以在内部承载搭建系统的所有组件,做到这一步才算是真正发挥出富文本的潜力。
|
||||
|
||||
> 讨论地址是:[精读《可视化搭建思考 - 富文本搭建》· Issue #262 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/262)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,185 @@
|
||||
## 1 引言
|
||||
|
||||
本周跟着 [Tasks, microtasks, queues and schedules](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) 这篇文章一起深入理解这些概念间的区别。
|
||||
|
||||
先说结论:
|
||||
|
||||
- Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。
|
||||
- Microtasks 也按顺序执行,时机是:
|
||||
- 如果没有执行中的 js 堆栈,则在每个回调之后。
|
||||
- 在每个 task 之后。
|
||||
|
||||
## 2 概述
|
||||
|
||||
### Event Loop
|
||||
|
||||
在说这些概念前,先要介绍 Event Loop。
|
||||
|
||||
首先浏览器是多线程的,每个 JS 脚本都在单线程中执行,每个线程都有自己的 Event Loop,同源的所有浏览器窗口共享一个 Event Loop 以便通信。
|
||||
|
||||
Event Loop 会持续循环的执行所有排队中的任务,浏览器会为这些任务划分优先级,按照优先级来执行,这就会导致 Tasks 与 Microtasks 执行顺序与调用顺序的不同。
|
||||
|
||||
### promise 与 setTimeout
|
||||
|
||||
看下面代码的输出顺序:
|
||||
|
||||
```js
|
||||
console.log("script start");
|
||||
|
||||
setTimeout(function () {
|
||||
console.log("setTimeout");
|
||||
}, 0);
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
console.log("promise1");
|
||||
})
|
||||
.then(function () {
|
||||
console.log("promise2");
|
||||
});
|
||||
|
||||
console.log("script end");
|
||||
```
|
||||
|
||||
正确答案是 `script start`, `script end`, `promise1`, `promise2`, `setTimeout`,在线程中,同步脚本执行优先级最高,然后 promise 任务会存放到 Microtasks,setTimeout 任务会存放到 Tasks,Microtasks 会优先于 Tasks 执行。
|
||||
|
||||
Microtasks 中文可以翻译为微任务,只要有 Microtasks 插入,就会不断执行 Microtasks 队列直到结束,在结束前都不会执行到 Tasks。
|
||||
|
||||
### 点击冒泡 + 任务
|
||||
|
||||
下面给出了更复杂的例子,提前说明后面的例子 Chrome、Firefox、Safari、Edge 浏览器的结果完全不一样,但只有 Chrome 的运行结果是对的!为什么 Chrome 是对的呢,请看下面的分析:
|
||||
|
||||
```html
|
||||
<div class="outer">
|
||||
<div class="inner"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// Let's get hold of those elements
|
||||
var outer = document.querySelector(".outer");
|
||||
var inner = document.querySelector(".inner");
|
||||
|
||||
// Let's listen for attribute changes on the
|
||||
// outer element
|
||||
new MutationObserver(function () {
|
||||
console.log("mutate");
|
||||
}).observe(outer, {
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
// Here's a click listener…
|
||||
function onClick() {
|
||||
console.log("click");
|
||||
|
||||
setTimeout(function () {
|
||||
console.log("timeout");
|
||||
}, 0);
|
||||
|
||||
Promise.resolve().then(function () {
|
||||
console.log("promise");
|
||||
});
|
||||
|
||||
outer.setAttribute("data-random", Math.random());
|
||||
}
|
||||
|
||||
// …which we'll attach to both elements
|
||||
inner.addEventListener("click", onClick);
|
||||
outer.addEventListener("click", onClick);
|
||||
```
|
||||
|
||||
点击 `inner` 区块后,正确输出顺序应该是:
|
||||
|
||||
```text
|
||||
click
|
||||
promise
|
||||
mutate
|
||||
click
|
||||
promise
|
||||
mutate
|
||||
timeout
|
||||
timeout
|
||||
```
|
||||
|
||||
逻辑如下:
|
||||
|
||||
1. 点击触发 `onClick` 函数入栈。
|
||||
2. 立即执行 `console.log('click')` 打印 `click`。
|
||||
3. `console.log('timeout')` 入栈 Tasks。
|
||||
4. `console.log('promise')` 入栈 microtasks。
|
||||
5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。
|
||||
6. `onClick` 函数执行完毕,此时线程调用栈为空,开始执行 microtasks 队列。
|
||||
7. 打印 `promise`,打印 `mutate`,此时 microtasks 已空。
|
||||
8. 执行冒泡机制,outer div 也触发 `onClick` 函数,同理,打印 `promise`,打印 `mutate`。
|
||||
9. 都执行完后,执行 Tasks,打印 `timeout`,打印 `timeout`。
|
||||
|
||||
### 模拟点击冒泡 + 任务
|
||||
|
||||
如果将触发 `onClick` 行为由点击改为:
|
||||
|
||||
```js
|
||||
inner.click();
|
||||
```
|
||||
|
||||
结果会不同吗?答案是会(单元测试与用户行为不符合,单测也有无解的时候)。然而四大浏览器的执行结果也是完全不一样,但从逻辑上讲仍然 Chrome 是对的,让我们看下 Chrome 的结果:
|
||||
|
||||
```text
|
||||
click
|
||||
click
|
||||
promise
|
||||
mutate
|
||||
promise
|
||||
timeout
|
||||
timeout
|
||||
```
|
||||
|
||||
逻辑如下:
|
||||
|
||||
1. `inner.click()` 触发 `onClick` 函数入栈。
|
||||
2. 立即执行 `console.log('click')` 打印 `click`。
|
||||
3. `console.log('timeout')` 入栈 Tasks。
|
||||
4. `console.log('promise')` 入栈 microtasks。
|
||||
5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。
|
||||
6. 由于冒泡改为 js 调用栈执行,所以此时 js 调用栈未结束,不会执行 microtasks,反而是继续执行冒泡,outer 的 `onClick` 函数入栈。
|
||||
7. 立即执行 `console.log('click')` 打印 `click`。
|
||||
8. `console.log('timeout')` 入栈 Tasks。
|
||||
9. `console.log('promise')` 入栈 microtasks。
|
||||
10. `MutationObserver` 由于还没调用,因此这次 `outer.setAttribute('data-random')` 的改动实际上没有作用。
|
||||
11. js 调用栈执行完毕,开始执行 microtasks,按照入栈顺序,打印 `promise`,`mutate`,`promise`。
|
||||
12. microtasks 执行完毕,开始执行 Tasks,打印 `timeout`,`timeout`。
|
||||
|
||||
## 3 精读
|
||||
|
||||
基于任务调度这么复杂,且浏览器实现方式很不同,下面两件事是我很不推荐的:
|
||||
|
||||
1. 业务逻辑 “巧妙” 依赖了 microtasks 与 Tasks 执行逻辑的微妙差异。
|
||||
2. 死记硬背调用顺序。
|
||||
|
||||
且不说依赖了调用顺序的业务逻辑本身就很难维护,不同浏览器之间对任务调用顺序还是不同的,这可能源于对 W3C 标准规范理解的偏差,也可能是 BUG,这会导致依赖于此的逻辑非常脆弱。
|
||||
|
||||
虽然上面两个例子非常复杂,但我们也不必把这个例子当作经典背诵,只要记住文章开头提到的执行逻辑就可以推导:
|
||||
|
||||
- Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。
|
||||
- Microtasks 也按顺序执行,时机是:
|
||||
- 如果没有执行中的 js 堆栈,则在每个回调之后。
|
||||
- 在每个 task 之后。
|
||||
|
||||
记住 `Promise` 是 `Microtasks`,`setTimeout` 是 `Tasks`,JS 一次 Event Loop 完毕后,即调用栈没有内容时才会执行 `Microtasks` -> `Tasks`,在执行 `Microtasks` 过程中插入的 `Microtasks` 会按顺序继续执行,而执行 `Tasks` 中插入的 `Microtasks` 得等到调用栈执行完后才继续执行。
|
||||
|
||||
上面说的内容都是指一次 Event Loop 时立即执行的优先级,不要和执行延迟时间弄混淆了。
|
||||
|
||||
把 JS 线程的 Event Loop 当作一个函数,函数内同步逻辑执行优先级是最高的,如果遇到 `Microtasks` 或 `Tasks` 就会立即记录下来,当一次 Event Loop 执行完后立即调用 `Microtasks`,等 `Microtasks` 队列执行完毕后可能进行一些渲染行为,等这些浏览器操作完成后,再考虑执行 `Tasks` 队列。
|
||||
|
||||
## 4 总结
|
||||
|
||||
最后,还是要强调一句,不要依赖 `Microtasks` 与 `Tasks` 的执行顺序,尤其在申明式编程环境中,我们可以把 `Microtasks` 与 `Tasks` 都当作是异步内容,在渲染时做好状态判断即可,不用关心先后顺序。
|
||||
|
||||
> 讨论地址是:[精读《Tasks, microtasks, queues and schedules》· Issue #264 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/264)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,306 @@
|
||||
[spring](https://spring.io/) 是 Java 非常重要的框架,且蕴含了一系列设计模式,非常值得研究,本期就通过 [Spring 学习](https://www.cnblogs.com/wmyskxz/p/8820371.html) 这篇文章了解一下 spring。
|
||||
|
||||
## spring 为何长寿
|
||||
|
||||
spring 作为一个后端框架,拥有 17 年历史,这在前端看来是不可思议的。前端几乎没有一个框架可以流行超过 5 年,就最近来看,react、angular、vue 三大框架可能会活的久一点,他们都是前端相对成熟阶段的产物,我们或多或少可以看出一些设计模式。然而这些前端框架与 spring 比起来还是差距很大,我们来看看 spring 到底强大在哪。
|
||||
|
||||
### 设计模式
|
||||
|
||||
设计模式是一种思想,不依附于任何编程语言与开发框架。比如你学会了工厂设计模式,可以在后端用,也可以转到前端用,可以在 Go 语言用,也可以在 Typescript 用,可以在 React 框架用,也可以在 Vue 里用,所以设计模式是一种具有迁移能力的知识,学会后可以受益整个职业生涯,而语言、框架则不具备迁移性,前端许多同学都把精力花在学习框架特性上,遇到前端技术迭代时期就尴尬了,这就是为什么大公司面试要问框架原理,就是看看你能否抓住一些不变的东西,所以洋洋洒洒的说上下文相关的细节也不是面试官想要的,真正想听到的是你抽象后对框架原理共性的总结。
|
||||
|
||||
spring 框架就用到了许多设计模式,包括:
|
||||
|
||||
工厂模式:用工厂生产对象实例来代替原始的 new。所谓工厂就是屏蔽实例话的细节,调用处无需关心实例化对象需要的环境参数,提升可维护性。spring 的 BeanFactory 创建 bean 对象就是工厂模式的体现。
|
||||
代理模式:允许通过代理对象访问目标对象。Spring 实现 AOP 就是通过动态代理模式。
|
||||
单例模式:单实例。spring 的 bean 默认都是单例。
|
||||
包装器模式:将几个不同方法通用部分抽象出来,调用时通过包装器内部引导到不同的实现。比如 spring 连接多种数据库就使用了包装器模式简化。
|
||||
观察者模式:这个前端同学很熟悉,就是事件机制,spring 中可以通过 ApplicationEvent 实践观察者模式。
|
||||
适配器模式:通过适配器将接口转换为另一个格式的接口。spring AOP 的增强和通知就使用了适配器模式。
|
||||
模板方法模式:父类先定义一些函数,这些函数之间存在调用关联,将某些设定为抽象函数等待子类继承时去重写。spring 的 `jdbcTemplate`、`hibernateTemplate` 等数据库操作类使用了模版方法模式。
|
||||
|
||||
### 全家桶
|
||||
|
||||
spring 作为一个全面的 java 框架,提供了系列全家桶满足各种场景需求:spring mvc、spring security、spring data、spring boot、spring cloud。
|
||||
|
||||
- spring boot:简化了 spring 应用配置,约定大于配置的思维。
|
||||
- spring data:是一个数据操作与访问工具集,比如支持 jdbc、redis 等数据源操作。
|
||||
- spring cloud:是一个微服务解决方案,基于 spring boot,集成了服务发现、配置管理、消息总线、负载均衡、断路器、数据监控等各种服务治理能力。
|
||||
- spring security:支持一些安全模型比如单点登录、令牌中继、令牌交换等。
|
||||
- spring mvc:MVC 思想的 web 框架。
|
||||
|
||||
## IOC
|
||||
|
||||
IOC(Inverse of Control)控制反转。IOC 是 Spring 最核心部分,因为所有对象调用都离不开 IOC 模式。
|
||||
|
||||
假设我们有三个类:Country、Province、City,最大的类别是国家,其次是省、城市,国家类需要调用省类,省类需要调用城市类:
|
||||
|
||||
```java
|
||||
public class Country {
|
||||
private Province province;
|
||||
public Country(){
|
||||
this.province = new Province()
|
||||
}
|
||||
}
|
||||
|
||||
public class Province {
|
||||
private City city;
|
||||
public Province(){
|
||||
this.city = new City()
|
||||
}
|
||||
}
|
||||
|
||||
public class City {
|
||||
public City(){
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
假设来了一个需求,City 实例化时需增加人口(people)参数,我们就要改动所有类代码:
|
||||
|
||||
```java
|
||||
public class Country {
|
||||
private Province province;
|
||||
public Country(int people){
|
||||
this.province = new Province(people)
|
||||
}
|
||||
}
|
||||
|
||||
public class Province {
|
||||
private City city;
|
||||
public Province(int people){
|
||||
this.city = new City(people)
|
||||
}
|
||||
}
|
||||
|
||||
public class City {
|
||||
public City(int people){
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
那么在真实业务场景中,一个底层类可能被数以千计的类使用,这么改显然难以维护。IOC 就是为了解决这个问题,它使得我们可以只改动 City 的代码,而不用改动其他类的代码:
|
||||
|
||||
```java
|
||||
public class Country {
|
||||
private Province province;
|
||||
public Country(Province province){
|
||||
this.province = province
|
||||
}
|
||||
}
|
||||
|
||||
public class Province {
|
||||
private City city;
|
||||
public Province(City city){
|
||||
this.city = city
|
||||
}
|
||||
}
|
||||
|
||||
public class City {
|
||||
public City(int people){
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,增加 `people` 属性只需要改动 city 类。然而这样做也是有成本的,就是类实例化步骤会稍微繁琐一些:
|
||||
|
||||
```java
|
||||
City city = new City(1000);
|
||||
Province province = new Province(city);
|
||||
Country country = new Country(province);
|
||||
```
|
||||
|
||||
这就是控制反转,由 Country 依赖 Province 变成了类依赖框架(上面的实例化代码)注入。
|
||||
|
||||
然而手动维护这种初始化依赖是繁琐的,spring 提供了 bean 容器自动做这件事,我们只需要利用装饰器 Autowired 就可以自动注入依赖:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class Country {
|
||||
@Autowired
|
||||
private Province province;
|
||||
}
|
||||
@Component
|
||||
public class Province {
|
||||
@Autowired
|
||||
public City city;
|
||||
}
|
||||
@Component
|
||||
public class City {
|
||||
}
|
||||
```
|
||||
|
||||
实际上这种自动分析并实例化的手段,不仅比手写方便,还能解决循环依赖的问题。在实际场景中,两个类相互调用是很常见的,假设现在有 A、B 类相互依赖:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class A {
|
||||
@Autowired
|
||||
private B b;
|
||||
}
|
||||
@Component
|
||||
public class B {
|
||||
@Autowired
|
||||
public A a;
|
||||
}
|
||||
```
|
||||
|
||||
那么假设我们想获取 A 实例,会经历这样一个过程:
|
||||
|
||||
```text
|
||||
获取 A 实例 -> 实例化不完整 A -> 检测到注入 B -> 实例化不完整 B -> 检测到注入 A -> 注入不完整 A -> 得到完整 B -> 得到完整 A -> 返回 A 实例
|
||||
```
|
||||
|
||||
其实 spring 仅支持单例模式下非构造器的循环依赖,这是因为其内部有一套机制,让 bean 在初始化阶段先提前持有对方引用地址,这样就可以同时实例化两个对象了。
|
||||
|
||||
除了方便之外,IOC 配合 spring 容器概念还可以使获取实例时不用关心一个类实例化需要哪些参数,只需要直接申明获取即可,这样在类的数量特别多,尤其是大量代码不是你写的情况下,不需要阅读类源码也可以轻松获取实例,实在是大大提升了可维护性。
|
||||
|
||||
说到这就提到了 Bean 容器,在 spring 概念中,Bean 容器是对 class 的加强,如果说 Class 定义了类的基本含义,那 Bean 就是对类进行使用拓展,告诉我们应该如何实例化与使用这个类。
|
||||
|
||||
举个例子,比如利用注解描述的这段 Bean 类:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class CityConfig {
|
||||
@Scope("prototype")
|
||||
@Lazy
|
||||
@Bean(initMethod = "init", destroyMethod = "destroy")
|
||||
public City city() {
|
||||
return new City()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,额外描述了是否延迟加载,是否单例,初始化与析构函数分别是什么等等。
|
||||
|
||||
下面给出一个从 Bean 获取实例的例子,采用比较古老的 xml 配置方式:
|
||||
|
||||
```java
|
||||
public interface City {
|
||||
Int getPeople();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public class CityImpl implements City {
|
||||
public Int getPeople() {
|
||||
return 1000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
接下来用 xml 描述这个 bean:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.springframework.org/schema/beans"
|
||||
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" default-autowire="byName">
|
||||
|
||||
<bean id="city" class="xxx.CityImpl"/>
|
||||
</beans>
|
||||
```
|
||||
|
||||
`bean` 支持的属性还有很多,由于本文并不做入门教学,就不一一列举了,总之 `id` 是一个可选的唯一标志,接下来我们可以通过 `id` 访问到 city 的实例。
|
||||
|
||||
```java
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
ApplicationContext context = new ClassPathXmlApplicationContext("classpath:application.xml");
|
||||
|
||||
// 从 context 中读取 Bean,而不 new City()
|
||||
City city = context.getBean(City.class);
|
||||
|
||||
System.out.println(city.getPeople());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,程序任何地方使用 city 实例,只需要调用 `getBean` 函数,就像一个工厂把实例化过程给承包了,我们不需要关心 City 构造函数要传递什么参数,不需要关心它依赖哪些其他的类,只要这一句话就可以拿到实例,是不是在维护项目时省心了很多。
|
||||
|
||||
## AOP
|
||||
|
||||
AOP(Aspect Oriented Program)面向切面编程。
|
||||
|
||||
AOP 是为了解决主要业务逻辑与次要业务逻辑之间耦合问题的。主要业务逻辑比如登陆、数据获取、查询等,次要业务逻辑比如性能监控、异常处理等等,次要业务逻辑往往有:不重要、和业务关联度低、贯穿多处业务逻辑的特性,如果没有好的设计模式,只能在业务代码里将主要逻辑与次要逻辑混合起来,但 AOP 可以做到主要、次要业务逻辑隔离。
|
||||
|
||||
使用 AOP 就是在定义在哪些地方(类、方法)切入,在什么地方切入(方法前、后、前后)以及做什么。
|
||||
|
||||
比如说,我们想在某个方法前后分别执行两个函数计算执行时间,下面是主要业务逻辑:
|
||||
|
||||
```java
|
||||
@Component("work")
|
||||
public class Work {
|
||||
public void do() {
|
||||
System.out.println("执行业务逻辑");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
再定义切面方法:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Aspect
|
||||
class Broker {
|
||||
@Before("execution(* xxx.Work.do())")
|
||||
public void before(){
|
||||
// 记录开始时间
|
||||
}
|
||||
|
||||
@After("execution(* xxx.Work.do())")
|
||||
public void after(){
|
||||
// 计算时间
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
再通过 xml 定义扫描下这两个 Bean,就可以在运行 `work.do()` 之前执行 `before()`,之后执行 `after()`。
|
||||
|
||||
还可以完全覆盖原函数,利用 `joinPoint.proceed()` 可以执行原函数:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Aspect
|
||||
class Broker {
|
||||
@Around("execution(* xxx.Work.do())")
|
||||
public void around(ProceedingJoinPoint joinPoint) {
|
||||
// 记录开始时间
|
||||
|
||||
try {
|
||||
joinPoint.proceed();
|
||||
} catch (Throwable throwable) {
|
||||
throwable.printStackTrace();
|
||||
}
|
||||
|
||||
// 计算时间
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关于表达式 `"execution(* xxx.Work.do())"` 是用正则的方式匹配,`*` 表示任意返回类型的方法,后面就不用解释了。
|
||||
|
||||
可以看到,我们可以在不修改原方法的基础上,在其执行前后增加自定义业务逻辑,或者监控其报错,非常适合做次要业务逻辑,且由于不与主要业务逻辑代码耦合,保证了代码的简洁,且次要业务逻辑不容易遗漏。
|
||||
|
||||
## 总结
|
||||
|
||||
IOC 特别适合描述业务模型,后端天然需要这一套,然而随着前端越做越重,如果某个业务场景下需要将部分业务逻辑放到前端,也是非常推荐使用 IOC 设计模式来做,这是后端沉淀了近 20 年的经验,没有必要再另辟蹊径。
|
||||
|
||||
AOP 对前端有帮助但没有那么大,因为前端业务逻辑较为分散,如果要进行切面编程,往往用 `window` 事件监听来做会更彻底,可能这都是前端没有流行 AOP 的原因。当然前端约定大于配置的趋势下,比如打点或监控都集成到框架内部,往往也做到了业务代码无感,剩下的业务代码也就没有 AOP 的需求。
|
||||
|
||||
最后,spring 的低侵入式设计,使得业务代码不用关心框架,让业务代码能够快速在不同框架间切换,这不仅方便了业务开发者,更使得 spring 走向成功,这是前端还需要追赶的。
|
||||
|
||||
> 讨论地址是:[精读《Spring 概念》· Issue #265 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/265)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,783 @@
|
||||
bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。
|
||||
|
||||
> bi-designer 目前没有开源,因此文中使用的私有 npm 源 `@alife/bi-designer` 是无法在公网访问的。
|
||||
|
||||
本文介绍 bi-designer 设计器的使用 API。
|
||||
|
||||
bi-designer 设计有如下几个特点:
|
||||
|
||||
- **心智统一:编辑模式与渲染模式统一**。
|
||||
- **通用搭建:支持接入任意通用 npm 组件**。
|
||||
- **低入侵:围绕数据分析能力做了增强,但对组件代码无入侵**。
|
||||
|
||||
## 渲染画布
|
||||
|
||||
做搭建,第一步是将画布渲染出来,需要用到 `Designer` 与 `Canvas` 组件:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas } from '@alife/bi-designer'
|
||||
export () => (
|
||||
<Designer>
|
||||
<Canvas />
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
- `Designer`:数据容器,用于管理渲染引擎数据流。
|
||||
- 参数 `defaultPageSchema`:页面 DSL 默认值。
|
||||
- 参数 `defaultMode`:控制编辑渲染状态,`edit` or `render`。
|
||||
- `Canvas`:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。
|
||||
|
||||
## 编辑模式
|
||||
|
||||
编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas } from '@alife/bi-designer'
|
||||
|
||||
export () => (
|
||||
<Designer defaultMode="edit">
|
||||
<div>Header</div>
|
||||
<Canvas />
|
||||
<div>Footer</div>
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
编辑模式的拓展采用了 JSX 模式,没有增加任何新的语法,只要放置任意数量的组件,并将画布 `Canvas` 摆放在想要的位置即可。
|
||||
|
||||
`defaultMode` 描述了当前引擎所处状态,有 `edit` 与 `render` 两个可选值,可以通过 `{ mode } = useDesigner(modeSelector)` 获取。bi-designer 没有对 `mode` 做任何特殊处理,我们可以在 panel、组件中判断不同的 `mode` 走不同的逻辑,以此区分编辑与渲染态。
|
||||
|
||||
## 页面 DSL 结构
|
||||
|
||||
`pageSchema` 描述了页面 DSL 信息,其结构是一个 `Map<组件 id, 组件实例信息>`。
|
||||
|
||||
这里统一一下名词:
|
||||
|
||||
- 组件实例信息:`componentInstance`。
|
||||
- 组件元信息:`componentMeta`。
|
||||
|
||||
那么 `pageSchema` 的结构大致如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"componentInstances": {
|
||||
"1": {
|
||||
"id": "1",
|
||||
"componentName": "root",
|
||||
},
|
||||
"2": {
|
||||
"id": "2",
|
||||
"parentId": "1",
|
||||
"componentName": "button",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
根据 `id` `parentId` 关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 `index` 标记顺序。
|
||||
|
||||
## 注册组件
|
||||
|
||||
DSL 描述信息中最重要的是 `componentName`,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(`componentMetas`)传递给 `Designer`:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
|
||||
|
||||
export () => (
|
||||
<Designer componentMetas={componentMetas}>
|
||||
<Canvas />
|
||||
</Designer>
|
||||
)
|
||||
|
||||
const componentMetas: Interfaces.ComponentMetas = {
|
||||
button: {
|
||||
componentName: 'button',
|
||||
element: Button
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关于 `componentMeta` 会在下一篇精读详细介绍,这里只说明两个最重要的属性:
|
||||
|
||||
- `componentName`:组件名,唯一。
|
||||
- `element`:组件 UI 对象,对应一个 React 组件实例。
|
||||
|
||||
注意这里就留下了不少拓展空间,`componentMetas` 可以存储在服务端,`element` 可以远程异步加载,也可以在项目代码中固化,但传递给渲染引擎的 API 是固定的。
|
||||
|
||||
## 布局
|
||||
|
||||
bi-designer 支持流式布局、磁贴布局、自由布局三种模式,通过 `Designer.layout` 属性定义:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
|
||||
import { LayoutMover } from '@alife/bi-designer-stream-layout'
|
||||
|
||||
export () => (
|
||||
<Designer layout={LayoutMover}>
|
||||
<Canvas />
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
我们提供了三种不同的布局包,切换对应的包即可切换布局,你甚至可以再包裹一层,通过代码控制在运行时切换布局。
|
||||
|
||||
`layout` 会包裹在每个组件外层,无论是流式、磁贴还是自由布局,都可以通过附着在每个组件外层来实现。
|
||||
|
||||
## 操作/获取画布内容
|
||||
|
||||
只要在数据容器 `Designer` 下,就可以通过 `useDesigner()` 获取画布信息或者修改画布内容。
|
||||
|
||||
举个例子,比如实现组件配置面板,需要获取到 **当前选中组件**,以及实现操作 **更新 DSL 中某个组件信息**:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas, useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
|
||||
|
||||
const EditPanel = () => {
|
||||
const { updateComponentById, selectedComponents } =
|
||||
useDesigner(selectedComponentsSelector());
|
||||
|
||||
// 在合适的时候调用 updateComponentById 更新 selectedComponents
|
||||
|
||||
// 渲染组件配置表单..
|
||||
}
|
||||
|
||||
export () => (
|
||||
<Designer>
|
||||
<Canvas />
|
||||
<EditPanel />
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
我们在 `Canvas` 下面渲染了一个自定义组件 `EditPanel` 作为组件配置面板,这个配置面板中,最重要的是这块代码:
|
||||
|
||||
```jsx
|
||||
import { useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
|
||||
const { updateComponentById, selectedComponents } =
|
||||
useDesigner(selectedComponentsSelector());
|
||||
```
|
||||
|
||||
- `useDesigner` 是 React Hook,导出的函数都是静态的,不会因为画布信息变更而导致组件重渲染。
|
||||
- 如果需要监听一些会变化的元素,比如当前选中组件,就需要用 Selector 完成,当这些信息变更时,使用了这些 Selector 的组件也会重渲染,具体 Selector 有很多,比如:
|
||||
- `selectedComponentsSelector`: 当前选中的组件。
|
||||
- `pageSchemaSelector`: 当前画布 DSL。
|
||||
- `modeSelector`: 当前渲染模式。等等。
|
||||
- 对画布组件操作有几个重要的静态方法,包括:
|
||||
- `updateComponentById`: 更新某个 id 组件信息。
|
||||
- `addComponent`: 添加组件。
|
||||
- `deleteComponent`: 删除组件。
|
||||
- `moveComponent`: 移动组件。等等。
|
||||
- 除此之外,`useDesigner` 还提供了很多有用的方法,在用到时再介绍。
|
||||
|
||||
## 主题风格
|
||||
|
||||
通过 `pageSchema.theme` 设置主题风格:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
|
||||
const App = () => (
|
||||
<Designer
|
||||
defaultPageSchema={{
|
||||
theme: { primaryColor: '#333' }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
我们也可以在运行时使用 `setTheme` 动态修改主题风格,做到动态切换主题:
|
||||
|
||||
```jsx
|
||||
const { setTheme, theme } = useDesigner();
|
||||
|
||||
return <Button onClick={() => {
|
||||
setTheme({
|
||||
...theme,
|
||||
primaryColor: '#ffffff'
|
||||
})
|
||||
}} />
|
||||
```
|
||||
|
||||
这些主题颜色,组件可以通过 css 变量拿到:
|
||||
|
||||
```css
|
||||
.ok-button {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
```
|
||||
|
||||
## 获取组件数据
|
||||
|
||||
数据分析引擎中,组件是由数据驱动展示的,这些数据可能来自 OLAP 数据集,或者普通 URL 接口,但无论如何数据都是一个组件重要组成部分,因此对组件的取数与数据操作是 bi-designer 的一个重点。
|
||||
|
||||
可以利用 `fetchStateSelector` 获取任意组件的数据信息,包括取数状态、数据、是否有查询错误等:
|
||||
|
||||
```jsx
|
||||
import { useDesigner, fetchStateSelector } from '@alife/bi-designer';
|
||||
|
||||
const App = () => {
|
||||
const { fetchState } = useDesigner(fetchStateSelector(componentInstance.id));
|
||||
|
||||
console.log(
|
||||
fetchState.isFetching, // 是否在取数中
|
||||
fetchState.isFilterReady, // 筛选条件是否准备好了
|
||||
fetchState.data, // 取数结果
|
||||
fetchState.error, // 取数错误,如果取数阶段报错的话
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
bi-designer 将所有组件的取数状态统一管理,因此可以跨组件获取数据信息,实现一些复杂需求:比如某些组件配置面板要获取组件取数结果填充配置表单。
|
||||
|
||||
## 组件加载器
|
||||
|
||||
组件加载器 `ComponentLoader` 可以加载任意组件, `Canvas` 就是基于此实现的。
|
||||
|
||||
### 加载画布中已有组件
|
||||
|
||||
通过申明 id 加载一个画布中已有组件,与其共享同一套数据:
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader } from '@alife/bi-designer'
|
||||
const App = () => {
|
||||
return <ComponentLoader id="some-id-already-exist" />
|
||||
}
|
||||
```
|
||||
|
||||
### 加载一个额外的新组件
|
||||
|
||||
如果这个组件不需要响应事件,只是做简单的渲染,那就不需要记录到数据流中,此时仅申明 `componentName` 即可:
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader } from '@alife/bi-designer'
|
||||
const App = () => {
|
||||
return <ComponentLoader componentName="button" />
|
||||
}
|
||||
```
|
||||
|
||||
但这种方式加载的组件存在如下问题:
|
||||
|
||||
- 其组件 `id` 不会存储到 `pageSchema` ,后端可能无法做一些校验。
|
||||
- 无法响应事件,因为事件响应前提是组件信息存在于 `pageSchema` 中。
|
||||
|
||||
### 加载一个有事件功能的额外新组件
|
||||
|
||||
通过申明 `id` 与 `componentName` 加载一个全新组件,为了在其销毁时做有效清理,请将其 id 记录到 `useKeepComponentLoaders` 中。
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader, useDesigner } from '@alife/bi-designer'
|
||||
const App = () => {
|
||||
const { useKeepComponentLoaders } = useDesigner();
|
||||
useKeepComponentLoaders(["1"])
|
||||
|
||||
return <ComponentLoader id="1" componentName="button" />
|
||||
}
|
||||
```
|
||||
|
||||
通过此方式加载的组件会在其渲染时记录到 `pageSchema` 中。
|
||||
|
||||
> 注意,此时 id 与仅写一个 id 时含义不同,这个 id 在当前父组件作用域下唯一就可以。
|
||||
|
||||
## 全屏功能
|
||||
|
||||
所有组件实例都可以存在副本,共享一套状态数据,可以通过 `ComponentLoader` 随时渲染一个组件副本:
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader } from '@alife/bi-designer'
|
||||
|
||||
// ... 任意可拿到 componentInstance 处
|
||||
return (
|
||||
<ComponentLoader id={componentInstance.id} />
|
||||
)
|
||||
```
|
||||
|
||||
那么全屏就是将组件渲染到一个新容器内,非常 easy。
|
||||
|
||||
## 局部配置覆盖
|
||||
|
||||
可以通过 `DesignerProvider` 实现干涉其子元素 `useDesigner` 获取信息的能力:
|
||||
|
||||
```jsx
|
||||
import { DesignerProvider, ComponentLoader } from '@alife/bi-designer';
|
||||
|
||||
// 某个组件内,或者某个 UI 内以 render 模式加载组件
|
||||
// ...
|
||||
return (
|
||||
<DesignerProvider mode="render">
|
||||
<ComponentLoader id={id} />
|
||||
</DesignerProvider>
|
||||
)
|
||||
```
|
||||
|
||||
举个例子,比如在编辑模式下要全屏预览组件,可以通过 `ComponentLoader + id` 把某个画布组件实例渲染到弹出的 Modal 中,但问题是当前属于编辑模式,组件还可以被拖拽甚至响应编辑效果,我们只想让局部变成渲染状态,怎么做呢?
|
||||
|
||||
答案就是通过 `DesignerProvider` 包裹这个 Modal,这个 Modal 内部无论是组件还是其他 Panel 代码通过 `const { mode } = useDesigner(modeSelector)` 拿到的值都会被强制覆盖为 `render`。
|
||||
|
||||
## 配置国际化
|
||||
|
||||
国际化信息在 `pageSchema.i18n` 定义:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
|
||||
const App = () => (
|
||||
<Designer
|
||||
defaultPageSchema={{
|
||||
i18n: {
|
||||
"zh-CN": {
|
||||
你好: "你好",
|
||||
中国: "中国"
|
||||
},
|
||||
"en-US": {
|
||||
你好: "Hello",
|
||||
中国: "China"
|
||||
}
|
||||
}
|
||||
}}
|
||||
defaultLocaleKey="zh-CN"
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `defaultLocaleKey`: 默认国际化语言,可以通过 `{ setLocaleKey } = useDesigner()` 动态改变。
|
||||
|
||||
这样在 DSL 中通过描述 `JSExpression` 表达式的 `this.i18n` 访问:
|
||||
|
||||
```json
|
||||
{
|
||||
"componentInstances": {
|
||||
"1": {
|
||||
"id": "1",
|
||||
"componentName": "button",
|
||||
"props": {
|
||||
"text": {
|
||||
"type": "JSExpression",
|
||||
"value": "this.i18n['你好']"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 容器拓展组件 props
|
||||
|
||||
`componentMeta.container` 可以定义组件外层容器,但有的时候我们想在容器做一点事情,比如获取宽高,以 props 的方式传递给子组件。
|
||||
|
||||
因为子组件以 `children` 的方式书写不易拓展,因此提供了 `PropsProvider` 来拓展子组件拿到的 props:
|
||||
|
||||
```jsx
|
||||
import { Interfaces, PropsProvider } from '@alife/bi-designer'
|
||||
|
||||
const ComponentContainer = ({ children }) => {
|
||||
return (
|
||||
// 注入 width 和 height
|
||||
<PropsProvider width={100} height={100}>
|
||||
{children}
|
||||
</PropsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const Element = ({ width, height }) => {
|
||||
// width=100
|
||||
// height=100
|
||||
}
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
element: Element,
|
||||
container: ComponentContainer
|
||||
};
|
||||
```
|
||||
|
||||
上面的例子中,因为 `container` 注入了 `width`,因此组件可以通过 `props.width` 拿到容器注入的值。
|
||||
|
||||
## 撤销重做
|
||||
|
||||
撤销重做按钮在基于每个搭建系统都有,在 bi-designer 的使用方式是这样:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer'
|
||||
|
||||
export default () => {
|
||||
const { undo, redo } = useDesigner()
|
||||
|
||||
// 撤销调用 undo()
|
||||
// 重做调用 redo()
|
||||
}
|
||||
```
|
||||
|
||||
是不是觉得很简单?是的,因为所有值得撤销重做的操作在引擎内部使用了 `HistoryManager` 管理,因此引擎知道每一个可以被撤销或者重做的操作,直接调用函数即可。
|
||||
|
||||
## 组件复制
|
||||
|
||||
执行 `copyComponent` 命令即可复制组件,比如:
|
||||
|
||||
```jsx
|
||||
const App() {
|
||||
const { copyComponent } = useDesigner()
|
||||
|
||||
// 复制组件 copyComponent(componentInstance)
|
||||
}
|
||||
```
|
||||
|
||||
`copyComponent` 的参数分别为:
|
||||
|
||||
```jsx
|
||||
function copyComponent(
|
||||
componentInstance?: ComponentInstance,
|
||||
parentId?: string,
|
||||
index?: number
|
||||
)
|
||||
```
|
||||
|
||||
- 如不指定 `parentId` ,默认复制到自己父元素下。
|
||||
- 如不指定 `index` ,默认复制到当前元素下方。
|
||||
|
||||
## 组件模版
|
||||
|
||||
如果觉得某些组件配置可能被复用,可以在画布组件右上角增加一个 “添加到组件模版” 按钮,bi-designer 也提供了生成、添加组件模版的方法。
|
||||
|
||||
### 创建组件模版
|
||||
|
||||
利用 `createCombine` 函数从画布中已有组件创建出组件模版,也可以将其生成结果持久化,作为一个固定的组件模版:
|
||||
|
||||
```jsx
|
||||
const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => {
|
||||
const { createCombine } = useDesigner();
|
||||
|
||||
const setToCombine = React.useCallback(() => {
|
||||
// 创建组件模版
|
||||
const combine = createCombine(componentInstance.id)
|
||||
}, [createCombine]);
|
||||
}
|
||||
```
|
||||
|
||||
`createCombine` 的参数就是画布中组件的 `id`。
|
||||
|
||||
### 添加组件模版到画布
|
||||
|
||||
利用 `addCombine` 函数将组件模版添加到画布,第一个参数就是上面生成的 `combine` 对象:
|
||||
|
||||
```jsx
|
||||
const App = () => {
|
||||
const { addCombine } = useDesigner();
|
||||
|
||||
const addComponent = React.useCallback(() => {
|
||||
// 创建组件模版
|
||||
const combine = addCombine(combine, parentId)
|
||||
}, [addCombine]);
|
||||
}
|
||||
```
|
||||
|
||||
## 渲染完成标识
|
||||
|
||||
当画布中所有组件都完成渲染了,可能要做一些监控上报,或者告诉截图软件可以截图了,bi-designer 提供了这种回调时机 `onRendered`:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
|
||||
const App = () => (
|
||||
<Designer
|
||||
onRendered={errors => {
|
||||
errors.map(each => {
|
||||
// 错误组件 id
|
||||
console.log(each.id)
|
||||
|
||||
// 错误信息
|
||||
console.log(each.error)
|
||||
})
|
||||
// 渲染完毕
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `errors`: 如果有组件代码报错,引擎会吞掉这个错误保证其他组件正常渲染,并把错误组件的 id 和错误信息返回到这里。
|
||||
|
||||
## 自定义数据流
|
||||
|
||||
如果 `useDesigner` 提供的数据流无法满足业务需要,可以通过进行自定义拓展。
|
||||
|
||||
### 1. 拓展字段
|
||||
|
||||
举个例子,我们需要新增一个 `edges` 字段描述当前画布中有哪些 “边节点”:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer';
|
||||
const App = ({ defaultPageSchema }) => (
|
||||
<Designer defaultPageSchema={{
|
||||
...defaultPageSchema,
|
||||
edges: []
|
||||
}} />
|
||||
)
|
||||
```
|
||||
|
||||
可以看到,只要任意拓展 `pageSchema` 即可。
|
||||
|
||||
### 2. 通过 useDesigner 拿到拓展字段
|
||||
|
||||
首先定义一个 `edgesSelector` :
|
||||
|
||||
```jsx
|
||||
import { DesignerState } from '@alife/bi-designer';
|
||||
export const edgesSelector = () => (state: DesignerState) => {
|
||||
return {
|
||||
// 从 pageSchema.edges 读取 edges
|
||||
edges: state.pageSchema?.edges as Edge[],
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
在需要读取的地方结合 `useDesigner` :
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer';
|
||||
import { edgesSelector } from './selector'
|
||||
const Panel = () => {
|
||||
// 自带类型
|
||||
const { edges } = useDesigner(edgesSelector())
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 通过 useDesigner 修改拓展字段
|
||||
|
||||
通过 `setPageSchema` 更新拓展字段:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer';
|
||||
const Panel = () => {
|
||||
const { setPageSchema } = useDesigner()
|
||||
|
||||
const handleChangeEdges = React.useCallback(newEdges => {
|
||||
setPageSchema(pageSchema => ({
|
||||
...pageSchema,
|
||||
newEdges
|
||||
}))
|
||||
}, [setPageSchema])
|
||||
}
|
||||
```
|
||||
|
||||
总结一下,这个拓展字段由业务定义,透过 `useDesigner` 读与改,使业务数据管理方式更聚合。
|
||||
|
||||
## 存储临时非结构化数据
|
||||
|
||||
对于非结构化数据比如组件 `ref` 是不能存储到数据流的,既不能使用 `setPageSchema`,也不能调用 `updateComponentId` 存储到 `componentInstance` 中。
|
||||
|
||||
此时可以利用 `temporary` 进行临时数据存取,要注意非结构化数据是无法监听变化的,引用永远保持不变:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer';
|
||||
const App = () => (
|
||||
const { temporary } = useDesigner()
|
||||
// 写
|
||||
temporary.set('component1', ref)
|
||||
// 读
|
||||
console.log(temporary.get('component1'))
|
||||
)
|
||||
```
|
||||
|
||||
temporary 本质是个 Map,所以拥有 Map 类型所有语法。
|
||||
|
||||
## 拦截画布操作
|
||||
|
||||
如果你限制某个低配版本只能在画布使用最多 50 个组件,我们需要阻止画布超过 50 个组件的添加,这个场景可以通过 `DesignerProps` 生命周期可以对画布操作进行拦截。
|
||||
|
||||
`shouldAddComponents()` 返回 `false` 可以阻止画布添加组件:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
const App = () => (
|
||||
<Designer
|
||||
shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => {
|
||||
// 阻止添加
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `addedComponentInstancesArray` :添加的组件, `ComponentInstance[]` 类型。
|
||||
|
||||
`shouldMoveComponents()` 返回 `false` 可以阻止画布移动组件:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
const App = () => (
|
||||
<Designer
|
||||
shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => {
|
||||
// 阻止移动
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `movedComponentInstancesArray` :移动的组件,`ComponentInstance[]` 类型。
|
||||
- `taragetComponentInstance` :要移动到的父组件实例信息, `ComponentInstance` 类型。
|
||||
|
||||
`shouldDeleteComponents()` 返回 `false` 可以阻止画布删除组件:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
const App = () => (
|
||||
<Designer
|
||||
shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => {
|
||||
// 阻止删除
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `deletedComponentInstancesArray` :删除的组件, `ComponentInstance[]` 类型。
|
||||
|
||||
## 仅刷新可视区域组件
|
||||
|
||||
默认组件都会以按需加载的方式渲染,即对于不在可视区域的组件,不会触发任何重渲染,以此提升交互操作的效率,以及首屏速度。
|
||||
|
||||
对于筛选条件等可能影响到其他组件的组件,可以通过 `ComponentMeta.keepActive` 强制保持激活状态:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from '@alife/bi-designer'
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
keepActive: true
|
||||
}
|
||||
```
|
||||
|
||||
- `keepActive`:组件始终保持激活状态,即不出现在可视区域也会被渲染与响应刷新,默认关闭。
|
||||
|
||||
对于特殊场景比如截图,可能要求所有组件强制为 `active` 状态,可以通过 `forceActive` 函数实现:
|
||||
|
||||
```jsx
|
||||
import { Interfaces, useDesigner } from '@alife/bi-designer'
|
||||
const Test: Interfaces.ComponentElement = () => {
|
||||
const { forceActive, cancelForceActive } = useDesigner()
|
||||
|
||||
// forceActive() 强制所有组件 active
|
||||
// cancelForceActive() 取消强制 active,组件根据实际情况 active
|
||||
};
|
||||
```
|
||||
|
||||
可以通过 `getSnapshot().actives` 获取任意组件当前瞬时 `active` 状态:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer'
|
||||
const Test = () => {
|
||||
const { getSnapshot, id } = useDesigner()
|
||||
|
||||
// 当前组件激活状态
|
||||
const active = getSnapshot().actives[id]
|
||||
};
|
||||
```
|
||||
|
||||
## 上下文数据对象
|
||||
|
||||
组件 DSL 描述中,表达式类型(`JSExpression`)可以通过 `this.` 访问到上下文数据对象。上下文数据对象符合如下规则:
|
||||
|
||||
- 任何组件都通过配置 `ComponentMeta.stateful` 持有上下文。
|
||||
- 画布根节点 `root` 一定是 `stateful` 的。
|
||||
- `JSFunction` 与 `JSExpression` 都可通过 `this.state` 访问上下文, `this.setState` 修改上下文。
|
||||
|
||||
举例子:
|
||||
|
||||
```jsx
|
||||
// 初始化 pageSchema
|
||||
const defaultPageSchema: Interfaces.PageSchema = {
|
||||
componentInstances: {
|
||||
test1: {
|
||||
id: 'test1',
|
||||
componentName: 'test',
|
||||
parentId: 'jtw4x8ns',
|
||||
index: 0,
|
||||
props: {
|
||||
variable: {
|
||||
type: 'JSExpression',
|
||||
value: 'this.state.variable + "%"',
|
||||
},
|
||||
onClick: {
|
||||
type: 'JSFunction',
|
||||
value: 'function onClick() { this.setState({ variable: 5 }) }',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
这个例子中,组件调用 `this.props.onClick` 会修改上下文 `a=5` ,触发后,其 `this.props.variable` 拿到的值会变为 5% 。
|
||||
|
||||
任何组件或容器只要设置了 `stateful` 就可以持有状态:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from '@alife/bi-designer'
|
||||
const statefulComponentMeta: Interfaces.ComponentMeta = {
|
||||
stateful: true
|
||||
}
|
||||
```
|
||||
|
||||
被有状态的容器包裹的组件 `this.state` 与 `this.setState` 都局限在当前状态容器内,也就是当前状态容器内组件的 state 是互通的,且一个有状态容器与外部环境是隔离的,可以独立运行。
|
||||
|
||||
## 工具类拓展
|
||||
|
||||
工具类拓展可以通过上下文访问,如下是拓展方式:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from '@alife/bi-designer'
|
||||
// DSL 中增加 utils 描述
|
||||
const defaultPageSchema: Interfaces.PageSchema = {
|
||||
utils: [
|
||||
{
|
||||
name: 'format',
|
||||
type: 'function',
|
||||
content: `function format(str){ return str + '%' }`,
|
||||
},
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
- `name` :工具函数名。
|
||||
- `type` :类型,包括 `npm` 、 `umd` 、 `function` 。
|
||||
- `content` :内容。
|
||||
|
||||
用法:
|
||||
|
||||
```jsx
|
||||
JSFunction 与 JSExpression 都可以通过 this.utils 访问工具类拓展函数,比如
|
||||
// DSL 中增加 Expression 描述
|
||||
const defaultPageSchema: Interfaces.PageSchema = {
|
||||
componentInstances: {
|
||||
test: {
|
||||
id: 'tg43g42f',
|
||||
componentName: 'expressionComponent',
|
||||
index: 0,
|
||||
props: {
|
||||
variable: {
|
||||
type: 'JSExpression',
|
||||
value: 'this.utils.format("100")',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
上面的例子中,组件拿到的 `props.variable` 值为 100% 。
|
||||
|
||||
## 总结
|
||||
|
||||
如果你认真看完了全文,就会发现,bi-designer 是一个集成了数据流的开发框架,而不仅是一个渲染引擎,但却可以和你现有的业务代码友好相处,没有入侵性。
|
||||
|
||||
像渲染完成标识、按需渲染、组件加载器、局部配置覆盖等功能是强依赖渲染引擎存在的,因此较难在剥离渲染引擎的条件下转换为代码,因为做 BI 分析工具毕竟不是做研发提效用,业务上没有出码的必要,因此我们会做许多依赖渲染引擎的能力增强。
|
||||
|
||||
更多数据分析特性的功能将在下一个话题 API 之组件说明。
|
||||
|
||||
> 讨论地址是:[精读《数据搭建引擎 bi-designer API-设计器》· Issue #267 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/267)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
筛选条件是 BI 搭建的核心概念,我们大部分所说的探索式分析、图表联动也都属于筛选条件的范畴,**其本质就是一个组件对另一个组件的数据查询起到筛选作用**。
|
||||
|
||||
## 筛选组件是如何作用的
|
||||
|
||||
我们最常见的筛选条件就是表单场景的查询控件,如下图所示:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB107njjRFR4u4jSZFPXXanzFXa-724-302.png">
|
||||
|
||||
若干 “具有输出能力” 的组件作为筛选组件,点击查询按钮时触发其作用组件重新取数。
|
||||
|
||||
注意这里 “具有输出能力” 的组件不仅是输入框等具有输入性质的组件,其实所有具备交互能力的组件都可以,甚至可以由普通组件承担筛选触发的能力:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1V.sVgSslXu8jSZFuXXXg7FXa-858-196.png">
|
||||
|
||||
一个表格的表头点击也可以触发筛选行为,或者柱状图的一个柱子被点击都可以,只要进行到这层抽象,**组件间联动本质也属于筛选行为**。
|
||||
|
||||
同样重要的,筛选作用的组件也可以是具备输入能力的组件:
|
||||
|
||||
<img width=450 src="https://img.alicdn.com/tfs/TB1qqrxUpT7gK0jSZFpXXaTkpXa-1280-198.png">
|
||||
|
||||
当目标组件是具备筛选能力组件时,这就是筛选联动场景了,所以 **筛选联动也属于普通筛选行为**。至于目标组件触发取数后,是否立即修改其筛选值,进而触发后续的筛选联动,就完全由业务特性决定了。
|
||||
|
||||
一个组件也可以自己联动自己筛选,比如折线图点击下钻的场景,就是自己触发了筛选,作用到自己的例子。
|
||||
|
||||
## 什么是筛选组件
|
||||
|
||||
**任何组件都可以是筛选组件**。
|
||||
|
||||
可能最容易理解的是输入框、下拉框、日期选择器等具备输入特征的组件,这些组件只能说天然适合作为筛选组件,但不代表系统设计要为这些组件特殊处理。
|
||||
|
||||
扩大想一想,其实普通的按钮、表格、折线图等等 **具有展示属性的组件也具有输入特性的一面**,比如按钮被点击时触发查询、单元格被点击时想查询当前城市的数据趋势、折线图某条线被点击时希望自身从年下钻到月等等。
|
||||
|
||||
所以 **不存在筛选组件这概念,而是任何组件都具有筛选的能力**,因此筛选是一种任何组件都具有的能力,而不局限在某几个组件上,一旦这么设计,可以做到以下几点:
|
||||
|
||||
1. 实现输入类组件到展示类组件的筛选,符合基本筛选诉求。
|
||||
2. 实现展示类组件到展示类组件的筛选,属于图表联动图表的高级功能。
|
||||
3. 实现输入类组件到输入类组件的筛选,属于筛选联动功能。
|
||||
4. 实现组件自身到自身的筛选,实现下钻功能。
|
||||
|
||||
下面介绍 bi-designer 的筛选条件设计。
|
||||
|
||||
## 筛选条件设计
|
||||
|
||||
基于上述分析,bi-designer 在组件元信息中没有增加所谓的筛选组件类型,而是将其设定为一种筛选能力,任何组件都能触发。
|
||||
|
||||
### 如何触发筛选
|
||||
|
||||
组件调用 `onFilterChange` 即可完成筛选动作:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from "@alife/bi-designer";
|
||||
|
||||
const InputFilter = () => {
|
||||
const { onFilterChange } = useDesigner();
|
||||
|
||||
return (
|
||||
<input onChange={(event) => () => onFilterChange(event.target.value)} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
但这种开发方式违背了 **低侵入** 的设计理念,我们可以采用组件与引擎解构的方式,让输入框变更的时候直接调用 `props.onChange` ,这个组件保持了最大的独立性:
|
||||
|
||||
```jsx
|
||||
const InputFilter = ({ onChange }) => {
|
||||
return <input onChange={(event) => () => onChange(event.target.value)} />;
|
||||
};
|
||||
```
|
||||
|
||||
那渲染引擎怎么将 `onFilterChange` 映射到 `props.onChange` 呢?如下配置 DSL 即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"props": {
|
||||
"onChange": {
|
||||
"type": "JSExpression",
|
||||
"value": "this.onFilterChange"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 筛选影响哪些组件
|
||||
|
||||
一般筛选组件会选择作用于的目标组件,类似下图:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1RJHPUxD1gK0jSZFsXXbldVXa-768-486.png">
|
||||
|
||||
这些信息会存储在筛选组件的组件配置中,即 `componentInstance.props`,筛选目标组件在 `componentMeta.eventConfigs` 组件元信息的事件中配置:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
eventConfigs: ({ componentInstance }) =>
|
||||
componentInstance.props.targets?.map((target) => ({
|
||||
// 筛选取数
|
||||
type: "filterFetch",
|
||||
// 触发组件
|
||||
source: componentInstance.id,
|
||||
// 作用组件
|
||||
target: target.id,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
如上所示,假设作用于组件存储在 `props.targets` 字段中,我们将其 `map` 一下都设置为 `filterFetch` 类型,表示筛选作用,`source` 触发源是自己,`target` 目标组件是存储的 `target.id`。
|
||||
|
||||
这样当 `source` 组件调用了 `onFilterChange`,`target` 组件就会触发取数,并在取数参数中拿到作用于其的筛选组件信息与筛选值。
|
||||
|
||||
### 组件如何感知筛选条件
|
||||
|
||||
组件取数是结合了筛选条件一起的,只要如上设置了 `filterFetch`,渲染引擎会自动在计算取数参数的回调函数 `getFetchParam` 中添加 `filters` 代表筛选组件信息,组件可以结合自身 `componentInstance` 与 `filters` 推导出最终取数参数:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1tDDTUuH2gK0jSZJnXXaT1FXa-870-434.png">
|
||||
|
||||
最终,组件元信息只要写一个 `getFetchParam` 回调函数即可,**可以自动拿到作用于它的筛选组件,而不用关心是哪些配置导致了关联,只要响应式的去处理筛选作用即可**。
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
// 组装取数参数
|
||||
getFetchParam: ({ componentInstance, filters }) => {
|
||||
// 结合 componentInstance 与 filters.map... 返回取数参数
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 筛选组件间联动带来的频繁取数问题
|
||||
|
||||
对于筛选联动的复杂场景,会遇到频繁取数的问题。
|
||||
|
||||
假设国家、省、市三级联动筛选条件同时 `filterFetch` 作用于一个表格,这个表格取数的筛选条件需要同时包含国家、省、市三个参数,但我们又设置了 国家、省、市 这三个筛选组件之间的 `filterFetch` 作为筛选联动,那么国家切换后、省改变、联动市改变,这个过程筛选值会变化三次,但我们只想表格组件取数函数仅执行最后的一次,怎么办呢?
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1CFD9UBr0gK0jSZFnXXbRRXXa-984-656.png">
|
||||
|
||||
如上图所示,其实每个筛选条件在渲染引擎数据流中还存储了一个 `ready` 状态,表示筛选条件是否就绪,**一个组件关联的筛选条件只要有一个 `ready` 不为 `true`,组件就不会触发取数**。
|
||||
|
||||
因此我们需要在筛选变化的过程中,总是保证一个筛选组件的 `ready` 为 `false`,等筛选间联动完毕了,所有筛选器的 `ready` 为 `true`,组件才会取数,我们可以使用 `filterReady` 筛选依赖配置:
|
||||
|
||||
```jsx
|
||||
import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
eventConfigs: ({ componentInstance }) =>
|
||||
componentInstance.props.targets?.map((target) => ({
|
||||
// 筛选就绪依赖
|
||||
type: "filterReady",
|
||||
// 触发组件
|
||||
source: componentInstance.id,
|
||||
// 作用组件
|
||||
target: target.id,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
这样配置后,当 `source` 组件触发 `onFilterChange` 后,`target` 组件的筛选 `ready` 会立即设置为 `false`,只有 `target` 组件取完数后主动触发 `onFilterChange` 才会将自己的 `ready` 重新置为 `true`。**That'a all,其他流程没有任何感知**。
|
||||
|
||||
## 若干筛选组件聚合成一个查询控件
|
||||
|
||||
除了联动外,也会存在防止频繁查询的诉求,希望将多个筛选条件绑定成一个大筛选组件,在点击 “查询” 按钮时再取数:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1nmHVUuH2gK0jSZJnXXaT1FXa-972-174.png">
|
||||
|
||||
可以利用 **筛选作用域** 轻松实现此功能,只需要两步:
|
||||
|
||||
### 筛选组件设置独立筛选作用域
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
// 通过 componentInstance 判断,如果是全局筛选器内部,则设置 filterScope
|
||||
filterScope: ({ componentInstance }) => ["my-custom-scope-name"],
|
||||
};
|
||||
```
|
||||
|
||||
这样,这批筛选组件就与其作用的组件属于不同的 **筛选作用域** 了,所以筛选不会对其立即生效,功能实现了一半。
|
||||
|
||||
### 确认按钮点击时调用 `submitFilterScope`
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer'
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
const { submitFilterScope } = useDesigner()
|
||||
// 点击确认按钮时,调用 submitFilterScope('my-custom-scope-name')
|
||||
};
|
||||
```
|
||||
|
||||
你可以在点击查询按钮后调用 `submitFilterScope` 并传入对应作用域名称,这样作用域内筛选组件就会立即对其 `target` 组件生效了。
|
||||
|
||||
至于确认按钮、UI 上的聚合,这些你可以写一个自定义组件去做,利用 `ComponentLoader` 把筛选组件聚合到一起加载,总之功能与 UI 是解耦的。
|
||||
|
||||
如果你对原理感兴趣,可以再多看一下这张图:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1eZPJhAcx_u4jSZFlXXXnUFXa-1082-645.png">
|
||||
|
||||
### 突破筛选作用域
|
||||
|
||||
然而实际场景中,可能存在更复杂的组合,见下面的例子:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1cfGNiIVl614jSZKPXXaGjpXa-966-600.png">
|
||||
|
||||
筛选器 1 同时对 筛选器 2、表格 产生筛选作用 `filterFetch`,但对 表格 的作用希望通过查询按钮拦截住,而对 筛选器 2 的作用希望能立即生效,对于这个例子有两种方式解决:
|
||||
|
||||
最简单的方式就是将 筛选器 1、筛选器 2 设置为相同作用域 `group1`,这样就通过作用域分割自然实现了效果,**而且这本质上是两个筛选器 UI 不在一起,但筛选作用域相同的例子**:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1_kn1UpT7gK0jSZFpXXaTkpXa-1056-660.png">
|
||||
|
||||
但是再变化一下,如果筛选器 2 也对表格产生筛选作用,那我们将 筛选器 1、筛选器 2 放入同一个 `group1` 等于对表格的查询都会受到 “查询” 按钮的控制,但 **我们又希望筛选器 2 可以立即作用于表格**:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1ftP3Urr1gK0jSZFDXXb9yVXa-968-602.png">
|
||||
|
||||
如图所示,我们只能将 筛选器 1 的筛选作用域设置为 `group1`,这样 筛选器 2 与 表格 属于同一个筛选作用域,他们之间筛选会立即生效,我们只要解决 筛选器 1 不能立即作用于 筛选器 2 的问题即可,可以通过 `ignoreFilterScope` 方式突破筛选作用域:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
eventConfigs: ({ componentInstance }) =>
|
||||
componentInstance.props.targets?.map((target) => ({
|
||||
// 筛选取数
|
||||
type: "filterFetch",
|
||||
// 触发组件
|
||||
source: componentInstance.id,
|
||||
// 作用组件
|
||||
target: target.id,
|
||||
// 突破筛选作用域
|
||||
ignoreFilterFetch: true,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
我们只要在 `source: 筛选器1` `target: 筛选器2` 的 `filterFetch` 配置中,将 `ignoreFilterFetch` 设置为 `true`,这个 `filterFetch` 就会忽略筛选作用域,实现立即 筛选器 1 立即作用到 筛选器 2 的效果。
|
||||
|
||||
## 总结
|
||||
|
||||
你还有哪些特殊的筛选诉求?可以用这套筛选设计解决吗?
|
||||
|
||||
> 讨论地址是:[精读《BI 搭建 - 筛选条件》· Issue #270 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/270)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,115 @@
|
||||
# Abstract Factory(抽象工厂)
|
||||
|
||||
Abstract Factory(抽象工厂)属于创建型模式,工厂类模式抽象程度从低到高分为:简单工厂模式 -> 工厂模式 -> 抽象工厂模式。
|
||||
|
||||
**意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。**
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 汽车工厂
|
||||
|
||||
我们都知道汽车有很多零部件,随着工业革命带来的分工,很多零件都可以被轻松替换。但实际生活中我们消费者不愿意这样,我们希望买来的宝马车所包含的零部件都是同一系列的,以保证最大的匹配度,从而带来更好的性能与舒适度。
|
||||
|
||||
所以消费者不愿意到轮胎工厂、方向盘工厂、车窗工厂去一个个采购,而是将需求提给了宝马工厂这家抽象工厂,由这家工厂负责组装。那你是这家工厂的老板,已知汽车的组成部件是固定的,只是不同配件有不同的型号,分别来自不同的制造厂商,你需要推出几款不同组合的车型来满足不同价位的消费者,你会怎么设计?
|
||||
|
||||
### 迷宫游戏
|
||||
|
||||
你做一款迷宫游戏,已知元素有房间、门、墙,他们之间的组合关系是固定的,你通过一套算法生成随机迷宫,这套算法调用房间、门、墙的工厂生成对应的实例。但随着新资料片的放出,你需要生成具有新功能的房间(可以回复体力)、新功能的门(需要魔法钥匙才能打开)、新功能的墙(可以被炸弹破坏),但修改已有的迷宫生成算法违背了开闭原则(需要在已有对象进行修改),如果你希望生成迷宫的算法完全不感知新材料的存在,你会怎么设计?
|
||||
|
||||
### 事件联动
|
||||
|
||||
假设我们做一个前端搭建引擎,现在希望做一套关联机制,以实现点击表格组件单元格,可以弹出一个模态框,内部展示一个折线图。已知业务方存在定制表格组件、模态框组件、折线图组件的需求,但组件之间联动关系是确定的,你会怎么设计?
|
||||
|
||||
## 意图解释
|
||||
|
||||
在汽车工厂的例子中,我们已知车子的构成部件,**为了组装成一辆车子,需要以一定方式拼装部件,而具体用什么部件是需要可拓展的**。
|
||||
|
||||
在迷宫游戏的例子中,我们已知迷宫的组成部分是房间、门、墙,**为了生成一个迷宫,需要以某种算法生成许多房间、门、墙的实例,而具体用哪种房间、哪种门、哪种墙是这个算法不关心的,是需要可被拓展的**。
|
||||
|
||||
在事件联动的例子中,我们已知这个表格弹出趋势图的交互场景基本组成元素是表格组件、模态框组件、折线图组件,**需要以某种联动机制让这三者间产生联动关系,而具体是什么表格、什么模态框组件、什么折线图组件是这个事件联动所不关心的,是需要可以被拓展的**,表格可以被替换为任意业务方注册的表格,只要满足点击 `onClick` 机制就可以。
|
||||
|
||||
> **意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。**
|
||||
|
||||
这三个例子不正是符合上面的意图吗?我们要设计的抽象工厂就是要 **创建一系列相关或相互依赖的对象**,在上面的例子中分别是汽车的组成配件、迷宫游戏的素材、事件联动的组件。**而无须指定它们具体的类**,也就说明了我们不关心车子方向盘用的是什么牌子,迷宫的房间是不是普通房间,联动机制的折线图是不是用 `Echarts` 画的,我们只要描述好他们之间的关系即可,**这带来的好处是,未来我们拓展新的方向盘、新的房间、新的折线图时,不需要修改抽象工厂。**
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB1k8DVVkT2gK0jSZFkXXcIQFXa-1472-658.png">
|
||||
|
||||
`AbstractFactory` 就是我们要的抽象工厂,描述了创建产品的抽象关系,比如描述迷宫如何生成,表格和趋势图怎么联动。
|
||||
|
||||
至于具体用什么方向盘、用什么房间,是由 `ConcreteFactory` 实现的,所以我们可能有多个 `ConcreteFactory`,比如 `ConcreteFactory1` 实例化的墙壁是普通墙壁,`ConcreteFactory2` 实例化的墙壁是魔法墙壁,但其对 `AbstractFactory` 的接口是一致的,所以 `AbstractFactory` 不需要关心具体调用的是哪一个工厂。
|
||||
|
||||
`AbstractProduct` 是产品抽象类,描述了比如方向盘、墙壁、折线图的创建方法,而 `ConcreteProduct` 是具体实现产品的方法,比如 `ConcreteProduct1` 创建的表格是用 `canvas` 画的,折线图是用 `G2` 画的,而 `ConcreteProduct2` 创建的表格是用 `div` 画的,折线图是用 `Echarts` 画的。
|
||||
|
||||
这样,当我们要拓展一个用 `Rcharts` 画的折线图,用 `svg` 画的表格,用 `div` 画的模态框组成的事件机制时,只需要再创建一个 `ConcreteFactory3` 做相应的实现即可,再将这个 `ConcreteFactory3` 传递给 `AbstractFactory`,并不需要修改 `AbstractFactory` 方法本身。
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 javascript 编写。
|
||||
|
||||
```typescript
|
||||
class AbstractFactory {
|
||||
createProducts(concreteFactory: ConcreteFactory) {
|
||||
const productA = concreteFactory.createProductA();
|
||||
const productB = concreteFactory.createProductB();
|
||||
|
||||
// 建立 A 与 B 固定的关联,即便 A 与 B 实现换成任意实现都不受影响
|
||||
productA.bind(productB);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`productA.bind(productB)` 是一种抽象表示:
|
||||
|
||||
- 对于汽车工厂的例子,表示组装汽车的过程。
|
||||
- 对于迷宫游戏的例子,表示生成迷宫的过程。
|
||||
- 对于事件联动的例子,表示创建组件间关联的过程。
|
||||
|
||||
假设我们的迷宫有两套素材,分别是普通素材与魔法素材,只要在分别创建普通素材工厂 `ConcreteFactoryA`,与魔法素材工厂 `ConcreteFactoryB`,调用 `createProducts` 时传入的是普通素材,则产出的就是普通素材搭建的迷宫,传入的是魔法素材,则产出的就是用魔法素材搭建的迷宫。
|
||||
|
||||
当我们要创建一套新迷宫材料,比如熔岩迷宫,我们只要创建一套熔岩素材(熔岩房间、熔岩门、熔岩墙壁),再组装一个 `ConcreteFactoryC` 熔岩素材生成工厂传递给 `AbstractFactory.createProducts` 即可。
|
||||
|
||||
我们可以发现,使用抽象工厂模式,我们可以轻松拓展新的素材,比如拓展一套新的汽车配件,拓展一套新的迷宫素材,拓展一套新的事件联动组件,**这个过程只需要新建类即可,不需要修改任何类,符合开闭原则**。
|
||||
|
||||
## 弊端
|
||||
|
||||
任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。
|
||||
|
||||
还是上面的例子,如果我们的需求不是拓展一个新轮子、新墙壁、新折线图,而是:
|
||||
|
||||
- 汽车工厂要给汽车加一个新部件:自动驾驶系统。
|
||||
- 迷宫游戏要新增一个功能素材:陷阱。
|
||||
- 事件联动要新增一个联动对象:明细趋势统计表格。
|
||||
|
||||
你看,这种情况不是为已有元素新增一套实现,而是实现一些新元素,就会非常复杂,因为我们不仅要为所有 `ConcreteFactory` 新增每一个元素,还要修改抽象工厂,以将新元素与旧元素间建立联系,违背了开闭原则。
|
||||
|
||||
因此,对于已有元素固定的系统,适合使用抽象工厂,反之不然。
|
||||
|
||||
## 总结
|
||||
|
||||
抽象工厂对新增已有产品的实现适用,对新增一个产品种类不适用,可以参考结合了例子的下图加深理解:
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB1Fbn7Vlr0gK0jSZFnXXbRRXXa-1416-852.png">
|
||||
|
||||
拓展一个熔岩素材包是 **增加一种产品风格**,适合使用抽象工厂设计模式;拓展一个陷阱是 **增加一个产品种类**,不适合使用抽象工厂设计模式。为什么呢?看下图:
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB12fL8VeL2gK0jSZFmXXc7iXXa-1696-640.png">
|
||||
|
||||
创建迷宫这个抽象工厂做的事情,**是把已有的房间、门、墙壁建立关联**,因为操作的是抽象类,所以拓展一套具体实现(熔岩素材包)对这个抽象工厂没有感知,这样做很容易。
|
||||
|
||||
但如果新增一个产品种类 - 陷阱,可以看到,抽象工厂必须将陷阱与前三者重新建立关联,这就要修改抽象工厂,不符合开闭原则。同时,如果我们已有素材包 1 ~素材包 999,就需要同时增加 999 个对应的陷阱实现(普通陷阱、魔法陷阱、熔岩陷阱),其工作量会非常大。
|
||||
|
||||
因此,只有产品种类稳定时,需要频繁拓展产品风格时才适合用抽象工厂设计模式。
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Abstract Factory 抽象工厂》· Issue #271 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/271)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,147 @@
|
||||
# Builder(生成器)
|
||||
|
||||
Builder(生成器)属于创建型模式,针对的是单个复杂对象的创建。
|
||||
|
||||
**意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。**
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 搭乐高积木
|
||||
|
||||
乐高积木是很典型的随机拼装场景,你有很多乐高积木,要搭一个小房子都太复杂了,可能不得不看着说明书一步步操作,这就像创建一个复杂的对象,要传入非常多的参数,而且顺序还不能错。
|
||||
|
||||
如果不考虑拼装乐高过程中的乐趣,你只是想快速得到一个标准的房子,怎么样才可以最快最省事?
|
||||
|
||||
### 工厂流水线
|
||||
|
||||
制作一个罐头要经历许多步骤,而其中一些步骤比如制作罐头是通用的,可以用这个罐头装很多东西,比如红枣罐头、黄桃罐头,那工厂流水线是怎么做到灵活可拓展的呢?
|
||||
|
||||
### 创建数据库连接池
|
||||
|
||||
建立一个数据库连接池,我们需要传入数据库的地址、用户名与密码、还有要创建多少大小的连接池,缓存的位置等等。
|
||||
|
||||
考虑到数据库必须正确连接后才有效,创建时必须校验传入的数据库地址与密码的正确性,甚至存储方式与数据库类型还有关系,这是一个简单的 `new` 实例化可以解决的吗?
|
||||
|
||||
## 意图解释
|
||||
|
||||
在乐高积木的例子中,我们为了得到一个房子其实不需要关心每一个积木应该如何摆放,**我们只要交给组装工厂(一个人或者一个程序)产出标准房子就行了**,这其中参数可能是 `.setHouseType().build()` 设置房屋类型,而不需要 `new House(block1, block2, ... block999)` 传递这些没必要的参数。**其中组装工厂就是生成器**。
|
||||
|
||||
在工厂流水线的例子中,**流水线就是生成器,一个流水线可以不通过不同组合生成不同作用的工厂**,黄桃罐头的流水线可以理解为 `new Builder().组装罐头().放入黄桃().build()`,红枣罐头的流水线可以理解为 `new Builder().组装罐头().放入红枣().build()`,我们可以复用生成器最基础的函数 `组装罐头()` 将其用于创建不同的产品中,复用了组装基础能力。
|
||||
|
||||
在创建数据库例子中,我们可以先设置一些必要的参数再创建,比如 `new Builder().setUrl().setPassword().setType().build()`,这样在最终执行 `build` 函数的时候,可以对参数中存在关联的进行校验,而得到的对象也无法再被修改,这样比直接暴露数据库连接池对象,再一个值一个值 Set 多了如下好处:
|
||||
|
||||
1. 对象无法被修改,保护了程序稳定性,减少了维护复杂度。
|
||||
2. 可以对参数关联进行一次性校验。
|
||||
3. 在创建对象之前不会存在中间态,即创建了对象实例,但缺少部分参数,这可能导致对象无法正确 work。
|
||||
|
||||
**意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。**
|
||||
|
||||
我们再理解一次意图,所谓构建与表示分离,就是指一个对象 `Person` 并不是简单的 `new Person()` 就可以实例化出来的,如果可以,那就是构建与表示一体。**所谓构建与表示分离,就是指 `Person` 只能描述,而不能通过 `new Person()` 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 `Person` 实例。**
|
||||
|
||||
在乐高积木的例子中,通过乐高创建的房子并不是 `new House()` 出来,而是将构建与表示分离了,工厂流水线中我们创建一个黄桃罐头,不是通过 `new 黄桃罐头()`,而是通过流水线不同拼装方式来完成,在数据库例子中,我们没有通过 `new DB()` 的方式创建数据库,而是通过 Builder 来创建,这都体现了构建与表示的分离。
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB14lOwYXT7gK0jSZFpXXaTkpXa-1382-466.png">
|
||||
|
||||
- `Director` 指导器,用来指导构建过程。
|
||||
- `Builder` 生成器接口,用来提供一系列构建对象的方法,以及最终的 `build` 生成对象函数,这个函数里可以做一些参数校验。
|
||||
- `ConcreteBuilder` 是 `Builder` 的具体实现。
|
||||
|
||||
实际上,Builder 模式抽象层次可高可低,我们上面三个例子都没有用到指导器与生成器接口,这是因为在代码不太复杂的情况下,可以使用简化模型。
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 javascript 编写。
|
||||
|
||||
```typescript
|
||||
class Director {
|
||||
create(concreteBuilder: ConcreteBuilder) {
|
||||
// 创建了一些零件
|
||||
concreteBuilder.buildA();
|
||||
concreteBuilder.buildB();
|
||||
|
||||
// 校验参数已经生成实例
|
||||
return concreteBuilder.build();
|
||||
}
|
||||
}
|
||||
|
||||
class HouseBuilder {
|
||||
public buildA() {
|
||||
// 创建房屋
|
||||
// this.xxx = xxx
|
||||
}
|
||||
|
||||
public buildB() {
|
||||
// 刷油漆
|
||||
}
|
||||
|
||||
public build() {
|
||||
// 最终创建实例
|
||||
return new House(/* ..一堆参数 this.xxx.. */);
|
||||
}
|
||||
}
|
||||
|
||||
// 接下来是正式使用
|
||||
const director = new Director();
|
||||
const builder = HouseBuilder();
|
||||
const house = director.create(builder);
|
||||
```
|
||||
|
||||
上面的例子是完整版本的 Builder 模式,抽象了指导器 `Director` 与生成器 `Builder`,只要两者都严格按照接口实现,我们可以:
|
||||
|
||||
1. 替换任意 `Director`,使创建的过程做任意修改。
|
||||
2. 替换任意 `Builder`,使创建的实现做任意修改。
|
||||
|
||||
做了任意的改动,都可以得到不同的房子实现,这就是创建与表示分离的好处,我们可以通过同样的构建过程创建不同的表示。
|
||||
|
||||
这个 `director.create()`:
|
||||
|
||||
- 在搭乐高积木的例子,表示用乐高搭建房屋的过程。
|
||||
- 在工程流水线的例子,表示罐头的组装构成。
|
||||
- 在创建数据库连接池的例子,表示数据库连接池的创建过程。
|
||||
|
||||
而 `Builder` 以及其函数 `buildA` `buildB` 等方法表示具体制造方法,比如:
|
||||
|
||||
- 在搭乐高积木的例子,表示如何盖房子,如何刷油漆。
|
||||
- 在工程流水线的例子,表示如何做一个罐头,如何添加黄桃。
|
||||
- 在创建数据库连接池的例子,表示如何设置数据库地址,如何设置用户名密码等。
|
||||
|
||||
对于数据库的例子中,我们不仅可以保证创建对象的便捷性,因为不需要传入过多参数,也保证了对象的正确校验,同时生成的实例也是不可变的。
|
||||
|
||||
更重要的是,如果使用完整模式,我们可以替换 `Director` 来修改创建数据库的方式,替换 `Builder` 来修改具体方法,比如 `.setUserName` 这个函数不做具体实现,而是统计性能,`build()` 函数创建的不是一个数据库连接实例,而是一个测试实例。
|
||||
|
||||
再比如前端同一个方法在 JS 和 Node 环境下运行效果不一样,我们可以实现 `BrowserBuild` 与 `NodeBuild`,实现相同的接口,这样可以共享相同的创建过程,创建不同环境可以运行的实例。
|
||||
|
||||
可以看到,使用 Builder 模式可以保证创建对象的便捷与稳定性,还留了足够的拓展空间改变对象的创建过程与创建方法,具有极强的拓展性。
|
||||
|
||||
## 弊端
|
||||
|
||||
任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。
|
||||
|
||||
- 实例化对象非常繁琐,重复定义了许多对象成员变量的 `set` 方法,而且也不如 `new` 看的直观,也就是场景足够简单时,不需要任何地方都用 Builder 实例化对象。
|
||||
- 一个对象只有一种表示时,没必要做如此地步的抽象。
|
||||
|
||||
上面的例子都是相对复杂的,假设我们的搭房子的例子中,我们不是用乐高积木搭建,而是用两块半成品模板拼起来就得到一个房子,那就没有必要使用 Builder 模式,直接 `new House()` 即可。
|
||||
|
||||
再者,如果我们只需要生产各种罐头,而不需要生产汽车,那么就没必要过度抽象 Builder,把创建汽车的方法也囊括进去,最后,如果我们的对象只有一种表示时,没有必要抽象 Builder,也就是流水线如果只生产黄桃罐头,就没必要把各个生产环节变成可拆卸的,因为也没有重新组合的需要。
|
||||
|
||||
## 总结
|
||||
|
||||
Builder 模式对于创建一个复杂对象特别有用,可以看下图加深理解:
|
||||
|
||||
<img wdith=800 src="https://img.alicdn.com/tfs/TB109aLYoT1gK0jSZFrXXcNCXXa-1412-984.png">
|
||||
|
||||
最后总结一下何时适合用 Builder 模式:只有当创建过程允许被构造对象有不同表示,或者对象复杂到对象描述与创建对象过程值得分离时,才使用 Builder 设计模式。
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Builder 生成器》· Issue #273 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/273)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,115 @@
|
||||
# Factory Method(工厂方法)
|
||||
|
||||
Factory Method(工厂方法)属于创建型模式,利用工厂方法创建对象实例而不是直接用 New 关键字实例化。
|
||||
|
||||
理解如何写出工厂方法很简单,但理解为什么要用工厂方法就需要动动脑子了。工厂方法看似简单的将 New 替换为一个函数,其实是体现了面向接口编程的思路,它创建的对象其实是一个符合通用接口的通用对象,这个对象的具体实现可以随意替换,以达到通用性目的。
|
||||
|
||||
**意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。**
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 换灯泡
|
||||
|
||||
我自己在家换过灯泡,以前我家里灯坏掉的时候,我看着这个奇形怪状的灯管,心里想,这种灯泡和这个灯座应该是一体的,市场上估计很难买到适配我这个灯座的灯泡了。结果等我把灯泡拧下来,跑到门口的五金店去换的时候,店员随便给了我一个灯泡,我回去随便拧了一下居然就能用了。
|
||||
|
||||
我买这个灯泡的过程就用到了工厂模式,而正是得益于这种模式,让我可以方便在家门口就买到可以用的灯泡。
|
||||
|
||||
### 卡牌对战游戏
|
||||
|
||||
卡牌对战中,卡牌有一些基本属性,比如攻防、生命值,也符合一些通用约定,比如一回合出击一起等等,那么对于战斗系统来说,应该怎样实例化卡牌呢?如何批量操作卡牌,而不是通用功能也要拿到每个卡牌的实例才能调用?另外每个卡牌有特殊能力,这些特殊能力又应该如何拓展呢?
|
||||
|
||||
### 实现任意图形拖拽系统
|
||||
|
||||
一个可以被交互操作的图形,它可以用鼠标进行拉伸、旋转或者移动,不同图形实现这些操作可能并不相同,要存储的数据也不一样,这些数据应该独立于图形存储,我们的系统如果要对接任意多的图形,具备强大拓展能力,对象关系应该如何设计呢?
|
||||
|
||||
## 意图解释
|
||||
|
||||
在使用工厂方法之前,我们就要创建一个 **用于创建对象的接口**,这个接口具备通用性,**所以我们可以忽略不同的实现来做一些通用的事情**。
|
||||
|
||||
换灯泡的例子来说,我去门口五金店买灯泡,而不是拿到灯泡材料自己 New 一个出来,就是因为五金店这个 “工厂” 提供给我的灯泡符合国家接口标准,而我家里的灯座也符合这个标准,所以灯座不需要知道对接的灯泡是具体哪个实例,什么颜色,什么形状,这些都无所谓,只要灯泡符合国家标准接口,就可以对接上。
|
||||
|
||||
对卡牌对战的系统来说,**所有卡牌都应该实现同一种接口**,所以卡牌对战系统拿到的卡牌应该就是简单的 Card 类型,这种类型具备基本的卡片操作交互能力,系统就调用这些能力完成基本流程就好了,如果系统直接实例化具体的卡片,那不同的卡片类型会导致系统难以维护,卡片间操作也无法抽象化。
|
||||
|
||||
正式这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。
|
||||
|
||||
对图形拖拽系统来说,用到了 “连接平行的类层次” 这个特性,所谓连接平行的类层次,就是指一个图形,与其对应的操作类是一个平行抽象类,而一个具体的图形与具体的操作类则是另一个平行关系,系统只要关注最抽象的 “通用图形类” 与 “通用操作类” 即可,操作时,底层可能是某个具体的 “圆类” 与 “圆操作类” 结合使用,具体的类有不同的实现,但都符合同一种接口,因此操作系统才可以把它们一视同仁,统一操作。
|
||||
|
||||
**意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。**
|
||||
|
||||
所以接口是非常重要的,工厂方法第一句话就是 “定义一个用于创建对象的接口”,这个接口就是 `Creator`,让子类,也就是具体的创建类(`ConcreteCreator`)决定要实例化哪个类(`ConcreteProduct`)。
|
||||
|
||||
所谓使一个类的实例化延迟到其子类,是因为抽象类不知道要实例化哪个具体类,所以实例化动作只能由具体的子类去做,这样绕一圈的好处是,我们可以将任意多对象看作是同一类事物,做统一的处理,比如 **无论何种灯泡实例都满足通用的灯座接口**,**所有工厂实例化的卡牌都具备玩一局卡牌游戏的基本功能**,**任何图形与交互类都满足特定功能关系**,这种思想让生活和设计得到了大幅简化。
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB1VjyZmsVl614jSZKPXXaGjpXa-1434-476.png">
|
||||
|
||||
`Creator` 就是工厂方法,`ConcreteCreator` 是实现了 `Creator` 的具体工厂方法,每一个具体工厂方法生产一个具体的产品 `ConcreteProduct`,每个具体的产品都实现通用产品的特性 `Product`。
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 typescript 编写。
|
||||
|
||||
```typescript
|
||||
// 产品接口
|
||||
interface Product {
|
||||
save: () => void;
|
||||
}
|
||||
|
||||
// 工厂接口
|
||||
interface Creator {
|
||||
createProduct: () => Product;
|
||||
}
|
||||
|
||||
// 具体产品
|
||||
class ConcreteProduct implements Product {
|
||||
save = () => {};
|
||||
}
|
||||
|
||||
// 具体工厂
|
||||
class ConcreteCreator implements Creator {
|
||||
createProduct = () => {
|
||||
return new ConcreteProduct();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
创建一个 `Product` 的子类 `ConcreteCreator`,并返回一个实现了 `Product` 的具体实例 `ConcreteProduct`,这样我们就可以方便使用这个工厂了。
|
||||
|
||||
工厂方法并不是直接调用 `new ConcreteCreator().createProduct` 那么简单,这样体现不出任何抽象性,真正的场景是,在一个创建产品的流程中,我们只知道拿到的工厂是 `Creator`:
|
||||
|
||||
```typescript
|
||||
function main(anyCreator: Creator) {
|
||||
const product = anyCreator.createProduct()
|
||||
}
|
||||
```
|
||||
|
||||
在外面调用 `main` 函数时,实际传进去的是一个具体工厂,比如 `myCreator`,但关键是 `main` 函数不用关心到底是哪一个具体工厂,只要知道是个工厂就行了,具体对象创建过程交给了其子类。
|
||||
|
||||
**你也许也发现了,这就是抽象工厂中其中的一步,所以抽象工厂使用了工厂方法。**
|
||||
|
||||
## 弊端
|
||||
|
||||
工厂方法中,每创建一种具体的子类,就要写一个对应的 `ConcreteCreate`,这相对比较笨重,但有意思的是,如果将创建多个对象放到一个 `ConcreteCreate` 中,就变成了 **简单工厂模式**,新增产品要修改已有类不符合开闭模式,反而推荐写成本文说的这种模式。
|
||||
|
||||
彼之毒药吾之蜜糖,要知道没有一种设计模式解决所有问题,没有一种设计模式没有弊端,**而这个弊端不代表这个设计模式不好,一个弊端的出现可能是为了解决另一个痛点。** 要接受不完美的存在,这么多种设计模式就是对应了不同的业务场景,**为合适的场景选择一种能将优势发扬光大,以至于能掩盖弊端,就算进行了合理的架构设计**。
|
||||
|
||||
## 总结
|
||||
|
||||
工厂方法并不是简单把 New 的过程换成了函数,而是抽象出一套面向接口的设计模式:
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB1WKH.Zoz1gK0jSZLeXXb9kVXa-1480-786.png">
|
||||
|
||||
你看,我要做灯泡,可以直接做具体的灯泡,也可以定一个灯泡接口,通过灯泡工厂拿到具体灯泡,灯泡工厂对待所有灯泡的只做流程都是一样的,不管是中世纪风灯泡,还是复古灯泡,还是普通白织灯,都是一模一样的制作流程,具体怎么做由具体的子类去实现,这样我们可以统一管理 “灯泡” 这一个通用概念,而忽略不同灯泡之间不太重要的差别,程序的可维护性得到了大幅提升。
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Factory Method 工厂方法》· Issue #274 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/274)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,121 @@
|
||||
# Prototype(原型模式)
|
||||
|
||||
Prototype(原型模式)属于创建型模式,既不是工厂也不是直接 New,而是以拷贝的方式创建对象。
|
||||
|
||||
**意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。**
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 做钥匙
|
||||
|
||||
很显然,为了房屋安全,要尽量做到一把钥匙只能开一扇门,每把钥匙结构都多多少少不一样,却又很相似,做钥匙的人按照你给的钥匙一模一样做一个新的,这属于什么模式呢?
|
||||
|
||||
### 两种状态表
|
||||
|
||||
当网站做不停机维护时,假设维护内容是给每个高级会员账户多打 100 元现金,现在需要改数据库表。已知:
|
||||
|
||||
1. 数据库表有几千万条数据,其中高级会员有几千位,为了方便调用已经缓存在中间层了,且数据库对应 ID 更新后对应缓存也会更新。
|
||||
2. 几千条数据修改语句执行完需要几分钟,这几分钟内无法接受用户数据不同步的问题。
|
||||
|
||||
一种常见的做法是,我们生成一份高级会员列表的拷贝,代替数据库缓存的结果,数据库只要读到对应会员 ID 就从拷贝列表中获取,数据表新增一列状态标志,操作完后这个拷贝移除,更新高级会员缓存。
|
||||
|
||||
但是如何生成高级会员列表拷贝呢?如果直接从几千万条用户数据中重新查询,会有较高的数据库查询成本。
|
||||
|
||||
### 模版组件
|
||||
|
||||
通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意为止,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢?
|
||||
|
||||
## 意图解释
|
||||
|
||||
解决上面问题的办法都很简单,就是基于已有对象进行复制即可,效率比 New 一个,或者工厂模式都要高。
|
||||
|
||||
**意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。**
|
||||
|
||||
所谓原型实例,就是被选为拷贝模版的那个对象,比如做钥匙例子中,你给老板的样板钥匙;两种状态表中的已有缓存高级会员列表;模版组件中选中的那个组件。然后,通过拷贝这些原型创建你想要的对象即可。
|
||||
|
||||
我们抽象思考一下,如果每把钥匙都遵循 `Prototype` 接口,提供了 `clone()` 方法以复制自己,那就可以快速复制任意一把钥匙。钥匙工厂可无法解决每把钥匙不一样的问题,我们要的就是和某个钥匙一模一样的副本,复制一份钥匙最简单。
|
||||
|
||||
高级会员状态表例子中,查询数据库的成本是高昂的,但如果仅仅复制已经查询好的列表,时间可以忽略不计,因此最经济的方案是直接复制,而不是通过工厂模式重新连接数据库并执行查询。
|
||||
|
||||
模版组件更是如此,我们根本没有定义那么多组件实例的基类,只要每个组件提供一个 `clone()` 函数,就可以立即复制任意组件实例,这无疑是最经济实惠的方案。
|
||||
|
||||
看到这里,你应该知道了,原型模式的精髓是对象要提供 `clone()` 方法,而这个 `clone()` 方法实现难度有高有低。
|
||||
|
||||
一般来说,原型模式的拷贝建议用深拷贝,毕竟新对象最好不要影响到旧对象,**但是在深拷贝性能问题较大的情况下,可以考虑深浅拷贝结合,也就是将在新对象中,不会修改的数据使用浅拷贝,可能被修改的数据使用深拷贝。**
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=800 src="https://img.alicdn.com/tfs/TB1roQlZWL7gK0jSZFBXXXZZpXa-1328-596.png">
|
||||
|
||||
`Client` 是发出指令的客户端,`Prototype` 是一个接口,描述了一个对象如何克隆自身,比如必须拥有 `clone()` 方法,而 `ConcretePrototype` 就是克隆具体的实现,不同对象有不同的实现来拷贝自身。
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 typescript 编写。
|
||||
|
||||
```typescript
|
||||
class Component implements Prototype {
|
||||
/**
|
||||
* 组件名
|
||||
*/
|
||||
private name: string
|
||||
/**
|
||||
* 组件版本
|
||||
*/
|
||||
private version: string
|
||||
|
||||
/**
|
||||
* 拷贝自身
|
||||
*/
|
||||
public clone = () => {
|
||||
// 构造函数省略了,大概就是传递 name 和 version
|
||||
return new Component(this.name, this.version)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
我们可以看到,实现了 `Prototype` 接口的 `Component` 必须实现 `clone` 方法,这样任意组件在执行复制时,就可以直接调用 `clone` 函数,而不用关心每个组件不同的实现方式了。
|
||||
|
||||
从这就能看出,原型模式与 Factory 与 Builder 模式还是有类似之处的,在隐藏创建对象细节这一点上。
|
||||
|
||||
使用的时候,我们就可以这样创建一个新对象:
|
||||
|
||||
```typescript
|
||||
const newComponent = oldComponent.clone()
|
||||
```
|
||||
|
||||
这里有两个注意点:一般来说,**如果要二次修改生成的对象,不建议给 `clone` 函数加参数,因为这样会导致接口的不一致。** 我们可以为对象实例提供一些 `set` 函数进行二次修改。另外,`clone` 函数要考虑性能,就像前面说过的,可以考虑深浅拷贝结合的方式,同时要注意当对象存在引用关系甚至循环引用时,甚至不一定能实现拷贝函数。
|
||||
|
||||
## 弊端
|
||||
|
||||
每个设计模式必有弊端,但就像每一期都说的,有弊端不代表设计模式不好用,而是指在某种场景喜爱存在问题,我们只要规避这些场景,在合理的场景使用对应设计模式即可。
|
||||
|
||||
原型模式的弊端:
|
||||
|
||||
1. 每个类都要实现 `clone` 方法,对类的实现是有一定入侵的,要修改已有类时,违背了开闭原则。
|
||||
2. 当类又调用了其他对象时,如果要实现深拷贝,需要对应对象也实现 `clone` 方法,整体链路可能会特别长,实现起来比较麻烦。
|
||||
|
||||
## 总结
|
||||
|
||||
**原型模式一般与工厂模式搭配使用,一般工厂方法接收一个符合原型模式的实例,就可以调用它的 `clone` 函数创建返回新对象啦。** 代码大概是这样:
|
||||
|
||||
```typescript
|
||||
// buildComponentFactory 内部通过 targetComponent.clone() 创建对象,而不是 New 或者调用其他工厂函数。
|
||||
const newComponent = buildComponentFactory(new Component())
|
||||
```
|
||||
|
||||
最后来一张图快速理解原型模式:
|
||||
|
||||
<img width=600 src="https://img.alicdn.com/tfs/TB1hBIdm6MZ7e4jSZFOXXX7epXa-982-486.png">
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Prototype 原型模式》· Issue #277 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/277)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,115 @@
|
||||
# Singleton(单例模式)
|
||||
|
||||
Singleton(单例模式)属于创建型模式,提供一种对象获取方式,保证在一定范围内是唯一的。
|
||||
|
||||
**意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。**
|
||||
|
||||
其实单例模式在前端体会的不明显,原因有:
|
||||
|
||||
1. 前端代码本身在单机运行,创建的任何变量都是天然分布式的,不需要担心影响另一个用户。
|
||||
2. 后端代码是一对多的,分辨出哪些资源是请求间共享的,哪些是请求内独有的很重要。
|
||||
|
||||
另外我们说到单例,是隐含了一个范围的,指的是在某个范围内单例,比如在一个上下文中,还是一个房间中,还是一个进程,一个线程中单例,不同场景范围会不同。
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 多人游戏的共享物品
|
||||
|
||||
玩过游戏的同学都知道,我们在每局游戏中使用的公共物品在当前房间中是唯一的,但在游戏房间间却不是唯一的,所以这些公共物品肯定有不同的类去描述,那每局游戏中怎么拿公共物品,可以保证拿到的是当前局内唯一的?
|
||||
|
||||
### Redux 数据流
|
||||
|
||||
其实前端的 Redux 数据流本身就是单例模式,在一个应用中,数据是唯一的,但可以有不同的 UI 使用这份唯一的数据,甚至把一个表格组件展示在两个不同地方,比如全屏模式,但数据依然是一份,我们没有必要为了全屏展示表格,就让它再发一次取数请求,完全可以和原来的表格共享一份数据。
|
||||
|
||||
### 数据库连接池
|
||||
|
||||
每个 SQL 查询都依赖数据库连接池,如果每次查询都建立一次数据库连接池,则建立连接的速度会远远慢于 SQL 查询速度,因此你会怎么设计数据库连接池的获取方法?
|
||||
|
||||
## 意图解释
|
||||
|
||||
单例模式的意图很简单,几乎就是其字面含义:
|
||||
|
||||
**意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。**
|
||||
|
||||
对于多人游戏的共享物品,比如一口锅,要保证在一局游戏内唯一,就要提供一种方法访问到唯一实例。
|
||||
|
||||
Redux 数据流的 `connect` 装饰器就是全局访问点的一种设计。
|
||||
|
||||
数据库连接池可以提前初始化好,并通过固定 API 提供这个唯一实例。
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=600 src="https://img.alicdn.com/tfs/TB1qVf20QY2gK0jSZFgXXc5OFXa-1060-342.png">
|
||||
|
||||
`Singleton` 是单例模式的接口,客户只能通过其定义的 `instance()` 访问实例,以保证单例。
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 typescript 编写。
|
||||
|
||||
```typescript
|
||||
class Ball {
|
||||
private _instance = undefined
|
||||
|
||||
// 构造函数申明为 private,就可以阻止 new Ball() 行为
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance = () => {
|
||||
if (this._instance === undefined) {
|
||||
this._instance = new Ball()
|
||||
}
|
||||
|
||||
return this._instance
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
const ball = Ball.getInstance()
|
||||
```
|
||||
|
||||
可以仔细想想,为什么这个例子把单例写成了静态方法,而不是一个全局变量?其实全局变量也能解决问题,但由于会污染全局,要尽可能通过模块化方式解决,上面的例子就是一个较好的封装方式。
|
||||
|
||||
当然这只是一个最简单的例子,实际上单例模式还有几种模式:
|
||||
|
||||
### 饿汉式
|
||||
|
||||
初始化时就生成一份实例,这样调用时直接就能获取。
|
||||
|
||||
### 懒汉式
|
||||
|
||||
就是代码例子中写的,按需实例化,即调用的时候再实例化。
|
||||
|
||||
> **要注意,按需不一定是什么好事,如果 New 的成本很高还按需实例化,可能把系统异常的风险留到随机的触发时机,导致难以排查 BUG,另外也会影响第一次实例化时的系统耗时。**
|
||||
|
||||
对 JAVA 来说,单例还需要考虑并发性,有 **双重检测、静态内部类、枚举** 等办法解决,这里不具体展开。
|
||||
|
||||
## 弊端
|
||||
|
||||
单例模式的问题有:
|
||||
|
||||
- 对面向对象不太友好。对封装、继承、多态支持不够友好。
|
||||
- 不利于梳理类之间的依赖关系。毕竟单例是直接调用的,而不是在构造函数申明的,所以要梳理关系要看完每一行代码才能确定。
|
||||
- 可拓展性不好。万一要支持多例就比较难拓展,比如全局数据流可能因为微前端方案改成多实例、数据库连接池为了分治 SQL 改成多实例,都是有可能的,在系统设计之初就要考虑到未来是否还会保持单例。
|
||||
- 可测试性不好,因为单例是全局共享的,无法保证测试用例间的隔离。
|
||||
- 无法使用构造函数传参。
|
||||
|
||||
另外单例模式还可以被工厂方法所替代,所以不用特别纠结一种设计模式,可以结合使用,工厂函数也可以内嵌单例模式。
|
||||
|
||||
## 总结
|
||||
|
||||
单例模式概念、用法都简单,是架构设计常用方案,但要充分理解到单例模式的弊端,防止不恰当的使用。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB15O3YmOpE_u4jSZKbXXbCUVXa-904-224.png">
|
||||
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Singleton 单例模式》· Issue #278 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/278)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,149 @@
|
||||
# Adapter(适配器模式)
|
||||
|
||||
Adapter(适配器模式)属于结构型模式,别名 `wrapper`,结构性模式关注的是如何组合类与对象,以获得更大的结构,我们平常工作大部分时间都在与这种设计模式打交道。
|
||||
|
||||
**意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。**
|
||||
|
||||
这个设计模式的意图很好懂,就是把接口不兼容问题抹平。注意,也仅仅能解决接口不一致的问题,而不能解决功能不一致的问题。
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 接口转换器
|
||||
|
||||
插座的种类很多,我们都用过许多适配器,将不同的插头进行转换,可以在不替换插座的情况下正常使用。
|
||||
|
||||
USB 接口转换也同样精彩,有将 TypeC 接口转换为 TypeA 的,也有将 TypeA 接口转换为 TypeC 的,支持双向转换。
|
||||
|
||||
接口转换器就是我们在生活中使用到的适配器模式,因为厂商并没有生产一个新的插座,我们也没有因为接口不适配而换一个手机,一切只需要一个接口转换器即可,这就是运用设计模式的收益。
|
||||
|
||||
### 数据库 ORM
|
||||
|
||||
ORM 屏蔽了 SQL 这一层,带来的好处是不需要理解不同 SQL 语法之间的区别,对于通用功能,ORM 会根据不同的平台,比如 Postgresql、Mysql 进行 SQL 的转换。
|
||||
|
||||
对 ORM 来说,屏蔽不同平台的差异,就是利用适配器模式做到的。
|
||||
|
||||
### API Deprecated
|
||||
|
||||
当一个广泛使用的库进行了含有 break change 的升级时,往往要留给开发者足够的时间去升级,而不能升级后就直接挂掉,因此被废弃的 API 要标记为 `deprecated`,而这种被废弃标记的 API 的实际实现,往往是使用新的 API 替代,这种场景正是使用了适配器模式,将新的 API 适配到旧的 API,实现 API Deprecated。
|
||||
|
||||
## 意图解释
|
||||
|
||||
上面三个例子都满足下面两个条件:
|
||||
|
||||
1. API 不兼容:因为接口的不同;数据库 SQL 语法的不同;框架 API 的不同。
|
||||
2. 但能力已支持:插座都拥有充电或读取能力;不同的 SQL 都拥有查询数据库能力;新 API 覆盖了旧 API 的能力。
|
||||
|
||||
这样就可以通过适配器满足 Adapter 的意图:
|
||||
|
||||
**意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。**
|
||||
|
||||
## 结构图
|
||||
|
||||
适配器的实现分为继承与组合模式。
|
||||
|
||||
下面是名词解释:
|
||||
|
||||
- `Adapter` 适配器,把 `Adeptee` 适配成 `Target`。
|
||||
- `Adaptee` 被适配的内容,比如不兼容的接口。
|
||||
- `Target` 适配为的内容,比如需要用的接口。
|
||||
|
||||
继承:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1iy7Gk4vbeK8jSZPfXXariXXa-1590-518.png">
|
||||
|
||||
适配器继承 `Adaptee` 并实现 `Target`,适用场景是 `Adaptee` 与 `Target` 结构类似的情况,因为这样只需要实现部分差异化即可。
|
||||
|
||||
组合:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1SrW21EY1gK0jSZFMXXaWcVXa-1524-500.png">
|
||||
|
||||
组合的拓展性更强,但工作量更大,如果 `Target` 与 `Adaptee` 结构差异较大,适合用组合模式。
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 typescript 编写。
|
||||
|
||||
继承:
|
||||
|
||||
```typescript
|
||||
interface ITarget {
|
||||
// 标准方式是 hello
|
||||
hello: () => void
|
||||
}
|
||||
|
||||
class Adaptee {
|
||||
// 要被适配的类方法叫 sayHello
|
||||
sayHello() {
|
||||
console.log('hello')
|
||||
}
|
||||
}
|
||||
|
||||
// 适配器继承 Adaptee 并实现 ITarget
|
||||
class Adapter extends Adaptee implements ITarget {
|
||||
hello() {
|
||||
// 用 sayHello 对接到 hello
|
||||
super.sayHello()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
组合:
|
||||
|
||||
```typescript
|
||||
interface ITarget {
|
||||
// 标准方式是 hello
|
||||
hello: () => void
|
||||
}
|
||||
|
||||
class Adaptee {
|
||||
// 要被适配的类方法叫 sayHello
|
||||
sayHello() {
|
||||
console.log('hello')
|
||||
}
|
||||
}
|
||||
|
||||
// 适配器继承 Adaptee 并实现 ITarget
|
||||
class Adapter implements ITarget {
|
||||
private adaptee: Adaptee
|
||||
|
||||
constructor(adaptee: Adaptee) {
|
||||
this.adaptee = adaptee
|
||||
}
|
||||
|
||||
hello() {
|
||||
// 用 adaptee.sayHello 对接到 hello
|
||||
this.adaptee.sayHello()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 弊端
|
||||
|
||||
**使用适配器模式本身就可能是个问题**,因为一个好的系统内部不应该做任何侨界,模型应该保持一致性。只有在如下情况才考虑使用适配器模式:
|
||||
|
||||
1. 新老系统接替,改造成本非常高。
|
||||
2. 三方包适配。
|
||||
3. 新旧 API 兼容。
|
||||
4. 统一多个类的接口。一般可以结合工厂方法使用。
|
||||
|
||||
## 总结
|
||||
|
||||
适配器模式也符合开闭原则,在不对原有对象改造的前提下,构造一个适配器就能完成模块衔接。
|
||||
|
||||
适配器模式的实现分为类与对象模式,类模式用继承,对象模式用组合,分别适用于 `Adaptee` 与 `Target` 结构相似与结构差异较大的场景,在任何情况下,组合模式都是灵活性最高的。
|
||||
|
||||
最后用一张图概括一下适配器模式的思维:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB16L2n1AY2gK0jSZFgXXc5OFXa-1254-630.png">
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Adapter 适配器模式》· Issue #279 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/279)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,111 @@
|
||||
# Bridge(桥接模式)
|
||||
|
||||
Bridge(桥接模式)属于结构型模式,是一种解决继承后灵活拓展的方案。
|
||||
|
||||
**意图:将抽象部分与它的实现部分分离,使它们可以独立地变化。**
|
||||
|
||||
桥接模式比较难理解,我会一步步还原该设计模式的思考,让你体会这个设计模式是如何一步一步被提炼出来的。
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 汽车生产线改造为新能源生产线
|
||||
|
||||
汽油车与新能源汽车的生产流程有很大相似之处,那么汽油车生产线能否快速改造为新能源汽车生产线呢?
|
||||
|
||||
如果汽油车生产线没有将内部实现解耦,只把生产汽油车的各部分独立了出来,对新能源车生产线是没什么用处的,但如果汽油车生产线提供了更底层的能力,比如加装轮胎,加装方向盘,那么这些步骤是可以同时被汽油车与新能源车所共享的。
|
||||
|
||||
在设计汽油车生产线时,就将生产过程与汽油车解耦,使其可以快速运用到新能源汽车的生产,这就是桥接模式的一种运用。
|
||||
|
||||
### 窗口(Window)类的派生
|
||||
|
||||
假设存在一个 Window 窗口类,其底层实现在不同操作系统是不一样的,假设对于操作系统 A 与 B,分别有 AWindow 与 BWindow 继承自 Window,现在要做一个新功能 ManageWindow(管理器窗口),就要针对操作系统 A 与 B 分别生成 AManageWindow 与 BManageWindow,这样显然不容易拓展。
|
||||
|
||||
无论我们新增支持 C 操作系统,还是新增支持一个 IconWindow,类的数量都会成倍提升,因为我们所做的 AMangeWindow 与 BMangeWindow 同时存在两个即以上的独立维度,这使得增加维度时,代码变得很冗余。
|
||||
|
||||
### 适配多个搭建平台的物料
|
||||
|
||||
做前端搭建平台时,经常出现一些物料(组件)因为固化了某个搭建平台的 API,因此无法迁移到另一个搭建平台,如果要迁移,就需要为不同的平台写不同的组件,而这些组件中大部分 UI 逻辑都是一样的,这使得产生大量代码冗余,如果再兼容一个新搭建平台,或者为已有的 10 个搭建平台再创建一个新组件,工作量都是写一个组件的好几倍。
|
||||
|
||||
## 意图解释
|
||||
|
||||
**意图:将抽象部分与它的实现部分分离,使它们可以独立地变化。**
|
||||
|
||||
“抽象” 部分与 “实现” 部分分离,这句话看起来很像接口与实现。确实,如果 “抽象” 指的是 接口(Interface),而 “实现” 指的是 类(Class) 的话,这就是简简单单的 `class MyWindow implements Window` 类实现过程而已。
|
||||
|
||||
但后半句话 “使它们可以独立地变化” 会让你难以和前半句联系起来,如果说 “抽象” 不变,“实现” 可以随意改变还好理解,但反过来就难以解释了。
|
||||
|
||||
**其实桥接模式中,抽象指的是一种接口(Abstraction),实现指的也是一种接口(Implementor),其中 Implementor 并不是直接实现了 Abstraction 定义的接口,而是提供更底层的方法,使 Abstraction 可以基于它们封装出自己的接口实现。**
|
||||
|
||||
这样一来,Abstraction 的接口可以随意变化,毕竟调用的是 Implementor 提供函数的组合,只要 Implementor 提供的功能全面,Implementor 可以不变;相应的,Implementor 的实现也可以随意变化,只要提供的底层函数不变,就不影响 Abstraction 对其的使用。
|
||||
|
||||
上面举的三个例子都是这样,我们应该把汽油车生产线的标准与通用汽车生产线标准分离、将具体功能窗口与适配不同操作系统的基础 GUI 能力隔离、将组件功能与平台功能隔离,只有做到了抽象部分与实现部分的隔离,才可以通过组合满足更多场景。
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=600 src="https://img.alicdn.com/tfs/TB1mZv52oH1gK0jSZSyXXXtlpXa-1726-696.png">
|
||||
|
||||
- Abstraction:定义抽象类的接口。
|
||||
- RefinedAbstraction:扩充 Abstraction。
|
||||
- Implementor:定义实现类的接口,该接口可以与 Abstraction 接口不一致。
|
||||
- ConcreteImplementor:实现 Implementor 接口并定义它的具体实现。
|
||||
|
||||
抽象部分就是 Abstraction,实现部分就是 Implementor,在这个结构图中,它们是分离的,可以各自独立变化的,桥接模式,就是指 `imp` 这个桥,通过 Implementor 实现 Abstraction 接口,就算是桥接上了,这种组合的桥接相比普通的类实现更灵活,更具有拓展性。
|
||||
|
||||
## 代码例子
|
||||
|
||||
对于完全版桥接模式,Implementor 可以有多套实现,Abstraction 不需关心具体用的是哪一种实现,而是通过抽象工厂方式封装。下面举一个简单版的例子。
|
||||
|
||||
下面例子使用 typescript 编写。
|
||||
|
||||
```typescript
|
||||
class Window {
|
||||
private windowImp: WindowImp
|
||||
|
||||
public drawBox() {
|
||||
// 通过画线生成 box
|
||||
this.windowImp.drawLine(0, 1)
|
||||
this.windowImp.drawLine(1, 1)
|
||||
this.windowImp.drawLine(1, 0)
|
||||
this.windowImp.drawLine(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 拓展 window 就非常容易
|
||||
class SuperWindow extends Window {
|
||||
public drawIcon {
|
||||
// 通过自定义画线
|
||||
this.windowImp.drawLine(0, 5)
|
||||
this.windowImp.drawLine(3, 9)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
桥接模式的精髓,通过上面的例子可以这么理解:
|
||||
|
||||
`Window` 的能力是 `drawBox`,那继承 `Window` 容易拓展 `drawIcon` 吗?默认是不行的,因为 `Window` 并没有提供这个能力。经分析可以看出,划线是一种基础能力,不应该与 `Window` 代码耦合,因此我们将基础能力放到 `windowImp` 中,这样 `drawIcon` 也可以利用其基础能力画线了。
|
||||
|
||||
## 弊端
|
||||
|
||||
不要过度抽象,桥接模式是为了让类的职责更单一,维护更便捷,但如果只是个小型项目,桥接模式会增加架构设计的复杂度,而且不正确的模块拆分,把本来关联的逻辑强制解耦,在未来会导致更大的问题。
|
||||
|
||||
另外桥接模式也有简单与复杂模式之分,只有一种实现的场景就不要用抽象工厂做过度封装了。
|
||||
|
||||
## 总结
|
||||
|
||||
桥接模式让我们重新审视类的设计是否合理,把类中不相关,或者说相互独立的维度抽出去,由桥接模式做桥接的方式使用,这样会使每个类功能更内聚,代码量更少更清晰,组合能力更强大,更容易做拓展。
|
||||
|
||||
下图做了一个简单的解释:
|
||||
|
||||
<img width=500 src="https://img.alicdn.com/tfs/TB1nossndTfau8jSZFwXXX1mVXa-1308-1078.png">
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Bridge 桥接模式》· Issue #280 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/280)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,111 @@
|
||||
# Composite(组合模式)
|
||||
|
||||
Composite(组合模式)属于结构型模式,是一种统一管理树形结构的抽象方式。
|
||||
|
||||
**意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。**
|
||||
|
||||
## 举例子
|
||||
|
||||
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
|
||||
|
||||
### 公司组织关系树
|
||||
|
||||
公司组织关系可能分为部门与人,其中人属于部门,有的人有下属,有的人没有下属。如果我们统一将部门、人抽象为组织节点,就可以方便的统计某个部门下有多少人、财务数据等等,而不用关心当前节点是部门还是人。
|
||||
|
||||
### 操作系统的文件夹与文件
|
||||
|
||||
操作系统的文件夹与文件也是典型的树状结构,为了方便递归出文件夹内文件数量或者文件总大小,我们最好设计的时候就将文件夹与文件抽象为文件,这样每个节点都拥有相同的方法添加、删除、查找子元素,而不需要关心当前节点是文件夹或是文件。
|
||||
|
||||
### 搭建平台的组件与容器
|
||||
|
||||
容器与组件的关系很小,用户常常认为容器也是一种组件,但搭建平台实现时,容器与组件稍有不同,不同之处在于容器可以嵌套子元素,而组件不可以。如果因此搭建平台就将组件分为容器与组件,会导致 API 割裂为两套,不利于组件开发者维护与用户理解,比较好的设计思路是将组件与容器统一看成组件,组件只是一种没有子元素的特殊容器,这样组件与容器就可以拥有相同的 API,统一理解与操作了。
|
||||
|
||||
## 意图解释
|
||||
|
||||
**意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。**
|
||||
|
||||
比较好理解,组合是指多个对象虽然有一定差异,但共同组合成了一个树形结构,那么对象之间就一定存在 “部分 - 整体” 的关系,组合模式要求我们抽象一个对象 `Component` 作为统一操作模型,叶子结点与非叶子结点都实现了所有功能,即便是没有子元素的叶子结点,为了强调透明性,还是具备比如 `getChildren` 方法,只不过永远都返回 `null`。
|
||||
|
||||
## 结构图
|
||||
|
||||
<img width=600 src="https://img.alicdn.com/tfs/TB19t0j27Y2gK0jSZFgXXc5OFXa-1504-678.png">
|
||||
|
||||
其中 `Component` 是组合中对象声明接口,一般会实现所有公共类的所有接口,还要提供一个接口管理其子组件。
|
||||
|
||||
`Leaf` 表示叶子结点,没有子结点,相应的 `Composite` 就是有子结点的节点。
|
||||
|
||||
可以看到,组合模式就是将树状结构中所有节点统一抽象了,**我们不需要关心叶子结点与非叶子结点的差异,而可以通过组合模式的抽象屏蔽掉这些差异,统一处理。**
|
||||
|
||||
## 代码例子
|
||||
|
||||
下面例子使用 typescript 编写。
|
||||
|
||||
```typescript
|
||||
// 统一的抽象
|
||||
class Component {
|
||||
// 添加子元素
|
||||
public add() {}
|
||||
// 获取名称
|
||||
public getName() {}
|
||||
// 获取子元素
|
||||
public getChildren() {}
|
||||
}
|
||||
|
||||
// 非叶子结点
|
||||
class Composite extends Component {
|
||||
public add(component: Component) {
|
||||
this.children.push(component)
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.name
|
||||
}
|
||||
|
||||
public getChildren() {
|
||||
return this.children
|
||||
}
|
||||
}
|
||||
|
||||
// 叶子结点
|
||||
class Leaf extends Component {
|
||||
public add(component: Component) {
|
||||
throw Error('叶子结点无法添加元素')
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.name
|
||||
}
|
||||
|
||||
public getChildren() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最后我们把对所有节点的操作都转为 `Component` 对象,而不用关心这个对象具体是 `Composite` 或 `Leaf`。
|
||||
|
||||
## 弊端
|
||||
|
||||
组合模式进行了一层抽象,其实增加了复杂系统中业务复杂度。如果 `Composite` 与 `Leaf` 差异过大,那么统一抽象带来的理解成本是很高的。
|
||||
|
||||
同时,`Leaf` 不得不实现一些仅 `Composite` 存在的空函数,比如 `add` `delete`,即便这些方法对他们是无意义的,此时可能要进行统一的无效或错误处理,才能使业务层真正不用感知他们的区别,否则 `add` 可能会失败,其本质上还是将节点的区别暴露给了业务层。
|
||||
|
||||
## 总结
|
||||
|
||||
组合模式是针对树状结构这个特定场景的统一抽象方案,对降低系统复杂度有很重要的意义,同时也不要忘了过度抽象是有害的,我们要拿捏其中的度。
|
||||
|
||||
下图做了一个简单的解释:
|
||||
|
||||
<img width=500 src="https://img.alicdn.com/tfs/TB1_g24rvzO3e4jSZFxXXaP_FXa-1228-614.png">
|
||||
|
||||
程序中始终关注 `Component` 就行了,树状结构的差异已经被抹平。
|
||||
|
||||
> 讨论地址是:[精读《设计模式 - Composite 组合模式》· Issue #284 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/284)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,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))
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user