
背景 公司有一部分业务是做互动的开发,比如签到、礼品兑换等。由于互动的业务需要快速迭代,并且需要支持H5、微信小程序、以及淘宝小程序,因此前端采用了taro作为基础框架来满足多端的需求。因此我们思考是不是采用可视化的方式对基础的组件进行拖拉拽,直接生成页面布局,提高开发效率。 面对项目的种种局限,采用的是taro2.x库,以及taro自带的组件库,非taro-ui。因为taro支持的属性参差不齐,和业务方讨论之后,我们取tarojs组件库支持的h5和微信小程序的交集进行属性编辑。
技术栈 react、mobx、cloud-react、tarojs
拖拽 从左侧可选择的组件拖拽元素到编辑器中,在编辑器里面进行二次拖拽排序,解决拖拽位置错误,需要删除重新拖拽的问题。 我们采用react-dnd作为拖拽的基础库,具体用法讲解单独有项目实践和文章说明,在此不做赘述。 项目代码: react-dnd-nested demo地址:react-dnd-nested-demo
包装组件 这里包装的是taro的组件,也可以为其他的第三方组件。每个组件包含index.js 用于包装组件的代码 和config.json 文件用于组件配置数据, 举个 Switch 组件的例子:
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Switch } from '@tarojs/components/dist-h5/react';
export default class Switch1 extends Component {
render() {
const { style, ...others } = this.props;
return <Switch style={style} {...others} />;
}
}
Switch1.propTypes = {
checked: PropTypes.bool,
type: PropTypes.oneOf(['switch', 'checkbox']),
color: PropTypes.string,
style: PropTypes.string
};
Switch1.defaultProps = {
checked: false,
type: 'switch',
color: '#04BE02',
style: ''
};
{
"type": "Switch",
"name": "开关选择器",
"canPlace": false,
"defaultProps": {
"checked": false,
"type": "switch",
"color": "#04BE02"
},
"defaultStyles": {},
"config": [
{
"key": "checked",
"type": "Radio",
"label": "是否选中"
},
{
"key": "type",
"type": "Select",
"label": "样式类型",
"dataSource": [
{
"label": "switch",
"value": "switch"
},
{
"label": "checkbox",
"value": "checkbox"
}
]
},
{
"key": "color",
"label": "颜色",
"type": "Input"
}
]
}
预置脚本永远坚信代码比人更加高效、准确、靠谱。 生成组件模板脚本每个组件都是包装taro对应的组件,因此我们预置index.js 和config.json 文件的代码,代码中设置一个__ComponentName__ 的特殊字符为组件名称,执行生成脚本,从用户的输入读取进来再正则替换,即可生成基础的代码。这块可以查看具体代码,生成脚本如下: const path = require('path');
const fs = require('fs');
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout
});
readline.question('请输入组件名称?', name => {
const componentName = name;
readline.close();
const targetPath = path.join(__dirname, '../src/components/');
fs.mkdirSync(`${targetPath}${componentName}`);
const componentPath = path.join(__dirname, `../src/components/${componentName}`);
const regx = /__ComponentName__/gi
const jsContent = fs.readFileSync(path.join(__dirname, '../scripts/tpl/index.js')).toString().replace(regx, componentName);
const configContent = fs.readFileSync(path.join(__dirname, '../scripts/tpl/config.json')).toString().replace(regx, componentName);
const options = { encoding: 'utf8' };
fs.writeFileSync(`${componentPath}/index.js`, jsContent, options, error => {
if (error) {
console.log(error);
}
});
fs.writeFileSync(`${componentPath}/config.json`, configContent, options, error => {
if (error) {
console.log(error);
}
});
});
package.json 配置如下:
"new": "node scripts/new.js",
执行脚本 npm run new
对外输出export 脚本我们需要把所有组件对外输出都放在components/index.js 文件中,每增加一个组件都需要改动这个文件,增加新组件的对外输出和配置文件。因此我们编写一个脚本,每次生成新组件之后,直接执行脚本,自动读取,改写文件,对外输出:
const path = require('path');
const fs = require('fs');
const prettier = require('prettier');
function getStringCodes() {
const componentsDir = path.join(__dirname, '../src/components');
const folders = fs.readdirSync(componentsDir);
const ignores = ['.DS_Store', 'index.js', 'Tips'];
let importString = '';
let requireString = '';
let defaultString = 'export default {\n';
let configString = 'export const CONFIGS = {\n';
folders.forEach(folder => {
if (!ignores.includes(folder)) {
importString += `import ${folder} from './${folder}';\n`;
requireString += `const ${folder.toLowerCase()}Config = require('./${folder}/config.json');\n`;
defaultString += `${folder},\n`;
configString += `${folder}: ${folder.toLowerCase()}Config,\n`;
}
});
return { importString, requireString, defaultString, configString };
}
function generateFile() {
const { importString, requireString, defaultString, configString } = getStringCodes();
const code = `${importString}\n${requireString}\n${defaultString}\n};\n\n${configString}\n};\n`;
const configPath = path.join(__dirname, '../.prettierrc');
prettier.resolveConfig(configPath).then(options => {
const content = prettier.format(code, Object.assign(options, { parser: 'babel' }));
const targetFilePath = path.join(__dirname, '../src/components/index.js');
fs.writeFileSync(targetFilePath, content, error => {
if (error) {
console.log(error);
}
});
});
}
generateFile();
package.json 配置如下:
"gen": "node scripts/generate.js"
执行脚本 npm run gen
数据结构
页面的交互数据存储在localstorage 的cacheData 数组里面,每个组件的数据模型: {
id: 1,
type: "View",
props: {},
styles: {},
chiildrens: []
}
简单页面数据示例如下: [
{
"id": 1,
"type": "View",
"props": {},
"styles": {
"minHeight": "100px"
},
"childrens": [
{
"id": 9397,
"type": "Button",
"props": {
"content": "ok",
"size": "default",
"type": "primary",
"plain": false,
"disabled": false,
"loading": false,
"hoverClass": "none",
"hoverStartTime": 20,
"hoverStayTime": 70
},
"styles": {}
},
{
"id": 4153,
"type": "View",
"props": {
"hoverClass": "none",
"hoverStartTime": 50,
"hoverStayTime": 400
},
"styles": {
"minHeight": "50px"
},
"childrens": [
{
"id": 7797,
"type": "Icon",
"props": {
"type": "success",
"size": 23,
"color": ""
},
"styles": {}
},
{
"id": 9713,
"type": "Slider",
"props": {
"min": 0,
"max": 100,
"step": 1,
"disabled": false,
"value": 0,
"activeColor": "#1aad19",
"backgroundColor": "#e9e9e9",
"blockSize": 28,
"blockColor": "#fff",
"showValue": false
},
"styles": {}
},
{
"id": 1739,
"type": "Progress",
"props": {
"percent": 20,
"showInfo": false,
"borderRadius": 0,
"fontSize": 16,
"strokeWidth": 6,
"color": "#09BB07",
"activeColor": "#09BB07",
"backgroundColor": "#EBEBEB",
"active": false,
"activeMode": "backwards",
"duration": 30
},
"styles": {}
}
]
},
{
"id": 8600,
"type": "Text",
"props": {
"content": "text",
"selectable": false
},
"styles": {}
},
{
"id": 7380,
"type": "Radio",
"props": {
"content": "a",
"checked": false,
"disabled": false
},
"styles": {}
}
]
}
]
编辑器
实现思路: 1、初始化获取到的值为空时,默认数据为: [
{
id: 1,
type: 'View',
props: {},
styles: {
minHeight: '100px'
},
childrens: []
}
]
2、遍历cacheData 数组,使用Tree 和Item 两个组件嵌套生成数据结构,在Item 组件中根据type 值获取到当前组件,render 到当前页面。核心代码如下:
null} items={store.pageData} move={this.moveItem} />
render() {
const { parentId, items, move } = this.props;
return (
<>
{items && items.length
? items.map(item => {
return <Item parentId={parentId} key={item.id} item={item} move={move} />;
})
: null}
);
}
const CurrentComponet = Components[type];
return (
<CurrentComponet
id={id}
type={type}
className={classes}
style={parseStyles(styles)}
onClick={event => this.handleClick({ id, parentId, type }, event)}>
<Tree parentId={id} items={childrens} move={move} />
CurrentComponet>
);
3、从左侧拖拽组件进入编辑器,找到它拖入的父组件id,使用push 修改当前的组件childrens 增加数据。 add(targetId, type) {
const item = findItem(this.pageData, targetId);
const obj = {
id: generateId(),
type,
props: CONFIGS[type].defaultProps || {},
styles: CONFIGS[type].defaultStyles || {}
};
if (item.childrens) {
item.childrens.push(obj);
} else {
item.childrens = [obj];
}
localStorage.setItem(KEY, JSON.stringify(this.pageData));
}
4、在编辑器中拖入组件,使用move方式移动组件到新的父组件下面 - 找到正在拖拽的组件和其父组件,找到目标组件和它的父组件
- 判断目标组件是否为可放置类型组件。是的话直接push到目标组件。不是的话,找到当前在父组件中的
index ,然后在指定位置插入 - 从目标组件的父组件中移除当前组件
5、单击某个组件,右侧编辑器区域出现关于这个组件所有的props 和style 配置信息。 6、清空工作区,添加二次确认防止误操作,恢复页面数据到初始化的默认数据。
单个组件操作 加载组件配置根据当前组件的id找到当前组件的props和style配置信息,在根据之前config中对于每一个字段的config记载对应的组件去编辑。 删除组件根据当前组件id和父组件id,删除这个组件,并且清空所有对当前选中组件的保存信息,更新localstorage。 复制组件根据当前组件id和父亲节点id,找到当前复制组件的所有信息,为其生成一个新id,然后push到父组件中,更新localstorage。 编辑属性props生成form表单,每个formitem的name设置为当前组件的key-currentId进行拼接, 当form中的item的value发生改变的时候,我们获取到整个configform的值,在cacheData 中查找到当前组件,更新它的props,重新渲染编辑器,同时更新localstorage 。 编辑样式style提供常用的css配置属性,通过勾选对应的key值在下面生成该属性对应的配置,组成一个表单,item的值发生改变的时候,收集所有勾选属性的值,更新到当前组件的配置中,重新渲染编辑器,同时更新localstorage 。 tips:在样式编辑的时候有className 的生成到独立的css 文件,没有添加则生成行内样式。
生成taro的源码 预览和下载源码预览代码下载源码- 调用
renderJSONtoJSX 方法,拿到生成的jsx 和css 字符串 调用download api - 设置
response header 的Content-Type 为application/zip - 调用
fs.truncateSync 删除上次生成的文件 - 预置生成一个名称为
code 的文件夹 - 美化
jsx 和css 字符串,并且写入对应的文件 - 往
code 文件夹添入taro.jsx 和index.css 文件夹 - 生成
base64 类型的zip 文件返回
- 获取接口返回的
data 数据,再以base64 进行加载,创建 blob 文件, 下载
验证将生成的代码复制到使用 taro-cli 的项目工程中验证效果 |