likes
comments
collection
share

使用 Strapi 和 Next.js 开发一个简易微博

作者站长头像
站长
· 阅读数 23

文章为稀土掘金技术社区首发签约文章,14 天内禁止转载,14 天后未获授权禁止转载,侵权必究!

阅读本文,你将收获:

  • 如何使用 Strapi 创建后端 API
  • 如何在 Next.js 中与单独后端联调
  • 并且了解一个全栈应用的实现逻辑

背景

在上一篇文章中,我们使用了 Next.js 和掘金 API 创建了一个全栈博客,使用了抓包的方式获得了掘金 API,终究有些投机取巧,若没有 API ,就无法独立完成一个应用的,今天我将分享另一种方式,同样不需要写后端接口,通过可视化界面就可以创建 Restful API,那有了接口,我们就可以来实现任意应用啦,一起来看看吧。

本文全部代码都在 GitHub 仓库

实现功能与效果

先来看看我们要实现的效果

使用 Strapi 和 Next.js 开发一个简易微博

实现功能

  • 注册与登录
  • 登录后可以发微博,并且可以在不同的话题下发布
  • 上传图片
  • 有一个后台可以管理微博

什么是 strapi

strapi 是免费开源的 Nodejs 无头 CMS 内容管理框架,可以通过后台管理界面创建自定义 API,并且完全使用 JavaScript 实现。 打开strapi 官网,首页的交互式动画映入眼睑。

使用 Strapi 和 Next.js 开发一个简易微博

我们可以清楚的看到,在后台配置字段,便可以生成 JSON schema,有了 JSON schema, strapi 便会自动生成 Restful API,是不是很强大呢?

后端工程

初始化项目

我们先使用 strapi 官网提供的脚手架初始化一个后端应用

npx create-strapi-app backend --quickstart

创建完成后,它会帮我们自动安装 npm 包,安装完成后会显示如下命令

yarn develop
# 开发环境,开始 Strapi 进入监听模型,修改工程文件会自动重启
yarn start
# 生产环境,启动 Strapi
yarn build
# 构建 Strapi 后台.
yarn strapi
显示所有可用的命令

紧接着会自动进入 backend 帮我们执行 yarn develop,并且自动打开如下页面

使用 Strapi 和 Next.js 开发一个简易微博

首先我们要注册一个 admin 账号,随便输入一个就行, 但要记住这个账号

First name: admin
Email: admin@admin.com
Password: Admin@123

登录后来的 strapi 的主界面

使用 Strapi 和 Next.js 开发一个简易微博

首页有关于 strapi 学习的主要入口,包括文档、代码示例、课程和博客。

纯英文的后台,一开始可能有些不习惯,我们可以使用 chrome 翻译插件直接翻译成中文

使用 Strapi 和 Next.js 开发一个简易微博

建表

通过导航上的 Content-Type Builder,也就是内容类型生成器入口开始建立表。

我们首先来想想,微博系统该建哪些表?

使用 Strapi 和 Next.js 开发一个简易微博

先来画一张实体图,梳理下各个实体直接的关系

  • 一个用户可以发送多条信息:一对多
  • 一个话题下也可以有多条信息:一对多
  • 一条信息可以上传多张图片:一对多

那么我们可以以此来写出 TS 类型接口

interface User{
    id:number
    username:string
    email:string
    password:string
}

interface Topic{
    id:number
    title:string
    desc:string
}

interface Message{
    id:number
    content:string
    images:string[]
    user:User
    createdTime:number
    topic:Topic
}

我们可以看到,后台中已经有 User 集合了,所以可以不必建用户表了,点击 Create new collection type来创建一个新的集合:

使用 Strapi 和 Next.js 开发一个简易微博

输入 Display name 为 topic 主题表,右边的参数会自动填充,默认不用修改,点击 Continue 继续

使用 Strapi 和 Next.js 开发一个简易微博

在弹出页面中,我们可以看到所有的字段类型,包含文本、邮箱、富文本、密码、数字、下拉选择等,只需要点击选择相应字段就可以建表字段了。

当我们建立完成一张表后,引导页面便会提示我们通过 api 访问接口了。

使用 Strapi 和 Next.js 开发一个简易微博

建立表关联

建立完 Message 表后,需要与 User 建立关系

使用 Strapi 和 Next.js 开发一个简易微博

点击 User 表,选择 Relation 字段

使用 Strapi 和 Next.js 开发一个简易微博

在右侧选择 Message 表,中间关系选择一对多,Message 表关系字段,输入 user。

因为是多对一,一条 message 只属于一个用户,所以输入 user 单数,而不是 users,若是多对多的情况,我们要输入 users, 字段命名上的规范,可以让我们从接口返回数据看出对应的关系。

重复上述步骤我们可以建完所有表了,其中 images 表和用户表是我们不要建立的,因为用户和附件上传是通用功能,strapi 系统帮我们内置了,这真的是极大地加快了开发效率。

接口查询与获取

我们可以通过官方的文档地址找到对应的注册和登录接口。

使用接口管理工具访问下 api,比如我们注册一个用户,如下图

使用 Strapi 和 Next.js 开发一个简易微博

访问后,便注册了一个用户, 但是,直接访问 http://localhost:1337/api/messages 时,可能会看到如下界面,状态码 403, 这是为什么呢?

使用 Strapi 和 Next.js 开发一个简易微博

因为我们还未对 message 表进行权限设置,所以无法访问。

使用 Strapi 和 Next.js 开发一个简易微博

进入settings ——> Roles ——> Public , 对每个接口的请求方法进行权限设置,

在 Public 选择 message 表,选择 find,点击保存,这样我们的 message 接口便可以在不授权的情况下访问了。

其中,角色分为 AuthenticatedPublic

  • Authenticated 是需要登录才能访问的接口
  • Public 是不需要登录也可以访问的接口

使用 Strapi 和 Next.js 开发一个简易微博

还有很重要的一点,所有的接口地址都可以通过这个地方查看和修改。

前端工程

使用 create-next-app 创建一个 next 工程,并且安装和初始化 tailwindcss

npx create-next-app frontend
cd frontend
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

修改 globals.css 为 tailwindcss 默认指令

@tailwind base;
@tailwind components;
@tailwind utilities;

首页布局

我们先来写一下首页页面样式和布局

import Head from "next/head";

export default function Home() {
  return (
    <>
      <Head>
        <title> Next Strapi Weibo</title>
        <meta name="description" content="随时随地发现新鲜事" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <header className="py-4 border-t-4 border-orange-500 bg-white sticky top-0">
        <div className="max-w-6xl mx-auto flex justify-between items-center">
          <h1 className="font-semibold font-mono text-2xl">微博</h1>
          <div className="space-x-3">
            <button className="rounded-3xl px-5 py-2 bg-orange-500  font-semibold text-white  border border-orange-500">
              登录
            </button>
            <button className="rounded-3xl px-5 py-2 font-semibold text-orange-500 border border-orange-500">
              注册
            </button>
          </div>
        </div>
      </header>

      <main className="min-h-screen max-w-5xl mx-auto flex gap-5 mt-5">
        <div className="sticky top-14 h-80 bg-white w-52 flex-none rounded-sm shadow-sm"></div>
        <div className="flex-auto bg-white rounded-sm shadow-sm"></div>
      </main>
      <footer className="bg-white py-2 text-center mt-5">
        Copyright © 2009-2022 随时随地发现新鲜事
      </footer>
    </>
  );
}

看下效果,一个简单的页面结果呈现在我们眼前。

使用 Strapi 和 Next.js 开发一个简易微博

目录结构

├── components
│   ├── ImageList.tsx
│   ├── LoginModal.tsx
│   ├── RegisterModal.tsx
│   ├── SendMessage.tsx
│   └── TopicList.tsx
├── lib
│   ├── auth.ts
│   ├── message.ts
│   └── request.ts
├── pages
│   ├── _app.tsx
│   ├── _document.tsx
│   ├── api
│   │   └── hello.ts
│   └── index.tsx
├── styles
│   └── globals.css
└── types
    ├── image.ts
    ├── message.ts
    ├── topic.ts
    └── user.ts

整理一下目录结构,将组件放入components目录下, 将接口请求想代码放到 lib 目录下,types 目录用于存放 typescript 实体接口。

前后端联调

此时

若是纯后端数据渲染,我们可以在 Page 中导出一个 getServerSideProps 方法,接口请求在 node.js 环境中执行,所以不存在跨域问题, 但在接下来的登录与注册功能中,避免不了跨域,难道要启一个 nginx 反向代理吗?

其实可以直接在 next.config.js 中直接配置路由重写。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: "http://localhost:1337/api/:path*",
      },
      {
        source: "/uploads/:path*",
        destination: "http://localhost:1337/uploads/:path*",
      },
    ];
  },
};

module.exports = nextConfig;

重写路由将 /api/uploads 开头的请求转发到 http://localhost:1337, 这样我们请求数据就不会存在跨域问题了。

登录注册实现

注册与登录是单独的逻辑,我们将它独立成单独的组件, 先来实现注册RegisterModal.tsx

yarn add @headlessui/react

由于登录是弹窗的形式,我们安装一个 tailwindcss 官方的 UI 库,官网上有弹窗的 demo,我们可以直接拷贝,顺便改下成我们的组件代码。

import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useState, FormEventHandler, useRef } from "react";
import { register, RegisterData } from "../lib/auth";

export default function RegisterModal() {
  let [isOpen, setIsOpen] = useState(false);
  let [loading, setLoaing] = useState(false);
  let [error, setError] = useState("");
  const ref = useRef<RegisterData>({});

  function closeModal() {
    setIsOpen(false);
  }

  function openModal() {
    setIsOpen(true);
  }

  const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    setLoaing(true);
    register(ref.current)
      .then((res) => {
        const data = res.data;
        sessionStorage.setItem("jwt", data.jwt);

        sessionStorage.setItem("user", JSON.stringify(data.user));
        setLoaing(false);
        window.location.reload();
      })
      .catch((err) => {
        const res = err.response.data;
        setError(res.error ? res.error.message : "请稍后再试");
        setLoaing(false);
      });
  };

  return (
    <>
      <button
        type="button"
        onClick={openModal}
        className="rounded-3xl px-5 py-2 text-orange-500  font-semibold  border border-orange-500"
      >
        注册
      </button>

      <Transition appear show={isOpen} as={Fragment}>
        <Dialog as="div" className="relative z-10" onClose={closeModal}>
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <div className="fixed inset-0 bg-black bg-opacity-25" />
          </Transition.Child>

          <div className="fixed inset-0 overflow-y-auto">
            <div className="flex min-h-full items-center justify-center p-4 text-center">
              <Transition.Child
                as={Fragment}
                enter="ease-out duration-300"
                enterFrom="opacity-0 scale-95"
                enterTo="opacity-100 scale-100"
                leave="ease-in duration-200"
                leaveFrom="opacity-100 scale-100"
                leaveTo="opacity-0 scale-95"
              >
                <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
                  <Dialog.Title
                    as="h3"
                    className="text-xl font-medium leading-6 text-gray-900"
                  >
                    注册
                  </Dialog.Title>
                  <section className="text-gray-500 pt-5">
                    {error && (
                      <div className="p-3 border border-orange-500 text-orange-500 rounded bg-orange-50 mb-5 flex items-center">
                        <svg
                          className="w-6 h-6 mr-2"
                          fill="none"
                          stroke="currentColor"
                          viewBox="0 0 24 24"
                          xmlns="http://www.w3.org/2000/svg"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            strokeWidth={2}
                            d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
                          />
                        </svg>
                        <span>{error}</span>
                      </div>
                    )}
                    <form onSubmit={onSubmit} className="space-y-6">
                      <div className="space-y-2">
                        <label>用户名:</label>
                        <input
                          className="border px-3 py-2 w-full rounded"
                          type="username"
                          name="username"
                          onChange={(e) =>
                            (ref.current.username = e.target.value)
                          }
                        />
                      </div>
                      <div className="space-y-2">
                        <label>邮箱:</label>
                        <input
                          className="border px-3 py-2 w-full rounded"
                          type="email"
                          name="email"
                          onChange={(e) => (ref.current.email = e.target.value)}
                        />
                      </div>
                      <div className="space-y-2">
                        <label>密码:</label>
                        <input
                          className="border px-3 py-2 w-full rounded"
                          type="password"
                          name="password"
                          required
                          onChange={(e) =>
                            (ref.current.password = e.target.value)
                          }
                        />
                      </div>

                      <div className="space-x-3 text-center">
                        <button
                          className="rounded-3xl px-5 py-2 bg-orange-500  font-semibold text-white  border border-orange-500"
                          color="primary"
                          type="submit"
                          disabled={loading}
                        >
                          {loading ? "注册中..." : "注册"}
                        </button>
                        <button
                          type="button"
                          className="rounded-3xl px-5 py-2  font-semibold text-orange-500  border border-orange-500"
                          onClick={closeModal}
                        >
                          取消
                        </button>
                      </div>
                    </form>
                  </section>
                </Dialog.Panel>
              </Transition.Child>
            </div>
          </div>
        </Dialog>
      </Transition>
    </>
  );
}

上面代码中使用 useRef 来存储表单数据,提交表单后,将返回的 jwt 存在 sessionStorage 中,我将 user 也存到了 sessionStorage 中,比较标准的做法是只存 jwt,用户信息通过/api/users/me接口获取。

axios 封装

登录成功后,我们要实现发微博的功能

首先我们需要对 axios 封装一下,方便后续接口请求自动往 http header 中加入 jwt 信息,建一个 lib/request.ts 文件,代码如下

import axios, { AxiosRequestConfig } from "axios";

export const request = axios.create({
  timeout: 5000,
});

request.interceptors.request.use(function (config: AxiosRequestConfig) {
  //比如是否需要设置 token
  const jwt = window.sessionStorage.getItem("jwt");
  if (jwt) {
    config!.headers!.Authorization = "Bearer " + jwt;
  }
  console.log(config);

  return config;
});

图片上传

浏览器默认的文件选择 input 样式很丑, tailwindcss 对它做了专门的样式优化,我们只需要拷贝 HTML, 就可以写出比较美观的文件上传组件。

使用 Strapi 和 Next.js 开发一个简易微博

上传接口

export const upload = async (files: File[]) => {
  const formData = new FormData();

  Array.from(files).forEach((file: any) => {
    formData.append("files", file, file.name);
  });
  const res = await request.post("/api/upload", formData);
  return res.data;
};

input onChange 方法,将上传的返回地址存放在 state 中,用于后续发微博接口中的 images 字段,并且将选择的文件清空。

interface ImageData {
  id: number;
  url: string;
}

const [images, setImages] = useState<ImageData[]>([]);

const handleFileChange = async (e: any) => {
    const files = e.target.files;
    const res = (await upload(files)) as any;
    setImages(res);
    e.target.value = "";
  };

发微博

接下来我们将发微博的逻辑封装成一个 SendMessage 组件,当前在哪个话题topic下发,发送人user是谁? 这些都不是当前组件的逻辑,所以可以使用 props 传入即可。

组件 HTML 结构:

import React, { useRef, useState } from "react";
import { User } from "../types/user";

type Props = {
  callBack: () => {};
  topicId: number;
  user: User;
};

export default function SendMessage({ callBack, user, topicId }: Props) {
  const ref = useRef<HTMLTextAreaElement | null>(null);
  const [loading, setLoading] = useState(false);

  const handleSendMesage = async () => {

  };

  return (
    <div className="bg-white rounded-sm shadow-sm p-3">
      <textarea className="w-full border p-3" ref={ref} rows={4}></textarea>
      <div className="flex justify-between items-center">
        <span>
          <button
            onClick={handleSendMesage}
            type="button"
            disabled={loading}
            className="rounded px-3 py-1 bg-orange-500  font-semibold text-white  border border-orange-500"
          >
            发送
          </button>
        </span>
      </div>
    </div>
  );
}

发送接口与逻辑:

export function sendMessage(data: any) {
  return request.post("/api/messages", { data });
}

const handleSendMesage = async () => {
  if (ref.current && ref.current.value) {
    setLoading(true);
    try {
      await sendMessage({
        content: ref.current.value,
        images: images.map((m) => m.id),
        user: user.id,
        topic: topicId,
      });
      setLoading(false);
      ref.current.value = "";
      setImages([]);
      callBack();
    } catch (error) {
      setLoading(false);
    }
  }
};

这里需要注意的是,关联对象的保存,我们只需要传入 id 就可以,比如关联的图片 images ,我们只需要传入一个 id 数组。

联表查询

接下来,我们需要实现微博列表,当我们请求接口时候返回的接口如下:

使用 Strapi 和 Next.js 开发一个简易微博

接口中只返回了 content 字段,并没有 userimages 等信息,那么该如何通过接口实现联表查询呢?

遇到问题不用慌,先看下文档,上面清楚地记录了 Rest API 关联关系查询的方式,在 url 上加参数?populate=*,意思是:将所有关联关系都填充,若我们只想看看这条微博是谁发送的,只需要改成具体的参数字段:

http://localhost:3000/api/messages?populate=user

接口请求返回结果如下:

使用 Strapi 和 Next.js 开发一个简易微博

最终效果

使用 Strapi 和 Next.js 开发一个简易微博

到此,我相信你对 strapi 有了一定了解,并且可以通过 next.js 实现微博列表,联调查询等功能,若还是不清楚,可以直接看我的源码

小结

本文主要介绍了使用 strapi 和 Next.js 实现了一个简易的微博,我们不必写后端,只需要后台配置便可以创建 api 接口,通过本文,我相信你对 strapi 有了一定了解,但 strapi 除了 Rest API,还有 :

  • GraphQL API,前端可以直接使用 GraphQL 查询语言
  • Query Engine API 直接操作数据库层来实现功能
  • Entity Service API 复杂数据结构查询

通过 Query Engine API 和 Entity Service API 实现自定义接口。

后续

接下来我将继续分享 Next.js 相关的实战文章,欢迎各位关注我的《Next.js 全栈开发实战》 专栏。

  • 使用 Notion 数据库进行 Next.js 应用全栈开发
  • 使用 Prisma 和 PostgreSQL 进行 Next.js 应用全栈开发
  • 使用 NextAuth 实现 Next.js 应用的鉴权与认证
  • 使用 React query 给 Next.js 应用全局状态管理
  • 使用 i18next 实现 Next.js 应用国际化
  • 使用 Playwright 进行 Next.js 应用的端到端测试
  • 使用 Github actions 给 Next.js 应用创建 CI/CD
  • 使用 Docker 部署 Next.js 应用
  • 将 Next.js 应用部署到腾讯云 serverless

你对哪块内容比较感兴趣呢?欢迎在评论区留言,感谢您的阅读。