bi-designer 是阿里数据中台团队自研的前端搭建引擎，基于它开发了阿里内部最大的数据分析平台，以及阿里云上的 QuickBI。

> bi-designer 目前没有开源，因此文中使用的私有 npm 源 `@alife/bi-designer` 是无法在公网访问的。

本文介绍 bi-designer 设计器的使用 API。

bi-designer 设计有如下几个特点：

- **心智统一：编辑模式与渲染模式统一**。
- **通用搭建：支持接入任意通用 npm 组件**。
- **低入侵：围绕数据分析能力做了增强，但对组件代码无入侵**。

## 渲染画布

做搭建，第一步是将画布渲染出来，需要用到 `Designer` 与 `Canvas` 组件：

```jsx
import { Designer, Canvas } from '@alife/bi-designer'
export () => (
  <Designer>
    <Canvas />
  </Designer>
)
```

- `Designer`：数据容器，用于管理渲染引擎数据流。
  - 参数 `defaultPageSchema`：页面 DSL 默认值。
  - 参数 `defaultMode`：控制编辑渲染状态，`edit` or `render`。
- `Canvas`：渲染画布的所有组件，会根据 DSL 结构将组件一一渲染出来。

## 编辑模式

编辑模式 = 渲染画布（编辑模式）+ 拓展一些自定义面板。

```jsx
import { Designer, Canvas } from '@alife/bi-designer'

export () => (
  <Designer defaultMode="edit">
    <div>Header</div>
    <Canvas />
    <div>Footer</div>
  </Designer>
)
```

编辑模式的拓展采用了 JSX 模式，没有增加任何新的语法，只要放置任意数量的组件，并将画布 `Canvas` 摆放在想要的位置即可。

`defaultMode` 描述了当前引擎所处状态，有 `edit` 与 `render` 两个可选值，可以通过 `{ mode } = useDesigner(modeSelector)` 获取。bi-designer 没有对 `mode` 做任何特殊处理，我们可以在 panel、组件中判断不同的 `mode` 走不同的逻辑，以此区分编辑与渲染态。

## 页面 DSL 结构

`pageSchema` 描述了页面 DSL 信息，其结构是一个 `Map<组件 id, 组件实例信息>`。

这里统一一下名词：

- 组件实例信息：`componentInstance`。
- 组件元信息：`componentMeta`。

那么 `pageSchema` 的结构大致如下：

```json
{
  "componentInstances": {
    "1": {
      "id": "1",
      "componentName": "root",
    },
    "2": {
      "id": "2",
      "parentId": "1",
      "componentName": "button",
    }
  }
}
```

根据 `id` `parentId` 关系描述了组件父子关系，对于同一个父节点在流式布局下的顺序，还会增加 `index` 标记顺序。

## 注册组件

DSL 描述信息中最重要的是 `componentName`，为了告诉渲染引擎这个组件是什么，我们需要将组件元信息（`componentMetas`）传递给 `Designer`：

```jsx
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'

export () => (
  <Designer componentMetas={componentMetas}>
    <Canvas />
  </Designer>
)

const componentMetas: Interfaces.ComponentMetas = {
  button: {
    componentName: 'button',
    element: Button
  }
}
```

关于 `componentMeta` 会在下一篇精读详细介绍，这里只说明两个最重要的属性：

- `componentName`：组件名，唯一。
- `element`：组件 UI 对象，对应一个 React 组件实例。

注意这里就留下了不少拓展空间，`componentMetas` 可以存储在服务端，`element` 可以远程异步加载，也可以在项目代码中固化，但传递给渲染引擎的 API 是固定的。

## 布局

bi-designer 支持流式布局、磁贴布局、自由布局三种模式，通过 `Designer.layout` 属性定义：

```jsx
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
import { LayoutMover } from '@alife/bi-designer-stream-layout'

export () => (
  <Designer layout={LayoutMover}>
    <Canvas />
  </Designer>
)
```

我们提供了三种不同的布局包，切换对应的包即可切换布局，你甚至可以再包裹一层，通过代码控制在运行时切换布局。

`layout` 会包裹在每个组件外层，无论是流式、磁贴还是自由布局，都可以通过附着在每个组件外层来实现。

## 操作/获取画布内容

只要在数据容器 `Designer` 下，就可以通过 `useDesigner()` 获取画布信息或者修改画布内容。

举个例子，比如实现组件配置面板，需要获取到 **当前选中组件**，以及实现操作 **更新 DSL 中某个组件信息**：

```jsx
import { Designer, Canvas, useDesigner, selectedComponentsSelector } from '@alife/bi-designer';

const EditPanel = () => {
  const { updateComponentById, selectedComponents } = 
    useDesigner(selectedComponentsSelector());

  // 在合适的时候调用 updateComponentById 更新 selectedComponents
  
  // 渲染组件配置表单..
}

export () => (
  <Designer>
    <Canvas />
    <EditPanel />
  </Designer>
)
```

我们在 `Canvas` 下面渲染了一个自定义组件 `EditPanel` 作为组件配置面板，这个配置面板中，最重要的是这块代码：

```jsx
import { useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
const { updateComponentById, selectedComponents } = 
    useDesigner(selectedComponentsSelector());
```

- `useDesigner` 是 React Hook，导出的函数都是静态的，不会因为画布信息变更而导致组件重渲染。
- 如果需要监听一些会变化的元素，比如当前选中组件，就需要用 Selector 完成，当这些信息变更时，使用了这些 Selector 的组件也会重渲染，具体 Selector 有很多，比如：
  - `selectedComponentsSelector`: 当前选中的组件。
  - `pageSchemaSelector`: 当前画布 DSL。
  - `modeSelector`: 当前渲染模式。等等。
- 对画布组件操作有几个重要的静态方法，包括：
  - `updateComponentById`: 更新某个 id 组件信息。
  - `addComponent`: 添加组件。
  - `deleteComponent`: 删除组件。
  - `moveComponent`: 移动组件。等等。
- 除此之外，`useDesigner` 还提供了很多有用的方法，在用到时再介绍。

## 主题风格

通过 `pageSchema.theme` 设置主题风格：

```jsx
import { Designer } from '@alife/bi-designer'

const App = () => (
  <Designer
    defaultPageSchema={{
      theme: { primaryColor: '#333' }
    }}
  />
)
```

我们也可以在运行时使用 `setTheme` 动态修改主题风格，做到动态切换主题：

```jsx
const { setTheme, theme } = useDesigner();

return <Button onClick={() => {
  setTheme({
    ...theme,
    primaryColor: '#ffffff'
  })
}} />
```

这些主题颜色，组件可以通过 css 变量拿到：

```css
.ok-button {
  color: var(--primaryColor);
}
```

## 获取组件数据

数据分析引擎中，组件是由数据驱动展示的，这些数据可能来自 OLAP 数据集，或者普通 URL 接口，但无论如何数据都是一个组件重要组成部分，因此对组件的取数与数据操作是 bi-designer 的一个重点。

可以利用 `fetchStateSelector` 获取任意组件的数据信息，包括取数状态、数据、是否有查询错误等：

```jsx
import { useDesigner, fetchStateSelector } from '@alife/bi-designer';

const App = () => {
  const { fetchState } = useDesigner(fetchStateSelector(componentInstance.id));
  
  console.log(
    fetchState.isFetching, // 是否在取数中
    fetchState.isFilterReady, // 筛选条件是否准备好了
    fetchState.data, // 取数结果
    fetchState.error, // 取数错误，如果取数阶段报错的话
  )
}
```

bi-designer 将所有组件的取数状态统一管理，因此可以跨组件获取数据信息，实现一些复杂需求：比如某些组件配置面板要获取组件取数结果填充配置表单。

## 组件加载器

组件加载器 `ComponentLoader` 可以加载任意组件， `Canvas` 就是基于此实现的。

### 加载画布中已有组件

通过申明 id 加载一个画布中已有组件，与其共享同一套数据：

```jsx
import { ComponentLoader } from '@alife/bi-designer'
const App = () => {
  return <ComponentLoader id="some-id-already-exist" />
}
```

### 加载一个额外的新组件

如果这个组件不需要响应事件，只是做简单的渲染，那就不需要记录到数据流中，此时仅申明 `componentName` 即可：

```jsx
import { ComponentLoader } from '@alife/bi-designer'
const App = () => {
  return <ComponentLoader componentName="button" />
}
```

但这种方式加载的组件存在如下问题：

- 其组件 `id` 不会存储到 `pageSchema` ，后端可能无法做一些校验。
- 无法响应事件，因为事件响应前提是组件信息存在于 `pageSchema` 中。

### 加载一个有事件功能的额外新组件

通过申明 `id` 与 `componentName` 加载一个全新组件，为了在其销毁时做有效清理，请将其 id 记录到 `useKeepComponentLoaders` 中。

```jsx
import { ComponentLoader, useDesigner } from '@alife/bi-designer'
const App = () => {
  const { useKeepComponentLoaders } = useDesigner();
  useKeepComponentLoaders(["1"])
  
  return <ComponentLoader id="1" componentName="button" />
}
```

通过此方式加载的组件会在其渲染时记录到 `pageSchema` 中。

> 注意，此时 id  与仅写一个 id 时含义不同，这个 id 在当前父组件作用域下唯一就可以。

## 全屏功能

所有组件实例都可以存在副本，共享一套状态数据，可以通过 `ComponentLoader` 随时渲染一个组件副本：

```jsx
import { ComponentLoader } from '@alife/bi-designer'

// ... 任意可拿到 componentInstance 处
return (
  <ComponentLoader id={componentInstance.id} />
)
```

那么全屏就是将组件渲染到一个新容器内，非常 easy。

## 局部配置覆盖

可以通过 `DesignerProvider` 实现干涉其子元素 `useDesigner` 获取信息的能力：

```jsx
import { DesignerProvider, ComponentLoader } from '@alife/bi-designer';

// 某个组件内，或者某个 UI 内以 render 模式加载组件
// ...
return (
  <DesignerProvider mode="render">
    <ComponentLoader id={id} />
  </DesignerProvider>
)
```

举个例子，比如在编辑模式下要全屏预览组件，可以通过 `ComponentLoader + id` 把某个画布组件实例渲染到弹出的 Modal 中，但问题是当前属于编辑模式，组件还可以被拖拽甚至响应编辑效果，我们只想让局部变成渲染状态，怎么做呢？

答案就是通过 `DesignerProvider` 包裹这个 Modal，这个 Modal 内部无论是组件还是其他 Panel 代码通过 `const { mode } = useDesigner(modeSelector)` 拿到的值都会被强制覆盖为 `render`。

## 配置国际化

国际化信息在 `pageSchema.i18n` 定义：

```jsx
import { Designer } from '@alife/bi-designer'

const App = () => (
  <Designer
    defaultPageSchema={{
      i18n: {
        "zh-CN": {
          你好: "你好",
          中国: "中国"
        },
        "en-US": {
          你好: "Hello",
          中国: "China"
        }
      }
    }}
    defaultLocaleKey="zh-CN"
  />
)
```

- `defaultLocaleKey`: 默认国际化语言，可以通过 `{ setLocaleKey } = useDesigner()` 动态改变。

这样在 DSL 中通过描述 `JSExpression` 表达式的 `this.i18n` 访问：

```json
{
  "componentInstances": {
    "1": {
      "id": "1",
      "componentName": "button",
      "props": {
        "text": {
          "type": "JSExpression",
          "value": "this.i18n['你好']"
        }
      }
    }
  }
}
```

## 容器拓展组件 props

`componentMeta.container` 可以定义组件外层容器,但有的时候我们想在容器做一点事情，比如获取宽高，以 props 的方式传递给子组件。

因为子组件以 `children` 的方式书写不易拓展，因此提供了 `PropsProvider` 来拓展子组件拿到的 props：

```jsx
import { Interfaces, PropsProvider } from '@alife/bi-designer'

const ComponentContainer = ({ children }) => {
  return (
    // 注入 width 和 height
    <PropsProvider width={100} height={100}>
      {children}
    </PropsProvider>
  )
}

const Element = ({ width, height }) => {
  // width=100
  // height=100
}

const componentMeta: Interfaces.ComponentMeta = {
  element: Element,
  container: ComponentContainer
};
```

上面的例子中，因为 `container` 注入了 `width`，因此组件可以通过 `props.width` 拿到容器注入的值。

## 撤销重做

撤销重做按钮在基于每个搭建系统都有，在 bi-designer 的使用方式是这样：

```jsx
import { useDesigner } from '@alife/bi-designer'

export default () => {
  const { undo, redo } = useDesigner()
  
  // 撤销调用 undo()
  // 重做调用 redo()
}
```

是不是觉得很简单？是的，因为所有值得撤销重做的操作在引擎内部使用了 `HistoryManager` 管理，因此引擎知道每一个可以被撤销或者重做的操作，直接调用函数即可。

## 组件复制

执行 `copyComponent` 命令即可复制组件，比如：

```jsx
const App() {
  const { copyComponent } = useDesigner()
  
  // 复制组件 copyComponent(componentInstance)  
}
```

`copyComponent` 的参数分别为：

```jsx
function copyComponent(
  componentInstance?: ComponentInstance,
  parentId?: string,
  index?: number
)
```

- 如不指定 `parentId` ，默认复制到自己父元素下。
- 如不指定 `index` ，默认复制到当前元素下方。

## 组件模版

如果觉得某些组件配置可能被复用，可以在画布组件右上角增加一个 “添加到组件模版” 按钮，bi-designer 也提供了生成、添加组件模版的方法。

### 创建组件模版

利用 `createCombine` 函数从画布中已有组件创建出组件模版，也可以将其生成结果持久化，作为一个固定的组件模版：

```jsx
const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => {
  const { createCombine } = useDesigner();
  
  const setToCombine = React.useCallback(() => {
    // 创建组件模版
    const combine = createCombine(componentInstance.id)
  }, [createCombine]);
}
```

`createCombine` 的参数就是画布中组件的 `id`。

### 添加组件模版到画布

利用 `addCombine` 函数将组件模版添加到画布，第一个参数就是上面生成的 `combine` 对象：

```jsx
const App = () => {
  const { addCombine } = useDesigner();
  
  const addComponent = React.useCallback(() => {
    // 创建组件模版
    const combine = addCombine(combine, parentId)
  }, [addCombine]);
}
```

## 渲染完成标识

当画布中所有组件都完成渲染了，可能要做一些监控上报，或者告诉截图软件可以截图了，bi-designer 提供了这种回调时机 `onRendered`:

```jsx
import { Designer } from '@alife/bi-designer'

const App = () => (
  <Designer
    onRendered={errors => {
      errors.map(each => {
        // 错误组件 id
        console.log(each.id)
        
        // 错误信息
        console.log(each.error)
      })
      // 渲染完毕
    }}
  />
)
```

- `errors`: 如果有组件代码报错，引擎会吞掉这个错误保证其他组件正常渲染，并把错误组件的 id 和错误信息返回到这里。

## 自定义数据流

如果 `useDesigner` 提供的数据流无法满足业务需要，可以通过进行自定义拓展。

### 1. 拓展字段

举个例子，我们需要新增一个 `edges` 字段描述当前画布中有哪些 “边节点”：

```jsx
import { Designer } from '@alife/bi-designer';
const App = ({ defaultPageSchema }) => (
  <Designer defaultPageSchema={{
    ...defaultPageSchema,
    edges: []
  }} />
)
```

可以看到，只要任意拓展 `pageSchema` 即可。

### 2. 通过 useDesigner 拿到拓展字段

首先定义一个 `edgesSelector` ：

```jsx
import { DesignerState } from '@alife/bi-designer';
export const edgesSelector = () => (state: DesignerState) => {
  return {
    // 从 pageSchema.edges 读取 edges
    edges: state.pageSchema?.edges as Edge[],
  };
};
```

在需要读取的地方结合 `useDesigner` ：

```jsx
import { useDesigner } from '@alife/bi-designer';
import { edgesSelector } from './selector'
const Panel = () => {
  // 自带类型
  const { edges } = useDesigner(edgesSelector())
}
```

### 3. 通过 useDesigner 修改拓展字段

通过 `setPageSchema` 更新拓展字段：

```jsx
import { useDesigner } from '@alife/bi-designer';
const Panel = () => {
  const { setPageSchema } = useDesigner()
  
  const handleChangeEdges = React.useCallback(newEdges => {
    setPageSchema(pageSchema => ({
      ...pageSchema,
      newEdges
    }))
  }, [setPageSchema])
}
```

总结一下，这个拓展字段由业务定义，透过 `useDesigner` 读与改，使业务数据管理方式更聚合。

## 存储临时非结构化数据

对于非结构化数据比如组件 `ref` 是不能存储到数据流的，既不能使用 `setPageSchema`，也不能调用 `updateComponentId` 存储到 `componentInstance` 中。

此时可以利用 `temporary` 进行临时数据存取，要注意非结构化数据是无法监听变化的，引用永远保持不变：

```jsx
import { useDesigner } from '@alife/bi-designer';
const App = () => (
  const { temporary } = useDesigner()
  // 写
  temporary.set('component1', ref)
  // 读
  console.log(temporary.get('component1'))
)
```

temporary 本质是个 Map，所以拥有 Map 类型所有语法。

## 拦截画布操作

如果你限制某个低配版本只能在画布使用最多 50 个组件，我们需要阻止画布超过 50 个组件的添加，这个场景可以通过 `DesignerProps` 生命周期可以对画布操作进行拦截。

`shouldAddComponents()` 返回 `false` 可以阻止画布添加组件:

```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
  <Designer
    shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => {
      // 阻止添加
      return false
    }}
  />
)
```

- `addedComponentInstancesArray` ：添加的组件， `ComponentInstance[]` 类型。

`shouldMoveComponents()` 返回 `false` 可以阻止画布移动组件:

```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
  <Designer
    shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => {
      // 阻止移动
      return false
    }}
  />
)
```

- `movedComponentInstancesArray` ：移动的组件，`ComponentInstance[]` 类型。
- `taragetComponentInstance` ：要移动到的父组件实例信息， `ComponentInstance` 类型。

`shouldDeleteComponents()` 返回 `false` 可以阻止画布删除组件:

```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
  <Designer
    shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => {
      // 阻止删除
      return false
    }}
  />
)
```

- `deletedComponentInstancesArray` ：删除的组件， `ComponentInstance[]` 类型。

## 仅刷新可视区域组件

默认组件都会以按需加载的方式渲染，即对于不在可视区域的组件，不会触发任何重渲染，以此提升交互操作的效率，以及首屏速度。

对于筛选条件等可能影响到其他组件的组件，可以通过 `ComponentMeta.keepActive` 强制保持激活状态：

```jsx
import { Interfaces } from '@alife/bi-designer'
const componentMeta: Interfaces.ComponentMeta = {
  keepActive: true
}
```

- `keepActive`：组件始终保持激活状态，即不出现在可视区域也会被渲染与响应刷新，默认关闭。

对于特殊场景比如截图，可能要求所有组件强制为 `active` 状态，可以通过 `forceActive` 函数实现：

```jsx
import { Interfaces, useDesigner } from '@alife/bi-designer'
const Test: Interfaces.ComponentElement = () => {
  const { forceActive, cancelForceActive } = useDesigner()
  
  // forceActive() 强制所有组件 active
  // cancelForceActive() 取消强制 active，组件根据实际情况 active
};
```

可以通过 `getSnapshot().actives` 获取任意组件当前瞬时 `active`  状态：

```jsx
import { useDesigner } from '@alife/bi-designer'
const Test = () => {
  const { getSnapshot, id } = useDesigner()
  
  // 当前组件激活状态
  const active = getSnapshot().actives[id]
};
```

## 上下文数据对象

组件 DSL 描述中，表达式类型（`JSExpression`）可以通过 `this.` 访问到上下文数据对象。上下文数据对象符合如下规则：

- 任何组件都通过配置 `ComponentMeta.stateful` 持有上下文。
- 画布根节点 `root` 一定是 `stateful` 的。
- `JSFunction` 与 `JSExpression` 都可通过 `this.state` 访问上下文， `this.setState` 修改上下文。

举例子：

```jsx
// 初始化 pageSchema
const defaultPageSchema: Interfaces.PageSchema = {
  componentInstances: {
    test1: {
      id: 'test1',
      componentName: 'test',
      parentId: 'jtw4x8ns',
      index: 0,
      props: {
        variable: {
          type: 'JSExpression',
          value: 'this.state.variable + "%"',
        },
        onClick: {
          type: 'JSFunction',
          value: 'function onClick() { this.setState({ variable: 5 }) }',
        },
      },
    }
  }
};
```

这个例子中，组件调用 `this.props.onClick` 会修改上下文 `a=5` ，触发后，其 `this.props.variable` 拿到的值会变为 5% 。

任何组件或容器只要设置了 `stateful` 就可以持有状态：

```jsx
import { Interfaces } from '@alife/bi-designer'
const statefulComponentMeta: Interfaces.ComponentMeta = {
  stateful: true
}
```

被有状态的容器包裹的组件 `this.state` 与 `this.setState` 都局限在当前状态容器内，也就是当前状态容器内组件的 state 是互通的，且一个有状态容器与外部环境是隔离的，可以独立运行。

## 工具类拓展

工具类拓展可以通过上下文访问，如下是拓展方式：

```jsx
import { Interfaces } from '@alife/bi-designer'
// DSL 中增加 utils 描述
const defaultPageSchema: Interfaces.PageSchema = {
  utils: [
    {
      name: 'format',
      type: 'function',
      content: `function format(str){ return str + '%' }`,
    },
  ]
};
```

- `name` ：工具函数名。
- `type` ：类型，包括 `npm` 、 `umd` 、 `function` 。
- `content` ：内容。

用法:

```jsx
JSFunction 与 JSExpression 都可以通过 this.utils 访问工具类拓展函数，比如
// DSL 中增加 Expression 描述
const defaultPageSchema: Interfaces.PageSchema = {
  componentInstances: {
    test: {
      id: 'tg43g42f',
      componentName: 'expressionComponent',
      index: 0,
      props: {
        variable: {
          type: 'JSExpression',
          value: 'this.utils.format("100")',
        }
      },
    },
  },
};
```

上面的例子中，组件拿到的 `props.variable` 值为 100% 。

## 总结

如果你认真看完了全文，就会发现，bi-designer 是一个集成了数据流的开发框架，而不仅是一个渲染引擎，但却可以和你现有的业务代码友好相处，没有入侵性。

像渲染完成标识、按需渲染、组件加载器、局部配置覆盖等功能是强依赖渲染引擎存在的，因此较难在剥离渲染引擎的条件下转换为代码，因为做 BI 分析工具毕竟不是做研发提效用，业务上没有出码的必要，因此我们会做许多依赖渲染引擎的能力增强。

更多数据分析特性的功能将在下一个话题 API 之组件说明。

> 讨论地址是：[精读《数据搭建引擎 bi-designer API-设计器》· Issue #267 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/267)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
