如何使用 Next.js 和 Sanity 创建评论引擎

Avatar of Bryan Robinson
Bryan Robinson

DigitalOcean 为您的旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

针对使用 Jamstack 构建网站提出的反对意见之一是,开发功能变得复杂,通常需要许多其他服务。 以评论为例。 要为 Jamstack 网站设置评论,您通常需要第三方解决方案,例如 DisqusFacebook,甚至只是单独的数据库服务。 该第三方解决方案通常意味着您的评论与它们的内容脱节。

当我们使用第三方系统时,我们必须忍受使用他人代码的权衡。 我们获得了即插即用的解决方案,但代价是什么? 向我们的用户展示广告? 我们无法优化的不必要的 JavaScript? 评论内容归他人所有的事实? 这些都是值得考虑的事情。

整体式服务,如 WordPress,已通过将所有内容都放在同一个应用程序中来解决此问题。 如果我们可以将我们的评论与我们的内容放在同一个数据库和 CMS 中,以与查询内容相同的方式查询它,并在前端使用相同框架显示它,会怎么样?

这将使这个特定的 Jamstack 应用程序对于我们的开发人员和编辑人员来说感觉更加一致。

让我们创建自己的评论引擎

在本文中,我们将使用 Next.js 和 Sanity.io 创建一个满足这些需求的评论引擎。 内容、编辑、评论者和开发人员的统一平台。

为什么选择 Next.js?

Next.js 是 Vercel 团队构建的 React 的元框架。 它具有用于无服务器函数、静态站点生成和服务器端渲染的内置功能。

对于我们的工作,我们主要使用其内置的“API 路由”来实现无服务器函数,以及其静态站点生成功能。 API 路由将简化项目,但如果您部署到类似 Netlify 的东西,这些可以转换为无服务器函数,或者我们可以使用 Netlify 的 next-on-netlify 包

正是静态、服务器渲染和无服务器函数的这种交集使 Next.js 成为此类项目的理想解决方案。

为什么选择 Sanity?

Sanity.io 是一个用于结构化内容的灵活平台。 它的核心是一个数据存储,鼓励开发人员将内容视为结构化数据。 它通常与名为 Sanity Studio 的开源 CMS 解决方案配对。

我们将使用 Sanity 将作者的内容与任何用户生成的内容(如评论)放在一起。 最后,Sanity 是一个内容平台,具有强大的 API 和可配置的 CMS,允许我们进行必要的自定义以将这些内容绑定在一起。

设置 Sanity 和 Next.js

我们不会从头开始这个项目。 我们将首先使用 Vercel 创建的简单博客入门程序来开始使用 Next.js 和 Sanity 集成。 由于 Vercel 入门程序存储库的 Frontend 和 Sanity Studio 是分开的,因此我创建了一个简化的存储库,其中包含两者。

我们将 克隆此存储库,并使用它来创建我们的评论基础。 想看看最终代码吗? 此“入门”将帮助您设置存储库、Vercel 项目和 Sanity 项目,所有这些都已连接

入门仓库分为两部分:由 Next.js 提供支持的 Frontend 和 Sanity Studio。 在继续之前,我们需要在本地运行这些程序。

要开始,我们需要设置我们的内容和我们的 CMS,以便 Next 可以使用数据。 首先,我们需要安装运行 Studio 并连接到 Sanity API 所需的依赖项。

# Install the Sanity CLI globally
npm install -g @sanity/cli
# Move into the Studio directory and install the Studio's dependencies
cd studio
npm install

一旦这些安装完成,在 /studio 目录中,我们可以使用 CLI 设置一个新项目。

# If you're not logged into Sanity via the CLI already
sanity login
# Run init to set up a new project (or connect an existing project)
sanity init

init 命令会问我们一些问题来设置所有内容。 由于 Studio 代码已经有一些配置值,因此 CLI 会问我们是否要重新配置它。 我们要。

从那里,它将询问我们连接到哪个项目,或者我们是否要配置一个新项目。

我们将使用描述性的项目名称配置一个新项目。 它将要求我们命名要创建的“数据集”。 这默认为“production”,这是完全可以的,但可以使用对您的项目有意义的任何名称来覆盖。

CLI 将使用项目的 ID 和数据集名称修改文件 ~/studio/sanity.json。 这些值在以后很重要,因此请将此文件放在手边。

现在,我们已准备好以本地方式运行 Studio。

# From within /studio
npm run start

Studio 编译后,可以在浏览器中通过 http://localhost:3333 打开。

此时,进入管理界面并创建一些测试内容是有意义的。 要使 Frontend 正确运行,我们需要至少一篇博客文章和一位作者,但额外的内容始终有助于获得感受。 请注意,即使您从 Studio 在 localhost 上工作,内容也会实时同步到数据存储。 它将立即可供查询。 不要忘记点击“发布”按钮,以便内容公开可用。

一旦我们拥有了一些内容,就该让我们的 Next.js 项目运行了。

使用 Next.js 设置

Next.js 所需的大多数内容已在存储库中设置。 我们需要做的主要事情是将我们的 Sanity 项目连接到 Next.js。 为此,在 /blog-frontent/.env.local.example 中有一组示例环境变量。 从该文件中删除 .example,然后我们将使用正确的值修改环境变量。

我们需要来自 Sanity 项目的 API 令牌。 要创建此值,让我们转到 Sanity 仪表板。 在仪表板中,找到当前项目并导航到“设置”→“API”区域。 从这里,我们可以创建要在项目中使用的新的令牌。 在许多项目中,创建只读令牌就足够了。 在我们的项目中,我们将数据发布回 Sanity,因此我们需要创建一个读写令牌。

Showing a modal open in the Sanity dashboard with a Add New Token heading, a text field to set the token label with a value of Comment Engine, and three radio buttons that set if the token as read, write or deploy studio access where the write option is selected.
在 Sanity 仪表板中添加一个新的读写令牌

单击“添加新令牌”时,我们会收到一个包含令牌值的弹出窗口。 关闭后,我们就无法再次检索令牌,因此请务必获取它!

此字符串在我们的 .env.local 文件中作为 SANITY_API_TOKEN 的值。 由于我们已经登录了 manage.sanity.io,因此我们也可以从项目页面的顶部获取项目 ID 并将其粘贴到 NEXT_PUBLIC_SANITY_PROJECT_ID 的值中。 SANITY_PREVIEW_SECRET 在我们要以“预览模式”运行 Next.js 时很重要,但对于此演示,我们不需要填写它。

我们几乎准备好运行我们的 Next Frontend 了。 当我们仍然打开 Sanity 仪表板时,我们需要对我们的“设置”→“API”视图进行最后一个更改。 我们需要允许我们的 Next.js localhost 服务器发出请求。

在 CORS 来源中,我们将添加一个新的来源,并使用当前的 localhost 端口填充它:http://localhost:3000。 我们不需要能够发送经过身份验证的请求,因此可以省略它。 当它上线时,我们需要添加一个包含生产 URL 的额外来源,以允许实时网站发出请求。

我们的博客现在已准备好以本地方式运行!

# From inside /blog-frontend
npm run dev

运行上面的命令后,我们现在拥有一个在我们的计算机上运行的博客,从 Sanity API 拉取数据。 我们可以访问 http://localhost:3000 来查看网站。

创建评论的架构

要将评论添加到我们的数据库并在 Studio 中进行查看,我们需要设置 数据的架构

要添加我们的架构,我们将在 /studio/schemas 目录中添加一个名为 comment.js 的新文件。 此 JavaScript 文件将导出一个对象,其中将包含整个数据结构的定义。 这将告诉 Studio 如何显示数据,以及我们如何将数据返回给我们的 Frontend。

在评论的情况下,我们希望获得评论界可能认为的“默认值”。 我们将有一个用于用户姓名、电子邮件和一个用于评论字符串的文本区域的字段。 除了这些基础知识之外,我们还需要一种将评论附加到特定帖子上的方法。 在 Sanity 的 API 中,字段类型是对另一种类型数据的“引用”。

如果我们想让我们的网站被垃圾邮件轰炸,我们可以在那里结束,但添加一个批准流程可能是个好主意。我们可以通过在评论中添加一个布尔字段来做到这一点,该字段将控制是否在我们的网站上显示评论。

export default {
  name: 'comment',
  type: 'document',
  title: 'Comment',
  fields: [
    {
      name: 'name',
      type: 'string',
    },
    {
      title: 'Approved',
      name: 'approved',
      type: 'boolean',
      description: "Comments won't show on the site without approval"
    },   
    {
      name: 'email',
      type: 'string',
    },
    {
      name: 'comment',
      type: 'text',
    },
    {
      name: 'post',
      type: 'reference',
      to: [
        {type: 'post'}
      ]
    }
  ],
}

添加此文档后,我们还需要将其添加到我们的 /studio/schemas/schema.js 文件中,以将其注册为新的文档类型。

import createSchema from 'part:@sanity/base/schema-creator'
import schemaTypes from 'all:part:@sanity/base/schema-type'
import blockContent from './blockContent'
import category from './category'
import post from './post'
import author from './author'
import comment from './comment' // <- Import our new Schema
export default createSchema({
  name: 'default',
  types: schemaTypes.concat([
    post,
    author,
    category,
    comment, // <- Use our new Schema
    blockContent
  ])
})

完成这些更改后,当我们再次查看 Studio 时,将在我们的主要内容列表中看到一个评论部分。我们甚至可以进入并添加第一个评论进行测试(因为我们还没有在前端构建任何 UI)。

精明的开发人员会注意到,在添加评论后,我们的评论列表视图预览不是很有帮助。现在我们有了数据,我们可以为此列表视图提供自定义预览。

在列表视图中为评论添加 CMS 预览

fields 数组之后,我们可以指定一个 preview 对象。preview 对象将告诉 Sanity 的列表视图显示哪些数据以及以什么配置显示。我们将为此对象添加一个属性和一个方法。select 属性是一个对象,我们可以使用它从我们的架构中收集数据。在本例中,我们将获取评论的 namecommentpost.title 值。我们将这些新变量传递给我们的 prepare() 方法,并使用它返回一个 titlesubtitle 用于列表视图。

export default {
  // ... Fields information
  preview: {
      select: {
        name: 'name',
        comment: 'comment',
        post: 'post.title'
      },
      prepare({name, comment, post}) {
        return {
          title: `${name} on ${post}`,
          subtitle: comment
        }
      }
    }
  }

}

标题将以较大字体显示,而副标题将以较小字体显示,并且更淡。在这个预览中,我们将标题设置为包含评论作者姓名和评论帖子的字符串,副标题为评论正文本身。你可以 配置预览 以匹配你的需求。

数据现在已经存在,我们的 CMS 预览已经准备就绪,但它还没有拉入我们的网站。我们需要修改我们的数据获取,将我们的评论拉到每个帖子中。

显示每个帖子的评论

在这个仓库中,我们有一个专门用于与 Sanity 的 API 交互的函数的文件。/blog-frontend/lib/api.js 文件包含针对我们网站中各种路由的用例的特定导出函数。我们需要更新此文件中的 getPostAndMorePosts 函数,该函数提取每个帖子的数据。它返回与当前页面 slug 关联的帖子的正确数据,以及要与之一起显示的一系列新帖子。

在这个函数中,有两个查询:一个是获取当前帖子的数据,另一个是获取额外的帖子。我们需要修改的请求是第一个请求。

使用 GROQ 投影更改返回的数据

该查询是在 开源的基于图的查询语言 GROQ 中进行的,Sanity 使用它从数据存储中提取数据。查询分为三个部分

  • 过滤器 - 要查找和发回的数据集 *[_type == "post" && slug.current == $slug]
  • 可选管道组件 - 对左侧组件返回的数据进行的修改 | order(_updatedAt desc)
  • 可选投影 - 要为查询返回的特定数据元素。在本例中,括号 ({}) 之间的所有内容。

在这个例子中,我们有一个字段变量列表,我们大多数查询都需要这些字段,以及博客帖子的 body 数据。紧接在 body 之后,我们想拉取与这个帖子关联的所有评论。

为此,我们在返回的对象上创建一个名为 'comments' 的命名属性,然后运行一个新的查询以返回包含对当前帖子上下文的引用的评论。

整个过滤器如下所示

*[_type == "comment" && post._ref == ^._id && approved == true]

该过滤器匹配所有满足方括号 ([]) 内部条件的文档。在本例中,我们将找到所有 _type == "comment" 的文档。然后我们将测试当前帖子的 _ref 是否与评论的 _id 匹配。最后,我们检查评论是否 approved == true

获得该数据后,我们将使用可选投影选择要返回的数据。如果没有投影,我们将获得每个评论的所有数据。在这个例子中不重要,但这是一个很好的习惯。

curClient.fetch(
    `*[_type == "post" && slug.current == $slug] | order(_updatedAt desc) {
        ${postFields}
        body,
        'comments': *[_type == "comment" && post._ref == ^._id && approved == true]{
            _id, 
            name, 
            email, 
            comment, 
            _createdAt
        }
    }`,
 { slug }
 )
 .then((res) => res?.[0]),

Sanity 在响应中返回一个数据数组。这在很多情况下都很有用,但对我们来说,我们只需要数组中的第一项,所以我们将响应限制为索引中的第零个位置。

向我们的帖子添加评论组件

我们的单个帖子使用 /blog-frontend/pages/posts/[slug].js 文件中的代码呈现。此文件中的组件已经在我们的 API 文件中接收了更新的数据。主要的 Post() 函数返回我们的布局。这就是我们将添加新组件的地方。

评论通常出现在帖子内容之后,所以让我们在结束 </article> 标签之后立即添加它。

// ... The rest of the component
</article>
// The comments list component with comments being passed in
<Comments comments={post?.comments} />

现在我们需要创建我们的组件文件。此项目中的组件文件位于 /blog-frontend/components 目录中。我们将遵循组件的标准模式。此组件的主要功能是获取传递给它的数组并创建一个带有正确标记的无序列表。

由于我们已经有一个 <Date /> 组件,我们可以使用它来正确地格式化我们的日期。

# /blog-frontend/components/comments.js

import Date from './date'

export default function Comments({ comments = [] }) {
  return (
    <>
     <h2 className="mt-10 mb-4 text-4xl lg:text-6xl leading-tight">Comments:</h2>
      <ul>
        {comments?.map(({ _id, _createdAt, name, email, comment }) => (
          <li key={_id} className="mb-5">
            <hr className="mb-5" />
            <h4 className="mb-2 leading-tight"><a href={`mailto:${email}`}>{name}</a> (<Date dateString={_createdAt}/>)</h4>
            <p>{comment}</p>
            <hr className="mt-5 mb-5" />
         </li>
        ))
      </ul>
    </>
  )
}

回到我们的 /blog-frontend/pages/posts/[slug].js 文件中,我们需要在顶部导入此组件,然后我们为有评论的帖子显示一个评论部分。

import Comments from '../../components/comments'

现在我们已经列出了我们手动输入的评论。这很好,但不是很有交互性。让我们在页面中添加一个表单,允许用户向我们的数据集提交评论。

向博客帖子添加评论表单

对于我们的评论表单,为什么要重新发明轮子呢?我们已经使用 Next.js 处于 React 生态系统中,所以我们不妨利用它。我们将使用 react-hook-form 包,但任何表单或表单组件都可以。

首先,我们需要安装我们的包。

npm install react-hook-form

在安装的同时,我们可以继续设置我们的表单组件。在 Post 组件中,我们可以在新的 <Comments /> 组件之后添加一个 <Form /> 组件。

// ... Rest of the component
<Comments comments={post.comments} />
<Form _id={post._id} />

请注意,我们将当前帖子的 _id 值传递给我们的新组件。这就是我们将评论与帖子绑定在一起的方式。

与我们的评论组件一样,我们需要在 /blog-frontend/components/form.js 中创建一个此组件的文件。

export default function Form ({_id}) {

  // Sets up basic data state
  const [formData, setFormData] = useState() 
        
  // Sets up our form states 
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [hasSubmitted, setHasSubmitted] = useState(false)
        
  // Prepares the functions from react-hook-form
  const { register, handleSubmit, watch, errors } = useForm()

  // Function for handling the form submission
  const onSubmit = async data => {
    // ... Submit handler
  }

  if (isSubmitting) {
    // Returns a "Submitting comment" state if being processed
    return <h3>Submitting comment…</h3>
  }
  if (hasSubmitted) {
    // Returns the data that the user submitted for them to preview after submission
    return (
      <>
        <h3>Thanks for your comment!</h3>
        <ul>
          <li>
            Name: {formData.name} <br />
            Email: {formData.email} <br />
            Comment: {formData.comment}
          </li>
        </ul>
      </>
    )
  }

  return (
    // Sets up the Form markup
  )
}

这段代码主要是处理表单各种状态的样板代码。表单本身将是我们返回的标记。

// Sets up the Form markup
<form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-lg" disabled>
  <input ref={register} type="hidden" name="_id" value={_id} />
									
  <label className="block mb-5">
    <span className="text-gray-700">Name</span>
    <input name="name" ref={register({required: true})} className="form-input mt-1 block w-full" placeholder="John Appleseed"/>
    </label>
																																																									
  <label className="block mb-5">
    <span className="text-gray-700">Email</span>
    <input name="email" type="email" ref={register({required: true})} className="form-input mt-1 block w-full" placeholder="[email protected]"/>
  </label>

  <label className="block mb-5">
    <span className="text-gray-700">Comment</span>
    <textarea ref={register({required: true})} name="comment" className="form-textarea mt-1 block w-full" rows="8" placeholder="Enter some long form content."></textarea>
  </label>
																																					
  {/* errors will return when field validation fails  */}
  {errors.exampleRequired && <span>This field is required</span>}
	
  <input type="submit" className="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" />
</form>

在这个标记中,我们有一些特殊情况。首先,我们的 <form> 元素有一个 onSubmit 属性,它接受 handleSubmit() 钩子。该钩子由我们的包提供,它接收处理表单提交的函数的名称。

评论表单中的第一个输入是一个隐藏的字段,其中包含我们帖子的 _id。任何必需的表单字段都将使用 ref 属性注册到 react-hook-form 的验证。当我们的表单提交时,我们需要对提交的数据做些什么。这就是我们 onSubmit() 函数的用途。

// Function for handling the form submission
const onSubmit = async data => {
  setIsSubmitting(true)
        
  setFormData(data)
        
  try {
    await fetch('/api/createComment', {
      method: 'POST',
     body: JSON.stringify(data),
     type: 'application/json'
    })  
    setIsSubmitting(false)
    setHasSubmitted(true)
  } catch (err) {
    setFormData(err)
  }
}

此函数有两个主要目标

  1. 通过我们之前创建的状态,在提交过程中设置表单的状态
  2. 通过 fetch() 请求将数据提交到一个无服务器函数。Next.js 自带 fetch(),因此我们不需要安装额外的包。

我们可以获取从表单提交的数据 - 我们表单处理程序的 data 参数 - 并将其提交到我们需要创建的无服务器函数。

我们可以直接将此数据发布到 Sanity API,但这需要一个具有写入权限的 API 密钥,并且你应该使用环境变量将它保护在你的前端之外。无服务器函数允许你在不将秘密令牌暴露给你的访问者的情况下运行此逻辑。

使用 Next.js API 路由将评论提交到 Sanity

为了保护我们的凭据,我们将把表单处理程序写成无服务器函数。在 Next.js 中,我们可以使用“API 路由”来创建无服务器函数。这些函数与我们的页面路由位于相同的目录下,即 /blog-frontent/pages 目录下的 api 目录。我们可以在此处创建一个名为 createComment.js 的新文件。

要写入 Sanity API,首先需要设置一个具有写入权限的客户端。在本演示的早期,我们设置了一个读写令牌并将其放在 /blog-frontent/.env.local 中。此环境变量已在 /blog-frontend/lib/sanity.js 中的客户端对象中使用。有一个名为 previewClient 的读写客户端,它使用令牌来获取未发布的更改以供预览模式使用。

createClient 文件的顶部,我们可以导入该对象以在我们的无服务器函数中使用。Next.js API 路由需要将它的处理程序导出为一个具有请求和响应参数的默认函数。在我们的函数内部,我们将从请求对象的正文中解构我们的表单数据,并使用它来创建一个新文档。

Sanity 的 JavaScript 客户端有一个 create() 方法,它接受一个数据对象。数据对象应该有一个 _type,它与我们希望创建的文档类型匹配,以及我们希望存储的任何数据。在我们的示例中,我们将传递名称、电子邮件和评论。

我们需要做一些额外的工作来将我们帖子的 _id 转换为对 Sanity 中帖子的引用。我们将定义 post 属性为 引用,并在该对象上使用 _id 作为 _ref 属性。提交到 API 后,我们可以根据 Sanity 的响应返回成功状态或错误状态。

// This Next.js template already is configured to write with this Sanity Client
import {previewClient} from '../../lib/sanity'

export default async function createComment(req, res) {
  // Destructure the pieces of our request
  const { _id, name, email, comment} = JSON.parse(req.body)
  try {
    // Use our Client to create a new document in Sanity with an object  
    await previewClient.create({
      _type: 'comment',
      post: {
        _type: 'reference',
        _ref: _id,
      },
     name,
     email,
     comment
    })
  } catch (err) {
    console.error(err)
    return res.status(500).json({message: `Couldn't submit comment`, err})
  }
    
  return res.status(200).json({ message: 'Comment submitted' })
}

一旦此无服务器函数就位,我们可以导航到我们的博客文章并通过表单提交评论。由于我们有一个审批流程,在我们提交评论后,我们可以在 Sanity Studio 中查看它,并选择批准它、拒绝它或将其保留为待处理状态。

进一步开发评论引擎

这为我们提供了评论系统的基本功能,并且它直接与我们的内容共存。当您控制此流程的双方时,有很多潜力。以下是一些进一步开发此评论引擎的想法。