在企业中,我们开发的很多大后台需求,都是基于表单、表格、描述列表等构建的后台系统。而这些需求中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/dashboard,ctx.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 已经开始执行了
- 前端代码已经跑起来了
而服务端注入的方式是:
- Koa 收到请求
- 先从
ctx.query.proj_key取出值 - 渲染模板时塞进 HTML
- 浏览器拿到 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,还传了:
nameenvoptions
所以它其实是在做一件统一的事情:
把“首屏初始化需要的上下文”一次性从服务端传给前端。
projKey 只是其中一个字段。
也就是说,项目作者的思路可能是:
- 环境信息从服务端传
- 页面配置从服务端传
- 当前项目上下文也从服务端传
这样前端启动时从一个地方读取就行了。
3. 更适合作为“全局上下文”而不是“页面参数”
这是一个很重要的架构区别。
如果从 window.location 取
说明把 proj_key 看成“当前页面 URL 的一部分”。
如果从服务端传给 window
说明把 proj_key 看成“当前整页应用的全局上下文”。
在这个项目里,proj_key 不只是页面展示参数,它还影响:
- 接口数据范围
- 当前项目菜单
- 项目配置加载
所以把它提升成全局上下文,是说得通的
服务端可以去做权限控制,因为服务端能拿到用户的鉴权信息,结合这个proj_key去判断是否能展示数据