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) { ... }
相关推荐
CaptainDrake9 小时前
React低代码项目:用户登陆
前端·react.js·低代码
CaptainDrake10 小时前
React低代码项目:Redux 状态管理
前端·react.js·低代码
OpenTiny社区11 小时前
直播预告|TinyEngine低代码引擎v2.2版本特性介绍
前端·低代码·github
名之以父18 小时前
【AI Coding】Windsurf:【Prompt】全局规则与项目规则「可直接使用」
前端·javascript·vscode·低代码·chatgpt·性能优化·aigc
hamburgerDaddy11 天前
从零开始用react + tailwindcss + express + mongodb实现一个聊天程序(六) 导航栏 和 个人信息设置
前端·javascript·mongodb·react.js·node.js·reactjs·express
thubier(段新建)1 天前
低代码与开发框架的一些整合[3]
低代码
fxrz123 天前
利用 AWS API Gateway 和 Lambda 节省成本的指南
低代码·架构·云计算·gateway·aws·无服务器
低代码布道师4 天前
加油站小程序实战教程04类目级联选择
低代码·小程序
停止重构4 天前
【开源】低代码 C++程序框架,Linux多线程程序
c++·低代码·多线程·开源框架·云计算引擎