Elpis DSL:用一份配置驱动整个后台系统

35 阅读9分钟

这是 Elpis 框架系列的第三篇。前两篇分别拆解了服务端框架内核和 Webpack 构建体系,这一篇进入核心主题——如何用一套 DSL(领域特定语言)配置,同时驱动导航菜单、路由跳转、页面渲染、API 请求和数据校验。


一、要解决什么问题

后台管理系统有一个特点:80% 的页面结构是重复的

商品管理、订单管理、客户管理——每个模块都是"左边菜单 + 右边表格 + 搜索栏 + 表单弹窗"。但传统开发方式下,每个模块都要单独写路由、写页面组件、写 API 对接、写表格列定义、写搜索控件。

更麻烦的是多租户场景。同一套电商系统,淘宝、拼多多、京东各有一套后台,80% 相同,20% 不同。复制三份代码维护成本很高,改一个公共逻辑要同步三份。

Elpis DSL 的思路是:把这些重复的结构抽象成配置,让配置驱动一切


二、配置长什么样

先看一个完整的配置节点,这是"商品管理"模块的定义:

{
  key: "product",
  name: "商品管理",
  menuType: "module",
  moduleType: "schema",
  schemaConfig: {
    api: "/api/proj/product",
    schema: {
      type: "object",
      properties: {
        product_id: {
          type: "string",
          label: "商品ID",
          tableOption: { width: 300, "show-overflow-tooltip": true },
        },
        product_name: {
          type: "string",
          label: "商品名称",
          tableOption: { width: 200 },
          searchOption: { comType: "dynamicSelect", api: "/api/proj/product_enum/list" },
        },
        price: {
          type: "number",
          label: "价格",
          tableOption: { width: 200 },
          searchOption: {
            comType: "select",
            enumList: [
              { label: "全部", value: -1 },
              { label: "¥39.9", value: 39.9 },
            ],
          },
        },
        create_time: {
          type: "string",
          label: "创建时间",
          tableOption: {},
          searchOption: { comType: "dateRange" },
        },
      },
    },
  },
}

这份配置只描述了"是什么":这个模块叫商品管理,它有哪些字段,每个字段是什么类型、在表格里多宽、在搜索栏里用什么控件。它没有说"怎么渲染"——用什么组件、怎么布局、怎么发请求,这些都是框架的事。


三、一次定义,多端投影

一个字段在系统中会出现在多个地方:表格里是一列,搜索栏里是一个控件,表单里是一个输入框。传统做法在每个地方单独定义,信息分散在五六处。

Elpis 的做法是:一个字段定义一次,通过 Option 后缀向不同方向投影

graph TD
    A["字段定义<br/>product_name: { type, label }"] --> B["tableOption<br/>→ 表格列"]
    A --> C["searchOption<br/>→ 搜索栏控件"]
    A --> D["createFormOption<br/>→ 新增表单控件"]
    A --> E["editFormOption<br/>→ 编辑表单控件"]
    A --> F["detailPanelOption<br/>→ 详情面板"]

    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#f3e5f5,stroke:#6a1b9a
    style E fill:#f3e5f5,stroke:#6a1b9a
    style F fill:#efebe9,stroke:#4e342e

product_name 为例:

product_name: {
  type: "string",
  label: "商品名称",
  tableOption:      { width: 200 },                                    // → 表格中宽 200px 的一列
  searchOption:     { comType: "dynamicSelect", api: "/api/..." },     // → 搜索栏中一个动态下拉框
  createFormOption: { comType: "input" },                              // → 新增表单中一个输入框
  editFormOption:   { comType: "input" },                              // → 编辑表单中一个输入框
  detailPanelOption: {},                                               // → 详情面板中展示
}

框架内部有一个 buildDtoSchema 方法,负责从完整配置中提取某个方向的投影:

// hook/schema.js
const buildDtoSchema = (_schema, comName) => {
  const dtoSchema = { type: "object", properties: {} };

  for (const key in _schema.properties) {
    const props = _schema.properties[key];
    // 只提取有对应 Option 的字段
    if (props[`${comName}Option`]) {
      let dtoProps = {};
      // 提取非 Option 的公共属性(type、label 等)
      for (const pKey in props) {
        if (pKey.indexOf("Option") < 0) {
          dtoProps[pKey] = props[pKey];
        }
      }
      // 把对应的 Option 提取为 option 字段
      dtoProps.option = props[`${comName}Option`];
      dtoSchema.properties[key] = dtoProps;
    }
  }
  return dtoSchema;
};

调用 buildDtoSchema(schema, "table") 就得到表格需要的 schema,调用 buildDtoSchema(schema, "search") 就得到搜索栏需要的 schema。同一份原始配置,按需提取,不同组件各取所需。


四、配置节点类型:一个节点同时承载三种职责

后台系统有层级(商品管理下面有列表、表单)、有路由(点击菜单跳转页面)、有渲染类型(有些是表格,有些是嵌入外部页面)。传统做法用菜单表达层级、用路由配置表达页面、用业务代码表达渲染类型——三套体系要手动对齐。

Elpis 用一个配置节点统一承载这三种职责。节点有两个关键属性:menuTypemoduleType

graph TD
    A["配置节点"] --> B{"menuType"}
    B -->|group| C["分组节点<br/>只组织层级,不对应功能<br/>包含 subMenu 子节点"]
    B -->|module| D{"moduleType"}
    D -->|schema| E["Schema 模块<br/>配置驱动的 CRUD 页面<br/>表格 + 搜索 + 表单"]
    D -->|iframe| F["Iframe 模块<br/>嵌入外部页面<br/>零开发"]
    D -->|sider| G["侧边栏模块<br/>左侧菜单 + 右侧内容<br/>内部可继续嵌套"]
    D -->|custom| H["自定义模块<br/>代码逃生舱<br/>指向自定义 Vue 组件"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style E fill:#e3f2fd,stroke:#1565c0
    style F fill:#fff3e0,stroke:#f57c00
    style G fill:#f3e5f5,stroke:#6a1b9a
    style H fill:#efebe9,stroke:#4e342e

看一个实际的配置树:

// model/business/project/jd.js — 京东项目配置
menu: [
  {
    key: "shop-setting",
    name: "店铺设置",
    menuType: "group", // 分组,只有层级,没有页面
    subMenu: [
      {
        key: "info-setting",
        name: "店铺信息设置",
        menuType: "module",
        moduleType: "custom", // 自定义组件
        customConfig: { path: "/todo" },
      },
      {
        key: "quality-setting",
        name: "店铺资质",
        menuType: "group", // group 可以嵌套 group
        subMenu: [
          {
            key: "category-2",
            name: "二级分类",
            menuType: "module",
            moduleType: "iframe", // 嵌入外部页面
            iframeConfig: { path: "https://www.doubao.com" },
          },
        ],
      },
    ],
  },
];

这棵配置树直接映射为三个东西:

  1. 导航菜单的层级结构(group 渲染为子菜单)
  2. 路由跳转的目标路径(moduleType 决定跳转到 /schema/iframe/sider 还是自定义路径)
  3. 页面的渲染方式(schema 渲染表格+搜索+表单,iframe 渲染嵌入页,sider 渲染侧边栏布局)

改结构只需要改配置,不需要动代码。


五、容器与内容分离

页面布局和业务内容是两个不同的维度。Elpis 把它们分开:布局是稳定的骨架,内容是流动的业务,插槽是连接方式。

graph TD
    A["header-container<br/>顶部导航骨架"] --> B["#menu-content 插槽<br/>放菜单"]
    A --> C["#setting-content 插槽<br/>放项目切换"]
    A --> D["#main-content 插槽<br/>放主体内容"]
    D --> E["router-view<br/>根据路由渲染不同内容"]
    E -->|"/schema"| F["schema-view<br/>表格 + 搜索 + 表单"]
    E -->|"/iframe"| G["iframe-view<br/>嵌入外部页面"]
    E -->|"/sider"| H["sider-view<br/>侧边栏布局"]
    H --> I["sider-container<br/>侧边栏骨架"]
    I --> J["#menu-content 插槽<br/>放侧边菜单"]
    I --> K["#main-content 插槽<br/>放 router-view"]
    K --> L["继续嵌套<br/>schema / iframe / custom"]

    style A fill:#fff3e0,stroke:#f57c00
    style H fill:#f3e5f5,stroke:#6a1b9a
    style I fill:#f3e5f5,stroke:#6a1b9a
    style F fill:#e3f2fd,stroke:#1565c0
    style G fill:#e8f5e9,stroke:#2e7d32

项目中有两个容器组件:

header-container:顶部导航 + 主体内容区,暴露三个插槽(菜单、设置、主内容)。

sider-container:左侧菜单 + 右侧内容区,暴露两个插槽(菜单、主内容)。

<!-- sider-container.vue -->
<el-container class="sider-container">
  <el-aside width="200px">
    <slot name="menu-content" />
  </el-aside>
  <el-main>
    <slot name="main-content" />
  </el-main>
</el-container>

容器不关心插槽里放什么。header-container#main-content 里可以放 router-viewsider-view#main-content 里也可以放 router-view。容器可以嵌套容器——顶部导航里嵌套侧边栏,侧边栏里再嵌套具体内容。

这样改布局不需要动业务代码,改业务内容也不需要动布局。


六、路由是怎么工作的

配置中的 moduleType 决定了点击菜单后跳转到哪个路由,路由决定了渲染哪个 Vue 组件。

graph LR
    A["点击菜单项"] --> B{"moduleType"}
    B -->|schema| C["跳转 /view/dashboard/schema"]
    B -->|iframe| D["跳转 /view/dashboard/iframe"]
    B -->|sider| E["跳转 /view/dashboard/sider"]
    B -->|custom| F["跳转 customConfig.path"]

    C --> G["schema-view.vue"]
    D --> H["iframe-view.vue"]
    E --> I["sider-view.vue"]

    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#f3e5f5,stroke:#6a1b9a
    style F fill:#efebe9,stroke:#4e342e

菜单点击时的路由跳转逻辑:

// dashboard.vue
const onMenuSelect = (menuItem) => {
  const { moduleType, key, customConfig } = menuItem;
  const pathMap = {
    sider: "/sider",
    iframe: "/iframe",
    schema: "/schema",
    custom: customConfig?.path,
  };
  router.push({
    path: `/view/dashboard${pathMap[moduleType]}`,
    query: { key, proj_key: route.query.proj_key },
  });
};

路由定义中,sider 类型支持嵌套子路由——侧边栏内部的内容区也是一个 router-view,可以继续渲染 schema、iframe 或 custom:

// entry.dashboard.js
routes.push({
  path: "/view/dashboard/sider",
  component: () => import("./complex-view/sider-view/sider-view.vue"),
  children: [
    {
      path: "iframe",
      component: () => import("./complex-view/iframe-view/iframe-view.vue"),
    },
    {
      path: "schema",
      component: () => import("./complex-view/schema-view/schema-view.vue"),
    },
    { path: "todo", component: () => import("./todo/todo.vue") },
  ],
});

URL 中通过 keyproj_key 两个 query 参数定位当前模块和当前项目。sider 类型还有一个 sider_key 参数,定位侧边栏中选中的子模块。


七、Schema View:配置如何变成页面

schema-view 是整个 DSL 的核心渲染器。它接收配置,输出一个完整的 CRUD 页面。

graph TD
    A["schema-view 接收配置"] --> B["useSchema Hook<br/>从 menuStore 中找到当前模块配置"]
    B --> C["buildDtoSchema(schema, 'table')<br/>提取表格 schema"]
    B --> D["buildDtoSchema(schema, 'search')<br/>提取搜索 schema"]
    B --> E["提取 tableConfig<br/>按钮配置"]
    B --> F["提取 componentConfig<br/>动态组件配置"]

    C --> G["table-panel<br/>渲染表格"]
    D --> H["search-panel<br/>渲染搜索栏"]
    E --> G
    F --> I["动态组件<br/>表单 / 详情面板"]

    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#f3e5f5,stroke:#6a1b9a
    style G fill:#e3f2fd,stroke:#1565c0
    style H fill:#e8f5e9,stroke:#2e7d32
    style I fill:#fce4ec,stroke:#c62828

useSchema 是一个 Vue Composable Hook,它做的事情是:

  1. 监听路由变化(route.query.key 改变说明用户切换了模块)
  2. menuStore 中根据 key 找到对应的配置节点
  3. 调用 buildDtoSchema 分别提取 table、search、form 等方向的 schema
  4. 通过 provide 把处理后的数据注入给子组件
// schema-view.vue
const { api, tableConfig, tableSchema, searchSchema, components } = useSchema();

const apiParams = ref({});
provide("schemaViewData", {
  api,
  apiParams,
  tableSchema,
  tableConfig,
  searchSchema,
  components,
});

子组件通过 inject 获取数据,各自渲染自己的部分。搜索栏搜索后更新 apiParams,表格组件监听到 apiParams 变化后重新请求数据。


八、搜索栏:动态组件渲染

搜索栏的控件类型由配置中的 searchOption.comType 决定。框架维护了一个控件类型到 Vue 组件的映射表:

// search-item-config.js
const SearchItemConfig = {
  input: { component: Input },
  select: { component: Select },
  dynamicSelect: { component: DynamicSelect },
  dateRange: { component: DateRange },
};

搜索栏组件遍历 schema 的 properties,根据每个字段的 comType 动态渲染对应的控件:

<!-- schema-search-bar.vue -->
<el-form-item
  v-for="(schemaItem, key) in schema.properties"
  :key="key"
  :label="schemaItem.label"
>
  <component
    :is="SearchItemConfig[schemaItem.option?.comType]?.component"
    :schemaKey="key"
    :schema="schemaItem"
  />
</el-form-item>

Vue 的 <component :is> 是动态组件的关键——它根据传入的组件对象决定渲染哪个组件。配置里写 comType: "dynamicSelect",框架就渲染 DynamicSelect 组件。

每个控件组件都暴露统一的接口:getValue() 获取当前值,reset() 重置。搜索栏在点击"搜索"时遍历所有控件调用 getValue(),合并成一个查询参数对象。

const getValue = () => {
  let dtoObject = {};
  searchComList.value.forEach((component) => {
    dtoObject = { ...dtoObject, ...component?.getValue() };
  });
  return dtoObject;
};

dynamicSelect 控件比较特殊——它的选项列表不是写死在配置里的,而是通过 api 字段指定一个接口,组件挂载时自动请求接口获取选项:

// dynamic-select.vue
const fetchEnumList = async () => {
  const res = await $curl({ method: "get", url: schema.option?.api });
  if (res?.data?.length > 0) {
    enumList.value.push(...res.data);
  }
};
onMounted(async () => {
  await fetchEnumList();
  reset();
});

九、表格:配置驱动列渲染和操作按钮

schema-table 组件接收 table schema 和按钮配置,渲染表格。

表格列由 schema 的 properties 驱动:

<template v-for="(schemaItem, key) in schema.properties">
  <el-table-column
    v-if="schemaItem.option.visible !== false"
    :prop="key"
    :label="schemaItem.label"
    v-bind="schemaItem.option"
  />
</template>

v-bind="schemaItem.option"tableOption 中的所有配置(width、show-overflow-tooltip 等)直接透传给 el-table-column。配置里写什么属性,ElementPlus 的表格列就接收什么属性。visible: false 的字段不渲染。

操作按钮(新增、修改、删除)由 tableConfig 中的 headerButtonsrowButtons 驱动:

tableConfig: {
  headerButtons: [
    { label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } },
  ],
  rowButtons: [
    { label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
    { label: "删除", eventKey: "remove", eventOption: { params: { product_id: "schema::product_id" } } },
  ],
}

按钮不直接绑定处理函数,而是声明一个 eventKey。点击按钮时,schema-view 根据 eventKey 查找对应的处理器:

const EventHandleMap = {
  showComponent, // 展示动态组件(表单、详情面板)
};

const onTableOperate = ({ buttonConfig, rowData }) => {
  const { eventKey } = buttonConfig;
  EventHandleMap[eventKey]?.({ buttonConfig, rowData });
};

配置只说"点击这个按钮要做什么事"(eventKey),不说"怎么做"。具体的处理逻辑在框架内置的事件处理器中。


十、Model → Project 继承

多租户场景下,不同项目之间大部分配置相同。Elpis 用 Model + Project 的继承体系解决这个问题。

graph TD
    A["Model 基础模型<br/>model/business/model.js<br/>商品管理 + 订单管理 + 客户管理"] --> B["Project 淘宝<br/>project/taobao.js<br/>只写差异"]
    A --> C["Project 拼多多<br/>project/pdd.js<br/>只写差异"]
    A --> D["Project 京东<br/>project/jd.js<br/>只写差异"]

    B --> E["合并结果<br/>Model 配置 + 淘宝差异"]
    C --> F["合并结果<br/>Model 配置 + 拼多多差异"]
    D --> G["合并结果<br/>Model 配置 + 京东差异"]

    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fce4ec,stroke:#c62828

Model 定义基础配置(电商系统通用的商品管理、订单管理、客户管理),Project 只写和 Model 不同的部分。

合并规则在 model/index.jsprojectExtendModel 方法中实现:

const projectExtendModel = (model, proj) => {
  return _.mergeWith({}, model, proj, (modelValue, projValue) => {
    if (Array.isArray(modelValue) && Array.isArray(projValue)) {
      let result = [];
      // 同 key → 递归合并(修改/重载)
      for (const modelItem of modelValue) {
        const projItem = projValue.find((p) => p.key === modelItem.key);
        result.push(
          projItem ? projectExtendModel(modelItem, projItem) : modelItem,
        );
      }
      // Project 独有 → 追加(扩展)
      for (const projItem of projValue) {
        if (!modelValue.find((m) => m.key === projItem.key)) {
          result.push(projItem);
        }
      }
      return result;
    }
    // 非数组字段:lodash mergeWith 默认深度合并
  });
};

三条规则:

  • Model 有、Project 也有(同 key)→ 递归合并,Project 覆盖 Model
  • Model 有、Project 没有 → 保留 Model 的(继承)
  • Model 没有、Project 有 → 追加 Project 的(扩展)

举个例子。Model 定义了商品管理、订单管理、客户管理。拼多多的 Project 配置:

// model/business/project/pdd.js
menu: [
  {
    key: "product",          // 同 key → 和 Model 的商品管理合并
    name: "拼多多商品管理",   // 覆盖名称
  },
  {
    key: "data-analysis",    // 新 key → 追加
    name: "数据分析",
    moduleType: "sider",
    siderConfig: { menu: [...] },
  },
]

合并后拼多多拥有:拼多多商品管理(改了名字)+ 订单管理(继承)+ 客户管理(继承)+ 数据分析(新增)。

Project 只存储"和 Model 的差异",类似 Git 存储 diff 而不是快照。


十一、服务端如何配合

配置不只驱动前端,也驱动服务端。

graph TD
    A["浏览器访问<br/>/view/dashboard?proj_key=pdd"] --> B["Koa Router<br/>匹配 /view/:page"]
    B --> C["ViewController<br/>渲染 entry.dashboard.tpl<br/>注入 projKey"]
    C --> D["前端 Vue 应用启动"]
    D --> E["请求 /api/project?proj_key=pdd<br/>获取项目配置(含 menu 树)"]
    E --> F["menuStore 缓存配置"]
    F --> G["header-view 渲染菜单"]
    G --> H["用户点击菜单"]
    H --> I["路由跳转 + schema-view 渲染"]
    I --> J["请求 /api/proj/product/list<br/>获取表格数据"]

    style A fill:#e8f5e9,stroke:#2e7d32
    style E fill:#e3f2fd,stroke:#1565c0
    style J fill:#e3f2fd,stroke:#1565c0

服务端做了几件事:

1. 配置加载与合并

model/index.js 在服务启动时扫描 model/ 目录,加载所有 Model 和 Project 配置,执行继承合并,生成最终的配置数据。

2. 项目配置 API

// app/controller/project.js
async get(ctx) {
  const { proj_key: projKey } = ctx.request.query;
  const projectConfig = await ProjectService.get({ projKey });
  this.success(ctx, projectConfig);  // 返回完整的项目配置(含 menu 树)
}

前端通过 /api/project?proj_key=pdd 获取拼多多的完整配置,包括合并后的 menu 树。

3. 业务 API 的项目隔离

业务 API(/api/proj/ 前缀)通过 project-handler 中间件注入 projKey

// app/middleware/project-handler.js
module.exports = (app) => {
  return async (ctx, next) => {
    if (ctx.path.indexOf("/api/proj/") < 0) return next();

    const { proj_key: projKey } = ctx.request.headers;
    if (!projKey) {
      ctx.body = { success: false, message: "proj_key not found", code: 446 };
      return;
    }
    ctx.projKey = projKey; // 注入到 ctx,Controller 中可以直接使用
    return next();
  };
};

Controller 中通过 ctx.projKey 区分不同项目的数据:

// app/controller/business.js
getList(ctx) {
  let productList = [
    { product_id: 1, product_name: `${ctx.projKey} -- xx`, price: 39.9 },
    // ...
  ];
  this.success(ctx, productList);
}

前端的 curl.js 在请求业务 API 时自动把 proj_key 放到 headers 中,整个链路自动完成项目隔离。


十二、Pinia Store:前端状态管理

前端用两个 Pinia Store 管理全局状态:

menuStore:缓存当前项目的菜单配置,提供菜单查找方法。

// store/menu.js
const findMenuItem = function ({ key, value }, mList = menuList.value) {
  for (const menuItem of mList) {
    if (menuItem[key] === value) return menuItem;
    // 递归搜索 group 的 subMenu
    if (menuItem.menuType === "group" && menuItem.subMenu) {
      const found = findMenuItem({ key, value }, menuItem.subMenu);
      if (found) return found;
    }
    // 递归搜索 sider 的 menu
    if (menuItem.moduleType === "sider" && menuItem.siderConfig?.menu) {
      const found = findMenuItem({ key, value }, menuItem.siderConfig.menu);
      if (found) return found;
    }
  }
};

这个递归查找方法支持在任意深度的嵌套菜单中定位节点。schema-viewsider-viewiframe-view 都通过它根据 URL 中的 key 参数找到对应的配置节点。

projectStore:缓存同模型下的项目列表,用于顶部的项目切换下拉框。


十三、完整数据流

从用户进入系统到看到一个 CRUD 页面,完整链路:

sequenceDiagram
    participant 用户
    participant Koa as Koa 服务端
    participant Vue as Vue 前端

    用户->>Koa: 访问 /view/dashboard?proj_key=pdd
    Koa->>Koa: 渲染 entry.dashboard.tpl,注入 projKey
    Koa-->>用户: 返回 HTML
    用户->>Vue: 浏览器加载 JS,Vue 应用启动

    Vue->>Koa: GET /api/project?proj_key=pdd
    Koa->>Koa: 从 modelList 中查找 pdd 的合并配置
    Koa-->>Vue: 返回 { name, menu: [...] }

    Vue->>Vue: menuStore 缓存 menu 配置
    Vue->>Vue: header-view 根据 menu 渲染顶部菜单

    用户->>Vue: 点击"商品管理"
    Vue->>Vue: 路由跳转 /schema?key=product&proj_key=pdd
    Vue->>Vue: useSchema 从 menuStore 找到 product 配置
    Vue->>Vue: buildDtoSchema 提取 tableSchema、searchSchema
    Vue->>Vue: 渲染搜索栏 + 表格

    Vue->>Koa: GET /api/proj/product/list (headers: proj_key=pdd)
    Koa->>Koa: projectHandler 中间件注入 ctx.projKey
    Koa->>Koa: BusinessController 返回数据
    Koa-->>Vue: 返回 { success, data: [...] }
    Vue->>Vue: 表格填充数据

十四、边界在哪里

配置驱动不是万能的。Elpis 明确了什么该配置、什么不该配置:

该配置的不该配置的原因
字段类型、名称交互流程交互是事件处理器的事,配置只声明 eventKey
表格列宽颜色、间距列宽是业务决策,颜色间距是主题系统的事
搜索控件类型控件内部逻辑控件类型可枚举,内部逻辑由组件实现
模块层级关系复杂业务流程层级是树形结构,复杂流程需要代码

对于配置覆盖不了的场景,moduleType: "custom" 是逃生舱——指向一个自定义 Vue 组件,用代码实现任何逻辑。覆盖 80% 的通用场景,为 20% 保留代码出口。