前端 – 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 的
Routes
和Route
)。 - 组织并引入其他子组件(如
Header
、Footer
、Home
等)。 - 可以包含全局状态管理(如 Context API 或 Redux 的 Provider)。
核心职责:
- 负责布局和页面逻辑:
- 定义页面的整体结构(如头部、导航、主内容区)。
- 配置页面路由,加载不同的页面组件。
- 组织组件:
- 引入其他子组件,并将它们组合起来构建应用的 UI。
- 控制全局状态:
- 如果使用 Context API、Redux 等全局状态管理工具,通常会在
App.jsx
中初始化状态管理。
- 如果使用 Context API、Redux 等全局状态管理工具,通常会在
我们进入目录,执行下面命令即可启动 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规则命中。
所以你必须(最好)这样做:
- 创建 CSS 文件时,不要使用
.css
结尾,要使用.module.css
结尾(或者.module.scss
) - 在 jsx 源码文件,这样导入 CSS:
import importStyles from './xxxx.module.css';
- 在代码中,这样命名元素:
<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 状态传递给子组件(
Routes
和Route
)。
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 的路由挂载 – 路由标签
BrowserRouter
:- 是 React Router 的路由上下文组件,必须包裹在应用的最外层,负责管理路由的状态和历史记录。
- 通过它,子组件可以使用路由功能。
Routes
:- 用于定义路由规则,包含多个
Route
。
- 用于定义路由规则,包含多个
Route
:- 定义每个路由的路径和对应的组件。
path
属性表示 URL 路径,element
属性表示要渲染的组件。
Link
:- 用于创建导航链接,类似于 HTML 的
<a>
标签,但不会刷新页面,而是通过 React Router 实现页面切换。
- 用于创建导航链接,类似于 HTML 的
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:管理员和普通用户放在同一个表
在用户表中添加一个 角色字段(如 role
或 is_admin
),用来区分用户的身份。
表结构示例
users
+----+----------+----------------+----------+
| id | username | email | role |
+----+----------+----------------+----------+
| 1 | Alice | [email protected] | user |
| 2 | Bob | [email protected] | admin |
| 3 | Carol | [email protected] | user |
+----+----------+----------------+----------+
role
字段:- 可以存储用户的角色(如
user
、admin
等)。 - 如果只是简单区分管理员和普通用户,可以用布尔值字段(如
is_admin
)代替。
- 可以存储用户的角色(如
is_admin
+----+----------+----------------+----------+
| id | username | email | is_admin |
+----+----------+----------------+----------+
| 1 | Alice | [email protected] | false |
| 2 | Bob | [email protected] | true |
+----+----------+----------------+----------+
优点
- 简单易维护:
- 所有用户信息都在一个表中,便于查询和管理。
- 便于扩展:
- 如果需要支持更多角色(如
editor
,moderator
等),只需扩展role
字段即可。
- 如果需要支持更多角色(如
- 性能优化:
- 避免多表查询或关联操作,查询性能较高。
缺点
- 字段语义复杂:
- 如果用户的角色逻辑复杂,可能会导致
role
字段值过于多样,增加维护难度。
- 如果用户的角色逻辑复杂,可能会导致
- 权限控制逻辑复杂:
- 需要在代码中根据
role
或is_admin
动态判断权限,权限管理可能比较分散。
- 需要在代码中根据
方案 2:管理员和普通用户分两个表
将管理员和普通用户分开存储在不同的表中,例如 users
和 admins
。
表结构示例
users
+----+----------+----------------+
| id | username | email |
+----+----------+----------------+
| 1 | Alice | [email protected] |
| 2 | Carol | [email protected] |
+----+----------+----------------+
admins
+----+----------+----------------+
| id | username | email |
+----+----------+----------------+
| 1 | Bob | [email protected] |
+----+----------+----------------+
优点
- 数据结构清晰:
- 管理员和普通用户分开存储,逻辑更直观,权限逻辑更容易管理。
- 便于权限扩展:
- 如果管理员的数据结构与普通用户差异较大,可以为管理员单独设计专属字段,而不影响普通用户的结构。
缺点
- 查询复杂度增加:
- 查询用户数据时需要判断是在
users
表还是admins
表,增加开发复杂度。
- 查询用户数据时需要判断是在
- 冗余字段:
- 两个表可能会有大量重复字段(如
username
、email
等),导致数据冗余。
- 两个表可能会有大量重复字段(如
- 扩展不灵活:
- 如果需要支持更多的角色(如
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 页面
)
}
/>
);
};
- 返回权限不足提示:
- 如果普通用户访问了管理员路由,显示一个提示页面(如「权限不足」或「无权访问」)。
- 优点:
- 提供明确的信息反馈,用户知道自己没有权限访问该页面。
- 缺点:
- 通过返回权限不足的页面,攻击者可以推测出后台路由的存在,从而增加暴力破解的风险。
- 实现方式:
- 后端在返回路由数据时,校验用户权限,如果无权限,返回 403 错误(Forbidden)。
哪种方式更好?
- 后端在返回路由数据时,校验用户权限,如果无权限,返回 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
发表回复