Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9867c1ee9d | ||
|
|
e8a0539984 | ||
|
|
685e8ba32a | ||
|
|
6c9493df7b | ||
|
|
1994438792 | ||
|
|
60be3e354b | ||
|
|
9d0bb5c996 | ||
|
|
304dc5fc36 |
@@ -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
|
||||
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
@@ -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))
|
||||
Reference in New Issue
Block a user