前言
对于大多数中后台项目的前后端开发者来说,大家日常的开发可能都是围绕于业务进行CRUD的操作,只不过可能大家有的业务简单、有的业务复杂,但其本质都是数据的CRUD、重复性的劳动。
而全栈领域模型框架eplis的目标,就是为了从根源上解决这个问题:将高度相似、重复性的CRUD的操作,基于领域模型建模,高度抽象出对于前端UI界面的描述、对后端接口API的描述、以及后端数据库DB的描述。基于模型,通过前端UI的渲染引擎、后端接口生成引擎、数据库建表引擎,将模型解析为具体的功能。
elpis下载使用,点击这里
elpis的实现大致分为 5 个阶段:
- elpis-core设计
- 前端工程化
- 领域模型DSL设计
- 动态组件设计
- 抽离打包SDK
1. elpis-core 设计
1.1 elpis-core 是什么?
elpis的设计初衷是一个为了解决重复性CRUD的企业级、面向开发者的框架。我们的设计是基于约定范式,开发者需要遵守这个约定。
当然,elpis是全栈式框架,所以是以服务的形式运行的,具体技术栈选用的是nodejs,框架是 koa2。
对于这个约定,详细点来说,我们提供了多种加载器:
- 环境变量加载器
- 路由加载器
- 路由schema加载器
- 中间件加载器
- controller控制器加载器
- service服务加载器
- 其他功能加载器(如日志加载器)
对于开发者来说,开发者只需要根据约定书写对应的功能模块,elpis将这些模块功能集成到 koa2 服务上下文和服务应用 app 中,方便程序调用且减少了开发者的开发劳动。
当然,我们也开放一些自定义的能力:如开发者可以自定义自己的中间件。
1.2 elpis-core 的实现
当用户引入elpis-core并进行初始化调用后,elpis-core将会依次执行:envLoader、middlewareLoader、routreSchemaLoader、controllerLoader、serviceLoader、configLoader、extendLoader、自定义middleware、rouetrLoader,进行服务初始化。
1.2.1 envLoader
envLoader 负责提供环境变量的获取、判断等方法。
1.2.2 middlewareLoader
middlewareLoader 负责将业务代码下的 middlewares 文件夹下所有的中间件文件读取、加载、并挂载到应用 app 上,例如 /middlewares/a/b.js 可以通过 app.a.b 的形式调用。
1.2.3 routreSchemaLoader
routreSchemaLoader 负责加载业务代码下的 router-schema 文件夹下所有的 router-schema 文件、以打平的方式挂载到 app.routerSchema 下,后续直接通过 app.routerSchema.apiName 访问对应 api 路径为 apiName 的 schema。
1.2.4 controllerLoader
controllerLoader 负责加载业务代码下的 controller 文件夹下所有的 controller,后续通过 app.a.b 的形式直接调用 controller。controller 的实现我们采用面向对象的方式,将 controller 的通用方法收拢于 BaseController,其他的 controller 继承于 BaseController。
1.2.5 serviceLoader
serviceLoader 负责加载业务代码下的 service 文件夹下所有的 service,后续通过 app.a.b 的形式直接调用 service。service 的实现我们采用面向对象的方式,将 service 的通用方法收拢于 BaseService,其他的 service 继承于 BaseService。
1.2.6 configLoader
将环境变量相关配置存放于根目录的 config 文件夹下,具体的配置文件以 config.${env}.js 的形式存放,来支持不同环境变量的配置,如:
当开发者以不同的环境启动项目时,我们会先读取默认的环境变量配置文件 config.default.js,再读取对应环境变量的配置文件,以当前环境配置的配置为优先,合并、覆盖默认的配置,如:
则会:先读取 config.default.js 再读取 config.local.js 合并、覆盖默认配置。
1.2.7 extendLoader
extendLoader 负责加载业务代码下的 extend 通用能力方法,后续直接通过 app.extend.xxx 的形式调用。如:日志打印服务。
1.2.8 自定义middleware的加载
约定:存放于业务代码的 middleware.js。上面的 middlewareLoader 以及将所有的 middleware 加载到了 app.middlerwares 下,这里将所有的 middleware 进行注册,并且可以注册其他公共中间件,如静态资源托管中间件等等。
1.2.9 routerLoader
约定:开发者将路由文件存放在业务源代码的router文件夹下,以xxx.js名称即可,elpis-core 会自动加载注册对应的路由,如:
router文件书写的规范(elpis-core自动注入app和router):
module.exports = (app, router) => {
const { project: projectController } = app.controller;
router.get('/api/project', projectController.get.bind(projectController));
}
1.3 loader 代码示例
1.3.1 middlewareLoader 的实现
以下代码展示了 middlewareLoader 的实现,将 middleware 下的中间件收集到 app.middlewares下
const glob = require('glob');
const path = require('path');
const { sep } = path;
/**
* middleware loader
* @param {*} app koa实例
*
* 加载所有middleware,通过 'app.middle.${目录).${文件}' 访问
*
* 例子:
* app/middleware
* | -- custom-folder
* | -- custom-middleware.js
*
* 加载 => app.middleware.customFolder.customMiddleware
*/
module.exports = (app) => {
// 读取 app/middleware/**/*.js 所有文件
const middlerwarePath = path.resolve(app.businessDir, `.${sep}middlewares`);
const fileList = glob.sync(path.resolve(middlerwarePath, `.${sep}**${sep}**.js`));
// 遍历所有文件,将所有中间件加载到 app.middlewares 下
const middlerwares = {};
fileList.forEach(filePath => {
// 提取文件名称
let name = path.resolve(filePath);
// 截取路径 app/middlewares/custom-module/custom-middleware.js => custom-module/custom-middleware
name = name.substring(name.lastIndexOf(`middlewares${sep}`) + `middlewares${sep}`.length, name.lastIndexOf('.'))
// 把 中划线文件命名改为驼峰式
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase());
// 挂载 middleware 到app实例上
// => 并且转化为 { customModule: { customMiddleware } } 对象嵌套的形式,后续可以通过a.b.c的语法调用
let tempMiddlewares = middlerwares;
const fileNames = name.split(sep);
for (let i = 0; i < fileNames.length; i++) {
if (i === fileNames.length - 1) {
tempMiddlewares[fileNames[i]] = require(path.resolve(filePath))(app);
} else {
if (!tempMiddlewares[fileNames[i]]) {
tempMiddlewares[fileNames[i]] = {};
}
tempMiddlewares = tempMiddlewares[fileNames[i]];
}
}
});
app.middlerwares = middlerwares;
}
1.3.2 elpis使用示例
基本使用如下,后续会为 start 提供入参,以支持更多功能:如SSR渲染的数据注入等等。
const ELpis = require('./elpis-core');
ELpis.start();
2. elpis 为什么需要工程化?
elpis致力于打造企业级应用框架,支持多系统切换。elpis 的渲染模式:每个独立站点的入口由 SSR 渲染分发,而每个入口渲染的页面又是一个独立的 SPA 应用。
工程化在 elpis 中的首要作用就是:根据不同的入口文件,生成不同的页面模板,并指定页面模板需要注入的代码块。
其次,我们想要将代码提交到生成环境中运行,还需要代码进行兼容性处理、分包、压缩、打包分析等等,以保证生产环境代码的正常运行。再者,对于前端的代码的修改,每次修改后都需要重启服务器过于麻烦,因此对于开发环境,我们还需要代码的热更新功能,实现代码修改后的实时热更新,提升本地开发效率和体验。
最重要的是,elpis需要作为一个独立的npm包对外发布,也需要工程化!
2.1 SSR 入口文件的处理
对于SSR的入口文件,如果我们使用的是 Vue 框架,则我们需要创建一个 Vue-APP;如果我们使用的是 React,那么我们需要创建一个 React-APP;但是我们的目的打造的是一个多入口的应用,如果每个入口都重复的写一遍创建 APP 应用的代码,就显得很繁琐。
于是,我们可以对于 Vue 框架设计一个 boot-vue.js 的公共方法,用于创建 Vue 的 APP应用(React同理)。再通过配置项的形式给到不同的 APP 可以创建不同的 APP 实例,它们可以拥有不同的路由、不同的三方库等等。
我们以创建 Vue 的 APP 为例,创建一个 boot.js 方法用于创建 Vue-APP:
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/theme-chalk/index.css';
import 'element-plus/theme-chalk/dark/css-vars.css';
import pinia from '@store';
import { createRouter, createWebHistory } from 'vue-router';
/**
* 应用主入口 启动多个入口
* @param {Object} pageComponent 页面入口组件
* @param {Object} options 配置项
* - {Array} routes 路由配置
* - {Array} libs 需要加载的库
*/
export default (pageComponent, options) => {
const { routes, libs } = options || {};
const app = createApp(pageComponent);
app.use(ElementPlus); // 注册 UI 组件库
// 加载第三方库
if (libs && libs?.length > 0) {
try {
for (let i = 0; i < libs.length; i++) {
app.use(libs[i]);
}
} catch (error) {
console.error(`加载第三方库失败: ${error}`);
}
}
app.use(pinia); // 注册 pinia
// 页面路由
if (routes && routes?.length > 0) {
const router = createRouter({
history: createWebHistory(), // 统一使用 history 模式
routes,
});
app.use(router);
router.isReady().then(() => {
app.mount('#root');
})
} else {
app.mount('#root');
}
};
不同的应用页面入口,直接调用 boot.js 即可创建 APP 应用。
2.2 Vue 单文件的处理
因为使用到了 Vue 框架单文件,因此我们需要给工程加上处理 Vue 单文件的能力。
首先,以面向对象的编程思维,我们新建一个 webpack.common.js 文件,将开发环境和生产环境都需要的配置配置在此,而开发环境的入口文件 webpack.dev.js、生产环境的入口文件 webpack.prod.js 会先继承 webpack.common.js 文件配置,在新增各种环境所需的配置即可(这里使用的是 webpack,当然使用 vite 等其他构建工具也行)。
对于 Vue,我们需要使用解析 Vue 对应的 vue-loader 和 插件 VueLoaderPlugin。
const { VueLoaderPlugin } = require('vue-loader');
module.exports = {
module: {
rules: [
{
test: /\.vue$/,
use: [ 'vue-loader' ]
},
// ...
]
},
plugins: [
new VueLoaderPlugin(),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true', // 支持 vue 解析 optionApi
__VUE_PROD_DEVTOOLS__: 'false', // 禁用 Vue 调试工具
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' // 禁用 水合 信息
}),
]
}
2.3 入口文件的处理
约定:我们的入口文件以 entry.xxx.js 的命名形式存在的。
经过 vue 相关的 loader 和 插件的处理后,可以将 vue 文件打包成 js 文件,我们还需要根据不同的入口页面模板,给它们注入不同的代码块(我们的应用可能有很多很多的入口文件,如果每个入口文件都在 webpack.common.js 文件中单独声明且指定它们的代码块,那么这个配置文件就会显得特别臃肿。从工程化的角度,这里我们采用统一处理)。
// 所有页面的入口
const pageEntries = {};
// 所有页面的 html 插件配置
const htmlWebpackPluginList = [];
// 获取 app/pages 目录下所有的入口文件(entry.xxx.js)
const entryFileList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(entryFileList).forEach(file => {
const entryFileName = path.basename(file, '.js');
pageEntries[entryFileName] = file;
htmlWebpackPluginList.push(new HtmlWebpackPlugin({
template: path.resolve(process.cwd(), './app/views/entry.tpl'),
filename: path.resolve(process.cwd(), './app/public/dist/', `${entryFileName}.tpl`),
chunks: [ entryFileName ]
}))
})
module.exports ={
plugins: [
...htmlWebpackPluginList
]
}
2.4 样式文件的处理
处理 less、css 等样式文件,需要使用到对应的 loader(注意:loader 的执行顺序是从右到左)。
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader'],
},
2.5 处理图片等其他资源
webpack5 以及内置处理图片、字体文件等资源的功能,这里就不再赘述。
2.6 生产环境处理
考虑到项目庞大之后,打包的速度会很慢,打包后的代码体积会很大。所有我们针对这些问题需要做出对应的配置。
优化打包速度,以使用 thread-loader 为示例:
const ThreadLoaderConfig = {
workers: OS.cpus().length,
workerNodeArgs: ['--max-old-space-size=1024'],
workerParallelJobs: 50,
}
{
loader: 'thread-loader', // 小项目没有必要使用,启动进程也需要时间的
options: {
...ThreadLoaderConfig
}
},
优化单个文件打包体积过大,在 vue-router 上,我们使用动态导入的策略,加上 webpack 的分包策略,对 js 进行分包:
// 动态导入
component: () => import('./complex-view/schema-view/schema-view.vue')
// 分包
splitChunks: {
chunks: 'all',
maxAsyncRequests: 10,
maxInitialRequests: 10,
cacheGroups: {
vendor: { // 第三方模块
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 20,
enforce: true,
reuseExistingChunk: true,
},
common: {
test: /[\\/]widgets|common[\\/]/,
name: 'common',
minChunks: 2,
minSize: 1,
priority: 10,
reuseExistingChunk: true,
},
}
},
实现 js 文件的动态导入,减小首屏加载的资源体积。
2.7 开发环境搭建
使用 express 搭建本地服务器,结合 webpack 的中间件,监听文件的改动,当文件改动时,通知浏览器更新页面局部内容,达到实时热更新的效果。使用到的 webpack 中间件有 webpack-dev-middleware、webpack-hot-middleware,以及 HMR 插件 HotModuleReplacementPlugin。具体配置细节需要查看 webpack 官方文档。
// webpack.dev.js
const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const webpackCommonConfig = require('./webpack.common');
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 2024,
HMR_PATH: '__webpack_hmr',
TIMEOUT: 20000
}
Object.keys(webpackCommonConfig.entry).forEach(v => {
if (v !== 'vendor') {
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;
webpackCommonConfig.entry[v] = [
webpackCommonConfig.entry[v],
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}?timeout=${TIMEOUT}&reload=true`
]
}
})
const webpackDevConfig = merge.smart(webpackCommonConfig, {
mode: 'development',
// 开发环境打包产物输出
output: {
path: path.resolve(process.cwd(), './app/public/dist/dev/'), // dev 输出文件路径
publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, // 服务器-外部资源的路径
filename: 'js/[name]_[chunkhash:8].bundle.js',
globalObject: 'this',
},
plugins: [
// 应用运行时,可以热模块替换
new webpack.HotModuleReplacementPlugin({
multiStep: false,
}),
],
devtool: 'cheap-module-source-map'
})
module.exports = {
webpackDevConfig,
DEV_SERVER_CONFIG
};
本地开发环境启动入口文件,如下:
// dev.js 本地开发环境启动文件
const path = require('path');
const express = require('express');
const webpack = require('webpack');
const WebpackDevMiddleware = require('webpack-dev-middleware');
const WebpackHotMiddleware = require('webpack-hot-middleware');
const { webpackDevConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev');
const app = express(); // 初始化 express 服务
const compiler = webpack(webpackDevConfig);
// 指定 静态文件目录
app.use(express.static(path.join(__dirname, '../public/dist')))
// 监控文件改动
app.use(WebpackDevMiddleware(compiler, {
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
publicPath: webpackDevConfig.output.publicPath,
// headers
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, HEAD, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'X-Request-With, Content-Type, Authorization',
},
stats: {
colors: true,
}
}))
// 热更新,通知浏览器
app.use(WebpackHotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
log: () => {}
}))
console.log('=== 等待 webpack 初次构建完成... ===');
const { PORT } = DEV_SERVER_CONFIG;
app.listen(PORT, () => {
console.log(`dev server listening on port: ${PORT}`);
})
2.8 增加启动脚本
区分本地开发环境启动脚本、生产环境打包脚本
"build:dev": "node --max_old_space_size=4096 ./app/webpack/dev.js",
"build:prod": "node ./app/webpack/prod.js"
2.9 elpis 工程化小结
为了能够集成【node服务 => SSR => SPA】的能力我们处理了这些问题:
- 应用程序的创建方法封装
- Vue 文件的处理
- 页面模板与代码块的绑定
- 样式处理
- 资源处理
- 生产环境处理
- 开发环境搭建
这些处理只是冰山一角,还有很多需要处理的问题,如:代码兼容性处理(css兼容性处理、js兼容性处理等等),这里就不再赘述。
3. 领域模型DSL设计
前两节中都提到了“全栈领域模型”,但是没有对其进行详细的声明,可能大家对这个概念应该都很模糊。本文章将对其进行解释,并展现其设计思路。
既然是全栈领域模型,那么它的设计是服务于前、后两个端的。
我们的设计初衷是为了解决重复性劳动的问题,从多方面考虑,我们才有了面向对象的设计模式,将不同领域的公共部分沉淀下来,设计为领域模型的基类。再设计一层子类,子类通过继承基类,获取同样的能力,并且在通用能力有差异的情况下,子类可以通过重载,实现子类自己的能力;除此之外,子类拥有完全独立于自身的其他能力。我们根据子类则可以派生出不同的、独立的系统,这些独立的系统中拥有了大部分通用能力,因此对于相同领域的系统,我们避免了重复性的开发,而是将我们的精力集中在了领域模型的拓展、维护,以及独立系统的部分定制化开发。
3.1 领域模型的设计
我们下面以一个常见的后台管理系统为例(头部导航、侧边菜单、表格搜索、表格主体),讲解elpis领域模型的设计。
上面是一张常见的中后台布局图,第二步:elpis的工程化中,我们提到了: 【node => SSR => SPA】的前端渲染模式,前端独立系统我们采用主流的单页面应用SPA架构,菜单路由决定了应用渲染的内容,因此我们以菜单路由为模型设计的突破口。根据不同的菜单类型进行不同的逻辑处理。
我们集成了多种菜单、路由,其中最重要的就是 schema路由,schema路由对应的页面将使用模型解析引擎进行解析渲染。因此,模型需要有明确的数据源,有明确的渲染规则。
就数据源而言,我们约定以restful的规范定义接口api,所以模型给到一个api-url即可。
有了数据源,模型需要针对每个字段进行描述,描述字段在表格中的展示规则和在搜索区域的展示规则。
对于表格其他展示,如表格的操作列、表格数据的头部按钮(新增、批量删除、导入导出等等)、该页面的组件需要一份独立于字段之外的配置。
以下是一份简易的模型设计,模型1:
const modelConfig = {
modelName: 'model-1',
menu: [
{
key: 'menu-1',
menu: { // 菜单
schemaConfig: '/api/product', // 数据源
fieldSchema: { // 字段规则
type: 'object',
properties: {
key1: {
...schema, // 标准 schema 配置
type: 'string', // 字段类型
label: '', // 字段中文名
tableOption: {}, // key1字段在table中的展示规则
searchOption: {}, // key1字段在搜索栏中的展示规则
// ... 其他字段配置
},
key2: {
...schema, // 标准 schema 配置
type: 'string', // 字段类型
label: '', // 字段中文名
tableOption: {}, // key2字段在table中的展示规则
searchOption: {}, // key2字段在搜索栏中的展示规则
// ... 其他字段配置
}
}
},
tableConfig: {}, // 表格配置项
searchConfig: {}, // 搜索栏配置项
components: {}, // 组件配置项
// ... 其他配置项
}
}
]
};
首先,目前的模型1是对于前端页面的抽象描述,针对模型1,我们需要有对应的解析引擎来解析成具体的页面。解析规则大致是这样的:
如上图所示,我们的模型经过解析器解析出对应的数据,给到我们页面的table和search,它们根据解析后的数据,遵守模型的约定规则,进行页面渲染。
这是针对于模型1的解析规则,也是模型中的一种。甚至针对不同领域,我们可以用多种模型,每种模型可以拥有自己的解析规则和解析器。
经过api解析器和DB解析器解析出对应的业务api和建表语句,即可实现api的自动生成和DB的自动建表。
简单重复性的劳动,已经悄悄的转移到解析器和自动化脚本之中了。
3.2 schema解析器
有了DSL之后,我们需要对DSL进行解析,我们称之为schema解析器或者DSL解析器。
table的展示需要table的schema配置和字段在table中的描述配置:tableSchema + tableConfig。
search-bar的展示需要search-bar的schema配置和字段在search-bar中的描述配置:searchSchema + searchConfig。
动态组件也是同理,不过动态组件是schema和config都只在组件中使用,所以数据结构我们采用统一管理,而不是像table和search-bar一样分开:
component: {
schema: {},
config: {}
}
这样一来,DSL解析器的功能就很明朗了:它负责解析、组装数据,给到table、search-bar、动态组件。
3.3 DSL设计小结
elpis的设计初衷:为了解决简单重复性的开发工作。通过领域模型 + 解析器实现页面配置解析、自动化渲染,无需手动CRUD。
在这个设计下,对于能够沉淀到领域模型的业务开发来说,我们只需要做的是配置好模型。进而,我们能够拥有更多的时间给到定制化需要的开发。
4. 动态组件设计
4.1 动态组件设计理想
动态组件的设计仍然和DSL强绑定,我们通过在DSL中对schema-table的头部按钮和行内按钮进行事件描述,不同事件触发不同的场景:如新增按钮触发新增弹窗表单组件、修改按钮触发修改弹窗表单组件、查看详情对应查看详情的组件...。事件的分发统一由shema-view进行,组件的schema也统一由schema-view集中管理提供。
4.2 动态组件的scheme设计
配置项的字段命名是关联的,因为schema的解析统一由解析器进行解析。比如,新增组件的组件配置项命名为:createForm,则字段对应此组件的配置项则应为:createFormOption,组件配置的schema示例如下:
// 组件配置项
componentConfig: {
// 新增 form表单 组件配置
createForm: {
title: '', // 表单标题
saveBtnText: '', // 保存按钮文案
},
}
// 字段在组件中的展现配置项
createFormOption: {
...elComponentConfig,
comType: '', // 控件类型,如:input、select单独
visible: true, // 默认true,(true/false)false不展示
disabled: false, // 是否禁用(true/false)--是否可编辑
// comType === 'select' 时,启用
enumList: [], // 枚举值
// comType === 'dynamicSelect' 时,启用
api: '', // dynamicSelect 控件数据源
},
组件触发的源头是按钮,则我们需要对按钮事件也有对应的schema进行描述,描述触发什么事件、展示什么动态组件,scheme的设计如下:
rowButtons: [
{
label: '', // 按钮名称
eventKey: '', // 事件 key
eventOption: {
// 当 eventKey === 'showComponent' 时,启用 comName,决定调用哪个组件
comName: '', // 组件名
// 当 eventKey === 'remove'(add、remove、update、read)
params: {
// paramKey = 参数的键值
// rowValueKey = 参数值(格式:schema::tableKey时,从table中查找传值的值)
paramKey: rowValueKey,
}
}, // 按钮配置项
...elButtonConfig, // 标准的el-button 配置项
},
// ...
]
有了这个设计,我们点击按钮就能触发不同组件的不同事件,从而可以满足基本的CRULD操纵,对于这样的渲染模式能够满足要求的页面,则无需进行开发,只需要进行DSL的设计即可。
4.3 schema-form的设计
在动态组件中,schema-form扮演着重要的角色,schem-form负责将来自schema解析器解析出来的schema数据进行分发渲染。它支持多种组件的渲染,如input输入框、select选择器、picker日期选择器等等(这些组件都是可持续拓展的)。schema-form的渲染大致如下:
<template>
<el-row v-if="schema && Object.keys(schema.properties).length > 0" class="schema-form">
<template v-for="(schemaItem, key) in schema.properties">
<component
:is="formItemConfig[schemaItem.option?.comType]?.component"
v-show="schemaItem.option?.visible !== false"
ref="formCompRefList"
:schema-key="key"
:schema="schemaItem"
:model="model ? model[key] : undefined"
/>
</template>
</el-row>
</template>
schema-form的核心能力除了能够统一分发schema渲染字段组件之外,还需要有对子组件的值进行校验、取值的能力(能力收拢)。
4.4 动态组件设计小结
为了应对页面的CRUD以及其他常规操作,我们将这样通用能力沉淀在DSL中,通过DSL的描述,确定什么按钮触发什么组件的什么事件。在业务开发过程中,常规的需求我们只需进行DSL的模型配置。
5. 打包npm包
elpis的使用者是业务开发人员,作为一个npm包,内部应该是不含业务逻辑的,因此在打包发布为npm之前,我们需要对elpis进行提纯。除此之外,我们需要提供扩展能力给到开发人员。
5.1 对外提供服务启动能力、打包能力
elpis的主文件对外暴露启动服务的方法,打包前端工程的方法
const ELpis = require('./elpis-core');
const FRBuildDev = require('./app/webpack/dev')
const FRBuildProd = require('./app/webpack/prod')
module.exports = {
/**
* 启动 elpis
* @param {*} options 项目配置项,头传到 elpis 中
* @returns koa app
*/
startServer(options = { }) {
const app = ELpis.start(options);
return app;
},
/**
* 编译前端工程
* @param env 环境变量 - local/prod
*/
buildFrontend(env) {
if (env === 'local') {
FRBuildDev();
} else if (env == 'prod') {
FRBuildProd();
} else {
console.error(`the env for buildFrontend is error, must be 'local' or 'prod'`);
}
},
}
webpack 的dev、prod脚本,由原理的直接执行调整为函数,给到业务代码使用:
// webpack/dev.js
module.exports = () => {
// dev 具体代码
}
5.2 业务代码去除
将开发阶段用于调试的业务代码统统去除,只留下沉淀功能部分
- 去除
pages/project-list/ - 去除
pages/dashboard/todo-view/,自定义页面由业务开发人员自行提供 - 去除设计阶段的
docs/文档 - 去除服务端相关的业务代码:router-schema/router/controller/service
- 去除日志文件
log - 去除模型文件,由业务开发人员自行提供
5.3 能力扩展
5.3.1 elpis-core能力扩展
在能够加载elpis内部的router-schema/router/service/middleware等等能力的情况下,我们需要也能够加载业务代码对应的router-schema/router/service/middleware。 下面以router-schema的扩展为例:
// elpis router schema - 内置
const elpisRouterSchemaPath = path.resolve(__dirname, `..${sep}..${sep}app${sep}router-schema`);
const elpisFileList = glob.sync(path.resolve(elpisRouterSchemaPath, `.${sep}**${sep}**.js`));
elpisFileList.forEach(handleRouterSchema);
// business router schema - 业务
const businessRouterSchemaPath = path.resolve(app.businessDir, `.${sep}router-schema`);
const businessFileList = glob.sync(path.resolve(businessRouterSchemaPath, `.${sep}**${sep}**.js`));
businessFileList.forEach(handleRouterSchema);
5.3.2 路由扩展
我们希望能够加载业务代码中定义的路由,以实现自定义页面的开发:
// elpis - entry.dashboard.js 中
import businessDashboradRouterConfig from '@businessDashboradRouterConfig';
// 业务路由
if (typeof businessDashboradRouterConfig === 'function') {
businessDashboradRouterConfig({ routes, siderRoutes});
}
// 业务代码中 app/pages/dashboard/router.js
module.exports = ({ routes, siderRoutes }) => {
// 头部导航路由
routes.push({
path: '/view/dashboard/todo',
component: () => import('./todo-view/todo-view.vue')
});
routes.push({
path: '/view/dashboard/custom-1',
component: () => import('./custom-1-view/custom-1-view.vue')
});
// ... 其他自定义路由
// 侧边菜单路由
siderRoutes.push({
path: 'todo',
component: () => import('./todo-view/todo-view.vue')
});
// ... 其他自定义路由
}
5.3.3 自定义动态组件
elpis内置了常用的动态组件,但也需提供业务开发人员自定义动态组件,实现更多业务功能:
// elpis app/pages/dashboard/complex-view/schema-view/components/component-config.js 中
// 业务动态组件拓展配置
import businessComponentConfig from '@businessComponentConfig';
// 这里是elpis内置动态组件
const componentConfig = {};
export default {
...componentConfig,
...businessComponentConfig, // 可覆盖elpis内置的组件
};
// 业务代码中 app/pages/dashboard/complex-view/schema-view/components/component-config.js
import DemoComponent from "./demo-component/demo-component.vue";
const ComponentConfig = {
demoComponent: {
component: DemoComponent
}
}
export default ComponentConfig;
5.3.4 其他扩展
- schema-form 控件扩展能力提供
- schema-search-bar 组件扩展能力提供
- webpack配置能力提供
5.3.5 elpis中的第三方路径处理
作为独立的 npm 包,elpis 最终会跑在业务代码目录中,因此我们的第三方路径,以及elpis中获取业务代码的路径都需要处理。第三方包,我们需要加上 require.resolve 来告诉加载包的路径。
// elpis 中 webpack.common.js
module: {
rules: [
{
test: /\.vue$/,
use: [ require.resolve('vue-loader') ]
},
]
}
一些路径的处理,如业务页面入口的处理:
// elpis 中 webpack.common.js
// 业务 所有页面的入口
const businessPageEntries = {};
// 业务 所有页面的 html 插件配置
const businessHtmlWebpackPluginList = [];
// 获取 业务 app/pages 目录下所有的入口文件(entry.qqq.js)
const businessEntryFileList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(businessEntryFileList).forEach(file => {
hanldeEntryAndHtmlWebpackPlugin(file, businessPageEntries, businessHtmlWebpackPluginList);
});
// webpack 插件
plugins:[
...businessHtmlWebpackPluginList,
]
5.4 发布npm包
5.4.1 发布前的准备
- 写好 MD 文档
- 写好包版本、文件入口、作者、关键词
- 去掉多余的启动脚本
- 将除了elpis开发调试接口所需要的第三方包之外的包,作为 dependencies 依赖
5.4.2 正式发布
// 登录
npm login
npm who am i
// 告诉npm这是公告包,而不是私有包
npm publish --access public
5.4.2 使用
新建一个工程,下载elpis包并使用
// 服务启动入口 - server.js
const {
startServer
} = require('@fivaclo/elpis');
const app = startServer({
name: 'elpis-demo',
homePath: '/view/project-list'
});
// 前端工程打包入口 - build.js
const { buildFrontend } = require('@fivaclo/elpis');
buildFrontend(process.env._ENV);
新建启动服务的脚本
// package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "set _ENV=local&&nodemon ./server.js",
"beta": "set _ENV=beta&&nodemon ./server.js",
"prod": "set _ENV=prod&&nodemon ./server.js",
"build:dev": "set _ENV=local&&node ./build.js",
"build:beta": "set _ENV=beta&&node ./build.js",
"build:prod": "set _ENV=prod&&node ./build.js"
},
配置模型:在model/下配置模型,给到elpis解析
启动工程
// 启动服务
npm run dev
// 启动前端
npm run build:dev
5.4.3 npm 包地址
欢迎下载使用体验: www.npmjs.com/package/@fi…
引用
- 抖音“哲玄前端”《大前端全栈实践》
- nodejs
- koa
- json-schema
- ajv
- Nunjucks
- supertest
- log4js
- mocha
- vue
- vue-router
- pinia
- webpack
- axios
- nodemon
- element-plus
- npm