从零开始构建一个 MERN 堆栈的简单博客网站🔥

2025-05-24

从零开始构建一个 MERN 堆栈的简单博客网站🔥

几年前,Web 应用程序开发远不及如今。如今,选择如此之多,以至于新手常常不知所措,不知道哪种最适合自己。这不仅体现在整体堆栈上,也体现在开发工具上;选择如此之多。本博客教程强调 MERN 堆栈是开发完整 Web 应用程序的理想选择,并以非常详细的方式引导读者了解整个开发过程。

那么,MERN 堆栈到底是什么?

MERN 堆栈

MERN 技术栈是一套用于创建现代单页应用 (SPA) 的流行技术。MERN 代表 MongoDB、Express、React 和 Node.js:

  • Node.js是一个流行的服务器端框架,它允许我们在 Web 服务器上运行 JavaScript 代码。

  • Express是一个 Node.js Web 应用程序框架,它使 Node 应用程序开发更简单、更快捷。

  • MongoDB是一个 NoSQL 数据库,以集合和文档的形式持久存储数据。

  • React是一个用于创建用户界面的 JavaScript 前端库。

在本博客教程中,我们将创建一个利用 MERN 堆栈执行 CRUD 操作的全栈博客应用程序。本博客教程将帮助您了解 MERN 堆栈的基本操作。

这是我们应用程序的最终预览。

演示

设置后端

在项目目录中创建两个文件夹,分别为客户端和服务器,然后在 Visual Studio Code 或您选择的任何代码编辑器中打开它。

制作目录

客户端和服务器目录

现在,我们首先使用 npm 配置后端并安装必要的软件包,然后配置 MongoDB 数据库,使用 Node 和 Express 配置服务器,设计数据库模式来定义我们的博客,并配置 API 路由来创建、读取、更新和删除数据库中的博客数据和信息。所以,现在导航到服务器的目录并从命令提示符运行下面的代码。

npm init -y 
Enter fullscreen mode Exit fullscreen mode

更新 package.json

要安装依赖项,请在终端中执行以下命令。

npm install cors express dotenv mongoose nodemon body-parser
Enter fullscreen mode Exit fullscreen mode

依赖项

已安装的软件包

安装依赖项后,“package.json”文件应如下所示。

json 包

另外,请记住更新脚本和类型。

脚本和类型

现在,导航到您的服务器目录并在其中创建一个 server.js 文件。

配置 server.js

  • 导入 express 模块。

  • 导入 bodyParser 模块

  • 导入 mongoose 模块

  • 导入 CORS 模块

  • 使用 express() 启动我们的应用程序。

//server.js
import express from "express";
import bodyParser from "body-parser";
import mongoose from "mongoose";
import cors from "cors";

const app = express();
Enter fullscreen mode Exit fullscreen mode

现在我们可以在该应用实例上使用所有不同的方法了。首先,我们来做一些常规设置。我们将使用 app.use,并简单地传递 bodyParser 并将其大小限制在 20 到 50mb 之间,因为我们要发送一些可能非常大的图片。我们还将扩展为 true,并对 bodyParser 的 URL 进行相同的操作,并传递相同的参数。现在,我们还将使用 CORS 并将其作为函数调用。

//server.js
import express from "express";
import bodyParser from "body-parser";
import mongoose from "mongoose";
import cors from "cors";

const app = express();

app.use(bodyParser.json({ limit: '50mb', extended: true }))
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }))
app.use(cors());
Enter fullscreen mode Exit fullscreen mode

现在是时候将我们的服务器应用程序链接到真实的数据库了,因此我们将使用 MongoDB 数据库,特别是 MongoDB 云 Atlas 版本,这意味着我们将把我们的数据库托管到他们的云上。

设置MongoDB云集群

MongoDB 是一个面向文档的开源跨平台数据库。MongoDB 是一个 NoSQL 数据库,它将数据存储在类似 JSON 的文档中,并支持可选的模式。MongoDB 是由 MongoDB Inc. 根据服务器端公共许可证创建和分发的数据库。

MongoDB 官方网站
MongoDB云

登录 MongoDB
登录 MongoDB

创建项目
创建项目

添加成员
添加成员

建立数据库
建立数据库

创建集群
创建集群

选择云服务提供商
服务提供商

创建一个集群并等待集群构建完成后再继续(通常需要 5-10 分钟左右)
数据库部署

导航到网络访问选项卡并选择“添加 IP 地址”。
允许 IP 地址

在数据库中创建一个用户。您需要 MongoDB URI 的用户名和密码,最后创建一个数据库用户。
创建用户

现在,选择连接方法。
连接方法

单击连接您的应用程序,最后选择正确的驱动程序和版本。
连接到集群
云图

现在,在 server.js 中创建一个新变量并将其命名为 DB_CONNECTION。在其中创建一个字符串,并粘贴复制的 mongo DB 连接 URL。现在,在其中输入您的用户名和密码,确保删除所有括号并输入您自己的凭据。我们稍后会通过创建环境变量来保护凭据,但现在我们先这样添加。第二个我们需要的是 PORT,因此只需输入端口号,目前为 6000。最后,我们将使用 mongoose 连接到数据库,因此输入 mongoose.connect() 函数,该函数包含两个不同的参数。第一个参数是 DB CONNECTION,第二个参数是一个包含两个不同选项的对象。第一个参数是 useNewUrlParser,我们将其设置为 true,第二个参数是 useUnifiedTopology,我们也将其设置为 true。这些对象不是必需的,但我们会在控制台上看到一些错误或警告。接下来,让我们链接 a.then() 和 .catch(),因为这将返回一个承诺,所以在 .then() 内部将调用应用程序并调用 listen,它有两个参数,第一个是 PORT,第二个是回调函数,如果我们的应用程序连接成功,则将执行该函数,最后,如果与数据库的连接不成功,我们将简单地在控制台记录我们的错误消息。

//server.js
import express from "express";
import bodyParser from "body-parser";
import mongoose from "mongoose";
import cors from "cors";
import dotenv from "dotenv";

dotenv.config();

const app = express();

app.use(bodyParser.json({ limit: "50mb", extended: true }));
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true }));
app.use(cors());

const DB_CONNECTION = process.env.DATABASE_URL;
const PORT = process.env.PORT || 6000;

mongoose
  .connect(DB_CONNECTION, { useNewUrlParser: true, useUnifiedTopology: true })
  .then(() =>
    app.listen(PORT, () =>
      console.log(`Server is running @ : http://localhost:${PORT}`)
    )
  )
  .catch((error) => console.error(error));
Enter fullscreen mode Exit fullscreen mode

将 mongodb+srv 插入到 .env 文件中。

PORT=4000
DATABASE_URL=mongodb+srv://admin:<password>@cluster0.ddtsa.mongodb.net/myFirstDatabase?retryWrites=true&w=majority
Enter fullscreen mode Exit fullscreen mode

就是这样;我们已成功将服务器链接到数据库。

现在我们已经成功连接到数据库,接下来开始为后端应用程序创建路由。为此,我们需要在服务器内部创建一个名为 routes 的新文件夹。在 routes 文件夹中,我们将创建一个名为 blogPosts.routes.js 的 js 文件。

您的文件夹结构应该是这样的。

文件夹结构

我们将在 blogPosts.routes.js 中添加所有路由,因此首先必须从“express”导入 express,并配置路由器。现在我们可以开始添加路由了。

// routes/blogPosts.routes.js
import express from "express";

const router = express.Router();

router.get("/", (req, res) => {
  res.send("Awesome MERN BLOG");
});

export default router;
Enter fullscreen mode Exit fullscreen mode

让我们开始在你的 server.js 文件中导入 blogPost 路由。现在我们可以使用 Express 中间件将这个 blogPost 连接到我们的应用程序。

// server.js
import express from "express";
import bodyParser from "body-parser";
import mongoose from "mongoose";
import cors from "cors";
import dotenv from "dotenv";

import blogPosts from "./routes/blogPosts.js";

dotenv.config();

const app = express();

app.use(bodyParser.json({ limit: "50mb", extended: true }));
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true }));
app.use(cors());

// remember to add this after cors
app.use("/api/blogs", blogPosts);

const DB_CONNECTION = process.env.DATABASE_URL;
const PORT = process.env.PORT || 6000;

mongoose
  .connect(DB_CONNECTION, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  })
  .then(() =>
    app.listen(PORT, () =>
      console.log(`Server is running at: http://localhost:${PORT}`)
    )
  )
  .catch((error) => console.log(error));
Enter fullscreen mode Exit fullscreen mode

在继续之前,让我们为后端应用程序创建一个文件夹结构,使其更具可扩展性。因此,我们在 controllers 文件夹中创建一个名为 controllers 的新文件夹,同时我们还将创建一个名为 blogPosts.controller.js 的文件。controllers 只是一个包含路由特定逻辑的文件。
因此,你的 blogPosts.routes.js 和 blogPosts.controller.js 应该类似于以下内容。

文件夹结构

//routes/blogPosts.routes.js
import express from 'express';

import { getAllBlogPosts } from '../controllers/blogPosts.controller.js';

const router = express.Router();

router.get('/', getAllBlogPosts);

export default router;
Enter fullscreen mode Exit fullscreen mode

blogPosts.controller.js

//controllers/blogPosts.controller.js
import express from "express";
import mongoose from "mongoose";

const router = express.Router();

export const getAllBlogPosts = (req, res) => {
  res.send("Awesome MERN BLOG");
};

export default router;

Enter fullscreen mode Exit fullscreen mode

让我们为我们的博客文章创建一个新模型,因此在里面创建一个名为 models 的文件夹和一个名为 blogs.js 的文件。

文件夹结构应该类似于以下内容

文件夹结构

// models/blogs.js
import mongoose from "mongoose";

const blogSchema = mongoose.Schema({
  title: String,
  description: String,
  tags: [String],
  fileUpload: String,
  upvote: {
    type: Number,
    default: 0,
  },
  creator: String,
  createdAt: {
    type: Date,
    default: new Date(),
  },
});

var BlogPost = mongoose.model("BlogArticle", blogSchema);

export default BlogPost;
Enter fullscreen mode Exit fullscreen mode

现在我们的模型已经完成,让我们开始添加更多的路线。

// routes/blogPosts.routes.js
import express from "express";

import {
  getAllBlogPosts,
  addBlogPost,
  getSinglePost,
  updateSingleBlogPost,
  removeSingleBlogPost,
  likeBlogPost,
} from "../controllers/blogPosts.controller.js";

const router = express.Router();

router.get("/", getAllBlogPosts);
router.post("/", addBlogPost);
router.get("/:id", getSinglePost);
router.patch("/:id", updateSingleBlogPost);
router.delete("/:id", removeSingleBlogPost);
router.patch("/:id/likeedBlogPost", likeBlogPost);

export default router;
Enter fullscreen mode Exit fullscreen mode

现在,在控制器的文件夹中,将以下代码添加到您的 blogPosts.controllers.js 文件中。

getAllBlogPosts 方法获取所有博客信息。

export const getAllBlogPosts = async (req, res) => {
  try {
    const blogPosts = await BlogPost.find();
    res.status(200).json(blogPosts);
  } catch (error) {
    res.status(404).json({ message: error.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

addBlogPost 方法仅添加/插入一个博客

export const addBlogPost = async (req, res) => {
  const { title, description, fileUpload, creator, tags } = req.body;

  const createNewPost = new BlogPost({
    title,
    description,
    fileUpload,
    creator,
    tags,
  });

  try {
    await createNewPost.save();
    res.status(201).json(createNewPost);
  } catch (error) {
    res.status(409).json({ message: error.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

getSinglePost 方法获取单个博客文章

export const getSinglePost = async (req, res) => {
  const { id } = req.params;

  try {
    const singlepost = await BlogPost.findById(id);

    res.status(200).json(singlepost);
  } catch (error) {
    res.status(404).json({ message: error.message });
  }
};

Enter fullscreen mode Exit fullscreen mode

updateSingleBlogPost 方法更新单个博客文章

export const updateSingleBlogPost = async (req, res) => {
  const { id } = req.params;
  const { title, description, creator, fileUpload, tags } = req.body;

  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`post ${id} not found`);

  const updatedBlogPost = {
    creator,
    title,
    description,
    tags,
    fileUpload,
    _id: id,
  };
  await BlogPost.findByIdAndUpdate(id, updatedBlogPost, { new: true });
  res.json(updatedBlogPost);
};
Enter fullscreen mode Exit fullscreen mode

removeSingleBlogPost 方法删除单个博客文章

export const removeSingleBlogPost = (req, res) => {
  const { id } = req.params;

  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`post ${id} not found`);

  await BlogPost.findByIdAndRemove(id);

  res.json({ message: "Successfully deleted" });
};
Enter fullscreen mode Exit fullscreen mode

likeBlogPost 方法为帖子点赞

export const likeBlogPost = async (req, res) => {
  const { id } = req.params;

  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`No post with id: ${id}`);

  const post = await BlogPost.findById(id);

  const updatedBlogPost = await BlogPost.findByIdAndUpdate(
    id,
    { upvote: post.upvote + 1 },
    { new: true }
  );

  res.json(updatedBlogPost);
};
Enter fullscreen mode Exit fullscreen mode

你的 blogPosts.controller.js 应该类似于以下内容

// blogPosts.controller.js
import express from "express";
import mongoose from "mongoose";

import BlogPost from "../models/blogs.js";

const router = express.Router();

export const getAllBlogPosts = async (req, res) => {
  try {
    const blogPosts = await BlogPost.find();
    res.status(200).json(blogPosts);
  } catch (error) {
    res.status(404).json({ message: error.message });
  }
};

export const addBlogPost = async (req, res) => {
  const { title, description, fileUpload, creator, tags } = req.body;

  const createNewPost = new BlogPost({
    title,
    description,
    fileUpload,
    creator,
    tags,
  });

  try {
    await createNewPost.save();
    res.status(201).json(createNewPost);
  } catch (error) {
    res.status(409).json({ message: error.message });
  }
};

export const getSinglePost = async (req, res) => {
  const { id } = req.params;

  try {
    const singlepost = await BlogPost.findById(id);

    res.status(200).json(singlepost);
  } catch (error) {
    res.status(404).json({ message: error.message });
  }
};

export const updateSingleBlogPost = async (req, res) => {
  const { id } = req.params;
  const { title, description, creator, fileUpload, tags } = req.body;

  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`post ${id} not found`);

  const updatedBlogPost = {
    creator,
    title,
    description,
    tags,
    fileUpload,
    _id: id,
  };
  await BlogPost.findByIdAndUpdate(id, updatedBlogPost, { new: true });
  res.json(updatedBlogPost);
};

export const likeBlogPost = async (req, res) => {
  const { id } = req.params;

  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`No post with id: ${id}`);

  const post = await BlogPost.findById(id);

  const updatedBlogPost = await BlogPost.findByIdAndUpdate(
    id,
    { upvote: post.upvote + 1 },
    { new: true }
  );

  res.json(updatedBlogPost);
};

export const removeSingleBlogPost = async (req, res) => {
  const { id } = req.params;

  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`post ${id} not found`);

  await BlogPost.findByIdAndRemove(id);

  res.json({ message: "Successfully deleted" });
};

export default router;
Enter fullscreen mode Exit fullscreen mode

重新启动服务器后,您应该会看到类似这样的内容:

服务器正在运行


配置我们的前端

我们先用 create-react-app 设置前端。之后会从头开始创建 UI 及其功能。现在就开始我们的应用程序吧。

安装 React 应用程序

让我们从前端部分开始,使用 React 进行开发。如果您的系统上尚未安装 Node.js,那么首先应该安装它。请访问 Node.js 官方网站并安装正确且合适的版本。我们需要 Node.js 才能使用 Node 包管理器(也称为 NPM)。

现在,在你选择的代码编辑器中打开“client”文件夹。在本教程中,我将使用 VScode。下一步,打开集成终端并输入 npx create-react-app 。此命令将在当前目录中创建应用程序,该应用程序将被命名为“client”。

npx 脚本

安装通常只需几分钟。通常,我们会使用 npm 将软件包下载到项目中,但在本例中,我们使用软件包运行器 npx,它会为我们下载并配置所有内容,以便我们能够使用一个出色的模板开始工作。现在是时候启动我们的开发服务器了,只需输入 npm start,浏览器就会自动打开 react-app。

启动 React 应用程序

React 样板清理

在开始构建项目之前,我们必须先清理一下 create-react-app 提供的一些文件。清理完成后,你的 src 文件应该如下所示。

文件夹结构

安装一些软件包

我们需要为这个项目安装一些第三方软件包。因此,请将以下命令复制并粘贴到您的终端中

npm install @material-ui/core axios moment react-file-base64 redux react-redux redux-thunk
Enter fullscreen mode Exit fullscreen mode

安装所有这些包后,你的 packge.json 文件应该如下所示:

json 包

安装了项目的所有依赖项后,让我们向其中添加两个组件并将它们命名为 Blogs、BlogPosts 和 BlogPostsForm。

文件夹结构

现在我们已经完成了所有设置,让我们转到 App.js 文件并开始编写一些代码。在此之前,让我们在 src 文件夹中创建一个 Assets 文件夹,并添加您选择的徽标图像。之后,再创建一个名为 style 的文件夹,并在其中创建一个名为 app.styles.js 的文件,并将以下代码粘贴到其中。

// src/styles/app.styles.js
import { makeStyles } from "@material-ui/core/styles";

export default makeStyles(() => ({
  navigationBar: {
    borderRadius: 10,
    margin: "6px 0px",
    display: "flex",
    flexDirection: "row",
    justifyContent: "center",
    alignItems: "center",
  },
  title: {
    color: "#8661d1",
    fontFamily: "Poppins",
    fontStyle: "bold",
  },
  image: {
    marginRight: "25px",
  },
}));
Enter fullscreen mode Exit fullscreen mode

最后,转到 App.js 并从核心材料 UI 库中导入所有必要的组件文件、样式和组件,然后按如下所示实现它。

//App.js
import React, { useState, useEffect } from "react";
import "./App.css";
import { Container, AppBar, Typography, Grow, Grid } from "@material-ui/core";
import blogLogo from "./Assets/blogLogo.gif";
import BlogPosts from "./components/BlogPosts";
import BlogPostsForm from "./components/BlogPostsForm";
import useStyles from "./styles/app.styles.js";

function App() {
  const appStyles = useStyles();

  return (
    <div className="App">
      <Container maxWidth="xl">
        <AppBar
          className={appStyles.navigationBar}
          position="static"
          color="inherit"
        >
          <img
            className={appStyles.image}
            src={blogLogo}
            alt="icon"
            height="100"
          />
          <Typography className={appStyles.title} variant="h4" align="center">
            Mern awesome blog
          </Typography>
        </AppBar>
        <Grow in>
          <Container>
            <Grid
              container
              justify="space-between"
              alignItems="stretch"
              spacing={2}
            >
              <Grid item xs={12} sm={7}>
                <BlogPostsForm />
              </Grid>
              <Grid item xs={12} sm={4}>
                <BlogPosts />
              </Grid>
            </Grid>
          </Container>
        </Grow>
      </Container>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

现在让我们最终将前端与后端连接起来。为此,让我们创建一个名为 api 的文件夹,并在其中创建一个名为 api.js 的文件。因此,让我们导入 axios 来进行 api 调用,然后指定后端服务器 url 并编写一个使用 axios 简单获取帖子的函数。

import axios from "axios";

const url = "http://localhost:4000/api/blogs";

export const fetchAllBlogPosts = () => axios.get(url);
Enter fullscreen mode Exit fullscreen mode

现在,让我们专注于为我们的 React 应用添加 Redux 功能,因为我们所有的后端操作都将通过 Redux 完成,所以我们需要调度这些操作。为此,我们需要创建一些文件和文件夹来构建应用程序,以便应用程序具有可扩展性。因此,在 src 文件夹中,创建一个名为 actions 的文件夹和一个名为 reducers 的文件夹,并在这两个文件夹中创建一个名为 blogPosts.js 的文件。

您的文件夹结构应该类似于以下内容。

文件夹结构

在继续之前,我们先修复一下 index.js 文件,这样就可以在里面使用 redux 了。在这个文件中,我们导入 provider,它会跟踪 store(全局状态),并允许我们从应用程序的任何位置访问 store,这样我们就不必在父组件甚至子组件中访问了,这样我们就可以轻松地从任何地方访问该状态。之后,我们从 redux 包中导入 createStore、applyMiddleware 和 compose。最后,我们从 redux-thunk 包中导入 thunk,并相应地设置我们的 index.js 文件。

//index.js
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { createStore, applyMiddleware, compose } from "redux";
import thunk from "redux-thunk";
import { reducers } from "./reducers/blogPosts.js";
import App from "./App";
import "./index.css";

const store = createStore(reducers, compose(applyMiddleware(thunk)));

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

如果您现在运行您的应用程序,您可能会遇到未找到模块的错误。

未找到模块错误

让我们进入 Reducer 文件夹并修复该错误。因此,我们在其中创建一个 index.js 文件,并从 redux 包中导入 combineReducers,导出并调用该函数,并在其中放入一个对象。现在,我们可以实现此应用案例中的所有单个 Reducer,该案例仅包含 blogPosts。

// reducers/index.js
import { combineReducers } from "redux";
import blogPosts from "./blogPosts";

export const reducers = combineReducers({ blogPosts });
Enter fullscreen mode Exit fullscreen mode

文件夹结构

如果一切顺利,你的应用程序现在应该可以顺利运行了。现在我们需要在主 App.js 文件中调度我们的 action。

// App.js
import React, { useEffect } from "react";
import "./App.css";
import { Container, AppBar, Typography, Grow, Grid } from "@material-ui/core";
import blogLogo from "./Assets/blogLogo.gif";
import Blogs from "./components/Blogs";
import BlogPostsForm from "./components/BlogPostsForm";
import useStyles from "./styles/app.styles.js";
import { useDispatch } from "react-redux";
import { fetchAllBlogPosts } from "./actions/blogPosts";

function App() {
  const dispatch = useDispatch();
  const appStyles = useStyles();

  useEffect(() => {
    dispatch(fetchAllBlogPosts());
  }, [dispatch]);

  return (
    <div className="App">
      <Container maxWidth="xl">
        <AppBar
          className={appStyles.navigationBar}
          position="static"
          color="inherit"
        >
          <img
            className={appStyles.image}
            src={blogLogo}
            alt="icon"
            height="100"
          />
          <Typography className={appStyles.title} variant="h2" align="center">
            Mern awesome blog
          </Typography>
        </AppBar>
        <Grow in>
          <Grid
            container
            justifyContent="space-between"
            alignItems="stretch"
            spacing={2}
          >
            <Grid item xs={12} sm={3}>
              <BlogPostsForm />
            </Grid>
            <Grid item xs={12} sm={9}>
              <Blogs />
            </Grid>
          </Grid>
        </Grow>
      </Container>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

现在,让我们转到我们的操作并导入我们的 api,然后创建一些操作创建器,它们只是返回操作的函数,所以让我们实际实现 redux 来从我们的后端传递或分派数据函数。

// actions/blogPosts.js
import * as api from "../api/api.js";

export const fetchAllBlogPosts = () => async (dispatch) => {
  try {
    const { data } = await api.fetchAllBlogPosts();

    dispatch({ type: GET_ALL_BLOGS, payload: data });
  } catch (error) {
    console.log(error.message);
  }
};
Enter fullscreen mode Exit fullscreen mode

最后,让我们回到我们的减速器并处理获取和提取所有博客文章的逻辑。

// reducers/blogPosts.js
export default (posts = [], action) => {
  switch (action.type) {
    case "GET_ALL_BLOGS":
      return action.payload;
    default:
      return posts;
  }
};
Enter fullscreen mode Exit fullscreen mode

现在,让我们从子组件中检索这些数据,所以让我们转到 Blogs 组件并从全局 redux 存储中获取数据,我们可以借助 useSelector 来做到这一点

//components/Blogs
import React from "react";
import { Grid, CircularProgress } from "@material-ui/core";
import { useSelector } from "react-redux";

import BlogPosts from "../BlogPosts";
import useStyles from "./styles";

const Blogs = () => {
  const posts = useSelector((state) => state.blogPosts);
  const classes = useStyles();

  console.log("this is post", posts);

  return (
    <>
      <BlogPosts />
    </>
  );
};

export default Blogs;
Enter fullscreen mode Exit fullscreen mode

当您运行应用程序时,您可能会看到一个空数组和网络错误;要解决这个问题,只需在 package.json 文件中包含一个代理即可。

代理人

因此,如果您仍然看到该空数组,则表示数据已成功获取,现在是时候实现表单了,以便我们可以向数据库发出发布请求并向其中实际添加新帖子。

因此,让我们转到 BlogPostsForm 组件并创建一个表单。第一步,让我们从 Material UI 核心库中导入所有我们将在表单中实现的组件。

// BlogPostsForm.js
import React, { useState, useEffect } from "react";
import { Paper, TextField, Typography, Button } from "@material-ui/core";
import { useDispatch, useSelector } from "react-redux";
import FileBase from "react-file-base64";

import useStyles from "./styles";
import { addBlogPosts, editBlogPosts } from "../../actions/blogPosts";

const BlogPostsForm = ({ blogPostId, setBlogPostId }) => {
  const [blogInfo, setBlogInfo] = useState({
    creator: "",
    title: "",
    description: "",
    tags: "",
    fileUpload: "",
  });
  const post = useSelector((state) =>
    blogPostId
      ? state.posts.find((message) => message._id === blogPostId)
      : null
  );
  const dispatch = useDispatch();
  const blogPostsStyles = useStyles();

  useEffect(() => {
    if (post) setBlogInfo(post);
  }, [post]);

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (blogPostId === 0) {
      dispatch(addBlogPosts(blogInfo));
    } else {
      dispatch(editBlogPosts(blogInfo));
    }
  };

  return (
    <Paper className={blogPostsStyles.paper}>
      <form
        autoComplete="on"
        noValidate
        className={`${blogPostsStyles.root} ${blogPostsStyles.form}`}
        onSubmit={handleSubmit}
      >
        <Typography variant="h5">
          {blogPostId ? `Update "${post.title}"` : "✨ Create a blog ✨"}
        </Typography>

        <div className={blogPostsStyles.chooseFile}>
          <Typography> 🖼️ Upload Blog Image</Typography>
          <FileBase
            type="file"
            multiple={false}
            onDone={({ base64 }) =>
              setBlogInfo({ ...blogInfo, fileUpload: base64 })
            }
          />
        </div>
        <TextField
          name="title"
          variant="outlined"
          label="🔥 Blog Title"
          fullWidth
          value={blogInfo.title}
          onChange={(e) => setBlogInfo({ ...blogInfo, title: e.target.value })}
        />
        <TextField
          name="description"
          variant="outlined"
          label="📙 Blog Description"
          fullWidth
          multiline
          rows={7}
          value={blogInfo.description}
          onChange={(e) =>
            setBlogInfo({ ...blogInfo, description: e.target.value })
          }
        />
        <TextField
          name="creator"
          variant="outlined"
          label="✍️ Author name"
          fullWidth
          value={blogInfo.creator}
          onChange={(e) =>
            setBlogInfo({ ...blogInfo, creator: e.target.value })
          }
        />
        <Typography>Tags (5 max seperated by comma)</Typography>
        <TextField
          name="tags"
          variant="outlined"
          label="🏷️ Tags"
          fullWidth
          value={blogInfo.tags}
          onChange={(e) =>
            setBlogInfo({ ...blogInfo, tags: e.target.value.split(",") })
          }
        />

        <Button
          className={blogPostsStyles.publishButton}
          variant="contained"
          color="secondary"
          size="large"
          type="submit"
        >
          Publish 📝
        </Button>
      </form>
    </Paper>
  );
};

export default BlogPostsForm;
Enter fullscreen mode Exit fullscreen mode

另外,不要忘记修改 style.js 中的 blogPostForm 样式

// components/BlogPostsForm/styles.js
import { makeStyles } from "@material-ui/core/styles";

export default makeStyles((theme) => ({
  root: {
    "& .MuiTextField-root": {
      margin: theme.spacing(1),
    },
  },
  paper: {
    padding: theme.spacing(5),
  },
  chooseFile: {
    width: "95%",
    margin: "10px 0",
  },
  publishButton: {
    marginBottom: 10,
  },
  form: {
    display: "flex",
    flexWrap: "wrap",
    justifyContent: "center",
  },
}));
Enter fullscreen mode Exit fullscreen mode

因此,在继续之前,让我们先修复我们的 API

// api/api.js
import axios from "axios";

const url = "http://localhost:4000/api/blogs";

export const fetchBlogPosts = () => axios.get(url);
export const addNewBlogPost = (newBlog) => axios.post(url, newBlog);
export const editSingleBlogPost = (id, editedBlogPost) =>
  axios.patch(`${url}/${id}`, editedBlogPost);
Enter fullscreen mode Exit fullscreen mode

成功添加和导出 addNewBlogPost 和 editSingleBlogPost 函数后,让我们通过分别创建一些名为 addBlogPosts 和 editBlogPosts 的操作来实际实现它们。

addBlogPosts 操作
添加博客文章

editBlogPosts 操作
编辑博客文章

您的 blogPosts.js 操作应该看起来像这样。

// actions/blogPosts.js
import * as api from "../api/api.js";

export const fetchAllBlogPosts = () => async (dispatch) => {
  try {
    const { data } = await api.fetchBlogPosts();

    dispatch({ type: "GET_ALL_BLOG_POST", payload: data });
  } catch (error) {
    console.log(error.message);
  }
};

export const addBlogPosts = (post) => async (dispatch) => {
  try {
    const { data } = await api.addNewBlogPost(post);

    dispatch({ type: "ADD_NEW_BLOG_POST", payload: data });
  } catch (error) {
    console.log(error.message);
  }
};

export const editBlogPosts = (id, post) => async (dispatch) => {
  try {
    const { data } = await api.editSingleBlogPost(id, post);

    dispatch({ type: "EDIT_SINGLE_BLOG_POST", payload: data });
  } catch (error) {
    console.log(error.message);
  }
};
Enter fullscreen mode Exit fullscreen mode

之后,让我们更新 Reducers 部分。

export default (posts = [], action) => {
    switch (action.type) {
        case "GET_ALL_BLOG_POST":
            return action.payload;
        case "ADD_NEW_BLOG_POST":
            return [...posts, action.payload];
        case "EDIT_SINGLE_BLOG_POST":
            return posts.map((post) =>
                post._id === action.payload._id ? action.payload : post
            );
        default:
            return posts;
    }
};
Enter fullscreen mode Exit fullscreen mode

最后,让我们更新我们的 App.js 以包含 blogPostId 状态,我们将其作为 prop 传递给我们的 BlogPostsForm 和 Blogs 组件。

//App.js
import React, { useState, useEffect } from "react";
import "./App.css";
import { Container, AppBar, Typography, Grow, Grid } from "@material-ui/core";
import blogLogo from "./Assets/blogLogo.gif";
import Blogs from "./components/Blogs";
import BlogPostsForm from "./components/BlogPostsForm";
import useStyles from "./styles/app.styles.js";
import { useDispatch } from "react-redux";
import { fetchAllBlogPosts } from "./actions/blogPosts";

function App() {
  const [blogPostId, setBlogPostId] = useState(0);
  const dispatch = useDispatch();
  const appStyles = useStyles();

  useEffect(() => {
    dispatch(fetchAllBlogPosts());
  }, [blogPostId, dispatch]);

  return (
    <div className="App">
      <Container maxWidth="xl">
        <AppBar
          className={appStyles.navigationBar}
          position="static"
          color="inherit"
        >
          <img
            className={appStyles.image}
            src={blogLogo}
            alt="icon"
            height="100"
          />
          <Typography className={appStyles.title} variant="h2" align="center">
            Mern awesome blog
          </Typography>
        </AppBar>
        <Grow in>
          <Grid
            container
            justifyContent="space-between"
            alignItems="stretch"
            spacing={2}
          >
            <Grid item xs={12} sm={3}>
              <BlogPostsForm
                blogPostId={blogPostId}
                setBlogPostId={setBlogPostId}
              />
            </Grid>
            <Grid item xs={12} sm={9}>
              <Blogs setBlogPostId={setBlogPostId} />
            </Grid>
          </Grid>
        </Grow>
      </Container>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

修复 App.js 之后,我们将转到 Blogs 组件,并在其中使用传递的 props,并将其钻取到 BlogPosts 组件中

// components/Blogs.js
import React from "react";
import { Grid, CircularProgress } from "@material-ui/core";
import { useSelector } from "react-redux";

import BlogPosts from "../BlogPosts";
import useStyles from "./styles";

const Blogs = ({ setBlogPostId }) => {
  const posts = useSelector((state) => state.posts);
  const classes = useStyles();

  console.log("this is post", posts);

  return !posts.length ? (
    <CircularProgress />
  ) : (
    <Grid
      className={classes.container}
      container
      alignItems="stretch"
      spacing={4}
    >
      {posts.map((post) => (
        <Grid key={post._id} item xs={12} sm={12}>
          <BlogPosts post={post} setBlogPostId={setBlogPostId} />
        </Grid>
      ))}
    </Grid>
  );
};

export default Blogs;
Enter fullscreen mode Exit fullscreen mode

现在我们几乎完成了所有工作,是时候开始着手处理单独的博客文章了。首先,前往 BlogPosts 组件并安装 Material UI 图标,然后从 Material UI 核心库中导入几个组件,最后将以下代码复制并粘贴到其中。

// components/BlogPosts.js
import React from "react";
import {
  Typography,
  CardMedia,
  Button,
  Card,
  CardActions,
  CardContent,
} from "@material-ui/core/";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
import DeleteIcon from "@material-ui/icons/Delete";
import EditIcon from "@material-ui/icons/Edit";
import moment from "moment";
import { useDispatch } from "react-redux";
import blogImageLogo from "../../Assets/blogLogo.gif";

import { upvoteBlogPosts, removeBlogPosts } from "../../actions/blogPosts";
import useStyles from "./styles";

const BlogPosts = ({ post, setCurrentId }) => {
  const dispatch = useDispatch();
  const blogPostStyles = useStyles();

  return (
    <>
      <Card className={blogPostStyles.blogContainer}>
        <CardMedia
          className={blogPostStyles.imageContainer}
          image={post.fileUpload || blogImageLogo}
          title={post.title}
        />{" "}
        <div className={blogPostStyles.nameOverlay}>
          <Typography variant="h6"> {post.creator} </Typography>{" "}
          <Typography variant="body2">
            {" "}
            {moment(post.createdAt).fromNow()}{" "}
          </Typography>{" "}
        </div>{" "}
        <div className={blogPostStyles.editOverlay}>
          <Button
            style={{
              color: "white",
            }}
            size="small"
            onClick={() => setCurrentId(post._id)}
          >
            <EditIcon fontSize="default" />
          </Button>{" "}
        </div>{" "}
        <div className={blogPostStyles.tagSection}>
          <Typography variant="body2" color="textSecondary" component="h2">
            {" "}
            {post.tags.map((tag) => `#${tag} `)}{" "}
          </Typography>{" "}
        </div>{" "}
        <Typography
          className={blogPostStyles.titleSection}
          gutterBottom
          variant="h5"
          component="h2"
        >
          {post.title}{" "}
        </Typography>{" "}
        <CardContent>
          <Typography variant="body2" color="textSecondary" component="p">
            {" "}
            {post.description}{" "}
          </Typography>{" "}
        </CardContent>{" "}
        <CardActions className={blogPostStyles.cardActions}>
          <Button
            size="small"
            color="primary"
            onClick={() => dispatch(upvoteBlogPosts(post._id))}
          >
            <ArrowUpwardIcon fontSize="small" /> {post.likeCount}{" "}
          </Button>{" "}
          <Button
            size="small"
            color="primary"
            onClick={() => dispatch(removeBlogPosts(post._id))}
          >
            <DeleteIcon fontSize="big" />
          </Button>{" "}
        </CardActions>{" "}
      </Card>{" "}
    </>
  );
};

export default BlogPosts;
Enter fullscreen mode Exit fullscreen mode

最后,让我们创建一个操作来实际点赞和移除博客文章。首先,在 API 内部创建一个函数,分别命名为 upvoteSingleBlogPost 和 removeBlogPost,然后将其导出。

// api/api.js
import axios from "axios";

const url = "http://localhost:4000/api/blogs";

export const fetchBlogPosts = () => axios.get(url);
export const addNewBlogPost = (newBlog) => axios.post(url, newBlog);
export const editSingleBlogPost = (id, editedBlogPost) =>
    axios.patch(`${url}/${id}`, editedBlogPost);
export const upvoteSingleBlogPost = (id) =>
    axios.patch(`${url}/${id}/likedBlogPost`);
export const removeBlogPost = (id) => axios.delete(`${url}/${id}`);
Enter fullscreen mode Exit fullscreen mode

最后,让我们修复减速器,这样我们的应用程序就完成了。

// reducers/blogPosts.js
export default (posts = [], action) => {
    switch (action.type) {
        case "GET_ALL_BLOG_POST":
            return action.payload;
        case "ADD_NEW_BLOG_POST":
            return [...posts, action.payload];
        case "EDIT_SINGLE_BLOG_POST":
            return posts.map((post) =>
                post._id === action.payload._id ? action.payload : post
            );
        case "UPVOTE_SINGLE_BLOG_POST":
            return posts.map((post) =>
                post._id === action.payload._id ? action.payload : post
            );
        case "DELETE_SINGLE_BLOG_POST":
            return posts.filter((post) => post._id !== action.payload);
        default:
            return posts;
    }
};
Enter fullscreen mode Exit fullscreen mode

我们已经讨论了大量材料,为您提供从头开始构建成熟的 MERN 堆栈应用程序所需的知识。

完整的源代码可以在这里找到。

https://github.com/aviyeldevrel/devrel-tutorial-projects/tree/main/MERN-awesome-blog

结论

在本博客教程的第一部分,我们使用 Node.js、Express 和 MongoDB 构建了后端服务器。我们使用 Mongoose 库将 Node.js/Express 服务器连接到 MongoDB。然后,在本教程的第二部分,我们创建了 React 前端应用程序,并使用 Redux 为我们的 MERN Stack 博客应用程序全局管理状态。干杯!祝您编程愉快!

主要文章可在此处查看 => https://aviyel.com/post/1304

编码愉快!!

如果您是项目维护者、贡献者或只是开源爱好者,请关注@aviyelHQ或在 Aviyel 上注册以获得早期访问权限。

加入 Aviyel 的 Discord => Aviyel 的世界

推特 =>[ https://twitter.com/AviyelHq ]

文章来源:https://dev.to/aviyel/building-a-mern-stack-simple-blog-site-from-absolute-scratch-5pm
PREV
从零开始用 React 构建音乐播放器应用程序🔥🎶
NEXT
利用 MERN 堆栈的绝对威力构建全栈公路旅行地图应用程序🔥