乐趣区

关于javascript:tarodesigner-可视化拖拽的技术点整理

忽然间可视化拖拽的风如同在前端的各个角落吹起,本人也鼓捣了一下,代码根本开发结束,做一下整顿。

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的我的项目工程中验证成果

退出移动版