Compare commits

...
20 Commits
Author SHA1 Message Date
ascoders 25938c60a6 171 2020-11-01 21:41:08 +08:00
ascoders 9920f0dbb7 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-10-24 20:50:18 +08:00
ascoders 90f7d89db7 170 2020-10-24 20:50:04 +08:00
黄子毅 6ddbdbbbb2 Merge pull request #276 from NaturelLee/patch-1
Correct Hooks implimentation
2020-10-20 15:44:25 +08:00
NaturelLee 3c8c9fd5ef Correct Hooks implimentation
Correct Hooks implimentation from Array to single linked list
2020-10-20 15:35:08 +08:00
ascoders 91a9bf82fd fix bug 2020-10-19 10:22:17 +08:00
ascoders 4603ac77d8 169 2020-10-18 11:25:55 +08:00
ascoders 998940af1b 168 2020-10-10 17:40:42 +08:00
ascoders 24ed724eab fix: 放大图片 2020-10-07 19:32:11 +08:00
ascoders 33b71628ab update title 2020-09-24 20:15:28 +08:00
ascoders 2dbf1429ba 167 2020-09-21 09:45:35 +08:00
ascoders 9867c1ee9d 166 2020-09-14 09:52:10 +08:00
ascoders e8a0539984 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-09-07 11:28:18 +08:00
ascoders 685e8ba32a 165 2020-09-07 11:27:07 +08:00
黄子毅 6c9493df7b Merge pull request #268 from spiritree/patch-1
Update 163.精读《Spring 概念》.md
2020-09-02 09:41:32 +08:00
深樹 1994438792 Update 163.精读《Spring 概念》.md
`lass` -> `class`
2020-09-01 16:04:21 +08:00
ascoders 60be3e354b fix 2020-08-31 10:25:00 +08:00
ascoders 9d0bb5c996 163 2020-08-31 10:22:21 +08:00
ascoders 304dc5fc36 163 2020-08-24 09:35:45 +08:00
ascoders 819b6d7452 162 2020-08-17 10:24:38 +08:00
12 changed files with 3459 additions and 2 deletions
+1 -1
View File
@@ -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 的潜力的(造什么轮子)。
@@ -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 任务会存放到 MicrotaskssetTimeout 任务会存放到 TasksMicrotasks 会优先于 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)
+306
View File
@@ -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 mvcMVC 思想的 web 框架。
## IOC
IOCInverse 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
AOPAspect 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
shouldAddComponents={({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
+250
View File
@@ -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。
**意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。**
我们再理解一次意图,所谓构建与表示分离,就是指一个对象 `Persion` 并不是简单的 `new Persion()` 就可以实例化出来的,如果可以,那就是构建与表示一体。**所谓构建与表示分离,就是指 `Persion` 只能描述,而不能通过 `new Persion()` 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 `Persion` 实例。**
在乐高积木的例子中,通过乐高创建的房子并不是 `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 instance = () => {
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)