人人都会AI编程

28.4 富文本编辑器、文件上传、拖拽排序等通用组件实现

更新时间:2026-07-11

在前端业务开发中,富文本编辑器、文件上传、拖拽排序是高频出现的需求。这类组件一旦封装好,可以大幅提升后续开发效率。本节以三个典型场景为例,提供可直接落地的实现方案。

28.4.1 富文本编辑器

选型建议

  • 轻量场景(简单格式、评论框):react-quill@tiptap/react(轻量模式)。
  • 复杂需求(自定义节点、协同编辑):Slate.jsProseMirror(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 的大文件,可采用切片上传:

  1. 使用 File.slice 将文件按固定大小(如 5MB)切割成多个 Blob。
  2. 逐个上传切片,并为每个切片标记索引。
  3. 全部上传完成后,请求服务端接口合并切片。
  4. 支持断点续传:可存储已上传的切片信息,刷新页面后跳过已上传的部分。

具体实现因后台存储方案差异较大,这里不展开,但思路可作为封装组件的扩展点。

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 更新状态顺序即可。

小结

这三个通用组件几乎覆盖了大部分中后台应用的交互需求。封装时要把握“接口通用、内部自由”的原则:向外暴露最少的配置,内部自由组合第三方能力,避免把业务逻辑写死在组件里。这样封装出来的组件才能在不同场景下直接复用。