从零实现一个React可视化搭建组件库

1,743 阅读10分钟

VLayout 是一个学习页面可视化搭建的项目, 使用了React + TS 技术来开发。如果您有好的建议,欢迎提出,如果感觉帮助到了您,不妨点个赞或star

功能设计

首先开始一个项目时,我们需要理清他有哪些功能。以下页面可视化搭建的基础功能列表

  1. 数据协议(JSON Schema)
  2. 自定义组件库
  3. 编辑器(画布)
  4. 可拖拽
  5. 放大缩小
  6. 组件属性编辑功能
  7. 图层管理
  8. 复制粘贴
  9. 持久化存储、预览

后续会在这篇文章的基础上添加如下功能:

  1. 事件绑定
  2. 支持动画
  3. ...

有了上面的功能设计,我们可以通过画图来更清晰的理解流程

可视化搭建.png

总的来说,我们需要一个自定义组件库,它需要支持我们定义的数据协议(JSON Schema)。而编辑器则是通过对一个组件的JSON Schema进行编辑,然后输出最终用于在页面中展示的数据, 用现在流行的前端框架的展示逻辑就是UI = f(JSON Schema)

技术选型

  • 项目管理工具: lerna
  • 构建工具:
    • 编辑器: vite
    • 自定义组件库: rollup
  • 前端框架/库: ts + react + redux-toolkit + redux-persist + react-router + react-dnd + antd
  • 代码规范以及提交规范: eslint + prettier + husky + lint-stage + commitlint
  • 单元测试: jest + react-testing-library

项目管理 (如果想看实现逻辑,可直接到下面的功能实现

通过上面我会发现,一个可视化搭建项目至少会包含两个子项目:

  1. 自定义的组件库
  2. 用于可视化编辑的管理页面

上面的子项目1目前是只实现了React版本的组件库,但是我觉的一个公司业务规模比较大的时候可能不止一种技术栈。可能会有Vue、微信小程序等

所以,为了项目的可扩展性,我们使用lerna来做 monorepo

monorepo不是框架也不是库,它是一种项目管理的概念。它表示将多个项目放在一个仓库中统一开发,便于管理,使用统一标准开发,当有多个依赖项目的时候也便于发布。而lerna是基于这个概念实现的项目管理工具。

lerna简单介绍

全局安装

yarn global add lerna 或者npm install lerna -g

创建项目根目录

mkdir lerna-demo && lerna init

执行完init后会多出一个packages目录和lerna.json,并且会配置一个workspace

创建不同的子项目

然后我们可以在packages中创建不同的项目,具体代码可以查看lerna-demo

代码里有个需要注意的点就是打包配置中设置的打包方式要和你引入的方式是一致的,或者直接在打包配置中设置esmcjsumd三种方式,然后根据不同的规范去引入不同的代码。

这里我们有三个项目header, footer, website

webstite就是我们的项目,其他两个是组件库。而我们需要在website中使用它们。那么就需要在website的package.json中导入,方法如下:

{

  // ...
  "dependencies": {
    // ...
    "header": "*",
    "footer": "*"
  }
}

这样就相当于告诉lerna去link workspace中的headerfooter,就像npm install了一样。

然后再yarn执行一下命令

打包项目

如果需要打包所有的项目则直接运行lerna run build

lerna会按照依赖顺序,先打包headerfooter,最好再打包website

也可以使用--scope配置 指定需要打包的项目lerna run build --scope header --scope footer,这样,website项目就不会被打包。

运行单元测试也同上。

项目运行

打包好了两个依赖项目后,就可以运行website了,

lerna run dev --scope=website

也可以不加--scope,因为其他两个项目中并没有dev这个运行命令。

最后就可以直接访问了

功能实现

1. 数据协议(JSON Schema)

定义一个通用的数据协议,我们以一个Button组件为例,它接收的Schema数据如下: 其他的自定义组件也都是接收这种格式的数据

{
  id: '',
  type: 'Button',
  propValue: '点击', // 组件所使用的值
  animations: [], // 动画列表
  events: {}, // 事件列表
  style: {
    // 组件样式
    boxSizing: 'border-box',
    position: 'absolute',
    left: 0,
    top: 0,
    width: 100,
    height: 34,
    borderWidth: 0,
    borderColor: '',
    borderStyle: '',
    borderRadius: 0,
    fontSize: '',
    fontWeight: 400,
    lineHeight: '',
    letterSpacing: 0,
    textAlign: '',
    color: '',
    backgroundColor: '',
  },
};

你可以看到上面的数据中有一个type: Button字段,这个字段就是每个组件库中的唯一的字段,标识了是什么组件。

接着,进入自定义组件的入口处,我们使用的策略模式根据schema的type来判断加载哪个组件


// 懒加载
const ComponentMap = {
  Button: React.lazy(() => import('./custom-components/Button/index')),
  Text: React.lazy(() => import('./custom-components/Text/index')),
  Image: React.lazy(() => import('./custom-components/Image/index')),
};


export type MaterialProps = {
  [key in string]: any;
};
const Material: React.FC<MaterialProps> = (props) => {
  const { schema } = props;
  const Comp = ComponentMap[schema.type];
  if (!Comp) {
    return null;
  }
  return (
    <Suspense fallback={<div>Component `{schema.type}` is loading!</div>}>
      <Comp {...props} />
    </Suspense>
  );
};

2. 自定义组件

我们还是以实现一个Button组件为例,目前只需要将style渲染到组件中即可.

**PS: 只要修改组件库,就需要使用lerna run build --scope [your-components]**来从新打包,不然依赖这个组件库的项目无法使用到你更改后的版本

export function Button({ schema, ...rest }: MaterialProps) {
  const { propValue, style } = schema;
  return (
    <button {...rest} style={style}>
      {propValue || '按钮'}
    </button>
  );
}

并且,每个组件都对应着一个初始的schematemplate。 前者我们已经说过了,我们主要说一下后者。template 它相对比较简单,就是定义这个组件的一些基础信息,用于在编辑器中展现出一个可拖拽的组件列表

**PS: template中的type必须要和schema中的type是一致的,因为我们后续需要通过这个type获取对应的schema

const Button: T_Template = {
  type: 'Button',
  h: 20,
  icon: btn,
  displayName: '按钮组件',
};
export default Button;

3. 编辑器(画布)

画布主要有两个部分

  • 可拖拽的组件目标
  • 用于放置拖拽目标的画布容器

有了上面的template,我们只需要实现一个通用的拖拽Box组件,用于传递默认的schema 主要代码如下:

const Box = ({ tpl }: any) => {
  // @ts-ignore
  // 自定义组件库中默认的schmea
  const cSchema = schema[tpl.type];
  const [, drag] = useDrag(() => ({
    type: ItemTypes.BOX,
    item: { cSchema },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
      handlerId: monitor.getHandlerId(),
    }),
  }));
  return (
    <div ref={drag}>
      <img src="" alt="" />
      <p>{tpl.displayName}</p>
    </div>
  );
};

然后,直接遍历template生成组件列表即可。

image.png

上面的代码中我们可以看到通过react-dnduseDrag 传输了对应的默认schemaitem属性中。这是组件能在画布中展示的核心逻辑,对应到画布容器中,有对应的useDrop来接收这个item

代码实现如下:

   // ...

  const [, drop] = useDrop(() => ({
    accept: ItemTypes.BOX,
    drop: ({ cSchema }: any, monitor) => {
      const { x, y } = monitor.getClientOffset() as { x: number; y: number };

      // 拷贝一下schema数据,避免指针出错
      addSchema(clone(cSchema), x, y);
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  }));
   // ...

获取到了Schema数据后,我们通过一个schemaList来保存插入的数据。然后就是通过遍历schemaList来生成对应的组件。这里的组件,就是我们在另一个子项目中的自定义组件库中的一个组件。下面是主要逻辑


  {schemaList.map((schema) => {
      return (
        <Material
          onMouseDown={(e: MouseEvent) => handleMouseDown(e, schema.id)}
          key={schema.id}
          schema={schema}
        />
      );
  })}

4. 可拖拽

上面只是简单的实现了将组件拖拽到了画布中。但是,我们希望的是在画布中也可以拖拽组件来完成布局。 拖拽.gif

因为我们采用的是定位的布局方式,那么实际需要的就是移动的元素位于画布的坐标 截屏2023-03-10 11.19.00.png

获取lefttop主要实现逻辑大致如下:

  1. 监听mousedown事件
  2. 获取到画布位于浏览器的坐标
  3. 通过 1 获取到鼠标位于画布的坐标
  4. 通过 2 获取到鼠标位于当前元素的坐标
  5. mousedown中监听mousemove事件
  6. 通过mousemove 的事件对象和 画布位于浏览器的坐标计算出 鼠标在画布中移动的坐标
  7. 然后然后再通过 6 的坐标 减去 4 的坐标 获取到移动元素位于画布的坐标
  8. 处理超出画布的边界情况
  9. mousedown中监听mouseup事件,并解绑mousemovemouseup

我们看核心代码实现:


  // 处理画布内元素移动
  const handleMouseDown = (e: MouseEvent, id: string) => {
    e.preventDefault();
    e.stopPropagation();
    dispatch(setCurSchemaId(id));
    if (e.button === 2) {
      // 打开右击键
      setMenuTag(ITEM_MENU_TAG);
      dispatch(toggleRightClick(true));
      return;
    }
    const schema = selectCurSchema(schemaList, id) as Schema;

    // 计算鼠标位于画布中的坐标
    const pointX = e.clientX - canvasInfo.x;
    const pointY = e.clientY - canvasInfo.y;

    // 获取鼠标位于当前元素的位置
    const targetX = pointX - schema?.style.left;
    const targetY = pointY - schema?.style.top;

    const move = (moveEvent: MouseEvent) => {
      moveEvent.preventDefault();
      const moveX = moveEvent.clientX - canvasInfo.x;
      const moveY = moveEvent.clientY - canvasInfo.y;

      // 计算元素最后的坐标
      let x = moveX - targetX;
      let y = moveY - targetY;

      // 处理超出画布的边界情况
      const caclRes = calcPos(x, y, schema);
      x = caclRes.x;
      y = caclRes.y;
      dispatch(updateSchemaPos({ x, y, id }));
    };

    const up = () => {
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseup', up);
    };

    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  };

5. 放大缩小

放大缩小.gif

它的实现思路是通过在拖拽组件的外面包裹一个节点,然后再加上三个point(圆点),通过当前组件的schema计算出包裹层的坐标,然后将三个point依据包裹层定位,再通过在point上添加对应的事件来监听即可,这里就不贴代码了。如果想看实现细节可直接到 PointWrapper 查看

6. 组件属性编辑

样式编辑.gif

当我们选中画布中的一个组件时,右侧就会显示出当前组件对应的schema,目前只实现了样式的编辑。 实现这个功能的核心就是需要定义与样式对应的map styleMap


export const styleMap: any = {
  rotate: { label: '旋转角度', type: 'number' },
  width: { label: '宽', type: 'number' },
  height: { label: '高', type: 'number' },
  color: { label: '颜色', type: 'color' },
  backgroundColor: { label: '背景色', type: 'color' },
  borderWidth: { label: '边框宽度', type: 'number' },
  borderStyle: { label: '边框风格', type: 'select' },
  borderColor: { label: '边框颜色', type: 'color' },
  borderRadius: { label: '边框半径', type: 'number' },
  fontSize: { label: '字体大小', type: 'number' },
  fontWeight: { label: '字体粗细', type: 'number' },
  lineHeight: { label: '行高', type: 'number' },
  letterSpacing: { label: '字间距', type: 'number' },
  textAlign: { label: '左右对齐', type: 'select' },
  verticalAlign: { label: '上下对齐', type: 'select' },
  opacity: { label: '不透明度', type: 'number' },
};

然后在组件中遍历这个map,并通过不同的type生成不同的输入框。然后将数据和功能绑定即可

Attrs.tsx


  const renderComp = (style: any, styleProp: string, type: string) => {
    const value = style[styleProp];
    if (type === 'number') {
      return (
        <InputNumber
          value={value}
          onChange={(value) => handleChange(styleProp, value)}
        />
      );
    }

    if (type === 'text') {
      return (
        <Input
          value={value}
          onChange={(e) => handleChange(styleProp, e.target.value)}
        />
      );
    }
    if (type === 'select') {
      return (
        <Select
          value={value}
          onChange={(value) => handleChange(styleProp, value)}
          options={optionMap[styleProp]}
        />
      );
    }

    if (type === 'color') {
      return (
        <ColorPicker
          color={value}
          onChange={(value: any) => handleChange(styleProp, value)}
        />
      );
    }
    return null;
  };

7. 图层管理

图层.gif

图层管理相对比较简单,因为我们是基于absolute定位做的。且已经有了对应schemaList数据,那么我们需要做的就是移动当前组件对应的schema数据在schemaList中的数据就行了

核心的实现就是一个swapSchema方法,通过数据的索引切换位置

    swapSchema: (
      state,
      action: PayloadAction<{ curIdx: number; targetIdx: number }>
    ) => {
      const { curIdx, targetIdx } = action.payload;
      const temp = state.schemaList[curIdx];
      state.schemaList[curIdx] = state.schemaList[targetIdx];
      state.schemaList[targetIdx] = temp;
    },

8. 复制粘贴

复制粘贴.gif

复制粘贴的实现需要屏蔽浏览器的默认右击事件,所以我在项目中写了一个RightClick组件,实现细节我就不贴代码了。

主要逻辑还是复制一份当前操作的组件的schema数据,然后粘贴的时候调用显示RightClick组件的方法,通过RightClick组件获取当前右击的鼠标位置,来确定粘贴后的组件的坐标。

9. 持久化存储和预览

持久化存储使用的是redux-presist 因为并没有接入后端,所以希望通过在localStorage中长期存储,防止刷新丢失数据。

预览页面的实现就是获取schemaList数据,然后生成最终的展示页面即可。

总结

通过自己造轮子,确实收获颇多。当你独立去做一个项目的时候,你就会从整体上出发,思考项目的管理,技术的选型等等。最重要的是能够实践你的所学。

PS: 后续会继续迭代未完成的功能,如果您有好的建议,感谢您能提出。如果觉得有帮助可以帮我点个赞或者star

站在巨人肩上

  1. 可视化拖拽组件库的技术要点分析
  2. h5-doording