前言
在后端篇中已对代码生成器的原理进行了详细介绍,同时也做了java和python版的实现。但是对于前端来说,仅靠后端提供的数据库元数据还是不足以满足代码生成的要求的,而且前后端分离后,个人还是想把代码生成的活独自交给前端维护,因此也为前端单独开发一个代码生成器。
前端代码生成原理
其实前端代码生成的原理和后端的差不多,唯一区别可能就是关于元数据的来源上,这里提供三个方案:
-
前端直接连接数据库获取元数据
该方案并不是很建议,因为这样前端小哥的权限过大,不好把控
-
前端通过后端开放的接口获取数据库元数据
该方案可以考虑,但是因为需要扩展元数据,仅该方式获取的元数据也不全。
-
前端自己定义元数据(基于数据库元数据进行扩展)
本文并没有采用方案1和方案2,原因是单独使用该两种方案获取到的元数据都是不全的,不过后续做到页面收集元数据时会考虑由方案2获取最基础的元数据,然后再基于基础的元数据进行扩展。
页面元数据
页面元数据,比如:
属性 | 类型 | 默认值 | 说明 |
---|---|---|---|
isTree | Boolean | false | 是否为树型列表 |
dialogWidth | String | 50% | 弹框宽度 |
labelWidth | String | 100px | 表单域标签的宽度 |
hasDelete | Boolean | true | 是否有删除 |
hasAdd | Boolean | true | 是否有添加 |
hasEdit | Boolean | true | 是否有修改 |
formLayout | String | 1r1c | 表单布局(1r1c->一行一列,1r2c->一行两列) |
表单元数据
表单的基础元数据
属性 | 类型 | 默认值 | 说明 |
---|---|---|---|
formtype | String | text | 表单类型(详见下表) |
required | Boolean | false | 是否必填 |
defaultValue | String | undefined | 默认值 |
labelWidth | String | 100px | 表单域标签的宽度 |
show | Boolean | true | 是否在列表中显示 |
searchable | Boolean | false | 是否可搜索属性 |
searchType | String | EQ | EQ/LIKE/BT等 |
ext | Object | 根据表单类型扩展的属性 |
表单类型:
表单类型 | 是否自定义组件 | 组件 | 说明 |
---|---|---|---|
text | 否 | el-input | 单行文本 |
password | 否 | el-input | 密码输入框 |
textarea | 否 | el-input | 多行文本 |
radio | 否 | el-radio | 单选 |
checkbox | 否 | el-checkbox | 多选 |
select | 否 | select | 下拉组件 |
dict | 是 | m-dect | 字典组件 |
mselect | 是 | m-select | 自定义下拉组件 |
selectTree | 是 | m-select-tree | 选择关联树 |
upload | 是 | m-upload | 上传组件 |
ricttext | 是 | m-rict-text | 富文本组件 |
-
单行文本
{
"formtype": "text",
"required": true,
"defaultValue": "undefined"
}
-
密码输入框
{
"formtype": "password",
"required": true,
"defaultValue": "undefined"
}
-
多行文本
{
"formtype": "textarea",
"required": false,
"defaultValue": "undefined"
}
-
单选
{
"formtype": "radio",
"required": false,
"defaultValue": "1",
"ext": {
"items": [
{
"label": "男",
"value": "1"
},
{
"label": "女",
"value": "2"
}
]
}
}
-
多选
{
"formtype": "checkbox",
"required": false,
"defaultValue": ["1","2"],
"ext": {
"items": [
{ "label": "苹果", "value": "1" },
{ "label": "梨", "value": "2" },
{ "label": "香蕉", "value": "3" },
{ "label": "橘子", "value": "4" }
]
}
}
-
下拉选择
{
"formtype": "select",
"required": false,
"defaultValue": "1",
"ext": {
"multiple": false,
"items": [
{
"label": "苹果",
"value": "1"
},
{
"label": "梨",
"value": "2"
},
{
"label": "香蕉",
"value": "3"
},
{
"label": "橘子",
"value": "4"
}
]
}
}
-
字典组件
- 使用接口枚举类方式
{ "formtype": "dict", "required": false, "defaultValue": 1, "ext": { "dictKey": "sys_role_role_type", "type": "map" }
- 使用接口db存储方式
{ "formtype": "dict", "required": false, "default": 1, "ext": { "dictKey": "sys_role_role_type", "type": "db" }
- 使用本地存储方式
{ "formtype": "dict", "required": false, "defaultValue": 1, "ext": { "dictKey": "sys_role_role_type", "type": "local" }
-
自定义下拉组件
{
"formtype": "mselect",
"required": false,
"defaultValue": "undefined",
"ext": {
"valueKey": "id", // 列表中选项的值对应的key
"labelKey": "companyName", // 列表中选项的值对应的key
"searchKey": "name",
"url": "/sys/company/list", // 接口地址
"placeholder": "请选择",
"multiple": false, // 是否多选
}
}
-
选择树
{
"formtype": "selectTree",
"required": false,
"defaultValue": "undefined",
"ext": {
"url": "/sys/menu/list" // 接口地址
}
}
-
文件上传
{
"formtype": "upload",
"required": false,
"defaultValue": "undefined",
"ext": {
"bizType": "业务类型" // 业务类型
}
}
-
富文本
{
"formType": "richtext"
}
关于模板引擎
前端肯定是使用nodejs的模板引擎了
-
ejs
优点:ejs在使用vue-cli脚手架时自带的模板引擎,如果使用该模板引擎,可以不再安装其他依赖
缺点:其模板语法并不是很优雅,在模板制作中有点不是很方便
-
art-template
优点: art-template 支持标准语法与原始语法。标准语法可以让模板易读写。
缺点:无
通过对比,本框架选择后者,模板易读才是关键。
开始编码
编码之前先介绍两个依赖库
- art-template
上述说的nodejs模板引擎
npm install art-template --save-dev
- commander
nodejs的命令行解析工具
npm install commander --save-dev
目录结构
├── generate
├── data # 定义的元数据
├── sys_role.json
└── ...
├── templates # 模板目录
├── add.art
├── details.art
├── edit.art
├── form.art
├── index.art
├── search.art
└── service.js
├── config.json # 配置文件
└── index.js # 代码生成主函数
文件详解
generate/index.js
代码生成
const { program } = require('commander')
const template = require('art-template')
const path = require('path')
const fs = require('fs')
program
.version('1.0.0')
.requiredOption('-f, --file <type>', '数据文件')
.option('-d, --debug <type>', '开启调试模式', 1)
.option('-c, --config <type>', '配置文件', 'config.json')
.option('-co, --covered <type>', '是否覆盖(1->覆盖,0->不覆盖)', 0)
.parse(process.argv)
// 原始语法的界定符规则
template.defaults.rules[0].test = /<%(#?)((?:==|=#|[=-])?)[ \t]*([\w\W]*?)[ \t]*(-?)%>/
// 标准语法的界定符规则(默认的开始结束标签为{{和}},与vue的模板语法有冲突,所以修改一下<{ }>)
template.defaults.rules[1].test = /<{([@#]?)[ \t]*(\/?)([\w\W]*?)[ \t]*}>/
// 设置模板引擎调试模式
template.defaults.debug = program.debug === 1
// 禁止压缩
template.defaults.minimize = false
/**
* 主函数
*/
function main() {
var dataFile = program.file
if (!fs.existsSync(dataFile)) {
dataFile = path.join(__dirname, `data/${dataFile}`)
if (!fs.existsSync(dataFile)) {
log(`${program.file}元数据文件不存在`)
process.exit(1)
}
}
var configFile = program.config
if (!fs.existsSync(program.config)) {
configFile = path.join(__dirname, configFile)
if (!fs.existsSync(configFile)) {
log(`${program.config}元数据文件不存在`)
process.exit(1)
}
}
var data = JSON.parse(fs.readFileSync(dataFile, 'utf-8'))
var config = JSON.parse(fs.readFileSync(configFile, 'utf-8'))
genCode(config, data)
}
/**
* 生成代码
* @param config 配置文件
* @param {*} data 元数据
*/
function genCode(config, data) {
config.templates.forEach(item => {
if (item.selected) {
var templateFile = item.templateFile
var targetPath = template.render(item.targetPath, data)
var targetFileName = template.render(item.targetFileName, data)
log(`模板名称:${item.name}`)
log(`模板文件:${templateFile}`)
var content = template(path.join(__dirname, `templates/${templateFile}`), data)
targetPath = path.join(path.resolve(__dirname, '..'), `${targetPath}`)
if (!fs.existsSync(targetPath)) {
mkdirs(targetPath)
}
var targetFile = path.join(targetPath, targetFileName)
if (fs.existsSync(targetFile)) {
if (program.covered === 1 || program.covered === '1') {
log(`目标文件-被覆盖:${targetFile}`)
writeFile(content, targetFile)
} else {
log(`目标文件-已存在:${targetFile}`)
}
} else {
log(`目标文件-新生成:${targetFile}`)
writeFile(content, targetFile)
}
}
})
}
/**
* 写文件
* @param {*} content
* @param {*} targetFile
*/
function writeFile(content, targetFile) {
fs.writeFile(targetFile, content, {}, (err) => {
if (err) {
log(err)
}
})
}
/**
* 创建多级目录
* @param {} dirpath
*/
function mkdirs(dirpath) {
if (!fs.existsSync(path.dirname(dirpath))) {
mkdirs(path.dirname(dirpath))
}
fs.mkdirSync(dirpath)
}
/**
* 日志打印
* @param {} msg 打印的消息
*/
function log(msg) {
if (program.debug === 1 || program.debug === '1') {
console.log(msg)
}
}
// 入口函数
main()
generate/config.json
配置文件,目前主要是配置模板
{
"templates": [
{
"name": "首页模板",
"selected": true,
"templateFile": "index.art",
"targetPath": "src/views/modules/<%=moduleName%>/<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>",
"targetFileName": "index.vue"
},
{
"name": "接口模板",
"selected": true,
"templateFile": "service.art",
"targetPath": "src/api/<%=moduleName%>",
"targetFileName": "<%=moduleName%>.<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>.service.js"
},
{
"name": "添加模板",
"selected": true,
"templateFile": "add.art",
"targetPath": "src/views/modules/<%=moduleName%>/<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>",
"targetFileName": "add.vue"
},
{
"name": "修改模板",
"selected": true,
"templateFile": "edit.art",
"targetPath": "src/views/modules/<%=moduleName%>/<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>",
"targetFileName": "edit.vue"
},
{
"name": "详情模板",
"selected": true,
"templateFile": "details.art",
"targetPath": "src/views/modules/<%=moduleName%>/<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>",
"targetFileName": "details.vue"
},
{
"name": "表单组件",
"selected": true,
"templateFile": "form.art",
"targetPath": "src/views/modules/<%=moduleName%>/<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>/components",
"targetFileName": "form.vue"
},
{
"name": "搜索组件",
"selected": true,
"templateFile": "search.art",
"targetPath": "src/views/modules/<%=moduleName%>/<%=table.tableCameName.replace(moduleName,'').charAt(0).toLowerCase()+table.tableCameName.replace(moduleName,'').slice(1)%>/components",
"targetFileName": "search.vue"
}
]
}
generate/sys_role.json
角色表的元数据,样例
{
"moduleName": "sys",
"table": {
"fullscreen": false,
"remark": "角色",
"isTree": false,
"dialogWidth": "50%",
"labelWidth": 100,
"hasDelete": true,
"hasAdd": true,
"hasEdit": true,
"hasExport": false,
"tableName": "sys_role",
"className": "SysRole",
"tableCameName": "sysRole",
"columns": [
{
"primaryKey": true,
"javaProperty": "id",
"formtype": "none",
"defaultValue": "undefined",
"javaType": "String"
},
{
"primaryKey": false,
"javaProperty": "name",
"formtype": "text",
"remark": "角色名称",
"defaultValue": "undefined",
"searchable": true,
"searchType": "LIKE",
"required": true,
"show": true,
"javaType": "String"
},
{
"primaryKey": false,
"javaProperty": "roleKey",
"formtype": "text",
"remark": "角色标识",
"defaultValue": "undefined",
"searchable": false,
"required": true,
"show": true,
"javaType": "String"
},
{
"primaryKey": false,
"javaProperty": "roleType",
"formtype": "dict",
"remark": "角色类型",
"ext": {
"dictKey": "sys_role_role_type"
},
"defaultValue": "10",
"searchable": true,
"searchType": "EQ",
"required": true,
"show": true,
"javaType": "Integer"
},
{
"primaryKey": false,
"javaProperty": "isEnabled",
"formtype": "dict",
"ext": {
"dictKey": "yes_no"
},
"remark": "是否启用",
"defaultValue": 2,
"searchable": true,
"required": true,
"show": true,
"javaType": "Integer"
},
{
"primaryKey": false,
"javaProperty": "remark",
"formtype": "textarea",
"remark": "备注",
"defaultValue": "undefined",
"searchable": false,
"required": false,
"show": true,
"javaType": "String"
},
{
"primaryKey": false,
"javaProperty": "createTime",
"formtype": "none",
"remark": "创建时间",
"defaultValue": "undefined",
"searchable": true,
"searchType":"BT",
"required": false,
"show": true,
"javaType": "Date"
}
]
}
}
运行说明
查看帮助
node generate/index.js -h
Usage: index [options]
Options:
-V, --version output the version number
-f, --file <type> 数据文件
-d, --debug <type> 开启调试模式 (default: 1)
-c, --config <type> 配置文件 (default: "config.json")
-co, --covered <type> 是否覆盖(1->覆盖,0->不覆盖) (default: 0)
-h, --help display help for command
指定某个元数据生成代码
node generate/index.js -f sys_role.json
指定某个元数据生成代码-覆盖式
node generate/index.js -f sys_role.json -co 1
小结
本文通过自定义元数据的方式来做代码生成器,对于一些基础的CURD需求,基本上可以做到生成一次,无需再修改。当然,对于复杂的需求还是需要手工去调整,不过这其实也大大的提高了开发效率。如果想尽可能的少修改,那么可以继续去补充元数据和完善模板。
最后附上模板语法
输出
标准语法
<{value}>
<{data.key}>
<{data['key']}>
<{a ? b : c}>
<{a || b}>
<{a + b}>
原始语法
<%= value %>
<%= data.key %>
<%= data['key'] %>
<%= a ? b : c %>
<%= a || b %>
<%= a + b %>
原文输出,不转义
标准语法
<{@ value }>
原始语法
<%- value %>
条件
标准语法
<{if value}> ... <{/if}>
<{if value}> ... <{else}> ... <{/if}>
<{if v1}> ... <{else if v2}> ... <{/if>}
<{if v1}> ... <{else if v2}> ... <{else}> ... <{/if}>
原始语法
<% if (value) { %> ... <% } %>
<% if (value) { %> ... <% } else { %>... <% } %>
<% if (v1) { %> ... <% } else if (v2) { %> ... <% } %>
<% if (v1) { %> ... <% } else if (v2) { %> ... <% } else { %>... <% } %>
循环
标准语法
隐式定义,默认$value/$index
<{each target}>
<{$index}} <{$value>}>
<{/each}>
显示定义
<{each target val index}>
<{index}> <{val>}>
<{/each}>
原始语法
<% for(var i = 0; i < target.length; i++){ %>
<%= i %> <%= target[i] %>
<% } %>
变量
标准语法
<{set temp = data.sub.content}>
原始语法
<% var temp = data.sub.content; %>
项目源码地址
- 后端
- 前端