第一章:引擎内核实现
概述
本章节主要记录elpis-core内核的项目搭建及实现。 elpis的内核主要基于node.js的koa框架进行开发搭建,可以视做为一个简洁版的egg.js,其中使用了node经典的洋葱圈模型,下面我给大家来介绍下核心项目目录结构及作用。
项目目录结构

红色部分为我们提供给开发人员的项目目录的固定结构,蓝色部分则为内核的核心解析器部分,每一个loader在项目启动时都会自动加载app相同名称目录下的js文件。
项目目录的作用
configLoader(配置项)
serviceLoader(服务层)
middlewareLoader(中间件)
routerSchemaLoader(路由检验)
controllerLoader(控制器)
extendLoader(其他拓展功能)
routerLoader(路由)
他们都会将解析后的js挂载到koa的实例上,也就是app上。 例如:config.js (将config下的配置挂载到app.config)
项目的启动
elpis作为一个使用框架而非单纯的一个项目,所以我们需要抛出一个启动框架的包含启动函数的对象
scss
const Koa = require('koa')
const path = require('path')
const {sep} = path //兼容不同操作系统上的斜杠
const env = require('./env')
const middlewareLoader = require('./loader/middleware')
const routerLoader = require('./loader/router')
const routerSchemaLoader = require('./loader/router-schema')
const extendLoader = require('./loader/extend')
const configLoader = require('./loader/config')
const controllerLoader = require('./loader/controller')
const serviceLoader = require('./loader/service')
module.exports = {
/**
* 启动项目
* @params options 项目配置
options = {
name //项目名称
honePage //项目首页
}
*/
start(options = {}){
// koa 实例
const app = new Koa()
// 应用配置
app.options = options
//基础路径
app.baseDir = process.cwd()
//业务文件路径
app.businessPath = path.resolve(app.baseDir, `.${sep}app`)
console.log(`-- [start] businessPath: ${app.businessPath} --`)
app.env = env()
console.log(`-- [start] evn: ${app.env.get()} --`)
//加载 middleware
middlewareLoader(app)
console.log(`-- [start] load middleware done --`)
//加载 routerSchema
routerSchemaLoader(app)
console.log(app.routerSchema)
console.log(`-- [start] load routerSchema done --`)
//加载 controller
controllerLoader(app)
console.log(app.controller)
console.log(`-- [start] load controller done --`)
//加载 service
serviceLoader(app)
console.log(app.service)
console.log(`-- [start] load service done --`)
//加载 config
configLoader(app)
console.log(app.config)
console.log(`-- [start] load config done --`)
//加载 extend
extendLoader(app)
console.log(`-- [start] load extend done --`)
//注册全局中间件
try{
require(`${app.businessPath}${sep}middleware.js`)(app)
console.log(`-- [start] load global middleware done --`)
}catch (e){
console.log('[exception] there is no global middleware file.')
}
// 注册路由
routerLoader(app)
console.log(`-- [start] load router done --`)
// 启动服务
try{
const port = process.env.PORT || 80
const host = process.env.IP || '0.0.0.0'
app.listen(port, host)
console.log('Server is running on port ' + port, 'http://localhost:'+port)
}catch (e){
console.error(e)
}
}
}
我们在启动方法中,会运行我们的loader,通过loader挂载到app的实例对象上,这里我们需要注意一下各个中间件的加载顺序。例如controllerLoader需要用到app.services,那就需要serviceLoader先于controllerLoad挂载。
第二章:webpack配置
概述
elpis的工程化是通过webpack工具进行构建的。看完本章节你就学习到什么是工程化、如何进行工程化,不管是用webpack还是vite或者其他,它们都是我们工程化的一个工具或者说手段,主要是要理解工程化的思想,接下来我们就一起来看下工程化在elpis中的具体实践。
elpis工程化架构前端目录

图中为我们的前端工程的目录结构
pages 前端工程的根路径
asserts 静态文件目录
common 公共js文件目录
page 存放页面,因为elpis为多页面的一个架构,当然可以有多个存放页面的目录,但要注意的是每个目录下只能有一个页面,里面需要有个页面Vue文件,文件名称尽可能和目录名称保持一致。还需要有一个entry.**.js的入口文件
store 存在状态管理(pinia)
boot.js 这里将我们页面目录下的vue文件进行注册

上图为webpack工程化的目录结构,打包我们分为开发环境(dev.js)和生产环境(prod.js)两种。在config文件夹中有一个webpack.base.js的文件,这里的思想和BFF层设计保存一致,比如controller中的base.js,都为了我们更好的扩展
webpack打包流程
这里主要介绍webpack的打包过程
- 加载config配置
- 读取入口文件
- 依赖解析
- loader处理
- plugin处理
- 生成打包文件
具体配置
接下来我只介绍比较重要的部分,其他的配置可具体查询webpack官网
代码分割部分
这里为开发环境的代码分割打包配置
javascript
// 配置打包输出优化(代码分割, 模块合并, 缓存, TreeShaing, 压缩等优化策略)
optimization: {
/**
* 把 js 文件打包成3种类型
* 1. vendor: 第三方 lib 库, 基本不会改动,除非依赖版本升级
* 2. common: 业务组件代码的公共部分抽取出来, 改动较小
* 3. entry.{page}: 不同页面 entry 里的业务组件代码的差异部分, 会经常改动
* 目的: 把改动和引用频率不一样的 js 区分出来, 以达到更好利用浏览器缓存的效果
*/
splitChunks: {
chunks: 'all', // 对同步和异步模块都进行分割
maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
maxInitialRequests: 10, // 入口点的最大并行请求
cacheGroups: {
vendors: { // 第三方依赖库
test: /[\/]node_modules[\/]/,
name: 'vendor', //模块名称
priority: 20, // 优先级, 数字越大, 优先级越高
enforce: true, //强制执行
reuseExistingChunk: true, // 复用已有的公共 chunk
},
commons: { // 公共模块
name: 'common', //文件名称
minChunks: 2, //被两处引用即被归为公共模块
minSize: 1, // d最小分割文件大小 (1 byte)
priority: 10, // 优先级, 数字越大, 优先级越高
}
}
},
// 将 webpack 运行时生产的代码打包到 runtime.js
runtimeChunk: true
}
html模版输出插件配置
less
//获取 app/pages 目录下所有的入口文件 (entry.xx.js)
const entryList = path.resolve(process.cwd(), "./app/pages/*/entry.*.js");
glob.sync(entryList).forEach(file => {
const entryName = path.basename(file,'.js');
// 构造 entry
pageEntries[entryName] = file;
// 构造最终渲染的页面文件
htmlWebpackPlugin.push(
// html-webpack-plugin 辅助注入打包后 bundle 文件到 tpl 中
new HtmlWebpackPlugin({
// 产物(最终模版)输出路径
filename: path.resolve(process.cwd(), './app/public/dist/', `${entryName}.tpl`),
// 指定要使用的模版文件
template: path.resolve(process.cwd(), './app/view/entry.tpl'),
// 要注入的代码块
chunks: [entryName]
})
);
})
这里主要用到的是html-webpack-plugin这个插件,因为是多页面入口项目,所有我们需要拿到pages目录下所有的/entry.*.js,通过指定我们打包输出的路径及我们打包文件的参照模版以及配置需要注入的代码块,最终构建出我们想要的打包文件。
生产环境插件配置
这里使用了css公共提取、压缩及多线程css、js打包(HappyPack)来减少我们生产包的体积及打包的速度
js
plugins: [
// 每次 build 前,清空 public/dist 目录
new CleanWebpackPlugin(['public/dist'],{
root:path.resolve(process.cwd(), './app/'),
exclude:[],
verbose:true,
dry:false
}),
// 提取 css 的公共部分,有效利用缓存
new MiniCssExtractPlugin({
chunkFilename:'css/[name]_[contenthash:8].css',
}),
// 优化并压缩 css 资源
new CSSMinimizerPlugin(),
// 多线程打包 JS,加快打包速度
new HappyPack({
...happypackCommonConfig,
id:'js',
loaders:[`babel-loader?${JSON.stringify({
presets: ['@babel/preset-env'],
plugins:[
'@babel/plugin-transform-runtime',
]
})}`]
}),
// 多线程打包 css,加快打包速度
new HappyPack({
...happypackCommonConfig,
id:'css',
loaders:[{
path:'css-loader',
options:{
importLoaders:1,
}
}]
}),
// 浏览器在请求资源时不发送用户的身份凭证
new HtmlWebpackInjectAttributesPlugin({
crossorigin:'anonymous',
})
],
热更新
热更新的过程:首先当我们修改了文件,热更新的服务(express)会监听的到,然后进行重新打包,当打包完成后,就会通知浏览器的对应的文件,让浏览器重新加载我们的最新文件。
热更新服务使用express框架服务,热更新的实现主要使用两个中间件:
-
webpack-dev-middleware(热更新服务和本地文件保持通讯)
-
webpack-hot-middleware(热更新服务和浏览器文件保持通讯)
这里有一个比较好的优化点:前几节课,我们都将各种tpl模版和资源文件落地为实物文件,如何进行热更新的话,我们每修改一下都将重新生成文件,这会影响我们的更新效率,所以我们只生成tpl实体文件,他们的引用资源都将放在我们的服务器内存中,以代码片段的形式保存,tpl文件引用的也是我们服务器的代码片段地址,这里极大的提升了我们更新的效率。
第三章 领域模型DSL设计与实践
概述
作为一名前端开发,我们每天都会和页面打交道。为了产品页面交互和风格的一致性,产品经理会在各个业务功能、页面设计(原型图)之间保持统一风格(理想状态下),但往往不同的业务方案会给我们不同的页面输出,尽管产品经理已经进了最大努力去磨平差异,但页面间还是会有一小部分的不同,这是业务导致的。那么这个问题应该怎么解决呢?大部分同学通常会把相似的页面复制一份,根据当前的业务改一改就OK,随着业务不断壮大,我们的代码也会越来越臃肿,那么恭喜你,屎山已经堆砌好了。那有没有比较好的系统解决方案呢?这里不得不提到Elpis,Elpis为此而生!
什么是Elpis
Elpis是前后端分离的领域模型框架,通过DSL动态生成不同的页面,来满足我们日常差异化开发,这里主要来介绍DSL。 

DSL标准配置
go
module.exports = {
mode: 'dashboard', // 模版类型,不同模版类型对应不一样的模版数据结构
name: '', // 名称
desc: '', // 描述
icon: '', // 图标
homePage: '', // 项目首页( )
// 头部菜单
menu: [{
key: '', // 菜单唯一描述
name: '', // 菜单名称
menuType: '', // 枚举值,group / module
// 当 menuType === group 时,可填
sunMenu: [{
// 可递归 menuItem
}],
// 当 menuType === module 时,可填
moduleType: '', // 枚举值:sider/iframe/custom/schema
// 当 moduleType == sider 时
siderConfig: {
menu: [{
// 可递归 menuItem(除 moduleType == sider)
}]
},
// 当 moduleType == iframe 时
iframeConfig: {
path: '', // iframe 路径
},
// 当 moduleType == custom 时
customConfig: {
path: '' // 自定义路由路径
},
// 当 moduleType == schema 时
schemaConfig: {
api: '', // 数据源 API (遵循 RESTFUL 规范)
schema: { // 板块数据结构
type: 'object',
properties: {
key: {
...schema, // 标准 schema 配置
type: '', // 字段类型
label: '', // 字段的中文名
// 字段在 table 中的相关配置
tableOption: {
...elTableColumnConfig, // 标准 el-table-column 配置
toFixed: 0, // 保留小数点后几位
visiable: true, // 默认为 true(false时,表示不在表单中显示)
},
// 字段在 search-bar 中的相关配置
searchOption: {
...eleComponentConfig, // 标准 el-search-bar 配置
comType: '', // 配置组件类型 input/select/...
default: '', // 默认值
// comType === 'select'
enumList: [], // 下拉框选项列表
// comType === 'dynamicSelect'
api: '', // 动态下拉框数据源 API
},
}
}
},
tableConfig: {
headerButtons: [{
label: '', // 按钮中文名
eventKey: '', // 按钮事件名
option: {}, // 按钮具体配置
...elButtonConfig // 标准 el-button 配置
}],
rowButtons: [{
label: '', // 按钮中文名
eventKey: '', // 按钮事件名
eventOption: {
// 当 eventKey === 'remove'
params: {
// paramKey = 参数的键值
// rowValueKey = 参数值,格式为 schema::tableKey,到 table 中找相应的字段
paramKey: rowValueKey
}
}, // 按钮具体配置
...elButtonConfig // 标准 el-button 配置
}],
}, // table 相关配置
searchConfig: {}, // search-bar 相关配置
component: {}, // 模块组件
}
}]
}
一份DSL配置就是一个系统描述文件,这里我们以dashboard为例
js
homePage 设置我们系统首页
menu 头部菜单集合
menuType 指定菜单类型(group/module),当菜单为下拉多个菜单时,设置为group
sunMenu 当menuType为group时,配置子菜单,配置项和menu一致(共用一份配置)
moduleType 当menuType为module时配置,共有四个配置项(sider/iframe/custom/schema)
siderConfig 有左侧菜单栏时配置,可配置menu,注意左侧菜单栏配置项中除去 moduleType == sider
iframeConfig 引入第三方页面资源配置
customConfigConfig 自定义页面,当有个别特殊页面无法配置时,这时候就需要自己去实现页面。
schemaConfig 标准配置页面配置项
api 这里设置获取业务数据API,需要遵循 RESTFUL 规范
schema 定义板块数据结构,是标准 schema 配置,里面可配置 tableOption(表格),searchOption(表单搜索)
tableConfig 配置headerButtons(表格上方按钮),rowButtons(表格行按钮)
searchConfig search-bar 相关配置
component 模块组件

在项目中文件结构如上图所示 model.js 里面为我们项目的标准页面配置(可理解为面相对象的基类) project 中的每一个js都是一个项目的配置 那项目中是怎么继承基类的配置的呢?
ini
const _ = require('lodash')
const glob = require('glob')
const path = require('path')
const {sep} = path
// project 继承 model 方法
const projectExtendModel = (model, project) => {
return _.mergeWith({}, model, project, (modelValue, projValue) => {
// 处理数组合并的特殊情况
if (Array.isArray(modelValue) && Array.isArray(projValue)) {
let result = []
// 因为 project 继承 model,所以需要处理修改内容和新增内容的情况
// project有的键值,model也有 => 修改(重载)
// project有的键值,model没有 => 新增(拓展)
// model有的键值,project没有 => 保留(继承)
// 处理修改和保留
for (let i = 0; i < modelValue.length; i++) {
let modelItem = modelValue[i]
const projItem = projValue.find(projItem => projItem.key === modelItem.key)
// project 有的键值,model也有,则递归调用 projectExtendModel 方法覆盖修改
result.push(projItem ? projectExtendModel(modelItem, projItem) : modelItem)
}
// 处理新增
for (let i = 0; i < projValue.length; i++) {
const projItem = projValue[i]
const modelItem = modelValue.find(modelItem => modelItem.key === projItem.key)
if (!modelItem) {
result.push(projItem)
}
}
return result
}
})
}
/**
* 解析 model 配置,并返回组织且继承后的数据结构
* 【{
* model: ${model},
* project: {
* proj1Key: ${proj1}
* proj2Key: ${proj2}
* }
* },...】
*/
module.exports = (app) => {
const modelList = []
// 遍历当前文件夹,构造模型数据结构,挂载到 modelList 上
const modelPath = path.resolve(app.baseDir, `.${sep}model`)
const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`))
fileList.forEach(file => {
if (file.indexOf('index.js') > -1) {
return
}
// 区分配置类型(model / project)
const type = file.indexOf(`/project/`) > -1 ? 'project' : 'model'
if (type === 'project') {
const modelKey = file.match(//model/(.*?)/project/)?.[1]
const projKey = file.match(//project/(.*?).js/)?.[1]
let modelItem = modelList.find(item => item.model?.key === modelKey)
if (!modelItem) { // 初始化 model 数据结构
modelItem = {}
modelList.push(modelItem)
}
if (!modelItem.project) { // 初始化 project 数据结构
modelItem.project = {}
}
modelItem.project[projKey] = require(path.resolve(file))
modelItem.project[projKey].key = projKey // 注入 projectKey
modelItem.project[projKey].modelKey = modelKey // 注入 modelKey
}
if (type === 'model') {
const modelKey = file.match(//model/(.*?)/model.js/)?.[1]
let modelItem = modelList.find(item => item.model?.key === modelKey)
if (!modelItem) { // 初始化 model 数据结构
modelItem = {}
modelList.push(modelItem)
}
modelItem.model = require(path.resolve(file))
modelItem.model.key = modelKey // 注入 modelKey
}
})
// 数据进一步整理: project => 继承 model
modelList.forEach(item => {
const {model, project} = item
for (const key in project) {
project[key] = projectExtendModel(model, project[key])
}
})
return modelList
}
第四章:动态组件库
概述
在上一章节,我们介绍了模型模页面的DSL,通过DSL配置就可以派生出很多个不同的模型实例,这很大程度上解决了我们重复劳动的问题。在我们平时开发过程中,最小的开发颗粒度就是组件级别的,比如第三方的elementUI,我们使用这些第三方的组件也是为了解决重复劳动及易用性的问题,尽管如此我们还是不断的在重复写着el-form、el-table...等等,那我们能不能把这些常用的组件也进行封装,并将其纳入我们的DSL里呢?当然可以,这样不仅可以生成模型的实例框架,还可以通过DSL配置,达到一步到位的效果。接下里我们就来看看具体的配置吧。
schemaForm实现
我们以schemaForm为例

我们在组件目录中来开发schemaForm组件,这里只封装了input、input-number、select三个组件,这里就不赘述封装的细节了。封装好组建后,在form-item-config.js中我们将组件保存在一个对象中用来在schema-form.vue文件中引入,通过动态组件方式渲染
form-item-config.js
css
import input from './complex-view/input.vue'
import inputNumber from './complex-view/input-number.vue'
import select from './complex-view/select.vue'
const FormItemConfig = {
input: {
component: input
},
inputNumber: {
component: inputNumber
},
select: {
component: select
}
}
export default FormItemConfig
schema-form.vue 动态渲染部分
ini
<template v-for="(itemSchema,key) in schema.properties">
<component
ref="formConList"
v-show="itemSchema.option.visible !==false"
:is="FormItemConfig[itemSchema.option?.comType]?.component"
:schemaKey="key"
:schema="itemSchema"
:model="model ? model[key]: undefined"
></component>
</template>
createForm、editForm、detailPanel 思想都是一样的
DSL配置
css
product_id: {
type: 'string',
label: '商品名称',
tableOption: {
width: 300,
'show-overflow-tooltip': true,
},
createFormOption: {
comType: 'input'
},
editFormOption: {
comType: 'input',
disabled: true
},
detailPanelOption: {}
}
在DSL中,以商品ID这个字段为例,因为当前的字段在新建表单的时候用到,createFormOption,这个字段有两个作用,1.编辑表单字段的表示。2.描述在编辑表单中的具体配置。编辑同理。
解析DSL
设计好DSL后,我们我们是怎么进行解析呢?
ini
// 构造 schemaConfig 相关配置,输送 schemaView 解释。
const buildData = function () {
const {key, sider_key: siderKey} = route.query
const mItem = menuStore.findMenuItem({
key: 'key',
value: siderKey ?? key
})
if (mItem && mItem.schemaConfig) {
const {schemaConfig: sConfig} = mItem
const configSchema = JSON.parse(JSON.stringify(sConfig.schema))
api.value = sConfig.api ?? ''
tableSchema.value = {}
tableConfig.value = undefined
searchSchema.value = {}
searchConfig.value = undefined
components.value = {}
nextTick(() => {
// 构造 tableSchema 和 tableConfig
tableSchema.value = buildDtoSchema(configSchema, 'table')
tableConfig.value = sConfig.tableConfig
// 构造 searchSchema 和 searchConfig
const dtoSearchSchema = buildDtoSchema(configSchema, 'search')
for (const key in dtoSearchSchema) {
if (route.query[key] !== undefined) {
dtoSearchSchema.properties[key].option.default = route.query[key]
}
}
searchSchema.value = dtoSearchSchema
searchConfig.value = sConfig.searchConfig
// 构造 components = { comKey: { schema, config } }
const {componentConfig} = sConfig
if (componentConfig && Object.keys(componentConfig).length) {
const dtoComponents = {}
for (const comName in componentConfig) {
dtoComponents[comName] = {
schema: buildDtoSchema(configSchema, comName),
config: componentConfig[comName]
}
}
components.value = dtoComponents
}
})
}
}
// 通用构建 schema 方法(清除噪音)
const buildDtoSchema = function (_schema, comName) {
if (!_schema?.properties) {
return {}
}
const dtoSchema = {
type: 'object',
properties: {}
}
// 提取有效 schema 字段信息
for (const key in _schema.properties) {
const props = _schema.properties[key]
if (props[`${comName}Option`]) {
let dtoProps = {}
// 提取 props 中非 option 的部分,存放在 dtoProps 中
for (const pKey in props) {
if (pKey.indexOf('option') < 0) {
dtoProps[pKey] = props[pKey]
}
}
// 处理 comName Option
dtoProps = Object.assign({}, dtoProps, {option: props[`${comName}Option`]})
// 处理 required 字段
const {required} = _schema
if (required && required.find(pk => pk === key)) {
dtoProps.option.required = true
}
dtoSchema.properties[key] = dtoProps
}
}
return dtoSchema
}
tableSchema(表格schema配置)、tableConfig(表格配置)、searchSchema(表单schema配置)、searchConfig(表单配置)、components(组件Map)
第五章 npm包抽离和发布
概述
最后一个章节,主要介绍如何抽离和发布自己的npm包。
npm包抽离
核心思想是我们eplis-core中各个loader由原来的加载解析elpis里的对应的文件夹改为还需要加载业务使用方项目对应的文件夹,webpack的配置同理。我们以middleware为例
ini
module.exports = (app) => {
const middlewares = {}
// 读取 elpis/app/middleware/**/**.js 下所有的文件
const elpisMiddlewarePath = path.resolve(__dirname, `..${sep}..${sep}app${sep}middleware`)
const elpisFileList = glob.sync(path.resolve(elpisMiddlewarePath, `.${sep}**${sep}**.js`))
elpisFileList.forEach(file => {
handleFile(file)
})
// 读取 业务根目录/app/middleware/**/**.js 下所有的文件
const businessMiddlewarePath = path.resolve(app.businessPath, `.${sep}middleware`)
const businessFileList = glob.sync(path.resolve(businessMiddlewarePath, `.${sep}**${sep}**.js`))
businessFileList.forEach(file => {
handleFile(file)
})
// 把内容加载到 app.middlewares 下
function handleFile(file) {
//提取文件名称
let name = path.resolve(file)
//截取路径 app/middlewares/custom-module/custom-middleware.js => custom-module/custom-middleware.js
name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf(`.`))
//把 '-' 统一改为驼峰式,custom-module/custom-middleware.js => customModule.customMiddleware
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
//挂载 middleware 到内存 app 对象上
let tempMiddleware = middlewares
const names = name.split(sep)
for (let i = 0, len = names.length; i < len; ++i) {
if (i === len - 1) {
tempMiddleware[names[i]] = require(path.resolve(file))(app)
} else {
if (!middlewares[names[i]]) {
tempMiddleware[names[i]] = {}
}
tempMiddleware = middlewares[names[i]]
}
}
}
app.middlewares = middlewares
}
webpack中需要注意的是第三方包的引用,我们需要require.resolve() 方式引入
npm包发布
本地调试
- 在elpis的开发目录使用
npm link - 创建一个测试目录,使用
npm link 包名链接包到本地开始测试
发布
- 创建npm帐号并登录
npm config get registry确保是官方镜像npm publish,首次发布,需要加上--access public参数 表示这不是一个私有包