一个现代化的博客应用【react+ts】

一个现代化的博客应用,支持文章发布、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 里用:

    ts 复制代码
    import { 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 写法也改了:

    ts 复制代码
    import { 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 以及它的子路径」。

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

现在有 ListDetail 两个组件,但请求的接口返回的数据类型不一样,一个是 数组 ,一个是 对象

你又抽了一个 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/

注意BrowserRouterRouter 是不一样的,步骤清单:

  • vite.config.js → base: '/blog-demo-react-ts/'
  • BrowserRouter → basename="/blog-demo-react-ts"
  • 根目录加一个 404.html
相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端
爱敲代码的小鱼14 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax