likes
comments
collection
share

React-sortablejs-实现可视化拖拽表单设计器

作者站长头像
站长
· 阅读数 65

前言

公司后台项目准备做一个表单设计器,所以我先动起手预热一下。

至于为什么选择React-sortablejs?

恰好搜到,看着简单?!

思路

官网的示例很简单,导入组件简单配置

import { useState } from 'react';
import { ReactSortable } from 'react-sortablejs';

import './index.less';

const component = [
  { id: 1, compName: '组件1' },
  { id: 2, compName: '组件2' },
  { id: 3, compName: '组件3' },
];

export default function Index() {
  const [components, setComponents] = useState(component);
  return (
    <ReactSortable
      tag={'div'}
      group={{
        name: 'component',
        pull: true,
        put: true,
      }}
      sort={true}
      list={components}
      setList={(data) => {
        console.log(data);
        setComponents(data);
      }}
    >
      {components.map((item) => (
        <div className={`form-sandbox__components__item`} key={item.compName}>
          {item.compName}
        </div>
      ))}
    </ReactSortable>
  );
}

ReactSortable组件表示里面的子级元素children可以被拖拽,也代表着可以从其他ReactSortable组件中拖拽得到子级元素。ReactSortable提供group属性,关联多个组件的拖拽交互。

list属性传入的数据是作为当前ReactSortable组件内子级元素的映射,setList则根据当前ReactSortable组件内拖拽后整理好的子级元素的数据结构。

比如两个组件交换了位置,setList事件回调的第一个参数就能拿到对应的组件结构

React-sortablejs-实现可视化拖拽表单设计器

ReactSortable还提供了onAdd、onUpdate、onRemove事件来细分用户操作。

大体框架

一个表单设计器的大体框架,至少要2处地方使用到ReactSortable组件

  1. 左侧的表单组件选择区
  2. 右侧的表单组件放置区

React-sortablejs-实现可视化拖拽表单设计器

考虑到表单的元素存在嵌套形式子父关系,用于实现如表单横向排序和纵向排序等效果。那么ReactSortable组件会以嵌套组件方式,用递归的逻辑来实现。

React-sortablejs-实现可视化拖拽表单设计器

最终拿到这种结构的数据,很好的表现出上图的组件树结构。

React-sortablejs-实现可视化拖拽表单设计器

核心逻辑

如何组合出上图的组件树数据?

先了解组件树结构的核心:

  • itemId:当前组件的唯一标识,用于作为vdom的key。
  • parentId:当前组件的父级组件的itemId,用于方便查找父级。
  • nodeIndex:当前组件相对其相邻同级组件位置的数组下标,用于方便组件的插入操作。
  • children:当前组件的子级组件,表达父子关系。

所以需要有一个函数把树结构转一维数组递归遍历传入的组件树,每一对象添加上面的字段,然后全部push到新的数组。

// 核心函数 - 树结构转一维数组
export function mapSelected<T>(child: T[], mainParent: T): T[] {
  if (!child) return [];
  const tempArr = [];
  function map(arr: T[], parent: T) {
    arr.forEach((s: T, idx: number) => {
      s.itemId = s.itemId || getFormId();
      s.parentId = s.parentId || parent?.itemId || null;
      // nodeIndex始终根据遍历顺序
      s.nodeIndex = idx;
      if (s.compType === 'wrap') {
        s.children = s.children || [];
        map(s.children, s);
      }
      tempArr.push(s);
    });
  }
  map(child, mainParent);
  return tempArr;
}

parentId、itemId可以保证组件的子父级关系,nodeIndex可以保证子组件在父级children里的具体位置。

然后再把处理好的一维数组转回为树结构,将组件树传入ReactSortable组件的list属性。

核心函数 - 一维数组转回为树结构
export function arrayToTree<T>(ary: T[], root: string | null): TAtot<T> {
  const result = [];
  const map = {};
  // eslint-disable-next-line no-restricted-syntax
  for (const ia of ary) {
    const { parentId: pid, itemId: id } = ia as any;
    map[id] = {
      ...ia,
      children: map[id]?.children || [],
    };
    const item = map[id];
    if (pid === root) {
      result.push(item);
    } else {
      if (!map[pid]) {
        map[pid] = {
          children: [],
        };
      }
      map[pid].children.splice(item.nodeIndex, 0, item);
    }
  }
  return [result, map];
}

组件点击时的onClick事件利用观察者通知页面触发相应的操作。

index.tsx
...
// 页面中监听组件的用户操作,根据用户操作调用相应的事件
useEffect(() => {
    RemoveObserver.watch((o: { nodeIndex }) => {
      // 点击了组件的删除按钮,做删除操作
      removeComponent(nodeIndex);
    });
    DisposeObserver.watch((o: SetStateAction<TEventData>) => {
      // 点击组件整体,打开组件配置
      toggleDispose(true);
      selectDispose(o);
    });
    return () => {
      RemoveObserver.destroy();
      DisposeObserver.destroy();
    };
  }, []);
...
eventCover.tsx
...
// 组件外套一层div,专门收集点击操作
export default function Index({ noMask = false, children, eventData }: IEventCover) {
  return (
    <div
      className={`form-sandbox__payground__item ${(!noMask && 'form-sandbox__payground--mask') || 'under-delete'}`}
      onClick={(e) => {
        e.stopPropagation();
        // eventData是组件的数据
        // 这里是点击了组件整体,通知页面打开组件配置页
        DisposeObserver.notify(eventData);
      }}
    >
      <div
        className='form-sandbox__payground--delete'
        onClick={(e) => {
          e.stopPropagation();
          // 这里是点击了组件的删除按钮,通知页面删除当前组件
          RemoveObserver.notify(eventData);
        }}
      >
        -
      </div>
      {children}  // 具体的某个组件
    </div>
  );
}

再利用对象引用的机制,可以直接操作对象属性,页面再调用useState来刷新组件状态。

注意事项

1、由于setList函数的回调在组件点击、拖拽、放置时都会调用,导致嵌套的组件无法区别当前的用户操作,所以使用了ReactSortable提供的onAdd、onUpdate、onRemove事件来区分用户操作。

function renderChildContainer(item: ItemType) {
    return (
      <ReactSortable
        tag={'div'}
        group={{
          name: 'component',
          pull: true,
          put: true,
        }}
        swap
        direction={'horizontal'}
        fallbackOnBody={true}
        swapThreshold={1}
        animation={200}
        list={item.children}
        onUpdate={(e: any) => {
          console.log('child-onUpdate操作------------------->');
          updateComponent(e, item.children, item);
        }}
        onAdd={(e: any) => {
          console.log('child-onAdd操作------------------->');
          addComponent(e, item.children, item);
        }}
        onRemove={(e: any) => {
          console.log('child-onRemove操作------------------->');
          removeComponent(e, item.children, item);
        }}
        // 废弃,用on-event代替
        setList={() => {}}
      >
        {(item.children && renderFormItem(item.children)
      </ReactSortable>
    );
  }

这三个事件回调的第一个参数里返回了当前操作的组件对象和对应的dom,newDraggableIndex和oldDraggableIndex能知道组件的位置变化

React-sortablejs-实现可视化拖拽表单设计器

2、 setList、onAdd、onUpdate、onRemove等事件回调中需要修改list的数据,必须先深拷贝list的数据,否则会直接修改到list,导致组件错误渲染。

奠出预览地址和仓库地址:

预览:wulibinbin.github.io/react-vite-…

源码:github.com/WULIbinbin/…

有兴趣的建议拉下源码运行看看,不定时更新功能!