发布于 2026-01-06 0 阅读
0

使用 React 构建无头 CMS。

使用 React 构建无头 CMS。

作者:Ovie Okeh ✏️

我想在我的个人 React 网站上搭建一个博客,但我有一些疑问。每篇文章的图片和内容应该存储在哪里?如何存储内容?当然,我可以把每篇文章的内容硬编码到代码里,把图片上传到 CDN 然后手动添加链接,但这样能扩展到 100 篇甚至 200 篇吗?

我需要像 WordPress 这样的内容管理系统 (CMS),但我对我的 React 网站很满意,不想更换。哦,我知道了——我听一些同事说过无头内容管理系统。那会不会正是我需要的?

好的,我做了一些研究,发现无头CMS正是我需要的。它提供了一个界面来撰写博客文章,并且能够将文章发布到任何我想要的地方。听起来不错,但我该选择哪一个呢?我知道市面上有很多选择。

我四处打听了一下,很多人都推荐Contentful,所以我想不妨一试。以下是我的计划:

  • 设置 Contentful 来托管我的博客文章
  • 上传并发布一些帖子
  • 将我的博客文章导入到我的 React 应用中
  • 把它呈献给我的假想读者们吧。

LogRocket 免费试用横幅

设置 Contentful

嗯……我在官网上仔细阅读了关于 Contentful 的一些资料,它声称自己并非传统的无头 CMS,而是一个“内容基础设施”,显然能让我更灵活地构建内容结构。

不过依我看,它其实就是无头CMS的一种变体,因为它符合无头CMS的标准。如果它能让你一次编写,到处发布,那在我看来它就是个无头CMS。🤷

总之,我注册了一个免费账户,结果发现注册过程非常简单。点击注册后,我看到了这个页面:

Contentful 入门页面
我两者都做,那我该选哪个呢?🤔

我决定探索内容建模,于是点击了左侧按钮,系统自动创建了一个示例项目。不过,我喜欢探索,所以决定从头开始创建自己的项目。顺便一提,在 Contentful 中,项目被称为“空间”。

我点击左侧边栏,然后点击“+ 创建空间”按钮,打开了下面的模态框:

空间类型模态
免费的东西对谁都没坏处。

然后我需要为我的新项目选择一个名字,所以我选择了一个有创意的名字,因为我的创意简直源源不断。

选择名称模态框
我敢打赌,你肯定想不出比这更好的太空名称。

最后,为了确认我确实想要一个新的空间,我被要求完成最后一个模态框。

确认模态框

好了,我已经创建好了新的空间。现在是时候开始写博客文章了。

创建博客文章

在创建博客文章之前,我必须先创建一个叫做内容模型的东西,它简单来说就是某种类型内容的结构。我选择把它看作是我内容的模式。

我需要设计文章的结构,好在这很简单。只需要写下每篇文章需要哪些数据以及数据类型即可。就我而言,需要的数据及其数据类型如下:

  • 标题– 简短文本
  • slug – 短文本
  • 描述– 长文本
  • 特色图片– 一张图片
  • 日期– 日期和时间
  • 正文– 长文本

记下所需数据后,我开始在 Contentful 中创建内容模型。在我刚刚创建的博客空间中,我点击顶部导航菜单中的“内容模型” ,然后在随后的页面中点击“添加内容类型” 。

弹出了一个对话框,我填写了新内容模型的名称。我把它命名为“博客文章”,然后开始添加上面列出的字段。添加完所有字段后,我得到了类似下面的内容:

内容模型模态
小菜一碟。

现在我的博客文章内容模型(或者如果你喜欢的话,也可以称之为模式)已经设置好了,我决定是时候添加我将要拉取到我的 React 应用程序中的实际博客文章了。

仍然在我的博客界面,我点击了顶部导航菜单中的“内容” ,然后点击了“添加博客文章”。如果您正在跟着操作,并且您给内容模型起了其他名字,那么“添加博客文章”可能看起来会不一样。

总之,点击那个按钮后,我进入了一个页面,可以像这样撰写和编辑我的博客文章:

撰写和编辑博客文章页面

这就是我最初需要内容管理系统的原因——一个可以撰写和编辑博客文章的地方,这样我就可以把它们发布到任何我想发布的地方。我先添加了三篇示例文章,以便之后可以导入到我的 React 应用中。

以下是我完成博客文章列表后的样子:

博客文章列表

好了,一切进展顺利,我觉得是时候总结一下我目前为止学到的东西了:

  • 无头内容管理系统让我可以一次创建内容,然后将其发布到任何我喜欢的地方。
  • Contentful就是这样一款内容管理系统,它拥有更高级的功能,例如为我的内容创建结构良好的模式。
  • 我可以创建和编辑多种格式的内容,包括 Markdown 和富文本格式。
  • Contentful 还提供 CDN,用于存储和托管我选择上传到博客文章中的任何媒体。

将 Contentful 集成到 React 应用中

在将 Contentful 集成到我的应用之前,我实际上必须先创建这个应用。我希望我的博客看起来和下面的示例一模一样。

那么,这个应用程序都包含哪些不同的组成部分呢?

  • App.jsx用于处理不同页面路由的组件
  • Posts.jsx用于显示网站上帖子列表的组件
  • SinglePost.jsx用于显示单个帖子的组件

其实也没多少组件。当然,如果你有自己的个人网站,并且想按照本教程操作,你可能需要更多组件,但就我的情况而言,这些就足够了。

构建应用程序

我运行了以下脚本来设置我的项目并安装所需的依赖项:

mkdir react-contentful && cd react-contentful
npm init -y
npm i --save react react-dom react-router-dom react-markdown history contentful
npm i --save-dev parcel-bundler less
Enter fullscreen mode Exit fullscreen mode

我刚刚安装了两个特别重要的软件包:react-markdowncontentful

react-markdown它允许我将 Markdown 内容解析为 HTML 标签。我需要这个功能,因为我将文章内容以“长文本”的形式存储在 Contentful 中,这意味着我的文章正文将使用 Markdown 格式。

contentful这是 Contentful 官方的 Node 包,它允许我与 Contentful 的 API 进行交互。我需要它来从 Contentful 获取我的内容。其他包的功能都一目了然。

创建我的文件

安装完所有必需的依赖项后,我创建了本项目所需的各种文件和文件夹。本教程将省略部分文件的内容,但我会提供链接,方便您复制并跟随教程操作。

  • 运行此脚本以创建所有必需的文件夹:
mkdir public src src/components src/custom-hooks src/components/{posts,single-post}
Enter fullscreen mode Exit fullscreen mode
  • 运行此脚本以创建所有必需的文件:
touch public/index.html public/index.css src/{index,contentful}.js
Enter fullscreen mode Exit fullscreen mode
  • 运行此脚本以创建所有组件:
touch src/components/App.jsx src/components/helpers.js src/components/posts/Posts.jsx src/components/posts/Posts.less src/components/single-post/SinglePost.jsx src/components/single-post/SinglePost.less
Enter fullscreen mode Exit fullscreen mode
  • 运行此脚本以创建所有自定义钩子:
touch src/custom-hooks/{index,usePosts,useSinglePost}.js
Enter fullscreen mode Exit fullscreen mode

以下文件的代码我不会详细讲解,因为它们与本教程无关:

填充文件

现在我的项目结构已经准备就绪,所有必需的文件和文件夹都已就绪,我开始编写代码,我将首先从最基本的部分开始。

src/contentful.js

const client = require('contentful').createClient({
  space: '<my_space_id>',
  accessToken: '<my_access_token>'
})

const getBlogPosts = () => client.getEntries().then(response => response.items)

const getSinglePost = slug =>
  client
    .getEntries({
      'fields.slug': slug,
      content_type: 'blogPost'
    })
    .then(response => response.items)

export { getBlogPosts, getSinglePost }
Enter fullscreen mode Exit fullscreen mode

所以我首先编写了与 Contentful 交互以检索我的博客文章的代码。

我想从 Contentful 查询我的内容,所以我查阅了contentful软件包文档,发现我需要导入该软件包并向其传递一个包含空间 ID 和我的访问令牌的配置对象。

获取这些信息非常简单,我只需要按照Contentful 文档中的说明操作即可。

获取到空间 ID 和访问令牌后,我安装了相应的contentful包,并createClient使用包含凭据的配置对象调用了该方法。这使我获得了一个对象,client从而可以与 Contentful 进行交互。

总结一下,我想要找回:

  • 我的所有博客文章
  • 它的一篇博客文章

为了检索我的所有博客文章,我创建了一个函数 `getAllblogposts`getBlogPosts来完成这项工作。在这个函数内部,我调用了 `getAllblogposts` 方法client.getEntries(),该方法返回一个 Promise,最终解析为一个包含`blogposts` 数组的response对象。items

为了检索单篇博客文章,我创建了一个名为 `post_list` 的函数getSinglePost,该函数接收一个名为“slug”的参数,并查询 Contentful 数据库中所有具有该 slug 的文章。请注意,“slug”是我在博客文章内容模型中创建的字段之一,因此我可以在查询中引用它。

在函数内部getSinglePost,我client.getEntries()再次调用了该函数,但这次我传递了一个查询对象,指定我想要包含以下任何内容:

  • 具有与“slug”参数匹配的slug
  • 是一篇博客文章

然后,在文件末尾,我导出了这两个函数,以便可以在其他文件中使用它们。接下来,我创建了自定义钩子。

custom-hooks/usePosts.js

import { useEffect, useState } from 'react'

import { getBlogPosts } from '../contentful'

const promise = getBlogPosts()

export default function usePosts() {
  const [posts, setPosts] = useState([])
  const [isLoading, setLoading] = useState(true)

  useEffect(() => {
    promise.then(blogPosts => {
      setPosts(blogPosts)
      setLoading(false)
    })
  }, [])

  return [posts, isLoading]
}
Enter fullscreen mode Exit fullscreen mode

这个usePostsHook 允许我从组件中检索 Contentful 上的博客文章Posts.jsx

我将三个模块导入到这个文件中:

  1. useEffect我需要这个来更新自定义 Hook 的状态
  2. useState我需要用它来存储博客文章列表以及当前的加载状态
  3. getBlogPosts这个功能让我能够从 Contentful 查询我的博客文章。

将所有必需模块导入到该文件后,我调用了该函数来获取我的博客文章getBlogPosts()。该函数返回一个 Promise 对象,我将其存储在一个promise变量中。

在 Hook 函数内部usePosts(),我初始化了两个状态变量:

  1. posts用来保存博客文章列表
  2. isLoading,用于保存博客文章获取请求的当前加载状态

然后,在useEffect调用过程中,我解析了之前创建的 Promise,并posts用新的博客文章数据更新了状态变量。完成后,我还将加载状态设置为 false。

在这个 Hook 的末尾,我返回了一个包含posts变量的数组isLoading

custom-hooks/useSinglePost.js

import { useEffect, useState } from 'react'

import { getSinglePost } from '../contentful'

export default function useSinglePost(slug) {
  const promise = getSinglePost(slug)

  const [post, setPost] = useState(null)
  const [isLoading, setLoading] = useState(true)

  useEffect(() => {
    promise.then(result => {
      setPost(result[0].fields)
      setLoading(false)
    })
  }, [])

  return [post, isLoading]
}
Enter fullscreen mode Exit fullscreen mode

定制useSinglePost版 Hook 与 Hook 非常相似usePosts,只有一些细微的差别。

usePosts与之前在 Hook 外部发起调用不同,这次我在 Hook内部getBlogPosts发起了调用(但调用对象是 `<function> `) 。我这样做是因为我想将“slug”参数传递给该函数,而如果在自定义 Hook 外部调用,就无法做到这一点。getSinglePost()useSinglePostgetSinglePost

接下来,我还使用了相同的状态变量来保存正在检索的单个帖子,以及请求的加载状态。

useEffect调用过程中,我解析了 Promise 并根据需要更新了状态变量。

最后我还返回了一个包含状态变量的post数组isLoading

components/App.jsx

import React from 'react'
import { Router, Switch, Route } from 'react-router-dom'
import { createBrowserHistory } from 'history'

import Posts from './posts/Posts'
import SinglePost from './single-post/SinglePost'

export default function App() {
  return (
    <Router history={createBrowserHistory()}>
      <Switch>
        <Route path="/" exact component={Posts} />
        <Route path="/:id" component={SinglePost} />
      </Switch>
    </Router>
  )
}
Enter fullscreen mode Exit fullscreen mode

App.jsx是负责将用户路由到正确页面的根组件。

我导入了一堆必需的依赖项。我还得复习一下 React Router 的工作原理,所以我读了这篇简短的文章。

components/posts/Posts.jsx

现在我已经设置好了所有自定义钩子和查询函数,接下来我想检索所有博客文章并将它们以网格形式显示,如下所示:

博客首页文章预览网格
这个设计值得一个Awwward。

我首先导入了一系列依赖项,其中包括usePosts用于从 Contentful 获取所有博客文章的自定义 Hook。我还创建了一个名为 `date_posts` 的实用小工具readableDate,它可以将文章的发布日期解析成用户友好的格式。

import React from 'react'
import { Link } from 'react-router-dom'

import { usePosts } from '../../custom-hooks/'
import { readableDate } from '../helpers'
import './Posts.less'

...continued below...
Enter fullscreen mode Exit fullscreen mode

接下来我创建了这个组件。它是一个简单的函数式组件,没有任何需要管理或跟踪的状态变量。

一开始,我利用usePostsHook 获取了我的文章和加载状态。然后我定义了一个函数,renderPosts用于遍历博客文章列表,并为每篇文章返回一组 JSX 代码。

在这个函数内部,我首先检查了加载状态。如果请求仍在加载,则返回加载消息并结束执行。否则,它会遍历帖子数组,并为每条帖子返回一个<Link />元素。

这个Link元素会将读者重定向到他们点击的任何文章的链接地址。在这个链接元素内,我还渲染了一些重要信息,例如文章的特色图片、发布日期、标题和简短描述。

最后,在组件的返回语句中Posts,我调用了该renderPosts()函数。

...continuation...
export default function Posts() {
  const [posts, isLoading] = usePosts()

  const renderPosts = () => {
    if (isLoading) return <p>Loading...</p>

    return posts.map(post => (
      <Link
        className="posts__post"
        key={post.fields.slug}
        to={post.fields.slug}
      >
        <div className="posts__post__img__container">
          <img
            className="posts__post__img__container__img"
            src={post.fields.featuredImage.fields.file.url}
            alt={post.fields.title}
          />
        </div>

        <small>{readableDate(post.fields.date)}</small>
        <h3>{post.fields.title}</h3>
        <p>{post.fields.description}</p>
      </Link>
    ))
  }

  return (
    <div className="posts__container">
      <h2>Articles</h2>

      <div className="posts">{renderPosts()}</div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

综上所述,我在这个组件中做了以下工作:

  • 我调用了自usePosts()定义 Hook。它会返回两个变量,posts一个是空的,isLoading另一个posts是包含我的 Contentful 空间中博客文章列表的变量。另一个变量的值isLoading取决于获取博客文章的请求是否仍在等待处理,其值为 true 或 false。
  • 我定义了一个renderPosts()函数,它可以在 DOM 中渲染加载信息或渲染我的博客文章。它会检查一个isLoading变量来判断博客文章是否已准备就绪,然后将相应的内容渲染到 DOM 中。
  • 在返回语句中,我返回了一堆 JSX 代码并调用了它。renderPosts()

接下来是下一个组件。

components/single-post/SinglePost.jsx

我还需渲染单个博客文章,为此,我需要一个SinglePost组件,它应该看起来像这样:

单篇文章示例
很有意思的帖子。

和往常一样,我首先导入了一堆依赖项:

import React from 'react'
import { Link, useParams } from 'react-router-dom'
import MD from 'react-markdown'

import { useSinglePost } from '../../custom-hooks'
import { readableDate } from '../helpers'
import './SinglePost.less'
Enter fullscreen mode Exit fullscreen mode

这里有一些新的、不常见的进口商品:

  • useParams这将允许我从 React Router 读取动态路由参数。
  • MD这将帮助我将 Markdown 内容转换为 HTML 并进行渲染。

除了新增的 Hook 和辅助函数之外,我还导入了useSinglePost自定义 Hook 和readableDate辅助函数。

接下来,我创建了实际的组件。

...continued...
export default function SinglePost() {
  const { id } = useParams()
  const [post, isLoading] = useSinglePost(id)

  const renderPost = () => {
    if (isLoading) return <p>Loading...</p>

    return (
      <>
        <div className="post__intro">
          <h2 className="post__intro__title">{post.title}</h2>
          <small className="post__intro__date">{readableDate(post.date)}</small>
          <p className="post__intro__desc">{post.description}</p>

          <img
            className="post__intro__img"
            src={post.featuredImage.fields.file.url}
            alt={post.title}
          />
        </div>

        <div className="post__body">
          <MD source={post.body} />
        </div>
      </>
    )
  }
...continued below...
Enter fullscreen mode Exit fullscreen mode

在继续之前,我想先简单介绍一下useParams它的工作原理。在代码中App.jsx,我有以下代码片段:

<Route path="/:id" component={SinglePost} />
Enter fullscreen mode Exit fullscreen mode

这段代码会将所有匹配指定 URL 模式的请求路由到path组件SinglePost。React Router 还会向SinglePost组件传递一些额外的 props。其中一个 props 是一个params对象,其中包含 URL 路径中的所有参数。

在这种情况下,由于我已在特定路由的路径 URL 中明确指定了该参数,因此params它将包含id该参数。所以,如果我导航到类似这样的 URL 它将看起来像这样:idlocalhost:3000/contentful-rulesparams

{
  id: 'contentful-rules'
}
Enter fullscreen mode Exit fullscreen mode

这也正是它useParams发挥作用的地方。它允许我查询params对象,而无需从组件的 props 中解构对象。现在我有办法获取当前 URL 中的任何 slug。

好了,回到组件本身。现在我已经找到了获取被点击文章的别名的方法,就可以将别名传递给自useSinglePost定义 Hook,从而获取带有该别名的文章以及获取文章请求的加载状态。

从 Hook 获取帖子对象和加载状态后useSinglePost,我定义了一个renderPost函数,该函数会根据加载状态,向 DOM 渲染加载消息或实际的帖子。

还要注意,在代码片段的末尾,我有这样一行代码:

<MD source={post.body} />
Enter fullscreen mode Exit fullscreen mode

这是我需要用来将 Markdown 文章正文解析成浏览器可识别的实际 HTML 的 React Markdown 组件。

...continued...

  return (
    <div className="post">
      <Link className="post__back" to="/">
        {'< Back'}
      </Link>

      {renderPost()}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

最后,我添加了返回语句,用于从该组件渲染数据。我添加了一个返回首页的链接,方便用户轻松返回首页。链接之后,我调用了renderPost()将文章渲染到 DOM 的函数。

总结一下,我在这个组件中做了以下工作。

  • 我调用了useSinglePost()自定义 Hook。它会返回两个变量,post其中一个isLoading变量post要么是 null,要么是一个包含帖子数据的对象。另一个变量的值isLoading取决于获取帖子的请求是否仍在等待处理,其值为 true 或 false。
  • 我定义了一个renderPost()函数,该函数会根据博客文章的加载状态来决定是向 DOM 渲染加载信息还是渲染博客文章本身。它会检查一个isLoading变量来判断博客文章是否已准备就绪,然后将相应的内容渲染到 DOM 中。
  • 在返回语句中,我返回了一堆 JSX 代码并调用了它。renderPost()

把所有东西整合起来

在编写完所有组件的代码并添加相应的样式后,我决定运行我的项目,看看一切是否正常。在我的项目中package.json,我添加了以下脚本:

"scripts": {
    "start": "parcel public/index.html",
    "build": "parcel build public/index.html --out-dir build --no-source-maps"
  },
Enter fullscreen mode Exit fullscreen mode

当我npm run start在终端运行 Parcel 时,它为我构建了 React 应用,并通过 1234 端口提供服务。在浏览器中访问该应用后http://localhost:1234,我的应用以及博客文章都清晰地显示出来。

我尝试点击一篇博客文章,然后被重定向到一个页面,在那里我可以阅读那篇博客文章,所以看来我使用 React 和 Contentful 进行的小实验按预期进行了。

网站预览

我完全明白,对于像静态博客这样简单的项目来说,这并非最佳方案。还有更好的选择,例如Next.jsGatsby.js,它们能大大简化构建过程,并且默认情况下就能生成速度更快、更易于访问的博客。

但如果你的使用场景仅仅是将内容从 Contentful 导入到你的 React 应用中,那么本指南应该对你有所帮助。


全面了解生产环境中的 React 应用

调试 React 应用可能很困难,尤其是在用户遇到难以重现的问题时。如果您有兴趣监控和跟踪 Redux 状态、自动显示 JavaScript 错误、跟踪缓慢的网络请求和组件加载时间,不妨试试 LogRocket。

替代文字

LogRocket就像 Web 应用的 DVR,它会记录 React 应用中发生的一切。你无需猜测问题原因,即可汇总并报告问题发生时应用的状态。LogRocket 还会监控应用的性能,报告客户端 CPU 负载、客户端内存使用情况等指标。

LogRocket Redux 中间件包为用户会话增加了一层额外的可见性。LogRocket 会记录 Redux store 中的所有操作和状态。

革新 React 应用的调试方式——开始免费监控


本文《使用 React 构建无头 CMS》最初发表于LogRocket 博客

文章来源:https://dev.to/bnevilleoneill/using-a-headless-cms-with-react-2ec7