我是如何构建现代“食物菜单” Web 应用程序的:从技术栈到工作流程

2025-06-09

我是如何构建现代“食物菜单” Web 应用程序的:从技术栈到工作流程

介绍

构建成功且高效的 Web 应用程序需要两样东西:强大的技术基础和管理工作本身的可靠方法。

许多开发资源都侧重于其中之一。有很多优秀的技术资源记录了如何使用各种技术和框架进行构建。也有很多优秀的资源提供了关于如何管理跨多个利益相关者的开发工作流程的见解。

但在本文中,我将尝试将两者合二为一,并说明如何利用一些出色的技术不仅构建全栈应用程序,而且还管理流程和预发布工作流程。

我使用的技术

我们将探讨我如何使用四种主要技术:Node.js、GraphQL、Next.js 和Preevy。这些工具都对更广泛的 Web 开发社区产生了深远的影响,因此我认为这是一个值得与社区分享的有趣项目和技术栈示例。

例如,Node.js 为开发者提供了可扩展且高效的后端开发工具。此外,还有 GraphQL,一种尖端的数据查询语言,它正在改变我们处理和修改数据的方式。Next.js 是一个复杂的 React 框架,有助于创建高性能且 SEO 友好的前端界面。最后,Preevy 是一款可在您的云平台(亚马逊、谷歌或微软)上快速轻松地配置预发布预览环境的工具。Preevy 的可共享预览环境是开发者在任何代码合并到暂存或生产环境之前共享最新代码更改、获取反馈并与其他利益相关者协作的有效方式,从而使开发者能够享受更快的预发布审查工作流程。

我们对构建全栈应用程序的总结将涵盖以下主题:

  • 构建 Node.js Express 后端服务器
  • 构建 Next.js 前端服务器
  • 在 Docker 中运行项目
  • 使用Preevy配置可共享的预览环境

公开构建“食物菜单”应用程序

我以“公开学习”日记的形式撰写了这篇总结。希望这能让内容既有趣,又能让广大读者理解。

我们将要构建一个菜单应用。本质上,它会有一个 Node.js 后端和一个 GraphQL 服务器,该服务器有一个用于存储我们数据的端点,这些数据将以硬编码的方式存储。Next.js 前端将连接到 Node.js 后端,并使用 GraphQL 查询检索数据。

先决条件

确保您已满足先决条件,现在让我们开始吧!

构建 Node.js Express 后端服务器

我们将首先在计算机上本地创建项目。导航到目录并运行以下命令来设置我们的项目和后端结构:

mkdir menu-project
cd menu-project
mkdir server
touch docker-compose.yml
cd server
npm init -y
npm i express express-graphql graphql cors
npm i -D dotenv nodemon
mkdir data schema
touch .env Dockerfile index.js
touch data/menu.js schema/schema.js
Enter fullscreen mode Exit fullscreen mode

接下来,在代码编辑器中打开项目,然后将即将到来的代码添加到正确的文件中。

将此代码放入data/menu.js

const bases = [
  {
    id: '1',

    menuItem: 'Base',

    name: 'Egg Noodles',
  },

  {
    id: '2',

    menuItem: 'Base',

    name: 'Whole-wheat Noodles',
  },

  {
    id: '3',

    menuItem: 'Base',

    name: 'Rice Noodles',
  },

  {
    id: '4',

    menuItem: 'Base',

    name: 'Udon Noodles',
  },

  {
    id: '5',

    menuItem: 'Base',

    name: 'Jasmine Rice',
  },

  {
    id: '6',

    menuItem: 'Base',

    name: 'Whole-grain Rice',
  },
];

const vegetables = [
  {
    id: '1',

    menuItem: 'Vegetables',

    name: 'Pak Choi',
  },

  {
    id: '2',

    menuItem: 'Vegetables',

    name: 'Shiitake Mushrooms',
  },

  {
    id: '3',

    menuItem: 'Vegetables',

    name: 'Champignon Mushrooms',
  },

  {
    id: '4',

    menuItem: 'Vegetables',

    name: 'Mixed Peppers',
  },

  {
    id: '5',

    menuItem: 'Vegetables',

    name: 'Broccoli',
  },

  {
    id: '6',

    menuItem: 'Vegetables',

    name: 'Spinach',
  },

  {
    id: '7',

    menuItem: 'Vegetables',

    name: 'Baby Corn',
  },

  {
    id: '8',

    menuItem: 'Vegetables',

    name: 'Red Onion',
  },

  {
    id: '9',

    menuItem: 'Vegetables',

    name: 'Bamboo Shoots',
  },
];

const meats = [
  {
    id: '1',

    menuItem: 'Meat',

    name: 'Chicken',
  },

  {
    id: '2',

    menuItem: 'Meat',

    name: 'Chicken Katsu',
  },

  {
    id: '3',

    menuItem: 'Meat',

    name: 'Beef',
  },

  {
    id: '4',

    menuItem: 'Meat',

    name: 'Pulled Beef',
  },

  {
    id: '5',

    menuItem: 'Meat',

    name: 'Bacon',
  },

  {
    id: '6',

    menuItem: 'Meat',

    name: 'Pork',
  },

  {
    id: '7',

    menuItem: 'Meat',

    name: 'Duck',
  },

  {
    id: '8',

    menuItem: 'Meat',

    name: 'Prawns',
  },

  {
    id: '9',

    menuItem: 'Meat',

    name: 'Tofu',
  },
];

const sauces = [
  {
    id: '1',

    menuItem: 'Sauce',

    name: 'Sweet Teriyaki',
  },

  {
    id: '2',

    menuItem: 'Sauce',

    name: 'Sweet and Sour',
  },

  {
    id: '3',

    menuItem: 'Sauce',

    name: 'Garlic and black pepper',
  },

  {
    id: '4',

    menuItem: 'Sauce',

    name: 'Oyster Sauce',
  },

  {
    id: '5',

    menuItem: 'Sauce',

    name: 'Hot soybean sauce',
  },

  {
    id: '6',

    menuItem: 'Sauce',

    name: 'Yellow curry & coconut',
  },

  {
    id: '7',

    menuItem: 'Sauce',

    name: 'Peanut',
  },

  {
    id: '8',

    menuItem: 'Sauce',

    name: 'Asian spiced red sauce',
  },
];

module.exports = { bases, vegetables, meats, sauces };
Enter fullscreen mode Exit fullscreen mode

此代码将进入schema/schema.js

const { bases, vegetables, meats, sauces } = require('../data/menu');

const {
  GraphQLObjectType,

  GraphQLID,

  GraphQLString,

  GraphQLList,

  GraphQLSchema,
} = require('graphql');

const BaseType = new GraphQLObjectType({
  name: 'Base',

  fields: () => ({
    id: { type: GraphQLID },

    menuItem: { type: GraphQLString },

    name: { type: GraphQLString },
  }),
});

const VegetableType = new GraphQLObjectType({
  name: 'Vegetable',

  fields: () => ({
    id: { type: GraphQLID },

    menuItem: { type: GraphQLString },

    name: { type: GraphQLString },
  }),
});

const MeatType = new GraphQLObjectType({
  name: 'Meat',

  fields: () => ({
    id: { type: GraphQLID },

    menuItem: { type: GraphQLString },

    name: { type: GraphQLString },
  }),
});

const SauceType = new GraphQLObjectType({
  name: 'Sauce',

  fields: () => ({
    id: { type: GraphQLID },

    menuItem: { type: GraphQLString },

    name: { type: GraphQLString },
  }),
});

const RootQuery = new GraphQLObjectType({
  name: 'RootQueryType',

  fields: {
    bases: {
      type: new GraphQLList(BaseType),

      resolve(parent, args) {
        return bases;
      },
    },

    base: {
      type: BaseType,

      args: { id: { type: GraphQLID } },

      resolve(parent, args) {
        return bases.find((base) => base.id === args.id);
      },
    },

    vegetables: {
      type: new GraphQLList(VegetableType),

      resolve(parent, args) {
        return vegetables;
      },
    },

    vegetable: {
      type: VegetableType,

      args: { id: { type: GraphQLID } },

      resolve(parent, args) {
        return vegetables.find((vegetable) => vegetable.id === args.id);
      },
    },

    meats: {
      type: new GraphQLList(MeatType),

      resolve(parent, args) {
        return meats;
      },
    },

    meat: {
      type: MeatType,

      args: { id: { type: GraphQLID } },

      resolve(parent, args) {
        return meats.find((meat) => meat.id === args.id);
      },
    },

    sauces: {
      type: new GraphQLList(SauceType),

      resolve(parent, args) {
        return sauces;
      },
    },

    sauce: {
      type: SauceType,

      args: { id: { type: GraphQLID } },

      resolve(parent, args) {
        return sauces.find((sauce) => sauce.id === args.id);
      },
    },
  },
});

module.exports = new GraphQLSchema({
  query: RootQuery,
});
Enter fullscreen mode Exit fullscreen mode

下面是我们的.env文件:

NODE_ENV = "development"

PORT = 8080
Enter fullscreen mode Exit fullscreen mode

我们将此代码放入Dockerfile

FROM node:18

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 8080

CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

我们的index.js文件获取此服务器代码:

const express = require('express');

const cors = require('cors');

require('dotenv').config();

const { graphqlHTTP } = require('express-graphql');

const schema = require('./schema/schema');

const app = express();

app.use(cors());

app.use(
  '/graphql',

  graphqlHTTP({
    schema,

    graphiql: process.env.NODE_ENV === 'development',
  })
);

const port = process.env.PORT || 8080;

app.listen(port, () =>
  console.log(`Server running on port ${port}, http://localhost:${port}`)
);
Enter fullscreen mode Exit fullscreen mode

最后是docker-compose.yml文件:

version: '3'
services:
  server:
    container_name: server
    build:
      context: ./server
      dockerfile: Dockerfile
    volumes:
      - ./server:/app
    ports:
      - '8080:8080'
    environment:
      - NODE_ENV=development

  client:
    container_name: client
    build:
      context: ./client
      dockerfile: Dockerfile
    volumes:
      - ./client/src:/app/src
      - ./client/public:/app/public
    restart: always
    ports:
      - 3000:3000
Enter fullscreen mode Exit fullscreen mode

我们只需将这些运行脚本添加到package.json文件中就可以了:]

"scripts": {

"start": "node index.js",

"dev": "nodemon index.js"

},
Enter fullscreen mode Exit fullscreen mode

现在,我们的项目设置后端部分只需进入服务器的根文件夹并运行以下命令来启动后端服务器:

npm run start
Enter fullscreen mode Exit fullscreen mode

只需转到http://localhost:8080/graphql即可查看您的 GraphQL API。

接下来是前端,让我们开始吧。

构建 Next.js 前端服务器

更改目录,使其位于menu-project文件夹的根目录中,然后运行以下命令来设置我们的项目以使用 Next.js。

npx create-next-app client
Enter fullscreen mode Exit fullscreen mode

完成设置我在这里使用了此配置:

✔ 您想在此项目中使用 TypeScript 吗?…/ 是
✔ 您想在此项目中使用 ESLint 吗?…/ 是
✔ 您想在此项目中使用 Tailwind CSS 吗?…/ 是
✔ 您想src/在此项目中使用目录吗?… 否 /
✔ 使用 App Router(推荐)吗?… 否 /
✔ 您想自定义默认导入别名吗?…/ 是

cd进入client文件夹并运行此命令来安装我们需要的软件包:

npm i @apollo/client graphql
Enter fullscreen mode Exit fullscreen mode

我们现在需要创建项目文件,因此让我们运行此代码来完成它们:

touch Dockerfile
cd src/app
mkdir components queries utils
touch components/Bases.js components/Meats.js components/Sauces.js components/Vegetables.js
touch queries/clientQueries.js
touch utils/withApollo.js
Enter fullscreen mode Exit fullscreen mode

剩下的就是把代码添加到我们的文件中,就大功告成了。所以从以下开始components/Bases.js

'use client';

import { useQuery } from '@apollo/client';

import { GET_BASE } from '../queries/clientQueries';

import withApollo from '../utils/withApollo';

const Bases = () => {
  const { loading, error, data } = useQuery(GET_BASE);

  if (loading) return <p>Loading bases...</p>;

  if (error) return <p>The food failed to load there is a problem</p>;

  return (
    <div>
      {!loading && !error && (
        <div className="base-box">
          <div>
            <div className="cost-container">
              <h1>01</h1>

              <p>$5.95 only one</p>
            </div>

            <h2>
              Chose <br />
              your base
            </h2>

            {data.bases.map((bases) => (
              <div key={bases.id}>
                <table>
                  <tr>
                    <td>
                      {bases.id} {bases.name}
                    </td>
                  </tr>
                </table>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default withApollo(Bases);
Enter fullscreen mode Exit fullscreen mode

接下来是components/Meats.js

'use client';

import { useQuery } from '@apollo/client';

import { GET_MEAT } from '../queries/clientQueries';

import withApollo from '../utils/withApollo';

const Meats = () => {
  const { loading, error, data } = useQuery(GET_MEAT);

  if (loading) return <p>Loading meats...</p>;

  if (error) return <p>The food failed to load there is a problem</p>;

  return (
    <div>
      {!loading && !error && (
        <div className="meat-box">
          <div>
            <div className="cost-container">
              <h1>03</h1>

              <p>$1.25 each 2 Max</p>
            </div>

            <h2>
              Choose <br /> your Meats
            </h2>

            {data.meats.map((meats) => (
              <div key={meats.id}>
                <table>
                  <tr>
                    <td>
                      {meats.id} {meats.name}
                    </td>
                  </tr>
                </table>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default withApollo(Meats);
Enter fullscreen mode Exit fullscreen mode

接下来将此代码添加到components/Sauces.js

'use client';

import { useQuery } from '@apollo/client';

import { GET_SAUCE } from '../queries/clientQueries';

import withApollo from '../utils/withApollo';

const Sauces = () => {
  const { loading, error, data } = useQuery(GET_SAUCE);

  if (loading) return <p>Loading sauces...</p>;

  if (error) return <p>The food failed to load there is a problem</p>;

  return (
    <div>
      {!loading && !error && (
        <div className="sauces-box">
          <div>
            <div className="cost-container">
              <h1>04</h1>

              <p>FREE</p>
            </div>

            <h2>
              Choose <br />
              your Sauces
            </h2>

            {data.sauces.map((sauces) => (
              <div key={sauces.id}>
                <table>
                  <tr>
                    <td>
                      {sauces.id} {sauces.name}
                    </td>
                  </tr>
                </table>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default withApollo(Sauces);
Enter fullscreen mode Exit fullscreen mode

此代码将进入components/Vegetables.js

'use client';

import { useQuery } from '@apollo/client';

import { GET_VEGETABLE } from '../queries/clientQueries';

import withApollo from '../utils/withApollo';

const Vegetables = () => {
  const { loading, error, data } = useQuery(GET_VEGETABLE);

  if (loading) return <p>Loading vegetables...</p>;

  if (error) return <p>The food failed to load there is a problem</p>;

  return (
    <div>
      {!loading && !error && (
        <div className="vegetable-box">
          <div>
            <div className="cost-container">
              <h1>02</h1>

              <p>$1.25 each 4 Max</p>
            </div>

            <h2>
              Choose <br />
              your Vegetables
            </h2>

            {data.vegetables.map((vegetables) => (
              <div key={vegetables.id}>
                <table>
                  <tr>
                    <td>
                      {vegetables.id} {vegetables.name}
                    </td>
                  </tr>
                </table>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default withApollo(Vegetables);
Enter fullscreen mode Exit fullscreen mode

现在转到queries/clientQueries.js

import { gql } from '@apollo/client';

const GET_BASE = gql`
  query getBase {
    bases {
      id

      name

      menuItem
    }
  }
`;

const GET_VEGETABLE = gql`
  query getVegetable {
    vegetables {
      id

      name

      menuItem
    }
  }
`;

const GET_MEAT = gql`
  query getMeat {
    meats {
      id

      name

      menuItem
    }
  }
`;

const GET_SAUCE = gql`
  query getSauce {
    sauces {
      id

      name

      menuItem
    }
  }
`;

export { GET_BASE, GET_VEGETABLE, GET_MEAT, GET_SAUCE };
Enter fullscreen mode Exit fullscreen mode

几乎完成了此代码的用途utils/withApollo.js

import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';

import { useMemo } from 'react';

export function initializeApollo(initialState = null) {
  const _apolloClient = new ApolloClient({
    // Local GraphQL Endpoint

    uri: 'http://localhost:8080/graphql',

    // Add your Preevy GraphQL Endpoint

    // uri: 'https://your-backend-server-livecycle.run/graphql',

    cache: new InMemoryCache().restore(initialState || {}),
  });

  return _apolloClient;
}

export function useApollo(initialState) {
  const store = useMemo(() => initializeApollo(initialState), [initialState]);

  return store;
}

export default function withApollo(PageComponent) {
  const WithApollo = ({ apolloClient, apolloState, ...pageProps }) => {
    const client = useApollo(apolloState);

    return (
      <ApolloProvider client={client}>
        <PageComponent {...pageProps} />
      </ApolloProvider>
    );
  };

  // On the server

  if (typeof window === 'undefined') {
    WithApollo.getInitialProps = async (ctx) => {
      const apolloClient = initializeApollo();

      let pageProps = {};

      if (PageComponent.getInitialProps) {
        pageProps = await PageComponent.getInitialProps(ctx);
      }

      if (ctx.res && ctx.res.finished) {
        // When redirecting, the response is finished.

        // No point in continuing to render

        return pageProps;
      }

      const apolloState = apolloClient.cache.extract();

      return {
        ...pageProps,

        apolloState,
      };
    };
  }

  return WithApollo;
}
Enter fullscreen mode Exit fullscreen mode

接下来我们的 CSSglobals.css用这个替换所有代码:

*,
*::before,
*::after {
  margin: 0;

  padding: 0;

  box-sizing: border-box;
}

html {
  font-size: 16px;
}

header h1 {
  text-align: center;

  color: #ffffff;

  font-size: 4rem;

  text-transform: uppercase;
}

h1 {
  color: #1c1917;
}

h2 {
  color: #1c1917;

  font-size: 1.4rem;

  text-transform: uppercase;

  border-top: 0.3rem solid black;

  border-bottom: 0.3rem solid black;

  margin-bottom: 1rem;

  padding: 1rem 0 1rem 0;
}

body {
  background: #374151;
}

.container {
  width: 100%;

  max-width: 90rem;

  margin: 2rem auto;

  display: flex;

  flex-flow: row wrap;

  justify-content: space-around;

  background: #f9fafb;

  padding: 2rem;
}

.base-box,
.vegetable-box,
.meat-box,
.sauces-box {
  background: #f43f5e;

  width: 20rem;

  height: auto;

  padding: 1rem;

  color: #ffffff;

  font-weight: bold;

  margin-bottom: 2rem;
}

.cost-container {
  display: flex;

  flex-flow: row nowrap;

  justify-content: space-between;

  align-items: center;

  margin-bottom: 1rem;
}

.cost-container p {
  background: #eff6ff;

  padding: 0.2rem;

  color: #f43f5e;

  font-size: 1rem;
}

@media screen and (max-width: 800px) {
  .container {
    flex-flow: column;

    align-items: center;
  }
}
Enter fullscreen mode Exit fullscreen mode

之后还剩一个,接下来是page.js文件,像之前一样用我们这里的内容替换所有代码:

'use client';

import Bases from './components/Bases';

import Vegetables from './components/Vegetables';

import Meats from './components/Meats';

import Sauces from './components/Sauces';

export default function Home() {
  return (
    <>
      <header>
        <h1>Menu</h1>
      </header>

      <div className="container">
        <Bases />

        <Vegetables />

        <Meats />

        <Sauces />
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

最后,让我们通过将此代码放入Dockerfile客户端文件夹来完成我们的项目:

FROM node:18-alpine

WORKDIR /app

# Install dependencies based on the preferred package manager

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./

RUN \

if [ -f yarn.lock ]; then yarn --frozen-lockfile; \

elif [ -f package-lock.json ]; then npm ci; \

elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \

# Allow install without lockfile, so example works even without Node.js installed locally

else echo "Warning: Lockfile not found. It is recommended to commit lockfiles to version control." && yarn install; \

fi

COPY src ./src

COPY public ./public

COPY next.config.mjs .

# Next.js collects completely anonymous telemetry data about general usage. Learn more here: https://nextjs.org/telemetry

# Uncomment the following line to disable telemetry at run time

# ENV NEXT_TELEMETRY_DISABLED 1

# Note: Don't expose ports here, Compose will handle that for us

# Start Next.js in development mode based on the preferred package manager

CMD \

if [ -f yarn.lock ]; then yarn dev; \

elif [ -f package-lock.json ]; then npm run dev; \

elif [ -f pnpm-lock.yaml ]; then pnpm dev; \

else yarn dev; \

fi
Enter fullscreen mode Exit fullscreen mode

现在,我们的项目设置已完成,只需进入根文件夹client并运行以下命令即可启动前端服务器:

npm run dev
Enter fullscreen mode Exit fullscreen mode

仔细检查您的后端服务器是否仍在运行,您应该看到正在从后端服务器接收数据的菜单。

下一节将向我们展示如何在 Docker 容器内运行应用程序。

在 Docker 中运行项目

在 Docker 中运行应用程序非常简单。首先,确保 Docker 正在你的计算机上运行,​​并且其他服务器未运行,因为它们将使用相同的端口。然后,只需确保你位于 的根文件夹中menu-project,然后运行命令docker-compose up。这将同时运行后端和前端服务器。现在,只需将服务器和客户端的 URL 与之前相同,即可看到一切正常。

服务器运行于http://localhost:8080
客户端运行于http://localhost:3000

最后,是时候使用 Preevy 部署预览环境了。这样,我们就能轻松地与项目的其他成员共享工作成果。他们只需单击一下即可查看应用的最新版本,而无需查看它在开发环境中的外观和运行情况。这已经为我节省了数小时的往返时间,我很高兴找到了这个工具并将其纳入我的工作流程。

在 Preevy 上创建预配置预览环境

首先,阅读Preevy的文档并安装它,然后准备运行这里的命令来创建一个配置环境。在根文件夹中menu-project运行以下命令:

preevy init
preevy up --id 321
Enter fullscreen mode Exit fullscreen mode

您必须传递一个--id包含数字字符串的标志。我以 321 为例,应该可以正常工作。设置可能需要一些时间才能完成,因为它需要在 AWS 上创建后端和前端。完成后,您应该会得到两个 URL,一个用于服务器,一个用于客户端。请参阅此处的示例:

server  8080 https://your-url-server.livecycle.run/
client  3000 https://your-url-client.livecycle.run/
Enter fullscreen mode Exit fullscreen mode

点击链接应该会跳转到两个在线运行的服务器。你会注意到页面显示错误,并且客户端链接无法加载数据。这是因为http://localhost:8080/graphql你的代码仍然设置了。请将uri文件中的client/src/app/utils/withApollo.jsGraphQL 端点更新到你的 Preevy 服务器,然后preevy up --id 321再次运行该命令以推送最新更改。

现在页面应该显示来自我们的 GraphQL API 的数据。

结论

最终,Node.js、GraphQL 和 Next.js 等工具的组合正在通过提升性能、灵活性和数据隐私合规性来改变在线开发环境。这些技术结合在一起,构成了一个强大的工具箱,使开发人员能够创建可扩展、高效且注重隐私的应用程序。

Preevy 等工具使开发人员能够以闪电般的速度安全地部署和与他人共享他们的工作,从而提供更好的开发人员体验,以便他们能够收集清晰的反馈并发布更高质量的产品。

希望您发现本文提出了一种利用这些技术协同作用的切实可行的策略。

然而,由于技术总是在不断发展,每个开发人员都必须不断研究和试验这些工具。有了这些知识,你将能够更好地提升你的Web开发能力,保持技术创新的前沿,并创建真正具有影响力的应用程序。

鏂囩珷鏉yu簮锛�https://dev.to/livecycle/how-i-built-a-modern-food-menu-web-app-from-tech-stack-to-workflow-3iok
PREV
获得更多 GitHub Stars 的(详细且有创意的)指南
NEXT
2023 年,5 个 React 库助你项目更上一层楼