一个现代化的博客应用【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
相关推荐
一颗不甘坠落的流星1 小时前
【@ebay/nice-modal-react】管理React弹窗(Modal)状态
前端·javascript·react.js
黛色正浓1 小时前
【React】极客园案例实践-Layout模块
前端·react.js·前端框架
辛-夷1 小时前
vue高频面试题
前端·vue.js
IT小哥哥呀1 小时前
《纯前端实现 Excel 导入导出:基于 SheetJS 的完整实战》
前端·excel
文心快码BaiduComate1 小时前
CCF程序员大会码力全开:AI加速营决赛入围名单揭晓,12月6日大理见!
前端·百度·程序员
vivo互联网技术1 小时前
从不足到精进:H5即开并行加载方案的演进之路
前端·h5·webview·客户端·大前端
我命由我123451 小时前
微信小程序 - 内容弹出框实现(Vant Weapp 实现、原生实现)
开发语言·前端·javascript·微信小程序·小程序·前端框架·js
裴嘉靖1 小时前
uniapp做的APP和安卓苹果做的什么区别
前端
申阳1 小时前
Day 20:开源个人项目时的一些注意事项
前端·后端·程序员