核心 SDK 详细设计文档 (Visual-Render-SDK)
一. 架构目标
构建一个渲染引擎,通过解析 JSON 协议,实现布局嵌套、图表渲染、数据源绑定及交互控制。支持通过“逃生通道”挂载业务定制组件。
二. 核心数据结构 (The Protocol)
所有的渲染都基于一份 config 对象。
| 字段 | 类型 | 说明 |
|---|---|---|
type |
String |
节点类型:container(布局) 或widget(组件) |
component |
String |
渲染标识,如FlexLayout,EChartsRender,TabContainer
|
children |
Array |
仅container有效,存放子节点配置 |
content |
Object |
仅widget有效,包含templateId(样式) 和sourceId(数据) |
customComponent |
String |
逃生通道:若有值,则忽略content,直接加载此业务组件 |
reactiveParams |
Array<String> |
声明需要监听的全局变量名,如['date']
|
示例:
{
"type": "widget",
"key":"chart-01",
"comp": "ChartWidget",
"echartsOptions": {},
"formOptions": {},
"actions":{
"type": "click",
"execute": "openDialog",
"controls":[
{
"comp":"ExportButton",
"type": "action",
"field": "name",
"props":[
]
}
],
"config": {
"datasourceKey": "data-source-1",
"params": {
"category": "{{data.name}}",
"date": "{{global.selectedDate}}"
}
}
}
}
{
"type": "datasouce",
"key": "datasource-1",
"url": "",
"params":[
{
"field":"x",
"to":"replaceTextMap",
"value": ""
}
],
"templateKey":"chart-01"
}
{
"type": "layout",
"comp": "NineGridLayout",
"deptId": "",
"children": [
{
"id":"",
"title":"xxx指标",
"controls":[
{
"comp":"FilterSelect",
"type": "filter",
"field": "name",
"props":[
]
}
],
"datasourceKey": "data-source-1"
}
]
}
复杂示例
|--------------------------------------------------------------------|
| |-----------| |
| | 卡片 | |
| |-----------| |
| |
|-------------|-------------| |
| 选项卡1 | 选项卡2 | |
|--------------------------------------------------------------------|
| 选项卡内容 |
|--------------------------------------------------------------------|
{
"pageId": "dept_dashboard_001",
"pageTitle": "部门综合管理看板",
"type": "container",
"component": "FlexLayout",
"props": { "direction": "column", "gap": "20px", "padding": "20px" },
"children": [
{
"id": "top_row",
"type": "container",
"component": "FlexLayout",
"props": { "direction": "row", "gap": "15px" },
"style": { "height": "120px" },
"children": [
{
"id": "kpi_1",
"type": "widget",
"style": { "flex": 1 },
"content": {
"templateId": "tpl_kpi_card",
"sourceId": "ds_total_sales",
"override": { "title": "当日销售总额", "color": "#1890ff" }
}
},
{
"id": "kpi_2",
"type": "widget",
"style": { "flex": 1 },
"content": {
"templateId": "tpl_kpi_card",
"sourceId": "ds_active_users",
"override": { "title": "活跃用户数", "color": "#52c41a" }
}
}
]
},
{
"id": "bottom_tabs",
"type": "container",
"component": "TabContainer",
"props": { "type": "card" },
"children": [
{
"tabKey": "overview",
"label": "业务概览",
"authKey": "VIEW_OVERVIEW",
"controls": [
{ "id": "c1", "type": "Select", "field": "region", "label": "区域", "props": { "options": [{ "label": "华东", "value": "sh" }, { "label": "华南", "value": "gd" }] } },
{ "id": "c2", "type": "DateRange", "field": "timeRange", "label": "周期" }
],
"content": {
"type": "container",
"component": "GridLayout",
"props": { "columns": 3, "gap": "15px" },
"children": [
{
"id": "grid_item_1",
"type": "widget",
"content": {
"templateId": "tpl_bar_chart",
"sourceId": "ds_dept_perf",
"override": { "title": "各组业绩对比" }
},
"interactions": [
{
"trigger": "click",
"action": "openDialog",
"config": {
"title": "业绩明细列表",
"content": {
"type": "widget",
"component": "TableRender",
"content": { "templateId": "tpl_detail_table", "sourceId": "ds_perf_detail" }
}
}
}
]
}
]
}
},
{
"tabKey": "detail",
"label": "明细数据",
"controls": [
{ "id": "c3", "type": "SearchInput", "field": "keyword", "props": { "placeholder": "搜索项目名称..." } }
],
"content": {
"type": "widget",
"component": "TableRender",
"content": {
"templateId": "tpl_main_list",
"sourceId": "ds_project_list"
}
}
}
]
}
]
}
三. 渲染逻辑实现 (Recursive Renderer)
1. 递归分发器 (VisualRenderer.vue)
这是 SDK 的入口。
-
逻辑:根据
type判断。如果是container,渲染布局组件并把children传给它;如果是widget,渲染加载器。 - 伪代码实现:
<component :is="config.type === 'container' ? LayoutResolver : WidgetLoader" :config="config" />
2. 布局解析器 (LayoutResolver.vue)
-
职责:实现
FlexLayout,GridLayout,TabContainer。 -
逻辑:如果是
TabContainer,需维护一个activeKey。每个布局组件内部必须包含:
<VisualRenderer v-for="child in config.children" :config="child" />
四. 数据与交互中心 (Data & State)
1. 宿主注入 (Dependency Injection)
SDK 不得直接访问 Pinia。必须通过 provide/inject 获取宿主提供的能力。
-
request: 执行 API 的函数(sourceId, params) => Promise。 -
globalContext: 包含全局状态的对象,如{ date: '2023-10' } bizComponents: 一个对象映射表,用于“逃生通道”查找业务组件
2. 响应式监听 (useGlobalReactive.js)
-
目标:实现 A 项目需要日期联动,B 项目不需要。
-
逻辑:
- 使用
watch监听globalContext。 判断当前 config.reactiveParams 是否包含改变的变量。若包含,调用 fetchData 刷新数据。
- 使用
五. 逃生通道实现 (The Escape Hatch)
当 config.customComponent 有值时:
从 inject('bizComponents') 中寻找对应的 Vue 组件使用 <component :is="foundComponent" /> 进行渲染将 config.props 透传给该组件
六. 交互面板 (Action Bar)
用于实现 Tab 右侧差异化控制按钮。
-
分类:
-
Filter: 下拉框/搜索框。修改TabContainer内部的localState。 -
Action: 按钮。点击时读取localState,调用request执行下载或接口调用。
-
七. 前端开发任务清单 (Implementation Checklist)
任务 1:核心骨架 (基础)
- 实现
VisualRenderer递归递归组件。 - 实现
FlexLayout(支持 row/column) 和GridLayout(支持 columns 配置)。
任务 2:物料加载器 (数据)
- 实现
WidgetLoader:根据sourceId调用注入的request方法。 - 实现
ECharts通用渲染模板:接收option和data。
任务 3:交互与权限 (进阶)
- 实现
AuthGuard:在渲染每个节点前判断config.acl。 - 实现
TabContainer及其右侧动态ControlRenderer。
任务 4:逃生机制 (定制)
- 实现动态组件查找逻辑,确保能挂载宿主项目传入的
.vue文件。
八. 开发提示
-
样式:统一使用 CSS Variables (如
--primary-color),不要写死颜色。 -
错误处理:若
sourceId请求失败,组件应渲染ErrorPlaceholder而不是白屏。 -
性能:ECharts 实例必须在
onUnmounted时调用dispose()。 -
sdk升级问题
- 当 A 项目由于时间紧,无法整体重构但又要新功能时:在 SDK 1.0 中紧急发布一个补丁版本(v1.1.0),仅把 2.0 中某些核心功能组件(比如那个新的九宫格或导出按钮)以“独立插件”的形式回填。(A 项目仍然用 1.0 的框架,但局部引用了 2.0 的功能。)
- 多版本协议兼容层:
转换函数的作用是:抹平字段差异,填充默认值。
核心渲染分发器 (VisualRenderer.vue)
它是 SDK 的唯一出口。它不负责具体的渲染,只负责根据版本号进行路由分发。
<template>
<!-- 根据版本号动态切换渲染器 -->
<component
:is="currentRenderer"
v-bind="$attrs"
:config="processedConfig"
/>
</template>
<script setup>
import { computed } from 'vue';
import RendererV1 from './v1/RendererV1.vue';
import RendererV2 from './v2/RendererV2.vue';
import { transformV1ToV2 } from './adapters/v1ToV2';
const props = defineProps({
config: { type: Object, required: true }
});
// 1. 自动转换逻辑:如果是旧版本,且我们希望用新引擎跑,可以先转换
const processedConfig = computed(() => {
const version = props.config.version || '1.0';
// 如果是 1.0 的配置,但在 2.0 环境下运行,执行转换
if (version === '1.0') {
return transformV1ToV2(props.config);
}
return props.config;
});
// 2. 分发逻辑:决定使用哪个版本的 UI 渲染器
const currentRenderer = computed(() => {
const version = processedConfig.value.version;
if (version >= '2.0') return RendererV2;
return RendererV1;
});
</script>
/**
* 将 V1 版本的协议转换为 V2 版本的标准协议
* 场景示例:V1 中图表配置在 renderOptions,V2 中统一到了 content.style
*/
export function transformV1ToV2(oldConfig) {
// 深度克隆,避免污染原始数据
const newConfig = JSON.parse(JSON.stringify(oldConfig));
// 1. 升级版本标识
newConfig.version = '2.0-compat';
// 2. 递归处理组件
const walk = (node) => {
if (!node) return;
// 示例:V1 使用 'chartType', V2 统一映射到 'component'
if (node.type === 'chart' && node.chartType) {
node.component = node.chartType === 'line' ? 'EChartsLine' : 'EChartsBar';
delete node.chartType;
}
// 示例:V1 的数据源配置在 dataSource,V2 要求在 content 目录下
if (node.dataSource && !node.content) {
node.content = {
sourceId: node.dataSource.id || node.dataSource.url,
params: node.dataSource.params || {}
};
delete node.dataSource;
}
// 递归处理子节点
if (node.children && Array.isArray(node.children)) {
node.children.forEach(walk);
}
};
walk(newConfig);
return newConfig;
}
目录结构建议
src/
├── sdk/ # 核心 SDK (多项目共用)
│ ├── renderer/ # 递归渲染引擎
│ └── components/ # 基础物料库 (图表/表格/容器)
├── designer/ # 配置界面 (仅管理员可见)
│ ├── setters/ # 各类属性编辑器 (JSON Schema Form)
│ └── canvas/ # 拖拽画布/结构树
└── views/ # 各项目具体的业务页面 (引用 renderer)
九. 插件化架构
插件化架构的核心是将 SDK 从一个“巨型功能库”转变为一个“轻量级调度中心”。
在 SDK 内部,我们不再通过 if (config.xxx) 来堆砌逻辑,而是建立一套生命周期钩子(Hooks) ,将功能逻辑像“外挂”一样按需挂载。
插件化架构的三个维度
逻辑插件化:基于 Hook 的“功能注入”
不要在 WidgetLoader.vue 中写业务,它只负责调用。
// WidgetLoader.vue 核心逻辑
import { useDataFetcher } from './hooks/useDataFetcher';
import { useGlobalReactive } from './plugins/globalReactive';
import { useLocalInteraction } from './plugins/localInteraction';
export default {
setup(props) {
// 1. 核心功能:取数逻辑(每个组件必有)
const { data, refresh } = useDataFetcher(props.config);
// 2. 插件功能:全局响应(只有配置了 reactiveParams 才会真正生效)
// 逻辑在独立文件中,SDK 主逻辑不关心它是如何监听 date 的
useGlobalReactive(props.config, refresh);
// 3. 插件功能:组件联动(如点击 A 刷新 B)
useLocalInteraction(props.config, refresh);
return { data };
}
}
物料插件化:基于“组件映射表”的解耦
SDK 内部不 import 具体的业务图表,只维护一个 ComponentMap。
-
SDK 内部:只有一个
BaseChart.vue。 -
宿主项目:在
app.use(SDK, { components: { MySpecialChart } })时注入。 - 效果:如果 A 项目要一个“极其复杂的 3D 飞线图”,你直接在 A 项目里写,SDK 根本不需要知道它的存在。
协议插件化:自定义字段解析
允许 SDK 注册“协议处理器”。
// 比如你想增加一个“水印”功能,但不想改 SDK 源码
VisualSDK.use({
name: 'watermark-plugin',
// 当解析到包含 'watermark': true 的 JSON 节点时触发
onRender(node, el) {
if (node.watermark) {
applyWatermark(el);
}
}
});
为什么要这么做?
| 特性 | 传统模式 (硬编码) | 插件化模式 (解耦) |
|---|---|---|
| 功能隔离 | 改了日期联动,可能会把权限逻辑改坏。 | 日期联动逻辑在globalReactive.js,权限在auth.js,互不干扰。 |
| 代码体积 | 所有功能都打进包里,B 项目不需要也要加载。 | 可以实现 Tree-shaking,没用到的插件逻辑不打包。 |
| 多人协作 | 大家都在WidgetLoader里改代码,冲突不断。 |
每个人负责不同的 Hook 文件。 |
| 应对强势方 | “这个功能 SDK 不支持,我得改源码”。 | “我在项目里写个插件/组件注入进去就行了”。 |
💡 针对你的场景:如何落地插件化?
-
抽离 Hook:把 API 请求、全局状态监听、事件广播 分别写成
src/sdk/hooks下的独立文件。 -
定义 Contract(契约) :
-
useDataFetcher必须返回data和loading。 -
useGlobalReactive必须接受config和callback。
-
-
保持 Renderer 纯净:
RecursiveRenderer.vue里的代码不应超过 100 行,它只负责递归,不处理任何业务。
插件化架构是你对抗强势需求方的“盾牌”。
当他们提出奇葩需求时,你的回复从 “我要改 SDK 核心逻辑(风险大)” 变成了 “我给这个项目单独注入一个逻辑钩子(风险受控)” 。
十. 关键代码伪码实现
SDK拿到宿主项目提供的全局状态
如果 SDK 也要修改宿主的狀態?
可以在
app.use的配置項中再注入一個 Action 回調函數。
- SDK 内部使用
getGlobalField的组件实现
import { inject, computed } from 'vue';
// 定義一個 Hook 方便 SDK 內部組件獲取宿主狀態
export function useVisualContext() {
// 注入宿主提供的全局上下文
const context = inject('VISUAL_GLOBAL_CONTEXT', {});
// 提供輔助方法,確保數據獲取時有默認值,防止崩潰
const getGlobalField = (field, defaultValue = null) => {
return computed(() => {
// 宿主傳入的可能是個函數 (Getter),也可能是個響應式對象
const data = typeof context === 'function' ? context() : context;
return data[field] ?? defaultValue;
});
};
return { getGlobalField };
}
<!-- WidgetLoader.vue -->
<script setup>
import { watch, onMounted } from 'vue';
import { useVisualContext } from '../hooks/useVisualContext';
import { useVisualApi } from '../hooks/useVisualApi';
const props = defineProps(['config']); // 包含 sourceId 等信息
const { getGlobalField } = useVisualContext();
const { fetchData } = useVisualApi();
// 1. 获取响应式的全局 date 状态
// getGlobalField 返回的是一个 computedRef
const globalDate = getGlobalField('date', '2023-01-01');
// 2. 封装请求逻辑
const refreshData = async () => {
const params = {
date: globalDate.value, // 自动获取最新的 date 值
...props.config.params // 合并组件自身的参数
};
console.log(`🚀 正在为组件 ${props.config.id} 请求数据, 日期为: ${params.date}`);
const data = await fetchData(props.config.sourceId, params);
// ... 处理返回的数据并渲染图表
};
// 3. 监听 globalDate 的变化
// 当宿主项目中的 Pinia 状态改变时,这里会自动触发
watch(globalDate, (newDate, oldDate) => {
if (newDate !== oldDate) {
refreshData();
}
});
// 4. 初始化加载
onMounted(() => {
refreshData();
});
</script>
- 宿主项目(Host Project)的配合
// 宿主项目 main.js
import { useDateStore } from '@/stores/date';
import VisualSDK from '@company/visual-sdk';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(VisualSDK, {
globalContext: () => {
const dateStore = useDateStore();
const userStore = useUserStore();
return {
token: userStore.token,
deptId: userStore.userInfo.deptId,
role: userStore.role,
theme: userStore.currentTheme,
date: dateStore.currentDate // 返回 Pinia 中的日期
};
}
});
SDK 内部的“条件监听”实现
在 WidgetLoader.vue 中,利用 Vue 3 的 watch 动态判断是否需要执行刷新逻辑。
// WidgetLoader.vue
import { watch } from 'vue';
import { useVisualContext } from '../hooks/useVisualContext';
const props = defineProps(['config']);
const { getGlobalField } = useVisualContext();
// 1. 依然获取响应式的 globalDate
const globalDate = getGlobalField('date');
// 2. 监听逻辑
watch(globalDate, (newVal) => {
// 核心判断:检查当前组件配置中是否包含了 'date'
const reactiveParams = props.config.content.reactiveParams || [];
if (reactiveParams.includes('date')) {
console.log('A项目:监测到 date 变化,开始刷新数据...');
refreshData();
} else {
// B项目:虽然 globalDate 变了,但配置里没说要理它
console.log('B项目:监测到 date 变化,但配置要求忽略。');
}
});
实现一个“局部逃生”的动态组件加载器
動態組件分發 (WidgetLoader.vue)
<template>
<div class="widget-container">
<!-- 方案 A: 逃生通道 - 如果配置了 customComponent,直接渲染手寫組件 -->
<component
:is="customComponentInstance"
v-if="customComponentInstance"
v-bind="config.props"
:context="globalContext"
/>
<!-- 方案 B: 標準通道 - 走 ECharts/Table 等預設模板 -->
<StandardChartRender
v-else-if="config.type === 'chart'"
:config="config"
/>
<StandardTableRender
v-else-if="config.type === 'table'"
:config="config"
/>
</div>
</template>
<script setup>
import { computed, inject } from 'vue';
const props = defineProps(['config']);
// 注入宿主項目註冊的所有自定義組件表
const bizComponents = inject('BIZ_COMPONENTS', {});
const globalContext = inject('GLOBAL_CONTEXT', {});
const customComponentInstance = computed(() => {
const name = props.config.customComponent;
if (!name) return null;
// 從注入的組件庫中尋找,找不到則報錯提示
const comp = bizComponents[name];
if (!comp) {
console.error(`[SDK] 找不到逃生組件: ${name},請確認是否已在主項目註冊。`);
}
return comp;
});
</script>
宿主項目(業務層)如何「遞刀子」
在業務項目中,你寫好那個「逆反需求」的組件,然後在初始化 SDK 時傳進去。
步驟 1:寫一個手寫組件 UrgentRequirement.vue
<template>
<div class="my-crazy-css">
<h3>需求方非要的奇葩功能</h3>
<button @click="doSomethingCrazy">點擊執行逆反邏輯</button>
</div>
</template>
步驟 2:在 main.js 中註冊給 SDK
javascript
import UrgentRequirement from '@/biz-custom/UrgentRequirement.vue';
app.use(VisualSDK, {
// 這裡就是「逃生艙」名單
customComponents: {
UrgentRequirement // 鍵名與 JSON 中的 customComponent 對應
}
});
步骤3: JSON 配置如何調用
當遇到搞不定的需求,JSON 直接這麼寫:
{
"id": "widget_001",
"type": "custom",
"customComponent": "UrgentRequirement", // 指定逃生組件名
"props": {
"someData": "可以傳入自定義參數"
}
}
将 echarts 的 option 配置 生成 json schema
ECharts 官方提供了完善的 TypeScript 类型定义。你可以通过工具将 EChartsOption 类型直接转为 JSON Schema。
-
安装工具:
npm install typescript-json-schema -g -
创建入口文件 (
chart.ts):
import { EChartsOption } from 'echarts';
export interface MyChartOption extends EChartsOption {}
命令行生成:
typescript-json-schema chart.ts MyChartOption --out echarts-schema.json
布局组件中的局部状态如何实现?
实现 Tab 级别的局部状态管理,核心在于 “数据向上汇聚,状态向下分发” 。
你需要为每个 TabContainer 实例建立一个响应式的 context 对象,并通过 Vue 的 provide / inject 机制或 Props 穿透,让内部所有的控制组件(Filters)和内容组件(Charts/Tables)共享这个状态。
- 定义 Tab 内部状态结构
每个 Tab 页签维护一个独立的 state 对象,结构如下:
{
"activeTab": "overview",
"tabStates": {
"overview": { "region": "sh", "timeRange": [] }, // A Tab 的过滤参数
"detail": { "keyword": "" } // B Tab 的过滤参数
}
}
- 前端组件实现逻辑
第一步:在 TabContainer.vue 中建立状态中心
使用 reactive 初始化各 Tab 的默认参数。
<!-- TabContainer.vue -->
<script setup>
import { reactive, provide, watch } from 'vue';
const props = defineProps(['config']); // 传入的 JSON 配置
// 1. 初始化每个 Tab 的状态池
const tabFilterData = reactive({});
props.config.children.forEach(tab => {
tabFilterData[tab.tabKey] = {};
// 注入初始默认值
tab.controls?.forEach(ctrl => {
tabFilterData[tab.tabKey][ctrl.field] = ctrl.defaultValue || null;
});
});
// 2. 将当前活跃 Tab 的状态“提供”给下级所有组件
provide('tabContext', {
filterData: tabFilterData,
activeKey: props.activeKey
});
</script>
第二步:控制组件(Filters)修改状态
<!-- ControlRenderer.vue (过滤栏) -->
<template>
<component
:is="componentMap[ctrl.type]"
v-model="tabContext.filterData[activeKey][ctrl.field]"
@change="onFilterChange"
/>
</template>
<script setup>
const { filterData, activeKey } = inject('tabContext');
</script>
第三步:内容组件(Charts)监听状态并刷新
图表组件深层注入这个状态,一旦发现属于自己 Tab 的参数变了,自动触发接口请求。
<!-- WidgetLoader.vue (内容加载器) -->
<script setup>
const { filterData, activeKey } = inject('tabContext');
const props = defineProps(['config']); // 包含 sourceId
// 监听属于当前 Tab 的过滤数据变化
watch(
() => filterData[activeKey],
(newParams) => {
// 重新组合参数并发起请求
loadData(props.config.sourceId, newParams);
},
{ deep: true }
);
</script>
状态管理的三个关键细节
- 状态隔离(Namespace) 🔒
- 问题:A Tab 的日期筛选不应该影响 B Tab 的图表。
-
解决:在
tabFilterData中以tabKey作为命名空间。只有当用户切换回该 Tab 时,对应的状态才生效。
- 参数合并优先级 ⚡
渲染引擎在发起 API 请求前,会按以下顺序合并参数:
-
静态参数:数据源配置里写死的
url?type=1。 -
Tab 局部参数:控制组件选中的
region=sh。 - 全局参数:如用户信息、当前选中的部门 ID。
-
代码实现:
const finalParams = { ...staticParams, ...globalParams, ...tabParams };
- 强势方的特殊需求:联动全局
- 需求:在 A Tab 选了日期,希望切换到 B Tab 时日期也是选好的。
-
解决:在 JSON 配置中增加一个标识
syncToGlobal: true。如果有此标识,状态变更时同步写入 Pinia 的全局状态池,其他 Tab 初始化时优先读取全局值。