忽然间可视化拖拽的风如同在前端的各个角落吹起,本人也鼓捣了一下,代码根本开发结束,做一下整顿。
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
的我的项目工程中验证成果