里程碑3--基于vue3完成领域模型建设

0 阅读1分钟

在企业中,我们开发的很多大后台需求,都是基于表单、表格、描述列表等构建的后台系统。而这些需求中80%的工作都是在做一些字段的增删改查,重复度很高,占用了我们大量的开发时间。所以设计了这套网站生产系统,基于领域模型的DSL设计,通过模板引擎解析dsl生成网站,核心在于沉淀重复性劳动,同时也支持定制化需求。通过schema描述我们的系统架构,用这一套schema,通过我们的解析引擎,生成一套完整的系统,并且可以动态映射为我们的组件库,也可以基于这套标准的json-schema结构生成数据库表。

这套框架也具备很高的扩展性,支持不同的模板和自定义页面

首先就是最底层,也就是我们的model

在我们的系统中,很多查询列表都是基于一条业务线去做的,例如我们有一套投诉单系统,根据这套投诉单后续会流转到不同的单据,例如风险单、催收单、舆情单等等,这样我们就出现了多套表格页(包含el-form和el-table)的结构,然而在这几个子单据中,有大量的数据和菜单来自于投诉单,所以我们可以围绕这个投诉单建立我们的model(投诉单业务),根据model去派生出多个project(风险单系统、舆情单系统、催收单系统)

这样就基于model生成了多个project

那么是怎么实现的呢?我们把投诉单系统的底层业务逻辑沉淀在model里,例如投诉单系统的头部菜单会有 投诉单管理、货物数据管理 、以及各类的 工单管理,而在风险单中需要继承投诉单的部分,例如 货物数据管理 和 工单管理 ,会被完整的继承过来,而 风险单管理 菜单则是基于投诉单的schema配置重载后得出,和投诉单内容相同的部分自动保留,和投诉单不同的部分解析引擎会自动重载,投诉单没有而风险单定制的内容直接添加在风险单的schema中。

project 有的键值, model也有 => 修改 (重载)

project 有的键值, model没有 => 新增 (拓展)

model 有的键值, project没有 => 保留 model 的值(继承)

通过这样,我们依据一套业务模型(model),也就是我们的 投诉单模块,扩展出了多套子业务系统(n个project)

那么我们拿到了每个project对应的schema,是如何解析生成页面的呢?

拿到了我们的schema数据,就要解析里面的内容了

name: '风险单',desc: '风险单系统',homePage: '/schema?proj_key=risk&key=product',

首先这几项代表着project的名字,描述和选中/打开这个项目时,要进到的相对路径(含查询参数),proj_key代表当前项目的key,key代表当前选中的头部菜单

那头部菜单是如何解析的呢:

 menu: [    {      key: 'client',      name: '客户管理(拼多多)',      menuType: 'module',      moduleType: 'schema',      schemaConfig: {        api: '/api/proj/pdd/client',        schema: {          type: 'object',          properties: {            name: { type: 'string', label: '姓名' },          },        },      },    },    {      key: 'data',      name: '数据分析',      menuType: 'module',      moduleType: 'sider',      siderConfig: {        menu: [          {            key: 'analysis',            name: '电商罗盘',            menuType: 'module',            moduleType: 'custom',            customConfig: {              path: '/todo',            }          },          {            key: 'sider-search',            name: '信息查询',            menuType: 'module',            moduleType: 'iframe',            iframeConfig: {              path: 'https://www.baidu.com',            },          },          {            key: 'categories',            name: '分类数据',            menuType: 'group',            subMenu: [              {                key: 'category-1',                name: '一级分类',                menuType: 'module',                moduleType: 'custom',                customConfig: {                  path: '/todo',                }              },              {                key: 'category-2',                name: '二级分类',                menuType: 'module',                moduleType: 'iframe',                iframeConfig: {                  path: 'https://www.jd.com',                }              },              {                key: 'category-3',                name: '三级分类',                menuType: 'module',                moduleType: 'custom',                customConfig: {                  path: '/todo',                }              }            ]          }        ]      }    },    {      key: 'search',      name: '信息查询',      menuType: 'module',      moduleType: 'iframe',      iframeConfig: {        path: 'https://www.baidu.com',      },    }  ]

这一项就是menu,因为头部菜单有多个,所以menu是一个数组。key代表菜单的key,也就是路由里的key,name是菜单的名字,menuType是菜单的类型,menuType有两种:

module 和 group

先说 menuType=module,这种菜单点击之后就直接渲染菜单的内容,内容的类型根据moduleType来决定,moduleType有以下几种

sider、iframe、custom、schema

~moduleType=sider,代表这个模块包含侧边栏,那这些信息就对应地包含在siderConfig里,siderConfig里就有有了menu,menu里就又有了menuType、moduleType、以及对应的Config,依此可以无限嵌套下去...

~moduleType=iframe,代表这个模块是iframe模块,对应的iframeConfig包含path,就是iframe的链接

~moduleType=custom,代表这个模块是自定义的内容,对应的customConfig包含path,纠是跳转的路由,一般来说custom模块就是为了解决那些很定制化的内容,不方便使用schema进行描述

~moduleType=schema,就是我们沉淀80%能力的模块,像表单、表格、描述列表、弹窗、抽屉这些内容,都可以通过一套schema进行描述,解析出一套系统。schema类的moudleType可以有多种,代表我们不同种类的模块。我们已搜索列表类的search-table-schema举例,在schemaConfig中,包含了一个api,代表请求这个模块数据的接口。还有schema,里面就是数据列表,type代表当前schema的类型,schema.properties里的每个keyValue代表表单的每一项或表格的每一列。每一组数据里包含有tableOption(描述当前table列的属性),searchOption(描述这个表单项的属性)。

和schema同级的还有例如searchConfig和tableConfig,searchConfig里面可以放一些table整体的配置,例如表格操作区展示的按钮和对应的eventKey代表的事件,例如我们想增加一个关闭工单的按钮,就可以这样写:

tableConfig: {          headerButtons: [{            label: '删除工单',            eventKey: 'closeOrder',            type: 'danger',            eventOption: {              params: {                id: 'schema::id',              },            },          }],

点击删除工单,调用closeOrder方法,自动去row中寻找id调用删除接口

menuType=group的情况,代表点击这个头部菜单要展开多个子菜单,和menuType同级要有subMenu,里面内容和menu内一致:

{            key: 'categories',            name: '分类数据',            menuType: 'group',            subMenu: [              {                key: 'category-1',                name: '一级分类',                menuType: 'module',                moduleType: 'custom',                customConfig: {                  path: '/todo',                }              },              {                key: 'category-2',                name: '二级分类',                menuType: 'module',                moduleType: 'iframe',                iframeConfig: {                  path: 'https://www.jd.com',                }              },              {                key: 'category-3',                name: '三级分类',                menuType: 'module',                moduleType: 'custom',                customConfig: {                  path: '/todo',                }              }            ]          }

讲完了这套schema的设计,接下来就是如何实现对应的页面的

进入到每个页面都会先引入header-container组件,里面包含了头部区域,通过插槽注入对应的头部菜单,也通过插槽去渲染对应的main-content,在main-content里面就是靠router-view去加载对应的组件了

sider-container也是如此,在sider-view中会引入sider-container,通过插槽注入menu-content和main-content

进入project-list页面,点击进入对应的系统,路由会变成/view/dashboard后面拼接上对应项目的homePage,路由参数中有proj_key代表当前的项目,key代表默认选中的头部菜单

进入dashboard页面后读取路由中的proj_key,获取当前项目的配置,获取到项目的menu数组存到store中

会根据点击头部菜单对应的moduleType来切换路由:

 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,    },  })

根据不同路由切换不同的view:

// 头部菜单路由routes.push({  path: '/view/dashboard/iframe',  component: () => import('./complex-view/iframe-view/iframe-view.vue')})routes.push({  path: '/view/dashboard/schema',  component: () => import('./complex-view/schema-view/schema-view.vue')})// custom 自定义路由routes.push({  path: '/view/dashboard/todo',  component: () => import('./todo/todo.vue')})// 侧边栏菜单路由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')  }]})

这样就渲染到了对应的view组件。iframe-vue就是使用iframe标签解析这个页面链接;sider-view会监听路由中key的变化和store中menuList的变化,根据路由中的sider_key去store中找到对应的menuItem,找到menuItem.siderConfig.menu赋值给menuList,循环menuList去渲染内容,如果item.subMenu存在,就渲染subMenu组件,subMenu组件内部递归实现:

<el-sub-menu :index="menuItem.key">    <template #title>{{ menuItem.name }}</template>    <div v-for="item in menuItem.subMenu" :key="item.key">      <sub-menu v-if="item.subMenu && item.subMenu.length > 0" :menu-item="item"></sub-menu>      <el-menu-item v-else :index="item.key">{{ item.name }}</el-menu-item>    </div>  </el-sub-menu>

如果item.subMenu不存在,就直接渲染这个头部菜单,选择了对应的菜单也会路由跳转切换到对应的模块:

const pathMap = {    iframe: 'iframe',    schema: 'schema',    // custom 的 path 可能带 /,统一去掉开头的 / 再拼,避免 /sider//todo 双斜杠    custom: rawPath.replace(/^\//, ''),  }  router.push({    path: `/view/dashboard/sider/${pathMap[moduleType]}`,    query: {      key: route.query.key,      sider_key: key,      proj_key: route.query.proj_key,    },  })

最重要的就是schema-view了,还是以search-table-schema举例,包含两个组件table-panel和search-panel,实现useSchema-hook,将获取到的schemaConfig处理为两份数据,分别是tableSchema、tableConfig和searchSchema、searchConfig,分别传到对应的组件中去渲染。

schemaConfig中的tableOption和searchOption里也可以直接写element支持的属性,都会通过v-bind传入对应的el-table-column和动态表单组件中,所以就支持配置所有element的原生属性

还有一个点,就是为什么使用history路由而不是hash路由

因为我们在渲染的时候要知道这是哪个project,好请求对应的数据,所以就要在每个接口请求时都带上projKey,

在ctx.render时把ctx.query.proj_key注入到entry文件中,所以要从ctx.query中拿到proj_key

例如我访问:http://127.0.0.1:8080/view/dashboard?proj\_key=pdd

  • 路径 /view/dashboard 匹配路由 router.get("/view/:page", ...)
  • 查询串 proj_key=pdd 会被解析成一个对象,挂在 ctx.request.query 上;Koa 还提供了别名 ctx.query

所以 ctx.query?.proj_key 能取到值

proj_key 只出现在 hash 后面的查询里,例如:http://127.0.0.1:8080/view/dashboard#/todo?proj\_key=pdd

# 及其后面的内容不会作为 HTTP 请求发给 Node,服务端只能看到 /view/dashboardctx.query 里就没有 proj_key

这样在entry里就可以拿到projKey挂载到window上了

<!DOCTYPE html><html class="dark"></html><html lang="en">  <head>    <meta charset="UTF-8" />    <link href="/static/normalize.css" rel="stylesheet" />    <link href="/static/logo.png" rel="icon" type="image/x-icon" />    <title>{{ name }}</title>  <script defer src="http://127.0.0.1:9002/public/dist/dev/js/runtime_e43eecf6.bundle.js"></script><script defer src="http://127.0.0.1:9002/public/dist/dev/js/vendor_39d941ae.bundle.js"></script><script defer src="http://127.0.0.1:9002/public/dist/dev/js/common_21b8055e.bundle.js"></script><script defer src="http://127.0.0.1:9002/public/dist/dev/js/entry.project-list_fff0ffb4.bundle.js"></script></head>  <body style="margin: 0;">    <div id="root"></div>    <input id="projKey" value="{{ projKey }}" style="display: none" />    <input id="env" value="{{ env }}" style="display: none" />    <input id="options" value="{{ options }}" style="display: none" />  </body>  <script type="text/javascript">    try {      window.projKey = document.getElementById("projKey").value;      window.env = document.getElementById("env").value;      const options = document.getElementById("options").value;      window.options = JSON.parse(options);    } catch (e) {      console.error(e);    }  </script></html>

在请求库发送请求的时候,就可以window.projKey直接取出来放到请求头里了

为什么要通过服务端渲染挂载到window上,而不是直接从路由参数里取呢,例如使用window.location获取,为什么放到请求头里呢

1. window.location 虽然能拿,但它是“浏览器端才有”的

如果直接从:window.location.search 里取值,那必须满足:

  • 浏览器已经打开页面了
  • JS 已经开始执行了
  • 前端代码已经跑起来了

而服务端注入的方式是:

  1. Koa 收到请求
  2. 先从 ctx.query.proj_key 取出值
  3. 渲染模板时塞进 HTML
  4. 浏览器拿到 HTML 后,页面一加载就有 window.projKey

也就是说:

  • window.location:前端启动后再解析
  • 服务端注入:HTML 生成时就确定好了

后者更早。

2. 服务端已经知道这个值了,顺手传下来更统一

这里的代码是:

await ctx.render(`dist/entry.${ctx.params.page}`, {

name: app.options?.name,

env: app.env?.get(),

options: JSON.stringify(app.options),

projKey: ctx.query?.proj_key

});

注意这里不只是传了 projKey,还传了:

  • name
  • env
  • options

所以它其实是在做一件统一的事情:

把“首屏初始化需要的上下文”一次性从服务端传给前端。

projKey 只是其中一个字段。

也就是说,项目作者的思路可能是:

  • 环境信息从服务端传
  • 页面配置从服务端传
  • 当前项目上下文也从服务端传

这样前端启动时从一个地方读取就行了。

3. 更适合作为“全局上下文”而不是“页面参数”

这是一个很重要的架构区别。

如果从 window.location 取

说明把 proj_key 看成“当前页面 URL 的一部分”。

如果从服务端传给 window

说明把 proj_key 看成“当前整页应用的全局上下文”。

在这个项目里,proj_key 不只是页面展示参数,它还影响:

  • 接口数据范围
  • 当前项目菜单
  • 项目配置加载

所以把它提升成全局上下文,是说得通的

服务端可以去做权限控制,因为服务端能拿到用户的鉴权信息,结合这个proj_key去判断是否能展示数据