一、前言
最近在公司一直在做低代码方面的工具,这东西说不好是对业务提效的工具还是没什么用的KPI玩具,无论怎样,参与了还是有一些沉淀和心得,所以今天跟大家分享下如何将结构化数据解析成一个前端工程,由于想让低代码工具生成的工程具备多端的能力,所以采用了生成taro代码的方案,再基于taro构建多端小程序,来打通一套代码多端的能力。
二、核心设计
生成器总共分为两部分:
1)、提供数据存储、驱动node解析引擎的服务;
2)、解析结构化数据、生成前端工程的node解析引擎;
结构化数据对象
用户通过可视化编排工具编排页面,生成一个描述应用的结构化对象,再通过调取node服务提供的接口将结构化数据传过来,这样node服务就获取到了描述应用的结构化数据对象
{
"appName": "", //应用名字
"appId": "", //应用的唯一id
"desc": "", //应用的描述
"appConfig": {}, //应用的配置信息
"menus": [ //tabbar的配置信息
{
"uuid": "8b961ea8-38b3-44bb-955f-bbb009a1f099", //uuid
"selectedIconPath": "http://xiaobinbincdn.oss-cn-beijing.aliyuncs.com/cate-active.png", //选中时展示的图标
"iconPath": "http://xiaobinbincdn.oss-cn-beijing.aliyuncs.com/cate.png", //默认图标
"pageName": "首页", //页面的名字
"pageId": "1c09774b-4a2d-414d-a5ee-8e2238c8f23c" //页面的唯一id
},
{
"uuid": "32f8c4b9-7ebc-459e-bb87-363beba8e029",
"selectedIconPath": "http://xiaobinbincdn.oss-cn-beijing.aliyuncs.com/home-active.png",
"iconPath": "http://xiaobinbincdn.oss-cn-beijing.aliyuncs.com/home.png",
"pageName": "列表",
"pageId": "37b75348-f81b-4d28-9d13-445140b10c5e"
}
],
"pages": [ //页面的集合
{
"pageName": "未命名", //页面的名字
"pageId": "1c09774b-4a2d-414d-a5ee-8e2238c8f23c", //页面的唯一id
"pageConfig": {}, //页面配置
"components": [ //页面应用到的组件集合
{
"type": "View", //组件名称
"name": "布局组件", //组件中文名
"componentSource": { //组件的仓库地址
"npm": "@tarojs/components: 3.0.7"
},
"userProps": {}, //组件的内部属性
"uuid": "5be68443-8dd3-497d-aad0-0194a0110067" //组件的唯一标示
"children": [ //子组件集合
{
"name": "轮播图",
"type": "Swiper",
"desc": "轮播图",
"version": "0.0.1",
"canNested": false,
"exclude": [],
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
},
"userProps": {
"vertical": true,
"circular": true,
"indicatorColor": "#999",
"indicatorActiveColor": "#333",
"indicatorDots": true,
"autoplay": true
},
"children": [
{
"type": "SwiperItem",
"children": [
{
"type": "View",
"userProps": {},
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
}
}
],
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
}
},
{
"type": "SwiperItem",
"children": [
{
"type": "View",
"userProps": {},
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
}
}
],
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
}
},
{
"type": "SwiperItem",
"children": [
{
"type": "View",
"userProps": {},
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
}
}
],
"componentSource": {
"npm": "@tarojs/components: 3.0.7"
}
}
],
"propertySetting": []
},
{
"uuid": "407d55b8-4938-4920-b897-372712726cd2",
"id": "02001001",
"width": "",
"height": "",
"sourceUrl": "",
"name": "通告栏",
"type": "AtNoticebar",
"desc": "通告栏",
"version": "0.0.1",
"canNested": false,
"exclude": [],
"userProps": {
"children": "这是 NoticeBar 通告栏"
},
"componentSource": {
"npm": "taro-ui:^3.0.0-alpha.3"
}
}
],
}
]
},
...
...
...
]
}
node服务
在这里作者采用koa2+mongoDB(因为存储的结构化数据是key value的形势,所以在这里采用了mongoDB),网上koa2和mongoDB的介绍有很多,在这里就不过多介绍了,其实这部分的工作很简单,node服务获取到结构化数据对象,调用triggerFunc函数,即triggerFunc中的data参数就是这个结构化数据对象,在triggerFunc中,首先通过download-git-repo拉取远端的node解析引擎的工程代码low-code-taro-engine到本地“zero-code-taro-engine”文件夹下,再将结构化数据写入到解析引擎工程的“data”目录下供解析引擎读取数据,然后通过shelljs驱动解析引擎执行;主要代码如下:
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-14 14:11"
* @Description:"调取脚本function"
*/
/**
const shell = require("shelljs");
const util = require("util");
const path = require("path");
const fs = require("fs");
const chalk = require("chalk");
const download = require("download-git-repo");
const Package = require("./package.json");
const writeAsync = util.promisify(fs.writeFile);
const downloadAsync = util.promisify(download);
async function triggerFunc(data) {
await downloadAsync(
"direct:git@github.com:zhangbin625/low-code-taro-engine.git",
"zero-code-taro-engine",
{ clone: true }
);
await writeAsync(path.resolve(__dirname, Package.appData), data, "utf-8");
await shell.exec("cd zero-code-taro-engine && yarn start");
console.log(chalk.green("执行成功!"));
}
module.exports = triggerFunc;
package.json
{
"name": "zero-code-node-server",
"version": "0.1.0",
"private": true,
"appData": "./zero-code-taro-engine/data/index.json",
"scripts": {
...
...
},
"dependencies": {
"shelljs": "^0.8.4",
"chalk": "^4.1.0",
"download-git-repo": "^3.0.2",
...
...
},
...
...
}
node解析引擎
目录结构展示
bin目录:存放着解析引擎的核心代码;
data目录:存放node服务写入的结构化数据对象;
generate-app目录:存放taro的模版工程;
template目录:存放用来生成代码文件及配置文件的模版;
utils目录:存放一些工具方法;
node解析引擎流程
入口文件代码:
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-14 14:11"
* @Description:" 引擎入口 "
*/
const Data = require("../data");
const initialize = require("./initialize");
const replaceImages = require("./replaceImagesUrl");
/**本地化图标 */
replaceImages(Data);
/**start 生成 */
initialize(Data);
分为两步:
一、本地化tabbar图标:(由于小程序要求tabbar的图标必须放在当前工程内部,所以需要对结构化数据中图标的cdn地址进行本地化处理)在这里调用replaceImages方法来执行此步操作,在replaceImages方法中通过声明一个数组images用来存储需要下载的图片地址,遍历tabbar的配置信息menus,获取iconPath和selectedIconPath中存储的cdn地址,将其push到images数组中,然后调用下载图片方法下载图片,并且将图片地址替换为本地地址,就完成了图标的本地化处理,代码如下(具体的工具方法可以去node解析引擎github地址中查看):
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-21 10:11"
* @Description:" 图标本地化处理 "
*/
const {
asynDownloadMultiImages,
checkValidateUrl,
} = require("./replace-utils");
/**本地化处理 */
const replaceImages = (data) => {
//获取tabbar配置信息
const { menus } = data;
//定义图片下载路径
const folder = `../../generate-app/src/images/`;
//存储图片地址
const images = [];
//遍历tabbar配置保存需要下载图片的地址
menus.forEach((menu) => {
const { iconPath, selectedIconPath } = menu;
if (checkValidateUrl(iconPath).isUrl) {
images.push(iconPath);
//将图标地址替换成本地路径
replaceUrl(iconPath, menu, "iconPath");
} else {
throw new Error("图标本地化失败,请检查地址格式");
}
if (checkValidateUrl(selectedIconPath).isUrl) {
images.push(selectedIconPath);
replaceUrl(selectedIconPath, menu, "selectedIconPath");
} else {
throw new Error("图标本地化失败,请检查地址格式");
}
});
//异步下载图标
asynDownloadMultiImages(images, folder);
};
/**替换路径 */
function replaceUrl(imageUrl, menu, key) {
const imageSplit = imageUrl.split("/");
menu[key] = `./images/${imageSplit[imageSplit.length - 1]}`;
}
module.exports = replaceImages;
二、调用initialize方法生成工程:采用“模版”+“模版工程”+“解析脚本”的方式,通过解析脚本处理结构化数据,将工程需要的配置文件及页面代码文件写入到模版工程中,首先我看下模版工程目录结构:
config目录:存放着taro的工程配置信息;
images目录:存放tabbar图标;
pages目录:存放页面;
app.config.js文件:app配置文件;
package.json文件:工程依赖文件;
因为我们的目标是生成一个可执行的taro工程,所以就需要按照taro的规范来生成代码,其实就是把我们之前通过撸码的方式写taro工程,改成了执行脚本“写”taro工程,回想下我们当初撸码的时候是如何写一个项目的,通过yarn或者npm 下载依赖并将依赖项写入package.json,然后在pages创建目录存储不同的页面,在对应的页面中写我们的业务代码,当页面代码写好了,再去app.config.js文件中配置下tabbar的映射关系,按照常规开发来说也就这么多了,所以通过脚本生成的方式我们也只需要关注这三件事 (1.遍历结构化数据,将各个页面中用到的依赖写入到package.json中、2.遍历结构化数据将各个页面写入到pages目录中、3.遍历tabbar配置信息,写入app.config.js配置文件中), 因为这三步可以同时进行,所以在initialize方法中我们分别调用三个异步方法:调用initPackage方法生成package.json,调用initAppConfig方法生成app.config.js,调用initPage方法生成页面
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-18 10:11"
* @Description:" 初始化方法 "
*/
const initPackage = require("./init-package");
const initAppConfig = require("./init-app-config");
const initPage = require("./init-page");
async function initialize(data) {
const { pages, menus } = data;
/**初始化package文件 */
initPackage(pages);
/**初始化app config */
initAppConfig(menus);
/**初始化页面 */
initPage(pages);
}
module.exports = initialize;
生成package.json
在initPackage方法中首先通过调用getPackageJSON方法将模版工程中的package.json读过来,然后遍历各个页面中组件或者控件的依赖项,在这里页面中的组件及控件可能是楼层排列的,也可能有的组件需要嵌套使用(例如布局组件View和taro ui中的Swiper组件都是嵌套使用的),所以需要按照遍历components组件集合+递归的方式处理,才能将无论是扁平化的组件还是有嵌套关系的组件的依赖项都解析出来;由于我们一个页面中可能多次用到同一个组件,或者多个页面用到了相同的组件,在这个项目来看,这个组件的依赖只需要写入到package.json中一次,所以在initPackage方法中创建了一个isCache方法,来缓存已经加载过的组件依赖,即可以达到去重的处理;
initPackage.js文件
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-18 10:11"
* @Description:" 生成package.json "
*/
const fs = require("fs");
const Package = require("../package.json");
const { resolveFile } = require("../utils");
const util = require("util");
const readAsync = util.promisify(fs.readFile);
const writeAsync = util.promisify(fs.writeFile);
/**增加依赖缓存及依赖去重 */
const cacheDependencies = {};
const isCache = (key) => {
if (cacheDependencies[key]) {
return true;
}
cacheDependencies[key] = key;
return false;
};
/**初始化入口 */
async function initPackage(pages) {
const packageJSON = await getPackageJSON();
pages.forEach(({ components }) => {
parseComponents(components, packageJSON);
});
try {
await writeAsync(
resolveFile(Package.appPackageJson),
JSON.stringify(packageJSON),
"utf-8"
);
} catch (error) {
console.log(error);
}
}
/**获取package json文件 */
async function getPackageJSON() {
const data = await readAsync(resolveFile(Package.appPackageJson));
return JSON.parse(data);
}
/**递归解析组件 */
async function parseComponents(components, packageJSON) {
components.forEach((component) => {
parseDependencies(component, packageJSON);
});
}
/**解析依赖 */
async function parseDependencies(component, packageJSON) {
const { children, componentSource } = component;
if (children) {
parseComponents(children, packageJSON);
}
if (componentSource) {
const { npm } = componentSource;
if (npm) {
const npmName = npm.replace(/'/g, '"');
const [sourceName, version] = npmName.split(":");
if (!isCache(`${sourceName}-${version}`)) {
packageJSON.dependencies[sourceName] = version;
}
}
}
}
module.exports = initPackage;
生成app.config.js
由于app.config.js文件中的格式比较固定,所以我们在template文件夹中定义了一个app-config-template模版,用于生成app.config.js文件,在这里没有用什么模版引擎,为了轻量直接使用字符串模版实现了
app-config-template模版
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-19 14:11"
* @Description:" app.config.js文件模版 "
*/
const genAppConfigTemplate = (
pages,
window = {
backgroundTextStyle: "light",
navigationBarBackgroundColor: "#fff",
navigationBarTitleText: "WeChat",
navigationBarTextStyle: "black",
},
tabBar
) => {
return `export default {
pages:${JSON.stringify(pages)},
window:${JSON.stringify(window)},
tabBar:${JSON.stringify(tabBar)}
}`;
};
module.exports = {
genAppConfigTemplate,
};
在结构化数据中拿到menus的相关信息,构造tabBar所需要的数据,然后将app-config-template模版读进来,将构造好的数据传入模版中,得到app.config.js的内容,最后写入到模版项目中,这样app.config.js就算生成好了。
init-app-config.js
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2020-01-19 14:11"
* @Description:" 生成app.config.js "
*/
const fs = require("fs");
const Package = require("../package.json");
const { resolveFile } = require("../utils");
const util = require("util");
const { genAppConfigTemplate } = require("../template/app-config-template");
const writeAsync = util.promisify(fs.writeFile);
/**生成app-config */
async function initAppConfig(menus) {
const tabBar = {
list: menus.map((menu) => {
return {
pagePath: `pages/${menu.pageId}/index`,
text: menu.pageName,
iconPath: menu.iconPath,
selectedIconPath: menu.selectedIconPath,
};
}),
};
const pages = menus.map((menu) => `pages/${menu.pageId}/index`);
const config = genAppConfigTemplate(pages, undefined, tabBar);
try {
await writeAsync(resolveFile(Package.appConfig), config, "utf-8");
} catch (error) {
console.log(error, "生成工程配置文件失败");
}
}
module.exports = initAppConfig;
生成页面文件
最后就是向模版项目pages目录下写入页面了,因为一个项目中包括多个页面,在结构化数据中的pages字端中保存着各个页面的描述信息,所以在这里用一个循环,遍历pages的内容调用generatePage方法,生成所有的页面;
/**
* @Author:"xiaobinbin"
* @Email:"1933495710@qq.com"
* @LastEditTime:"2021-01-19 14:11"
* @Description:" 生成页面 "
*/
const { generatePage } = require("./generate/generate-page");
/**生成页面start */
async function initPages(pages = []) {
for (let i = 0; i < pages.length; i++) {
generatePage(pages[i], pages[i].pageId);
}
}
module.exports = initPages;
生成一个页面主要有两方面:
1、生成描述页面的配置文件index.config.js;
2、生成页面组件的jsx文件;
生成index.config.js文件跟生成应用的app.config.js文件的原理一样,读取到模版,将构造好的数据传入模版中得到index.config.js,最后写入到页面的文件夹下,如果想了解细节可以查看源码;
如何生成页面组件
在这里我们可以想想,我们是如何撸一个react组件的,首先需要将依赖通过import引入进来,然后声明一个组件(类组件或函数组件),编写组件的jsx代码和需要的方法,常规下也就这么多,用生成的方式其实就是多了一步生成包含组件的文件夹,生成一个jsx文件,其他的跟我们手撸的方式都是一样的,调用generateFolder函数生成各个页面的文件夹,调用generateJSX方法,生成组件的jsx代码,调用parseImports方法,生成引入依赖的代码,最后拿到生成组件页面需要的模版component-template,将生成好的依赖代码及jsx代码传入模版中得到页面组件代码,写入到指定目录中,这样我们就可以获得一个可以执行的taro工程了;
/**生成页面 */
async function generatePage(page, pagesPathUrl) {
const Imports = [];
const RenderList = [];
const { components = [], pageName } = page;
await generateFolder(pagesPathUrl);
generateConfigFile(pageName, pagesPathUrl);
await components.forEach(async function (component) {
const currentJSX = await generateJSX(component, Imports);
RenderList.push(currentJSX);
});
const currentImport = parseImports(Imports);
try {
await writeAsync(
resolveFile(`${Package.appPagesFolder}/${pagesPathUrl}/index.jsx`),
genComponentTemplate(currentImport, RenderList),
"utf-8"
);
} catch (error) {
console.log(error);
}
}
三、总结
就先说这么多吧,还有一些细节,像对依赖做缓存、模拟栈的方式解析嵌套组件结构、对组件属性进行类型判断生成属性列表等,在low-code-taro-engine中都有提现,想了解的同学可以直接看下源码,软件开发是一个不断学习不断完善的过程,也希望各位大佬来拍砖,知识在交换中升华。各大公司都在花大力气探索低代码(low code),想通过低代码的方式来进行业务上的提效,但是这个东西真的提效吗,还不好说;在作者看来实现低代码的整体思路一共有两点,1)、可视化拖拽、编排工具:用来通过编排组件或者是控件搭建出UI设计稿给出的页面,并且生成页面描述对象,(好多人习惯称这个对象为大JSON),其实JSON只是前后端传输的一种格式,最后都是转换成前后端相应的对象来使用,在这类编排工具中最重要的是能有一个简单而优雅的交互,让用户使用起来方便,才能达到提效的效果吧,如果说拖过来一个组件,上来就是一堆复杂的配置,甚至是要花大量的学习成本去学习如何配置的话,我想还不如撸码来的过瘾。2)、类似作者分享的这种解析生成代码工程的引擎,虽然这次是拿taro做例,但是如果掌握了这种思想,完全可以生成其他框架(react、vue)的项目,只需要变更模版工程及相关模版配置,大体思路不变。