基于vue3和koa2打造的一款企业级应用框架(建设中)-Elpis

前言

对于大多数后台管理系统而言,往往是由layout,header,sider,main组成,而main绝大多数都是有search-form、table组成,对于大多数中后台的前端开发者,每天可能就是在重复的做着相同的crud的工作,而elpis设计的目的就是为了解决这个问题。

接下来我将从围绕5个方面来讲述elpis这个全栈框架是如何帮助我们来解决这大量重复的crud的工作的。

一、elpis-core内核的设计(简易版egg.js)

elpis-core基于koa2封装,秉承"约定大于配置"的理念,主要依赖于loader来加载文件,由以下模块组成

  • middlewareLoader (挂载中间件,例如验签、校验参数)
  • routerSchemaLoader (描述api请求规范)
  • controllerLoader (控制器,接受用户的输入,返回结果)
  • serviceLoader (业务逻辑层)
  • configLoader (配置项)
  • extendLoader(框架拓展能力,例如日志服务、数据库连接)
  • elpisMiddleware (注册中间件)
  • routerLoader (注册路由)

接下来以middlewareLoader为例来阐述elpis-core是如何挂载中间件的

js 复制代码
const path = require('path');
const glob = require('glob');
const { sep } = path;
/**
 * middleware loader
 * @param {Object} app koa实例
 *
 * 加载所有middleware,可通过app.middlewares.${目录}.${文件}访问
 *
 * @example
 * app/middleware
 *   |
 *   | -- custom-mudule
 *            |
 *            | -- custom-middleware.js
 *
 *   => app.middlewares.customMudule.customMiddleware
 */
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.bussinessPath, `.${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/middleware/custom-mudule/custom-middleware.js => custom-mudule/custom-middleware
    name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'));

    //将 '-' 命名替换成驼峰命名 custom-mudule/custom-middleware=> customMudule/customMiddleware
    name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());

    //挂载middlewares到内存对象app中
    let tempMiddleware = middlewares;
    const names = name.split(sep);
    names.forEach((item, index) => {
      if (index === names.length - 1) {
        //文件
        tempMiddleware[item] = require(path.resolve(file))(app);
      } else {
        // 文件夹 递归  tempMiddleware = {a:{b:{c:{d:{}}}
        tempMiddleware[item] = tempMiddleware[item] ?? {};
        //指向下一层
        tempMiddleware = tempMiddleware[item];
      }
    });
  }

  app.middlewares = middlewares;
};

elpis是一个框架,所以elpis-core它不仅仅要能够挂载框架自身提供的中间件的能力,同时也需要有挂载框架使用者业务上自定义的中间件的能力。其他loader也是同理,在处理框架本身文件的同时,也要处理框架使用者业务上的定制需求。

二、elpis工程化的设计

工程化是现代前端开发的基石,通过一系列规范、工具和流程来提高开发效率和质量的实践,而elpis致力于打造一个企业级应用框架,必须集成工程化的能力,提高项目的开发效率和可维护性。

elpis通过ssr动态分发站点,每个入口承载不同的应用,所以elpis要解决的是根据不同的入口文件,生成不同的页面模板,同时也要为使用者提供开发环境下所需要承载的热更新能力,以及生产环境的分包、压缩等能力。

工程化解决方案
  • 入口文件的处理,约定入口文件为entry.xx.js
js 复制代码
const elpisPageEntries = {};
const elpisHtmlWebPackPluginList = [];
//获取elpis/app/pages下面的所有入口文件(entry.xxx.js)
const elpisEntryPathList = path.resolve(__dirname, '../../pages/**/entry.*.js');
glob.sync(elpisEntryPathList).forEach((file) => {
  hanleFile(file, elpisPageEntries, elpisHtmlWebPackPluginList);
});
//获取业务/app/pages下面的所有入口文件(entry.xxx.js)
const businessPageEntries = {};
const businessHtmlWebPackPluginList = [];
const businessEntryPathList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(businessEntryPathList).forEach((file) => {
  hanleFile(file, businessPageEntries, businessHtmlWebPackPluginList);
});

function hanleFile(file, pageEntries = {}, htmlWebPackPluginList = []) {
  const entryName = path.basename(file, '.js');
  pageEntries[entryName] = file;
  htmlWebPackPluginList.push(
    new HtmlWebPackPlugin({
      // 产物(最终模板)输出路径
      filename: path.resolve(process.cwd(), './app/public/dist/', `${entryName}.tpl`),
      //指定要使用的模板文件
      template: path.resolve(__dirname, '../../view/entry.tpl'),
      // 要注入的代码块
      chunks: [entryName],
    }),
  );
}
// elpis webpack基础配置
const elpisConfig = {
  //入口配置
  entry: Object.assign({}, elpisPageEntries, businessPageEntries)
 }
  • 利用loader对模块的源代码进行转化
  • 利用plugin赋予webpack额外的能力,进行打包优化
  • 利用分包策略达到更好的利用浏览器缓存的目的
  • 开发环境热更新的能力
webpack.dev.js 复制代码
const merge = require('webpack-merge');
const path = require('path');
const webpackBaseConfig = require('./webpack.base.js');
const webpack = require('webpack');

//devserver 配置
const DEV_SERVER_CONFIG = {
  HOST: '127.0.0.1',
  PORT: 9002,
  HMR_PATH: '__webpack_hmr', // 官方规定
  TIMEOUT: 20000,
};
//  开发环境的entry配置需要注入 hmr
Object.keys(webpackBaseConfig.entry).forEach((key) => {
  //第三方包不需要热更新
  if (!key.indexOf('vendor') != -1) {
    webpackBaseConfig.entry[key] = [
      webpackBaseConfig.entry[key],
      `${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${
        DEV_SERVER_CONFIG.PORT
      }/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`,
    ];
  }
});

// 开发环境webpack配置
const webpackConfig = merge.smart(webpackBaseConfig, {
  //开发环境插件配置
  ...省略
  plugins: [
    // 热模块替换插件(HMR)
    // 模块热替换允许在应用程序运行时替换模块
    // 不需要刷新整个页面就能实现代码更新,提高开发效率
    new webpack.HotModuleReplacementPlugin(),
  ],
});
module.exports = {
  //webpack 配置
  webpackConfig,
  //devserver 配置,暴露给dev.js使用
  DEV_SERVER_CONFIG,
};
启动文件dev.js 复制代码
//本地开发启动 devserver
const path = require('path');
const consoler = require('consoler');
const express = require('express');
const webpack = require('webpack');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');

//从webpack.dev.js中获取webpack配置和devserver配置
const { webpackConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev.js');

module.exports = () => {
  const app = express();
  const compiler = webpack(webpackConfig); //初始化配置
  //指定静态目录
  app.use(express.static(path.resolve(process.cwd(), './app/public/dist/')));
  // 引入 webpack-dev-middleware 中间件 (监控文件改动)
  app.use(
    devMiddleware(compiler, {
      //落地文件
      writeToDisk: (filePath) => filePath.endsWith('.tpl'),
      //资源路径
      publicPath: webpackConfig.output.publicPath,

      //headers配置
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
        'Access-Control-Allow-Headers': 'X-Request-With,content-type,Authorization',
      },
      stats: {
        colors: true, //在控制台显输出色彩信息
      },
    }),
  );

  // 引入 webpack-hot-middleware 中间件(实现热更新通讯)
  app.use(
    hotMiddleware(compiler, {
      //热更新的路径
      path: '/' + DEV_SERVER_CONFIG.HMR_PATH,
      log: () => {},
    }),
  );

  consoler.info('请等待webpack初次构建完成提示...');
  const port = DEV_SERVER_CONFIG.PORT;
  // 启动devserver
  app.listen(port, () => {
    console.log(`dev server listening on port ${port}`);
  });
};

webpack仅仅是工程化的一部分,在elpis中帮我们处理了多入口的配置、开发环境的搭建、源文件以及资源等转化、生产环境的打包等等,工程化还包含很多,例如CICD,后续会提到,这里就不做多的说明。

三、基于领域模型的dsl设计

领域模型是对业务领域的抽象表示,定义业务实体和规则,而dsl则是已更贴近业务的语言来描述规则,用更加通俗的话来讲就是领域模型负责做什么,dsl负责怎么描述。

通常我们常见的一个中后台管理系统的架构都如下图所示

从图中我们可以看到,系统中80%的工作可能都是重复性的crud,只有20%的工作是需要额外定制开发的,那我们是不是可以寻求一种方法来处理这80%的重复性工作。

如何设计dsl

以下是一份dsl demo

js 复制代码
module.exports = {
  name: '拼多多',
  desc: '拼多多电商系统',
  homePage: '/schema?proj_key=pdd&key=product',
  menu: [
    {
      key: 'product',
      name: '商品管理(PDD)',
    },
    {
      key: 'client',
      name: '客户管理(PDD)',
      moduleType: 'schema',
      schemaConfig: {
        api: '/api/client',
        schema: {},
      },
    },
    {
      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://cn.vuejs.org',
            },
          },
          {
            key: 'categories',
            name: '分类数据',
            menuType: 'group',
            subMenu: [
              {
                key: 'category-1',
                name: '一级分类',
                menuType: 'module',
                moduleType: 'custom',
                customConfig: {
                  path: '/todo',
                },
              },
              {
                key: 'category-2',
                name: '二级分类',
                menuType: 'module',
                moduleType: 'custom',
                customConfig: {
                  path: '/todo',
                },
              },
              {
                key: 'tags',
                name: '标签管理',
                menuType: 'module',
                moduleType: 'schema',
                schemaConfig: {
                  api: '/api/tags',
                  schema: {},
                },
              },
            ],
          },
        ],
      },
    },
    {
      key: 'search',
      name: '信息查询',
      menuType: 'module',

      moduleType: 'iframe',
      iframeConfig: {
        path: 'https://cn.vuejs.org',
      },
    },
  ],
};

这个时候80%的重复性工作已经转变成如何配置一份站点需要的dsl,当配置多份dsl的时候,我们会发现相似站点之间的dsl存在很多共性,因此我们可以利用面向对象的思维,将公共部分沉淀下来作为基类,子类通过继承的方式获取与基类相同的能力,并在此基础上拓展自身的能力,于是诞生了下图设计

通过解析器解析子类的dsl,结合渲染引擎生成相应的站点。

四、围绕DSL设计动态组件

动态组建的设计与dsl密切相关,以下是一份动态组建的dsl demo

js 复制代码
{
 ...
 schemaConfig: {
        api: '/api/proj/product',
        schema: {
          type: 'object',
          properties: {
            id: {
              type: 'string',
              label: 'ID',
              tableOption: {
                width: 100,
                'show-overflow-tooltip': true,
              },
            },
            name: {
              type: 'string',
              label: '商品名称',
              minLength: 2,
              maxLength: 10,
              tableOption: {
                width: 100,
                'show-overflow-tooltip': true,
              },
              searchOption: {
                comType: 'dynamicSelect',
                api: '/api/proj/product_enum/list',
              },
              createFormOption: {
                comType: 'input',
                default: '哲哥牛逼!!!!!!',
              },
              editFormOption: {
                comType: 'input',
              },
              detailPanelOption: {},
            },
            price: {
              type: 'number',
              label: '价格',
              tableOption: {
                width: 100,
              },
              searchOption: {
                comType: 'select',
                emunList: [
                  { label: '100', value: 100 },
                  { label: '200', value: 200 },
                ],
              },
              createFormOption: {
                comType: 'inputNumber',
              },
              editFormOption: {
                comType: 'inputNumber',
              },
              detailPanelOption: {},
            },
            stock: {
              type: 'number',
              label: '库存',
              tableOption: {
                width: 100,
              },
              searchOption: {
                comType: 'input',
              },
              createFormOption: {
                comType: 'select',
                emunList: [
                  { label: '100', value: 100 },
                  { label: '200', value: 200 },
                ],
              },
              editFormOption: {
                comType: 'select',
                emunList: [
                  { label: '100', value: 100 },
                  { label: '200', value: 200 },
                ],
              },
              detailPanelOption: {},
            },
            create_time: {
              type: 'string',
              label: '创建时间',
              tableOption: {},
              searchOption: {
                comType: 'dateRange',
              },
              detailPanelOption: {},
            },
          },
          required: ['name'],
        },
        tableConfig: {
          headerButtons: [
            {
              label: '添加商品',
              eventKey: 'showComponent',
              eventOption: {
                comName: 'createForm',
              },
              type: 'primary',
              plain: true,
            },
            
          ],
          rowButtons: [
            {
              label: '详情',
              eventKey: 'showComponent',
              eventOption: {
                comName: 'detailPanel',
              },
            },
            {
              label: '编辑',
              eventKey: 'showComponent',
              type: 'primary',
              eventOption: {
                comName: 'editForm',
              },
            },
            {
              label: '删除',
              eventKey: 'remove',
              type: 'danger',
              eventOption: {
                params: {
                  id: 'schema::id',
                },
              },
            },
          ],
        },
        componentsConfig: {
          createForm: {
            title: '添加商品',
            saveBtnText: '添加商品',
          },
          editForm: {
            mainKey: 'id',
            title: '编辑商品',
            saveBtnText: '编辑商品',
          },
          detailPanel: {
            mainKey: 'id',
            title: '商品详情',
          },
        },
      },
   ...
 }

通过dsl描述组件的结构和行为,只要通过DSL解析器动态渲染组件,处理交互,用配置代替代码,原来开发页面的流程就转变成了 dsl配置 -> 解析dsl -> 动态渲染 -> 处理交互,elpis已经帮我们做好了后三步,所以对于框架使用者而言,只需要完成第一步即可,极大的提升了效率和可维护性。

五、打包SDK

elpis是一个框架,框架是不应该含有业务逻辑的,因此在发布npm包之前,我们需要对elpis进行提纯,即剔除业务代码,暴露相关的勾子给开发者使用并且要支持开发者拓展框架能力。

  • 对外提供服务启动命令和构建命令
js 复制代码
// 引入elpis-core
const ElpisCore = require('./elpis-core');
// 引入前端构建
const FEBuildDev = require('./app/webpack/dev');
const FEBuildProd = require('./app/webpack/prod');

module.exports = {
  /**
   * 服务端基础
   */
  Controller: {
    Base: require('./app/controller/base.js'),
  },
  Service: {
    Base: require('./app/service/base.js'),
  },
  /**
   * 前端构建
   * @param {string} env 环境变量 local: 开发环境 production: 生产环境
   */
  frontEndBuild(env) {
    if (env === 'local') {
      FEBuildDev();
    } else if (env === 'production') {
      FEBuildProd();
    }
  },
  // 启动elpis服务
  serverStart(options) {
    const app = ElpisCore.start(options);
    return app;
  },
};
  • 通过工程化工具暴露框架的能力给外部调用
js 复制代码
//配置模块解析的具体行为(定义webpack在打包时如何找到并解析模块的路径)
  resolve: {
    extensions: ['.js', '.vue', '.less', '.css'],
    alias: (() => {
      const aliasMap = {};
      const blackLibsPath = path.resolve(__dirname, '../libs/blank.js');
      // dashboard 路由拓展配置
      const businessDashboardRouterConfig = path.resolve(process.cwd(), './app/pages/dashboard/router.js');
      aliasMap['@businessDashboardRouterConfig'] = fs.existsSync(businessDashboardRouterConfig)
        ? businessDashboardRouterConfig
        : blackLibsPath;

      // schema-view拓展组件配置
      const businessComponentsConfig = path.resolve(
        process.cwd(),
        './app/pages/dashboard/complex-view/schema-view/components/components-config.js',
      );
      aliasMap['@businessComponentsConfig'] = fs.existsSync(businessComponentsConfig) ? businessComponentsConfig : {};

      // schema-form拓展组件配置
      const businessFormItemConfig = path.resolve(process.cwd(), './app/pages/widgets/schema-form/form-item-config.js');
      aliasMap['@businessFormItemConfig'] = fs.existsSync(businessFormItemConfig) ? businessFormItemConfig : {};

      // schema-search-bar拓展组件配置
      const businessSearchItemConfig = path.resolve(
        process.cwd(),
        './app/pages/widgets/schema-search-bar/search-item-config.js',
      );
      aliasMap['@businessSearchItemConfig'] = fs.existsSync(businessSearchItemConfig) ? businessSearchItemConfig : {};

      return {
        vue: require.resolve('vue'),
        '@babel/runtime/helpers/asyncToGenerator': require.resolve('@babel/runtime/helpers/asyncToGenerator'),
        '@babel/runtime/regenerator': require.resolve('@babel/runtime/regenerator'),

        '@elpisPages': path.resolve(__dirname, '../../pages'), //路径别名
        '@elpisCommon': path.resolve(__dirname, '../../pages/common'),
        '@elpisCurl': path.resolve(__dirname, '../../pages/common/curl.js'),
        '@elpisUtil': path.resolve(__dirname, '../../pages/common/util.js'),

        '@elpisWidgets': path.resolve(__dirname, '../../pages/widgets'),
        '@elpisHeaderContainer': path.resolve(__dirname, '../../pages/widgets/header-container/header-container.vue'),
        '@elpisSiderContainer': path.resolve(__dirname, '../../pages/widgets/sider-container/sider-container.vue'),
        '@elpisSchemaTable': path.resolve(__dirname, '../../pages/widgets/schema-table/schema-table.vue'),
        '@elpisSchemaForm': path.resolve(__dirname, '../../pages/widgets/schema-form/schema-form.vue'),
        '@elpisSchemaSearchBar': path.resolve(__dirname, '../../pages/widgets/schema-search-bar/schema-search-bar.vue'),

        '@elpisStore': path.resolve(__dirname, '../../pages/store'),
        '@elpisPlugins': path.resolve(__dirname, '../../pages/plugins'),
        '@elpisRouter': path.resolve(__dirname, '../../pages/router'),
        '@elpisBoot': path.resolve(__dirname, '../../pages/boot.js'),
        ...aliasMap,
      };
    })(),
  },
  • 拓展loader需要支持处理开发者业务上的定制能力(在elpis-core里已有阐述)
  • 剔除前期开发过程中的业务代码
  • 发布到npm进行版本管理

CICD持续部署与集成

相信很多同学也经历过这个阶段

每次要更新前端服务都要做这一步重复的工作,所以我们希望尽可能减少去做重复工作,CICD就能很好的帮我们去解决这个问题,帮我们达到如下效果,当代码提交的时候,能自动触发以下流程

这个过程中jenkins承担着自动化构建的任务,执行前端构建命令,如npm install、npm run prod以及build docker镜像等;docker承担着容器的作用,帮助我们解决环境标准化的问题;kubernetes帮我们实现容器的编排与调度,k8s通过调度pod再worker节点上运行,service暴露pod给公网访问。

分工一句话

  • Jenkins :负责 "什么时候做" (触发流程)和 "怎么做" (定义步骤)。
  • Docker :负责 "用什么跑" (打包一致的环境)。
  • Kubernetes :负责 "在哪里跑" (调度容器)和 "跑得稳" (管理生命周期)。

对于elpis这个还在建设中的框架,框架顾名思义就是为了让开发者减少重复劳动,专注业务本身。通过elpis-cli脚手架帮助开发者<5分钟完成项目的初始化,甚至于基于ai的能力接入elpis-ai-plugins,在脚手架created项目的时候,通过输入的描述直接帮助开发者完成站点的建设。

相关推荐
花菜会噎住14 分钟前
Vue3核心语法进阶(computed与监听)
前端·javascript·vue.js
花菜会噎住37 分钟前
Vue3核心语法基础
前端·javascript·vue.js·前端框架
全宝37 分钟前
echarts5实现地图过渡动画
前端·javascript·echarts
vjmap38 分钟前
MCP协议:CAD地图应用的AI智能化解决方案(唯杰地图MCP)
前端·人工智能·gis
simple_lau1 小时前
鸿蒙设备如何与低功耗蓝牙设备通讯
前端
啃火龙果的兔子2 小时前
解决 Node.js 托管 React 静态资源的跨域问题
前端·react.js·前端框架
ttyyttemo2 小时前
Compose生命周期---Lifecycle of composables
前端
以身入局2 小时前
FragmentManager 之 addToBackStack 作用
前端·面试
sophie旭2 小时前
《深入浅出react》总结之 10.7 scheduler 异步调度原理
前端·react.js·源码
练习前端两年半2 小时前
Vue3 源码深度剖析:有状态组件的渲染机制与生命周期实现
前端·vue.js