阅读视图

发现新文章,点击刷新页面。

核心 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 通用渲染模板:接收 optiondata

任务 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 必须返回 dataloading
    • useGlobalReactive 必须接受 configcallback
  • 保持 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。

  1. 安装工具npm install typescript-json-schema -g
  2. 创建入口文件 (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)共享这个状态。

  1. 定义 Tab 内部状态结构

每个 Tab 页签维护一个独立的 state 对象,结构如下:

{
  "activeTab": "overview",
  "tabStates": {
    "overview": { "region": "sh", "timeRange": [] }, // A Tab 的过滤参数
    "detail": { "keyword": "" }                      // B Tab 的过滤参数
  }
}
  1. 前端组件实现逻辑

第一步:在 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>

状态管理的三个关键细节

  1. 状态隔离(Namespace) 🔒
  • 问题:A Tab 的日期筛选不应该影响 B Tab 的图表。
  • 解决:在 tabFilterData 中以 tabKey 作为命名空间。只有当用户切换回该 Tab 时,对应的状态才生效。
  1. 参数合并优先级 ⚡

渲染引擎在发起 API 请求前,会按以下顺序合并参数:

  1. 静态参数:数据源配置里写死的 url?type=1
  2. Tab 局部参数:控制组件选中的 region=sh
  3. 全局参数:如用户信息、当前选中的部门 ID。
  • 代码实现const finalParams = { ...staticParams, ...globalParams, ...tabParams };
  1. 强势方的特殊需求:联动全局
  • 需求:在 A Tab 选了日期,希望切换到 B Tab 时日期也是选好的。
  • 解决:在 JSON 配置中增加一个标识 syncToGlobal: true。如果有此标识,状态变更时同步写入 Pinia 的全局状态池,其他 Tab 初始化时优先读取全局值。
❌