项目整体架构设计
elpis-core 内核构建
elpis-core 是一个基于 Koa 的轻量级框架核心。它是整个项目的基础架构和启动引擎。
-
应用启动与初始化
- 提供了 start 方法作为应用的入口点
- 创建 Koa 实例并配置基本环境
- 设置应用的基础路径和业务文件路径
- 启动 HTTP 服务器并监听指定端口
-
模块化加载系统,通过一系列 loader 加载不同类型的模块
- configLoader : 加载应用配置
- serviceLoader : 加载服务层组件
- controllerLoader : 加载控制器
- routerSchemaLoader : 加载路由校验
- middlewareLoader : 加载中间件
- extendLoader : 加载扩展功能
- routerLoader : 加载路由定义
-
框架与业务代码分离
- 实际业务逻辑位于 app 目录
它采用约定优于配置的方式,通过对目录的约定来规范项目结构,减少协作和沟通成本。
- config/ 中存放不同环境下的配置文件
- app/service/ 中存放所有服务层文件
- app/controller/ 中存放所有控制器
- app/router-schema/ 中存放所有路由接口的校验
- app/middleware/ 中存放自定义中间件
- app/extend/ 中存放所有扩展功能文件
- app/router/ 中存放所有路由接口
开发者可以专注于在 app 目录中编写业务逻辑,而不必关心底层架构的实现细节。
loader 的加载顺序
config -> service -> controller -> router-schema -> middleware -> extend -> router
- controller 依赖于 service 和 config
- router 依赖于 router-schema, controller 和 middleware
- router-schema需要在middleware前加载(API 检验需要)
注意:
-
middleware 中执行返回的是异步函数,而 service 和 controller 中执行返回的是class类
-
koa实例中具有middleware属性。它是一个数组,专门用来存储通过
app.use()方法注册的所有中间件函数- 自己手动挂载 middleware 需要变成 app.middlewares
-
middlewareLoader只是加载了自定义的中间件,为了使用生态中的中间件,还需要加载 app/middleware.js文件
模板页面的渲染能力
Nunjucks 引入中间件 koa-nunjucks-2
- ctx 会增加一个 render 方法
采用 tpl 而不用 html 的原因
- 有动态数据,而且动态数据是由服务端注入进去的
- html 往往是静态数据。直接返回 html 无法利用框架的数据绑定能力
Nunjucks模板
<!-- app/controller/view.js
async renderPage(ctx) {
await ctx.render(`output/entry.${ctx.params.page}`, {
name: app.options?.name,
env: app.$env.get(),
options: JSON.stringify(app.options),
})
}
-->
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ name }}</title>
</head>
<body>
<h1 style="color: red">Page1</h1>
</body>
<script type="text/javascript">
window.APP_CONFIG = {
env: '{{env}}',
options: JSON.parse('{{ options | safe }}'),
}
</script>
</html>
- 使用
{{ ... | safe }}时,Nunjucks 不会对变量内容进行 HTML 转义。 - 对于简单字符串,直接加引号。 '{{env}}'
前端工程化搭建
-
通过自动化构建工具(如Webpack、Vite)处理编译、打包等重复劳动,让开发者能专注于业务逻辑。
-
通过模块化(将代码拆分为独立功能单元)和组件化(构建可复用的UI部件)来组织代码,使得代码结构清晰、易于复用和维护。同时,利用ESLint、Prettier等工具强制统一代码风格,并结合自动化测试,确保代码的健壮性和可读性。
-
通过代码分割、懒加载、Tree Shaking、压缩资源等优化手段,显著减少资源体积,提升应用加载速度和运行时性能。
多页面的构建
为每个页面配置独立的入口和应用实例 使用 glob 工具动态扫描约定目录 app/pages/**/entry.*.js,为每个找到的入口文件生成一个配置项。通过 webpack 的 entry 和 HtmlWebpackPlugin,为每个入口生成对应的模板(.tpl)文件并自动注入对应的 chunks。这使得增加新页面只需添加符合约定的文件,无需修改构建配置,实现了“约定大于配置”。
// app/webpack/config/webpack.base.js
/**
* 动态构造 entry 入口配置和最终渲染的页面模板 HtmlWebpackPlugin
* entry: {
* 'entry.xxx': './app/pages/entry.xxx.js',
* }
*
* new HtmlWebpackPlugin({
* template: path.resolve(__dirname, '../../view/entry.tpl'),
* filename: path.resolve(process.cwd(), 'app/public/dist', 'entry.xxx.tpl')
* chunks: ['entry.xxx'],
* })
*/
const handleFilePath = (entryFilePath, entry = {}, HtmlWebpackPluginList = []) => {
const entryFileName = path.basename(entryFilePath, '.js')
entry[entryFileName] = entryFilePath
HtmlWebpackPluginList.push(
new HtmlWebpackPlugin({
// 指定要使用的模板文件
template: path.resolve(__dirname, '../../view/entry.tpl'),
// 产物(最终模板)输出路径
filename: path.resolve(process.cwd(), 'app/public/dist', `${entryFileName}.tpl`),
// 要注入的代码块, 对应 entry 中的 key
chunks: [entryFileName],
})
)
}
const elpisEntry = {}
const elpisHtmlWebpackPluginList = []
// 获取 elpis/app/pages 目录下的所有入口文件
const elpisEntryFileList = glob.sync(path.resolve(__dirname, '../../pages/**/entry.*.js'))
elpisEntryFileList.forEach(entryFilePath => {
handleFilePath(entryFilePath, elpisEntry, elpisHtmlWebpackPluginList)
})
const businessEntry = {}
const businessHtmlWebpackPluginList = []
// 获取业务 app/pages 目录下的所有入口文件
const businessEntryFileList = glob.sync(path.resolve(process.cwd(), 'app/pages/**/entry.*.js'))
businessEntryFileList.forEach(entryFilePath => {
handleFilePath(entryFilePath, businessEntry, businessHtmlWebpackPluginList)
})
- Koa 挂载静态目录 ,模板引擎(Nunjucks)负责渲染 .tpl 模板文件, controller 选择注入的入口对应模板,形成完整页面。
开发环境的热更新
修改代码并保存后,几乎能立即在浏览器中看到变化,无需手动刷新页面
关键步骤:devServer、source-map、热更新、 内存系统。
领域模型的架构建设-DSL
DSL全称为Domain-Specific Language (领域特定语言)
DSL 的主要价值在于简化问题的描述和解决方式。设计 DSL 的核心是要体现出意图,而不仅仅是实现功能。通过 DSL,程序员可以将复杂的实现细节隐藏在简洁的语法之下,让使用者专注于表达需求,而非实现细节。
核心在于数据驱动
dashboard
以dashboard为例
- DSL都只需要描述数据以及数据相关的配置项,而不用去在意这些配置应该如何的实现。
一份DSL模板-> 不同模型(model) -> 不同项目(project)配合解析引擎落地为具体的一个产品
-
不同项目属于同一个模型(
继承实现) -
模型配置描述80%的共同点,项目配置20%的定制化需求
- 图中绿色部分均为
可拓展部分(可定制化部分)
- 图中绿色部分均为
配置项可用以下的描述来表示
module.exports = {
mode: 'dashboard', // 模板类型描述, 不同模板类型对应不一样的数据结构
name: '', // 模板名称
desc: '', // 模板描述
icon: '', // icon
homePage: '', // 首页(项目配置)
// 头部菜单
menu: [
// menuItem[]
{
key: '', // 菜单唯一描述,
name: '', // 菜单名称
menuType: '', // 菜单类型, 枚举值: 'group' | 'module'
// 1. 当 menuType == group 时, 可填
subMenu: [], // menuItem[],
// 2. 当 menuType == module 时
moduleType: '', // 模块类型, 枚举值: 'schema' | 'iframe' | 'custom' | 'sider'
// 2.1 当 moduleType == schema 时
schemaConfig: {
api: '', // 数据源 API (遵循 RESTFUL 规范)
schema: {
// 模块数据结构
type: 'object',
properties: {
prop: {
...schema, // 标准 schema 结构
type: '', // 字段类型
label: '', // 字段中文名
// 字段在 table 中的相关配置
tableOption: {
...elTableColumnConfig, // 标准 el-table-column 配置
toFixed: 0, // 保留几位小数点
visible: true, // 控制该字段是否在表格展示, 默认 true(false则不在表格展示)
},
// 字段在 search-bar 中的相关配置
searchOption: {
...elComponentConfig, // 标准 el-component 配置
componentType: '', // 配置组件类型, 枚举值: 'input' | 'select' | 'dynamic-select' | 'date-range'
default: '', // 默认值
// componentType === 'select' 时, 可填
enumList: [{ label: '', value: '' }], // 下拉框选项
// componentType === 'dynamic-select' 时, 可填
api: '',
},
// 字段在不同动态组件中的相关配置, 前缀对应 componentConfig 中的键值
// 例如: componentOption.createFormOption, 这里就是 createFormOption
// 字段在 create-form 中的相关配置
createFormOption: {
...elComponentConfig, // 标准 el-component 配置
componentType: '', // 配置组件类型, 枚举值: 'input' | 'input-number' | 'select'
visible: true, // 是否展示, 默认 true(false则不在表格展示)
disabled: false, // 是否禁用, 默认 false
default: '', // 默认值
// componentType === 'select' 时, 可填
enumList: [{ label: '', value: '' }], // 下拉框选项
},
// 字段在 edit-form 中的相关配置
editFormOption: {
...elComponentConfig, // 标准 el-component 配置
componentType: '', // 配置组件类型, 枚举值: 'input' | 'input-number' | 'select'
visible: true, // 是否展示, 默认 true(false则不在表格展示)
disabled: false, // 是否禁用, 默认 false
default: '', // 默认值
// componentType === 'select' 时, 可填
enumList: [{ label: '', value: '' }], // 下拉框选项
},
// 字段在 detail-panel 中的相关配置
detailPanelOption: {
...elComponentConfig, // 标准 el-component 配置
},
},
},
// 哪些字段必填
required: [],
},
// table 相关配置
tableConfig: {
headerButtons: [
{
...elButtonConfig, // 标准 el-button 配置
label: '', // 按钮名称
eventKey: '', // 按钮事件名称
// 按钮具体配置
eventOption: {
// eventKey === 'showComponent'
componentName: '', // 组件名称
},
},
],
rowButtons: [
{
...elButtonConfig, // 标准 el-button 配置
label: '', // 按钮名称
eventKey: '', // 按钮事件名称
// 按钮具体配置
eventOption: {
// eventKey === 'showComponent'
componentName: '', // 组件名称
// eventKey === 'remove'
params: {
// paramKey 为参数的键值,
// rowValueKey 为参数值(格式为 schema::tableKey 时, 到 table 中找相应字段)
paramKey: rowValueKey,
},
},
},
],
},
// search-bar 相关配置
searchConfig: {},
// 动态组件相关配置
componentConfig: {
// create-form 相关配置
createForm: {
title: '', //表单标题
saveButtonText: '', // 保存按钮文案
},
// edit-form 相关配置
editForm: {
mainKey: '', // 表单主键, 唯一标识要修改的数据对象
title: '', //表单标题
saveButtonText: '', // 保存按钮文案
},
// detail-panel 相关配置
detailPanel: {
mainKey: '', // 表单主键, 唯一标识要修改的数据对象
title: '', //表单标题
},
},
},
// 2.2 当 moduleType == iframe 时
iframeConfig: {
path: '', // iframe 路径
},
// 2.3 当 moduleType == custom 时
customConfig: {
path: '', // 自定义路由路径
},
// 2.4 当 moduleType == sider 时
siderConfig: {
menu: [], // menuItem[], 这里 menuItem 的 moduleType 只能是 'schema' | 'iframe' | 'custom'
},
},
],
}
- 通过解析 menu,即可渲染出对应的菜单,而每个 menuItem 的数据对应其菜单具体要渲染的页面内容。
以 menuItem 里面的 moduleType == schema 为例(即schema-view)
- schemConfig.schema.properties 里面包括我们要渲染的每个数据。
- 通过 tableOption 来控制这个数据在表格中的表现,通过 schemaConfig.tableConfig 来控制整个表格在页面的表现。
- 通过 searchOption 来控制这个数据在表格中的表现,通过 schemaConfig.searchConfig 来控制整个搜索栏在页面的表现。
通过这样的方式完成了用一份源数据在不同的块进行渲染的目标。
- 并不局限于给出的 table 和 search,还可以进行额外的拓展(form, dialog, drawer等等)
通过领域模型实现项目配置(子类)
// app/model/index.js
const path = require('path')
const glob = require('glob')
const _ = require('lodash')
const { sep } = path
const projExtendModel = (model, proj) => {
// 从左到右的顺序依次合并
return _.mergeWith({}, model, proj, (modelValue, projValue) => {
// 对数组进行合并,其他属性默认替换
if (Array.isArray(modelValue) && Array.isArray(projValue)) {
let res = []
const moduleTypes = ['schema', 'iframe', 'custom', 'sider']
for (let i = 0; i < modelValue.length; ++i) {
modelItem = modelValue[i]
let projItem = projValue.find(item => item.key === modelItem.key)
if (projItem) {
// 1. model 有, proj 有 => 修改
let mergedItem = projExtendModel(modelItem, projItem)
// 检查 moduleType 是否修改
if (modelItem.moduleType && projItem.moduleType && modelItem.moduleType !== projItem.moduleType) {
// moduleType 修改了,只保留相对应的 Config 属性 `${moduleType}Config`
const newModuleType = projItem.moduleType
const configKey = `${newModuleType}Config`
// 移除其他 moduleType 的 Config 属性
moduleTypes.forEach(type => {
const oldConfigKey = `${type}Config`
if (oldConfigKey !== configKey && mergedItem[oldConfigKey]) {
delete mergedItem[oldConfigKey]
}
})
}
res.push(mergedItem)
} else {
// 2. model 有, proj 没有 => 保留
res.push(modelItem)
}
}
// 3.model 没有, proj 有 => 新增
for (let i = 0; i < projValue.length; ++i) {
projItem = projValue[i]
let modelItem = modelValue.find(item => item.key === projItem.key)
if (!modelItem) res.push(projItem)
}
return res
}
})
}
/**
* 解析 model 配置,并返回合并后的数据结构
* modelItem: {
* model: ${model, modelKey},
* project: {
* proj1: ${proj1, projKey1, modelKey},
* proj2: ${proj2, projKey2, modelKey},
* },
* },
*
* @param {object} app
* @returns {modelItem[]} modelList
*/
module.exports = app => {
const modelList = []
// 遍历当前文件夹, 构造模型数据结构
modelPath = path.resolve(process.cwd(), 'model')
const fileList = glob.sync(path.resolve(modelPath, `**/*.js`))
fileList.forEach(file => {
// ${modelName}/project/${projectName}.js
// ${modelName}/model.js
relativePath = path.relative(modelPath, file)
if (relativePath == 'index.js') return
const { dir, name: fileName } = path.parse(relativePath)
const modelKey = dir.split(sep)[0] // ${modelName}
let modelItem = modelList.find(item => item.model?.key === modelKey)
// 不存在则创建 modelItem 并添加到 modelList
if (!modelItem) {
modelItem = {}
modelList.push(modelItem)
}
// 区分配置类型(model/project)
const configType = fileName === 'model' ? 'model' : 'project'
if (configType === 'model') {
modelItem.model = require(path.resolve(file)) // 在 modelItem 上注入 model 对象
modelItem.model.key = modelKey // 注入 modelKey
}
if (configType === 'project') {
const projKey = fileName // ${projectName}
modelItem.project = modelItem.project || {}
modelItem.project[projKey] = require(path.resolve(file)) // 在 modelItem.project 上注入 proj 对象
modelItem.project[projKey].key = projKey // 注入 projKey
modelItem.project[projKey].modelKey = modelKey // 注入 modelKey
}
})
// 将 modelItem 上的 proj 和 model合并, 即 proj 继承自 model
modelList.forEach(item => {
const { model, project } = item
for (const proj in project) {
project[proj] = projExtendModel(model, project[proj])
}
})
return modelList
}
对schema配置进行解析
import { nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMenuStore } from '$elpisStore/menu'
// 构建 schema 方法, 提取 {optionName}Option
const buildDtoSchema = (_schema, optionName) => {
if (!_schema.properties) return
const dtoSchema = {
type: 'object',
properties: {},
}
// 处理 required 字段
const required = new Set(_schema.required || [])
for (const prop in _schema.properties) {
const schemaProp = _schema.properties[prop]
if (schemaProp[`${optionName}Option`]) {
let dtoProp = {}
for (const key in schemaProp) {
if (key.endsWith('Option')) continue
dtoProp[key] = schemaProp[key]
}
dtoProp = Object.assign({}, dtoProp, { option: schemaProp[`${optionName}Option`] })
if (required.has(prop)) dtoProp.option.required = true
dtoSchema.properties[prop] = dtoProp
}
}
return dtoSchema
}
export const useSchema = () => {
const route = useRoute()
const menuStore = useMenuStore()
const api = ref('')
const tableSchema = ref({})
const tableConfig = ref()
const searchSchema = ref({})
const searchConfig = ref()
const components = ref({})
// 构造 schemaConfig 相关配置, 给 schema-view 解析
const buildData = () => {
// 先根据 sider_key, 再根据 key
const { key, sider_key } = route.query
const menuItem = menuStore.findMenuItem({
key: 'key',
value: sider_key ?? key,
})
if (menuItem && menuItem.schemaConfig) {
const { schemaConfig } = menuItem
const schema = JSON.parse(JSON.stringify(schemaConfig.schema))
api.value = schemaConfig.api ?? ''
// 先清空再赋值
tableSchema.value = {}
tableConfig.value = undefined
searchSchema.value = {}
searchConfig.value = undefined
components.value = {}
// nextTick 确保清空操作完成并等待一个 DOM 更新周期后, 再执行新数据的赋值
nextTick(() => {
// 构造 tableSchema, tableConfig
tableSchema.value = buildDtoSchema(schema, 'table')
tableConfig.value = schemaConfig.tableConfig
// 构造 searchSchema, searchConfig
const dtoSearchSchema = buildDtoSchema(schema, 'search')
for (const prop in dtoSearchSchema.properties) {
// 如果路由中存在该字段, 则设置默认值
if (route.query[prop] !== undefined) dtoSearchSchema.properties[prop].option.default = route.query[prop]
}
searchSchema.value = dtoSearchSchema
searchConfig.value = schemaConfig.searchConfig
// 构造 components = { componentKey: { schema: {}, config: {} } }
const { componentConfig } = schemaConfig
const componentKeys = Object.keys(componentConfig)
if (componentConfig && componentKeys.length > 0) {
components.value = componentKeys.reduce((dtoComponents, componentKey) => {
return {
...dtoComponents,
[componentKey]: {
schema: buildDtoSchema(schema, componentKey),
config: componentConfig[componentKey],
},
}
}, {})
}
})
}
}
buildData()
watch(
[() => route.query.key, () => route.query.sider_key, () => menuStore.menuList],
() => {
buildData()
},
{ deep: true }
)
return { api, tableConfig, tableSchema, searchConfig, searchSchema, components }
}
- config 是针对
整个组件的配置, 而 option 是针对字段在组件中的配置(hook 中的 schema),两者需要区分。
npm包的抽离
自定义SSR页面的扩展
在 webpack.base.js 中修改 entry 和 HtmlWebpackPlugin 来获取全部的入口。
webpack里面的loader会在调用方里面的根目录开始找(即在 elpis-demo 的 node_modules)
-
如果用自己 node_modules 的目录,改为 require.resolve()
- 如果找不到包 用require.resolve()
-
当这个 webpack 配置作为 npm 包被其他项目引用时,loader 的解析路径会基于 elpis 包本身,而不是调用方的 node_modules。
在 elpis-demo/app/pages 目录下写入口文件 entry.xxx.js 和对应组件
自定义 view 页面 dashboard/cutom-view
- 在
elpis-demo/app/pages/dashboard/xxx写页面组件 - 在
elpis-demo/app/pages/dashboard/router.js中进行配置
module.exports = ({routes, siderRoutes}) => {}
当用户没配置 dashboard/router.js
-
try-catch无法解决
- webpack是静态构建,并不是运行时构建,所以会直接报错
try{
businessDashboardRouterConfig = require('$businessDashboardRouterConfig')
}catch (e) {}
- import的方式无法解决 依旧报错
(async () => {
businessDashboardRouterConfig = await import('$businessDashboardRouterConfig')
})()
解决方案
-
创建空文件让 webpack 别名指向它作为 fallback
-
alias: (() => { const aliasMap = {} const blankModulePath = path.resolve(__dirname, '../libs/blankModule.js') const businessDashboardRouterConfigPath = path.resolve(process.cwd(), 'app/pages/dashboard/router.js') aliasMap['$businessDashboardRouterConfig'] = fs.existsSync(businessDashboardRouterConfigPath) ? businessDashboardRouterConfigPath : blankModulePath return { ...aliasMap, } })(), - 简单、但是需要额外维护文件
-
-
编译时常量 + if 判断
-
new webpack.DefinePlugin({ __HAS_BUSINESS_DASHBOARD_ROUTER__: fs.existsSync() // true/false }),if(__HAS_BUSINESS_DASHBOARD_ROUTER__) {...}不会往 window 上挂变量;不会在运行时代码里真的出现 __HAS_...__ 这个名字。
webpack 在构建时会做常量折叠:如果这个宏是
false,整块if分支会被摇掉 -
无额外文件,但是定义全局变量进行编译期宏替换
-
-
反向依赖——由业务入口调用基础库
动态组件扩展 dashboard/schema-view/components
- 在
app/pages/dashboard/complex-view/schema-view/components/下写组件
- components的统一写法
<script setup>
import { ref } from 'vue'
const name = ref('createForm')
const isShow = ref(false)
const show = () => {
isShow.value = true
}
const close = () => {
isShow.value = false
}
defineExpose({
name,
show,
})
</script>
<template></template>
<style scoped lang="less"></style>
- 配置
app/pages/dashboard/complex-view/schema-view/components/component-config.js
schema-form 控件扩展
- 在
app/pages/widgets/schema-form/complex-view/下写组件
<script setup>
const validate = () => {}
const getValue = () => {}
defineExpose({
name,
validate,
getValue,
})
</script>
<template></template>
<style scoped lang="less"></style>
- 配置
app/pages/widgets/schema-form/form-item-config.js
schema-search-bar 控件的扩展
- 在
app/pages/widgets/schema-search-bar/complex-view/下写组件
<script setup>
const reset = () => {}
const getValue = () => {}
defineExpose({
reset,
getValue,
})
</script>
<template></template>
<style scoped lang="less"></style>
- 配置
app/pages/widgets/schema-search-bar/search-item-config.js
发布
npm config get registry
-> https://registry.npmjs.org/
如果不是, npm config set registry
npm login
npm whoami
-> 会显示自己帐号的名字
npm publish --access public
后续优化方向
接入TS
目前项目都是 JavaScript。随着 Schema 复杂度的增加,没有较好的类型提示。
可视化
虽然实现了 Schema 驱动,但仍需手写复杂的 JSON 配置。 迭代建议:可视化界面搭建。
给每个字段提供属性面板,通过修改属性实时更新配置文件->类似于低代码平台
接入大模型
虽然有了可视化编辑可以拖拽生成,但配置复杂页面仍然需要时间。
JSON Schema 结构非常适合 LLM 理解和生成。
-
编写系统提示词,告诉 AI 关于 elpis 的 Schema 规则(例如:
searchConfig对应搜索栏,tableConfig对应表格)。 -
交互:用户输入“帮我生成一个包含姓名、年龄、注册时间的名为‘用户管理’的页面,支持按姓名搜索”。
-
输出:AI 直接输出符合 elpis 规范的 JSON 配置,填入页面。