突然间可视化拖拽的风好像在前端的各个角落吹起,自己也鼓捣了一下,代码基本开发完毕,做一下整理。
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。
编辑属性props
生成form表单,每个formitem的name设置为当前组件的key-currentId进行拼接, 当form中的item的value发生改变的时候,我们获取到整个configform的值,在cacheData
中查找到当前组件,更新它的props,重新渲染编辑器,同时更新localstorage
。
编辑样式style
提供常用的css配置属性,通过勾选对应的key值在下面生成该属性对应的配置,组成一个表单,item的值发生改变的时候,收集所有勾选属性的值,更新到当前组件的配置中,重新渲染编辑器,同时更新localstorage
。
tips:在样式编辑的时候有className
的生成到独立的css
文件,没有添加则生成行内样式。
生成taro的源码
- 预置一个模版字符串
- 从
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
的项目工程中验证效果
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。