精读《数据搭建引擎 bi-designer API-设计器》
bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。
bi-designer 目前没有开源,因此文中使用的私有 npm 源
@alife/bi-designer
是无法在公网访问的。
本文介绍 bi-designer 设计器的使用 API。
bi-designer 设计有如下几个特点:
- 心智统一:编辑模式与渲染模式统一。
- 通用搭建:支持接入任意通用 npm 组件。
- 低入侵:围绕数据分析能力做了增强,但对组件代码无入侵。
渲染画布
做搭建,第一步是将画布渲染出来,需要用到 Designer
与 Canvas
组件:
import { Designer, Canvas } from '@alife/bi-designer'
export () => (
<Designer>
<Canvas />
</Designer>
)
Designer
:数据容器,用于管理渲染引擎数据流。- 参数
defaultPageSchema
:页面 DSL 默认值。 - 参数
defaultMode
:控制编辑渲染状态,edit
orrender
。 Canvas
:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。
编辑模式
编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。
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
的结构大致如下:
{
"componentInstances": {
"1": {
"id": "1",
"componentName": "root",
},
"2": {
"id": "2",
"parentId": "1",
"componentName": "button",
}
}
}
根据 id
parentId
关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 index
标记顺序。
注册组件
DSL 描述信息中最重要的是 componentName
,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(componentMetas
)传递给 Designer
:
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
属性定义:
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 中某个组件信息:
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
作为组件配置面板,这个配置面板中,最重要的是这块代码:
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
设置主题风格:
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
defaultPageSchema={{
theme: { primaryColor: '#333' }
}}
/>
)
我们也可以在运行时使用 setTheme
动态修改主题风格,做到动态切换主题:
const { setTheme, theme } = useDesigner();
return <Button onClick={() => {
setTheme({
...theme,
primaryColor: '#ffffff'
})
}} />
这些主题颜色,组件可以通过 css 变量拿到:
.ok-button {
color: var(--primaryColor);
}
获取组件数据
数据分析引擎中,组件是由数据驱动展示的,这些数据可能来自 OLAP 数据集,或者普通 URL 接口,但无论如何数据都是一个组件重要组成部分,因此对组件的取数与数据操作是 bi-designer 的一个重点。
可以利用 fetchStateSelector
获取任意组件的数据信息,包括取数状态、数据、是否有查询错误等:
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 加载一个画布中已有组件,与其共享同一套数据:
import { ComponentLoader } from '@alife/bi-designer'
const App = () => {
return <ComponentLoader id="some-id-already-exist" />
}
加载一个额外的新组件
如果这个组件不需要响应事件,只是做简单的渲染,那就不需要记录到数据流中,此时仅申明 componentName
即可:
import { ComponentLoader } from '@alife/bi-designer'
const App = () => {
return <ComponentLoader componentName="button" />
}
但这种方式加载的组件存在如下问题:
- 其组件
id
不会存储到pageSchema
,后端可能无法做一些校验。 - 无法响应事件,因为事件响应前提是组件信息存在于
pageSchema
中。
加载一个有事件功能的额外新组件
通过申明 id
与 componentName
加载一个全新组件,为了在其销毁时做有效清理,请将其 id 记录到 useKeepComponentLoaders
中。
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
随时渲染一个组件副本:
import { ComponentLoader } from '@alife/bi-designer'
// ... 任意可拿到 componentInstance 处
return (
<ComponentLoader id={componentInstance.id} />
)
那么全屏就是将组件渲染到一个新容器内,非常 easy。
局部配置覆盖
可以通过 DesignerProvider
实现干涉其子元素 useDesigner
获取信息的能力:
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
定义:
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
访问:
{
"componentInstances": {
"1": {
"id": "1",
"componentName": "button",
"props": {
"text": {
"type": "JSExpression",
"value": "this.i18n['你好']"
}
}
}
}
}
容器拓展组件 props
componentMeta.container
可以定义组件外层容器,但有的时候我们想在容器做一点事情,比如获取宽高,以 props 的方式传递给子组件。
因为子组件以 children
的方式书写不易拓展,因此提供了 PropsProvider
来拓展子组件拿到的 props:
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 的使用方式是这样:
import { useDesigner } from '@alife/bi-designer'
export default () => {
const { undo, redo } = useDesigner()
// 撤销调用 undo()
// 重做调用 redo()
}
是不是觉得很简单?是的,因为所有值得撤销重做的操作在引擎内部使用了 HistoryManager
管理,因此引擎知道每一个可以被撤销或者重做的操作,直接调用函数即可。
组件复制
执行 copyComponent
命令即可复制组件,比如:
const App() {
const { copyComponent } = useDesigner()
// 复制组件 copyComponent(componentInstance)
}
copyComponent
的参数分别为:
function copyComponent(
componentInstance?: ComponentInstance,
parentId?: string,
index?: number
)
- 如不指定
parentId
,默认复制到自己父元素下。 - 如不指定
index
,默认复制到当前元素下方。
组件模版
如果觉得某些组件配置可能被复用,可以在画布组件右上角增加一个 “添加到组件模版” 按钮,bi-designer 也提供了生成、添加组件模版的方法。
创建组件模版
利用 createCombine
函数从画布中已有组件创建出组件模版,也可以将其生成结果持久化,作为一个固定的组件模版:
const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => {
const { createCombine } = useDesigner();
const setToCombine = React.useCallback(() => {
// 创建组件模版
const combine = createCombine(componentInstance.id)
}, [createCombine]);
}
createCombine
的参数就是画布中组件的 id
。
添加组件模版到画布
利用 addCombine
函数将组件模版添加到画布,第一个参数就是上面生成的 combine
对象:
const App = () => {
const { addCombine } = useDesigner();
const addComponent = React.useCallback(() => {
// 创建组件模版
const combine = addCombine(combine, parentId)
}, [addCombine]);
}
渲染完成标识
当画布中所有组件都完成渲染了,可能要做一些监控上报,或者告诉截图软件可以截图了,bi-designer 提供了这种回调时机 onRendered
:
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
字段描述当前画布中有哪些 “边节点”:
import { Designer } from '@alife/bi-designer';
const App = ({ defaultPageSchema }) => (
<Designer defaultPageSchema={{
...defaultPageSchema,
edges: []
}} />
)
可以看到,只要任意拓展 pageSchema
即可。
2. 通过 useDesigner 拿到拓展字段
首先定义一个 edgesSelector
:
import { DesignerState } from '@alife/bi-designer';
export const edgesSelector = () => (state: DesignerState) => {
return {
// 从 pageSchema.edges 读取 edges
edges: state.pageSchema?.edges as Edge[],
};
};
在需要读取的地方结合 useDesigner
:
import { useDesigner } from '@alife/bi-designer';
import { edgesSelector } from './selector'
const Panel = () => {
// 自带类型
const { edges } = useDesigner(edgesSelector())
}
3. 通过 useDesigner 修改拓展字段
通过 setPageSchema
更新拓展字段:
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
进行临时数据存取,要注意非结构化数据是无法监听变化的,引用永远保持不变:
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
可以阻止画布添加组件:
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => {
// 阻止添加
return false
}}
/>
)
addedComponentInstancesArray
:添加的组件,ComponentInstance[]
类型。
shouldMoveComponents()
返回 false
可以阻止画布移动组件:
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => {
// 阻止移动
return false
}}
/>
)
movedComponentInstancesArray
:移动的组件,ComponentInstance[]
类型。taragetComponentInstance
:要移动到的父组件实例信息,ComponentInstance
类型。
shouldDeleteComponents()
返回 false
可以阻止画布删除组件:
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => {
// 阻止删除
return false
}}
/>
)
deletedComponentInstancesArray
:删除的组件,ComponentInstance[]
类型。
仅刷新可视区域组件
默认组件都会以按需加载的方式渲染,即对于不在可视区域的组件,不会触发任何重渲染,以此提升交互操作的效率,以及首屏速度。
对于筛选条件等可能影响到其他组件的组件,可以通过 ComponentMeta.keepActive
强制保持激活状态:
import { Interfaces } from '@alife/bi-designer'
const componentMeta: Interfaces.ComponentMeta = {
keepActive: true
}
keepActive
:组件始终保持激活状态,即不出现在可视区域也会被渲染与响应刷新,默认关闭。
对于特殊场景比如截图,可能要求所有组件强制为 active
状态,可以通过 forceActive
函数实现:
import { Interfaces, useDesigner } from '@alife/bi-designer'
const Test: Interfaces.ComponentElement = () => {
const { forceActive, cancelForceActive } = useDesigner()
// forceActive() 强制所有组件 active
// cancelForceActive() 取消强制 active,组件根据实际情况 active
};
可以通过 getSnapshot().actives
获取任意组件当前瞬时 active
状态:
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
修改上下文。
举例子:
// 初始化 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
就可以持有状态:
import { Interfaces } from '@alife/bi-designer'
const statefulComponentMeta: Interfaces.ComponentMeta = {
stateful: true
}
被有状态的容器包裹的组件 this.state
与 this.setState
都局限在当前状态容器内,也就是当前状态容器内组件的 state 是互通的,且一个有状态容器与外部环境是隔离的,可以独立运行。
工具类拓展
工具类拓展可以通过上下文访问,如下是拓展方式:
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
:内容。
用法:
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
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)
发表评论 (审核通过后显示评论):