普通视图
深入理解react——3. 函数组件与useState
前两篇文章已经将fiber的基本工作原理介绍完成,接下来我们添加函数式组件与useState,将miniReact正式完结。
一,函数组件
1.1 performUnitOfWork(工作单元计算)
从前两篇文章我们知道,每个组件经过编译后其实都是一个对象,那么函数组件自然也就是执行函数 return 出来的jsx对象,所以我们对原来的performUnitOfWork做一点修改。
export function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
// 3. 返回下一个工作单元
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
基本逻辑不变,只是做一个if的判断,将函数式组件与非函数式组件分开处理。这里值得注意的地方是,函数式组件的fiber.type就是函数本身。也就是React.createElement(MyComponent, { propKey: propValue })]
function updateFunctionComponent(fiber) {
console.log("updateFunctionComponent执行");
wipFiber = fiber;
wipFiber.hooks = [];
hookIndex = 0;
// TODO 更新函数组件
const children = [fiber.type(fiber.props)]; // 执行函数组件,直到此时,函数中的setState才会被调用
reconcileChildren(fiber, children);
}
function updateHostComponent(fiber) {
// 添加节点元素到dom
// 如果没有dom属性,根据fiber新构建
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 遍历节点的children属性创建Fiber对象
const elements = fiber.props.children;
// 调和fiber对象,设置状态:添加、更新和删除
reconcileChildren(fiber, elements);
}
1.2 commitWork
另外在提交节点commitWork函数也需要做一些修改,因为函数式组件并没有对应的dom节点,所以新增和删除实际上需要向上或者向下递归寻找到真实的dom节点。
/**
* commitWork 函数
* 递归提交Fiber节点的DOM更新
* @param {Object} fiber - 当前的Fiber节点
*/
export function commitWork(fiber) {
if (!fiber) {
return;
}
// 函数组件本身没有dom属性,需要向上寻找
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom; // 获取父DOM节点
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
}
if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
}
if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
return;
}
commitWork(fiber.child); // 递归提交子节点
commitWork(fiber.sibling); // 递归提交兄弟节点
}
export function commitDeletion(fiber, domParent) {
// 找不到dom,继续向下寻找
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
至此,函数式组件就添加完成了,我们来测试一下。
function MyComponent(props) {
return React.createElement(
"div",
null,
`hellow,${props.name}`
);
}
const element = React.createElement(MyComponent, { name: "World" })
render(element, rootDOM);
![]()
二,useState
上面我们已经添加了函数组件了,那么hook自然需要提上日程,不然函数组件就没法用。我们先添加最常用的useState。
export function useState(initial) {
console.log("useState执行");
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
// 执行所有setState的回调函数
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
const setState = (action) => {
// 推入队列
hook.queue.push(action);
// 将下一次任务设为当前根fiber
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
这里有几个非常值得注意的点,我们一个一个来说:
- 首先是执行时机,useState在函数组件中执行的时机其实是函数组件本身被执行它才被执行
- 那么我们可以回去看看处理函数组件组件fiber的时候做了什么,我们有一个全局的wipFiber指向的正是当前正在计算的fiber,所以上面的wipFiber.hooks.push(hook);实际上可以简单的看出每一个函数组件的hook都是存在当前组件的fiber中
- 另外由于useState函数本身并没有带什么特殊标识,在寻找oldHook时是凭借全局的hookIndex最后再做返回
return [hook.state, setState];。这也引出一个常问的面试问题,为什么hook不能放在if条件中使用。 - 还有一个问题,useState到底是同步还是异步的,从这里可以看出,函数肯定是同步执行,但是相对于渲染是异步的,因为实际state值的更改实际上是下一次fiber计算和构建时才会触发。如果将useState放在定时器中,那么他又将是在下一次事件循环中的宏任务中去执行。(所以看博客不能知其所以然的地方,亲自写过源码后才会更加的清晰。ps:听说更加高版本的react批处理已经把定时器的情况也考虑进去了,我这里没有了解过,有知道的大佬可以评论区分享一下)
![]()
到这里为止,我们的useState就算是完成了。老规矩,搞点测试。
function MyComponent(props) {
const [count, setCount] = useState(0);
const handleClick_1 = () => {
setCount(count + 1);
setCount(count + 1);
setCount((count) => count + 1);
console.log("handleClick_1 clicked:", count);
};
return React.createElement(
"div",
null,
React.createElement("button", { onClick: handleClick_1, className: "button1" }, `Count, ${count}`),
`hellow,${props.name}`
);
}
const element = React.createElement(MyComponent, {})
render(element, rootDOM);
![]()
大功告成,完成并理解useState后再添加别的hook就会变得简单很多。
三,useEffect
既然正在兴头上,那我们就再写一个useEffect
export function useEffect(callback, deps) {
console.log("useEffect执行");
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];
const hasChanged = !oldHook || !deps || deps.some((dep, i) => dep !== oldHook.deps[i]);
const hook = {
deps,
};
if (hasChanged) {
if (oldHook && oldHook.cleanup) {
oldHook.cleanup(); // 清理上一次的副作用
}
hook.cleanup = callback(); // 执行副作用,并保存清理函数
}
wipFiber.hooks.push(hook);
hookIndex++;
}
很简单就完成了,逻辑都是一样的,我们再来测试一下
function MyComponent(props) {
const [count, setCount] = useState(0);
const handleClick_1 = () => {
setCount(count + 1);
setCount(count + 1);
setCount((count) => count + 1);
console.log("handleClick_1 clicked:", count);
};
useEffect(()=>{
console.log("我监听到count的变化了",count)
return ()=>{
console.log("我正在执行清理")
}
},[count])
return React.createElement(
"div",
null,
React.createElement("button", { onClick: handleClick_1, className: "button1" }, `Count, ${count}`),
`hellow,${props.name}`
);
}
const element = React.createElement(MyComponent, {})
render(element, rootDOM);
![]()
至此,深入理解react这个小专栏到这里就算是结束了,源码请查看:github.com/time-is-cod…
ink-markdown-es:为 Ink 打造的高性能 Markdown 渲染组件
前言
最近在用 Ink + LangChain deepagents开发一个轻量的 CLI Coding Agent,遇到了一个棘手的问题:如何在命令行中优雅地渲染 Markdown 内容?
调研了现有方案后发现,ink-markdown 虽然是大家常用的库,但:
- 我使用的 bun 来管理项目,而 ink-markdown 不支持 ES Module,无法开箱即用
- 太老了,不支持 ink 最新版本以及其依赖的 React 19
所以,我基于 marked 重新实现了一个:ink-markdown-es
特性
- 纯 ESM 支持:完全拥抱现代 JavaScript 生态
-
高性能渲染:使用
memo和useMemo优化,每个 block 独立缓存(Inspired by prompt-kit) -
灵活的自定义:支持
styles和renderers两种自定义方式 - 完整的 Markdown 支持:标题、列表、代码块、表格、引用、链接等
- TypeScript 友好:完整的类型定义
安装
npm install ink-markdown-es
# 或者
pnpm add ink-markdown-es
# 或者
bun add ink-markdown-es
基础用法
import Markdown from "ink-markdown-es";
import { render } from "ink";
const content = `
# Hello World
这是一段**加粗**和*斜体italic*文字。
## 代码示例
\`\`\`javascript
const greeting = "Hello, Ink!";
console.log(greeting);
\`\`\`
- 列表项 1
- 列表项 2
- 列表项 3
`;
render(<Markdown>{content}</Markdown>);
![]()
自定义渲染器
你可以完全自定义任意 Markdown 元素的渲染方式:
import Markdown from "ink-markdown-es";
import { Box, Text } from "ink";
render(
<Markdown
showSharp
renderers={{
h1: (text) => (
<Box padding={1} borderStyle="round" borderDimColor>
<Text bold color="greenBright">
{text}
</Text>
</Box>
),
code: (code, lang) => (
<Box borderStyle="single" padding={1}>
<Text color="yellow">{code}</Text>
</Box>
),
}}
>
{content}
</Markdown>
);
性能优化原理
在处理流式输出(如 AI 对话)时,Markdown 内容会频繁更新。传统方案每次更新都会重新渲染整个组件树,造成性能问题。
ink-markdown-es 的优化策略:
-
Block 级别缓存:使用
useMemo缓存 marked 的词法分析结果 -
Block 级别 memo:每个 Markdown block 都是独立的
memo组件 - 智能对比:只有 block 内容变化时才会触发重新渲染
在流式输出场景下,已经渲染的 block 不会重复渲染,只有新增的内容才会被处理。
Props 说明
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | string | - | Markdown 文本内容 |
| id | string | - | 组件唯一标识,适用于 AI 场景区分不同消息 |
| styles | BlockStyles | {} | 自定义样式配置 |
| renderers | BlockRenderers | {} | 自定义渲染器 |
| showSharp | boolean | false | 是否显示标题的 # 符号 |
适用场景
- CLI AI 助手:流式输出 Markdown 格式的 AI 回复
- 终端文档查看器:在命令行中渲染 README 等文档
- 交互式终端应用:任何需要展示富文本的 CLI 工具
库信息
GitHub: github.com/miownag/ink…
如果这个库对你有帮助,欢迎给个 Star 支持一下!有任何问题或建议,也欢迎在评论区交流~
React Router DOM 全面学习笔记:从原理到实战
React Router DOM 全面学习笔记:从原理到实战
在 React 生态中,react-router-dom 是实现前端路由、构建单页应用(SPA)的核心库。它解决了单页应用中页面切换、路由匹配、权限控制等关键问题,让前端开发能够脱离后端路由的强依赖,实现更流畅的用户体验。本文将从路由基础、核心用法、实战案例、原理剖析等维度,全面梳理 react-router-dom 的学习要点,结合代码示例深入讲解,助力开发者快速掌握并灵活运用。
一、前端路由的核心概念
1.1 路由的演变:从后端到前端
在前后端未分离的传统开发模式中,路由的控制权完全掌握在后端。前端仅负责页面切图与静态展示,当用户点击链接或输入 URL 时,会向服务器发送 HTTP 请求,后端根据请求路径匹配对应的资源,返回完整的 HTML 页面。这种模式存在明显弊端:每次页面切换都会重新加载整个页面,导致页面白屏、加载速度慢,用户体验较差,此时的前端开发者也被戏称为“切图仔”。
随着前后端分离架构的普及,前端技术栈日益成熟,HTML5 提供了原生的路由能力,前端路由应运而生。前端路由允许在不刷新整个页面的前提下,通过改变 URL 路径,实现页面组件的切换与内容更新。其核心逻辑是:URL 变化时,前端捕获该事件,通过路由规则匹配对应的组件,在页面中渲染新组件,从而实现“无刷新跳转”,大幅提升用户体验。
1.2 前端路由的两种实现形式
react-router-dom 提供了两种主流的路由实现方式,分别基于不同的技术原理,适用于不同场景:
1.2.1 HashRouter:基于锚点的路由
HashRouter 利用 URL 中的 锚点(#) 实现路由跳转。锚点原本用于定位页面内的元素,其特性是:改变锚点内容不会触发浏览器的页面刷新,仅会触发 hashchange 事件。HashRouter 正是借助这一特性,将路由信息存储在锚点之后,例如 http://localhost:3000/#/about。
特点:
- URL 格式带有
#,视觉上相对“丑陋”; - 兼容性极强,支持所有主流浏览器,包括低版本 IE,因为锚点是早期 HTML 就支持的特性;
- 无需后端配置,因为锚点部分不会被发送到服务器,后端无需对路由路径做额外处理。
1.2.2 BrowserRouter:基于 HTML5 History API 的路由
BrowserRouter 采用 HTML5 新增的 History API(pushState、replaceState 等方法)实现路由控制,URL 格式与后端路由一致,不包含锚点,例如 http://localhost:3000/about。History API 允许前端直接操作浏览器的历史记录栈,实现 URL 变化而不刷新页面。
特点:
- URL 格式简洁美观,与传统后端路由一致;
- 兼容性稍弱,不支持 IE11 及以下版本,但其实现的功能更符合现代前端开发需求,且目前主流浏览器(Chrome、Firefox、Edge 等)均已完美支持;
- 需要后端配合配置:当用户直接访问非根路径(如
http://localhost:3000/about)时,后端需将请求转发到根页面(index.html),否则会返回 404 错误(因为后端不存在该路由路径)。
1.3 路由别名:提升代码可读性
在实际开发中,为了简化代码并提升可读性,通常会为路由组件设置别名,使用 as 关键字实现。例如将 BrowserRouter 别名为 Router,避免在后续代码中重复书写冗长的组件名:
import { BrowserRouter as Router } from 'react-router-dom';
这样的写法不仅简洁,还能让其他开发者快速理解代码意图,尤其在多人协作项目中,统一的别名规范能提升代码可维护性。
1.4 路由与性能优化:组件懒加载
单页应用的核心优势之一是加载速度快,但如果应用规模较大,一次性加载所有页面组件会导致初始加载体积过大,影响首屏渲染速度。react-router-dom 结合 React 提供的 lazy 和 Suspense 组件,实现路由级别的组件懒加载,有效优化性能。
懒加载核心逻辑:仅当用户访问某个路由时,才加载对应的组件,而非在应用初始化时全部加载。例如:
- 用户访问根路径
/时,仅加载Home组件,About组件暂不加载; - 当用户跳转至
/about路径时,再动态加载About组件。
这种方式能显著减小应用初始加载体积,提升首屏加载速度,是大型单页应用的必备优化手段。
二、react-router-dom 核心路由类型
react-router-dom 支持多种路由类型,覆盖不同业务场景,包括普通路由、动态路由、嵌套路由等,每种路由都有其特定的使用场景和实现方式。
2.1 普通路由:基础路径匹配
普通路由是最基础的路由类型,通过固定的 URL 路径匹配对应的组件,适用于页面路径固定的场景,例如首页、关于页、联系页等。其核心是 Route 组件,通过 path 属性指定路由路径,element 属性指定对应渲染的组件。
示例代码:
import { Routes, Route } from 'react-router-dom';
import Home from '../pages/Home';
import About from '../pages/About';
function RouterConfig() {
return (
<Routes>
<Route path="/" element={<Home />} /> {/* 根路径匹配 Home 组件 */}
<Route path="/about" element={<About />} /> {/* /about 路径匹配 About 组件 */}
</Routes>
);
}
注意:Routes 组件用于包裹一组 Route 组件,相当于路由容器,确保路由规则有序匹配,每次仅渲染匹配到的第一个路由组件。
2.2 动态路由:路径参数传递
在实际业务中,很多页面路径并非固定,例如商品详情页、用户个人中心等,需要根据不同的 ID 展示不同内容。此时就需要使用动态路由,通过在路径中定义参数占位符(/:参数名),实现路径参数的传递与接收。
动态路由的路径格式通常为 /product/:id、/user/:userId,其中 :id、:userId 为参数占位符,代表可变的参数值。
2.2.1 动态路由定义
<Routes>
{/* 动态路由:匹配 /user/123、/user/456 等路径 */}
<Route path="/user/:id" element={<UserProfile />} />
{/* 商品详情动态路由:匹配 /products/789 等路径 */}
<Route path="/products/:productId" element={<ProductDetail />} />
</Routes>
2.2.2 路径参数接收
在目标组件中,可通过 react-router-dom 提供的 useParams 钩子函数获取动态路由传递的参数。useParams 返回一个对象,键为参数占位符名称,值为 URL 中的实际参数值。
示例(UserProfile 组件):
import { useParams } from 'react-router-dom';
export default function UserProfile() {
// 获取动态路由参数 id
const { id } = useParams();
return (
<div>
<h1>用户个人中心</h1>
<p>用户 ID:{id}</p>
</div>
);
}
示例(ProductDetail 组件):
import { useParams } from 'react-router-dom';
export default function ProductDetail() {
// 获取商品 ID 参数
const { productId } = useParams();
return (
<div>
<h1>商品详情</h1>
<p>商品 ID:{productId}</p>
</div>
);
}
注意:动态路由参数仅能传递简单的字符串类型数据,若需传递复杂数据,可结合查询参数(Query String)或状态管理工具(如 Redux)实现。
2.3 通配路由:404 页面匹配
通配路由使用 * 作为路径匹配规则,可匹配所有未被前面路由规则匹配到的路径,主要用于实现 404 页面(页面不存在提示)。
核心规则:通配路由必须放在所有路由规则的最后,因为路由匹配遵循“自上而下”的顺序,若放在前面,会优先匹配所有路径,导致其他路由失效。
示例代码:
import NotFound from '../pages/NotFound';
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* 通配路由:匹配所有未被匹配的路径,渲染 404 组件 */}
<Route path="*" element={<NotFound />} />
</Routes>
404 组件可结合 useNavigate 钩子实现自动跳转功能,例如 6 秒后自动返回首页:
import { useNavigate, useEffect } from 'react-router-dom';
const NotFound = () => {
const navigate = useNavigate();
useEffect(() => {
// 6 秒后自动跳转到首页
const timer = setTimeout(() => {
navigate('/');
}, 6000);
// 清除定时器,避免内存泄漏
return () => clearTimeout(timer);
}, [navigate]);
return (
<div>
<h1>404 Not Found</h1>
<p>页面不存在,6 秒后自动返回首页...</p>
</div>
);
};
export default NotFound;
2.4 嵌套路由:页面结构复用
在复杂应用中,页面通常存在公共结构(如侧边栏、导航栏、页脚),嵌套路由可实现公共结构的复用,同时在公共结构中渲染不同的子路由内容。react-router-dom 中,嵌套路由通过 Outlet 组件实现子路由内容的渲染。
2.4.1 嵌套路由定义
嵌套路由的核心是在父路由中通过 children 属性定义子路由,父路由组件中通过 Outlet 组件指定子路由内容的渲染位置。
示例(产品模块嵌套路由):
import { Routes, Route, Outlet } from 'react-router-dom';
import Product from '../pages/Product';
import ProductDetail from '../pages/Product/ProductDetail';
import NewProduct from '../pages/Product/NewProduct';
function RouterConfig() {
return (
<Routes>
{/* 父路由:产品列表页 */}
<Route path="/products" element={<Product />}>
{/* 子路由:商品详情页,路径为 /products/:productId */}
<Route path=":productId" element={<ProductDetail />} />
{/* 子路由:新增商品页,路径为 /products/new */}
<Route path="new" element={<NewProduct />} />
</Route>
</Routes>
);
}
注意:子路由的 path 属性无需添加前缀 /,否则会被解析为绝对路径,脱离父路由的嵌套关系。例如子路由 path="new" 对应绝对路径 /products/new,若写为 path="/new" 则对应绝对路径 /new。
2.4.2 Outlet 组件使用
Outlet 是 react-router-dom 提供的内置组件,用于在父路由组件中预留子路由内容的渲染位置。当用户访问子路由路径时,对应的子路由组件会自动渲染到 Outlet 所在位置。
示例(Product 父组件):
import { Outlet } from 'react-router-dom';
export default function Product() {
return (
<div>
<h1>产品列表</h1>
<div className="product-container">
{/* 侧边栏:公共结构 */}
<aside>
<ul>
<li>商品分类 1</li>
<li>商品分类 2</li>
</ul>
</aside>
{/* 子路由内容渲染位置 */}
<main><Outlet /></main>
</div>
</div>
);
}
当用户访问 /products/123 时,Product 组件的侧边栏会保持不变,main 区域会渲染 ProductDetail 组件;访问 /products/new 时,main 区域会渲染 NewProduct 组件,实现公共结构复用与子内容动态切换。
2.5 鉴权路由:路由守卫实现
在实际应用中,部分页面需要用户登录后才能访问(如个人中心、支付页面),鉴权路由(也称路由守卫)用于控制路由的访问权限,未登录用户访问时会自动跳转到登录页。react-router-dom 中可通过自定义 ProtectRoute 组件实现鉴权逻辑。
2.5.1 鉴权组件实现
自定义 ProtectRoute 组件,通过 children 属性接收需要鉴权的组件,判断用户登录状态(可通过 localStorage、sessionStorage 或状态管理工具存储登录状态),未登录则通过 Navigate 组件跳转到登录页,已登录则渲染目标组件。
import { Navigate } from 'react-router-dom';
// 鉴权路由组件
export default function ProtectRoute({ children }) {
// 从 localStorage 获取登录状态(登录成功时设置 localStorage.setItem('isLogin', 'true'))
const isLoggedIn = localStorage.getItem('isLogin') === 'true';
// 未登录:跳转到登录页
if (!isLoggedIn) {
return <Navigate to="/login" />;
}
// 已登录:渲染目标组件
return <div>{children}</div>;
}
2.5.2 鉴权路由使用
在路由配置中,将需要鉴权的路由组件用 ProtectRoute 包裹,即可实现权限控制。
import ProtectRoute from '../components/ProtectRoute';
import Pay from '../pages/Pay';
import Login from '../pages/Login';
<Routes>
<Route path="/login" element={<Login />} />
{/* 支付页面需要鉴权 */}
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
} />
</Routes>
扩展:鉴权逻辑可根据业务需求升级,例如区分普通用户与管理员权限,不同角色展示不同路由;或结合接口请求验证 Token 有效性,实现更严谨的权限控制。
2.6 重定向路由:路径跳转
重定向路由用于将一个路径自动跳转到另一个路径,例如旧路径废弃后,将用户访问旧路径时重定向到新路径。react-router-dom v6 中,Redirect 组件已被 Navigate 组件替代,Navigate 组件通过 to 属性指定目标路径,replace 属性控制跳转方式。
2.6.1 基础重定向
示例:将 /old-path 重定向到 /new-path:
import { Navigate } from 'react-router-dom';
<Routes>
{/* 重定向:访问 /old-path 自动跳转到 /new-path */}
<Route path="/old-path" element={<Navigate to="/new-path" />} />
<Route path="/new-path" element={<NewPath />} />
</Routes>
2.6.2 replace 跳转模式
Navigate 组件默认使用 push 模式跳转,会在浏览器历史记录栈中添加新记录,用户点击后退按钮可返回上一页;若添加 replace 属性,则使用 replace 模式跳转,会替换当前历史记录栈中的内容,不会留下跳转痕迹,用户点击后退按钮无法返回上一页。
// replace 模式重定向,替换当前历史记录
<Route path="/old-path" element={<Navigate replace to="/new-path" />} />
使用场景:登录页跳转至首页时,通常使用 replace 模式,避免用户点击后退按钮重新回到登录页;普通页面跳转则使用默认的 push 模式。
三、路由历史记录与跳转控制
3.1 历史记录栈结构
浏览器的历史记录采用 栈结构 存储,遵循“先进后出”的原则。当用户通过路由跳转时,本质上是对历史记录栈进行操作:
-
push跳转:向栈中添加一条新的历史记录,栈长度加 1; -
replace跳转:替换栈顶的历史记录,栈长度不变; - 后退操作:弹出栈顶的历史记录,栈长度减 1,页面回到上一个路径。
react-router-dom 中的 Navigate 组件、useNavigate 钩子均基于此栈结构实现跳转控制。
3.2 useNavigate 钩子:编程式跳转
除了通过 Link 组件实现声明式跳转,react-router-dom 还提供 useNavigate 钩子,用于在组件逻辑中实现编程式跳转(如按钮点击后跳转、接口请求成功后跳转等)。
3.2.1 基础用法
import { useNavigate } from 'react-router-dom';
function Login() {
const navigate = useNavigate();
const handleLogin = () => {
// 模拟登录接口请求成功
const loginSuccess = true;
if (loginSuccess) {
// 存储登录状态
localStorage.setItem('isLogin', 'true');
// 跳转到首页(push 模式)
navigate('/');
// 若使用 replace 模式跳转:navigate('/', { replace: true });
}
};
return (
<div>
<h1>登录页</h1>
<button onClick={handleLogin}>登录</button>
</div>
);
}
3.2.2 后退与前进操作
useNavigate 还支持通过传递数字参数实现后退、前进操作,正数表示前进,负数表示后退:
// 后退一页(相当于浏览器的后退按钮)
navigate(-1);
// 前进一页(相当于浏览器的前进按钮)
navigate(1);
// 后退两页
navigate(-2);
四、单页应用与路由集成实战
4.1 单页应用(SPA)的核心优势
单页应用(Single Page Application,SPA)是基于前端路由实现的一种应用架构,其核心特点是整个应用仅加载一个 HTML 页面,后续页面切换均通过前端路由控制,无需重新请求服务器。
与传统多页应用的对比:
- 传统多页应用:每次 URL 变化都会向服务器发送 HTTP 请求,加载完整 HTML 页面,页面会出现白屏、加载动画,用户体验较差;
- 单页应用:仅初始加载一次 HTML、CSS、JS 资源,后续路由变化时,前端通过捕获 URL 变化事件,匹配对应的组件并渲染,无页面刷新,加载速度快,用户体验流畅。
react-router-dom 是构建 React 单页应用的核心工具,结合 HTML5 History API 实现前端路由控制,完美支撑单页应用的页面切换需求。
4.2 完整路由集成案例
以下结合前文知识点,实现一个完整的 React 单页应用路由集成案例,包含路由配置、组件懒加载、导航栏、鉴权控制、加载动画等功能。
4.2.1 项目结构
src/
├── components/ // 公共组件
│ ├── Navigation.js // 导航栏组件
│ ├── ProtectRoute.js // 鉴权路由组件
│ └── LoadingFallback.js // 加载动画组件
├── pages/ // 页面组件
│ ├── Home.js // 首页
│ ├── About.js // 关于页
│ ├── Login.js // 登录页
│ ├── Pay.js // 支付页(需鉴权)
│ ├── NotFound.js // 404 页面
│ └── Product/ // 产品模块
│ ├── Product.js // 产品列表页(父路由)
│ ├── ProductDetail.js // 商品详情页(子路由)
│ └── NewProduct.js // 新增商品页(子路由)
├── router/ // 路由配置
│ └── index.js // 路由配置文件
├── App.js // 根组件
└── index.js // 入口文件
4.2.2 加载动画组件(LoadingFallback.js)
用于懒加载组件加载过程中的占位展示,结合 CSS 动画实现旋转加载效果:
import styles from './index.module.css';
export default function LoadingFallback() {
return (
<div className={styles.container}>
<div className={styles.spinner}>
<div className={styles.circle}></div>
<div className={`${styles.circle} ${styles.inner}`}></div>
</div>
<p className={styles.text}>Loading...</p>
</div>
);
}
对应的 CSS 样式(index.module.css):
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background-color: rgba(255, 255, 255, 0.9);
}
.spinner {
position: relative;
width: 60px;
height: 60px;
}
.circle {
position: absolute;
width: 100%;
height: 100%;
border: 4px solid transparent;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.circle.inner {
width: 70%;
height: 70%;
top: 15%;
left: 15%;
border-top-color: #e74c3c;
animation: spin 0.8s linear infinite reverse;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.text {
margin-top: 20px;
color: #2c3e50;
font-size: 18px;
font-weight: 500;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
4.2.3 导航栏组件(Navigation.js)
实现页面导航功能,通过 Link 组件实现声明式跳转,并通过 useResolvedPath、useMatch 钩子实现导航高亮效果:
import { Link, useResolvedPath, useMatch } from 'react-router-dom';
export default function Navigation() {
// 导航高亮逻辑
const isActive = (to) => {
const resolvedPath = useResolvedPath(to);
const match = useMatch({
path: resolvedPath.pathname,
end: true, // 完全匹配路径
});
return match ? 'active' : '';
};
return (
<nav style={{ background: '#f5f5f5', padding: '10px' }}>
<ul style={{ listStyle: 'none', display: 'flex', gap: '20px', margin: 0, padding: 0 }}>
<li>
<Link to="/" className={isActive('/')} style={{ textDecoration: 'none' }}>
首页
</Link>
</li>
<li>
<Link to="/about" className={isActive('/about')} style={{ textDecoration: 'none' }}>
关于我们
</Link>
</li>
<li>
<Link to="/products" className={isActive('/products')} style={{ textDecoration: 'none' }}>
产品列表
</Link>
</li>
<li>
<Link to="/products/new" className={isActive('/products/new')} style={{ textDecoration: 'none' }}>
新增商品
</Link>
</li>
<li>
<Link to="/pay" className={isActive('/pay')} style={{ textDecoration: 'none' }}>
支付中心
</Link>
</li>
<li>
<Link to="/old-path" className={isActive('/old-path')} style={{ textDecoration: 'none' }}>
旧路径(测试重定向)
</Link>
</li>
</ul>
</nav>
);
}
说明:end: true 表示完全匹配路径,例如 /products 不会匹配 /products/123,确保导航高亮的准确性;active 类名可结合 CSS 样式实现高亮效果(如改变文字颜色、添加下划线)。
4.2.4 路由配置文件(router/index.js)
结合组件懒加载、嵌套路由、鉴权路由、重定向等功能,统一配置所有路由规则:
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import LoadingFallback from '../components/LoadingFallback';
import ProtectRoute from '../components/ProtectRoute';
// 懒加载页面组件
const Home = lazy(() => import('../pages/Home'));
const About = lazy(() => import('../pages/About'));
const Login = lazy(() => import('../pages/Login'));
const Pay = lazy(() => import('../pages/Pay'));
const NotFound = lazy(() => import('../pages/NotFound'));
const Product = lazy(() => import('../pages/Product/Product'));
const ProductDetail = lazy(() => import('../pages/Product/ProductDetail'));
const NewProduct = lazy(() => import('../pages/Product/NewProduct'));
const NewPath = lazy(() => import('../pages/NewPath'));
export default function RouterConfig() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* 普通路由 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/login" element={<Login />} />
{/* 动态路由 + 嵌套路由 */}
<Route path="/products" element={<Product />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
{/* 鉴权路由 */}
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
} />
{/* 重定向路由 */}
<Route path="/old-path" element={<Navigate replace to="/new-path" />} />
<Route path="/new-path" element={<NewPath />} />
{/* 通配路由(404) */}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
);
}
说明:Suspense 组件包裹所有路由,fallback 属性指定懒加载组件加载时的占位内容(加载动画);路由规则按“普通路由 → 嵌套路由 → 鉴权路由 → 重定向路由 → 通配路由”的顺序排列,确保匹配逻辑正确。
4.2.5 根组件(App.js)
集成路由容器与导航栏,作为应用的入口组件:
import { BrowserRouter as Router } from 'react-router-dom';
import Navigation from './components/Navigation';
import RouterConfig from './router';
export default function App() {
return (
<Router>
{/* 导航栏:全局公共组件 */}
<Navigation />
{/* 路由配置 */}
<RouterConfig />
</Router>
);
}
4.2.6 页面组件示例(Home.js、About.js)
// Home.js
export default function Home() {
console.log('首页组件加载');
return (
<div style={{ padding: '20px' }}>
<h1>首页</h1>
<p>欢迎访问 React 路由实战应用!</p>
</div>
);
}
// About.js
console.log('About 组件加载日志');
export default function About() {
console.log('About 组件渲染');
return (
<div style={{ padding: '20px' }}>
<h1>关于我们</h1>
<p>这是一个基于 react-router-dom 构建的单页应用示例。</p>
</div>
);
}
4.3 实战注意事项
- 路由匹配顺序:
Routes组件会自上而下匹配路由,通配路由必须放在最后,否则会覆盖其他路由; - 懒加载优化:仅对非首屏组件使用懒加载,首屏组件(如 Home)建议直接加载,避免首屏加载过慢;
- 后端配置:使用 BrowserRouter 时,后端需配置路由转发,确保所有路径都指向 index.html,避免 404 错误;
- 内存泄漏防范:使用
useEffect实现自动跳转、定时器等功能时,需清除副作用(如定时器、事件监听); - 导航高亮:
useMatch钩子的end属性需根据需求合理设置,避免高亮错误。
五、react-router-dom 核心原理剖析
5.1 路由匹配原理
react-router-dom 的路由匹配核心是“路径与组件的映射关系”,其流程如下:
- 用户改变 URL(通过点击 Link 组件、编程式跳转或手动输入 URL);
- 路由容器(Router)捕获 URL 变化事件(HashRouter 监听
hashchange事件,BrowserRouter 监听popstate事件); -
Routes组件遍历所有Route子组件,根据path属性匹配当前 URL 路径; - 匹配成功后,渲染对应
Route组件的element属性内容;若未匹配到任何路由,则渲染通配路由(若存在)。
5.2 历史记录管理原理
BrowserRouter 基于 HTML5 History API 实现历史记录管理,核心 API 包括:
-
history.pushState(state, title, url):向历史记录栈添加一条新记录,改变 URL 但不刷新页面; -
history.replaceState(state, title, url):替换当前历史记录栈顶内容,改变 URL 但不刷新页面; -
popstate事件:当用户点击后退、前进按钮或调用history.back()、history.forward()时触发,路由容器通过监听该事件更新组件渲染。
HashRouter 则通过监听 hashchange 事件捕获锚点变化,无需依赖 History API,兼容性更强,但 URL 格式不够美观。
5.3 嵌套路由实现原理
嵌套路由的核心是 Outlet 组件,其原理的本质是“父子路由的路径拼接与内容分发”:
- 父路由的
path与子路由的path自动拼接,形成完整的 URL 路径(如父路由/products+ 子路由:productId→/products/:productId); - 当用户访问子路由路径时,路由系统同时匹配父路由与子路由;
- 父路由组件渲染时,
Outlet组件会接收路由系统传递的子路由组件,将其渲染到指定位置,实现父子路由内容的联动展示。
六、常见问题与解决方案
6.1 路由跳转后页面不刷新
问题原因:单页应用路由跳转本质是组件切换,而非页面刷新,若组件依赖 URL 参数或状态更新,可能因未重新获取数据导致页面内容未更新。
解决方案:
- 使用
useEffect监听 URL 参数变化,参数变化时重新请求数据:
import { useParams, useEffect } from 'react-router-dom';
function ProductDetail() {
const { productId } = useParams();
useEffect(() => {
// 监听 productId 变化,重新请求商品详情数据
fetchProductDetail(productId);
}, [productId]);
// ...
}
6.2 BrowserRouter 部署后刷新 404
问题原因:BrowserRouter 的 URL 路径与后端路由冲突,用户直接访问非根路径时,后端无法匹配该路径,返回 404 错误。
解决方案:
- Nginx 配置:在 Nginx 配置文件中添加路由转发规则,将所有请求转发到 index.html:
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html; # 路由转发
}
- Apache 配置:修改 .htaccess 文件,添加重写规则;
- 开发环境:使用
create-react-app时,开发服务器已默认配置转发,无需额外处理。
6.3 懒加载组件加载失败
问题原因:组件路径错误、网络异常或 Suspense 组件使用不当。
解决方案:
- 检查懒加载组件的导入路径是否正确,确保路径与文件位置一致;
- 确保
Suspense组件包裹懒加载组件,且fallback属性设置有效; - 添加错误边界组件(Error Boundary),捕获懒加载失败错误,提升用户体验。
6.4 导航高亮不准确
问题原因:useMatch 钩子的 end 属性设置不当,或路由路径重叠。
解决方案:
- 精确匹配路径时设置
end: true,模糊匹配时省略该属性; - 避免路由路径重叠,例如
/products与/products/new需确保父路由不设置end: true,子路由正常匹配。
七、总结与扩展
7.1 核心知识点总结
react-router-dom 是 React 单页应用的核心路由库,其核心知识点可概括为:
- 两种路由模式:HashRouter(兼容性强)与 BrowserRouter(URL 美观,需后端配置);
- 六大路由类型:普通路由、动态路由、通配路由、嵌套路由、鉴权路由、重定向路由;
- 核心 API 与钩子:
Routes、Route、Link、Navigate、Outlet、useParams、useNavigate、useMatch等; - 性能优化:组件懒加载(
lazy+Suspense); - 实战要点:路由匹配顺序、后端配置、内存泄漏防范、导航高亮控制。
7.2 扩展学习方向
掌握基础用法后,可进一步学习以下内容,提升路由使用能力:
- 路由状态管理:结合 Redux、React Context 实现路由状态全局共享;
- 高级鉴权逻辑:基于角色的访问控制(RBAC)、Token 过期自动跳转;
- 路由动画:结合 React Transition Group 实现页面切换动画;
- 多语言路由:实现国际化路由(如
/en/about、/zh/about); - react-router-dom v6 新特性:对比 v5 版本的差异(如
Routes替代Switch、Navigate替代Redirect等)。
react-router-dom 是 React 开发的必备技能之一,熟练掌握其用法与原理,能有效提升单页应用的开发效率与用户体验。在实际开发中,需结合业务场景灵活选择路由模式与实现方案,不断优化路由配置与性能,构建高质量的 React 应用。
React从入门到出门第十章 Fiber 架构升级与调度系统优化
大家好~ 相信很多 React 开发者都有过这样的困惑:为什么我的组件明明只改了一个状态,却感觉页面卡顿?为什么有时候异步更新的顺序和预期不一样?其实这些问题的根源,都和 React 的底层架构——Fiber,以及它的调度系统密切相关。
从 React 16 引入 Fiber 架构至今,它经历了多次迭代优化,而 React 19 更是在 Fiber 架构和调度系统上做了不少关键升级,进一步提升了应用的流畅度和性能。今天这篇文章,我们就用“浅显易懂的语言+丰富的案例+直观的图例”,把 React 19 的 Fiber 架构和调度系统讲透:从 Fiber 解决的核心问题,到它的升级点,再到调度系统如何智能分配任务,让你不仅知其然,更知其所以然~
一、先搞懂:为什么需要 Fiber 架构?
在聊 React 19 的升级之前,我们得先明白:Fiber 架构是为了解决什么问题而诞生的?这就要从 React 15 及之前的 Stack Reconciliation(栈协调)说起。
1. 旧架构的痛点:不可中断的“长任务”
React 15 的栈协调机制,本质上是一个“递归递归过程”。当组件树需要更新时(比如状态变化、props 改变),React 会从根组件开始,递归遍历整个组件树,进行“虚拟 DOM 对比”(Reconciliation)和“DOM 操作”。这个过程有个致命问题:一旦开始,就无法中断。
浏览器的主线程是“单线程”,既要处理 JS 执行,也要处理 UI 渲染(重排、重绘)、用户交互(点击、输入)等任务。如果递归遍历的组件树很深、任务很重,这个“长任务”会占据主线程很长时间(比如几百毫秒),导致浏览器无法及时响应用户操作,出现页面卡顿、输入延迟等问题。
2. 案例:栈协调的卡顿问题
假设我们有一个嵌套很深的组件树:
// 嵌套很深的组件树
function App() {
const [count, setCount] = useState(0);
return (
<div onClick={() => setCount(count + 1)}>
<Level1 />
<Level2 />
{/* ... 嵌套 100 层 Level 组件 ... */}
<Level100 />
<p>计数:{count}</p>
</div>
);
}
当我们点击页面修改 count 时,React 15 会递归遍历这 100 层组件,进行虚拟 DOM 对比。这个过程会占据主线程几十甚至几百毫秒,期间用户如果再点击、输入,浏览器完全无法响应,出现明显卡顿。
3. Fiber 的核心目标:让任务“可中断、可恢复”
为了解决这个问题,React 16 引入了 Fiber 架构,核心目标是:将不可中断的递归遍历,拆分成可中断、可恢复的小任务。通过这种方式,React 可以在执行这些小任务的间隙,“还给”浏览器主线程时间,让浏览器有机会处理用户交互、UI 渲染等紧急任务,从而避免卡顿。
这就像我们平时工作:旧架构是“一口气做完一整套复杂工作,中间不休息”,容易累倒且无法响应突发情况;Fiber 架构是“把复杂工作拆成一个个小任务,做一个小任务就看看有没有紧急事,有就先处理紧急事,处理完再继续做剩下的小任务”,效率更高、更灵活。
二、React 19 Fiber 架构:核心升级点拆解
Fiber 架构的核心是“Fiber 节点”和“双缓存机制”。React 19 在继承这一核心的基础上,做了三个关键升级:优化 Fiber 节点结构、增强任务优先级区分、优化双缓存切换效率。我们先从最基础的 Fiber 节点开始讲起。
1. 基础:Fiber 节点是什么?
在 Fiber 架构中,每个组件都会对应一个“Fiber 节点”。Fiber 节点不仅存储了组件的类型、属性(props)、状态(state)等信息,更重要的是,它还存储了“任务调度相关的信息”,比如:
- 当前任务的优先级;
- 下一个要处理的 Fiber 节点(用于链表遍历,替代递归);
- 任务是否已完成、是否需要中断;
- 对应的 DOM 元素。
可以把 Fiber 节点理解为“组件的任务说明书”,它让 React 不仅知道“这个组件是什么”,还知道“该怎么处理这个组件的更新任务”。
2. React 19 Fiber 节点结构优化(简化代码模拟)
我们用简化的 JS 代码,模拟 React 19 中 Fiber 节点的核心结构(真实结构更复杂,这里只保留关键字段):
// React 19 Fiber 节点结构(简化版)
class FiberNode {
constructor(type, props) {
this.type = type; // 组件类型(如 'div'、FunctionComponent)
this.props = props; // 组件属性
this.state = null; // 组件状态
this.dom = null; // 对应的真实 DOM 元素
// 调度相关字段(React 19 优化点)
this.priority = 0; // 任务优先级(1-5,数字越大优先级越高)
this.deferredExpirationTime = null; // 延迟过期时间(用于低优先级任务)
// 链表结构相关字段(替代递归,实现可中断遍历)
this.child = null; // 第一个子 Fiber 节点
this.sibling = null; // 下一个兄弟 Fiber 节点
this.return = null; // 父 Fiber 节点
// 双缓存相关字段
this.alternate = null; // 对应的另一个 Fiber 树节点
this.effectTag = null; // 需要执行的 DOM 操作(如插入、更新、删除)
}
}
React 19 的核心优化点之一,就是精简了 Fiber 节点的冗余字段,同时增强了优先级相关字段的精度。比如新增的 deferredExpirationTime 字段,可以更精准地控制低优先级任务的执行时机,避免低优先级任务“饿死”(一直得不到执行)。
3. 核心:链表遍历替代递归(可中断的关键)
React 15 用递归遍历组件树,而 React 19 基于 Fiber 节点的链表结构,用“循环遍历”替代了递归。这种遍历方式的核心是“从根节点开始,依次处理每个 Fiber 节点,处理完一个节点后,记录下一个要处理的节点,随时可以中断”。
我们用简化代码模拟这个遍历过程:
// 模拟 React 19 Fiber 树遍历(循环遍历,可中断)
function traverseFiberTree(rootFiber) {
let currentFiber = rootFiber;
// 循环遍历,替代递归
while (currentFiber !== null) {
// 1. 处理当前 Fiber 节点(比如虚拟 DOM 对比、计算需要的 DOM 操作)
processFiber(currentFiber);
// 2. 检查是否需要中断(比如有更高优先级任务进来)
if (shouldYield()) {
// 记录当前进度,下次从这里继续
nextUnitOfWork = currentFiber;
return; // 中断遍历
}
// 3. 确定下一个要处理的节点(链表遍历逻辑)
if (currentFiber.child) {
// 有子节点,先处理子节点
currentFiber = currentFiber.child;
} else if (currentFiber.sibling) {
// 没有子节点,处理兄弟节点
currentFiber = currentFiber.sibling;
} else {
// 既没有子节点也没有兄弟节点,回溯到父节点的兄弟节点
while (currentFiber.return && !currentFiber.return.sibling) {
currentFiber = currentFiber.return;
}
currentFiber = currentFiber.return ? currentFiber.return.sibling : null;
}
}
// 所有节点处理完毕,进入提交阶段(执行 DOM 操作)
commitRoot();
}
关键逻辑说明:
-
processFiber:处理当前节点的核心逻辑(虚拟 DOM 对比、标记 DOM 操作); -
shouldYield:检查是否需要中断——React 会通过浏览器的requestIdleCallback或MessageChannelAPI,判断主线程是否有空闲时间,或者是否有更高优先级任务进来; - 链表遍历顺序:父 → 子 → 兄弟 → 回溯父节点的兄弟,确保遍历覆盖所有节点。
4. 双缓存机制:提升渲染效率(React 19 优化点)
Fiber 架构的另一个核心是“双缓存机制”,简单说就是:React 维护了两棵 Fiber 树——当前树(Current Tree) 和 工作树(WorkInProgress Tree) 。
- 当前树:对应当前页面渲染的 DOM 结构,存储着当前的组件状态和 DOM 信息;
- 工作树:是 React 在后台构建的“备用树”,所有的更新任务(虚拟 DOM 对比、计算 DOM 操作)都在工作树上进行,不会影响当前页面的渲染。
当工作树上的所有任务都处理完毕后,React 会快速“切换”两棵树的角色——让工作树变成新的当前树,当前树变成下一次更新的工作树。这个切换过程非常快,因为它只需要修改一个“根节点指针”,不需要重新创建整个 DOM 树。
React 19 对双缓存的优化点
React 19 主要优化了“工作树构建效率”和“切换时机”:
- 复用 Fiber 节点:对于没有变化的组件,React 19 会直接复用当前树的 Fiber 节点到工作树,避免重复创建节点,减少内存开销;
- 延迟切换:如果工作树构建过程中遇到高优先级任务,React 19 会延迟切换树的时机,先处理高优先级任务,确保用户交互更流畅。
双缓存机制流程图
三、React 19 调度系统:智能分配任务,避免卡顿
有了可中断的 Fiber 架构,还需要一个“智能调度系统”来决定:哪个任务先执行?什么时候执行?什么时候中断当前任务? React 19 的调度系统基于“优先级队列”和“浏览器主线程空闲检测”,实现了高效的任务分配。
1. 核心:任务优先级分级(React 19 增强版)
React 19 对任务优先级进行了更精细的分级,确保“紧急任务先执行,非紧急任务延后执行”。核心优先级分为 5 级(从高到低):
- Immediate(立即优先级) :最紧急的任务,必须立即执行,不能中断(比如用户输入、点击事件的同步响应);
- UserBlocking(用户阻塞优先级) :影响用户交互的任务,需要尽快执行(比如表单输入后的状态更新、按钮点击后的页面反馈);
- Normal(正常优先级) :普通任务,可延迟执行,但不能太久(比如普通的状态更新);
- Low(低优先级) :低优先级任务,可长时间延迟(比如列表滚动时的非关键更新);
- Idle(空闲优先级) :最不重要的任务,只有当主线程完全空闲时才执行(比如日志上报、非关键数据统计)。
2. 案例:优先级调度的实际效果
假设我们有两个任务同时触发:
- 任务 A:用户输入框输入文字(UserBlocking 优先级);
- 任务 B:页面底部列表的非关键数据更新(Low 优先级)。
如果没有调度系统,两个任务可能会并行执行,导致输入延迟。而 React 19 的调度系统会:
- 优先执行任务 A(UserBlocking 优先级),确保用户输入流畅;
- 任务 A 执行完成后,检查主线程是否有空闲时间;
- 如果有空闲时间,再执行任务 B(Low 优先级);如果期间又有紧急任务进来,暂停任务 B,先处理紧急任务。
3. 底层实现:如何检测主线程空闲?
React 19 调度系统的核心,是准确判断“主线程是否有空闲时间”,从而决定是否执行低优先级任务、是否中断当前任务。它主要依赖两个浏览器 API:
-
MessageChannel:用于实现“微任务级别的延迟执行”,替代了早期的
requestIdleCallback(requestIdleCallback有兼容性问题,且延迟时间不精准); - performance.now() :用于精确计算任务执行时间,判断当前任务是否执行过久,是否需要中断。
简化代码模拟调度系统的空闲检测逻辑:
// 模拟 React 19 调度系统的空闲检测
class Scheduler {
constructor() {
this.priorityQueue = []; // 优先级队列
this.isRunning = false; // 是否正在执行任务
// 使用 MessageChannel 实现精准延迟
const channel = new MessageChannel();
this.port1 = channel.port1;
this.port2 = channel.port2;
this.port1.onmessage = this.executeTask.bind(this);
}
// 添加任务到优先级队列
scheduleTask(task, priority) {
this.priorityQueue.push({ task, priority });
// 按优先级排序(从高到低)
this.priorityQueue.sort((a, b) => b.priority - a.priority);
// 如果没有正在执行的任务,触发任务执行
if (!this.isRunning) {
this.port2.postMessage('execute');
}
}
// 执行任务
executeTask() {
this.isRunning = true;
const currentTask = this.priorityQueue.shift();
if (!currentTask) {
this.isRunning = false;
return;
}
// 记录任务开始时间
const startTime = performance.now();
try {
// 执行任务(这里的 task 就是 Fiber 树的遍历任务)
currentTask.task();
} catch (error) {
console.error('任务执行失败:', error);
}
// 检查任务执行时间是否过长(超过 5ms 认为是长任务,需要中断)
const executionTime = performance.now() - startTime;
if (executionTime > 5) {
// 有未完成的任务,下次继续执行
this.port2.postMessage('execute');
} else {
// 任务执行完成,继续执行下一个任务
this.executeTask();
}
}
// 检查是否需要中断当前任务(供 Fiber 遍历调用)
shouldYield() {
// 计算当前主线程是否有空闲时间(简化逻辑)
const currentTime = performance.now();
// 假设 16ms 是一帧的时间(浏览器每秒约 60 帧),超过 12ms 认为没有空闲时间
return currentTime - this.startTime > 12;
}
}
关键逻辑说明:
- 任务添加后,会按优先级排序,确保高优先级任务先执行;
- 用
MessageChannel触发任务执行,避免阻塞主线程; - 通过
performance.now()计算任务执行时间,超过阈值(比如 5ms)就中断,下次再继续,避免长时间占据主线程。
4. React 19 调度系统的优化点
React 19 在调度系统上的核心优化的点:
- 优先级预测:根据历史任务执行情况,预测下一个可能的高优先级任务,提前预留主线程时间;
- 任务合并:将短时间内触发的多个相同优先级的任务合并为一个,减少重复计算;
- 低优先级任务防饿死:为低优先级任务设置“过期时间”,如果超过过期时间还没执行,自动提升优先级,确保任务最终能执行。
四、React 19 完整更新流程:Fiber + 调度协同工作
了解了 Fiber 架构和调度系统的核心后,我们把它们结合起来,看看 React 19 处理一次状态更新的完整流程。用流程图和步骤说明,让整个逻辑更清晰。
完整流程流程图
案例:React 19 处理一次点击更新的完整过程
我们以“点击按钮修改 count 状态”为例,拆解整个流程:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
点击计数:{count}
</button>
);
}
- 用户点击按钮,触发 onClick 事件,调用 setCount(1);
- 调度系统创建更新任务,判断该任务是“用户阻塞优先级”(UserBlocking),加入优先级队列;
- 调度系统发现当前没有正在执行的任务,触发任务执行;
- Fiber 架构基于当前 Fiber 树(count=0)创建工作树,遍历 Counter 组件对应的 Fiber 节点;
- 处理 Counter 节点:对比虚拟 DOM(发现 count 从 0 变为 1),标记“更新文本内容”的 DOM 操作;
- 检查中断:任务执行时间很短(不足 1ms),不需要中断;
- 工作树构建完成,切换当前树和工作树的角色;
- 提交阶段:执行 DOM 操作,将按钮文本从“点击计数:0”更新为“点击计数:1”;
- 任务完成,调度系统检查优先级队列,没有其他任务,结束流程。
五、实战避坑:基于 Fiber 架构的性能优化建议
了解了 React 19 的底层原理后,我们可以针对性地做一些性能优化,避免踩坑。核心优化思路是“减少不必要的任务、降低任务优先级、避免长时间占用主线程”。
1. 避免不必要的渲染(减少 Fiber 树遍历范围)
Fiber 树遍历的节点越多,任务耗时越长。我们可以通过以下方式减少不必要的渲染:
- 使用
React.memo缓存组件:对于 props 没有变化的组件,避免重新渲染; - 使用
useMemo缓存计算结果:避免每次渲染都执行复杂计算; - 使用
useCallback缓存函数:避免因函数重新创建导致子组件 props 变化。
// 示例:使用 React.memo 缓存组件
const Child = React.memo(({ name }) => {
console.log('Child 渲染');
return <p>{name}</p>;
});
function Parent() {
const [count, setCount] = useState(0);
// 使用 useCallback 缓存函数
const handleClick = useCallback(() => {}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>计数:{count}</button>
<Child name="小明" onClick={handleClick} />
</div>
);
}
优化后,点击按钮修改 count 时,Child 组件不会重新渲染,减少了 Fiber 树的遍历范围。
2. 拆分长任务(避免长时间占用主线程)
如果有复杂的计算任务(比如处理大量数据),不要在组件渲染或 useEffect 中同步执行,否则会占据主线程,导致卡顿。可以用 setTimeout 或 React 18+ 的 useDeferredValue 将任务拆分成小任务,降低优先级。
// 示例:使用 useDeferredValue 降低任务优先级
function DataList() {
const [data, setData] = useState([]);
// 延迟处理数据,降低优先级
const deferredData = useDeferredValue(data);
// 处理大量数据(复杂任务)
useEffect(() => {
const fetchData = async () => {
const res = await fetch('/api/large-data');
const largeData = await res.json();
setData(largeData);
};
fetchData();
}, []);
// 渲染延迟处理后的数据
return (
<ul>
{deferredData.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
使用 useDeferredValue 后,数据处理任务会被标记为低优先级,不会影响用户交互等紧急任务。
3. 避免在渲染阶段执行副作用
组件渲染阶段(函数组件执行过程)是 Fiber 树遍历的核心阶段,这个阶段的任务必须是“纯函数”(没有副作用)。如果在渲染阶段执行副作用(比如修改 DOM、发送请求、修改全局变量),会导致 Fiber 树构建混乱,且可能延长任务执行时间。
错误示例(渲染阶段执行副作用):
// 错误:渲染阶段修改 DOM
function BadComponent() {
const divRef = useRef(null);
// 渲染阶段执行副作用(修改 DOM 文本)
if (divRef.current) {
divRef.current.textContent = '渲染中...';
}
return <div ref={divRef}></div>;
}
正确做法:将副作用放在 useEffect 中:
// 正确:useEffect 中执行副作用
function GoodComponent() {
const divRef = useRef(null);
useEffect(() => {
// 副作用放在 useEffect 中,在渲染完成后执行
if (divRef.current) {
divRef.current.textContent = '渲染完成';
}
}, []);
return <div ref={divRef}></div>;
}
六、核心总结
今天我们从底层原理到实战优化,完整拆解了 React 19 的 Fiber 架构和调度系统。核心要点总结如下:
- Fiber 架构的核心价值:将不可中断的递归遍历拆分为可中断、可恢复的链表遍历,避免长任务占据主线程导致卡顿;
- React 19 Fiber 升级点:精简 Fiber 节点结构、增强优先级字段精度、优化双缓存切换效率、复用无变化节点;
- 调度系统的核心逻辑:基于优先级队列分配任务,通过 MessageChannel 和 performance.now() 检测主线程空闲时间,确保紧急任务先执行;
- 协同工作流程:触发更新 → 调度任务 → 构建 Fiber 工作树(可中断) → 切换双缓存树 → 提交 DOM 操作;
- 实战优化建议:减少不必要渲染、拆分长任务、避免渲染阶段执行副作用。
七、进阶学习方向
如果想进一步深入 React 19 底层原理,可以重点学习以下内容:
- Fiber 架构的“提交阶段”细节:如何批量执行 DOM 操作、如何处理副作用;
- React 19 的“自动批处理”(Automatic Batching):如何合并多个更新任务,减少 Fiber 树构建次数;
- 并发渲染(Concurrent Rendering):如何基于 Fiber 架构实现“并发更新”,让多个更新任务并行处理;
- React 源码阅读:重点看
react-reconciler包(Fiber 架构核心)和scheduler包(调度系统核心)。
如果这篇文章对你有帮助,欢迎点赞、收藏、转发~ 有任何问题也可以在评论区留言交流~ 我们下期再见!
React 事件绑定全攻略:5种方式优劣大比拼
React 事件绑定全攻略:5种方式优劣大比拼
为什么事件绑定这么重要?
在React中,事件绑定不仅仅是把函数和元素连接起来那么简单。它关系到:
- • 组件的性能表现
- • 代码的可维护性
- • this指向的正确性
- • 内存泄漏的防范
下面我们一起来看看React事件绑定的5种主要方式,以及它们各自的“性格特点”。
方式一:箭头函数内联绑定
class Button extends React.Component {
render() {
return (
<button onClick={() => this.handleClick()}>
点击我
</button>
);
}
handleClick() {
console.log('按钮被点击了');
}
}
优点:
- • 语法简洁直观
- • 无需担心this指向问题
缺点:
- • 性能陷阱:每次渲染都会创建新的函数实例
- • 不利于子组件的shouldComponentUpdate优化
方式二:构造函数内绑定
class Button extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
render() {
return <button onClick={this.handleClick}>点击我</button>;
}
handleClick() {
console.log('按钮被点击了');
}
}
优点:
- • 性能最佳,函数只在构造函数中绑定一次
- • 支持shouldComponentUpdate优化
缺点:
- • 代码稍显冗长
- • 需要维护构造函数中的绑定
方式三:类属性箭头函数(推荐)
class Button extends React.Component {
handleClick = () => {
console.log('按钮被点击了');
};
render() {
return <button onClick={this.handleClick}>点击我</button>;
}
}
优点:
- • 语法简洁美观
- • this永远指向组件实例
- • 性能优秀(函数只创建一次)
缺点:
- • 需要Babel插件支持(class properties)
- • 不属于ES标准语法(但已成为事实标准)
方式四:render中bind绑定
class Button extends React.Component {
render() {
return (
<button onClick={this.handleClick.bind(this)}>
点击我
</button>
);
}
handleClick() {
console.log('按钮被点击了');
}
}
优点:
- • 简单直接
缺点:
- • 性能最差:每次渲染都重新绑定
- • 代码可读性降低
- • 不推荐在生产环境使用
方式五:函数组件中的事件绑定
function Button() {
const handleClick = () => {
console.log('按钮被点击了');
};
// 或者使用useCallback优化
const memoizedHandleClick = React.useCallback(() => {
console.log('按钮被点击了');
}, []);
return <button onClick={handleClick}>点击我</button>;
}
优点:
- • 最适合函数组件
- • useCallback可以优化性能
缺点:
- • 对于简单事件可能显得“杀鸡用牛刀”
性能对比实测
让我们用数据说话:
| 绑定方式 | 每次渲染新建函数 | 内存占用 | 适合场景 |
|---|---|---|---|
| 箭头函数内联 | 是 | 高 | 简单组件、原型验证 |
| 构造函数绑定 | 否 | 低 | 性能敏感组件 |
| 类属性箭头函数 | 否 | 低 | 主流Class组件 |
| render中bind | 是 | 高 | 不推荐使用 |
| 函数组件+useCallback | 可选 | 中等 | 函数组件 |
实战建议
1. Class组件优先选择
// 推荐:类属性箭头函数
class Profile extends React.Component {
handleFollow = async () => {
await this.props.followUser(this.state.userId);
};
// 对于需要参数的事件
handleSelectItem = (itemId) => () => {
this.setState({ selectedItem: itemId });
};
render() {
return (
<div>
<button onClick={this.handleFollow}>关注</button>
{items.map(item => (
<div
key={item.id}
onClick={this.handleSelectItem(item.id)}
>
{item.name}
</div>
))}
</div>
);
}
}
2. 函数组件注意事项
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
// 好的做法:useCallback避免子组件不必要的重渲染
const handleSearch = useCallback(() => {
onSearch(query);
}, [query, onSearch]);
// 坏的做法:每次渲染都新建函数
const handleChange = (e) => {
setQuery(e.target.value);
};
// 好的做法:简单的setState可以直接内联
const handleChange = (e) => setQuery(e.target.value);
return <input value={query} onChange={handleChange} />;
}
3. 事件绑定优化技巧
技巧一:事件委托
class List extends React.Component {
handleClick = (e) => {
if (e.target.tagName === 'LI') {
const id = e.target.dataset.id;
this.handleItemClick(id);
}
};
render() {
return (
<ul onClick={this.handleClick}>
{this.props.items.map(item => (
<li key={item.id} data-id={item.id}>
{item.text}
</li>
))}
</ul>
);
}
}
技巧二:合成事件与原生事件
class Modal extends React.Component {
componentDidMount() {
// 在document上绑定原生事件
document.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
// 一定要记得移除!
document.removeEventListener('keydown', this.handleKeyDown);
}
handleKeyDown = (e) => {
if (e.key === 'Escape') {
this.props.onClose();
}
};
// React合成事件
handleOverlayClick = (e) => {
e.stopPropagation();
this.props.onClose();
};
}
常见坑点与避雷指南
🚫 坑点1:忘记绑定this
class BadExample extends React.Component {
handleClick() {
// 这里this是undefined!
console.log(this.props.message);
}
render() {
return <button onClick={this.handleClick}>点我</button>;
}
}
🚫 坑点2:内联箭头函数导致性能问题
// 在长列表中这样做会非常卡顿
render() {
return (
<div>
{items.map(item => (
<Item
key={item.id}
onClick={() => this.handleSelect(item.id)} // 每次渲染都新建函数
/>
))}
</div>
);
}
// 改进方案
render() {
return (
<div>
{items.map(item => (
<Item
key={item.id}
onClick={this.handleSelect}
data-id={item.id}
/>
))}
</div>
);
}
总结
- 1. Class组件:优先使用类属性箭头函数(
handleClick = () => {}) - 2. 函数组件:简单事件可直接定义,复杂事件考虑
useCallback - 3. 性能关键:避免在render中创建新函数,特别在列表渲染中
- 4. 内存管理:绑定在全局或document上的事件,一定要在组件卸载时移除
选择合适的事件绑定方式,能让你的React应用运行得更流畅,代码也更易于维护。
React Router进阶:懒加载、权限控制与性能优化
引言:现代前端路由的重要性
在单页面应用(SPA)的架构中,前端路由扮演着至关重要的角色。与传统的多页面应用不同,SPA 通过前端路由实现页面间的无缝切换,无需每次跳转都向服务器请求完整的 HTML 文档。React Router 作为 React 生态中最流行的路由解决方案,提供了强大而灵活的路由管理能力。
本文将通过一个完整的 React Router 实践项目,深入剖析路由配置、组件懒加载、动态路由、嵌套路由、路由守卫等核心概念,帮助你掌握现代前端路由的最佳实践。
项目架构概览
首先让我们了解项目的整体结构:
src/
├── App.jsx # 应用根组件,配置路由容器
├── router/
│ └── index.jsx # 路由配置文件
├── components/ # 通用组件目录
│ ├── Navigation.jsx # 导航组件
│ ├── ProtectRoute.jsx # 路由守卫组件
│ └── LoadingFallback/ # 加载状态组件
│ ├── index.jsx
│ └── index.module.css
├── pages/ # 页面组件目录
│ ├── Home.jsx # 首页
│ ├── About.jsx # 关于页
│ ├── Login.jsx # 登录页
│ ├── UserProfile.jsx # 用户详情页
│ ├── NotFound.jsx # 404页面
│ ├── Pay.jsx # 支付页面
│ ├── NewPath.jsx # 新路径页面
│ └── product/ # 产品相关页面
│ ├── index.jsx # 产品列表页
│ ├── ProductDetail.jsx
│ └── NewProduct.jsx
└── index.css # 全局样式
这种模块化的目录结构清晰地将路由配置、页面组件和通用组件分离,便于维护和扩展。
核心配置:路由容器的建立
App.jsx:路由容器的封装
在 App.jsx 中,我们建立了整个应用的路由基础框架:
import {
BrowserRouter as Router,
// HashRouter
} from 'react-router-dom'
import Navigation from './components/Navigation'
import RouterConfig from './router'
export default function App(){
return (
<Router>
<Navigation />
<RouterConfig />
</Router>
)
}
这里有几个关键点需要注意:
-
路由模式选择:我们使用
BrowserRouter作为路由容器。与HashRouter相比,BrowserRouter使用 HTML5 History API 实现路由,URL 更加清晰(没有#符号)。这在现代浏览器中得到良好支持,且有利于 SEO 优化,并且所有的<Link>useNavigateuseParams等Hook必须在BrowserRouter的子组件树中才能正常工作。 -
组件分离策略:将导航组件和路由配置组件分离,这种设计模式使得代码结构更加清晰。导航组件负责所有导航链接的展示,而路由配置组件专注于路由规则的声明。
-
路由层级关系:注意
Navigation组件在RouterConfig之前,这意味着无论路由如何切换,导航栏都会保持显示,这是典型的 SPA 导航模式。
高级路由配置详解
路由懒加载:性能优化的利器
在 router/index.jsx 中,我们实现了全面的路由懒加载策略:
import { Route,Routes, Navigate } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import LoadingFallback from '../components/LoadingFallback'
// 动态引入页面组件
const Home = lazy(() => import('../pages/Home'))
const About = lazy(() => import('../pages/About'))
const UserProfile = lazy(() => import('../pages/UserProfile'))
const Product = lazy(() => import('../pages/product'))
const ProductDetail = lazy(() => import('../pages/product/ProductDetail'))
const NewProduct = lazy(() => import('../pages/product/NewProduct'))
const Login = lazy(() => import('../pages/Login'))
const ProtectRoute = lazy(() => import('../components/ProtectRoute'))
const Pay = lazy(() => import('../pages/Pay'))
const NotFound = lazy(() => import('../pages/NotFound'))
const NewPath = lazy(() => import('../pages/NewPath'))
懒加载的核心机制
动态 import 语法:import('../pages/Home') 返回一个 Promise,Webpack 等打包工具会将其识别为代码分割点,单独打包成一个 chunk。
React.lazy 的工作原理:
-
React.lazy()接收一个返回 Promise 的函数 - 当组件首次渲染时,React 调用该函数,触发动态导入
- 导入过程中,React 会抛出(throw)这个 Promise
-
<Suspense>组件捕获这个 Promise,并显示 fallback 内容 - Promise 解析完成后,React 重新渲染,显示真实组件
这种机制的优势在于:
- 减小初始包体积:应用启动时只加载必要的代码
- 按需加载:用户在访问特定路由时才加载对应代码
- 优化用户体验:减少首屏加载时间,特别对于大型应用
为什么首页也要懒加载?
很多人误以为首页必须同步加载。但实际上,用户可能通过分享链接直接访问/about 或 /user/123,这种情况下加载首页就会产生浪费
Suspense 的优雅降级
<Suspense fallback={<LoadingFallback/>}>
<Routes>
{/* 路由配置 */}
</Routes>
</Suspense>
Suspense 组件为所有懒加载组件提供了统一的加载状态管理。fallback 属性接受一个 React 元素,在子组件加载期间显示。这里我们使用了自定义的 LoadingFallback 组件,提供了美观的加载动画。
路由守卫的实现
保护路由是应用中常见的需求,特别是在需要用户认证的场景:
export default function ProtectRoute({ children }){
const isLoggedIn = localStorage.getItem('isLogin') === 'true'
if(!isLoggedIn){
return <Navigate to="/login" />
}
return children
}
在路由配置中使用:
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
}>
</Route>
这种实现方式具有以下特点:
-
高阶组件模式:
ProtectRoute作为高阶组件,接收子组件作为参数 - 条件重定向:通过检查认证状态决定是否重定向到登录页
- 无侵入性:被保护的组件无需关心认证逻辑,保持了组件的纯粹性
- 灵活扩展:可以轻松添加其他权限检查逻辑
路由类型全解析
基础路由
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
这是最基本的路由配置,直接映射路径到对应组件。
动态路由:参数化路径
<Route path="/user/:id" element={<UserProfile />} />
在 UserProfile 组件中获取参数:
import { useParams } from "react-router-dom"
export default function UserProfile(){
const { id } = useParams()
return <div>UserProfile {id}</div>
}
动态路由的特点:
-
:id是路径参数占位符 - 可以匹配
/user/123、/user/abc等路径 -
useParams()返回包含所有参数的对象 - 支持多个参数:
path="/product/:category/:id"
嵌套路由:父子路由关系
<Route path="/products" element={<Product/>}>
<Route path=":productId" element={<ProductDetail/>} />
<Route path="new" element={<NewProduct />} />
</Route>
在父组件中使用 <Outlet /> 渲染子路由:
import { Outlet } from "react-router-dom"
export default function Product(){
return (
<>
Product
<Outlet />
</>
)
}
嵌套路由的优势:
- 共享布局:父组件可以包含导航、页眉、页脚等共享元素
-
层次化URL:
/products/123、/products/new有清晰的层级关系 - 独立渲染:子路由变化时,父组件可以保持不变
重定向路由
<Route path='/old-path' element={<Navigate replace to='/new-path'/>} />
<Navigate /> 组件在渲染时会执行重定向:
-
replace属性:为true时替换当前历史记录,而不是添加新记录 -
to属性:目标路径,可以是绝对路径或相对路径 - 避免用户点击浏览器后退按钮时再次进入旧路径
通配符路由:404处理
<Route path='*' element={<NotFound />} />
通配符路由 * 匹配所有未匹配的路径,通常用于404页面。必须放在 <Routes> 的最后,否则会拦截所有路由。
在 NotFound 组件中,我们实现了自动重定向:
import { useNavigate } from "react-router-dom"
import { useEffect } from "react"
const NotFound = () => {
let navigate = useNavigate()
useEffect(() => {
setTimeout(() => {
navigate('/')
}, 6000)
}, [])
return <div>NotFound</div>
}
这里使用了 useNavigate hook 进行编程式导航,结合 setTimeout 实现延迟跳转,6秒后自动跳回首页。
导航组件的实现细节
智能导航链接
在 Navigation.jsx 中,我们实现了带有活动状态指示的导航链接:
import { Link, useResolvedPath, useMatch } from "react-router-dom"
export default function Navigation(){
const isActive = (to) => {
const resolvedPath = useResolvedPath(to)
const match = useMatch({
path: resolvedPath.pathname,
end: true
})
return match ? 'active' : ''
}
return (
<nav>
<ul>
<li>
<Link to="/" className={isActive('/')}>Home</Link>
</li>
<li>
<Link to="/about" className={isActive('/about')}>About</Link>
</li>
{/* 其他链接 */}
</ul>
</nav>
)
}
关键Hook解析
useResolvedPath:将传入的 to 值解析为标准的路径对象。这确保了无论传入的是相对路径还是绝对路径,都能正确解析。
useMatch:将解析后的路径与当前URL进行匹配:
-
path:要匹配的路径模式 -
end: true:要求精确匹配。如果设为false,路径/会匹配所有以/开头的路由(如/about),导致多个链接同时显示激活状态
导航链接的类型
-
基础导航:
<Link to="/about">About</Link> -
嵌套路由导航:
<Link to="/products/new">Product New</Link> -
带参数路由导航:
<Link to="/products/123">Product Detail</Link>
样式与用户体验优化
加载状态组件
LoadingFallback 组件通过CSS动画提供了优雅的加载状态指示:
/* 旋转动画 */
@keyframes spin {
from{ transform: rotate(0deg); }
to{ transform: rotate(360deg); }
}
/* 呼吸效果动画 */
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
这种设计提升了用户体验,避免了页面切换时的突兀感。
活动状态样式
在 index.css 中定义了活动状态的样式:
.active{
color: red;
}
通过动态添加 active 类名,用户可以清晰地了解当前所在页面。
最佳实践总结
1. 代码分割策略
- 将路由组件按需加载,减小初始包体积
- 即使是首页也可以考虑懒加载,适用于直接访问深层链接的场景
- 使用统一的
Suspense边界管理加载状态
2. 路由组织原则
- 使用扁平化的路由配置结构
- 嵌套路由用于有明确父子关系的页面
- 路由配置与组件定义分离,便于维护
3. 导航设计要点
- 保持导航组件的独立性
- 提供清晰的活动状态指示
- 支持编程式导航和声明式导航
4. 错误处理与边界
- 使用通配符路由处理404情况
- 考虑用户友好型的错误提示
- 实现自动重定向机制
5. 权限控制实现
- 使用高阶组件模式实现路由守卫
- 分离认证逻辑和业务逻辑
- 提供友好的未授权处理(重定向到登录页)
状态管理集成
在实际项目中,路由状态经常需要与全局状态管理(如Redux、Context)集成:
- 路由参数同步:将路由参数同步到全局状态
- 导航状态管理:在状态管理中记录导航历史
- 权限状态集成:将路由守卫与全局权限状态结合
测试策略
- 路由配置测试:确保所有路由正确配置
- 导航组件测试:验证链接和活动状态
- 路由守卫测试:测试不同权限状态下的路由行为
结语
通过这个完整的 React Router 6 实践项目,我们深入探讨了现代前端路由的各个方面。从基础的路由配置到高级的懒加载、嵌套路由和路由守卫,React Router 提供了强大而灵活的工具集来构建复杂的单页面应用。
记住,良好的路由设计不仅仅是技术实现,更是用户体验的重要组成部分。合理的路由结构、清晰的URL设计和流畅的页面过渡都能显著提升应用质量。
随着 React 生态的不断发展,路由相关的模式和最佳实践也在不断演进。保持学习,持续优化,才能在快速变化的前端领域中保持竞争力。
前端样式工程化三剑客:CSS Modules、Scoped CSS 与 CSS-in-JS 深度实战
前言:为什么我们需要“工程化”样式?
在早期的前端开发中,CSS 是全局的。我们写一个 .button { color: red },它会立刻影响页面上所有的按钮。这在小型项目中或许可行,但在大型应用、多人协作或开源组件库开发中,这无异于“灾难”——样式冲突、优先级战争(Specificity Wars)层出不穷。
为了解决这个问题,现代前端框架提出了三种主流的解决方案:
- CSS Modules (React 生态主流方案)
- Scoped CSS (Vue 生态经典方案)
- CSS-in-JS (Stylus Components 为代表的动态方案)
本文将通过三个实战 Demo (css-demo, vue-css-demo, styled-component-demo),带你深入理解这三种方案的原理、差异及面试考点。
第一部分:CSS Modules - 基于文件的模块化
场景描述:
在 css-demo 项目中,我们不再直接使用全局的 CSS,而是利用 Webpack 等构建工具,将 CSS 文件编译成 JavaScript 对象。
1.1 核心原理
CSS Modules 并不是一门新的语言,而是一种编译时的解决方案。
-
编译机制:构建工具(如 Webpack)会将
.module.css文件编译成一个 JS 对象。 -
局部作用域:默认情况下,CSS Modules 中的类名是局部的。构建工具会将类名(如
.button)编译成唯一的哈希值(如_src-components-Button-module__button__23_a0)。 -
导入方式:在组件中,我们通过
import styles from './Button.module.css'导入这个对象,然后通过styles.button动态绑定类名。
1.2 代码实战解析
在我们的 Demo 中,定义了一个按钮组件:
// Button.jsx
import styles from './Button.module.css';
export default function Button() {
return (
<button className={styles.button}>My Button</button>
);
}
对应的样式文件:
/* Button.module.css */
.button {
background-color: blue;
color: white;
padding: 10px 20px;
}
发生了什么?
- 构建工具读取
Button.module.css。 - 将
.button转换为类似_button_hash123的唯一类名。 - 生成一个对象:
{ button: '_button_hash123' }。 - JSX 渲染时,
className变成了唯一的哈希值,实现了样式隔离。
1.3 答疑解惑与面试宝典
Q1:CSS Modules 是如何解决样式冲突的?
-
答: 核心在于哈希化(Hashing) 。它利用构建工具,在编译阶段将局部类名映射为全局唯一的哈希类名。由于哈希值的唯一性,不同组件即使定义了同名的
.button,最终生成的 CSS 类名也是不同的,从而从根本上杜绝了冲突。
Q2:CSS Modules 和普通的 CSS import 有什么区别?
-
答:
-
普通 CSS:
import './style.css'只是引入了样式,类名依然是全局的。 -
CSS Modules:
import styles from './style.module.css'将样式变成了 JS 对象,你必须通过对象的属性来引用类名,从而强制实现了作用域隔离。
-
普通 CSS:
Q3:如何在 CSS Modules 中使用全局样式?
-
答: 虽然不推荐,但有时确实需要。可以通过
:global伪类来声明::global(.global-class) { color: red; }这样
global-class就不会被哈希化,保持全局生效。
第二部分:Vue Scoped CSS - 属性选择器的魔法
场景描述:
在 vue-css-demo 项目中,我们使用 Vue 单文件组件(SFC)的经典写法,通过 <style scoped> 实现样式隔离。
2.1 核心原理
Vue 的 scoped 属性实现原理与 CSS Modules 截然不同,它采用的是属性选择器方案。
-
编译机制:Vue Loader 会为组件中的每个 DOM 元素生成一个唯一的属性(例如
data-v-f3f3eg9)。 -
样式重写:同时,它会将
<style scoped>中的选择器(如.txt)重写为属性选择器(如.txt[data-v-f3f3eg9])。 - 作用域限制:由于只有当前组件的 DOM 元素拥有该属性,样式自然只能作用于当前组件。
2.2 代码实战解析
在 Vue 的 Demo 中,我们有两个层级:App.vue 和 HelloWorld.vue。
<!-- App.vue -->
<template>
<div>
<h1 class="txt">Hello world in App</h1>
<HelloWorld />
</div>
</template>
<style scoped>
.txt {
color: red;
}
</style>
<!-- HelloWorld.vue -->
<template>
<div>
<h1 class="txt">你好,世界!!!</h1>
</div>
</template>
<style scoped>
.txt {
color: blue;
}
</style>
发生了什么?
- 编译后,
App.vue中的<h1>标签被加上了data-v-abc123属性。 -
App.vue的 CSS 变成了.txt[data-v-abc123] { color: red }。 - 编译后,
HelloWorld.vue中的<h1>标签被加上了data-v-xyz456属性。 -
HelloWorld.vue的 CSS 变成了.txt[data-v-xyz456] { color: blue }。 -
结果:父子组件的
.txt类名互不干扰,各自生效。
2.3 答疑解惑与面试宝典
Q1:Vue Scoped 的性能怎么样?
- 答: 性能通常很好,但也有局限。它只生成一次属性,且利用了浏览器原生的属性选择器能力。但是,如果组件层级很深,属性选择器的权重会增加。此外,它无法穿透子组件(即父组件的 scoped 样式无法直接修改子组件的样式),这是它的设计初衷,也是需要注意的点。
Q2:如何修改子组件的样式?(深度选择器)
-
答: 当需要修改第三方组件库(如 Element Plus)的样式时,scoped 会失效。Vue 提供了深度选择器:
-
Vue 2:使用
>>>或/deep/。 -
Vue 3:使用
:deep()。
/* Vue 3 写法 */ .parent-class :deep(.child-class) { color: red; } -
Vue 2:使用
Q3:scoped 会导致样式权重增加吗?
-
答: 会。 因为它变成了属性选择器,例如
.txt变成了.txt[data-v-123],其权重高于普通的类选择器。如果在全局样式中写了.txt { color: blue },而在 scoped 中写了.txt { color: red },scoped 的样式会因为权重更高而覆盖全局样式。
第三部分:Stylus Components - CSS-in-JS 的动态艺术
场景描述:
在 styled-component-demo 项目中,我们将 CSS 直接写在 JavaScript 文件中,通过模板字符串创建组件。
3.1 核心原理
CSS-in-JS 是一种运行时的解决方案(虽然也支持 SSR 和编译时优化)。
- 组件即样式:它将样式直接绑定到组件上。你不是在组件中引用类名,而是直接创建一个“带样式的组件”。
- 动态性:样式可以像 JS 变量一样使用,支持传参(Props)。这使得主题切换、动态样式变得非常简单。
- 唯一性:生成的类名也是唯一的(通常基于组件名和哈希),确保不污染全局。
3.2 代码实战解析
// App.jsx
import styled from 'styled-components';
// 创建一个带样式的 Button 组件
const Button = styled.button`
background: ${props => props.primary ? 'blue' : 'white'};
color: ${props => props.primary ? 'white' : 'blue'};
padding: 8px 16px;
`;
function App() {
return (
<>
<Button>默认按钮</Button>
<Button primary>主要按钮</Button>
</>
);
}
发生了什么?
-
组件定义:
styled.button是一个函数,它接收模板字符串作为参数,返回一个 React 组件。 -
动态插值:在模板字符串中,我们可以使用 JavaScript 逻辑(如三元表达式)来根据
props动态生成 CSS。 -
渲染:当
<Button primary>渲染时,库会根据逻辑生成对应的 CSS 规则(如background: blue),注入到<head>中,并将生成的唯一类名应用到 DOM 上。
3.3 答疑解惑与面试宝典
Q1:CSS-in-JS 的优缺点是什么?
-
答:
- 优点:极致的动态能力(基于 Props 的样式)、天然的组件隔离、支持主题(Theme)、解决了全局污染问题。
- 缺点:运行时性能开销(需要 JS 计算生成 CSS)、CSS 文件体积无法单独缓存(随 JS 打包)、调试时类名可读性差(全是哈希)、学习成本较高。
Q2:CSS-in-JS 和 CSS Modules 哪个更好?
-
答: 没有绝对的好坏,取决于场景。
- CSS Modules:适合对性能要求极高、样式逻辑简单的项目,或者团队习惯传统的 CSS 写法。
- CSS-in-JS:适合组件库开发、需要高度动态样式(如主题切换)、或者团队追求极致的组件封装性。
Q3:面试官问“你怎么看待把 CSS 写在 JS 里?”
-
答: 这是一个经典的“分离关注点”争论。
- 传统观点认为 HTML/CSS/JS 应该分离。
- 现代组件化观点认为,组件才是关注点。一个 Button 组件的逻辑、结构和样式是紧密耦合的,放在一起更利于维护和复用。CSS-in-JS 正是这种理念的体现。
第四部分:三剑客终极对比与选型建议
为了让你更直观地理解,我整理了以下对比表:
| 特性 | CSS Modules | Vue Scoped | CSS-in-JS (Stylus Components) |
|---|---|---|---|
| 作用域机制 | 哈希类名 (编译时) | 属性选择器 (编译时) | 哈希类名 (运行时/编译时) |
| 动态性 | 弱 (需配合 classnames 库) | 中 (需配合动态 class 绑定) | 强 (直接使用 JS 逻辑) |
| 学习成本 | 低 (仍是 CSS) | 低 (Vue 特性) | 中 (需学习新 API) |
| 调试难度 | 低 (类名清晰) | 低 | 中 (类名哈希化) |
| 适用场景 | 大型 React 应用 | Vue 2/3 项目 | 组件库、高动态 UI |
选型建议:
-
如果你在用 Vue:首选
scoped,简单高效。如果项目非常复杂,可以考虑 CSS Modules 或 CSS-in-JS。 -
如果你在用 React:
- 如果追求性能和工程化规范,选 CSS Modules。
- 如果追求极致的组件封装和动态主题,选 CSS-in-JS (如 Stylus Components 或 Emotion)。
- 如果是新项目,也可以考虑 Tailwind CSS 等 Utility-First 方案。
结语:样式工程化的未来
从全局 CSS 到现在的模块化、组件化,前端样式的发展始终围绕着**“隔离”与“复用”**这两个核心矛盾。
CSS Modules 和 Vue Scoped 通过编译时手段解决了隔离问题,而 CSS-in-JS 则通过运行时手段赋予了样式以逻辑能力。
无论你选择哪一种方案,理解其背后的原理(哈希化、属性选择器、动态注入)都是至关重要的。希望这篇博客能帮助你在 css-demo、vue-css-demo 和 styled-component-demo 三个项目中游刃有余,并在面试中脱颖而出。
最后的思考题:
- 如果让你设计一个组件库(如 Ant Design),你会选择哪种方案?为什么?(提示:考虑主题定制和样式隔离的平衡)
附录:常见面试题汇总
-
Vue scoped 的原理是什么?
- 答:通过属性选择器。给组件元素加唯一属性,给样式加属性选择器。
-
React 中如何实现 CSS Modules?
- 答:文件名加
.module.css,导入为对象,通过对象属性绑定 className。
- 答:文件名加
-
CSS-in-JS 的性能瓶颈在哪里?
- 答:运行时计算样式、注入 CSSOM 的操作(虽然现代库做了很多优化,如缓存)。
-
如何解决 CSS Modules 中的长类名问题?
- 答:通常不需要解决,构建工具会压缩。如果在 DevTools 中调试,可以配置 Webpack 的
localIdentName来生成可读的开发类名。
- 答:通常不需要解决,构建工具会压缩。如果在 DevTools 中调试,可以配置 Webpack 的
-
Shadow DOM 和上述方案有什么区别?
- 答:Shadow DOM 是浏览器原生的样式隔离方案,隔离性最强(完全独立的 DOM 树),但兼容性和集成成本较高。上述方案都是基于现有 DOM 的模拟隔离。
搞混了 setState 同步还是异步问题
刚学 React 接触setState的时候,经常会想一个问题:setState 到底是同步的还是异步的?
“好像是异步的”,结果写代码时又发现有时候它“立刻生效”了。越想越糊涂,直到后来踩了坑、看了源码、再结合 React 18 的变化,才真正理清楚。
就最近遇到的切换页面主题的react项目,里面的有一下一段代码
const toggleTheme = () => {
setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};
这又让我想起setState这个许久的问题,它和“同步/异步”有关系吗?决定写一篇文章来捋一捋。
一开始,我以为 setState 是“异步”的
脑子里立刻浮现出那个经典例子:
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); 、
};
这里打印出来的还是老值,导致我一直以为是因为“setState 是异步的,还没执行完”。 但后来我才意识到——这个理解其实有点跑偏了
一、 所谓的“异步”,其实是 React 在“攒大招”
为什么 console.log(count) 打印的是 0?
并不是因为 setCount 像 setTimeout 或者接口请求那样真的是个异步任务,被扔到了微任务队列里。根本原因是 React 为了性能,开启了一个叫 “批处理” 的机制。
想象一下你去超市结账。如果你拿一瓶水,收银员算一次钱;再拿包薯片,收银员再算一次钱……收银员(渲染引擎)肯定会被你累死。 React 很聪明,它会把你的多次 setState 操作先“记在小本本上”,等你这一轮事件处理函数执行完了,它再一次性把所有账单结了,这个操作在react里面叫更新dom。
所以,当你执行 console.log 的时候,React 甚至还没开始动手更新呢,你读到的自然是旧值。
为了验证这一点,咱们直接上代码测试,用 React 17 和 React 18 对比,真相立马浮出水面。
二、在 React 17 里的不同
后来我看了一些老教程,说“在 setTimeout 里 setState 是同步的”。于是我兴奋地去试了一下:
// 环境:React 17
const handleClick = () => {
setTimeout(() => {
setCount(c => c + 1);
// 很多人(包括以前的我)以为这里能打印出 1
// 结果控制台啪的一下打脸:依然是 0 !!!
console.log(count);
}, 0);
};
![]()
当时我就懵了,直到我打开 Chrome 开发者工具的 Elements 面板,盯着那个 DOM 节点看,才发现了一个惊人的事实:
-
DOM 确实变了! 在
console.log执行的那一瞬间,页面上的数字已经变成 1 了。说明 React 确实同步完成了渲染。 -
但
count变量没变! 因为我是用函数式组件写的。
这就触及到了知识盲区: 在 React 17 的 setTimeout 里,React 确实失去了“批处理”的能力,导致它被迫同步更新了视图。但是!由于函数式组件的闭包特性,我当前这个 handleClick 函数是在 count=0 的时候创建的,它手里拿的 count 永远是 0。
所以,视图是新的,变量是旧的。这才是最坑的地方。
三、React 18 的大一统
回到 React 18,官方推出了 自动批处理 。
现在,不管你是在 setTimeout、Promise 还是原生事件里,React 都会把门焊死,统统进行批处理。
setTimeout(() => {
setCount(c => c + 1);
setName('Alice');
setIsLoading(false);
}, 0);
👉 结果:只 re-render 1 次!
React 18 无论你在哪调用状态更新(事件、定时器、Promise、fetch 回调等) ,都会自动把它们“攒起来”,在当前 tick 结束时一次性合并更新并渲染。
这意味着,在 React 18 里,除非你用 flushSync 这种逃生舱,否则你几乎看不到 DOM 同步更新的情况了。这其实是好事,心智负担少了很多,不用再去记那些特例。
首先,我们来看最常见的场景。如果它是同步的,那我改三次,它就应该变三次
来看这段代码:
// React 18 环境
export default function App() {
console.log("组件渲染了!"); // 埋点:监控渲染次数
const [count, setCount] = useState(0);
const handleClick = () => {
// 连发三枪
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 马上查看
console.log("点击时的 count:", count);
};
return <button onClick={handleClick}>{count}</button>;
}
![]()
运行结果直接打脸:
- 控制台打印
点击时的 count: 0。(说明:代码执行到这行时,状态根本没变) -
"组件渲染了!"只打印了 1 次。(说明:三次操作被合并了) - 页面上的数字变成了
1,而不是3。
四、setState 同步的情况
我们可以逼 React 同步执行。在 React 18 里,我们需要用 flushSync 这个 API 来关掉自动批处理。
上代码:
import { useState } from 'react';
import { flushSync } from 'react-dom';
export default function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
// 第一次更新:强制同步
flushSync(() => {
setCount(c => c + 1);
});
console.log("第一次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
// 第二次更新:强制同步
flushSync(() => {
setCount(c => c + 1);
});
console.log("第二次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
};
return (
<div>
<span id="count-span">{count}</span>
<button onClick={handleClick}>点击增加</button>
</div>
);
}
![]()
结论: 看,React 其实完全有能力同步更新。只要你用 flushSync 勒令它“立刻、马上干活”,它就会停下手头的工作,立刻执行更新流程。
所以,准确地说:setState 本质上是同步执行代码的,只是 React 默认挂了个“防抖”的机制,让它看起来像是异步的。
五、最坑的“假异步”(闭包陷阱)
既然上面的代码里,DOM 都已经同步变了,那我在 JS 里直接打印 count 变量
看这段代码:
const handleClick = () => {
flushSync(() => {
setCount(c => c + 1);
});
// 刚才代码证明了,DOM 这里已经变成 1
// 那这里打印 count 应该是几?
console.log("也就是现在的 count 是:", count);
};
![]()
这不是 React 的锅,这是 JavaScript 闭包的锅。
我们这个 handleClick 函数,是在 count 为 0 的那次渲染中生成的。它就像一张照片,永远定格在了那一刻。
无论你用办法(比如 flushSync)让 React 在外部把 DOM 更新了,或者把 React 内部的 State 更新了,但你当前正在运行的这个 handleClick 函数作用域里,count 这个局部变量,它就是个常量 0,再怎么搞它也是 0。
回到最初的问题
理清了这些,再回过头看开头那段代码:
const toggleTheme = () => {
setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};
为什么要写成 previousState => ... 这种函数形式?
这和“同步/异步”有关系吗?有关系。
正因为 React 的 setState 是“异步”(批处理)的,而且函数式组件有闭包陷阱,如果直接写 setTheme(theme === 'light' ? ...),你拿到的 theme 很可能是旧值(也就是上面例子里那个永远是 0 的 count)。
当你传入一个函数时,你是在告诉 React:
“麻烦把当时最新的那个状态值传给我的函数。我不信我自己闭包里的旧变量,我只信你传给我的新值。”
总结一下
1、定性: “严格来说,setState 是由 React 调度的更新,表现得像异步(批处理的原因)。”
2、亮点:
-
“在 React 18 中,得益于自动批处理,无论在 React 事件还是
setTimeout中,它都会合并更新,表现为异步。” -
“但在 React 17 及以前,如果在
setTimeout或原生事件中,它会脱离 React 的管控,表现为同步行为。”
3、补充特例: “如果需要在 React 18 中强制同步更新 DOM,我们可以使用 flushSync。”
4、最后补刀(闭包): “但无论 DOM 是否同步更新,在函数式组件中,由于 JS 闭包 的存在,我们在当前函数执行上下文中拿到的 state 永远是本次渲染的快照(旧值),要获取最新值应该依赖 useEffect 或 useRef。”
Vercel React 最佳实践 中文版
React 最佳实践
版本 1.0.0
Vercel 工程团队
2026年1月
注意:
本文档主要供 Agent 和 LLM 在 Vercel 维护、生成或重构 React 及 Next.js 代码库时遵循。人类开发者也会发现其对于保持一致性和自动化优化非常有帮助。
摘要
这是一份针对 React 和 Next.js 应用程序的综合性能优化指南,专为 AI Agent 和 LLM 设计。包含 8 个类别的 40 多条规则,按影响力从关键(消除瀑布流、减少打包体积)到增量(高级模式)排序。每条规则都包含详细的解释、错误与正确实现的真实代码对比,以及具体的影响指标,以指导自动重构和代码生成。
目录
-
消除瀑布流 — 关键
- 1.1 推迟 Await 直到需要时
- 1.2 基于依赖的并行化
- 1.3 防止 API 路由中的瀑布链
- 1.4 对独立操作使用 Promise.all()
- 1.5 策略性 Suspense 边界
-
打包体积优化 — 关键
- 2.1 避免 Barrel 文件导入
- 2.2 条件模块加载
- 2.3 推迟非关键第三方库
- 2.4 重型组件动态导入
- 2.5 基于用户意图预加载
-
服务端性能 — 高
- 3.1 跨请求 LRU 缓存
- 3.2 最小化 RSC 边界序列化
- 3.3 通过组件组合并行获取数据
- 3.4 使用 React.cache() 进行按请求去重
- 3.5 使用 after() 处理非阻塞操作
-
客户端数据获取 — 中高
- 4.1 去重全局事件监听器
- 4.2 使用 SWR 自动去重
-
重渲染优化 — 中
- 5.1 推迟状态读取到使用点
- 5.2 提取为记忆化组件
- 5.3 缩小 Effect 依赖范围
- 5.4 订阅派生状态
- 5.5 使用函数式 setState 更新
- 5.6 使用惰性状态初始化
- 5.7 对非紧急更新使用 Transitions
-
渲染性能 — 中
- 6.1 动画化 SVG 包装器而非 SVG 元素
- 6.2 长列表使用 CSS content-visibility
- 6.3 提升静态 JSX 元素
- 6.4 优化 SVG 精度
- 6.5 无闪烁防止水合不匹配
- 6.6 使用 Activity 组件进行显示/隐藏
- 6.7 使用显式条件渲染
-
JavaScript 性能 — 中低
- 7.1 批量 DOM CSS 更改
- 7.2 为重复查找构建索引 Map
- 7.3 在循环中缓存属性访问
- 7.4 缓存重复函数调用
- 7.5 缓存 Storage API 调用
- 7.6 合并多个数组迭代
- 7.7 数组比较前先检查长度
- 7.8 函数提前返回
- 7.9 提升 RegExp 创建
- 7.10 使用循环求最小/最大值而非排序
- 7.11 使用 Set/Map 进行 O(1) 查找
- 7.12 使用 toSorted() 代替 sort() 以保证不可变性
- 高级模式 — 低
1. 消除瀑布流
影响力: 关键
瀑布流(Waterfalls)是头号性能杀手。每一个连续的 await 都会增加完整的网络延迟。消除它们能带来最大的收益。
1.1 推迟 Await 直到需要时
影响力: 高 (避免阻塞不使用的代码路径)
将 await 操作移动到实际使用它们的分支中,以避免阻塞不需要它们的代码路径。
错误:阻塞了两个分支
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId)
if (skipProcessing) {
// 立即返回,但仍然等待了 userData
return { skipped: true }
}
// 只有这个分支使用了 userData
return processUserData(userData)
}
正确:仅在需要时阻塞
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
// 不等待直接返回
return { skipped: true }
}
// 仅在需要时获取
const userData = await fetchUserData(userId)
return processUserData(userData)
}
另一个例子:提前返回优化
// 错误:总是获取权限
async function updateResource(resourceId: string, userId: string) {
const permissions = await fetchPermissions(userId)
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
// 正确:仅在需要时获取
async function updateResource(resourceId: string, userId: string) {
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
const permissions = await fetchPermissions(userId)
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
当被跳过的分支经常被执行,或者被推迟的操作非常昂贵时,这种优化通过尤为有价值。
1.2 基于依赖的并行化
影响力: 关键 (2-10倍 提升)
对于具有部分依赖关系的操作,使用 better-all 来即最大化并行性。它会在尽可能早的时刻启动每个任务。
错误:profile 不必要地等待 config
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig()
])
const profile = await fetchProfile(user.id)
正确:config 和 profile 并行运行
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
return fetchProfile((await this.$.user).id)
}
})
1.3 防止 API 路由中的瀑布链
影响力: 关键 (2-10倍 提升)
在 API 路由和 Server Actions 中,即使此时还不 await 它们,也要立即启动独立的操作。
错误:config 等待 auth,data 等待两者
export async function GET(request: Request) {
const session = await auth()
const config = await fetchConfig()
const data = await fetchData(session.user.id)
return Response.json({ data, config })
}
正确:auth 和 config 立即启动
export async function GET(request: Request) {
const sessionPromise = auth()
const configPromise = fetchConfig()
const session = await sessionPromise
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id)
])
return Response.json({ data, config })
}
对于具有更复杂依赖链的操作,使用 better-all 自动最大化并行性(参见"基于依赖的并行化")。
1.4 对独立操作使用 Promise.all()
影响力: 关键 (2-10倍 提升)
当异步操作没有相互依赖关系时,使用 Promise.all() 并发执行它们。
错误:顺序执行,3 次往返
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
正确:并行执行,1 次往返
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
1.5 策略性 Suspense 边界
影响力: 高 (更快的首次绘制)
不要在异步组件中等待数据后再返回 JSX,而是使用 Suspense 边界在数据加载时更快地显示包装器 UI。
错误:包装器被数据获取阻塞
async function Page() {
const data = await fetchData() // 阻塞整个页面
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<DataDisplay data={data} />
</div>
<div>Footer</div>
</div>
)
}
即便只有中间部分需要数据,整个布局也会等待数据。
正确:包装器立即显示,数据流式传输
function Page() {
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<Suspense fallback={<Skeleton />}>
<DataDisplay />
</Suspense>
</div>
<div>Footer</div>
</div>
)
}
async function DataDisplay() {
const data = await fetchData() // 仅阻塞此组件
return <div>{data.content}</div>
}
Sidebar、Header 和 Footer 立即渲染。只有 DataDisplay 等待数据。
替代方案:在组件间共享 promise
function Page() {
// 立即开始获取,但不要 await
const dataPromise = fetchData()
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<Suspense fallback={<Skeleton />}>
<DataDisplay dataPromise={dataPromise} />
<DataSummary dataPromise={dataPromise} />
</Suspense>
<div>Footer</div>
</div>
)
}
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise) // 解包 promise
return <div>{data.content}</div>
}
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise) // 复用同一个 promise
return <div>{data.summary}</div>
}
两个组件共享同一个 promise,因此只会进行一次获取。布局立即渲染,而两个组件一起等待。
何时不使用此模式:
-
布局决策所需的关键数据(影响定位)
-
首屏(Above the fold)的 SEO 关键内容
-
Suspense 开销不值得的小型快速查询
-
当你想要避免布局偏移(加载中 → 内容跳动)时
权衡: 更快的首次绘制 vs 潜在的布局偏移。根据你的 UX 优先级进行选择。
2. 打包体积优化
影响力: 关键
减少初始打包体积可以改善可交互时间 (TTI) 和最大内容绘制 (LCP)。
2.1 避免 Barrel 文件导入
影响力: 关键 (200-800ms 导入成本, 缓慢的构建)
直接从源文件导入而不是从 Barrel 文件导入,以避免加载数千个未使用的模块。Barrel 文件是重新导出多个模块的入口点(例如,执行 export * from './module' 的 index.js)。
流行的图标和组件库在其入口文件中可能有 多达 10,000 个重导出。对于许多 React 包,仅导入它们就需要 200-800ms,这会影响开发速度和生产环境的冷启动。
为什么 tree-shaking 没有帮助: 当库被标记为外部(不打包)时,打包器无法对其进行优化。如果你将其打包以启用 tree-shaking,分析整个模块图会导致构建变得非常缓慢。
错误:导入整个库
import { Check, X, Menu } from 'lucide-react'
// 加载 1,583 个模块,开发环境额外耗时 ~2.8s
// 运行时成本:每次冷启动 200-800ms
import { Button, TextField } from '@mui/material'
// 加载 2,225 个模块,开发环境额外耗时 ~4.2s
正确:仅导入你需要的内容
import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// 仅加载 3 个模块 (~2KB vs ~1MB)
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// 仅加载你使用的内容
替代方案:Next.js 13.5+
// next.config.js - 使用 optimizePackageImports
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', '@mui/material']
}
}
// 这样你可以保留符合人体工程学的 Barrel 导入:
import { Check, X, Menu } from 'lucide-react'
// 在构建时自动转换为直接导入
直接导入可提供 15-70% 更快的开发启动速度,28% 更快的构建速度,40% 更快的冷启动速度,以及显著更快的 HMR。
受影响的常见库:lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use。
2.2 条件模块加载
影响力: 高 (仅在需要时加载大数据)
仅在功能激活时加载大数据或模块。
例子:懒加载动画帧
function AnimationPlayer({ enabled }: { enabled: boolean }) {
const [frames, setFrames] = useState<Frame[] | null>(null)
useEffect(() => {
if (enabled && !frames && typeof window !== 'undefined') {
import('./animation-frames.js')
.then(mod => setFrames(mod.frames))
.catch(() => setEnabled(false))
}
}, [enabled, frames])
if (!frames) return <Skeleton />
return <Canvas frames={frames} />
}
typeof window !== 'undefined' 检查可防止在 SSR 时打包此模块,从而优化服务端包体积和构建速度。
2.3 推迟非关键第三方库
影响力: 中 (水合后加载)
分析、日志记录和错误跟踪不会阻塞用户交互。应当在水合(Hydration)之后加载它们。
错误:阻塞初始包
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
正确:水合后加载
import dynamic from 'next/dynamic'
const Analytics = dynamic(
() => import('@vercel/analytics/react').then(m => m.Analytics),
{ ssr: false }
)
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
2.4 重型组件动态导入
影响力: 关键 (直接影响 TTI 和 LCP)
使用 next/dynamic 懒加载初始渲染不需要的大型组件。
错误:Monaco 随主 chunk 打包 ~300KB
import { MonacoEditor } from './monaco-editor'
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
正确:Monaco 按需加载
import dynamic from 'next/dynamic'
const MonacoEditor = dynamic(
() => import('./monaco-editor').then(m => m.MonacoEditor),
{ ssr: false }
)
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
2.5 基于用户意图预加载
影响力: 中 (减少感知延迟)
在需要之前预加载繁重的包,以减少感知延迟。
例子:悬停/聚焦时预加载
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
void import('./monaco-editor')
}
}
return (
<button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
打开编辑器
</button>
)
}
例子:当功能标志启用时预加载
function FlagsProvider({ children, flags }: Props) {
useEffect(() => {
if (flags.editorEnabled && typeof window !== 'undefined') {
void import('./monaco-editor').then(mod => mod.init())
}
}, [flags.editorEnabled])
return <FlagsContext.Provider value={flags}>
{children}
</FlagsContext.Provider>
}
typeof window !== 'undefined' 检查可防止在 SSR 时打包预加载模块,从而优化服务端包体积和构建速度。
3. 服务端性能
影响力: 高
优化服务端渲染和数据获取可消除服务端瀑布流并减少响应时间。
3.1 跨请求 LRU 缓存
影响力: 高 (跨请求缓存)
React.cache() 仅在一个请求内有效。对于跨连续请求共享的数据(用户点击按钮 A 然后点击按钮 B),请使用 LRU 缓存。
实现:
import { LRUCache } from 'lru-cache'
const cache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000 // 5 分钟
})
export async function getUser(id: string) {
const cached = cache.get(id)
if (cached) return cached
const user = await db.user.findUnique({ where: { id } })
cache.set(id, user)
return user
}
// 请求 1: DB 查询, 结果被缓存
// 请求 2: 缓存命中, 无 DB 查询
当顺序的用户操作在几秒钟内命中多个需要相同数据的端点时,请使用此方法。
配合 Vercel 的 Fluid Compute: LRU 缓存特别有效,因为多个并发请求可以共享同一个函数实例和缓存。这意味着缓存可以跨请求持久化,而无需 Redis 等外部存储。
在传统 Serverless 中: 每次调用都是隔离运行的,因此请考虑使用 Redis 进行跨进而缓存。
3.2 最小化 RSC 边界序列化
影响力: 高 (减少传输数据大小)
React Server/Client 边界会将所有对象属性序列化为字符串,并将它们嵌入到 HTML 响应和后续的 RSC 请求中。此序列化数据直接影响页面重量和加载时间,因此 大小非常重要。仅传递客户端实际使用的字段。
错误:序列化所有 50 个字段
async function Page() {
const user = await fetchUser() // 50 个字段
return <Profile user={user} />
}
'use client'
function Profile({ user }: { user: User }) {
return <div>{user.name}</div> // 使用 1 个字段
}
正确:仅序列化 1 个字段
async function Page() {
const user = await fetchUser()
return <Profile name={user.name} />
}
'use client'
function Profile({ name }: { name: string }) {
return <div>{name}</div>
}
3.3 通过组件组合并行获取数据
影响力: 关键 (消除服务端瀑布流)
React Server Components 在树中顺序执行。使用组合重构以并行化数据获取。
错误:Sidebar 等待 Page 的 fetch 完成
export default async function Page() {
const header = await fetchHeader()
return (
<div>
<div>{header}</div>
<Sidebar />
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
正确:两者同时获取
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
</div>
)
}
使用 children prop 的替代方案:
async function Layout({ children }: { children: ReactNode }) {
const header = await fetchHeader()
return (
<div>
<div>{header}</div>
{children}
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<Layout>
<Sidebar />
</Layout>
)
}
3.4 使用 React.cache() 进行按请求去重
影响力: 中 (请求内去重)
使用 React.cache() 进行服务端请求去重。身份验证和数据库查询受益最大。
用法:
import { cache } from 'react'
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session?.user?.id) return null
return await db.user.findUnique({
where: { id: session.user.id }
})
})
在单个请求中,对 getCurrentUser() 的多次调用只会执行一次查询。
3.5 使用 after() 处理非阻塞操作
影响力: 中 (更快的响应时间)
使用 Next.js 的 after() 来调度应在发送响应后执行的工作。这可以防止日志记录、分析和其他副作用阻塞响应。
错误:阻塞响应
import { logUserAction } from '@/app/utils'
export async function POST(request: Request) {
// 执行变更
await updateDatabase(request)
// 日志记录阻塞了响应
const userAgent = request.headers.get('user-agent') || 'unknown'
await logUserAction({ userAgent })
return new Response(JSON.stringify({ status: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
正确:非阻塞
import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'
export async function POST(request: Request) {
// 执行变更
await updateDatabase(request)
// 响应发送后记录日志
after(async () => {
const userAgent = (await headers()).get('user-agent') || 'unknown'
const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
logUserAction({ sessionCookie, userAgent })
})
return new Response(JSON.stringify({ status: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
响应立即发送,而日志记录在后台发生。
常见用例:
-
分析跟踪
-
审计日志
-
发送通知
-
缓存失效
-
清理任务
重要说明:
-
即使响应失败或重定向,
after()也会运行 -
适用于 Server Actions、Route Handlers 和 Server Components
4. 客户端数据获取
影响力: 中高
自动去重和高效的数据获取模式减少了多余的网络请求。
4.1 去重全局事件监听器
影响力: 低 (N 个组件共用单个监听器)
使用 useSWRSubscription() 在组件实例之间共享全局事件监听器。
错误:N 个实例 = N 个监听器
function useKeyboardShortcut(key: string, callback: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === key) {
callback()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [key, callback])
}
当多次使用 useKeyboardShortcut 钩子时,每个实例都会注册一个新的监听器。
正确:N 个实例 = 1 个监听器
import useSWRSubscription from 'swr/subscription'
// 模块级 Map 跟踪每个键的回调
const keyCallbacks = new Map<string, Set<() => void>>()
function useKeyboardShortcut(key: string, callback: () => void) {
// 在 Map 中注册此回调
useEffect(() => {
if (!keyCallbacks.has(key)) {
keyCallbacks.set(key, new Set())
}
keyCallbacks.get(key)!.add(callback)
return () => {
const set = keyCallbacks.get(key)
if (set) {
set.delete(callback)
if (set.size === 0) {
keyCallbacks.delete(key)
}
}
}
}, [key, callback])
useSWRSubscription('global-keydown', () => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && keyCallbacks.has(e.key)) {
keyCallbacks.get(e.key)!.forEach(cb => cb())
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
}
function Profile() {
// 多个快捷键将共享同一个监听器
useKeyboardShortcut('p', () => { /* ... */ })
useKeyboardShortcut('k', () => { /* ... */ })
// ...
}
4.2 使用 SWR 自动去重
影响力: 中高 (自动去重)
SWR 支持跨组件实例的请求去重、缓存和重新验证。
错误:无去重,每个实例都获取
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
}
正确:多个实例共享一个请求
import useSWR from 'swr'
function UserList() {
const { data: users } = useSWR('/api/users', fetcher)
}
对于不可变数据:
import { useImmutableSWR } from '@/lib/swr'
function StaticContent() {
const { data } = useImmutableSWR('/api/config', fetcher)
}
对于变异 (Mutations):
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>更新</button>
}
参考: swr.vercel.app
5. 重渲染优化
影响力: 中
减少不必要的重渲染可最大限度地减少浪费的计算并提高 UI 响应能力。
5.1 推迟状态读取到使用点
影响力: 中 (避免不必要的订阅)
如果你只在回调中读取动态状态(搜索参数、localStorage),则不要订阅它。
错误:订阅所有 searchParams 更改
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
const handleShare = () => {
const ref = searchParams.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>分享</button>
}
正确:按需读取,无订阅
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
const ref = params.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>分享</button>
}
5.2 提取为记忆化组件
影响力: 中 (启用提前返回)
将昂贵的工作提取到记忆化 (memoized) 组件中,以便在计算及以前提前返回。
错误:即使在加载时也计算头像
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user)
return <Avatar id={id} />
}, [user])
if (loading) return <Skeleton />
return <div>{avatar}</div>
}
正确:加载时跳过计算
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
return (
<div>
<UserAvatar user={user} />
</div>
)
}
注意: 如果你的项目启用了 React Compiler,则无需使用 memo() 和 useMemo() 进行手动记忆化。编译器会自动优化重渲染。
5.3 缩小 Effect 依赖范围
影响力: 低 (最小化 effect 重新运行)
指定原始值依赖项而不是对象,以最大限度地减少 effect 的重新运行。
错误:在任何用户字段更改时重新运行
useEffect(() => {
console.log(user.id)
}, [user])
正确:仅在 id 更改时重新运行
useEffect(() => {
console.log(user.id)
}, [user.id])
对于派生状态,在 effect 外部计算:
// 错误:在 width=767, 766, 765... 时运行
useEffect(() => {
if (width < 768) {
enableMobileMode()
}
}, [width])
// 正确:仅在布尔值转换时运行
const isMobile = width < 768
useEffect(() => {
if (isMobile) {
enableMobileMode()
}
}, [isMobile])
5.4 订阅派生状态
影响力: 中 (降低重渲染频率)
订阅派生的布尔状态而不是连续值,以降低重渲染频率。
错误:在每个像素变化时重渲染
function Sidebar() {
const width = useWindowWidth() // 持续更新
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'}>
}
正确:仅在布尔值更改时重渲染
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'}>
}
5.5 使用函数式 setState 更新
影响力: 中 (防止闭包陷阱和不必要的回调重建)
当基于当前状态值更新状态时,使用 setState 的函数式更新形式,而不是直接引用状态变量。这可以防止闭包陷阱 (stale closures),消除不必要的依赖,并创建稳定的回调引用。
错误:需要 state 作为依赖
function TodoList() {
const [items, setItems] = useState(initialItems)
// 回调必须依赖 items,在每次 items 更改时重建
const addItems = useCallback((newItems: Item[]) => {
setItems([...items, ...newItems])
}, [items]) // ❌ items 依赖导致重建
// 如果忘记依赖,会有闭包陷阱风险
const removeItem = useCallback((id: string) => {
setItems(items.filter(item => item.id !== id))
}, []) // ❌ 缺少 items 依赖 - 将使用陈旧的 items!
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
第一个回调每次 items 更改时都会重建,这可能会导致子组件不必要地重渲染。第二个回调有一个闭包陷阱 bug——它将始终引用初始的 items 值。
正确:稳定的回调,无闭包陷阱
function TodoList() {
const [items, setItems] = useState(initialItems)
// 稳定的回调,从未重建
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems])
}, []) // ✅ 不需要依赖
// 始终使用最新状态,无闭包陷阱风险
const removeItem = useCallback((id: string) => {
setItems(curr => curr.filter(item => item.id !== id))
}, []) // ✅ 安全且稳定
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
好处:
-
稳定的回调引用 - 状态更改时无需重建回调
-
无闭包陷阱 - 始终对最新状态值进行操作
-
更少的依赖 - 简化了依赖数组并减少了内存泄漏
-
防止错误 - 消除了 React 闭包 bug 的最常见来源
何时使用函数式更新:
-
任何依赖于当前状态值的 setState
-
在需要 state 的 useCallback/useMemo 内部
-
引用 state 的事件处理程序
-
更新 state 的异步操作
何时直接更新是可以的:
-
将 state 设置为静态值:
setCount(0) -
仅从 props/参数设置 state:
setName(newName) -
State 不依赖于先前的值
注意: 如果你的项目启用了 React Compiler,编译器可以自动优化某些情况,但仍建议使用函数式更新以确保证正确性并防止闭包陷阱 bug。
5.6 使用惰性状态初始化
影响力: 中 (每次渲染都浪费计算)
将函数传递给 useState 用于昂贵的初始值。如果不使用函数形式,初始化程序将在每次渲染时运行,即使该值仅使用一次。
错误:每次渲染都运行
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() 在每次渲染时运行,即使在初始化之后
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [query, setQuery] = useState('')
// 当 query 更改时,buildSearchIndex 再次不必要地运行
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse 在每次渲染时运行
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
)
return <SettingsForm settings={settings} onChange={setSettings} />
}
正确:仅运行一次
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() 仅在初始渲染时运行
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [query, setQuery] = useState('')
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse 仅在初始渲染时运行
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings')
return stored ? JSON.parse(stored) : {}
})
return <SettingsForm settings={settings} onChange={setSettings} />
}
当从 localStorage/sessionStorage 计算初始值、构建数据结构(索引、Map)、从 DOM 读取或执行繁重的转换是,请使用惰性初始化。
对于简单的原始值 (useState(0))、直接引用 (useState(props.value)) 或廉价的字面量 (useState({})),函数形式是不必要的。
5.7 对非紧急更新使用 Transitions
影响力: 中 (保持 UI 响应及)
将频繁的、非紧急的状态更新标记为 transitions,以保持 UI 响应能力。
错误:每次滚动都阻塞 UI
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => setScrollY(window.scrollY)
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
正确:非阻塞更新
import { startTransition } from 'react'
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
6. 渲染性能
影响力: 中
优化渲染过程可减少浏览器需要做的工作。
6.1 动画化 SVG 包装器而非 SVG 元素
影响力: 低 (启用硬件加速)
许多浏览器不支持对 SVG 元素的 CSS3 动画进行硬件加速。将 SVG 包装在 <div> 中并对包装器进行动画处理。
错误:直接动画化 SVG - 无硬件加速
function LoadingSpinner() {
return (
<svg
className="animate-spin"
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
)
}
正确:动画化包装器 div - 硬件加速
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
)
}
这适用于所有 CSS 变换和过渡(transform, opacity, translate, scale, rotate)。包装器 div 允许浏览器使用 GPU 加速来实现更流畅的动画。
6.2 长列表使用 CSS content-visibility
影响力: 高 (更快的首次渲染)
应用 content-visibility: auto 以推迟屏幕外渲染。
CSS:
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
例子:
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
)
}
对于 1000 条消息,浏览器会跳过 ~990 个屏幕外项目的布局/绘制(首次渲染快 10 倍)。
6.3 提升静态 JSX 元素
影响力: 低 (避免重新创建)
将静态 JSX 提取到组件外部以避免重新创建。
错误:每次渲染都重新创建元素
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200" />
}
function Container() {
return (
<div>
{loading && <LoadingSkeleton />}
</div>
)
}
正确:复用相同元素
const loadingSkeleton = (
<div className="animate-pulse h-20 bg-gray-200" />
)
function Container() {
return (
<div>
{loading && loadingSkeleton}
</div>
)
}
这对于大型和静态的 SVG 节点特别有用,因为在每次渲染时重新创建它们可能会很昂贵。
注意: 如果你的项目启用了 React Compiler,编译器会自动提升静态 JSX 元素并优化组件重渲染,使得手动提升变得不必要。
6.4 优化 SVG 精度
影响力: 低 (减小文件大小)
降低 SVG 坐标精度以减小文件大小。最佳精度取决于 viewBox 大小,但在一般情况下,应考虑降低精度。
错误:过高的精度
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
正确:1 位小数
<path d="M 10.3 20.8 L 30.9 40.2" />
使用 SVGO 自动化:
npx svgo --precision=1 --multipass icon.svg
6.5 无闪烁防止水合不匹配
影响力: 中 (避免视觉闪烁和水合错误)
当渲染依赖于客户端存储(localStorage, cookies)的内容时,通过注入一个同步脚本在 React 水合之前更新 DOM,以避免 SSR 中断和水合后的闪烁。
错误:破坏 SSR
function ThemeWrapper({ children }: { children: ReactNode }) {
// localStorage 在服务器上不可用 - 抛出错误
const theme = localStorage.getItem('theme') || 'light'
return (
<div className={theme}>
{children}
</div>
)
}
服务端渲染将失败,因为 localStorage 未定义。
错误:视觉闪烁
function ThemeWrapper({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
// 在水合后运行 - 导致可见的闪烁
const stored = localStorage.getItem('theme')
if (stored) {
setTheme(stored)
}
}, [])
return (
<div className={theme}>
{children}
</div>
)
}
组件首先使用默认值(light)渲染,然后在水合后更新,导致不正确内容的可见闪烁。
正确:无闪烁,无水合不匹配
function ThemeWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="theme-wrapper">
{children}
</div>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme') || 'light';
var el = document.getElementById('theme-wrapper');
if (el) el.className = theme;
} catch (e) {}
})();
`,
}}
/>
</>
)
}
内联脚本在显示元素之前同步执行,确保 DOM 已经具有正确的值。无闪烁,无水合不匹配。
此模式对于主题切换、用户偏好、身份验证状态以及任何应立即渲染而不闪烁默认值的仅客户端数据特别有用。
6.6 使用 Activity 组件进行显示/隐藏
影响力: 中 (保留状态/DOM)
使用 React 的 <Activity> 来为频繁切换可见性的昂贵组件保留状态/DOM。
用法:
import { Activity } from 'react'
function Dropdown({ isOpen }: Props) {
return (
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ExpensiveMenu />
</Activity>
)
}
避免昂贵的重渲染和状态丢失。
6.7 使用显式条件渲染
影响力: 低 (防止渲染 0 或 NaN)
当条件可能为 0、NaN 或其他会渲染的假值时,使用显式三元运算符 (? :) 而不是 && 进行条件渲染。
错误:当 count 为 0 时渲染 "0"
function Badge({ count }: { count: number }) {
return (
<div>
{count && <span className="badge">{count}</span>}
</div>
)
}
// 当 count = 0, 渲染: <div>0</div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>
正确:当 count 为 0 时不渲染任何内容
function Badge({ count }: { count: number }) {
return (
<div>
{count > 0 ? <span className="badge">{count}</span> : null}
</div>
)
}
// 当 count = 0, 渲染: <div></div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>
7. JavaScript 性能
影响力: 中低
对热路径的微优化可以累积成有意义的改进。
7.1 批量 DOM CSS 更改
影响力: 中 (减少重排/重绘)
避免通过一次修改一个属性的方式更改样式。通过类或 cssText 将多个 CSS 更改组合在一起,以最大程度地减少浏览器重排 (reflows)。
错误:多次重排
function updateElementStyles(element: HTMLElement) {
// 每一行都会触发一次重排
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
element.style.border = '1px solid black'
}
正确:添加类 - 单次重排
// CSS 文件
.highlighted-box {
width: 100px;
height: 200px;
background-color: blue;
border: 1px solid black;
}
// JavaScript
function updateElementStyles(element: HTMLElement) {
element.classList.add('highlighted-box')
}
正确:改变 cssText - 单次重排
function updateElementStyles(element: HTMLElement) {
element.style.cssText = `
width: 100px;
height: 200px;
background-color: blue;
border: 1px solid black;
`
}
React 例子:
// 错误:逐个更改样式
function Box({ isHighlighted }: { isHighlighted: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current && isHighlighted) {
ref.current.style.width = '100px'
ref.current.style.height = '200px'
ref.current.style.backgroundColor = 'blue'
}
}, [isHighlighted])
return <div ref={ref}>内容</div>
}
// 正确:切换类
function Box({ isHighlighted }: { isHighlighted: boolean }) {
return (
<div className={isHighlighted ? 'highlighted-box' : ''}>
内容
</div>
)
}
尽可能使用 CSS 类而不是内联样式。类会被浏览器缓存,并提供更好的关注点分离。
7.2 为重复查找构建索引 Map
影响力: 中低 (1M 操作 -> 2K 操作)
同一键的多次 .find() 调用应使用 Map。
错误 (每次查找 O(n)):
function processOrders(orders: Order[], users: User[]) {
return orders.map(order => ({
...order,
user: users.find(u => u.id === order.userId)
}))
}
正确 (每次查找 O(1)):
function processOrders(orders: Order[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
return orders.map(order => ({
...order,
user: userById.get(order.userId)
}))
}
构建一次 Map (O(n)),然后所有查找都是 O(1)。
对于 1000 个订单 × 1000 个用户:100万次操作 → 2000 次操作。
7.3 在循环中缓存属性访问
影响力: 中低 (减少查找)
在热路径中缓存对象属性查找。
错误:3 次查找 × N 次迭代
for (let i = 0; i < arr.length; i++) {
process(obj.config.settings.value)
}
正确:总过 1 次查找
const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
process(value)
}
7.4 缓存重复函数调用
影响力: 中 (避免冗余计算)
当在渲染期间使用相同的输入重复调用相同的函数时,使用模块级 Map 缓存函数结果。
错误:冗余计算
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// slugify() 对相同的项目名称调用了 100+ 次
const slug = slugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
正确:缓存结果
// 模块级缓存
const slugifyCache = new Map<string, string>()
function cachedSlugify(text: string): string {
if (slugifyCache.has(text)) {
return slugifyCache.get(text)!
}
const result = slugify(text)
slugifyCache.set(text, result)
return result
}
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// 每个唯一的项目名称仅计算一次
const slug = cachedSlugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
单值函数的更简单模式:
let isLoggedInCache: boolean | null = null
function isLoggedIn(): boolean {
if (isLoggedInCache !== null) {
return isLoggedInCache
}
isLoggedInCache = document.cookie.includes('auth=')
return isLoggedInCache
}
// 身份验证更改时清除缓存
function onAuthChange() {
isLoggedInCache = null
}
使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。
7.5 缓存 Storage API 调用
影响力: 中低 (减少昂贵的 I/O)
localStorage, sessionStorage 和 document.cookie 是同步且昂贵的。在内存中缓存读取。
错误:每次调用都读取存储
function getTheme() {
return localStorage.getItem('theme') ?? 'light'
}
// 调用 10 次 = 10 次存储读取
正确:Map 缓存
const storageCache = new Map<string, string | null>()
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key))
}
return storageCache.get(key)
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value)
storageCache.set(key, value) // 保持缓存同步
}
使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。
Cookie 缓存:
let cookieCache: Record<string, string> | null = null
function getCookie(name: string) {
if (!cookieCache) {
cookieCache = Object.fromEntries(
document.cookie.split('; ').map(c => c.split('='))
)
}
return cookieCache[name]
}
重要:在外部更改时失效
window.addEventListener('storage', (e) => {
if (e.key) storageCache.delete(e.key)
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
storageCache.clear()
}
})
如果存储可以在外部更改(另一个标签页、服务器设置的 cookie),请使缓存失效。
7.6合并多个数组迭代
影响力: 中低 (减少迭代)
多个 .filter() 或 .map() 调用会多次迭代数组。合并为一个循环。
错误:3 次迭代
const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)
正确:1 次迭代
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []
for (const user of users) {
if (user.isAdmin) admins.push(user)
if (user.isTester) testers.push(user)
if (!user.isActive) inactive.push(user)
}
7.7 数组比较前先检查长度
影响力: 中高 (避免长度不同时的昂贵操作)
在通过昂贵操作(排序、深度相等、序列化)比较数组时,先检查长度。如果长度不同,数组就不可能相等。
在实际应用中,当比较运行在热路径(事件处理程序、渲染循环)中时,此优化通过尤为有价值。
错误:总是运行昂贵的比较
function hasChanges(current: string[], original: string[]) {
// 即使长度不同,也总是进行排序和连接
return current.sort().join() !== original.sort().join()
}
即使 current.length 是 5 而 original.length 是 100,也会运行两次 O(n log n) 排序。连接数组和比较字符串也有开销。
正确 (先进行 O(1) 长度检查):
function hasChanges(current: string[], original: string[]) {
// 如果长度不同,提前返回
if (current.length !== original.length) {
return true
}
// 仅当长度匹配时才排序/连接
const currentSorted = current.toSorted()
const originalSorted = original.toSorted()
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true
}
}
return false
}
这种新方法更高效,因为:
-
当长度不同时,它避免了排序和连接数组的开销
-
它避免了消耗内存来连接字符串(对于大数组尤其重要)
-
它避免了修改原始数组
-
发现差异时提前返回
7.8 函数提前返回
影响力: 中低 (避免不必要的计算)
确当定结果时提前返回,以跳过不必要的处理。
错误:即使找到答案也处理所有项目
function validateUsers(users: User[]) {
let hasError = false
let errorMessage = ''
for (const user of users) {
if (!user.email) {
hasError = true
errorMessage = 'Email required'
}
if (!user.name) {
hasError = true
errorMessage = 'Name required'
}
// 即使发现错误也继续检查所有用户
}
return hasError ? { valid: false, error: errorMessage } : { valid: true }
}
正确:一发现错误立即返回
function validateUsers(users: User[]) {
for (const user of users) {
if (!user.email) {
return { valid: false, error: 'Email required' }
}
if (!user.name) {
return { valid: false, error: 'Name required' }
}
}
return { valid: true }
}
7.9 提升 RegExp 创建
影响力: 中低 (避免重新创建)
不要在 render 内部创建 RegExp。提升到模块作用域或使用 useMemo() 进行记忆化。
错误:每次渲染都创建新的 RegExp
function Highlighter({ text, query }: Props) {
const regex = new RegExp(`(${query})`, 'gi')
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
正确:记忆化或提升
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function Highlighter({ text, query }: Props) {
const regex = useMemo(
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
[query]
)
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
警告:全局 regex 具有可变状态
const regex = /foo/g
regex.test('foo') // true, lastIndex = 3
regex.test('foo') // false, lastIndex = 0
全局 regex (/g) 具有可变的 lastIndex 状态。
7.10 使用循环求最小/最大值而非排序
影响力: 低 (O(n) 而非 O(n log n))
查找最小或最大元素只需要遍历数组一次。排序是浪费且更慢的。
错误 (O(n log n) - 排序以查找最新):
interface Project {
id: string
name: string
updatedAt: number
}
function getLatestProject(projects: Project[]) {
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
return sorted[0]
}
仅为了查找最大值而对整个数组进行排序。
错误 (O(n log n) - 排序以查找最旧和最新):
function getOldestAndNewest(projects: Project[]) {
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}
仅需要最小/最大值时仍然不必要地排序。
正确 (O(n) - 单次循环):
function getLatestProject(projects: Project[]) {
if (projects.length === 0) return null
let latest = projects[0]
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt > latest.updatedAt) {
latest = projects[i]
}
}
return latest
}
function getOldestAndNewest(projects: Project[]) {
if (projects.length === 0) return { oldest: null, newest: null }
let oldest = projects[0]
let newest = projects[0]
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
}
return { oldest, newest }
}
单次遍历数组,无复制,无排序。
替代方案:Math.min/Math.max 用于小数组
const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)
这对于小数组有效,但对于非常大的数组,由于展开运算符的限制,可能会更慢。为了可靠性,建议使用循环方法。
7.11 使用 Set/Map 进行 O(1) 查找
影响力: 中低 (O(n) -> O(1))
将数组转换为 Set/Map 以进行重复的成员身份检查。
错误 (每次检查 O(n)):
const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))
正确 (每次检查 O(1)):
const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))
7.12 使用 toSorted() 代替 sort() 以保证不可变性
影响力: 中高 (防止 React 状态中的变异 bug)
.sort() 会原地修改数组,这可能会导致 React 状态和 props 出现 bug。使用 .toSorted() 创建一个新的排序数组而不进行变异。
错误:修改原始数组
function UserList({ users }: { users: User[] }) {
// 修改了 users prop 数组!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
正确:创建新数组
function UserList({ users }: { users: User[] }) {
// 创建新的排序数组,原始数组未更改
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
为什么这在 React 中很重要:
-
Props/state 变异打破了 React 的不可变性模型 - React 期望 props 和 state 被视为只读
-
导致闭包陷阱 bug - 在闭包(回调、effects)内修改数组可能导致意外行为
浏览器支持:旧版浏览器回退
// 旧版浏览器的回退
const sorted = [...items].sort((a, b) => a.value - b.value)
.toSorted() 在所有现代浏览器(Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+)中均可用。对于旧环境,使用展开运算符。
其他不可变数组方法:
-
.toSorted()- 不可变排序 -
.toReversed()- 不可变反转 -
.toSpliced()- 不可变拼接 -
.with()- 不可变元素替换
8. 高级模式
影响力: 低
针对需要谨慎实现的特定情况的高级模式。
8.1 在 Refs 中存储事件处理程序
影响力: 低 (稳定的订阅)
当在不应因回调更改而重新订阅的 effect 中使用时,将回调存储在 refs 中。
错误:每次渲染都重新订阅
function useWindowEvent(event: string, handler: () => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}
正确:稳定的订阅
import { useEffectEvent } from 'react'
function useWindowEvent(event: string, handler: () => void) {
const onEvent = useEffectEvent(handler)
useEffect(() => {
window.addEventListener(event, onEvent)
return () => window.removeEventListener(event, onEvent)
}, [event])
}
替代方案:如果你使用的是最新版 React,请使用 useEffectEvent:
useEffectEvent 为相同的模式提供了更清晰的 API:它创建一个稳定的函数引用,该引用始终调用处理程序的最新版本。
8.2 使用 useLatest 获取稳定的回调 Refs
影响力: 低 (防止 effect 重新运行)
在不将值添加到依赖数组的情况下访问回调中的最新值。防止 effect 重新运行,同时避免闭包陷阱。
实现:
function useLatest<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
错误:在每次回调更改时重新运行 effect
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
}
正确:稳定的 effect,新鲜的回调
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchRef = useLatest(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchRef.current(query), 300)
return () => clearTimeout(timeout)
}, [query])
}
参考资料
前端路由不再难:React Router 从入门到工程化
React从入门到出门第九章 资源加载新特性Suspense 原生协调原理与实战
NASA项目一些关键代码展示
client 文件夹下
结构
![]()
app.js
import {
BrowserRouter as Router,
} from "react-router-dom";
import {
Arwes,
SoundsProvider,
ThemeProvider,
createSounds,
createTheme,
} from "arwes";
import AppLayout from "./pages/AppLayout";
import { theme, resources, sounds } from "./settings";
const App = () => {
return <ThemeProvider theme={createTheme(theme)}>
<SoundsProvider sounds={createSounds(sounds)}>
<Arwes animate background={resources.background.large} pattern={resources.pattern}>
{anim => (
<Router>
<AppLayout show={anim.entered} />
</Router>
)}
</Arwes>
</SoundsProvider>
</ThemeProvider>;
};
export default App;
setting.js
const resources = {
background: {
small: "/img/background-small.jpg",
medium: "/img/background-medium.jpg",
large: "/img/background-large.jpg",
},
pattern: "/img/glow.png",
};
const sounds = {
shared: {
volume: 0.5,
},
players: {
click: {
sound: { src: ["/sound/click.mp3"] },
settings: { oneAtATime: true }
},
typing: {
sound: { src: ["/sound/typing.mp3"] },
settings: { oneAtATime: true }
},
deploy: {
sound: { src: ["/sound/deploy.mp3"] },
settings: { oneAtATime: true }
},
success: {
sound: {
src: ["/sound/success.mp3"],
volume: 0.2,
},
settings: { oneAtATime: true }
},
abort: {
sound: { src: ["/sound/abort.mp3"] },
settings: { oneAtATime: true }
},
warning: {
sound: { src: ["/sound/warning.mp3"] },
settings: { oneAtATime: true }
},
}
};
const theme = {
color: {
content: "#a1ecfb",
},
padding: 20,
responsive: {
small: 600,
medium: 800,
large: 1200
},
typography: {
headerFontFamily: '"Titillium Web", "sans-serif"',
},
};
export {
resources,
sounds,
theme,
};
pages/Launch.js
import { useMemo } from "react";
import { Appear, Button, Loading, Paragraph } from "arwes";
import Clickable from "../components/Clickable";
const Launch = props => {
const selectorBody = useMemo(() => {
return props.planets?.map(planet =>
<option value={planet.keplerName} key={planet.keplerName}>{planet.keplerName}</option>
);
}, [props.planets]);
const today = new Date().toISOString().split("T")[0];
return <Appear id="launch" animate show={props.entered}>
<Paragraph>Schedule a mission launch for interstellar travel to one of the Kepler Exoplanets.</Paragraph>
<Paragraph>Only confirmed planets matching the following criteria are available for the earliest scheduled missions:</Paragraph>
<ul>
<li>Planetary radius < 1.6 times Earth's radius</li>
<li>Effective stellar flux > 0.36 times Earth's value and < 1.11 times Earth's value</li>
</ul>
<form onSubmit={props.submitLaunch} style={{display: "inline-grid", gridTemplateColumns: "auto auto", gridGap: "10px 20px"}}>
<label htmlFor="launch-day">Launch Date</label>
<input type="date" id="launch-day" name="launch-day" min={today} max="2040-12-31" defaultValue={today} />
<label htmlFor="mission-name">Mission Name</label>
<input type="text" id="mission-name" name="mission-name" />
<label htmlFor="rocket-name">Rocket Type</label>
<input type="text" id="rocket-name" name="rocket-name" defaultValue="Explorer IS1" />
<label htmlFor="planets-selector">Destination Exoplanet</label>
<select id="planets-selector" name="planets-selector">
{selectorBody}
</select>
<Clickable>
<Button animate
show={props.entered}
type="submit"
layer="success"
disabled={props.isPendingLaunch}>
Launch Mission ✔
</Button>
</Clickable>
{props.isPendingLaunch &&
<Loading animate small />
}
</form>
</Appear>
};
export default Launch;
pages/AppLayout.js
import {
useState,
} from "react";
import {
Switch,
Route,
} from "react-router-dom";
import {
Frame,
withSounds,
withStyles,
} from "arwes";
import usePlanets from "../hooks/usePlanets";
import useLaunches from "../hooks/useLaunches";
import Centered from "../components/Centered";
import Header from "../components/Header";
import Footer from "../components/Footer";
import Launch from "./Launch";
import History from "./History";
import Upcoming from "./Upcoming";
const styles = () => ({
content: {
display: "flex",
flexDirection: "column",
height: "100vh",
margin: "auto",
},
centered: {
flex: 1,
paddingTop: "20px",
paddingBottom: "10px",
},
});
const AppLayout = props => {
const { sounds, classes } = props;
const [frameVisible, setFrameVisible] = useState(true);
const animateFrame = () => {
setFrameVisible(false);
setTimeout(() => {
setFrameVisible(true);
}, 600);
};
const onSuccessSound = () => sounds.success && sounds.success.play();
const onAbortSound = () => sounds.abort && sounds.abort.play();
const onFailureSound = () => sounds.warning && sounds.warning.play();
const {
launches,
isPendingLaunch,
submitLaunch,
abortLaunch,
} = useLaunches(onSuccessSound, onAbortSound, onFailureSound);
const planets = usePlanets();
return <div className={classes.content}>
<Header onNav={animateFrame} />
<Centered className={classes.centered}>
<Frame animate
show={frameVisible}
corners={4}
style={{visibility: frameVisible ? "visible" : "hidden"}}>
{anim => (
<div style={{padding: "20px"}}>
<Switch>
<Route exact path="/">
<Launch
entered={anim.entered}
planets={planets}
submitLaunch={submitLaunch}
isPendingLaunch={isPendingLaunch} />
</Route>
<Route exact path="/launch">
<Launch
entered={anim.entered}
planets={planets}
submitLaunch={submitLaunch}
isPendingLaunch={isPendingLaunch} />
</Route>
<Route exact path="/upcoming">
<Upcoming
entered={anim.entered}
launches={launches}
abortLaunch={abortLaunch} />
</Route>
<Route exact path="/history">
<History entered={anim.entered} launches={launches} />
</Route>
</Switch>
</div>
)}
</Frame>
</Centered>
<Footer />
</div>;
};
export default withSounds()(withStyles(styles)(AppLayout));
pages/History.js
import { useMemo } from "react";
import { Appear, Table, Paragraph } from "arwes";
const History = props => {
const tableBody = useMemo(() => {
return props.launches?.filter((launch) => !launch.upcoming)
.map((launch) => {
return <tr key={String(launch.flightNumber)}>
<td>
<span style={
{color: launch.success ? "greenyellow" : "red"}
}>█</span>
</td>
<td>{launch.flightNumber}</td>
<td>{new Date(launch.launchDate).toDateString()}</td>
<td>{launch.mission}</td>
<td>{launch.rocket}</td>
<td>{launch.customers?.join(", ")}</td>
</tr>;
});
}, [props.launches]);
return <article id="history">
<Appear animate show={props.entered}>
<Paragraph>History of mission launches including SpaceX launches starting from the year 2006.</Paragraph>
<Table animate>
<table style={{tableLayout: "fixed"}}>
<thead>
<tr>
<th style={{width: "2rem"}}></th>
<th style={{width: "3rem"}}>No.</th>
<th style={{width: "9rem"}}>Date</th>
<th>Mission</th>
<th style={{width: "7rem"}}>Rocket</th>
<th>Customers</th>
</tr>
</thead>
<tbody>
{tableBody}
</tbody>
</table>
</Table>
</Appear>
</article>;
}
export default History;
pages/Upcoming.js
import { useMemo } from "react";
import {
withStyles,
Appear,
Link,
Paragraph,
Table,
Words,
} from "arwes";
import Clickable from "../components/Clickable";
const styles = () => ({
link: {
color: "red",
textDecoration: "none",
},
});
const Upcoming = props => {
const {
entered,
launches,
classes,
abortLaunch,
} = props;
const tableBody = useMemo(() => {
return launches?.filter((launch) => launch.upcoming)
.map((launch) => {
return <tr key={String(launch.flightNumber)}>
<td>
<Clickable style={{color:"red"}}>
<Link className={classes.link} onClick={() => abortLaunch(launch.flightNumber)}>
✖
</Link>
</Clickable>
</td>
<td>{launch.flightNumber}</td>
<td>{new Date(launch.launchDate).toDateString()}</td>
<td>{launch.mission}</td>
<td>{launch.rocket}</td>
<td>{launch.target}</td>
</tr>;
});
}, [launches, abortLaunch, classes.link]);
return <Appear id="upcoming" animate show={entered}>
<Paragraph>Upcoming missions including both SpaceX launches and newly scheduled Zero to Mastery rockets.</Paragraph>
<Words animate>Warning! Clicking on the ✖ aborts the mission.</Words>
<Table animate show={entered}>
<table style={{tableLayout: "fixed"}}>
<thead>
<tr>
<th style={{width: "3rem"}}></th>
<th style={{width: "3rem"}}>No.</th>
<th style={{width: "10rem"}}>Date</th>
<th style={{width: "11rem"}}>Mission</th>
<th style={{width: "11rem"}}>Rocket</th>
<th>Destination</th>
</tr>
</thead>
<tbody>
{tableBody}
</tbody>
</table>
</Table>
</Appear>;
}
export default withStyles(styles)(Upcoming);
hooks/usePlanets.js
import { useCallback, useEffect, useState } from "react";
import { httpGetPlanets } from "./requests";
function usePlanets() {
const [planets, savePlanets] = useState([]);
const getPlanets = useCallback(async () => {
const fetchedPlanets = await httpGetPlanets();
savePlanets(fetchedPlanets);
}, []);
useEffect(() => {
getPlanets();
}, [getPlanets]);
return planets;
}
export default usePlanets;
其他可以看仓库
server 文件夹下
![]()
app.js
const express = require('express')
const cors = require('cors');
const path = require('path');
const morgan = require('morgan');
const api = require('./routes/api')
const app = express()
// 日志记录位置尽量 早
app.use(morgan('combined'))
app.use(cors({
origin: 'http://localhost:3000',
}));
app.use(express.json())
app.use(express.static(path.join(__dirname, '..', 'public')))
app.use('/v1', api)
// 确保第一页打开就是 index.html 内容
app.get('/*', (req, res) => {
res.sendFile(path.join(__dirname, '..','public','index.html'));
} )
module.exports = app
server.js
const http = require('http');
require('dotenv').config()
const {mongoConnect} = require('./services/mongo')
const app = require('./app')
const {loadPlanetsData} = require('./models/planets.model')
const {loadLaunchData} = require('./models/launches.model')
const PORT = process.env.PORT || 8000
const server = http.createServer(app)
async function startServer() {
await mongoConnect()
await loadPlanetsData()
await loadLaunchData()
server.listen(PORT,() => {
console.log(`Listening on ${PORT}`);
});
}
startServer()
models/launches.model.js
const axios = require('axios');
const launchesDatabase = require('./launches.mongo')
const planets = require('./planets.mongo')
const DEFAULT_FLIGHT_NUMBER = 100
const launch = {
flightNumber: 100, // flight_number
mission: 'Kepler Exploration X', // name
rocket: 'Explorer IS1', // rocket.name
launchDate: new Date('December 27, 2030'), // date_local
target: 'Kepler-442 b', // not applicable
customers:['ZTM','NASA'], // payloads.customers for each payload
upcoming:true, // upcoming
success: true // success
}
saveLaunch(launch)
async function findLaunch(filter){
return await launchesDatabase.findOne(filter)
}
async function existsLaunchWithId(launchId) {
return await launchesDatabase.findOne({
flightNumber: launchId
})
}
async function getLatestFlightNumber(){
// findOne()用于查找匹配查询条件的第一条记录
// sort('-flightNumber')用于按照flightNumber字段降序排列结果
const latestLaunch = await launchesDatabase.findOne().sort('-flightNumber')
if(!latestLaunch) return DEFAULT_FLIGHT_NUMBER
return latestLaunch.flightNumber
}
async function getAllLaunches(skip, limit) {
return await launchesDatabase
.find(
{},{
"_id":0,
"__v":0
})
.skip(skip)
.limit(limit)
}
async function saveLaunch(launch) {
await launchesDatabase.findOneAndUpdate({
flightNumber: launch.flightNumber,
}, launch, {
upsert: true,
})
}
async function scheduleNewLaunch(launch) {
const planet = await planets.findOne({
keplerName: launch.target
})
if(!planet){
throw new Error('Not matching planet found')
}
const newFlightNumber = await getLatestFlightNumber() + 1
const newLaunch = Object.assign(launch, {
success: true,
upcoming: true,
customers:['ZTM','NASA'],
flightNumber: newFlightNumber
})
await saveLaunch(newLaunch)
}
async function abortLaunchById(launchId) {
const aborted = await launchesDatabase.updateOne({
flightNumber: launchId
},{
upcoming: false,
success: false,
})
return aborted.modifiedCount === 1
// const aborted = launches.get(launchId)
// aborted.success = false
// aborted.upcoming = false
// return aborted
}
async function populateLaunches(){
const response = await axios.post(SPACEX_API_URL,{
query: {},
options:{
// 不分页 拿到所有数据
pagination:false,
populate:[
{
path: 'rocket',
select:{
name:1
}
},
{
path: 'payloads',
select:{
customers:1
}
}
]
}
})
const launchDocs = response.data.docs
for(const launchDoc of launchDocs){
const payloads = launchDoc.payloads
// 使用 flatMap 将嵌套数组扁平化
const customers = payloads.flatMap(payload => payload.customers)
const launch = {
flightNumber: launchDoc.flight_number,
mission:launchDoc.name,
rocket: launchDoc.rocket.name,
launchDate: launchDoc.date_local,
customers,
upcoming: launchDoc.upcoming,
success: launchDoc.success
}
// console.log('launch',`${launch.flightNumber} ${launch.mission}`);
await saveLaunch(launch);
}
if(response.status !== 200) {
console.log('Problem downloading launch data');
}
}
const SPACEX_API_URL = 'https://api.spacexdata.com/v4/launches/query'
async function loadLaunchData(){
const firstLaunch = await findLaunch({
flightNumber:1,
rocket:'Falcon 1',
mission:'FalconSat'
})
if(firstLaunch){
console.log('Launch data already loaded');
}else{
await populateLaunches()
}
}
module.exports = {
getAllLaunches,
scheduleNewLaunch,
existsLaunchWithId,
abortLaunchById,
loadLaunchData,
}
routes/launches/launches.router.js
const express = require('express');
const {httpGetAllLaunch, httpAddLaunch, httpAbortLaunch} = require('./launches.controller')
const launchesRouter = express.Router();
launchesRouter.get('/', httpGetAllLaunch);
launchesRouter.post('/', httpAddLaunch);
launchesRouter.delete('/:id', httpAbortLaunch);
module.exports = launchesRouter;
仓库
TypeScript:JavaScript 的“防坑装甲”,写代码不再靠玄学!
从
console.log()到类型安全,我用 TS 把 Bug 拒之门外
🚀 开篇:你有没有被 JavaScript “背刺”过?
想象一下这个场景:
你信心满满地提交了 PR,CI 流水线绿得发亮。
结果上线 5 分钟后,用户反馈:“点按钮没反应!”
你打开控制台一看——
Uncaught TypeError: Cannot read property 'map' of undefined
……原来是你把 users 写成了 user,但 JS 并没有在你敲错的那一刻提醒你。
JavaScript 是个自由奔放的朋友,但它不负责帮你兜底。
而 TypeScript?它是那个在你写错时就拍桌子说:“兄弟,这不行!”的严谨搭档。
💡 TypeScript 是什么?一句话说清
TypeScript = JavaScript + 静态类型系统 + 编译时检查 + 更聪明的 IDE
它不是一门新语言,而是 JavaScript 的“超集”(Superset)——所有合法的 JS 代码都是合法的 TS 代码。
但反过来,TS 能在你写代码的时候就揪出潜在问题,而不是等到用户点击才崩溃。
🛠️ 安装?一行命令搞定!
npm install -g typescript
然后你就可以用 tsc 编译 .ts 文件,或者直接用 Vite / Create React App 等现代工具链,它们早已原生支持 TS。
✨ TypeScript 的五大“超能力”
1️⃣ 静态类型:让变量“有身份”
let name: string = "掘金读者";
name = 123; // ❌ 编译时报错!
不再是“万物皆可赋值”,每个变量都有明确的身份卡。
2️⃣ 边写边查错:IDE 实时预警
VS Code + TS = 黄金搭档。
你刚打错一个属性名,红色波浪线立刻出现:“亲,这个对象没有 useName,只有 username 哦~”
3️⃣ 编译时拦截 Bug,不让错误上生产
JS 是“运行时才知道错”,TS 是“写完就告诉你错”。
省下的不只是 debug 时间,更是半夜被 PagerDuty 叫醒的噩梦。
4️⃣ 智能提示 & 自动文档
接口定义即文档:
interface Todo {
id: number;
title: string;
completed: boolean;
}
当你在组件里用 todo. 时,IDE 自动弹出 id、title、completed —— 不用翻文档,代码自己会说话!
5️️⃣ 清理“僵尸代码”
TS 会警告你:“这个变量声明了但从没用过!”
包括那些藏在角落的 console.log('调试中...') —— 重构时再也不怕残留垃圾代码。
🧪 实战:用 TS + Zustand 打造一个“坚不可摧”的 TodoList
我们不用 Redux(太重),也不用手搓 useState(太乱)。
Zustand + TypeScript = 轻量、简洁、类型安全的状态管理!
📦 状态定义:接口先行
// types/todo.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
🧠 自定义 Hook:useTodos
// hooks/useTodos.ts
import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>(() => getStorage('todos', []));
useEffect(() => {
setStorage('todos', todos);
}, [todos]);
const addTodo = (title: string) => {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false
};
setTodos([...todos, newTodo]);
};
const toggleTodo = (id: number) => {
setTodos(todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
};
const removeTodo = (id: number) => {
setTodos(todos.filter(t => t.id !== id));
};
return { todos, addTodo, toggleTodo, removeTodo };
}
🔍 注意:所有函数参数、返回值、状态都带类型!
即使半年后回来看代码,也能秒懂数据结构。
🎨 组件 Props 也用接口约束
// components/TodoInput.tsx
interface Props {
onAdd: (title: string) => void;
}
const TodoInput: React.FC<Props> = ({ onAdd }) => {
// ...
}
父子组件传参?TS 保证你不会传错类型、漏掉属性!
😂 对比:JS vs TS 的“加法”哲学
// JavaScript:自由但危险
function add(a, b) {
return a + b; // 可能是 15,也可能是 "105"!
}
add(10, "5"); // 输出 "105" —— 你懵了?
// TypeScript:明确且安全
function add(a: number, b: number): number {
return a + b;
}
add(10, "5"); // ❌ 类型错误!编译不过!
TS 不是限制你,而是保护你。
🧩 TS 的类型魔法箱(简要彩蛋)
-
联合类型:
type ID = string | number; -
元组:
let user: [number, string] = [1, "Tom"]; -
枚举:
enum Status { Pending, Success, Failed } -
泛型:
function getStorage<T>(key: string, def: T): T -
unknown vs any:
unknown更安全,强制类型检查!
初学者别怕
any,它是“救命稻草”,但别让它变成“懒人借口”。
🌟 结语:TS 不是负担,而是生产力加速器
有人说:“TS 学习成本高。”
但我想说:调试一个线上 Bug 的成本,远高于写一个类型注解。
大型项目、团队协作、长期维护 —— TS 就是你的“代码保险”。
用 JS 写代码,靠运气;用 TS 写代码,靠实力。
所以,别再让 undefined is not a function 成为你项目的日常梗了。
拥抱 TypeScript,让你的代码既健壮又优雅。
✅ 今日行动建议:
- 在现有项目中新建一个
.ts文件试试- 给你的 React 组件 Props 加上
interface- 把
useState的初始值加上泛型<T>小步快跑,渐进式升级,你离“类型安全”只差一次尝试。
TypeScript:更加安全规范的JavaScript
前言
曾经有一份真挚的 JavaScript 代码摆在我面前,我没有珍惜。直到 Uncaught TypeError: Cannot read property of undefined 这种红色报错占满屏幕,我才后悔莫及。如果上天能够给我一个再来一次的机会,我会对那个变量说三个字:“定类型!”。
如果非要在这份类型上加一个期限,我希望是——TypeScript。
今天我们不聊高大上的架构,只聊怎么让你写代码时“心里有底”。我们要从最基础的弱类型痛点讲起,一路杀到 TypeScript 的核心——泛型,最后用 React 撸一个 实战小组件 TodoList。
系好安全带,我们要发车了!
第一章:JS 的温柔陷阱与 TS 的铁血秩序
1.1 弱类型的“二义性”之痛
JavaScript 是一个非常“随和”的语言,随和到什么程度?它允许你胡作非为。
看看这段代码
function add(a, b) {
// js 是弱类型的优势:好学,易上手
// 也就是这种“随和”,让你在大型项目中痛不欲生
return a + b; // 二义性:是加法?还是字符串拼接?
}
const res = add("1", "2"); // 结果是 "12",而不是 3
console.log(res);
在你写下 add 的那一刻,你心里想的是数学加法。但 JS 运行时心想:“嘿,大哥给我俩字符串,那我给你拼起来呗。”
这就是 动态语言 的特点:Bug 只有在运行的时候才会发生。在大型项目中,这就像在排雷,你永远不知道哪一行代码会在用户点击按钮时爆炸。要保证 99.999% 不出问题,靠人脑去记 a 是数字还是字符串,简直是天方夜谭。
1.2 TypeScript:给JS穿上外骨骼
TypeScript 是什么?官方说它是 JS 的超集。
在集合论中,如果集合 A 包含了集合 B 的所有元素,并且集合 A 还有 B 没有的东西,那么 A 就是 B 的超集。通俗点说,它是 JS 的亲爹,专门负责管教这个熊孩子。
TS 是 强类型、静态语言。它在代码编译阶段(运行前)就对其进行检查。
// 强类型可以杜绝 90% 的低级错误
// 其中,前两个:number规定的是参数的数据类型,最后一个:number规定的是函数返回值的数据类型
function addTs(a: number, b: number): number {
return a + b;
}
// const result = addTs("1", "2"); // 报错!编译都不让你过!
const result = addTs(1, 2);
console.log(result);
这就是 TS 的核心价值:把错误扼杀在摇篮里。它不仅是类型约束,更是你免费的“结对编程伙伴”,时刻提醒你:“兄弟,这里不能传字符串。”
1. 安装编译器 (TSC)
打开终端,运行:
npm install -g typescript
- 验证是否安装成功:输入 tsc -v,看到版本号即成功。
2. 编译(翻译)
在终端运行:
tsc index.ts
这时你会发现文件夹里多了一个 index.js 文件。这就是“翻译”后的结果。
3. 运行
运行生成的 JS 文件:
node index.js
第二章:TS 基础武器库 —— 不仅仅是加个冒号
在进入实战前,我们需要清点一下武器库。很多新手把 TS 写成了 AnyScript,遇见报错就加 any,这不仅违背了初衷,甚至让代码比原生 JS 更难维护。
TypeScript 的类型系统其实非常庞大,为了方便记忆,我们把它们分为五大类:基本底座、特殊兵种、对象建模、集合容器、以及逻辑运算。
2.1 基本底座:JS 的老朋友与新面孔
基本数据类型: boolean, number, string, null, undefined, symbol, bigint
这部分大家最熟悉,它们直接对应 JavaScript 的原始类型。但在 TS 中,它们变得更加“铁面无私”。
// 1. 老三样:一板一眼
let isDone: boolean = false;
let age: number = 18; // 支持十进制、十六进制等
let name: string = "Tom";
// 2. 只有在 ES2020+ 才有的新贵
// bigint: 处理超大整数,记得在 tsconfig 中开启 ES2020
let bigNumber: bigint = 100n;
// symbol: 独一无二的标识
let sym: symbol = Symbol("key");
// 3. 让人头疼的空值:null 和 undefined
// 在 strictNullChecks: true (严格模式) 下,它们不能赋值给 number 等其他类型
let u: undefined = undefined;
let n: null = null;
// let num: number = undefined; // ❌ 报错!别想蒙混过关
2.2 特殊兵种:虚空与黑洞
这是 TS 特有的概念,理解它们是脱离新手村的标志。
1. Any vs Unknown:放纵与克制
新手最爱用 any,但资深工程师偏爱 unknown。
// any: 放弃治疗,跳过检查 (逃生舱)
let aa: any = 1;
aa = "111";
aa.hello(); // ✅ 编译通过,但运行爆炸!这是 JS 的原罪
// unknown: 未知类型 (更安全的 Any)
let bb: unknown = 1;
bb = "hello";
// bb.hello(); // ❌ 报错!TS 说:我不确定它是啥,你不许乱动
// 必须先“验身” (类型收窄) 才能用
if (typeof bb === 'string') {
console.log(bb.toUpperCase()); // ✅ 现在安全了
}
2. Void vs Never:空无一物与万劫不复
// void: 空。通常用于函数没有返回值
function logMessage(): void {
console.log("只是打印一下,不返回东西");
}
// never: 绝不。表示永远不会有结果的类型 (黑洞)
// 场景1: 抛出错误,函数提前终结,执行不到结尾
function error(message: string): never {
throw new Error(message);
}
// 场景2: 死循环
function loop(): never {
while (true) {}
}
2.3 对象建模:描述世界的形状
在 TS 中,我们主要用两种方式描述对象:接口 (interface) 和 类型别名 (type)。
// 1. 字面量类型 (Literal Types)
// 只有 "male" 或 "female" 才是合法值,其他字符串不行
type Gender = "male" | "female";
// 2. 接口 (Interface):就像签订契约,适合定义对象形状
interface User {
name: string;
age: number;
gender: Gender;
readonly id: number; // 只读属性,不可篡改
hobby?: string; // 可选属性,有了更好,没有也行
[key: string]: any; // 索引签名:允许有额外的任意属性
}
const u: User = {
name: "李四",
age: 18,
gender: "female",
id: 1,
school: "Qinghua" // ✅ 匹配索引签名
};
// 3. 小写的 object
// 代表非原始类型 (即不是 number/string/boolean...)
// 很少直接用,因为它太宽泛了,你无法访问里面的属性
function create(o: object | null): void {}
create({ prop: 0 }); // OK
// create(42); // Error
2.4 集合容器:数组与元组
// 1. 数组:两种写法
let list1: number[] = [1, 2, 3]; // 写法一:简洁(推荐)
let list2: Array<string> = ["a", "b"]; // 写法二:泛型写法(逼格高,且 foreshadow 了后面的泛型章节)
// 2. 元组 (Tuple):一种特殊的数组
// 它是定长、定类型的。React 的 useState 就是返回一个元组
let x: [string, number];
x = ["hello", 10]; // OK
// x = [10, "hello"]; // Error,顺序不对
2.5 高级逻辑:组合与枚举
最后,我们需要一些工具来处理复杂的类型关系。
1. 枚举 (Enum):让魔法数字滚出代码
不要在代码里写 if (status === 2),鬼知道 2 是什么。
enum Status {
Pending = 0,
Success = 1,
Failed = 2,
}
let s: Status = Status.Pending;
// 可读性爆炸:Status.Success 比 s = 1 强一万倍
2. 联合 (Union) 与 交叉 (Intersection)
这是类型的“逻辑或”与“逻辑与”。
// 联合类型 (|):是 A 或者 B
// 就像 ID,可能是数字 ID,也可能是字符串 UUID
type ID = string | number;
function printId(id: ID) {
// 这里需要注意,只能访问 string 和 number 共有的方法
// 或者通过 typeof 判断类型
}
// 交叉类型 (&):是 A 并且也是 B
// 常用于对象合并
interface A { name: string }
interface B { age: number }
type C = A & B; // C 必须同时拥有 name 和 age
const person: C = {
name: "Tony",
age: 35
};
老司机总结:
- 能用 unknown 别用 any。
- 能用 interface 描述对象就先用 interface。
- 看到 | 竖线是“或者”,看到 & 符号是“合体”。
- 基础打牢,后面讲泛型才不会晕车。
第三章:TS 的核武器 —— 泛型 (Generics)
好,前面的都是开胃菜。接下来我们要讲 TS 中最难理解但也最强大的特性:泛型。
很多同学看泛型就像看天书,看到 就头大。其实,泛型就是类型的“传参” 。
3.1 为什么需要泛型?
想象一下,你要写一个函数,把传入的内容原样返回。
如果不这用泛型:
function echo(arg: number): number { return arg; } // 只能处理数字
function echoString(arg: string): string { return arg; } // 只能处理字符串
function echoAny(arg: any): any { return arg; } // 丧失了类型信息,传入 string 返回 any
我们希望:我传入什么类型,你就自动识别为什么类型,并保证返回值也是那个类型。
3.2 泛型实战:能够“变形”的容器
让我们看看项目中的 storages.ts,这是泛型最经典的应用场景:
// T 是一个占位符,就像函数的参数一样
// 当你调用 getStorage<User> 时,所有的 T 都会变成 User
export function getStorage<T>(key: string, defaultValue: T): T {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : defaultValue;
}
代码解析:
-
getStorage:告诉 TS,这个函数有一个“类型变量”叫 T。
-
defaultValue: T:默认值的类型必须是 T。
-
: T (返回值):函数返回的类型也是 T。
优势:
当我们存储 Todo 列表时,我们可以这样用:
// 哪怕 localStorage 本质存储的是字符串
// 通过泛型,res 自动获得了 Todo[] 的类型提示!
const res = getStorage<Todo[]>("todos", []);
// res.map... // 这里的 map 里面会自动提示 Todo 的属性!
如果你不用泛型,JSON.parse 返回的是 any,你后续对数据的操作将失去所有类型保护。泛型,让你的通用工具函数不仅通用,而且安全。
第四章:React + TS 全栈实战 —— TodoList 架构解析
现在,我们把 TS 的知识应用到 React 项目中。不要小看一个 TodoList,麻雀虽小,五脏俱全。我们会按照企业级的代码规范来组织结构。
4.1 项目结构:井井有条
观察我们的文件树,这是一个非常标准的分层结构:
src
├── components // 纯展示组件 (UI)
├── hooks // 自定义 Hooks (逻辑核心)
├── types // 类型定义 (契约)
├── utils // 工具函数 (泛型的高发地)
├── App.tsx // 根组件
└── assets
为什么要这样分?
- 关注点分离 :UI 归 UI,逻辑归逻辑,类型归类型。
- 可维护性 :当你想修改数据结构时,去 types;当你想修改业务逻辑时,去 hooks。
4.2 Step 1: 定义灵魂 —— Model (types/Todo.ts)
一切开发,先定数据结构。这是 TS 开发者的直觉。
// types/Todo.ts
// 接口用来约定对象必须实现的属性和方法
// export 导出,供全项目使用
export interface Todo {
id: number;
title: string;
completed: boolean;
}
有了这个 Todo 接口,全项目凡是涉及到 todo item 的地方,都有了标准。
4.3 Step 2: 逻辑抽离 —— Custom Hook (hooks/useTodos.ts)
在 App.tsx 里写一堆 useState 和 useEffect 是新手的做法。资深工程师会把业务逻辑抽离。
这里我们用到了刚才讲的 泛型 和 接口:
import { useState, useEffect } from "react";
import type { Todo } from "../types/Todo"; // 显式引入 type
import { getStorage, setStorage } from "../utils/storages";
export default function useTodos() {
// 泛型应用:useState<Todo[]>
// 告诉 React,这个状态是一个 Todo 类型的数组
const [todos, setTodos] = useState<Todo[]>(() =>
// 泛型应用:getStorage<Todo[]>
// 从本地存储取出来的一定是 Todo[]
getStorage<Todo[]>("todos", [])
);
useEffect(() => {
// 泛型应用:setStorage<Todo[]>
setStorage<Todo[]>("todos", todos);
}, [todos]);
const addTodo = (title: string) => {
const newTodo: Todo = {
id: +new Date(),
title,
completed: false,
};
// 这里如果写 newTodo.xxx = 123,TS 马上会报错,因为 Todo 接口里没定义 xxx
setTodos([...todos, newTodo]);
};
// ... toggleTodo, removeTodo 省略
return { todos, addTodo, toggleTodo, removeTodo };
}
亮点分析:
- useState<Todo[]>:这保证了 todos 变量在使用数组方法(如 .map, .filter)时,回调函数里的参数自动推断为 Todo 类型。
- 逻辑复用:如果以后要把 TodoList 移植到别的页面,直接引入这个 Hook 即可。
4.4 Step 3: 组件开发与 Props 约束 (components/*.tsx)
在 React + TS 中,组件最重要的就是定义 Props 的接口。
TodoInput.tsx:
import * as React from "react";
// 定义 Props 接口
// 清晰地告诉调用者:你要用我,必须给我一个 onAdd 函数,参数是 string,没返回值
interface Props {
onAdd: (title: string) => void;
}
// React.FC<Props>:
// FC = Function Component。泛型 P = Props。
// 这让 TS 知道 TodoInput 是一个组件,且接受符合 Props 接口的参数
const TodoInput: React.FC<Props> = ({ onAdd }) => {
const [value, setValue] = React.useState<string>("");
// ... 逻辑
};
TodoList.tsx:
import type { Todo } from "../types/Todo";
import TodoItem from "./TodoItem";
import * as React from "react";
interface Props {
todos: Todo[]; // 核心数据
onToggle: (id: number) => void; // 回调
onRemove: (id: number) => void;
}
const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
return (
<ul>
{/* 因为 todos 被定义为 Todo[],这里的 map 里 todo 自动识别为 Todo 类型 */}
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
);
};
注意看:
在 TodoList 组件中,当我们在 map 里面渲染 TodoItem 时,如果忘记传 onRemove,IDE 会立刻划红线报
错。这就叫编译时检查。这比在浏览器里跑半天发现按钮没反应要强一万倍。
4.5 Step 4: 拼装 (App.tsx)
最后,我们在 App.tsx 里把所有积木搭起来。
import useTodos from "./hooks/useTodos";
import TodoList from "./components/TodoList";
import TodoInput from "./components/TodoInput";
export default function App() {
// 就像去超市买东西,直接拿想要的逻辑
const { todos, addTodo, toggleTodo, removeTodo } = useTodos();
return (
<div>
<h1>TodoList</h1>
{/* TS 检查:addTodo 的类型匹配 TodoInput 的 props 要求吗?匹配! */}
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
</div>
);
}
第五章:细节拆分
一、 FC
在 React + TypeScript 的开发语境下,FC 是 Function Component(函数式组件)的缩写。
它是 React 官方类型库(@types/react)提供的一个泛型接口,用来专门定义“函数组件”的类型。
简单来说,它的作用就是告诉 TypeScript:“嘿,这个变量不仅仅是一个普通的函数,它是一个 React 组件。 ”
1. 它的全貌
在代码中,你通常这样看到它:
// React.FC<Props>
// ^^ ^^
// || ||
// 接口名称 泛型(传入组件的Props类型)
const TodoInput: React.FC<Props> = ({ onAdd }) => { ... }
2. FC 到底帮我们做了什么?
当你把一个组件标注为 React.FC 时,TypeScript 会自动帮你做这几件事:
A. 约定返回值
它强制要求这个函数的返回值必须是 JSX 元素(或者 null)。如果你不小心返回了一个对象或者 undefined,TS 会立刻报错。
B. 泛型传参 (最重要的功能)
它接受一个泛型参数(就是尖括号 <> 里的东西)。
比如 React.FC< Props>,这意味着:
- 这个组件接收的 props 参数,必须符合 Props 接口的定义。
- 你在组件内部使用 props 时,会有自动补全提示。
- 父组件在使用这个组件时,必须传递 Props 里规定的属性,少传或错传都会报错。
C. 提供静态属性类型 (相对少用)
它还包含了组件的一些静态属性定义,比如 displayName、propTypes、defaultProps(注:defaultProps 在函数组件中已不推荐使用)。
3. 一个需要注意的“坑”:Children (React 18 的变化)
这是面试或实战中常遇到的知识点。
-
在 React 17 及以前:
React.FC 实际上自带了一个隐含的属性 children。也就是说,即使你的 Props 接口里是空的,你也可以在组件里写 {props.children}。
但这被认为是不安全的,因为有些组件本来就不该包含子元素。 -
在 React 18 (现在) :
React.FC 移除了 隐式的 children。
如果你的组件需要包含子元素(比如一个 ... 组件),你需要显式地在接口里定义它:// React 18+ 的正确姿势 interface Props { title: string; children?: React.ReactNode; // 必须手动加上这一行,否则报错 } const Layout: React.FC<Props> = ({ title, children }) => { return ( <div> <h1>{title}</h1> {children} </div> ); }
二、 storage.js中的 T 是什么?
在 storages.ts 那个文件中,T 代表 Type (类型) 。
它是 TypeScript 中 泛型 (Generics) 的标准占位符。
你可以把 T 看作是一个 “类型的变量” 或者 “类型的占位符” 。就像你在数学函数
f(x)=x+1f(x)=x+1
中,x 代表任意数字一样;在 TS 中,T 代表任意类型。
我们来深入剖析一下 getStorage 这个函数:
// 1. 定义泛型变量 <T>
export function getStorage<T>(key: string, defaultValue: T): T {
// ...
}
1. 把它拆解来看
这里的 T 出现了三次,分别代表不同的含义:
-
getStorage (声明):
这是在告诉 TypeScript:“嘿,老兄,我现在定义一个函数。我不确定用户将来要存取什么类型的数据,可能是数字,可能是字符串,也可能是 Todo 对象。所以我先用 T 占个坑。” 在这里的T就相当于一个声明,方便后续读取使用
-
defaultValue: T (参数约束):
这表示:“传入的默认值,必须和 T 是同一种类型。” 你不能一边说 T 是数字,一边传个字符串做默认值。 -
: T (返回值约束):
这表示:“这个函数运行结束吐出来的数据,一定也是 T 类型。”
2. 它是如何“变身”的?
泛型的神奇之处在于,当你调用函数的时候,T 才会确定它到底是什么。
让我们看看在 useTodos.ts 是怎么用的:
// 场景一:获取 Todo 列表
getStorage<Todo[]>("todos", []);
当你写下 <Todo[]> 的那一瞬间,TypeScript 会在后台自动把所有的 T 替换掉:
-
function getStorage 变成 -> function getStorage<Todo[]>
-
defaultValue: T 变成 -> defaultValue: Todo[] (所以第二个参数必须传数组 [])
-
返回值 : T 变成 -> : Todo[]
如果换个场景:
// 场景二:获取一个计数器
getStorage<number>("count", 0);
此时,所有的 T 瞬间变成了 number。
3. 为什么要用 T?(不用行不行?)
如果你不用泛型,你只能面临两个糟糕的选择:
糟糕选择 A:写死类型
function getStorage(key: string, val: number): number { ... }
这样这个函数就废了,只能存取数字,存取 Todo 列表还得再写一个函数。
糟糕选择 B:使用 any
function getStorage(key: string, val: any): any { ... }
这是最常见的错误。虽然函数通用了,但当你拿到返回值时,它是 any。你敲代码时,IDE 无法提示你有 todo.title 还是 todo.name。你失去了 TS 所有的保护。
第六章:总结与思考
6.1 为什么这一套流程是“高质量”的?
-
类型即文档:你看一眼 interface Props 或 interface Todo,就知道数据长什么样,不用去猜后端返回的 JSON 到底有没有 id 字段。
-
泛型的妙用:在 utils/storages.ts 和 hooks/useTodos.ts 中,泛型极大地提高了代码的复用性和安全性。它让我们可以写出既通用又类型严格的代码。
-
开发体验 (DX) :智能提示(IntelliSense)让你敲代码如飞,重构代码时也不用担心漏改了哪个文件。
6.2 给初学者的建议
-
不要害怕报错:TS 的红色波浪线不是在骂你,而是在救你。
-
多用 Interface:养成先定义数据结构,再写业务逻辑的习惯。
-
理解泛型:把泛型想象成一个“类型插槽”,它是 TS 进阶的分水岭。
-
拒绝 Any:如果实在不知道写什么类型,先写 unknown,或者去查文档,不要轻易妥协用 any。
6.3 结语
从 JavaScript 到 TypeScript,是一次思维的升级。它让你从“大概也许可能是这样”变成了“肯定是这样”。在 AI 全栈的时代,代码的健壮性尤为重要。
希望这篇文章能帮你推开 TypeScript 的大门。记住,类型不是枷锁,而是你的铠甲。
现在,打开你的 IDE,把那个 .js 后缀改成 .ts,开始你的重构之旅吧!
Zustand:若 React 组件是公民,谁来当“中央银行”?—— 打造轻量级企业级状态管理
前言:在 React 的世界里,如果说组件(Component)是勤勤恳恳工作的公民,那么状态(State)就是流动的货币。当应用规模扩大,仅仅靠父子组件间的“现金交易”(Props drilling)已经无法维持经济系统的运转。我们需要一个中央银行,一个专业的财务管理部门。
今天,我们不谈繁琐的 Redux,而是聊聊 Zustand —— 这个来自德国的“小而美”的状态管理库,看看它是如何通过极其精简的 API,帮我们把“企业做大做强”的。
一、 为什么我们需要“中央银行”?
我们在写 React 组件时,心中的公式往往是:
但在实际开发中,如果不引入全局状态管理,我们面临着几个痛点:
- 层级地狱:想要把孙子的状态传给爷爷,Props 需要传递数层。
- 兄弟失联:兄弟组件之间无法直接通信,状态必须提升(Lifting State Up)到共同父级,导致不必要的重渲染。
“企业做大做强,请管理财务、状态以及修改状态的规矩。”
Zustand 就是这样一个基于 Hooks 思想实现的中央管理系统。它将状态存入 Store(仓库),实现全局共享,且不需要在最外层包裹繁琐的 Provider。
二、 建立你的第一家“分行”:基础状态管理
让我们从最简单的计数器开始。在 Zustand 中,创建一个 Store 就像开一家分店一样简单。
1. 定义规矩(Interface)与 存储(Store)
在“企业管理”中,不仅要有钱(Count),还要有动用这笔钱的规矩(Actions)。
TypeScript
// store/counter.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// 1. 定义账本的结构:即使是小钱,也要有类型约束
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
// 2. 创建金库,并制定修改规则
export const useCounterStore = create<CounterState>()(
persist( // 使用中间件,相当于给金库加了把“永久保存”的锁
(set) => ({
// 列出资产(状态)
count: 0,
// 状态要怎么改?必须通过合法的手段(Action)
// set 函数是 Zustand 的核心,它是唯一合法的修改器
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}),
{
name: 'counter-storage', // 存到 localStorage 里的名字
}
)
);
核心解读:
-
create: 建立仓库。 -
set: 这是唯一的“财务审批笔”。你不能直接state.count++,必须通过set返回一个新的对象。这保证了数据的不可变性(Immutability) 。 -
persist: 这是 Zustand 的杀手锏中间件。它自动将状态同步到localStorage,刷新页面数据不丢失。
三、 处理复杂资产:对象与数组的不可变性
当我们的资产不仅仅是数字,而是复杂的待办事项列表(TodoList)或用户信息(User)时,不可变性的操作显得尤为重要。
1. Todo List 的增删改查
在 useTodoStore 中,我们看到了数组操作的标准范式:
TypeScript
// store/todo.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Todo } from '../types';
export interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
}
export const useTodoStore = create<TodoState>()(
persist(
(set) => ({
todos: [],
// 新增:利用解构 [...old, new] 创建新数组
addTodo: (text: string) =>
set((state) => ({
todos: [...state.todos, {
id: Date.now(),
text,
completed: false
}]
})),
// 切换状态:利用 map 生成新数组,不修改原对象
toggleTodo: (id: number) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
})),
// 删除:利用 filter 过滤
removeTodo: (id: number) =>
set((state) => ({
todos: state.todos.filter(todo => todo.id !== id)
})),
}),
{ name: 'todos-storage' }
)
);
深度思考:
这里的 map、filter 和展开运算符 ... 不是为了炫技,而是为了配合 React 的更新机制。React 依赖引用的变化来感知更新,如果我们直接 todos.push(),引用不变,UI 就不会刷新。这就是“修改状态的规矩” 。
2. 用户鉴权状态
同样的逻辑适用于用户信息管理:
TypeScript
// store/user.ts
interface UserState {
isLoggin: boolean;
user: User | null;
login: (user: User) => void;
logout: () => void;
}
// ... 代码省略,逻辑同上,利用 set 同时更新多个字段
四、 消费状态:在 UI 组件中提款
有了银行(Store),组件(App.tsx)就可以轻松地存取数据了。Zustand 的 Hook API 让这一切变得像使用 useState 一样自然。
TypeScript
// App.tsx
import { useCounterStore } from './store/counter';
import { useTodoStore } from './store/todo';
function App() {
// 1. 直接提取所需的 State 和 Actions
// 就像从 ATM 取钱一样简单
const { count, increment, decrement, reset } = useCounterStore();
const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
// 2. 结合本地 UI 逻辑 (Input value)
const [inputValue, setInputValue] = useState("");
const handleAdd = () => {
if (!inputValue.trim()) return;
addTodo(inputValue); // 调用 Store 的 Action
setInputValue("");
}
return (
<div>
{/* UI 渲染逻辑,完全解耦 */}
<h1>Count: {count}</h1>
<button onClick={increment}>+1</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{/* 这里的 toggleTodo 直接来自 Store */}
<span onClick={() => toggleTodo(todo.id)}>
{todo.text}
</span>
</li>
))}
</ul>
</div>
)
}
五、 总结:Zustand 的企业级管理哲学
回到开头提到的代码注释: “专业管理状态,修改状态的规矩” 。
Zustand 相比于其他工具,胜在平衡:
-
极简主义:没有 Boilerplate(样板代码),没有
Provider包裹,即装即用。 -
规矩严明:通过 TypeScript 接口定义
State,通过Actions封装修改逻辑。组件只负责“触发”,Store 负责“执行”。 -
持久化:
persist中间件让数据存储变得透明化。
如果把 React 应用比作一家公司,useState 是员工口袋里的零花钱,而 Zustand 就是那个高效、透明且严格执行财务制度的财务部。
别再折磨自己了!放弃 Redux 后,我用 Zustand + TS 爽到起飞
前言:Redux 真的太难了...
作为一个刚入坑 React 不久的小白,我最近真的被状态管理搞得头皮发麻!
跟着教程学 Redux,一会儿 Action,一会儿 Reducer,一会儿又是 Selector... 我只是想存个数字,却要写一堆模板代码,文件切来切去,人都绕晕了。直到昨天,我在社区看到大佬安利 Zustand,号称只有 1KB,而且不用包组件,不用写 Provider。
我不信邪试了一下... 哇!这也太香了吧!
它写起来就像原生 JS 一样简单粗暴,配合 TypeScript 的智能提示,简直是为我们这种“手残党”量身定做的!今天就迫不及待把我的学习笔记(源码)分享给大家,希望能帮到同样迷茫的小伙伴!
第一关:从最简单的计数器开始
以前用 Redux 写个计数器要建好几个文件,用 Zustand 居然只要一个函数就搞定?
TypeScript
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
// create 后面接个泛型 <CounterState>,TS 马上就知道里面有什么
export const useCounterStore = create<CounterState>()((set, get) => ({
// 状态直接列出来,清晰明了!
n: 1, // 虽然接口里没定义这个,先放着(小声bb)
count: 0,
// 👇 这里我要自我检讨一下!
// 为了省事我用了 any... 大佬们轻喷
// set((state: any) => ...)
// 其实是因为我刚学 TS,有时候类型报错搞不定就用 any 大法保平安
// 大家千万别学我,后面我会改进的!
increment: () => set((state: any) => ({ count: state.count + 1 })),
decrement: () => set((state: any) => ({ count: state.count - 1 })),
// 这种直接重置的写法太舒服了,不用深拷贝什么的
reset: () => set({ count: 0 })
}));
小白心得:
虽然代码里那一坨 any 有点辣眼睛,但你们看这个逻辑!没有 switch-case,没有 dispatch,就是简单的函数调用!这才是人类该写的代码啊!
第二关:Todo List + 持久化魔法
接下来的需求是做一个待办事项列表。这里我发现 Zustand 有个超级厉害的中间件叫 persist。
以前我要把数据存到 localStorage,得在 useEffect 里写好几行。现在?只要配置一行代码! 刷新页面数据居然真的还在,当时我就震惊了!😲
TypeScript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// 先定义清楚我们的 Todo 长什么样,TS 的好处体现出来了
export interface Todo {
id: number,
title: string,
completed: boolean,
}
export interface TodoState {
todos: Todo[],
addTodo: (title: string) => void,
removeTodo: (id: number) => void,
toggleTodo: (id: number) => void,
}
// 这里的 <TodoState> 就像给代码装了导航仪
// 在写下面的 set 函数时,它会自动提示 todos 属性,太爽了
export const useTodoStore = create<TodoState>()(
persist(
(set, get) => ({
todos: [],
addTodo: (text: string) =>
set((state) => ({
// 这里的 ...state.todos 是不可变数据的写法
// 虽然有点绕,但为了 React 能更新视图,我忍了!
todos: [...state.todos, {
id: + Date.now(),
title: text,
completed: false,
}]
})),
toggleTodo: (id: number) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ?
{...todo, completed: !todo.completed} // 反转状态
: todo
)
})),
removeTodo: (id: number) =>
set((state) => ({
todos: state.todos.filter(todo => todo.id !== id)
})),
}),
{
name: 'todos', // 👇 见证奇迹的时刻!只要这一行,自动存 LocalStorage
}
)
)
真香时刻:
只要加上 persist 和 { name: 'todos' },剩下的脏活累活 Zustand 全包了。这体验,简直是从原始社会直接跨入现代文明!🌆
第三关:用户登录 & 接口规范
最后是用户模块。以前写 JS 的时候,经常不知道 user 对象里到底有 username 还是 userName,拼错单词 debug 半天。
现在配合 TS 的 interface,把规矩立在前面:
TypeScript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// 定义用户长啥样
export interface User {
id: number,
username: string,
avatar?: string, // ? 表示头像可有可无
}
interface UserState {
isLoggin: boolean; // 虽然这里我想写 isLoggedIn,但不小心拼错了...
login: (user: { username: string; password: string }) => void;
logout: () => void;
user: User | null;
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
isLoggin: false,
// 登录逻辑简直到离谱,一行代码搞定状态切换
login: (user) => set({ isLoggin: true, user: null }),
logut: () => set({ isLoggin: false, user: null }),
user: null,
}),
{
name: 'user',
}
)
)
TS 初体验总结:
虽然定义 interface User 和 UserState 确实要多写几行代码,但在TRAE里写代码时,那种敲一个点 . 就能自动弹出属性的感觉,真的太有安全感了! 再也不怕因为手滑写错单词而报错了。
结尾碎碎念
作为一个前端萌新,我觉得 Zustand + TypeScript 简直是绝配!
- Zustand 负责简单(拒绝样板代码)。
- TypeScript 负责安全(拒绝低级错误)。
如果你也像我一样被 Redux 折磨得痛不欲生,赶紧去试试 Zustand 吧!入股不亏!