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