一. 架构目标
构建一个渲染引擎,通过解析 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'] |
示例:
json
{
"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}}"
}
}
}
}
json
{
"type": "datasouce",
"key": "datasource-1",
"url": "",
"params":[
{
"field":"x",
"to":"replaceTextMap",
"value": ""
}
],
"templateKey":"chart-01"
}
json
{
"type": "layout",
"comp": "NineGridLayout",
"deptId": "",
"children": [
{
"id":"",
"title":"xxx指标",
"controls":[
{
"comp":"FilterSelect",
"type": "filter",
"field": "name",
"props":[
]
}
],
"datasourceKey": "data-source-1"
}
]
}
三. 渲染逻辑实现 (Recursive Renderer)
1. 递归分发器 (VisualRenderer.vue)
这是 SDK 的入口。
- 逻辑 :根据
type判断。如果是container,渲染布局组件并把children传给它;如果是widget,渲染加载器。 - 伪代码实现:
xml
<component :is="config.type === 'container' ? LayoutResolver : WidgetLoader" :config="config" />
2. 布局解析器 (LayoutResolver.vue)
- 职责 :实现
FlexLayout,GridLayout,TabContainer。 - 逻辑 :如果是
TabContainer,需维护一个activeKey。每个布局组件内部必须包含:
xml
<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 的唯一出口。它不负责具体的渲染,只负责根据版本号进行路由分发。
html
<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>
js
/**
* 将 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;
}
目录结构建议
html
src/
├── sdk/ # 核心 SDK (多项目共用)
│ ├── renderer/ # 递归渲染引擎
│ └── components/ # 基础物料库 (图表/表格/容器)
├── designer/ # 配置界面 (仅管理员可见)
│ ├── setters/ # 各类属性编辑器 (JSON Schema Form)
│ └── canvas/ # 拖拽画布/结构树
└── views/ # 各项目具体的业务页面 (引用 renderer)
九. 插件化架构
插件化架构的核心是将 SDK 从一个"巨型功能库 "转变为一个"轻量级调度中心"。
在 SDK 内部,我们不再通过 if (config.xxx) 来堆砌逻辑,而是建立一套生命周期钩子(Hooks) ,将功能逻辑像"外挂"一样按需挂载。
插件化架构的三个维度
逻辑插件化:基于 Hook 的"功能注入"
不要在 WidgetLoader.vue 中写业务,它只负责调用。
javascript
// 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 注册"协议处理器"。
javascript
// 比如你想增加一个"水印"功能,但不想改 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的组件实现
js
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 };
}
js
<!-- 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)的配合
js
// 宿主项目 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 动态判断是否需要执行刷新逻辑。
js
// 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)
html
<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
html
<template>
<div class="my-crazy-css">
<h3>需求方非要的奇葩功能</h3>
<button @click="doSomethingCrazy">點擊執行逆反邏輯</button>
</div>
</template>
步驟 2:在 main.js 中註冊給 SDK
javascript
js
import UrgentRequirement from '@/biz-custom/UrgentRequirement.vue';
app.use(VisualSDK, {
// 這裡就是「逃生艙」名單
customComponents: {
UrgentRequirement // 鍵名與 JSON 中的 customComponent 對應
}
});
步骤3: JSON 配置如何調用
當遇到搞不定的需求,JSON 直接這麼寫:
json
{
"id": "widget_001",
"type": "custom",
"customComponent": "UrgentRequirement", // 指定逃生組件名
"props": {
"someData": "可以傳入自定義參數"
}
}