[study] DSL 方案实现数据化快速搭建不同领域后台

429 阅读7分钟

前言

声明:仅代表个人观点,如有错误请指出,不要喷我,不然我会玉玉的 TvT,纯分享,如有不适请立马退出

在前后端的小伙伴当中,大多数都应该接触过管理后台,而这些管理后台又高度相似,于似乎每天重复性的 “忙” ,忙的莫名其妙, el-table、el-input... 各种组件重复写重复用,关键还是高度相似,也就区别一些重要的 key 等,那么这些东西能否沉淀出来呢?答案当然是肯定的,以 电商领域(各种购物平台) 和 视频领域(各种视频平台) 来举例, 如果让你来创建这个后台是否就会区分以下这些模块呢

电商一: 商品管理、订单管理、客户管理
电商二: 商品管理、订单管理、运营活动

抽离成
电商:商品管理、订单管理
电商一: (...电商)、客户管理
电商二:(...电商)、运营活动

当然不仅仅这些模块可以更多的去进行拓展,单独只有某个平台拥有的模块写入某个平台,而公共的的模块可以进行抽离,通过这样大模块然后去继承,就可以构建

  1. 电商领域(buiness) 通过继承这份模块也可以有(buiness_child_1、 buiness_child_2 ...)
  2. 视频领域(video) 通过继承这份模块也可以有(video_child_1、 video_child_2 ...)
  3. ...

这就是 DSL (Domain-Specific Language 领域特定语言) 的雏型,通过对某个领域的后台进行一份总体的配置抽离出来,后续还需要构建该领域的其他平台的后台即可直接继承去进行继承和重写便可完成,可能我的表述不是很清晰,先来看看代码实现吧。

DSL 配置

这里使用的方案是根据 json-schema 规范去进行该框架的拓展从而构造出对应的配置(仅描述部分核心思路)

module.exports = {
 mode: '', // 模板类型
 homePage: '' // 项目首页
 // 模版对应配置
 menu[{ // 头部菜单列表 可以若干个菜单
     key: '' // 菜单唯一值
     name: '' // 菜单名称
     menuType: '' // 菜单的类型 group / module
     
     // menuType === group 时 (下拉展示子菜单)
     subMenu[{
         // 同 menu 规则理论上可无限递归
     }, ...],
     
     // menuType === module 时
     modeulType: '', // 模块类型 sider(侧边栏) / iframe(内嵌 web 页面) / custom(自定义页面) / schema(模板配置页)
     
    // moduleType === sider 时 
    siderConfig: [{
        // 同 menu 配置 只是不允许再配置 sider 因为不会出现两个侧边栏
    }],
    
    // moduleType === iframe 时
    iframeConfig: {
        path: '', // 第三方页面路径
    },
    
    // moduleType === custom 时
    iframeConfig: {
        path: '', // 自定义页面路径配置
    },
 }, ...]
}

这是一些基础的配置,通过解析器解析配置分配对应的路由即可快速实现,重点来说说 schema 一个小功能(表格)的实现思路

当 moduleType === schema 时候
schemaConfig: {
    api: 'xxx' // 数据源的 API 且该 API 必须遵循 RESTFUL 规范
    schema: { // 板块数据结构 jsonSchema 规范 + 部分 ui DSL 自定义字段
        type: 'object',
        properties: { // 表格字段 表格的key 直接取对象的 key:value 的 key 值
            key: {
                type: '' // 字段类型 string / number ...
                label: '' // 表格中文名,
                tableOption: { // 列表的配置
                    ...elTableColumConfig, // 标准的 el-table-colum 配置(毕竟还是基于 elment-ui 去实现)
                    ...各种自定义拓展
                }
            },
            ...
        }
    }
}
// table 相关配置
*     tableConfig: {
*       headerButton: [{
*          label: '', // 按钮中文名
*          eventKey: '', // 按钮事件名称
*          eventOption: {}, // 按钮事件具体配置
*          ...elButtonConfig, // 标准的 el-button 配置
*       }, ...],
*       rowButtons: [{
*          label: '' // 按钮中文名
*          eventKey: '' // 按钮事件名
*          eventOption: { // 按钮事件具体配置
*            // 当 eventKey === 'remove' 将来可以无限拓展
*            params: {
*              // paramKey === 参数的键值
*              // rowValueKey === 参数的值 (当格式为 schema::tableKey 的时候, 到 table 中找到对应的值)
*              paramKey: rowValueKey
*            }
*          },
*          ...elButtonConfig, // 标准的 el-button 配置
*       }, ...]
*     },
// 方便观看我直接抽组件的核心代码去分享
<template>
  <div class="schema-table">
    <el-table
      v-if="schema && schema.properties"
      v-loading="loading"
      :data="tableData"
      class="table"
    >
      <template v-for="(schemaItem, key) in schema.properties">
        <el-table-column
          v-if="schemaItem.option.visibility !== false"
          :key="key"
          :prop="key"
          :label="schemaItem.label"
          v-bind="schemaItem.option"
        ></el-table-column>
      </template>
      <el-table-column
        v-if="buttons?.length > 0"
        label="操作"
        fixed="right"
        :width="operationWidth"
      >
        <template #default="scoped">
          <el-button
            v-for="item in buttons"
            link
            v-bind="item"
            @click="operationHandle({ btnConfig: item, rowData: scoped.row })"
          >
            {{ item.label }}
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    <el-row class="pagination" justify="end">
      <el-pagination
        :current-page="currentPage"
        :page-size="pageSize"
        :page-sizes="[10, 20, 50, 100, 200]"
        layout="total, sizes, prev, pager, next, jumper"
        :total="total"
        @size-change="onPageSizeChange"
        @current-change="onPageCurrentChange"
      />
    </el-row>
  </div>
</template>

<script setup>
import { ref, toRefs, computed, watch, nextTick, onMounted } from 'vue';
import $curl from '$common/curl.js';

const props = defineProps({
  /**
   * schema 配置, 结构如下
   * {
   *   type: 'object',
   *   properties: {
   *     key: {
   *       ...schema, // 标准 schema 字段
   *       type: ''// 字段类型,
   *       label: '' // 字段中文名
   *       option: {
   *         ...elTableColumnConfig // 标准 el-table-column 配置
   *         visibility: true 是否隐藏与否
   *       }
   *     }
   *   }
   * }
   * */
  schema: Object,
  /** 表格数据源 api */
  api: String,
  /** 表格参数 */
  apiParams: Object,
  /** button 操作配置, 结构如下
   * rowButtons: [{
   *  label: '' // 按钮中文名
   *  eventKey: '' // 按钮事件名
   *  eventOption: {} // 按钮事件具体配置
   *  ...elButtonConfig, // 标准的 el-button 配置
   *}, ...]
   * */
  buttons: Array
});
const { api, apiParams, schema, buttons } = toRefs(props);

const emit = defineEmits(['operate']);

/** 根据字体长度计算大概右侧按钮框宽度 */
const operationWidth = computed(() => {
  return buttons?.value?.length > 0 ? buttons?.value.reduce((pre, cur) => {
    return pre + cur.label.length * 18;
  }, 50) : 50;
});

const loading = ref(false);
const tableData = ref([]);
const currentPage = ref(1);
const pageSize = ref(50);
const total = ref(0);

onMounted(() => initData());
watch([
  schema,
  api,
  apiParams,
], () => initData(), {
  deep: true
});

const initData = () => {
  currentPage.value = 1;
  pageSize.value = 50;
  nextTick(async () => {
    await loadTableData();
  });
};

let timerId = null;
/** 对函数做一个截流 */
const loadTableData = async () => {
  clearTimeout(timerId);
  timerId = setTimeout(async () => {
    await fetchTableData();
    timerId = null;
  }, 100);
};

/** 请求数据 */
const fetchTableData = async () => {
  if (!api.value) return;

  showLoading();

  // 请求 table 数据
  const res = await $curl({
    method: 'get',
    url: `${ api.value }/list`,
    query: {
      ...apiParams.value,
      page: currentPage.value,
      size: pageSize.value
    }
  });

  hideLoading();

  if (!res || !res.success || !Array.isArray(res.data)) {
    tableData.value = [];
    total.value = 0;
    return;
  }

  tableData.value = buildTableData(res.data);
  total.value = res.metadata.total;
};

/** 对后端返回的数据进行预处理 */
const buildTableData = (listData) => {
  if (!schema.value?.properties) return listData;

  return listData.map(rowData => {
    for (const dKey in rowData) {
      const schemaItem = schema.value.properties[dKey];
      // 处理 toFixed
      if (schemaItem?.option?.toFixed) {
        rowData[dKey] = rowData[dKey].toFixed && rowData[dKey].toFixed(schemaItem.options.toFixed);
      }
    }
    return rowData;
  });
};

/** 显示 loading */
const showLoading = () => loading.value = true;
/** 关闭 loading */
const hideLoading = () => loading.value = false;

const operationHandle = ({ btnConfig, rowData }) => {
  emit('operate', { btnConfig, rowData });
};

const onPageSizeChange = (event) => {
  pageSize.value = event;
  loadTableData();
}

const onPageCurrentChange = (event) => {
  currentPage.value = event;
  loadTableData();
}

/** 暴露对外方法 */
defineExpose({
  initData,
  loadTableData,
  showLoading,
  hideLoading,
})
</script>

<style scoped lang="less">
.schema-table {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: auto;

  .table {
    flex: 1;
  }

  .pagination {
    margin: 10px 0;
  }
}
</style>

留心的也会发现组件中的 tableObject 变成了直接是 option 这是为什么呢?这是考虑到了代码的维护性,通过数据隔离去降低代码的耦合度,所以在前置数据区分的时候就直接命名成 option 再进行导入,当然区分的时候继续叫 tableOption 也没有任何问题,这就是 schema 的其中一个实现了

看到这里可能也还有疑问, 你就一个列表能干啥,这就不得不说这套 DSL 配置的高明之处了

// 可以看到在 schemaConfig 中的配置是这样的
schemaConfig: {
    api: 'xxx',
    schema: {
        type: 'object',
        properties: {
            [xxx]: {
                ...config, // 各种配置
                tableConfig, // 列表配置
                // 这里可以无限拓展 通过实现不同的解析器实现页面功能
                [componentName]Option // 各种组件的配置
                apiConfig // api 相关配置
                dbConfig // 通过数据库相关配置反推 sql 语句直接生成一张表
             }
        }
    }
}
// 列表通用配置
tableConfig: {}
componentConfig: {
    [componentName]: {
        ...componentConfig
    }
} // 各种组件的配置
apiConfig // api 相关配置
dbConfig // 通过数据库相关配置反推 sql 语句直接生成一张表

这就是它的拓展性

总结

这有什么好处呢,我总结了有以下两点

1. 提升

首先这么做,肯定是可以吧 80% 甚至更多的重复性工作去进行沉淀,只去进行独特的全新的模块去针对性的开发,减少了很多搬砖的工作(提升效率就不说了,都是打工人,不能这么鞭策自己,适当摸鱼,完美 TvT),不知道各位小伙伴怎么想,这种重复性的工作对于我而言十分的无聊,甚至敲着敲着想睡觉,但是只开发全新的功能,兴致盎然,不断的探索全新的领域,不断的接触新的知识是一件很有趣的事情。

2. ui设计的统一

如果有了这一套设计去给 UI 设计师,他们的设计就会不自觉的向着这套样式也好,规范也罢,这样的一个方式去实现,也会减少非常多的样式的重构(个人真的很讨厌写 css 这种玄学的东西)

最后

最后谢谢各位小伙伴能看到这里,如果文章有什么问题,或者您有什么好的想法请向我提出,还有不要喷我不要喷我,我只是个热爱 code 的菜鸡罢了

学习资源

抖音-哲玄前端
大全栈实践课