突然间可视化拖拽的风好像在前端的各个角落吹起,自己也鼓捣了一下,代码基本开发完毕,做一下整理。
github项目地址:taro-designer
在线体验地址:taro-desiger
主要涉及技术点如下:
背景
技术栈
拖拽
包装组件
数据结构
编辑器
单个组件操作
生成taro的源码
预览和下载源码
公司有一部分业务是做互动的开发,比如签到、礼品兑换等。由于互动的业务需要快速迭代,并且需要支持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
组件的例子:
// Switch index.js
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: ''
};
// config.json
{
// 组件类型标识
"type": "Switch",
// 组件名称
"name": "开关选择器",
// 是否可放置其他组件
"canPlace": false,
// 默认的props数据,与index.js中的 defaultProps 基本保持一致
"defaultProps": {
"checked": false,
"type": "switch",
"color": "#04BE02"
},
// 默认样式
"defaultStyles": {},
// props字段的具体配置
"config": [
{
// key值标识
"key": "checked",
// 配置时候采用的组件:大概有Input、Radio、Checkbox、Select 等
"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
文件中,每增加一个组件都需要改动这个文件,增加新组件的对外输出和配置文件。因此我们编写一个脚本,每次生成新组件之后,直接执行脚本,自动读取,改写文件,对外输出:
/**
* 动态生成 componets 下面的 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);
// ignore file
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配置
props: {},
// 组件style配置
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
到当前页面。核心代码如下:
// index.js
<Tree parentId={null} items={store.pageData} move={this.moveItem} />
// tree.js
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) {
// 递归查找到我们要push进去的目标组件
const item = findItem(this.pageData, targetId);
const obj = {
// 根据规则生成id
id: generateId(),
type,
// 为组件添加默认的props属性
props: CONFIGS[type].defaultProps || {},
// 为组件添加默认样式
styles: CONFIGS[type].defaultStyles || {}
};
// 如果childrens存在,直接push
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。
生成form表单,每个formitem的name设置为当前组件的key-currentId进行拼接, 当form中的item的value发生改变的时候,我们获取到整个configform的值,在cacheData
中查找到当前组件,更新它的props,重新渲染编辑器,同时更新localstorage
。
提供常用的css配置属性,通过勾选对应的key值在下面生成该属性对应的配置,组成一个表单,item的值发生改变的时候,收集所有勾选属性的值,更新到当前组件的配置中,重新渲染编辑器,同时更新localstorage
。
tips:在样式编辑的时候有className
的生成到独立的css
文件,没有添加则生成行内样式。
预置一个模版字符串
从localstorage
里面获取当前页面的配置数据
递归renderElementToJSX
将数据转换为jsx
字符串
将组件类型type
存储到一个数组
判断className
是否存在。存在将className称转为驼峰命名,便于css modules使用,调用renderCss
方法拼接css字符串。不存在,则调用renderInlineCss
生成行内样式,拼接到jsx。
调用renderProps
生成每个组件的props配置,并且在里面过滤当前的props值是否与默认的值相等,相等剔除该属性的判断,简化jsx字符串。
当前组件childrens处理,存在childrens
或者content
字段的时候,处理当前组件的children。否则当前组件就是一个自闭和的组件。
对组件type
保存的数据去重
使用生成的jsx
字符串和types
替换预置模版的占位符
具体代码查看
调用renderJSONtoJSX
方法,拿到生成的jsx
和css
字符串
调用format
api,格式化jsx
和css
字符串
使用prettier
和babel
美化jsx
使用prettier
和less
美化css
将api
返回的结果显示到代码预览区
提供一键复制jsx
和css
功能
调用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
的项目工程中验证效果