一个现代化的博客应用,支持文章发布、CRUD等功能。
框架:react+ts
预览和源码查看Overview,技术文档查看Introduction,demo中关键技术点可查看本博客内容。
# - Overview
另外也可以在我的前端项目集合中搜Blog 应用找到。
# - Introduction
React官网:https://react.dev/
中文社区版:https://react.docschina.org
HTML转JSX在线转化:https://transform.tools/html-to-jsx
What is React
- JavaScript library used to create websites
- Allows us to easily create Single Page Apps
- SPA's for short
实用插件:Simple React Snippets
实用设置:
# - Click Events
传递引用,而不是调用这个方法,所以不带()。
js
// 不带参,传的是引用
<button onClick={handleClick}>点击</button>
// 带参写法,用箭头函数,相当于传了这个箭头函数的匿名引用
<button onClick={() => handleDelete(blog.id)}>删除</button>
// 传事件参数event
// 不带参函数可以省略传参,在函数定义处直接使用
// 带参函数则将参数传递到自定义函数中使用
<button onClick={(e) => handleDelete(blog.id, e)}>删除</button>
# - useEffect Dependencies
js
const [name, setName] = useState('mario');
useEffect(() => {
console.log('use effect ran');
console.log(name);
}, [name]);
// 首次渲染 + 依赖项 name 变化时执行
✅ useEffect 三种依赖写法区别表
| 写法 | 第二个参数 | 执行时机 | 说明 / 场景 | 特点 |
|---|---|---|---|---|
useEffect(() => {...}) |
❌ 无 | 每次渲染都执行 | 页面每次更新(包括首次挂载 & 任意 state/props 改变)都执行 | 最频繁,性能开销最大 |
useEffect(() => {...}, []) |
✅ 空数组 | 仅第一次渲染执行(相当于 componentDidMount) | 用于只执行一次的副作用,如初始化、请求数据、注册监听 | 最常用于初始化逻辑 |
useEffect(() => {...}, [name]) |
✅ 有依赖项 | 首次渲染 + 依赖项 name 变化时执行 |
用于监听某些 state 或 props 的变化,触发副作用 | 最实用、最常用的模式 |
| 写法 | 记忆口诀 |
|---|---|
| 无依赖 | 逢 render 必执行 |
| 空数组 | 只挂载时执行一次 |
| 有依赖 | 依赖变才会执行 |
# - Handling Fetch Errors
js
import React, { useState, useEffect } from 'react';
// 定义 User 数据结构接口
interface User {
id: number;
name: string;
email: string;
}
const UserList: React.FC = () => {
const [users, setUsers] = useState<User[]>([]); // 明确是 User 类型数组
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error('请求失败');
}
const data: User[] = await response.json(); // 类型断言为 User[]
setUsers(data);
setLoading(false);
} catch (err) {
setError((err as Error).message); // 明确类型为 Error
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h2>User List</h2>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
};
export default UserList;
# - Making a Custom Hook
✅ 常见自定义 Hook 场景举例
| 场景 | 自定义 Hook 名称 |
|---|---|
| 获取窗口大小 | useWindowSize |
| 检查是否滚动到底部 | useScrollBottom |
| 本地存储封装 | useLocalStorage |
| 倒计时 | useCountdown |
| 节流 / 防抖 | useThrottle, useDebounce |
| 表单状态管理 | useForm |
示例:带 loading、错误处理的 useFetch 自定义 Hook
自定义hook(useFetch.ts):
js
import { useEffect, useState } from 'react';
function useFetch<T = unknown>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isCancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
const result: T = await response.json();
if (!isCancelled) {
setData(result);
}
} catch (err) {
if (!isCancelled) {
setError((err as Error).message);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchData();
return () => {
isCancelled = true;
};
}, [url]);
return { data, loading, error };
}
export default useFetch;
用户列表组件(UserList.tsx):
jsx
import React from 'react';
import useFetch from './useFetch';
interface User {
id: number;
name: string;
email: string;
}
const UserList: React.FC = () => {
const { data: users, loading, error } = useFetch<User[]>(
'https://jsonplaceholder.typicode.com/users'
);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了:{error}</p>;
if (!users) return <p>无用户数据</p>;
return (
<div>
<h2>用户列表</h2>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
};
export default UserList;
# - The React Router
在react-router-dom v6中,Switch 改成了 Routes ,而且 Route 写法也改了。
📌 v5 和 v6 的区别
-
在 react-router-dom v5 里用:
tsimport { BrowserRouter as Router, Route, Switch } from "react-router-dom"; <Router> <Switch> <Route path="/" exact component={Home} /> <Route path="/about" component={About} /> </Switch> </Router> -
在 react-router-dom v6 里,
Switch改成了 Routes ,而且Route写法也改了:tsimport { BrowserRouter as Router, Routes, Route } from "react-router-dom"; <Router> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> </Routes> </Router>
包版本的变化参考官网文档:
https://www.npmjs.com/package/react-router-dom
# - Exact Match Routes
在 react-router-dom v6 里,默认就是精准匹配路径,不再需要 exact。
想要「模糊匹配 / 子路由」→ 用 *。
📌 v5 的情况
在 v5 里,默认是「模糊匹配」:
<Route path="/" component={Home} /> // 会匹配所有 / 开头的路由,包括 /about
<Route path="/about" component={About} />
所以要精准匹配 /,必须加上 exact:
<Route path="/" exact component={Home} />
📌 v6 的情况
在 v6 里,默认就是 精准匹配:
<Route path="/" element={<Home />} /> // 只会匹配根路径 "/"
<Route path="/about" element={<About />} />
如果你要支持「模糊匹配」子路由,需要自己嵌套路由,比如:
<Route path="/about/*" element={<AboutLayout />} />
这里的 * 才表示「/about 以及它的子路径」。
# - Router Links
ts
import { Link } from "react-router-dom";
const Navbar = () => {
return (
<nav className="navbar">
<h1>Dojo Blog</h1>
<div className="links">
<Link to="/">Home</Link>
<Link
to="/create"
style={{
color: "white",
backgroundColor: "#f1356d",
borderRadius: "8px",
}}
>
New Blog
</Link>
</div>
</nav>
);
};
export default Navbar;
问题:在home和create页面快速切换时会报错:

这是因为home中有useEffect做网络请求,回调时发现组件已经不在挂载状态了。
处理方式看下一节。
# - useEffect Cleanup
🧠 什么是 useEffect Cleanup?
当组件卸载,或者依赖项变化导致 useEffect 再次执行前,我们可以 返回一个函数进行清理工作(cleanup)。
✅ 示例:setInterval 定时器清理
js
import { useEffect, useState } from 'react';
function TimerComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('⏱️ 启动定时器');
const intervalId = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// ✅ cleanup:组件卸载或下一次 effect 执行前清除定时器
return () => {
console.log('🧹 清除定时器');
clearInterval(intervalId);
};
}, []); // 空依赖,effect 只运行一次
return <h2>计时:{count} 秒</h2>;
}
🔍 输出日志可能如下:
⏱️ 启动定时器
🧹 清除定时器(当组件卸载时)
✅ 示例2:添加 & 清除事件监听器
js
useEffect(() => {
const handleResize = () => {
console.log('窗口大小改变了');
};
window.addEventListener('resize', handleResize);
// ✅ 清理监听器
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
✅ 示例3:WebSocket 或 API 订阅的清理
js
useEffect(() => {
const ws = new WebSocket('wss://example.com/socket');
ws.onmessage = (e) => {
console.log('收到消息:', e.data);
};
// ✅ 清理:断开连接
return () => {
ws.close();
};
}, []);
💡 什么时候需要写 cleanup?
| 副作用类型 | 是否需要 cleanup | 示例 |
|---|---|---|
定时器(setInterval) |
✅ 是 | 清除定时器 |
全局事件监听(window) |
✅ 是 | 移除事件监听器 |
| WebSocket / 订阅 | ✅ 是 | 关闭连接 / 取消订阅 |
| 网络请求 | 🚫 可选(更推荐用 AbortController) | |
| 普通赋值、状态更新 | 🚫 否 | 无需清理 |
✅ 示例4:使用 AbortController 清理网络请求
场景说明:
比如你有一个用户搜索组件,用户每次输入内容后都会发起请求。但如果用户快速输入,"上一次的请求"还没返回,结果就被"新的输入"覆盖或出现竞态。
这是防止组件卸载或切换请求时仍执行旧请求结果的推荐做法。
ts
import React, { useEffect, useState } from 'react';
function SearchUser({ query }: { query: string }) {
const [user, setUser] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) {
setUser(null);
return;
}
const controller = new AbortController();
const signal = controller.signal;
const fetchUser = async () => {
try {
const response = await fetch(`https://api.example.com/user?q=${query}`, {
signal,
});
if (!response.ok) {
throw new Error('请求失败');
}
const data = await response.json();
setUser(data);
} catch (err: any) {
if (err.name === 'AbortError') {
console.log('🚫 请求被中止');
} else {
setError(err.message);
}
}
};
fetchUser();
// ✅ 清理:组件卸载或 query 改变时中止请求
return () => {
controller.abort();
};
}, [query]);
if (error) return <div>❌ 错误: {error}</div>;
if (!user) return <div>🔍 正在查询: {query}</div>;
return (
<div>
<h3>用户信息</h3>
<pre>{JSON.stringify(user, null, 2)}</pre>
</div>
);
}
export default SearchUser;
✅ 关键点说明:
| 代码块 | 作用 |
|---|---|
const controller = new AbortController() |
创建一个控制器 |
fetch(..., { signal }) |
把信号传入 fetch,让它能被取消 |
controller.abort() |
❗组件卸载/依赖变化时取消请求 |
err.name === 'AbortError' |
区分是中止还是其他错误 |
🔍 示例行为:
- 当
query改变时,旧的请求会被 abort 掉,不会再设置状态。 - 当组件卸载时,请求同样会中止,避免内存泄漏。
# - Route Parameters
js
// router
<Route path='/blog/:id'>
<BlogDetails />
</Route>
// get param
const { id } = usePagems();
// pass param
<link to={'/blogs/${blog.id}'}>...</link>
# - Reusing Custom Hooks
现在有 List 和 Detail 两个组件,但请求的接口返回的数据类型不一样,一个是 数组 ,一个是 对象 。
你又抽了一个 useFetch.ts 自定义 hook 来统一请求,所以遇到的问题就是:
我到底要在
useFetch里把data定义成数组还是对象?
✅ 最佳实践思路
在这种场景里,最推荐的方式是 ------ 让 useFetch 支持泛型 (generic) 。
这样每个页面在调用的时候自己指定 data 的类型,而不是在 useFetch 里写死。
🔨 示例:useFetch.ts
ts
import { useState, useEffect } from "react";
function useFetch<T>(url: string, options?: RequestInit) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(url, options)
.then((res) => {
if (!res.ok) throw new Error("Network error");
return res.json();
})
.then((json) => setData(json))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
export default useFetch;
🔨 在 List 页面使用(返回数组)
ts
type Blog = {
id: number;
title: string;
};
function BlogList() {
const { data: blogs, loading, error } = useFetch<Blog[]>("/api/blogs");
if (loading) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
if (!blogs) return <p>No data</p>;
return (
<ul>
{blogs.map((b) => (
<li key={b.id}>{b.title}</li>
))}
</ul>
);
}
🔨 在 Detail 页面使用(返回对象)
ts
type BlogDetail = {
id: number;
title: string;
content: string;
};
function BlogDetail() {
const { data: blog, loading, error } = useFetch<BlogDetail>("/api/blog/1");
if (loading) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
if (!blog) return <p>No data</p>;
return (
<div>
<h1>{blog.title}</h1>
<p>{blog.content}</p>
</div>
);
}
✅ 总结
useFetch内部不要写死data的类型,用泛型参数<T>交给调用方决定。List组件调用时传Blog[]。Detail组件调用时传BlogDetail。
这样你就能保证 复用性 + 类型安全 ✨。
# - 部署到github pages
这次mock数据使用MockAPI:https://mockapi.io
最后生成Baseurl:https://68a82aaaaaaaaaaaaaab17.mockapi.io/
注意BrowserRouter 和 Router 是不一样的,步骤清单:
- vite.config.js → base: '/blog-demo-react-ts/'
- BrowserRouter → basename="/blog-demo-react-ts"
- 根目录加一个
404.html
