现代网页开发入门指南书 – React(前端)

前端 – React

本文对 React 基本语法不作任何解释,如幽灵标签与闭合等等。。只解释疑难杂症和项目上手

前端使用 React 挂载数据。使用 Tailwind CSS框架。

创建项目 – 官方(不推荐)

先不要跟着做

❯ npx create-next-app@latest

Need to install the following packages:
[email protected]
Ok to proceed? (y) y

✔ What is your project named? … xxxxx
✔ Would you like to use TypeScript? … No✅ / Yes
✔ Would you like to use ESLint? … No✅ / Yes
✔ Would you like to use Tailwind CSS? … No / Yes✅
✔ Would you like your code inside a `src/` directory? … No / Yes✅
✔ Would you like to use App Router? (recommended) … No / Yes✅
✔ Would you like to use Turbopack for `next dev`? … No✅ / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No✅ / Yes
......
...

项目创建完毕后,我们打开 package.json 文件,可以看到下面内容

{
  "name": "项目名字",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "PORT=3005 next dev",      // 更改这一行。在前面添加 PORT=3005
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "next": "15.3.2"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "tailwindcss": "^4"
  }
}

我们需要按照上面一样更改,因为 3000 端口在本地开发环境里被 Express 占用了,所以我们就用 3005 端口吧

之后进入目录,启动项目就行

npm run dev

项目目录结构:

.
└── XXXX                 # 项目名
    ├── README.md        # 项目详情
    ├── src/app              # 核心目录,用于存放页面、布局和全局样式等内容
    │       ├── favicon.ico
    │       ├── globals.css
    │       ├── layout.js
    │       └── page.js
    ├── jsconfig.json
    ├── next.config.mjs
    ├── package-lock.json
    ├── package.json
    ├── postcss.config.mjs
    └── public           # 存放静态资源(如图片、字体等)此目录文件,编译时不会重命名
        ├── file.svg
        ├── globe.svg
        ├── next.svg
        ├── vercel.svg
        └── window.svg

可以看到,项目目录有点云里雾里的,有些东西不知道如何存放才好。


创建项目 – Vite(推荐) – 支持各种前端框架创建

如果你无意参加编程语言的派别之争,那么使用 Vite 去创建 React 无疑是非常好的选择,尤其面对新手。

兼容性说明:
Vite 需要 版本 18+ 或 20+。但是,某些模板需要更高的 Node.js 版本才能运行,如果您的包管理器出现警告,请升级。

Vite 官网: https://vite.dev/guide/

❯ npm create vite@latest
Need to install the following packages:
[email protected]
Ok to proceed? (y) y


> npx
> create-vite

│
◇  Project name:
│  XXXX
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  JavaScript + SWC
│
◇  Scaffolding project in /path/to/project...
│
└  Done. Now run:

  cd XXXX
  npm install
  npm run dev

项目目录结构

看起来好多了

.
└── XXXX
    ├── README.md
    ├── eslint.config.js
    ├── index.html          # index 躯壳页面 - jsx最终挂载上这里(main.jsx)
    ├── package.json
    ├── public              # 存放静态资源(如图片、字体等)此目录文件,编译时不会重命名
    │   └── vite.svg        # 非 require 方式引入(如src)的内容(如LOGO),不可以变更名字
    ├── src           # 源码文件
    │   ├── App.css   # 对应了 App 这个页面的CSS(但ReactCSS不是你想那么简单)
    │   ├── App.jsx   # 代码的根,根路由,它提供给了 main.jsx 挂载 - 整理路由和逻辑用
    │   ├── assets    # 资源地址(这里的文件编译时会被重命名)
    │   │   └── react.svg
    │   ├── index.css # main.jsx 的CSS文件
    │   └── main.jsx  # 挂载文件 - 挂载上index.html - (真正的)index 文件
    └── vite.config.js

5 directories, 11 files

main.jsx 和 App.jsx 的关系 (重要)

说实话,这两个真的把人整得云里雾里的,这里说明一下。

main.jsx – 项目的入口文件

main.jsx 是项目的入口文件?很奇怪对吧,毕竟写惯了程序,入口文件不应该给定参数,调取参数吗?事实上,我也被这个概念搞得晕头转向。所以,你只需要知道,这个文件是最终挂载,渲染根路由(App.jsx),或者更多的 大组件 到 index.html 就行

为什么要说 大组件 …我都要哭了,因为那些路由之类的处理,一般都是 App.jsx 这个根组件完成就行。除非你的项目足够大型和复杂,不然一个 App.jsx ,之后 App.jsx 递交给 main.jsx 挂载到 index.html 就行了。

main.jsx – 它负责挂载 App.jsx 到 HTML 的根节点(index.html

  • 作用
    • 初始化 React 应用。
    • 将 React 应用挂载到 HTML 中的根节点(通常是 <div id="root">)。
    • 引入全局样式文件(如 index.css)。
    • 渲染整个 React 应用的根组件(App)。
核心职责:
  • 负责启动 React 应用
    • 使用 ReactDOM.createRoot() 创建 React 的根节点。
    • 使用 .render() 方法,将 React 的根组件(App)渲染到页面中。
  • 引入全局资源
    • 加载全局样式(如 index.css)。
    • 加载应用的根组件(App.jsx)。
App.jsx – React 应用的根组件
  • 作用
    • 定义整个应用的核心逻辑和页面结构。
    • 包含路由配置(如 React Router 的 RoutesRoute)。
    • 组织并引入其他子组件(如 HeaderFooterHome 等)。
    • 可以包含全局状态管理(如 Context API 或 Redux 的 Provider)。
核心职责:
  • 负责布局和页面逻辑
    • 定义页面的整体结构(如头部、导航、主内容区)。
    • 配置页面路由,加载不同的页面组件。
  • 组织组件
    • 引入其他子组件,并将它们组合起来构建应用的 UI。
  • 控制全局状态
    • 如果使用 Context API、Redux 等全局状态管理工具,通常会在 App.jsx 中初始化状态管理。

我们进入目录,执行下面命令即可启动 React 测试服务器

npm install
npm run dev    ## 不需要额外更改端口,因为vite默认使用 5173 这个端口 - (自带热更新)

删除一些东西

你可以启动服务器,并且熟悉一下项目目录。但是出于开发一个新的页面目标出发,我们需要删除一些东西。

.
└── XXXX
    ├── README.md
    ├── eslint.config.js
    ├── index.html          # 里面一些东西可以看着修改(如Title)
    ├── package.json
    ├── public              # 存放静态资源(如图片、字体等)此目录文件,编译时不会重命名
    │   └── vite.svg        # LOGO,删除即可
    ├── src           # 源码文件
    │   ├── App.css   # 删除文件里面的代码
    │   ├── App.jsx   # 删除 return 里面的代码(以及App函数里面一行const)
    │   ├── assets    # 资源地址(这里的文件编译时会被重命名)
    │   │   └── react.svg  # react图标,删除即可
    │   ├── index.css # 删除文件里面的代码
    │   └── main.jsx  # 无需删除任何,但是之后会作改变
    └── vite.config.js

5 directories, 11 files

安装React-Router(路由 )

Vite 创建项目时,一般不会询问你是否引入 React-Router 功能,所以我们需要自己安装

我们需要使用到 页面路由 (现代网页开发就几乎没有不用的),所以需要安装:

npm install react-router-dom

页面路由,比如说在页面快速切换 -主页 -关于我们 -我的 功能页,而不重新刷新网页。

基本语法和项目文件增减在后面安装完 CSS 框架后说


不使用CSS框架 / 自己写一些CSS (警告⚠️)

为什么是重要呢,因为 React 导入的 CSS 是全局的。这意味着,你在一个 pages/xxx.jsx 导入的 CSS 文件,将会被全局应用到 App.jsx, main.jsx 等等文件,一旦出现 选择器(class等) 名称相同,就会被 CSS规则命中。

所以你必须(最好)这样做:
  1. 创建 CSS 文件时,不要使用 .css 结尾,要使用 .module.css 结尾(或者.module.scss)
  2. 在 jsx 源码文件,这样导入 CSS:import importStyles from './xxxx.module.css';
  3. 在代码中,这样命名元素:<button className={importStyles.button}>
    这就是最原汁原味的用法。

当然你也可以使用 CSS-in-JS,安装库:npm install styled-components

如果,你在该页面下,没有用到多个 CSS 文件,而且 ClassName 没有重合,可以直接这样导入:

import './xxxx.module.css';

CSS框架(TailwindCSS / Bootstrap5)

尽管它们使用起来,体验感都很相似,都是使用一堆类名,但是 Tailwind CSS 比 Bootstrap5 安装起来要麻烦些,不过 Tailwind CSS 更加现代好看。其实我也挺喜欢 Bootstrap5 的设计的,具体看个人,本文使用 Tailwind CSS

安装 Tailwind CSS
npm install tailwindcss @tailwindcss/vite
配置 Tailwind

编辑 vite.config.js 文件,指定 Tailwind 的内容扫描范围:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'       // 增加这一行

export default defineConfig({ 
    plugins: [
        react(),
        tailwindcss()      // 增加这一行
    ],
})
添加 Tailwind 样式

src/index.css 中导入 Tailwind 的基础样式即可 (头部插入):

@import "tailwindcss";

即可应用 TailwindCSS


安装 Bootstrap 5

npm install bootstrap
导入 Bootstrap 样式

src/main.jsx 中导入 Bootstrap 的 CSS 文件:

import 'bootstrap/dist/css/bootstrap.min.css';
使用 Bootstrap 样式

编辑 src/App.jsx,使用 Bootstrap 样式:

function App() {
  return (
    <div className="container mt-5">
      <h1 className="text-primary">Welcome to Bootstrap 5 in Vite!</h1>
      <button className="btn btn-primary">Click Me</button>
    </div>
  );
}

export default App;

组件 – 现代网页开发 重要思维之一

在开始路由前,我们需要先讲组件。虽然网站是像剥洋葱一样剥开的,但是我们学的话,剥洋葱地学很容易乱,所以要从洋葱里面开始(有点像,把洋葱当成积木,拼起来)。

什么是组件?

在一个页面上面,有很多框框,比如说左边是你的头像和个人信息,中边是新闻,最右边是搜索。
类似于…Twitter,Facebook 之类的。

现代开发,组件的应用是必不可少的,重要的,先进的!


导航栏和公告栏 – 组件

首先我们创建一个目录用于存放组件,src/components ,组件还有个优点就是,复用性。

如果曾经有开发过网站的经验,应该都会就 每个页面都有的「导航栏」,「回到顶端」等功能,进行剥离,放到某个文件夹(如组件文件夹),之后被各大页面复用(引用)。就无须10个页面,就写(复制粘贴)10次的导航栏。

组件也就是这样的思维,不过因为 CSS 框架和前端框架的加持,会使得组件用起来,更加现代,优雅。

我们在 src/components 下创建两个文件,分别为 src/components/AnnouncementBar.jsx (公告行), src/components/Navbar.jsx (导航栏)


公告栏 AnnouncementBar.jsx
import React, { useState, useEffect } from "react";

const AnnouncementBar = () => {
  const [announcement, setAnnouncement] = useState("");

  // 获取公告内容
  useEffect(() => {
    const fetchAnnouncement = async () => {
      try {
        const response = await fetch("http://localhost:3000/data"); // 后端 API 地址
        const data = await response.json();
        setAnnouncement(data.readData.announcement);                // 剥离数据
      } catch (error) {
        console.error("Failed to fetch announcement:", error);
        setAnnouncement("无法加载公告,请稍后重试。");                  // 错误时展示的默认消息
      }
    };
    fetchAnnouncement();
  }, []);

  // 如果没有公告,则不显示组件
  if (announcement == null) {
    return null;
  }


  return (
    <div className="bg-blue-500 text-white text-center py-2 px-4">
      <p className="text-sm font-medium">{announcement}</p>
    </div>
  );
};

export default AnnouncementBar;

导航栏 Navvar.jsx

代码用到一个 LOGO,logo位于 /public/logo.png,随便找一个替代即可。

import React, { useState } from "react";

// 子组件:箭头图标
const ArrowRightIcon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 20 20"
    fill="currentColor"
    className="w-5 h-5 ml-1"
  >
    <path
      fillRule="evenodd"
      d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
      clipRule="evenodd"
    />
  </svg>
);

// 子组件:菜单图标
const MenuIcon = () => (
  <svg
    className="block h-6 w-6"
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    aria-hidden="true"
  >
    <path
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="2"
      d="M4 6h16M4 12h16M4 18h16"
    />
  </svg>
);

// 子组件:关闭图标
const CloseIcon = () => (
  <svg
    className="block h-6 w-6"
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    aria-hidden="true"
  >
    <path
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="2"
      d="M6 18L18 6M6 6l12 12"
    />
  </svg>
);

function Navbar() {
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);

  // 导航链接
  const navLinks = [
    { name: "主页", href: "#" },
    { name: "关于我们", href: "#" },
    { name: "测试页面", href: "#" },
  ];

  // 公共样式
  const linkStyle =
    "text-gray-700 hover:text-indigo-600 px-3 py-2 rounded-md text-sm font-medium";

  // 渲染导航链接
  const renderNavLinks = () =>
    navLinks.map((link) => (
      <a key={link.name} href={link.href} className={linkStyle}>
        {link.name}
      </a>
    ));

  return (
    <nav className="bg-white shadow-sm">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex items-center justify-between h-16">
          {/* 左侧导航链接 */}
          <div className="hidden md:flex space-x-4">{renderNavLinks()}</div>

          {/* 中间 Logo */}
          <div className="flex-shrink-0">
            <a href="/" aria-label="Home">
              <img
                src="/public/logo.png"
                alt="Logo"
                className="h-8 w-auto object-contain"
              />
            </a>
          </div>

          {/* 右侧登录链接 */}
          <div className="hidden md:flex items-center">
            <a href="#" className={`{linkStyle} flex items-center`}>
              Log in
              <ArrowRightIcon />
            </a>
          </div>

          {/* 移动端菜单按钮 */}
          <div className="md:hidden">
            <button
              onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
              className="bg-white p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
              aria-expanded={isMobileMenuOpen}
            >
              <span className="sr-only">Toggle menu</span>
              {isMobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
            </button>
          </div>
        </div>
      </div>

      {/* 移动端菜单 */}
      {isMobileMenuOpen && (
        <div className="md:hidden">
          <div className="px-2 pt-2 pb-3 space-y-1">{renderNavLinks()}</div>
          <div className="border-t border-gray-200 pt-4 pb-3">
            <a href="#" className={`{linkStyle} flex items-center`}>
              Log in
              <ArrowRightIcon />
            </a>
          </div>
        </div>
      )}
    </nav>
  );
}

export default Navbar;

挂载

完成上面的一切,现在要做的就是挂载了。

但是我们需要挂载到路由上,但是路由页面还没有写,所以先放一放,把路由解决,之后挂载到路由上,再将路由挂载到 App.jsx(主应用) 上。

层级关系 | 「组件」 \=\=挂载=\=> 「路由页面」 \=\=挂载\=\=> 「主页」(主应用)


React-Router (路由)使用

前后端路由 – 概念:

前后端路由是不同的,后端路由 指的是 API 接口,一般一个路由专注于做一件事,比如说注册路由,index路由,文章路由等等。

前端路由 是为了在浏览器中实现无刷新,即时切换,无需每次点击一个页面都要更新一次页面。

比如说,我们有一个页面,页面下面有三个分页,首页,关于我们,测试页面。那么在该页面下面点击分页,就在指定的区域加载分页出来,而不是 “跳转”,“重载” 整个页面。


1. 创建页面组件

src/pages 文件夹中创建页面组件。

Home.jsx (首页)
import React from "react";
import AnnouncementBar from '../components/AnnouncementBar';
import Navbar from '../components/Navbar';

const Home = () => {
  return (
    <>
        <div>
            <AnnouncementBar />
            <Navbar />
            
            <div className="h-screen bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center">
                <div className="text-center text-white">
                    <h1 className="text-5xl font-bold mb-6">
                        Welcome to Our Application
                    </h1>
                    <p className="text-lg text-gray-200 max-w-xl mx-auto">
                        This is a modern React application powered by Tailwind CSS. Explorethe features and enjoy a seamless user experience.
                    </p>
                </div>
            </div>
        </div>
    </>
  );
};

export default Home;
About.jsx (关于页)
import React from "react";
import AnnouncementBar from "../components/AnnouncementBar";
import Navbar from "../components/Navbar";

const About = () => {
    return (
        <>
            <div>
                <AnnouncementBar />
                <Navbar />
               
                <div className="min-h-screen flex flex-col items-center justify-center bg-white text-gray-800">
                    <h1 className="text-4xl font-bold mb-4">About Us</h1>
                    <p className="text-lg text-gray-600 text-center max-w-2xl">
                        Welcome to our application! We are a team dedicated to delivering high-quality solutions that empower our users to achieve their goals. Our mission is to blend simplicity, beauty, and functionality in all that we do.
                    </p>
                    <div className="mt-6">
                        <a
                        href="https://example.com"
                        target="_blank"
                        rel="noopener noreferrer"
                        className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition">
                            Learn More
                        </a>
                    </div>
                </div>
            </div>
        </>
    );
};

export default About;
NotFound.jsx (404 页面)
import React from "react";
import AnnouncementBar from '../components/AnnouncementBar';
import Navbar from '../components/Navbar';

import { Link } from "react-router-dom";   // 用来返回上一页(用a标签会刷新页面)

const NotFound = () => {
  return (
    <>
        <div>
            <AnnouncementBar />
            <Navbar />

            <div className="h-screen flex flex-col items-center justify-center bg-gray-100 text-gray-800">
            <h1 className="text-9xl font-bold text-blue-500">404</h1>
            <h2 className="text-2xl font-semibold mt-4">Oops! Page not found.</h2>
            <p className="text-gray-500 mt-2">
                Sorry, the page you are looking for doesn't exist or has been moved.
            </p>
                <Link
                    to="/"
                    className="mt-6 px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition">
                    Back to Home
                </Link>
            </div>
        </div>
    </>
  );
};

export default NotFound;

2. 定义路由结构

src/App.jsx 中挂载路由。

App.jsx
import React from 'react';
// 请注意,这条引用用了重命名,把 BrowserRouter 重命名成了 Router //
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";

import Home from './pages/Home';
import About from './pages/About';
import NotFound from './pages/NotFound'

import './App.css';

const App = () => {
  return (
    <>
      <Router>
        <div>
          <Routes>
            <Route path='/' element={<Home />} />
            <Route path='/About' element={<About />} />
            <Route path='/*' element={<NotFound />} />
          </Routes>
        </div>
      </Router>
    </>
  );
};

export default App;

上面为什么用 * 号? 认真把下面看完。

路由标签的区别。BrowserRouter、Routes和Route

上面的 BrowserRouter 被重命名为了 Router ,代码实际的情况是:

...
    ...
      <BrowserRouter>
        <div>
          <Routes>
            <Route path='/' element={<Home />} />
            <Route path='/About' element={<About />} />
            <Route path='/*' element={<NotFound />} />
          </Routes>
        </div>
      </BrowserRouter>
    ...
...
BrowserRouter 提供上下文
  • 管理路由的核心逻辑,监听浏览器地址栏的变化(如用户切换页面、点击返回按钮)。
  • 将当前 URL 状态传递给子组件(RoutesRoute)。
Routes 定义规则集
  • 收集所有的 Route 规则,检查当前路径是否匹配某条规则。
  • 如果找到匹配的规则,就渲染对应的组件。
Route 定义具体规则
  • path:表示匹配的路径。
  • element:表示路径对应的组件。
  • 例如:
    • / 匹配 Home 组件。
    • /about 匹配 About 组件。
    • * 匹配所有未定义的路径(通常用作 404 页面)。
为什么用 * 号?

* 号匹配了所有的路径地址,但是因为我们没有定义这些路径,所以其他路径自然而然就返回不出来模版,那么就把这些被匹配到的所有、找不到模版的路径,统一返回 404(NotFound) 模版(路由)页面

从 URL 到页面渲染的过程

假设用户访问了 /About
1. BrowserRouter 检测 URL
– 当前路径是 /About
– 将 URL 状态传递给 Routes
2. Routes 匹配路径
– 检查所有的 Route
/ 不匹配。
/About 匹配 – 返回 element属性 选择的模版。
*(404)不匹配 (因为上面找到有匹配的)
– 找到 /About 的匹配规则。
3. Route 渲染组件
– 根据 /About 的规则,渲染 <About /> 组件。

最终,浏览器显示 About 页面内容。

假设用户访问了 /Axxt 不存在的路由(路径):
1. BrowserRouter 检测 URL
– 当前路径是 /Axxt
– 将 URL 状态传递给 Routes
2. Routes 匹配路径
– 检查所有的 Route
/ 不匹配。
/About 不匹配。
*(404)匹配 – 都找不到匹配的吧,来吧,我来匹配你
– 找不到 /Axxt 的匹配规则,那就匹配到: *
3. Route 渲染组件
– 根据 * 的规则,渲染 <NotFound /> 组件。

最终,浏览器显示 NotFound 页面内容。


3. 更改 导航组件 – 重要

我们需要更改导航组件 src/components/Navbar.jsx 的跳转地址和方式,目前跳转的地址是 # ,并且使用 <a href='#' >(a标签) 跳转,这是不可取的,因为 a 标签跳转,是会刷新页面的,这并不符合我们对于页面不刷新切换路由的需要

import React, { useState } from "react";
import { Link } from "react-router-dom"; // 导入 Link 组件,用于代替会刷新页面的 a 标签

// 子组件:箭头图标
const ArrowRightIcon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 20 20"
    fill="currentColor"
    className="w-5 h-5 ml-1"
  >
    <path
      fillRule="evenodd"
      d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
      clipRule="evenodd"
    />
  </svg>
);

// 子组件:菜单图标
const MenuIcon = () => (
  <svg
    className="block h-6 w-6"
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    aria-hidden="true"
  >
    <path
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="2"
      d="M4 6h16M4 12h16M4 18h16"
    />
  </svg>
);

// 子组件:关闭图标
const CloseIcon = () => (
  <svg
    className="block h-6 w-6"
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    aria-hidden="true"
  >
    <path
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="2"
      d="M6 18L18 6M6 6l12 12"
    />
  </svg>
);

function Navbar() {
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);

  // 导航链接 ///////// 需要更改 /////////
  const navLinks = [
    { name: "主页", path: "/" },              // 更改为前端路由页面的URL地址
    { name: "关于我们", path: "/About" },      // 与 App.js 相关,path 指向
    { name: "测试页面", path: "/NotFound" },
    // 这里 NotFound 路径其实不存在,因为 App.js 里面定义的是 * 号,不过 * 匹配了所有地址,之后就返回了 404(NotFound)模版(路由)页面。
    // 所以这里爱啥啥,但是因为是测试页面,所以直接采用 /NotFound 路径来保持美观罢了。
  ];

  // 公共样式
  const linkStyle =
    "text-gray-700 hover:text-indigo-600 px-3 py-2 rounded-md text-sm font-medium";

  // 渲染导航链接 ///////// 需要更改 /////////
  const renderNavLinks = () =>
    navLinks.map((link) => ( // 为了避免 原生a标签 的跳转(刷新)网页问题
      <Link key={link.name} to={link.path} className={linkStyle}>
        {link.name}
      </Link>                // 这里的 a 标签换成 React-Route 提供的 Link 标签
    ));

  return (
    <nav className="bg-white shadow-sm">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex items-center justify-between h-16">
          {/* 左侧导航链接 */}
          <div className="hidden md:flex space-x-4">{renderNavLinks()}</div>

          {/* 中间 Logo */}
          <div className="flex-shrink-0">
            <Link to="/" aria-label="Home">
              <img
                src="/public/logo.png"
                alt="Logo"
                className="h-8 w-auto object-contain"
              />
            </Link>
          </div>

          {/* 右侧登录链接 */}
          <div className="hidden md:flex items-center">
            <Link to="#" className={`{linkStyle} flex items-center`}>
              Log in
              <ArrowRightIcon />
            </Link>
          </div>

          {/* 移动端菜单按钮 */}
          <div className="md:hidden">
            <button
              onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
              className="bg-white p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
              aria-expanded={isMobileMenuOpen}
            >
              <span className="sr-only">Toggle menu</span>
              {isMobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
            </button>
          </div>
        </div>
      </div>

      {/* 移动端菜单 */}
      {isMobileMenuOpen && (
        <div className="md:hidden">
          <div className="px-2 pt-2 pb-3 space-y-1">{renderNavLinks()}</div>
          <div className="border-t border-gray-200 pt-4 pb-3">
            <Link to="#" className={`{linkStyle} flex items-center`}>
              Log in
              <ArrowRightIcon />
            </Link>
          </div>
        </div>
      )}
    </nav>
  );
}

export default Navbar;

4. 挂载 React 应用

src/main.jsx 中使用 ReactDOM.createRoot 将 React 应用挂载到真实的 DOM 节点。

main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")).render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);


// 下面代码同样可行,不完整导入 React 和 ReactDOM ,只导入需要用的 React.StrictMode 和 ReactDOM.createRoot
// import { StrictMode } from 'react'
// import { createRoot } from 'react-dom/client'
// import App from './App.jsx'
// import './index.css'

// createRoot(document.getElementById('root')).render(
//   <StrictMode>
//       <App />
//   </StrictMode>,
// );

总结说明一下

React Router 的路由挂载 – 路由标签
  1. BrowserRouter:
    • 是 React Router 的路由上下文组件,必须包裹在应用的最外层,负责管理路由的状态和历史记录。
    • 通过它,子组件可以使用路由功能。
  2. Routes:
    • 用于定义路由规则,包含多个 Route
  3. Route:
    • 定义每个路由的路径和对应的组件。
    • path 属性表示 URL 路径,element 属性表示要渲染的组件。
  4. Link:
    • 用于创建导航链接,类似于 HTML 的 <a> 标签,但不会刷新页面,而是通过 React Router 实现页面切换。
ReactDOM 的作用 – 挂载标签

ReactDOM 是 React 提供的一个库,用于将 React 组件渲染到真实的 DOM 节点中。
1. ReactDOM.createRoot:
– 是 React 18 提供的新 API,用于创建 React 应用的根节点。
– 替代了 React 17 中的 ReactDOM.render 方法,支持并发模式并提升性能。
2. 挂载流程:
ReactDOM.createRoot(document.getElementById("root"))
– 找到 HTML 页面中 id="root" 的 DOM 节点,作为 React 应用的挂载点。
.render(<App />)
– 将 React 的根组件(如 App)渲染到 root 节点中,启动整个应用。
3. 作用:
– 将 React 的虚拟 DOM 转换为真实 DOM,显示在浏览器中。
– 实现 React 状态与真实 DOM 的同步更新,确保用户界面动态响应数据变化。


登陆路由

理解完成上面的东西,基本上也就已经入门前端开发了(吧?)
所以下面就过一遍,写一个登陆路由和注册来对接后端,之后把修改一下 Home.jsx ,列出一下最近10条文章。

⚠️警告:对于 后端路由 admin.js值得注意的是,管理员和普通用户在一个表,且没有区分的列。意味着普通用户 = 管理员

所以我们要对数据库做出一些更改:下面有两个方案

方案 1:管理员和普通用户放在同一个表

在用户表中添加一个 角色字段(如 roleis_admin),用来区分用户的身份。

表结构示例
users
+----+----------+----------------+----------+
| id | username | email          | role     |
+----+----------+----------------+----------+
| 1  | Alice    | [email protected] | user     |
| 2  | Bob      | [email protected]   | admin    |
| 3  | Carol    | [email protected] | user     |
+----+----------+----------------+----------+
  • role 字段
    • 可以存储用户的角色(如 useradmin 等)。
    • 如果只是简单区分管理员和普通用户,可以用布尔值字段(如 is_admin)代替。
        is_admin
        +----+----------+----------------+----------+
        | id | username | email          | is_admin |
        +----+----------+----------------+----------+
        | 1  | Alice    | [email protected] | false    |
        | 2  | Bob      | [email protected]   | true     |
        +----+----------+----------------+----------+
优点
  1. 简单易维护
    • 所有用户信息都在一个表中,便于查询和管理。
  2. 便于扩展
    • 如果需要支持更多角色(如 editor, moderator 等),只需扩展 role 字段即可。
  3. 性能优化
    • 避免多表查询或关联操作,查询性能较高。
缺点
  1. 字段语义复杂
    • 如果用户的角色逻辑复杂,可能会导致 role 字段值过于多样,增加维护难度。
  2. 权限控制逻辑复杂
    • 需要在代码中根据 roleis_admin 动态判断权限,权限管理可能比较分散。

方案 2:管理员和普通用户分两个表

将管理员和普通用户分开存储在不同的表中,例如 usersadmins

表结构示例
users
+----+----------+----------------+
| id | username | email          |
+----+----------+----------------+
| 1  | Alice    | [email protected] |
| 2  | Carol    | [email protected] |
+----+----------+----------------+

admins
+----+----------+----------------+
| id | username | email          |
+----+----------+----------------+
| 1  | Bob      | [email protected]   |
+----+----------+----------------+
优点
  1. 数据结构清晰
    • 管理员和普通用户分开存储,逻辑更直观,权限逻辑更容易管理。
  2. 便于权限扩展
    • 如果管理员的数据结构与普通用户差异较大,可以为管理员单独设计专属字段,而不影响普通用户的结构。
缺点
  1. 查询复杂度增加
    • 查询用户数据时需要判断是在 users 表还是 admins 表,增加开发复杂度。
  2. 冗余字段
    • 两个表可能会有大量重复字段(如 usernameemail 等),导致数据冗余。
  3. 扩展不灵活
    • 如果需要支持更多的角色(如 editor, moderator),可能需要创建更多的表,数据设计变得不够灵活。

正文 – 认证 你是你(管理员)

如果你希望有更多的鉴权应用场景,那多用户身份无疑是最好的选择。但是我的网站只会有用户和管理员之分,所以我用的是 「真或假」。

我们取用 方案一 ,我们需要在 Users 这个数据库里面加入一个列(role),直接采用 布尔值 的方式区分管理员和普通用户(毕竟只是小项目),但是我建议直接通过 字段(列 – role) 的值来判断是否是管理员或者其他用户,这样的数据库和代码,对于后期权限的扩展,更加灵活。

ALTER TABLE users ADD COLUMN role BOOLEAN NOT NULL DEFAULT FALSE;

之后,把管理员的 role 字段,改成 1(True) 即可。

我们需要在 /models/User.js 模型中增加这个列

/models/User.js

const { DataTypes } = require('sequelize');
const sequelize = require('../database/database');

const User = sequelize.define('User', {
  id: {
    type: DataTypes.INTEGER,
    autoIncrement: true,
    primaryKey: true,
  },
  username: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
  },
  password: {
    type: DataTypes.STRING,
    allowNull: false,
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
  },
  is_member: {
    type: DataTypes.BOOLEAN,
    defaultValue: true,
  },
  role: {                         // 追加刚刚增加的列
    type: DataTypes.BOOLEAN,
    defaultValue: false,
  }
}, {
  timestamps: true,
});

module.exports = User;

/routes/login.js 这个登陆路由是普通用户的…但是,我们也可以用。

但是,/admin 需要改变了,可不能让那群低等的用户,进入我们高雅的上层大厅

不好意思,刚刚被顶号了。好了,其实现代的网页程序,一般都不在后台再整一个登陆页面出来了,而是直接用户和管理员走一个登陆通道,但不同的是,普通用户访问「私人路由」,如「后台路由」这样的路由,会直接返回 404-NotFound 的页面,或者直接告诉你权限不足(前者明显安全性更高)。

上面是笼统的说法,如果你希望深入了解为什么,可以往下看,否则跳过。

1. 用户和管理员通常共用一个登录通道
在现代的网页程序中,用户和管理员通常通过 同一个登录界面 进行认证。以下是这样设计的原因:

为什么不区分登录页面?
1. 简化开发
– 使用统一的接口和登录逻辑可以减少重复开发工作,不需要为管理员单独设计一个登录页面或接口。
– 用户和管理员的认证流程本质上是一样的(验证用户名/密码),区别在于登录后的权限控制。
2. 安全性
– 如果管理员有独立的登录页面,会暴露出管理员专属的入口,可能会吸引攻击者进行暴力破解或其他攻击。
– 共用一个登录入口,可以隐藏管理员角色,减少攻击的可能性。
3. 用户体验
– 对于拥有多种角色的用户(如既是普通用户又是管理员),共用一个登录界面可以避免混淆。
如何区分用户和管理员?
– 登录成功后,后端会根据用户的 角色信息(如 role 字段或权限列表)返回对应的权限。
– 前端根据返回的权限信息动态展示不同的页面或功能。例如:
– 普通用户可以访问「用户中心」。
– 管理员可以访问「后台管理」。

2. 权限管理:私人路由与后台路由的处理方式
权限管理是现代网页程序中的核心部分,重点是如何处理 普通用户访问管理员路由 的情况。

两种常见处理方式
1. 返回 404 页面(更安全的做法):
– 如果普通用户尝试访问管理员的专属路由(如 /admin/dashboard),直接返回 404 页面。
– 这样可以隐藏管理员路由的存在,避免暴露后台系统的入口。
优点
– 提高安全性,攻击者无法轻易探测出哪些路由是管理员路由。
实现方式
– 后端根据用户权限控制是否返回路由内容。
– 前端通过路由守卫(如 React Router 的 PrivateRoute)拦截未授权用户。

    **示例(前端实现)**:
const PrivateRoute = ({ component: Component, ...rest }) => {
  const userRole = getUserRole(); // 从全局状态或后端获取用户角色
  return (
    <Route
      {...rest}
      render={(props) =>
        userRole === "admin" ? (
          <Component {...props} />
        ) : (
          <Redirect to="/404" /> // 未授权用户重定向到 404 页面
        )
      }
    />
  );
};
  1. 返回权限不足提示
    • 如果普通用户访问了管理员路由,显示一个提示页面(如「权限不足」或「无权访问」)。
    • 优点
      • 提供明确的信息反馈,用户知道自己没有权限访问该页面。
    • 缺点
      • 通过返回权限不足的页面,攻击者可以推测出后台路由的存在,从而增加暴力破解的风险。
    • 实现方式
      • 后端在返回路由数据时,校验用户权限,如果无权限,返回 403 错误(Forbidden)。
        哪种方式更好?

返回 404 页面:适用于对安全性要求较高的系统(如企业管理系统、金融系统)。
返回权限不足提示:适用于对用户体验要求更高的系统(如电商平台、内容管理系统)。

3. 前端与后端的权限分工
后端权限控制
– 后端是权限控制的核心,因为前端的代码可能会被攻击者反编译和修改。
– 后端需要对每个请求进行严格的权限校验:
– 验证用户的登录状态和角色。
– 如果用户无权访问某个资源,直接返回 403(权限不足)或 404(资源不存在)。
前端权限控制
– 前端的权限控制主要是提升用户体验,避免不必要的页面加载和请求。
– 常见的前端权限控制方法:
路由守卫
– 在用户访问某些路由前,校验其权限,如果无权限则重定向到 404 页面或显示权限不足提示。
动态菜单渲染
– 根据用户权限动态渲染页面菜单和功能按钮,普通用户看不到管理员特有的功能。

4. 补充:如何提升安全性?
1. 隐藏敏感路由
– 不要在前端代码中暴露管理员路由(如 /admin/dashboard)。
– 使用后端返回的动态路由表,让前端根据权限动态渲染路由。
2. 使用中间件校验权限
– 在后端通过中间件(如 Express 中的 middleware)统一校验请求权限,避免每个路由都重复实现权限逻辑。
3. 限制敏感信息的暴露
– 后端接口只返回当前用户有权限访问的数据。
– 对管理员的接口请求进行严格的身份验证(如 IP 白名单、二次认证等)。

5. 总结
现代网页程序中通常设计为:
1. 用户和管理员共用一个登录通道,登录后通过权限字段区分身份。
2. 对普通用户访问管理员专属路由的处理:
安全性优先:返回 404 页面,隐藏路由存在。
用户体验优先:显示权限不足提示。
推荐:
– 如果安全性是首要考虑(如管理系统),优先选择返回 404 页面。
– 如果用户体验更重要(如电商、内容管理系统),可以返回权限不足提示。

通过后端权限校验 + 前端路由守卫的组合,既能保证安全性,又能提升用户体验。


方案一

首先,我们更改一下 routes/login.js 下发的 Token ,因为它只携带了,user.id 和 user.username ,我们需要多携带一个 role ,之后「私人路由」解析Token,就可以获得一个 role 值,之后对这个值进行判断就行。

const express = require('express');
const bcrypt = require('bcrypt');
const { User } = require('../models');
const { generateToken } = require('../utils/jwt');

const router = express.Router();

router.post('/', async (req, res) => {
  const { username, password } = req.body;

  try {
    // 查找用户
    const user = await User.findOne({ where: { username } });

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // 验证密码
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // 生成 JWT ////////// 追加 user.role //////////
    const token = generateToken({ id: user.id, username: user.username, role: user.role });

    res.json({ token });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Something went wrong' });
  }
});

module.exports = router;

之后我解释一下 routes/admin.js

const express = require('express');
const { authenticateJWT } = require('../middlewares/auth');

const router = express.Router();

// authenticateJWT 这个中间件解密了 Token, 并通过了 req.user = decoded 这种方式赋值
// 我们通过 req.user.role 的方式去解析出 role 字段
router.get('/', authenticateJWT, (req, res) => {
    console.log(req.user.role); // 可以打印出 role 情况
    res.json({ message: 'Welcome to the admin panel', user: req.user });
});

module.exports = router;

带 Token(管理员) 访问后,返回的 数据 就是这样的:

{
    "message": "Welcome to the admin panel",
    "user": {
        "id": 1,
        "username": "admin",
        "role": true,
        "iat": 174xxxx359,
        "exp": 174xxxx759
    }
}

带 普通用户Token 访问后,返回的 数据 就是这样的:

{
    "error": "Pages Not Found."
}

下面是 routes/admin.js 布尔值版本(因为我的数据库设计是布尔值的):

const express = require('express');
const { authenticateJWT } = require('../middlewares/auth');

const router = express.Router();

// authenticateJWT 这个中间件解密了 Token
router.get('/', authenticateJWT, (req, res) => {
    // 对什么都不携带的嗅探返回 404
  if (!req.user || !req.user.role) {
    return res.status(404).json({ error: 'Pages Not Found.' });
  };

    // 对管理员返回 正常页面
  if (req.user.role == true) {
    res.json({ message: 'Welcome to the admin panel', user: req.user });
  };

    // 对普通用户返回 404
  if (req.user.role == false) {
    return res.status(404).json({ error: 'Pages Not Found.' });
  };
});

module.exports = router;

如果你希望有更多的鉴权应用场景,那多用户身份无非是最好的选择。但是我的网站只会有用户和管理员之分,所以我用的是 「真或假」。

方案二
const express = require('express');
const { authenticateJWT } = require('../middlewares/auth');

const router = express.Router();

// authenticateJWT 中间件解密 Token
router.get('/', authenticateJWT, (req, res) => {
  // 检查 req.user 和 req.user.role 是否存在
  if (!req.user || !req.user.role) {
    return res.status(403).json({ error: 'Unauthorized: Invalid token or role missing' });
  }

  // 判断角色
  if (req.user.role === 'admin') {
    // 管理员角色
    return res.json({ message: 'Welcome to the admin panel', user: req.user });
  } else if (req.user.role === 'user') {
    // 普通用户角色
    return res.status(404).json({ error: 'Pages Not Found.' });
  }

  // 其他未知角色
  return res.status(403).json({ error: 'Forbidden: Role not recognized' });
});

module.exports = router;

React 登陆页面

我们需要写一个登陆页面,使用测试工具测试 Token 都要自己手动带上,我们写一个前端的登陆页面,需要完成 Token 的自携带才行。

自动携带 Token 可以通过 「Axios」请求拦截器 自动将 Token 添加到每次请求的 Authorization 请求头中。但是我选择用原生的 Fetch ,但是使用它,我们需要自己封装一个功能出来。

设置 Token 到 Cookie

因为把 Token 设置到 LocalStorage 不安全,容易受到 XSS 攻击,所以为了支持 HttpOnly Cookie ,我们需要在服务器设置Cookie。

之前的代码,直接 res.json({ token }); 是为了方便查看到返回的 Token ,之后做测试 ,而代码 投入使用后 ,直接 res.cookie 设置完 Token 到 Cookie 之后,返回 status:200, message:'Login Successful!' 就行了

routes/login.js

......
...
// 验证密码
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}

// 生成 JWT
const token = generateToken({ id: user.id, username: user.username, role: user.role });

// res.json({ token });

// 设置 HttpOnly Cookie 
res.cookie("authToken", token, {
    httpOnly: true,      // 禁止 JavaScript 访问
    secure: true,        // 仅在 HTTPS 上传输
    maxAge: 14400000,    // Cookie 有效期 (4 小时 - 以毫秒为单位)
    sameSite: "Strict",  // 防止 CSRF 攻击
});

res.json({ message:`Login successful! ${user.username}` })
...
......

上面返回的一个 请求头 有 Set-Cookie ,浏览器就是靠这个设置 Cookie 的,之后会自己携带,如果失效了,就抛弃。

重新设置 CORS 策略 以及 设置 Cookie-parser

如果不重新设置,将会被拦截,因为本来的 CORS 设置全局太过逆天,并且没有设置 Cookie 放行。如果不设置的话,浏览器将会拦截你的请求,故登陆失败。

为什么 req.cookie 是未定义
  • Express 默认不会解析 cookie,需要手动使用 cookie-parser 中间件。
  • cookie-parser 会将 req.headers.cookie 中的原始字符串解析为一个对象,并将其挂载到 req.cookies
    • 安装:npm install cookie-parser

后端 app.js

const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');    // Cookie 解析
const indexData = require('./routes/siteintro');
const indexRoutes = require('./routes/index');
const articleRoutes = require('./routes/article');
const registerRoutes = require('./routes/register');
const authRoutes = require('./routes/login');
const adminRoutes = require('./routes/admin');

const cors = require("cors");
const app = express();

app.use(bodyParser.json());

app.use(cookieParser());             // 使用 cookie-parser 中间件

// // 全局启用 CORS
// app.use(cors());

// CORS 配置
app.use(
  cors({
    origin: "http://localhost:5173", // 前端地址
    credentials: true,               // 允许携带 Cookie
  })
);

// 路由
app.use('/', indexRoutes);
app.use('/data', indexData);
app.use('/article', articleRoutes);
app.use('/register', registerRoutes);
app.use('/login', authRoutes);
app.use('/admin', adminRoutes);

const PORT = 3000;

app.listen(PORT, async () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

前端 示例代码

当访问需要身份验证的后端路由时,例如 /api/protected,前端仍然需要设置 credentials: "include",让浏览器自动携带 HttpOnly Cookie

登陆请求示例代码:
const login = async (username, password) => {
  try {
    const response = await fetch("http://localhost:3000/login", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ username, password }), // 发送用户名和密码
      credentials: "include", // 确保携带和存储 HttpOnly Cookie
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(errorData.error || "Login failed");
    }

    // 登录成功
    alert("Login successful!");
  } catch (error) {
    console.error("Login error:", error.message);
    alert(error.message);
  }
};
访问受保护的接口示例代码:
const fetchProtectedData = async () => {
  try {
    const response = await fetch("http://localhost:3000/api/protected", {
      method: "GET",
      credentials: "include", // 自动携带 HttpOnly Cookie (重要)
    });

    if (response.status === 401) {
      // 如果服务器返回 401,说明 Token 已过期
      alert("Session expired. Redirecting to login...");
      window.location.href = "/login"; // 跳转到登录页面
      return;
    }

    if (!response.ok) {
      throw new Error("Failed to fetch protected data");
    }

    const data = await response.json();
    console.log("Protected data:", data);
  } catch (error) {
    console.error("Error fetching protected data:", error.message);
  }
};
全局处理 Token 过期问题
封装 fetch 工具

比如说前端的 src/api/customFetch.js

const customFetch = async (url, options = {}) => {
  try {
    const response = await fetch(url, {
      ...options,
      credentials: "include", // 自动携带 HttpOnly Cookie (重要)
    });

    if (response.status === 401) {
      // Token 过期处理
      alert("Session expired. Redirecting to login...");
      window.location.href = "/login"; // 跳转到登录页面
      return;
    }

    return response;
  } catch (error) {
    console.error("Network error:", error.message);
    throw error;
  }
};

export default customFetch;
使用封装的工具

在需要的路由页面,导入一下上面封装的工具

...
import { customFetch } from "../api/customFetch";

const fetchProtectedData = async () => {
  const response = await customFetch("http://localhost:3000/api/protected", {
    method: "GET",
  });

  if (response.ok) {
    const data = await response.json();
    console.log("Protected data:", data);
  }
};
...
后端获取 Token
// 记得安装上面的 Cookie-parser 
// 因为 cookie 存在于 req.headers.cookie 中,而不是直接挂载在 req.cookie 上
const token = req.cookies.authToken; // 从 HttpOnly Cookie 中获取 Token

改写代码 – 后端

我们的中间件依赖 Authorization 头中的 Bearer Token。如果你使用的是 HttpOnly Cookie 传递 Token,那么中间件将无法从 Authorization 头中获取 Token。会直接 undefined ,之后报错401。

所以,如果前后端通过 HttpOnly Cookie 传递 Token,可以在 req.cookies 中查找 Token

App.js 文件一览

const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');    // Cookie 解析
const indexData = require('./routes/siteintro');
const indexRoutes = require('./routes/index');
const articleRoutes = require('./routes/article');
const registerRoutes = require('./routes/register');
const authRoutes = require('./routes/login');
const adminRoutes = require('./routes/admin');

const cors = require("cors");

const app = express();

app.use(bodyParser.json());
app.use(cookieParser());             // 使用 cookie-parser 中间件

// CORS 配置
app.use(
  cors({
    origin: "http://localhost:5173", // 前端地址
    credentials: true,               // 允许携带 Cookie
  })
);

// 路由
app.use('/', indexRoutes);
app.use('/data', indexData);
app.use('/article', articleRoutes);
app.use('/register', registerRoutes);
app.use('/login', authRoutes);
app.use('/admin', adminRoutes);

const PORT = 3000;

app.listen(PORT, async () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

修改一下中间件的 Token 获取方式

middlewares/auth.js

const { verifyToken } = require('../utils/jwt');

// 中间件:验证 JWT
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.startsWith('Bearer ')
    ? authHeader.split(' ')[1]  // 从 Authorization 头中获取 Token
    : req.cookies?.authToken;   // 或从 Cookie 中获取 Token
// 因为我希望上面两种情况都支持,所以直接用三元表达式,如果获取不到 Authorization 的 Token ,再从 Cookie 中获取

  if (!token) {
    return res.status(401).json({ error: 'Unauthorized: No token provided' });
  }
  
  try {
    const decoded = verifyToken(token);  // 验证 JWT
    req.user = decoded;                  // 将用户信息附加到请求对象
    next();                              // 放行
  } catch (err) {
    return res.status(401).json({ error: 'Unauthorized: Invalid token' });
  }
};

module.exports = { authenticateJWT };

middlewares/verify.js

const { verifyToken } = require('../utils/jwt');

function verifyLogin(req, res, next) {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.startsWith('Bearer ')
    ? authHeader.split(' ')[1]  // 从 Authorization 头中获取 Token
    : req.cookies?.authToken;   // 或从 Cookie 中获取 Token

  // 如果没有 Authorization 请求头,则视为未登录
  if (!token) {
    console.log('No Authorization header found');
    req.user = null;
    return next();
  };

  // 提取 token(处理 Bearer 前缀)
  // const token = authHeader.startsWith('Bearer ') ? authHeader.split(' ')[1] : authHeader;

  try {
    // 验证并解码 token
    const decoded = verifyToken(token);

    // 检查解码后的信息是否完整
    if (!decoded || !decoded.username) {
      console.error('Decoded token missing required fields:', decoded);
      req.user = null;
      return next();
    }

    req.user = decoded; // 将解码后的用户信息传递到后续中间件
    next();
    
  } catch (err) {
      console.error('Token verification failed:', err.message); // 打印具体错误信息
      req.user = null;
      next();
  };
};

module.exports = { verifyLogin };

改写代码 – 前端

App.jsx 文件一览

import React from 'react';
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";

import Home from './pages/Home';
import About from './pages/About';
import NotFound from './pages/NotFound'
import LoginPage from './pages/Login';
import Admin from './pages/Admin';

import './App.css';

const App = () => {
  return (
    <>
      <Router>
        <div>
          <Routes>
            <Route path='/' element={<Home />} />
            <Route path='/About' element={<About />} />
            <Route path='/Login' element={<LoginPage />} />
            <Route path='/Admin' element={<Admin />} />
            <Route path='/*' element={<NotFound />} />
          </Routes>
        </div>
      </Router>
    </>
  );
};

export default App;

/src/api/customFetch.js

const customFetch = async (url, options = {}) => {

  try {
    const response = await fetch(url, {
      ...options,
      credentials: "include", // 自动携带 HttpOnly Cookie (重要)
    });

    if (!response.ok) {
      if (response.status === 401) {
        alert("Session expired. Redirecting to login...");
        window.location.href = "/login";
        return;
      } else if (response.status === 403) {
        throw new Error("Access denied: You do not have permission to access this resource.");
      } else if (response.status === 404) {
        throw new Error("Resource not found.");
      } else {
        throw new Error(`Unexpected error: ${response.statusText}`);
      }
    }

    // 自动解析 JSON 数据
    return await response.json();
  } catch (error) {
    console.error("Network error:", error.message);
    throw error;
  }
};

export default customFetch;

/src/pages/Admin.jsx

import React, { useEffect, useState } from "react";
import customFetch from "../api/customFetch";

const Admin = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);


  useEffect(() => {

    const getUserProfile = async () => {
      try {
         // 用我们封装的 Fetch 去访问需要认证的 私人路由 (它的逻辑明确了带上Cookie,如果失效或者未找到,即重定向到 /Login 路由)
        const data = await customFetch("http://localhost:3000/admin", { method: "GET" });
        
        if (!data || !data.user || data.user.role !== true) {
          throw new Error("Access denied: You do not have permission to access this page.");
        }
        setUser(data.user);

      } catch (error) {
        setError(error.message);
        if (error.message.includes("Session expired")) {
          window.location.href = "/login";
        }
      } finally {
        setLoading(false);
      }
    };

    getUserProfile();
    
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div className="text-red-500">Error: {error}</div>;
  
  return (
    <div>
      <h1>Admin Dashboard</h1>
      <p>Welcome, {user?.username}!</p>
      <p>Your role: {user?.role ? "Admin" : "User"}</p>
    </div>
  );
};

export default Admin;

后记

我相信代码已经没什么好说的了,并且我在网站已经提供了整个项目的文件打包,包括代码。

由 Dontalk 会员 – 【葵】 耗时4天编写 – 如有不对,敬请原谅和指正。

如果你希望下载本文 PDF 以及配套代码,请前往「dl.dontalk.org」找到「现代网页开发入门指南书 – 从数据库设计,到后端,再到前端」即可下载。或者博文提到的 GitHub地址 也可以下载到副本 (包括本指南书的PDF档)。

Dontalk 地址:dontalk.org

Github 仓库地址(25年5月中):https://github.com/infoabcd/Modern-Web-Development-Beginner-s-Guide/

但是请注意,本作品采用 [署名-非商业性使用 4.0 国际 (CC BY-NC 4.0)] 协议发布。
你可以自由转载、分发、分享,但必须注明作者及出处,不得用于任何商业用途

否则在沟通无果后,Dontalk 团队不介意采用法律武器捍卫权益。

协议详情请见:https://creativecommons.org/licenses/by-nc/4.0/deed.zh


发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注