React低代码项目:问卷编辑器 II

吐司问卷:问卷编辑器 II

Date: February 26, 2025


Log

**软件设计的可拓展性:**对修改封闭,对拓展开放




工具栏

删除组件

需求:

要点:

  • 实现删除选中组件
    • 思路:重新计算 selectedId,优先选择下一个,没有下一个则选择上一个
    • 以上通过componentReducer工具函数utils

componentReducer/index.ts

tsx 复制代码
removeSelectedComponent: (draft: ComponentsStateType) => {
  const { selectedId: removeId, componentList } = draft
  // 重新计算 selectedId, 优先选择下一个,没有下一个则选择上一个
  const nextSelectedId = getNextSelectedId(removeId, componentList)
  draft.selectedId = nextSelectedId
  // 删除组件
  const index = componentList.findIndex(c => c.fe_id === removeId)
  componentList.splice(index, 1)
},

componentReducer/utils.ts

tsx 复制代码
import { ComponentInfoType } from './index'

/**
 * 获取下一个选中的组件 id
 * @param fe_id 当前选中的组件 id
 * @param componentList 组件列表
 * @returns 下一个选中的组件 id
 */
export function getNextSelectedId(
  fe_id: string,
  componentList: ComponentInfoType[]
) {
  const index = componentList.findIndex(c => c.fe_id === fe_id)
  if (index < 0) return ''
  if (index === componentList.length - 1) {
    return componentList[index - 1].fe_id
  } else {
    return componentList[index + 1].fe_id
  }
}


隐藏/显示组件

需求:

要点:

  • 定义属性 isHidden(Mock + Redux store)
  • Redux中changeComponentHidden修改 isHidden,实现 显示/隐藏 功能
    • componentList更新后过滤掉隐藏的组件
  • 修复潜在问题:隐藏组件属性暴露

思路:

componentReducer 中先定义属性 isHidden ,Redux 中实现 changeComponentHidden

用于修改 isHidden,从而实现 显示/隐藏功能。不过,记得页面的 componentList 需要过滤掉隐藏的信息,并且在处理组件对应属性面板的时候,也得先过滤掉隐藏的信息,再做选中下个组件逻辑。

潜在问题:

当组件A上面有隐藏组件B时,隐藏组件A,右侧的组件属性面板会显示B的属性。

参考服务端的 Mock 数据如下:

tsx 复制代码
{
  fe_id: Random.id(),
  type: 'questionInput',
  title: '这是一个输入框组件',
  isHidden: false,
  props: {
    title: '你的电话',
    placeholder: '请输入内容'
  }
},
{
  fe_id: Random.id(),
  type: 'questionInput',
  title: '这是一个输入框组件',
  isHidden: true,
  props: {
    title: '隐藏咯!!!',
    placeholder: '请输入内容'
  }
},
{
  fe_id: Random.id(),
  type: 'questionInput',
  title: '这是一个输入框组件',
  isHidden: false,
  props: {
    title: '上面有一个隐藏元素',
    placeholder: '请输入内容'
  }
}

EditCanvas.tsx 组件列表更新后去除隐藏组件

tsx 复制代码
  }
  return (
    <div className={styles.canvas}>
      {componentList
        .filter(c => !c.isHidden)
        .map(c => {
          const { fe_id } = c
          // 拼接 class name
          const wrapperDefaultClassName = styles['component-wrapper']

componentReducer/index.ts Redux实现隐藏组件

tsx 复制代码
export type ComponentInfoType = {
  fe_id: string
  type: string
  title: string
  isHidden?: boolean
  props: ComponentPropsType
}

changeComponentHidden: (
  draft: ComponentsStateType,
  action: PayloadAction<{ fe_id: string; isHidden: boolean }>
) => {
  const { componentList } = draft
  const { fe_id, isHidden } = action.payload
  const component = draft.componentList.find(c => c.fe_id === fe_id)
  // 重新计算 selectedId, 优先选择下一个,没有下一个则选择上一个
  let newSelectedId = ''
  if (isHidden) {
    newSelectedId = getNextSelectedId(fe_id, componentList)
  } else {
    newSelectedId = fe_id
  }
  draft.selectedId = newSelectedId
  if (component) {
    component.isHidden = isHidden
  }
},

componentReducer/utils.ts 重新计算 selected 时需要过滤隐藏元素

tsx 复制代码
import { ComponentInfoType } from './index'

/**
 * 获取下一个选中的组件 id
 * @param fe_id 当前选中的组件 id
 * @param componentList 组件列表
 * @returns 下一个选中的组件 id
 */
export function getNextSelectedId(
  fe_id: string,
  componentList: ComponentInfoType[]
) {
	// 重新计算 selected 时需要过滤隐藏元素
  const visibleComponentList = componentList.filter(c => !c.isHidden)
  const index = visibleComponentList.findIndex(c => c.fe_id === fe_id)
  if (index < 0) return ''
  if (index === visibleComponentList.length - 1) {
    return visibleComponentList[index - 1].fe_id
  } else {
    return visibleComponentList[index + 1].fe_id
  }
}


锁定/解锁组件

需求:

思路:

分析需求:

当点击锁定按钮时,可能需要传递锁定这个参数,因此,先从数据层面入手:

数据层面:先为组件参数定义 isLocked 属性,并在 Redux 中设计 锁定逻辑

逻辑层面:定位到顶部的工具栏,获取 Redux 中的锁定函数,并绑定到对应组件。

样式层面:当点击实现锁定效果

另外,当点击对应组件,属性面板组件也需要锁定,这一块也得需要分析:

先从数据层面入手

数据层面:表单锁定,根据 AntD,可能需要 disable 的属性,因此我们需要为属性面板的组件添加 disabled 的参数设定。

逻辑层面:点击画布中组件时,传递 isHidden 到属性组件中,也就是属性面板,如果画布中组件是锁定的,那么我们就传递 disable 给组件对应的属性面板。

样式层面:给表单添加 disabled 属性即可。

要点:

  • 数据:
    • 定义属性 isLocked(Mock + Redux store)
  • 变化:
    • 面板组件锁定:定义 Redux 中 toggleComponentLock 处理锁定
    • 组件属性面板锁定:属性面板,组件锁定则禁用 form
  • 样式:
    • 画布:增加 locked 样式
    • 属性面板组件锁定

Code:

componentReducer/index.ts

tsx 复制代码
toggleComponentLock: (
  draft: ComponentsStateType,
  action: PayloadAction<{ fe_id: string }>
) => {
  const { fe_id } = action.payload
  const component = draft.componentList.find(c => c.fe_id === fe_id)
  if (component) {
    component.isLocked = !component.isLocked
  }
},

样式:

EditCanvas.tsx

tsx 复制代码
<div className={styles.canvas}>
  {componentList
    .filter(c => !c.isHidden)
    .map(c => {
      const { fe_id, isLocked } = c
      // 样式处理
      const wrapperDefaultClassName = styles['component-wrapper']
      const selectedClassName = styles.selected
      const locked = styles.locked
      const wrapperClassName = classNames({
        [wrapperDefaultClassName]: true,
        [selectedClassName]: fe_id === selectedId,
        [locked]: isLocked,
      })

      return (
        <div
          key={fe_id}
          className={wrapperClassName}
          onClick={e => handleClick(e, fe_id || '')}
        >
          <div className={styles.component}>{getComponent(c)}</div>
        </div>
      )
    })}
</div>

EditCanvas.module.scss

tsx 复制代码
.locked {
  opacity: 0.5;
  cursor: not-allowed;
}

属性面板,组件锁定则禁用 form

componentProp.tsx

tsx 复制代码
<PropComponent
  {...props}
  disabled={isLocked || isHidden}
  onChange={changeProps}
/>


复制/粘贴组件

需求:

要点:

  • 在 Redux store 中存储复制的内容 copiedComponent
  • 粘贴按钮,判断是否 disabled
  • 公共代码抽离 insertNewComponent :新增组件逻辑

思路:

需求:点击组件,然后点击复制按钮,再选择位置,后点击粘贴,将拷贝的组件插入对应位置。

数据层面:

  • 组件状态需要新增 copiedComponent 状态,用于处理粘贴。

逻辑层面:

  • 选中组件,再点击复制按钮,将 selected 传递到 redux 中
  • Redux中设定 拷贝和粘贴 函数,根据 selectedId 深度拷贝对应组件,然后生成具有新的id的深拷贝组件,最后插入到对应位置即可。

utils.ts

tsx 复制代码
/**
 * 插入新组件
 * @param draft 组件状态
 * @param newCompontent 新组件
 * @returns
 */
export const insertNewComponent = (
  draft: ComponentsStateType,
  newCompontent: ComponentInfoType
) => {
  const { selectedId, componentList } = draft
  const index = componentList.findIndex(c => c.fe_id === selectedId)
  if (index < 0) {
    draft.componentList.push(newCompontent)
  } else {
    draft.componentList.splice(index + 1, 0, newCompontent)
  }
  draft.selectedId = newCompontent.fe_id
}

componentReducer/index.ts

tsx 复制代码
export type ComponentsStateType = {
  selectedId: string
  componentList: Array<ComponentInfoType>
  copiedComponent: ComponentInfoType | null
}

const INIT_STATE: ComponentsStateType = {
  selectedId: '',
  componentList: [],
  copiedComponent: null,
}

------

copySelectedComponent: (draft: ComponentsStateType) => {
  const { selectedId, componentList } = draft
  const selectedComponent = componentList.find(c => c.fe_id === selectedId)
  if (selectedComponent) {
    draft.copiedComponent = clonedeep(selectedComponent)
  }
},
pasteCopiedComponent: (draft: ComponentsStateType) => {
  const { copiedComponent } = draft
  if (!copiedComponent) return
  const newCopiedComponent = clonedeep(copiedComponent)
  newCopiedComponent.fe_id = nanoid()
  insertNewComponent(draft, newCopiedComponent)
},


画布增加快捷键

需求:

要点:

  • 删除、复制、粘贴、上下选中功能
  • 处理潜在问题:属性面板进行 backspace 时,会删除画布组件

**潜在问题:**属性面板进行 backspace 时,会删除画布组件

解决方案:

点击input组件显示的时候 <input ... />,点击其他组件,比如画布组件会显示

根据以上这点,来处理删除快捷键问题。

tsx 复制代码
function isActiveElementValid() {
  const activeElement = document.activeElement
  // 光标没有 focus 到 ipnut 上
  if (activeElement === document.body) {
    return true
  }
  return false
}

useBindCanvasKeyPress.tsx

tsx 复制代码
import { useDispatch } from 'react-redux'
import {
  removeSelectedComponent,
  copySelectedComponent,
  pasteCopiedComponent,
  selectPrevComponent,
  selectNextComponent,
} from '../store/componentReducer'
import { useKeyPress } from 'ahooks'

/**
 * 判断光标是否在 input 上
 * @returns
 *  true: 光标在 input 上
 *  false: 光标不在 input 上
 *
 */
function isActiveElementValid() {
  const activeElement = document.activeElement
  // 光标没有 focus 到 ipnut 上
  if (activeElement === document.body) {
    return true
  }
  return false
}

const useBindCanvasKeyPress = () => {
  const dispatch = useDispatch()
  // 删除选中的组件
  useKeyPress(['Delete', 'backspace'], () => {
    if (!isActiveElementValid()) return
    dispatch(removeSelectedComponent())
  })
  // 复制选中的组件
  useKeyPress(['ctrl.c', 'meta.c'], () => {
    if (!isActiveElementValid()) return
    dispatch(copySelectedComponent())
  })
  // 粘贴复制的组件
  useKeyPress(['ctrl.v', 'meta.v'], () => {
    if (!isActiveElementValid()) return
    dispatch(pasteCopiedComponent())
  })
  // 选中上一个组件
  useKeyPress(['uparrow'], () => {
    if (!isActiveElementValid()) return
    dispatch(selectPrevComponent())
  })
  // 选中下一个组件
  useKeyPress(['downarrow'], () => {
    if (!isActiveElementValid()) return
    dispatch(selectNextComponent())
  })
}

export default useBindCanvasKeyPress

componentReducer.tsx

tsx 复制代码
  selectPrevComponent: (draft: ComponentsStateType) => {
    const { selectedId, componentList } = draft
    const index = componentList.findIndex(c => c.fe_id === selectedId)
    // 如果是第一个组件,不做任何操作
    if (index <= 0) return
    const prevComponent = componentList[index - 1]
    if (prevComponent) {
      draft.selectedId = prevComponent.fe_id
    }
  },
  selectNextComponent: (draft: ComponentsStateType) => {
    const { selectedId, componentList } = draft
    const index = componentList.findIndex(c => c.fe_id === selectedId)
    if (index <= 0) return
    if (index === componentList.length - 1) return
    const nextComponent = componentList[index + 1]
    if (nextComponent) {
      draft.selectedId = nextComponent.fe_id
    }
  },



组件库拓展设计

扩展性:

  • 从最简单的组件开始
  • 定义好规则,跑通流程
  • 增加其他组件,不改变编辑器的规则

**软件开发规则:**对拓展开放,对修改封闭



段落组件

需求:

要点:

  • 段落组件类型、接口、组件、属性组件实现
  • 潜在问题:段落换行处理

文件树:

markdown 复制代码
│   │   ├── QuestionComponents
│   │   │   ├── QuestionParagraph
│   │   │   │   ├── Component.tsx
│   │   │   │   ├── PropComponent.tsx
│   │   │   │   ├── index.ts
│   │   │   │   └── interface.ts
│   │   │   └── index.ts

潜在问题:段落换行处理

尽量不要使用 dangerouslySetInnerHTML 来渲染 html,会有 xss 攻击风险。

如下可以选用 map 对组件列表进行渲染

tsx 复制代码
const textList = text.split('\n')
<Paragraph
  style={{ textAlign: isCenter ? 'center' : 'start', marginBottom: 0 }}
>
  {/* <span dangerouslySetInnerHTML={{ __html: t }}></span> */}
  {textList.map((item, index) => (
    <span key={index}>
      {index === 0 ? '' : <br />}
      {item}
    </span>
  ))}
</Paragraph>

Component.tsx

tsx 复制代码
import React, { FC } from 'react'
import {
  QuestionParagraphPropsType,
  QuestionParagraphDefaultProps,
} from './interface'
import { Typography } from 'antd'

const { Paragraph } = Typography

const Component: FC<QuestionParagraphPropsType> = (
  props: QuestionParagraphPropsType
) => {
  const { text = '', isCenter = false } = {
    ...QuestionParagraphDefaultProps,
    ...props,
  }
  // 尽量不要使用 dangerouslySetInnerHTML 来渲染 html,会有 xss 攻击风险
  // const t = text.replace('\n', '<br/>')
  const textList = text.split('\n')
  return (
    <Paragraph
      style={{ textAlign: isCenter ? 'center' : 'start', marginBottom: 0 }}
    >
      {/* <span dangerouslySetInnerHTML={{ __html: t }}></span> */}
      {textList.map((item, index) => (
        <span key={index}>
          {index === 0 ? '' : <br />}
          {item}
        </span>
      ))}
    </Paragraph>
  )
}

export default Component

index.ts

tsx 复制代码
/**
 *  @description 段落组件
 */
import Component from './Component'
import { QuestionParagraphDefaultProps } from './interface'
import PropComponent from './PropComponent'
export * from './interface'
// paragraph 组件配置
export default {
  title: '段落',
  type: 'questionPragraph',
  Component: Component,
  PropComponent: PropComponent,
  defaultProps: QuestionParagraphDefaultProps,
}

interface.ts

tsx 复制代码
export type QuestionParagraphPropsType = {
  text?: string
  isCenter?: boolean
  onChange?: (newProps: QuestionParagraphPropsType) => void
  disabled?: boolean
}

export const QuestionParagraphDefaultProps: QuestionParagraphPropsType = {
  text: '一行段落',
  isCenter: false,
}

PropComponent.tsx

tsx 复制代码
import React, { FC } from 'react'
import { useEffect } from 'react'
import { Form, Input, Checkbox } from 'antd'
import { QuestionParagraphPropsType } from './interface'

const { TextArea } = Input
const PropComponent: FC<QuestionParagraphPropsType> = (
  props: QuestionParagraphPropsType
) => {
  const { text, isCenter, onChange, disabled } = props
  const [form] = Form.useForm()
  useEffect(() => {
    form.setFieldsValue({ text, isCenter })
  }, [text, isCenter])
  function handleValuesChange() {
    if (onChange) {
      onChange(form.getFieldsValue())
    }
  }
  return (
    <Form
      layout="vertical"
      initialValues={{ text, isCenter }}
      form={form}
      onChange={handleValuesChange}
      disabled={disabled}
    >
      <Form.Item
        label="段落内容"
        name="text"
        rules={[{ required: true, message: '请输入段落内容' }]}
      >
        <TextArea cols={5} />
      </Form.Item>
      <Form.Item label="是否居中" name="isCenter" valuePropName="checked">
        <Checkbox />
      </Form.Item>
    </Form>
  )
}

export default PropComponent


单选框组件

多选框同理处理

需求:

要点:

Component.tsx

tsx 复制代码
import React from 'react'
import { Radio, Typography } from 'antd'
import { QuestionRadioPropsType, QuestionRadioDefaultProps } from './interface'

const { Paragraph } = Typography
const QuestionRadio: React.FC<QuestionRadioPropsType> = (
  props: QuestionRadioPropsType
) => {
  const { title, isVertical, options, value } = {
    ...QuestionRadioDefaultProps,
    ...props,
  }

  const radioStyle: React.CSSProperties = isVertical
    ? { display: 'flex', flexDirection: 'column' }
    : {}

  return (
    <div>
      <Paragraph strong>{title}</Paragraph>
      <Radio.Group
        value={value}
        style={radioStyle}
        options={options?.map(option => ({
          value: option.value,
          label: option.text,
        }))}
      />
    </div>
  )
}
export default QuestionRadio

PropCompnent.tsx

tsx 复制代码
import React, { FC } from 'react'
import { useEffect } from 'react'
import { Checkbox, Form, Input, Button, Space, Select } from 'antd'
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
import { QuestionRadioPropsType, OptionType } from './interface'
import { nanoid } from '@reduxjs/toolkit'

const PropComponent: FC<QuestionRadioPropsType> = (
  props: QuestionRadioPropsType
) => {
  const { title, isVertical, options, value, disabled, onChange } = props
  const [form] = Form.useForm()

  useEffect(() => {
    form.setFieldsValue({ title, isVertical, options, value })
  }, [title, isVertical, options, value])

  function handleValuesChange() {
    const values = form.getFieldsValue()
    const { options } = values
    // 生成唯一的value
    if (options && options.length > 0) {
      options.forEach((opt: OptionType) => {
        if (!opt.value) {
          opt.value = nanoid(5)
        }
      })
    }
    if (onChange) {
      onChange(form.getFieldsValue())
    }
  }
  return (
    <Form
      layout="vertical"
      initialValues={{ title, isVertical, options, value }}
      onValuesChange={handleValuesChange}
      form={form}
      disabled={disabled}
    >
      <Form.Item
        label="标题"
        name="title"
        rules={[{ required: true, message: '请输入标题' }]}
      >
        <Input />
      </Form.Item>
      <Form.Item label="选项" shouldUpdate>
        <Form.List name="options">
          {(fields, { add, remove }) => (
            <>
              {fields.map(({ key, name }) => (
                <Space key={key} align="baseline">
                  <Form.Item
                    name={[name, 'text']}
                    rules={[
                      { required: true, message: '请输入选项文字' },
                      {
                        validator: (_, value) => {
                          const optionTexts = form
                            .getFieldValue('options')
                            .map((opt: OptionType) => opt.text)
                          if (
                            optionTexts.filter((text: string) => text === value)
                              .length > 1
                          ) {
                            return Promise.reject(new Error('选项重复!'))
                          }
                          return Promise.resolve()
                        },
                      },
                    ]}
                  >
                    <Input placeholder="选项文字" />
                  </Form.Item>
                  <MinusCircleOutlined onClick={() => remove(name)} />
                </Space>
              ))}
              <Form.Item>
                <Button
                  type="dashed"
                  onClick={() => add({ text: '', value: '' })}
                  block
                  icon={<PlusOutlined />}
                >
                  添加选项
                </Button>
              </Form.Item>
            </>
          )}
        </Form.List>
      </Form.Item>
      <Form.Item label="默认选中" name="value">
        <Select
          options={options?.map(({ text, value }) => ({
            label: text,
            value: value,
          }))}
          allowClear
          placeholder="请选择默认选项"
        />
      </Form.Item>
      <Form.Item label="竖向排列" name="isVertical" valuePropName="checked">
        <Checkbox />
      </Form.Item>
    </Form>
  )
}
export default PropComponent

表单细节:getFieldValue

在这两个文件中,getFieldValue 的使用方式不同是因为它们获取表单字段值的方式不同。

文件 PropComponent.tsx

tsx 复制代码
const optionTexts = form
.getFieldsValue()
.list.map((opt: OptionType) => opt.text)

在这个文件中,getFieldsValue 被用来获取整个表单的所有字段值,然后通过链式调用获取 list 字段的值。list 是一个数组,其中包含了所有选项的对象。

文件 PropComponent.tsx-1

tsx 复制代码
const optionTexts = form
.getFieldValue('options')
.map((opt: OptionType) => opt.text)

在这个文件中,getFieldValue 被用来直接获取 options 字段的值。options 是一个数组,其中包含了所有选项的对象。

总结

  • getFieldsValue 返回整个表单的所有字段值作为一个对象。
  • getFieldValue 需要一个参数,返回指定字段的值。

这两种方法的选择取决于你需要获取的字段值的范围。如果你只需要一个特定字段的值,使用 getFieldValue 更加直接和高效。如果你需要多个字段的值,使用 getFieldsValue 会更方便。


fix: 重复选项提示处理

需求:

注释代码运行时候,当用户添加选项,输入选项值时后,哪怕值与之前选项不重复,它会保持报"选项重复!

tsx 复制代码
<Form.Item
  name={[name, 'text']}
  rules={[
    { required: true, message: '请输入选项文字' },
    {
      validator: (_, value) => {
        const optionTexts = form
          .getFieldValue('list')
          .map((opt: OptionType) => opt.text)
        // if (optionTexts.indexOf(value) !== -1) {
        //   return Promise.reject('选项文字不能重复')
        // }
        if (
          optionTexts.filter((text: string) => text === value)
            .length > 1
        ) {
          return Promise.reject(new Error('选项重复!'))
        }
        return Promise.resolve()
      },
    },
  ]}
>
  <Input />
</Form.Item>

问题原因:

  • optionTexts 包含当前正在编辑选项的旧值

  • 当用户开始输入新值时,表单立即更新导致:

    旧值仍然存在于数组中,新值会被重复校验,即使输入唯一值,旧值的存在也会触发校验失败

解决方案:

tsx 复制代码
if (
  optionTexts.filter((text: string) => text === value)
    .length > 1
) {
  return Promise.reject(new Error('选项重复!'))
}

校验逻辑解析

  1. filter 会遍历所有选项文字(包含当前正在编辑的选项)
  2. 当相同文字出现 超过1次 时才触发错误
  3. 这意味着:
    • 允许当前编辑项自身存在一次
    • 只有当其他选项存在相同文字时才会报错
    • 空值场景:多个空选项会触发错误(因为 "" === ""
tsx 复制代码
// 原错误逻辑(任意重复即报错,包含自身)
if (optionTexts.indexOf(value) !== -1) { ... }
// 当前逻辑(允许自身存在一次)
if (重复次数 > 1) { ... }
相关推荐
百锦再9 小时前
Reactive编程入门:Project Reactor 深度指南
前端·javascript·python·react.js·django·前端框架·reactjs
百锦再9 小时前
React编程高级主题:测试代码
android·前端·javascript·react.js·前端框架·reactjs
酒精检测仪1 天前
Elpis 总结
低代码·json
Dragon Wu1 天前
Electron Forge集成React Typescript完整步骤
前端·javascript·react.js·typescript·electron·reactjs
麦聪聊数据1 天前
Web 原生架构如何重塑企业级数据库协作流?
数据库·sql·低代码·架构
CORNERSTONE3652 天前
一款可提高后台系统开发效率的低代码平台
低代码·低代码平台
麦聪聊数据3 天前
为何通用堡垒机无法在数据库运维中实现精准风控?
数据库·sql·安全·低代码·架构
不爱学英文的码字机器3 天前
解读CANN MindX SDK仓库:AIGC应用开发的“低代码加速器“
低代码·aigc
HUIBUR科技3 天前
低代码赋能供应商管理:打破管理壁垒,重塑供应链效能
低代码·数字化转型
guizhoumen3 天前
2026国内外企业级cms建站系统的BI图表功能评测
低代码·cms·网站建设·建站系统·内容管理系统