吐司问卷:问卷编辑器 II
Date: February 26, 2025
Log
**软件设计的可拓展性:**对修改封闭,对拓展开放
工具栏
删除组件
需求:
要点:
- 实现删除选中组件
- 思路:重新计算 selectedId,优先选择下一个,没有下一个则选择上一个
- 以上通过
componentReducer
工具函数utils
componentReducer/index.ts
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
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 数据如下:
{
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
组件列表更新后去除隐藏组件
}
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实现隐藏组件
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 时需要过滤隐藏元素
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
- 面板组件锁定:定义 Redux 中
- 样式:
- 画布:增加 locked 样式
- 属性面板组件锁定
Code:
componentReducer/index.ts
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
<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
.locked {
opacity: 0.5;
cursor: not-allowed;
}
属性面板,组件锁定则禁用 form
componentProp.tsx
<PropComponent
{...props}
disabled={isLocked || isHidden}
onChange={changeProps}
/>
复制/粘贴组件
需求:
要点:
- 在 Redux store 中存储复制的内容 copiedComponent
- 粘贴按钮,判断是否 disabled
- 公共代码抽离
insertNewComponent
:新增组件逻辑
思路:
需求:点击组件,然后点击复制按钮,再选择位置,后点击粘贴,将拷贝的组件插入对应位置。
数据层面:
- 组件状态需要新增
copiedComponent
状态,用于处理粘贴。
逻辑层面:
- 选中组件,再点击复制按钮,将 selected 传递到 redux 中
- Redux中设定 拷贝和粘贴 函数,根据 selectedId 深度拷贝对应组件,然后生成具有新的id的深拷贝组件,最后插入到对应位置即可。
utils.ts
/**
* 插入新组件
* @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
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 … />,点击其他组件,比如画布组件会显示
根据以上这点,来处理删除快捷键问题。
function isActiveElementValid() {
const activeElement = document.activeElement
// 光标没有 focus 到 ipnut 上
if (activeElement === document.body) {
return true
}
return false
}
useBindCanvasKeyPress.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
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
}
},
组件库拓展设计
扩展性:
- 从最简单的组件开始
- 定义好规则,跑通流程
- 增加其他组件,不改变编辑器的规则
**软件开发规则:**对拓展开放,对修改封闭
段落组件
需求:
要点:
- 段落组件类型、接口、组件、属性组件实现
- 潜在问题:段落换行处理
文件树:
│ │ ├── QuestionComponents
│ │ │ ├── QuestionParagraph
│ │ │ │ ├── Component.tsx
│ │ │ │ ├── PropComponent.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── interface.ts
│ │ │ └── index.ts
潜在问题:段落换行处理
尽量不要使用 dangerouslySetInnerHTML 来渲染 html,会有 xss 攻击风险。
如下可以选用 map 对组件列表进行渲染
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
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
/**
* @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
export type QuestionParagraphPropsType = {
text?: string
isCenter?: boolean
onChange?: (newProps: QuestionParagraphPropsType) => void
disabled?: boolean
}
export const QuestionParagraphDefaultProps: QuestionParagraphPropsType = {
text: '一行段落',
isCenter: false,
}
PropComponent.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
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
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
const optionTexts = form
.getFieldsValue()
.list.map((opt: OptionType) => opt.text)
在这个文件中,getFieldsValue 被用来获取整个表单的所有字段值,然后通过链式调用获取 list 字段的值。list 是一个数组,其中包含了所有选项的对象。
文件 PropComponent.tsx-1
const optionTexts = form
.getFieldValue('options')
.map((opt: OptionType) => opt.text)
在这个文件中,getFieldValue 被用来直接获取 options 字段的值。options 是一个数组,其中包含了所有选项的对象。
总结
- getFieldsValue 返回整个表单的所有字段值作为一个对象。
- getFieldValue 需要一个参数,返回指定字段的值。
这两种方法的选择取决于你需要获取的字段值的范围。如果你只需要一个特定字段的值,使用 getFieldValue 更加直接和高效。如果你需要多个字段的值,使用 getFieldsValue 会更方便。
fix: 重复选项提示处理
需求:
注释代码运行时候,当用户添加选项,输入选项值时后,哪怕值与之前选项不重复,它会保持报“选项重复!
<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 包含当前正在编辑选项的旧值
-
当用户开始输入新值时,表单立即更新导致:
旧值仍然存在于数组中,新值会被重复校验,即使输入唯一值,旧值的存在也会触发校验失败
解决方案:
if (
optionTexts.filter((text: string) => text === value)
.length > 1
) {
return Promise.reject(new Error('选项重复!'))
}
校验逻辑解析:
filter
会遍历所有选项文字(包含当前正在编辑的选项)- 当相同文字出现 超过1次 时才触发错误
- 这意味着:
- 允许当前编辑项自身存在一次
- 只有当其他选项存在相同文字时才会报错
- 空值场景:多个空选项会触发错误(因为
"" === ""
)
// 原错误逻辑(任意重复即报错,包含自身)
if (optionTexts.indexOf(value) !== -1) { ... }
// 当前逻辑(允许自身存在一次)
if (重复次数 > 1) { ... }