在React项目中实现富文本编辑文章并发布

在内容管理系统的开发中,文章发布功能 是核心模块之一。本文将深入剖析React项目中如何实现富文本编辑与文章发布的全流程 ,通过抽丝剥茧的方式解析代码中的关键技术点。

项目预览

项目源代码 GitHub地址 github.com/Objecteee/r...

此功能相关代码我会放置文末,可以自行领取

一、富文本编辑器的集成与实现

在React项目中集成富文本编辑器是文章发布功能的核心。我们选择了ReactQuill组件,这是一个基于Quill编辑器的React封装版本

jsx 复制代码
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'

// 在表单中的使用
<Form.Item
  label="内容"
  name="content"
  rules={[{ required: true, message: '请输入文章内容' }]}
>
  <ReactQuill
    className="publish-quill"
    theme="snow"
    placeholder="请输入文章内容"
  />
</Form.Item>

这里有几个需要注意的地方

  1. 样式导入的必要性

    除了导入组件本身,必须同时导入CSS文件import 'react-quill/dist/quill.snow.css' 。这是编辑器正常显示的基础,缺少它会导致工具栏和编辑区域样式混乱

  2. 主题配置的考量
    theme="snow"使用了Quill最常用的主题风格,提供了清晰的工具栏布局和友好的用户体验。这个主题经过广泛测试,能兼容大多数现代浏览器。

  3. 与表单的深度集成

    通过Ant Design的Form.Item包裹,实现了富文本内容与表单状态的自动绑定 。当表单提交时,可以通过name="content"直接获取富文本的HTML内容。

  4. 自定义样式的扩展
    className="publish-quill"允许我们通过CSS定制编辑器样式。在实际项目中,通常会调整编辑区域高度、工具栏位置等样式以适应设计需求。

定制样式如下

scss 复制代码
.publish-quill {
  .ql-editor {
    min-height: 300px;
  }
}

二、封面图片的动态管理机制

文章封面图片的管理采用了条件渲染策略,根据用户选择的封面类型动态展示不同的上传界面:

jsx 复制代码
// 封面类型状态管理
const [imageType, setImageType] = useState(0)
const onTypeChange = (e) => {
  setImageType(Number(e.target.value))
}

// 图片上传状态
const [imageList, setImageList] = useState([])
const onChange = (value) => {
  setImageList(value.fileList)
}

// 在表单中的动态渲染
{imageType > 0 && <Upload
  listType="picture-card"
  action={'http://geek.itheima.net/v1_0/upload'}
  name='image'
  onChange={onChange}
  maxCount={imageType}
>
  <div style={{ marginTop: 8 }}>
    <PlusOutlined />
  </div>
</Upload>}

下面是一些关键点

  1. 状态驱动的渲染逻辑
    通过imageType > 0条件判断,实现了当用户选择"无图"时隐藏上传组件的效果。这种状态驱动的渲染模式是React的核心思想。

  2. 上传数量的动态控制
    maxCount={imageType}属性根据封面类型动态限制上传图片数量。选择"单图"时只能上传1张,选择"三图"时最多可上传3张。

  3. 图片数据的转换处理

    在上传完成后,通过imageList.map(item => item.response.data.url)将上传结果转换为需要的URL数组(后端接口要求)。这里需要注意异步上传完成后的数据处理时机。

  4. 严格的类型校验

    在表单提交时进行二次验证if (imageType > 0 && imageList.length !== imageType)确保用户选择的封面类型与实际图片数量一致,避免数据不一致问题。

三、表单数据构造与提交处理

表单提交是整个发布功能的核心环节,涉及数据收集、校验和转换:

javascript 复制代码
const onFinish = (formValues) => {
  // 解构表单基础数据
  const { title, content, channel_id } = formValues;
  
  // 封面图片校验
  if (imageType > 0 && imageList.length !== imageType) {
    return message.error(`请选择${imageType}张图片`);
  }
  
  // 构造API所需数据结构
  const reqData = { 
    title,
    content,
    cover: {
      type: imageType,
      images: imageType > 0 
        ? imageList.map(item => item.response.data.url) 
        : []
    },
    channel_id
  };
  
  // 调用API提交数据
  createArticleAPI(reqData);
};

数据处理的关键点:

  1. 数据结构转换

    将前端表单数据转换为后端API需要的格式 ,特别是封面图片数据从imageList状态转换为cover对象结构。

  2. 防御性编程实践

    使用imageType > 0 ? ... : []条件表达式处理无图情况,避免在无图状态下发送无效的图片数据

  3. 用户反馈机制

    通过message.error()提供即时操作反馈,帮助用户快速发现并修正数据问题,提升用户体验。

  4. 解构赋值的应用
    const { title, content, channel_id } = formValues语法简洁地提取所需字段,避免传递不必要的数据。

四、异步数据加载与频道管理

文章发布需要选择文章频道,频道数据通过异步请求获取

javascript 复制代码
// 状态管理频道列表
const [channelList, setChannelList] = useState([])

// 异步获取频道数据
useEffect(() => {
  async function getChannelList() {
    try {
      const res = await getChannelsAPI();
      setChannelList(res.data.channels);
    } catch (error) {
      console.error('获取频道列表失败:', error);
    }
  }
  getChannelList();
}, []);

// 频道选择器渲染
<Select placeholder="请选择文章频道" style={{ width: 400 }}>
  {channelList.map((item) => (
    <Option key={item.id} value={item.id}>{item.name}</Option>
  ))}
</Select>

异步处理的最佳实践:

  1. 组件挂载时加载数据

    使用useEffect钩子配合空依赖数组[],确保仅在组件初始化时请求频道数据,避免重复请求。

  2. API调用的封装处理

    通过独立的getChannelsAPI函数封装API请求,遵循关注点分离原则,使组件代码更清晰。

  3. 数据映射与渲染

    使用channelList.map将API返回的数据转换为Option组件,注意为每个选项设置唯一的key属性。

  4. 错误处理机制

    虽然当前代码中未实现,但在实际项目中应添加错误处理逻辑(如try/catch),提供数据加载失败时的用户反馈。

总结

本文详细分析了React项目中富文本文章发布功能的实现全流程。从富文本编辑器的集成、封面图片的动态管理,到表单数据的构造与提交,每个环节都体现了React的核心设计理念:

  1. 组件化设计 - 将UI拆分为可复用的组件
  2. 状态驱动视图 - 通过状态变化控制UI渲染
  3. 异步数据流 - 合理管理API请求和数据加载
  4. 表单高效管理 - 借助Ant Design简化表单处理

这些技术点共同构建了一个健壮的文章发布系统。在实际项目中,开发者可以基于此扩展草稿保存、自动保存、内容版本控制等进阶功能,进一步优化内容创作体验。

功能部分源码(全源码见github)

API 复制代码
//封装和文章相关的接口
import {request} from "@/utils";

//1.获取频道列表
export function getChannelsAPI() {
  return request({
    url: '/channels',
    method: 'GET'
  })
}
//2.提交文章表单
export function createArticleAPI(data) {
  return request({
    url: '/mp/articles?draft=false',
    method: 'POST',
    data
  })
}
APP 复制代码
import {
  Card,
  Breadcrumb,
  Form,
  Button,
  Radio,
  Input,
  Upload,
  Space,
  Select
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'
import { useState } from'react'
import { useEffect } from 'react'
import { getChannelsAPI } from'@/apis/article'
import { createArticleAPI } from '@/apis/article'
import { message } from 'antd'



const { Option } = Select
const Publish = () => {
  //获取频道列表
  const [channelList, setChannelList] = useState([])
  useEffect(() => {
    async function getChannelList() {
      const res = await getChannelsAPI()
      setChannelList(res.data.channels)
    }
    getChannelList()
  }, [])
  //表单提交
  const onFinish = (formValues) => {
    const { title, content, channel_id } = formValues
    //校验图片数量是否等于Type数量
    if (imageType > 0 && imageList.length !== imageType) {
      return message.error(`请选择${imageType}张图片`)
    }
    //1.按照接口文档格式处理收集到的数据表单
    const reqData  = { 
      title,
      content,
      cover: {
        type: imageType,
        images: imageList.map(item => item.response.data.url)
      },
      channel_id
    }
    console.log(reqData)
    //2.调用接口
    createArticleAPI(reqData)
  };
  //上传图片
  const [imageList, setImageList] = useState([])
  const onChange = (value) => {
    setImageList(value.fileList)
  }

  // 切换图片封面类型
  const [imageType, setImageType] = useState(0)
  const onTypeChange = (e) => {
    setImageType(Number(e.target.value))
  }

  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '发布文章' },
          ]}
          />
        }
      >
        <Form
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 0 }}
          onFinish={onFinish}
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: '请输入文章标题' }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: '请选择文章频道' }]}
          >
            <Select placeholder="请选择文章频道" style={{ width: 400 }}>
              {/* value属性用户选中之后会自动收集起来作为接口的提交字段 */}
              {channelList.map((item) => (<Option key={item.id} value={item.id}>{item.name}</Option>))}
            </Select>
          </Form.Item>
          <Form.Item label="封面">
            <Form.Item name="type" onChange={onTypeChange}>
                <Radio.Group>
                <Radio value={1}>单图</Radio>
                <Radio value={3}>三图</Radio>
                <Radio value={0}>无图</Radio>
                </Radio.Group>
            </Form.Item>
            {imageType>0&&<Upload
                listType="picture-card"
                showUploadList
                action={'http://geek.itheima.net/v1_0/upload'}
                name='image'
                onChange={onChange}
                maxCount={imageType}
            >
                <div style={{ marginTop: 8 }}>
                <PlusOutlined />
                </div>
            </Upload>}
            
          </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          >
          <ReactQuill
            className="publish-quill"
            theme="snow"
            placeholder="请输入文章内容"
          />
          </Form.Item>

          <Form.Item wrapperCol={{ offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                发布文章
              </Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Publish
scss 复制代码
.publish {
  position: relative;
}

.ant-upload-list {
  .ant-upload-list-picture-card-container,
  .ant-upload-select {
    width: 146px;
    height: 146px;
  }
}
.publish-quill {
  .ql-editor {
    min-height: 300px;
  }
}
相关推荐
无名之逆29 分钟前
[特殊字符]For Speed Enthusiasts: The Ultimate Evolution of Rust HTTP Engines
开发语言·前端·后端·网络协议·http·rust
巴巴_羊31 分钟前
前端八股HTTP和https大全套
前端·http·https
qianmoQ33 分钟前
GitHub 趋势日报 (2025年05月30日)
github
不写八个1 小时前
Express教程【002】:Express监听GET和POST请求
前端·javascript·express
pianmian17 小时前
3D Tiles高级样式设置与条件渲染(3)
linux·服务器·前端
资深前端之路7 小时前
vue+threeJs 绘制3D圆形
前端·javascript·vue.js
Nymph_Zhu8 小时前
vue3+element-plus el-date-picker日期、年份筛选设置本周、本月、近3年等快捷筛选
前端·vue.js·elementui
极客密码8 小时前
DeepSeek-R1-0528,官方的端午节特别献礼
前端·ai编程·deepseek
打小就很皮...8 小时前
npm、pnpm、yarn使用以及区别
前端·npm·yarn
FungLeo8 小时前
vue2 + webpack 老项目升级 node v22 + vite + vue2 实战全记录
前端·webpack·vue2·vie·webpack 升级 vite