taro-designer可视化拖拽技术点整理

突然间可视化拖拽的风好像在前端的各个角落吹起,自己也鼓捣了一下,代码基本开发完毕,做一下整理。

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.jsconfig.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

数据结构

页面的交互数据存储在localstoragecacheData数组里面,每个组件的数据模型:

{
    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数组,使用TreeItem两个组件嵌套生成数据结构,在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、单击某个组件,右侧编辑器区域出现关于这个组件所有的propsstyle配置信息。

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方法,拿到生成的jsxcss字符串

  • 调用formatapi,格式化jsxcss字符串

    • 使用prettierbabel美化jsx

    • 使用prettierless美化css

  • api返回的结果显示到代码预览区

  • 提供一键复制jsxcss功能

下载源码
  • 调用renderJSONtoJSX方法,拿到生成的jsxcss字符串

  • 调用downloadapi

    • 设置response headerContent-Typeapplication/zip

    • 调用fs.truncateSync删除上次生成的文件

    • 预置生成一个名称为code的文件夹

    • 美化jsxcss字符串,并且写入对应的文件

    • code文件夹添入taro.jsxindex.css文件夹

    • 生成base64类型的zip文件返回

  • 获取接口返回的data数据,再以base64进行加载,创建 blob文件, 下载

验证

将生成的代码复制到使用 taro-cli的项目工程中验证效果

你可能感兴趣的:(实践总结,javascript,reactjs)