在前端业务开发中,富文本编辑器、文件上传、拖拽排序是高频出现的需求。这类组件一旦封装好,可以大幅提升后续开发效率。本节以三个典型场景为例,提供可直接落地的实现方案。
28.4.1 富文本编辑器
选型建议
- 轻量场景(简单格式、评论框):
react-quill、@tiptap/react(轻量模式)。 - 复杂需求(自定义节点、协同编辑):
Slate.js或ProseMirror(TiipTap 底层也是它)。 - 开箱即用:
react-quill上手最快,但定制能力有限;Tiptap生态更现代,支持 TypeScript、无头 UI。
以下以 react-quill 为例,演示一个可嵌入表单的编辑器,支持图片上传。
安装
npm install react-quill quill
基础封装
import React, { useState } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
// 工具栏配置(可按需增减)
const modules = {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean'],
],
};
function RichTextEditor({ value, onChange }) {
return (
<ReactQuill
theme="snow"
value={value}
onChange={onChange}
modules={modules}
placeholder="请输入内容..."
/>
);
}
集成图片上传
react-quill 默认插入的是 base64 或 URL,可通过自定义工具栏处理上传。
// 自定义图片上传处理
const imageHandler = (quill) => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
// 调用上传接口,返回图片 URL
const formData = new FormData();
formData.append('file', file);
try {
const res = await uploadImage(formData); // 需实现
const range = quill.getSelection();
quill.insertEmbed(range.index, 'image', res.url);
} catch (e) {
console.error('上传失败', e);
}
};
};
// 在 modules.toolbar 中绑定 handler
modules.toolbar.handlers = {
image: imageHandler, // 覆盖默认 image 按钮行为
};
使用示例
function ArticleForm() {
const [content, setContent] = useState('');
const handleSubmit = () => {
// 获取 HTML 内容
console.log(content);
};
return (
<div>
<RichTextEditor value={content} onChange={setContent} />
<button onClick={handleSubmit}>提交</button>
</div>
);
}
关键注意点
- 受控组件:
value+onChange绑定,避免同时使用defaultValue(会导致状态不同步)。 - XSS 防护:输出 HTML 时应使用
dompurify过滤,防止恶意脚本。 - 内容存储:一般存 HTML 字符串,展示时用
dangerouslySetInnerHTML或 Vue 的v-html。
28.4.2 文件上传
文件上传组件的核心能力包括:拖拽上传、多文件支持、进度反馈、取消请求。推荐使用 react-dropzone 处理拖拽/选择文件交互,搭配 axios 实现上传。
安装
npm install react-dropzone axios
通用上传组件
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import axios from 'axios';
function FileUpload({ onUploadSuccess }) {
const [files, setFiles] = useState([]);
const onDrop = useCallback((acceptedFiles) => {
setFiles(acceptedFiles.map(file => ({
file,
progress: 0,
status: 'pending', // pending | uploading | done | error
})));
// 上传每个文件
acceptedFiles.forEach((file, index) => uploadFile(file, index));
}, []);
const uploadFile = async (file, index) => {
const formData = new FormData();
formData.append('file', file);
setFiles(prev => prev.map((f, i) => i === index ? { ...f, status: 'uploading' } : f));
try {
const response = await axios.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setFiles(prev => prev.map((f, i) => i === index ? { ...f, progress } : f));
},
});
setFiles(prev => prev.map((f, i) => i === index ? { ...f, status: 'done' } : f));
onUploadSuccess?.(response.data);
} catch (error) {
setFiles(prev => prev.map((f, i) => i === index ? { ...f, status: 'error' } : f));
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div>
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`}>
<input {...getInputProps()} />
<p>拖拽文件到此处或点击上传</p>
</div>
<ul>
{files.map((item, index) => (
<li key={index}>
{item.file.name} - {item.status === 'uploading' ? `${item.progress}%` : item.status}
</li>
))}
</ul>
</div>
);
}
进阶:大文件切片上传(简要思路)
对于超过 100MB 的大文件,可采用切片上传:
- 使用
File.slice将文件按固定大小(如 5MB)切割成多个 Blob。 - 逐个上传切片,并为每个切片标记索引。
- 全部上传完成后,请求服务端接口合并切片。
- 支持断点续传:可存储已上传的切片信息,刷新页面后跳过已上传的部分。
具体实现因后台存储方案差异较大,这里不展开,但思路可作为封装组件的扩展点。
28.4.3 拖拽排序
拖拽排序常用于看板、列表重排等场景。推荐使用 @dnd-kit,它比老牌 react-beautiful-dnd 更活跃、支持更灵活的排序逻辑(如列表、网格),且对 React 18+ 兼容更好。
安装
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
可排序列表组件
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// 单个可拖拽项
function SortableItem({ id, content }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: '10px',
margin: '5px 0',
background: '#f0f0f0',
border: '1px solid #ddd',
cursor: 'grab',
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{content}
</div>
);
}
// 列表容器
function DraggableList({ initialItems }) {
const [items, setItems] = useState(initialItems);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over.id);
setItems(arrayMove(items, oldIndex, newIndex));
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy}>
{items.map(item => (
<SortableItem key={item.id} id={item.id} content={item.content} />
))}
</SortableContext>
</DndContext>
);
}
// 使用
export default function App() {
const tasks = [
{ id: '1', content: '任务一' },
{ id: '2', content: '任务二' },
{ id: '3', content: '任务三' },
];
return <DraggableList initialItems={tasks} />;
}
要点说明
- 必须给每个
SortableItem分配唯一的id,用于内部匹配。 sensors配置决定触发拖拽的方式,PointerSensor支持鼠标和触控。- 拖动结束后,通过
arrayMove更新状态顺序即可。
小结
这三个通用组件几乎覆盖了大部分中后台应用的交互需求。封装时要把握“接口通用、内部自由”的原则:向外暴露最少的配置,内部自由组合第三方能力,避免把业务逻辑写死在组件里。这样封装出来的组件才能在不同场景下直接复用。