大纲

  1. 遇到的问题场景及解决方案对比
  2. 什么是babel?
  3. 解决过程
  4. 目前遗留的问题
  5. 目前实现功能API
  6. 参考

遇到的问题场景及解决方案对比

我们目前采用的是antd + react(umi)的框架做业务开发。在业务开发过程中会有较多频繁出现并且相似度很高的场景,比如基于一个table的基础的增删改查,这个相信大家都非常熟悉。在接到一个新的业务需求的时候,相信有不少人会选择copy一份功能类似的代码然后基于这份代码去改造以满足当前业务,当然我目前也是这样做的~

其实想把这块功能提取成一个公共组建的想法由来已久,最近开始做基础组件,便拿这个下手了。经过一周左右的时间完成了基础组件的编写。

查看基础支持的功能点API。

基本的思路是通过json生成一些抽象配置,然后通过解析json的抽象配置+渲染器最终生成页面。json配置涵盖了目前80%的业务场景的基本需求,但是可扩展性很低。比如一些复杂的业务场景:表单的关联校验数据关联显示多级列表下钻等等功能。虽然通过一些较为复杂的处理可以把这些功能融入进来,但最终组件将会异常庞大难以维护。

所以,我能不能通过这些json配置通过某种工具生成对应的代码?这样一来以上提到的问题就完全不存在了,因为这和我们自己写的代码完全一样,工具只是帮我们完成初始化的过程。所以后来想了很多办法,最初采用template string的方式,这种方式较为简单粗暴,无非通过string中嵌套变量的判断来输出code。但是在实际写的时候发现很多问题,比如

  1. function的输出(JSON.stringify会将function忽略)
  2. 多层函数嵌套之后怎么获取最终渲染的节点code
  3. 嵌入变量怎么实现、umi-models-effects/reducer中额外的字典查询怎么生成等等..

最终学习了一些生成代码的工具比如angular-cli以及一些关于js生成代码的文章,主要是通过知乎上的这篇讨论了解到了大家是怎么处理这种问题的。最终决定采用babel的生态链来解决上述遇到的问题。

我们目前采用的方式是基于antd+react(umi)编写通用的CRUD模板,然后通过代码生成器解析json中的配置生成对应的代码,大致的流程是:

React --> JavaScript AST ---> Code Generator --> Compiler --> Page

目前功能只是完成了初步版本,待应用在项目中使用一段时间稳定之后将会开源~

什么是babel?

Babel是一个工具链,主要用于编译ECMAScript 2015+代码转换为向后兼容的可运行在各种浏览器上的JavaScript。主要功能:

  1. 语法转换
  2. 环境中缺少的Polyfill功能
  3. 源代码转换
  4. 查看更多Babel功能


Understanding ASTs by Building Your Own Babel Plugin

如上提供了babel基本的流程及一篇介绍AST的文章。

我的理解中比如一段string类型code,首先通过babel.transform会将code转为一个包含AST(Abstract Syntax Tree)的Object,同样可以使用@babel/generator将AST转为code完成逆向过程。
例如一段变量声明代码:

const a = 1;

在解析之后的结构为:

{  "type": "Program",  "start": 0,  "end": 191,  "body": [    {      "type": "VariableDeclaration",      "start": 179,      "end": 191,      "declarations": [        {          "type": "VariableDeclarator",          "start": 185,          "end": 190,          "id": {            "type": "Identifier",            "start": 185,            "end": 186,            "name": "a"          },          "init": {            "type": "Literal",            "start": 189,            "end": 190,            "value": 1,            "raw": "1"          }        }      ],      "kind": "const"    }  ],  "sourceType": "module"}

首先类型为VariableDeclaration,首先他的类型是const,可以通过点击查看api其它还有letvar的值。其次是声明declarations部分,这里值为数组,因为我们可以同时定义多个变量。数组中值的类型为VariableDeclarator,包含idinit两个参数,分别为变量名称以及变量值。id的类型为Identifier,译为修饰符即是变量名称。init类型为Literal,即是常量,一般常用的有stringLiteralnumericliteralbooleanliteral等。此时即完成了变量赋值的过程。

当然这只是很简单的语法转换,如果大家想学习更多关于转换及类型的知识,可参考如下两个官方链接:

  • babel-types
  • ast转换工具

解决过程

首先定义目录结构:

.├── genCode // 代码生成器|   ├── genDetail          // 需要新页面打开时单独的detail目录|   └── genIndex           // 首页|   └── genModels          // umi models|   └── genServices        // umi services|   └── genTableFilter     // table筛选区域|   └── genTableForm       // 非新页面模式,新增/更新模态框|   └── genUpsert          // 新页面模式下,新增/更新页面|   └── genUtils           // 生成工具类├── schema                 // 模型定义文件|   ├── table              // 当前要生成的模型|   └── ├──config.js       // 基础配置|   └── └──dataSchema.js   // 列表、新增、更新配置|   └── └──querySchema.js  // 筛选项配置├── scripts                // 生成脚本|   ├── generateCode.js    // 生成主文件|   └── index.js           // 入口|   └── utils.js           // 工具类├── toCopyFiles            // 生成时需要拷贝的文件,比如less└── index.js               // 主入口

主体流程为:

  1. 指定要生成代码的路径。
  2. 根据schema中当前json配置路径,依次调用genCode目录中各个模块的代码生成方法获取对应code。
  3. 在指定的路径下写入对应的文件。
  4. 执行eslint ${filePath} --fix格式化生成的代码。
  5. 根据配置对应复制toCopyFiles文件夹中依赖的less等文件到对应的文件夹。

其中主要模块为genCode文件夹中根据json配置生成代码的过程。
以genModels为例,首先提取可以使用template string完成的部分,减少代码解析的工作量。

module.exports = (tableConfig) => {  return `        import { message } from 'antd';        import { routerRedux } from 'dva/router'        import { parse } from 'qs'        ${dynamicImport(dicArray, namespace)}        export default {            namespace: '${namespace}',            state: {                ...            },            effects: {                *fetch({ payload }, { call, put }) {                    const response = yield call(queryData, payload);                    if (response && response.errorCode === 0) {                        yield put({                            type: 'save',                            payload: response.data,                        });                    } else {                        message.error(response && response.errorMessage || '请求失败')                    }                },                ...,                ${dynamicYieldFunction(dicArray)}            },            reducers: {                save(state, action) {                    return {                        ...state,                        data: action.payload,                    };                },                ...,                ${dynamicReducerFunction(dicArray)}            },        };    `}

因为列表数据可能有字典项从后台获取值来对应显示,所以importeffectsreducers模块均有需根据配置动态生成的代码。
以dynamecImport为例:

function dynamicImport (dicArray, namespace) {    // 基础api import    let baseImport = [      'queryData', 'removeData', 'addData', 'updateData', 'findById'    ]    // 判断json数据中是否有需从后台加载项    if (dicArray && dicArray.length) {      baseImport = baseImport.concat(dicArray.map(key => getInjectVariableKey(key)))    }    // 遍历生成依赖项    const _importDeclarationArray = map(specifier => (      _importDeclarationArray.push(t.importSpecifier(t.identifier(specifier), t.identifier(specifier)))    ))    // 定义importDeclaration    const ast = t.importDeclaration(      _importDeclarationArray,      t.stringLiteral(`../services/${namespace}`)    )    // 通过@babel/generator 将ast生成code    const { code } = generate(ast)    return code  }

其它代码生成逻辑类似,有不确定如何生成的部分可参考上方提供的链接完成代码转换再去生成。

若有通过babel转换无法生成的代码,可通过正则来完成。

例如以下umi-models代码:

*__dicData({ payload }, { call, put }) {      const response = yield call(__dicData, payload);      if (response && response.errorCode === 0) {        yield put({          type: 'updateDic',          payload: response.data,        });      } else {        message.error(response && response.errorMessage || '请求失败')      }    }

基础代码可通过yieldExpression生成,但是转换之后无function之后的*符号,反复差了文档之后没有解决办法,最后只能将生成完的code利用正则替换来解决。
如果大家有遇到类似的问题欢迎讨论~

问题

  1. 目前使用的编辑器组件为braft-editor,但是结合antd使用initialValue不生效,必须使用setFieldsValue。但是使用useEffects时会默认添加props.form作为依赖并且props.form会不断变化而触发死循环,目前无奈只有禁用eslint react-hooks/exhaustive-deps。
useEffect(() => {    props.form.setFieldsValue({      editorArea: BraftEditor.createEditorState(current.editorArea),      editorArea2: BraftEditor.createEditorState(current.editorArea2)    });  }, [current.editorArea, current.editorArea2]);
  1. 生成的代码怎么删除未使用的依赖?使用eslint --fix不会删除未使用的变量定义。
  2. 初始化之后的代码要修改怎么办?因当前方法只会完成代码初始化过程,以后修改的过程暂无思路解决。

功能API

参数规范参考react-antd-admin
功能配置包含三个基础配置文件:

  • config.json配置基本属性
  • dataSchema.json配置列表及新增修改字段
  • querySchema.json配置筛选区域字段

config.json

配置列表

参数必填类型默认值说明
namespacetruestringnull命名空间
showExportfalsebooleantrue是否显示导出
showCreatefalsebooleantrue是否显示创建
showDetailfalsebooleantrue是否显示查看
showUpdatefalsebooleantrue是否显示修改
showDeletefalsebooleantrue是否显示删除
newRouterModefalsebooleanfalse在新的页面新增/编辑/查看详情。若包含富文本编辑器,建议此值设为true,富文本在模态框展示不是非常美观。
showBatchDeletefalsebooleantrue是否显示批量删除,需multiSelection为 true
multiSelectionfalsebooleantrue是否支持多选
defaultDateFormatfalsestring'YYYY-MM-DD'日期格式
uploadfalseobjectnull上传相关配置,上传图片和上传普通文件分别配置。 详见下方upload属性
paginationfalseobjectnull分页相关配置, 详见下方pagination属性
dictionaryfalsearraynull需要请求的字典项,用于下拉框或treeSelect的值为从后端获取的情况,可在dataSchema 和querySchema中使用, 详见下方dictionary属性

upload

参数必填类型默认值说明
uploadUrlfalsestringnull默认的上传接口.优先级image/fileApiUrl > uploadUrl > Global.apiPath
imageApiUrlfalsestringnull默认的图片上传接口
fileApiUrlfalsestringnull默认的文件上传接口
imagefalsestring'/uploadImage'默认的上传图片接口
imageSizeLimitfalsenumber1500默认的图片大小限制, 单位KB
filefalsestring'/uploadFile'默认的上传文件接口
fileSizeLimitfalsenumber10240默认的文件大小限制, 单位KB

pagination

参数必填类型默认值说明
pageSizefalsenumber10每页显示数量
showSizeChangerfalsebooleanfalse是否可以改变pageSize
pageSizeOptionsfalsearray['10', '20', '50', '100']指定每页可以显示多少条
showQuickJumperfalsebooleanfalse是否可以快速跳转至某页
showTotalfalsebooleantrue是否显示总数

dictionary

参数必填类型默认值说明
keytruestringnull变量标识
urltruestringnull请求数据地址

dataSchema.json

配置列表

参数必填类型默认值说明
keytruestringnull唯一标识符
titletruestringnull显示名称
primaryfalsebooleanfalse主键 如果不指定主键, 不能update/delete, 但可以insert;
如果指定了主键, insert/update时不能填写主键的值;
showTypefalsestringinput显示类型
input/textarea/inputNumber/datePicker/rangePicker/radio/select/checkbox/multiSelect/image/file/cascader/editor
disabledfalsebooleanfalse表单中这一列是否禁止编辑
addonBeforefalsestring/ReactNodenullshowType 为input可以设置前标签
addonAfterfalsestring/ReactNodenullshowType 为input可以设置后标签
placeholderfalsestringnull默认提示文字
formatfalsestringnull日期类型的格式
showInTablefalsebooleantrue这一列是否要在table中展示
showInFormfalsebooleantrue是否在新增或编辑的表单中显示
validatorfalsebooleannull设置校验规则, 参考https://github.com/yiminghe/a...
widthfalsestring/numbernull列宽度
optionsfalsearraynullformat:[{ key: '', value: '' }]或string。showType为cascader时,此字段暂不支持Array,数据只能通过异步获取。
minfalsenumbernull数字输入的最小值
maxfalsenumbernull数字输入的最大值
acceptfalsestringnull上传文件格式限制
sizeLimitfalsenumber20480上传文件格式限制
urlfalsestringnull上传图片url。图片的上传接口, 可以针对每个上传组件单独配置, 如果不单独配置就使用config.js中的默认值;如果这个url是http开头的, 就直接使用这个接口; 否则会根据config.js中的配置判断是否加上host
sorterfalsebooleanfalse是否排序
actionsfalsearraynull操作

actions

参数必填类型默认值说明
keysfalsearraynull允许更新哪些字段, 如果不设置keys, 就允许更所有字段
nametruestringnull展示标题
typefalsestringnullupdate/delete/newLine/component

querySchema.json

配置列表

参数必填类型默认值说明
keytruestringnull唯一标识符
titletruestringnull显示名称
placeholderfalsestringnull提示语
showTypefalsestringinput显示类型, 一些可枚举的字段, 比如type, 可以被显示为单选框或下拉框
input, 就是一个普通的输入框, 这时可以省略showType字段
目前可用的showType: input/inputNumber/datePicker/rangePicker/select/radio/checkbox/multiSelect/cascader
addonBeforefalsestring/ReactNodenullshowType 为input可以设置前标签
addonAfterfalsestring/ReactNodenullshowType 为input可以设置后标签
defaultValuefalsestring/array/numbernull多选的defaultValue是个数组
minfalsenumbernullshowType为 inputNumber 时可设置最小值
maxfalsenumbernullshowType为 inputNumber 时可设置最大值
optionsfalsearraynulloptions的key要求必须是string, 否则会有warning
normal-format: [{"key": "", "value": ""}]
cascader-format: [{"value": "", "label": "", children: ["value": "", "label": "", children: []]}]
如果值为string,代表异步获取的数据,则获取当前命名空间下该key对应的值
defaultValueBeginfalsestringnullshowType为 rangePicker 时可设置默认开始值
defaultValueEndfalsestringnullshowType为 rangePicker 时可设置默认结束值
placeholderBeginfalsestring开始日期showType为 rangePicker 时可设置默认开始提示语
placeholderEndfalsestring结束日期showType为 rangePicker 时可设置默认结束提示语
formatfalsestringnull日期筛选格式
showInSimpleModefalsebooleanfalse在简单查询方式下展示,若数据中有一项包含此字段且为true的值,则开启简单/复杂筛选切换

参考

  • react-antd-admin
  • AST语法转换器
  • babel-types-api