一个现代化的博客应用【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
相关推荐
wearegogog1234 小时前
基于 MATLAB 的卡尔曼滤波器实现,用于消除噪声并估算信号
前端·算法·matlab
Drawing stars4 小时前
JAVA后端 前端 大模型应用 学习路线
java·前端·学习
品克缤5 小时前
Element UI MessageBox 增加第三个按钮(DOM Hack 方案)
前端·javascript·vue.js
小二·5 小时前
Python Web 开发进阶实战:性能压测与调优 —— Locust + Prometheus + Grafana 构建高并发可观测系统
前端·python·prometheus
小沐°5 小时前
vue-设置不同环境的打包和运行
前端·javascript·vue.js
qq_419854055 小时前
CSS动效
前端·javascript·css
烛阴5 小时前
3D字体TextGeometry
前端·webgl·three.js
acheding6 小时前
Vue3 + AntV/X6 自定义节点实践:组件化节点与事件联动
前端框架·vue
桜吹雪6 小时前
markstream-vue实战踩坑笔记
前端
C_心欲无痕6 小时前
nginx - 实现域名跳转的几种方式
运维·前端·nginx