这是本文档旧的修订版!
第五章:组件基础
5.1 函数组件
函数组件是定义 React 组件最简单的方式。它是一个接收 props 对象并返回 React 元素的 JavaScript 函数。
function Welcome(props) { return <h1>Hello, {props.name}</h1>; }
特点:
- 简洁:纯函数,没有生命周期方法和 this
- 无状态(Hooks 之前):只能接收 props,不能管理自己的 state
- 性能:没有实例化开销,渲染更快
使用箭头函数:
const Welcome = (props) => { return <h1>Hello, {props.name}</h1>; }; // 简写形式(隐式返回) const Welcome = (props) => <h1>Hello, {props.name}</h1>; // 解构 props const Welcome = ({ name }) => <h1>Hello, {name}</h1>;
5.2 类组件
类组件使用 ES6 的 class 语法定义,继承自 React.Component。
import React from 'react'; class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; } }
特点:
- 可以使用 state 管理组件内部状态
- 可以使用生命周期方法
- 有 this 关键字
- 需要 render() 方法返回 JSX
5.3 组件组合
组件可以相互组合,形成组件树。
function Welcome(props) { return <h1>Hello, {props.name}</h1>; } function App() { return ( <div> <Welcome name="张三" /> <Welcome name="李四" /> <Welcome name="王五" /> </div> ); }
提取组件:
当 UI 的一部分被多次使用,或者自身足够复杂时,可以将其提取为独立的组件。
// 原始代码 function Comment(props) { return ( <div className="Comment"> <div className="UserInfo"> <img className="Avatar" src={props.author.avatarUrl} alt={props.author.name} /> <div className="UserInfo-name"> {props.author.name} </div> </div> <div className="Comment-text"> {props.text} </div> <div className="Comment-date"> {formatDate(props.date)} </div> </div> ); } // 提取 Avatar 组件 function Avatar(props) { return ( <img className="Avatar" src={props.user.avatarUrl} alt={props.user.name} /> ); } // 提取 UserInfo 组件 function UserInfo(props) { return ( <div className="UserInfo"> <Avatar user={props.user} /> <div className="UserInfo-name"> {props.user.name} </div> </div> ); } // 重构后的 Comment 组件 function Comment(props) { return ( <div className="Comment"> <UserInfo user={props.author} /> <div className="Comment-text"> {props.text} </div> <div className="Comment-date"> {formatDate(props.date)} </div> </div> ); }
5.4 Props 详解
Props(properties 的缩写)是组件之间传递数据的方式。
传递 props:
function App() { return <User name="张三" age={25} isAdmin={true} />; }
接收 props:
function User(props) { return ( <div> <p>姓名:{props.name}</p> <p>年龄:{props.age}</p> <p>{props.isAdmin ? '管理员' : '普通用户'}</p> </div> ); } // 使用解构 function User({ name, age, isAdmin }) { return ( <div> <p>姓名:{name}</p> <p>年龄:{age}</p> <p>{isAdmin ? '管理员' : '普通用户'}</p> </div> ); }
Props 的只读性:
组件不能修改自己的 props。props 对于组件来说是只读的。
// 错误!props 是只读的 function Welcome(props) { props.name = 'Hello'; // 错误! return <h1>{props.name}</h1>; }
5.5 默认 Props
为 props 设置默认值:
函数组件:
function Button({ text = '点击', type = 'button' }) { return <button type={type}>{text}</button>; } // 或者 function Button(props) { const { text = '点击', type = 'button' } = props; return <button type={type}>{text}</button>; }
类组件:
class Button extends React.Component { static defaultProps = { text: '点击', type: 'button' }; render() { return <button type={this.props.type}>{this.props.text}</button>; } } // 或者在组件外部 Button.defaultProps = { text: '点击', type: 'button' };
5.6 PropTypes 类型检查
PropTypes 用于运行时类型检查。
安装:
npm install prop-types
使用:
import PropTypes from 'prop-types'; function User({ name, age, email }) { return ( <div> <p>{name}</p> <p>{age}</p> <p>{email}</p> </div> ); } User.propTypes = { name: PropTypes.string.isRequired, age: PropTypes.number, email: PropTypes.string, isAdmin: PropTypes.bool, hobbies: PropTypes.array, address: PropTypes.object, onClick: PropTypes.func, element: PropTypes.element, nodes: PropTypes.node, user: PropTypes.instanceOf(User), shape: PropTypes.shape({ color: PropTypes.string, fontSize: PropTypes.number }), oneOf: PropTypes.oneOf(['News', 'Photos']), oneOfType: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]), arrayOf: PropTypes.arrayOf(PropTypes.number), objectOf: PropTypes.objectOf(PropTypes.number), any: PropTypes.any }; User.defaultProps = { age: 18 };
TypeScript 替代方案:
如果使用 TypeScript,可以使用接口定义 props 类型,获得编译时类型检查。
5.7 children prop
children 是一个特殊的 prop,用于在组件标签之间传递内容。
function Card({ title, children }) { return ( <div className="card"> <div className="card-header">{title}</div> <div className="card-body">{children}</div> </div> ); } // 使用 function App() { return ( <Card title="欢迎使用"> <p>这是卡片的内容</p> <button>了解更多</button> </Card> ); }
children 的类型:
- React 元素
- 字符串或数字
- 数组(Fragment)
- 函数(render props)
- null、undefined、boolean(不渲染)
5.8 render props 模式
render props 是一种在 React 组件之间使用一个值为函数的 prop 共享代码的技术。
class MouseTracker extends React.Component { state = { x: 0, y: 0 }; handleMouseMove = (event) => { this.setState({ x: event.clientX, y: event.clientY }); }; render() { return ( <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}> {this.props.render(this.state)} </div> ); } } // 使用 function App() { return ( <MouseTracker render={({ x, y }) => ( <p>鼠标位置:({x}, {y})</p> )} /> ); }
5.9 高阶组件(HOC)
高阶组件是一个函数,接收一个组件并返回一个新的组件。
function withLogger(WrappedComponent) { return class extends React.Component { componentDidMount() { console.log('Component mounted:', WrappedComponent.name); } render() { return <WrappedComponent {...this.props} />; } }; } // 使用 const EnhancedComponent = withLogger(MyComponent);
常见的 HOC 用途:
- 权限控制
- 日志记录
- 数据获取
- 样式增强
5.10 组件的组织方式
按功能组织:
src/ ├── components/ # 可复用的 UI 组件 │ ├── Button/ │ │ ├── Button.js │ │ ├── Button.css │ │ └── Button.test.js │ └── Card/ ├── features/ # 功能模块 │ ├── User/ │ │ ├── UserList.js │ │ ├── UserForm.js │ │ └── userSlice.js │ └── Product/ ├── hooks/ # 自定义 Hooks ├── utils/ # 工具函数 ├── api/ # API 调用 └── App.js
按类型组织:
src/ ├── components/ # 所有组件 ├── containers/ # 容器组件 ├── actions/ # Redux actions ├── reducers/ # Redux reducers ├── utils/ # 工具函数 └── App.js
5.11 总结
本章介绍了组件的基础知识:
- 函数组件和类组件
- 组件组合
- Props 传递和使用
- 默认 Props 和类型检查
- children 和 render props
- 高阶组件
组件是 React 的核心概念,理解组件的各种用法是掌握 React 的关键。
useEffect 副作用管理
6.1 useEffect 简介
useEffect 是 React Hooks 中最重要、最基础的 Hook 之一,用于在函数组件中执行副作用操作。所谓副作用,是指那些不直接参与 UI 渲染、但会对组件外部产生影响的操作,例如数据获取、订阅、手动修改 DOM 等。
在类组件时代,我们通常使用生命周期方法(如 componentDidMount、componentDidUpdate、componentWillUnmount)来处理副作用。而 useEffect 的出现,让我们能够在函数组件中以更简洁、更统一的方式来处理这些场景。
基本语法:
import { useEffect } from 'react'; function Example() { useEffect(() => { // 副作用逻辑 console.log('组件挂载或更新'); // 可选的清理函数 return () => { console.log('清理工作'); }; }, [/* 依赖数组 */]); return <div>示例组件</div>; }
useEffect 接收两个参数:第一个参数是一个执行副作用的函数,该函数可以返回一个清理函数(可选);第二个参数是依赖数组,用于控制 effect 的执行时机。
6.2 useEffect 的执行时机
理解 useEffect 的执行时机是掌握它的关键。默认情况下,React 会在每次渲染完成后执行 useEffect 中的副作用函数。
挂载时执行:
当依赖数组为空数组 `[]` 时,effect 只在组件挂载时执行一次,类似于类组件的 componentDidMount。
function UserList() { useEffect(() => { console.log('组件挂载,开始获取用户数据'); fetchUsers(); }, []); // 空依赖数组,只在挂载时执行 return <div>用户列表</div>; }
依赖变化时执行:
当依赖数组中包含特定的 state 或 props 时,只有当这些依赖项发生变化时,effect 才会重新执行。
function Counter({ userId }) { const [count, setCount] = useState(0); // 当 userId 变化时执行 useEffect(() => { console.log(`用户ID变化为: ${userId}`); fetchUserData(userId); }, [userId]); // 当 count 变化时执行 useEffect(() => { console.log(`计数变化为: ${count}`); document.title = `点击了 ${count} 次`; }, [count]); return <button onClick={() => setCount(count + 1)}>点击 {count}</button>; }
每次渲染都执行:
如果不传递依赖数组,useEffect 会在每次组件渲染后都执行。这种方式在实际开发中较少使用,因为可能导致性能问题和无限循环。
useEffect(() => { console.log('每次渲染都会执行'); });
6.3 清理函数(Cleanup)
某些副作用需要清理,例如订阅外部数据源、设置定时器、添加事件监听器等。如果在组件卸载时不清理这些副作用,可能会导致内存泄漏。
useEffect 允许通过返回一个函数来指定清理逻辑。React 会在组件卸载时执行这个清理函数,也会在重新执行 effect 之前调用它来清理上一个 effect。
订阅和取消订阅:
function ChatRoom({ roomId }) { useEffect(() => { // 建立连接 const connection = createConnection(roomId); connection.connect(); console.log(`连接到房间: ${roomId}`); // 返回清理函数 return () => { connection.disconnect(); console.log(`断开房间: ${roomId} 的连接`); }; }, [roomId]); return <div>聊天房间: {roomId}</div>; }
当 roomId 从 “general” 变为 “random” 时,React 会依次执行: 1. 使用 “general” 运行的 effect 的清理函数,断开旧连接 2. 使用 “random” 运行的新 effect,建立新连接
定时器的清理:
function Timer() { const [seconds, setSeconds] = useState(0); useEffect(() => { const intervalId = setInterval(() => { setSeconds(s => s + 1); }, 1000); return () => { clearInterval(intervalId); console.log('定时器已清理'); }; }, []); return <div>已运行: {seconds} 秒</div>; }
事件监听器的清理:
function WindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { const handleResize = () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, []); return ( <div> 窗口宽度: {size.width}px<br /> 窗口高度: {size.height}px </div> ); }
6.4 数据获取模式
数据获取是 useEffect 最常见的使用场景之一。掌握正确的数据获取模式对于避免竞态条件和内存泄漏至关重要。
基本的数据获取:
function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let isCancelled = false; async function fetchUser() { try { setLoading(true); const response = await fetch(`/api/users/${userId}`); const data = await response.json(); // 检查组件是否已卸载或 userId 是否已改变 if (!isCancelled) { setUser(data); setError(null); } } catch (err) { if (!isCancelled) { setError(err.message); setUser(null); } } finally { if (!isCancelled) { setLoading(false); } } } fetchUser(); return () => { isCancelled = true; }; }, [userId]); if (loading) return <div>加载中...</div>; if (error) return <div>错误: {error}</div>; if (!user) return null; return ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); }
处理竞态条件:
当依赖项快速变化时(如用户快速切换选项),可能会出现竞态条件——先发出的请求后返回,导致显示错误的数据。使用取消标志或 AbortController 可以解决这个问题。
function SearchResults({ query }) { const [results, setResults] = useState([]); useEffect(() => { const controller = new AbortController(); async function search() { try { const response = await fetch(`/api/search?q=${query}`, { signal: controller.signal }); const data = await response.json(); setResults(data); } catch (error) { if (error.name === 'AbortError') { console.log('请求被取消'); return; } console.error('搜索失败:', error); } } if (query) { search(); } else { setResults([]); } return () => { controller.abort(); }; }, [query]); return ( <ul> {results.map(item => <li key={item.id}>{item.name}</li>)} </ul> ); }
6.5 多个 useEffect 的分离
与类组件的生命周期方法不同,useEffect 鼓励我们将相关的逻辑放在一起,而不是按生命周期阶段组织代码。这样可以更好地分离关注点,使代码更易读、更易维护。
分离不相关的副作用:
function Example() { const [count, setCount] = useState(0); const [user, setUser] = useState(null); // 处理计数相关的副作用 useEffect(() => { document.title = `点击了 ${count} 次`; }, [count]); // 处理用户数据获取 useEffect(() => { fetchUser().then(data => setUser(data)); }, []); // 处理订阅 useEffect(() => { const subscription = subscribeToNotifications(); return () => subscription.unsubscribe(); }, []); return <div>示例</div>; }
相比之下,如果使用类组件,这些逻辑都会混杂在 componentDidMount 和 componentDidUpdate 中,难以追踪和维护。
6.6 useEffect 的执行顺序
在同一个组件中,多个 useEffect 会按照它们在代码中出现的顺序依次执行。
function OrderExample() { useEffect(() => { console.log('Effect 1'); }, []); useEffect(() => { console.log('Effect 2'); }, []); useEffect(() => { console.log('Effect 3'); }, []); return <div>检查控制台输出顺序</div>; } // 输出顺序: Effect 1, Effect 2, Effect 3
清理函数的执行顺序与 effect 相反:先声明的 effect 后清理。
function CleanupOrderExample() { useEffect(() => { console.log('Effect 1 启动'); return () => console.log('Effect 1 清理'); }, []); useEffect(() => { console.log('Effect 2 启动'); return () => console.log('Effect 2 清理'); }, []); // 组件卸载时输出: // Effect 2 清理 // Effect 1 清理 return <div>组件</div>; }
6.7 常见的依赖问题
依赖数组是 useEffect 中最容易出错的部分。遗漏依赖或错误地添加依赖都会导致 bug。
遗漏依赖的问题:
// 错误示例:遗漏了 count 依赖 function Counter() { const [count, setCount] = useState(0); useEffect(() => { console.log(`当前计数: ${count}`); }, []); // 警告:count 应该在依赖数组中 return <button onClick={() => setCount(c => c + 1)}>增加</button>; }
上述代码中,effect 只在挂载时执行一次,之后 count 变化时不会重新执行,导致输出始终是初始值 0。
修复遗漏依赖:
useEffect(() => { console.log(`当前计数: ${count}`); }, [count]);
函数依赖的处理:
当 effect 中使用了函数时,需要将函数也加入依赖数组。但如果函数在每次渲染时都重新定义,会导致 effect 频繁执行。
// 问题:fetchData 每次渲染都重新定义,导致 effect 频繁执行 function UserList({ apiUrl }) { const [users, setUsers] = useState([]); const fetchData = async () => { const response = await fetch(apiUrl); const data = await response.json(); setUsers(data); }; useEffect(() => { fetchData(); }, [fetchData]); // fetchData 每次都变,导致无限循环 return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }
解决方案是将函数定义在 useEffect 内部,或使用 useCallback:
// 方案1:函数定义在 effect 内部 useEffect(() => { const fetchData = async () => { const response = await fetch(apiUrl); const data = await response.json(); setUsers(data); }; fetchData(); }, [apiUrl]); // 方案2:使用 useCallback const fetchData = useCallback(async () => { const response = await fetch(apiUrl); const data = await response.json(); setUsers(data); }, [apiUrl]); useEffect(() => { fetchData(); }, [fetchData]);
6.8 useEffect 与 useLayoutEffect
React 提供了两个类似的 Hook:useEffect 和 useLayoutEffect。它们的签名完全相同,但执行时机不同。
执行时机差异:
- useEffect:在浏览器绘制完成后异步执行,不会阻塞视觉更新 - useLayoutEffect:在浏览器绘制之前同步执行,会阻塞视觉更新
import { useLayoutEffect } from 'react'; function MeasureExample() { const divRef = useRef(null); const [width, setWidth] = useState(0); useLayoutEffect(() => { // 在浏览器绘制前测量 DOM const { width } = divRef.current.getBoundingClientRect(); setWidth(width); }, []); return ( <div ref={divRef}> 宽度: {width}px </div> ); }
何时使用 useLayoutEffect:
绝大多数情况下,应该使用 useEffect。只有当出现以下情况时才考虑 useLayoutEffect:
- 需要在浏览器绘制前测量 DOM 元素(如获取元素尺寸、位置) - 需要根据测量结果同步修改 DOM,避免视觉闪烁 - 需要同步重新渲染以修复视觉不一致
服务端渲染注意事项:
在服务端渲染(SSR)环境中,useLayoutEffect 会产生警告,因为服务端无法测量 DOM。解决方法是使用 useEffect 代替,或使用动态导入在客户端渲染。
import { useEffect, useLayoutEffect } from 'react'; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; function Component() { useIsomorphicLayoutEffect(() => { // 在服务端使用 useEffect,在客户端使用 useLayoutEffect }, []); return <div>组件</div>; }
6.9 自定义 Hook 封装 useEffect
将常用的 useEffect 逻辑封装成自定义 Hook 是一种良好的实践,可以提高代码的可复用性和可维护性。
useFetch Hook:
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); async function fetchData() { try { setLoading(true); const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { throw new Error(`HTTP 错误: ${response.status}`); } const result = await response.json(); setData(result); setError(null); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); setData(null); } } finally { setLoading(false); } } fetchData(); return () => controller.abort(); }, [url]); return { data, loading, error }; } // 使用 function UserList() { const { data: users, loading, error } = useFetch('/api/users'); if (loading) return <div>加载中...</div>; if (error) return <div>错误: {error}</div>; return ( <ul> {users.map(user => <li key={user.id}>{user.name}</li>)} </ul> ); }
useDebounce Hook:
function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; } // 使用 function SearchInput() { const [input, setInput] = useState(''); const debouncedInput = useDebounce(input, 500); useEffect(() => { if (debouncedInput) { performSearch(debouncedInput); } }, [debouncedInput]); return ( <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="搜索..." /> ); }
6.10 常见陷阱与最佳实践
避免无限循环:
// 错误:setState 导致重新渲染,形成无限循环 useEffect(() => { setCount(count + 1); }, [count]); // 正确:使用函数式更新 useEffect(() => { const timer = setTimeout(() => { setCount(c => c + 1); }, 1000); return () => clearTimeout(timer); }, []);
对象和数组依赖的处理:
对象和数组是引用类型,每次渲染都会创建新的引用,即使内容相同。
// 问题:options 每次都是新对象,导致 effect 每次都执行 useEffect(() => { fetchData(options); }, [options]); // 方案1:展开为原始值 useEffect(() => { fetchData({ page, size }); }, [page, size]); // 方案2:使用 useMemo const options = useMemo(() => ({ page, size }), [page, size]); useEffect(() => { fetchData(options); }, [options]);
依赖检查工具:
使用 ESLint 的 react-hooks/exhaustive-deps 规则可以自动检查依赖数组是否完整。
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
使用 ref 跳过首次渲染:
有时候我们只想在依赖更新时执行 effect,而不是在挂载时。
function useUpdateEffect(effect, deps) { const isFirst = useRef(true); useEffect(() => { if (isFirst.current) { isFirst.current = false; return; } return effect(); }, deps); } // 使用 function Component() { const [count, setCount] = useState(0); useUpdateEffect(() => { console.log('count 更新了:', count); }, [count]); return <button onClick={() => setCount(c => c + 1)}>增加</button>; }
6.11 总结
useEffect 是 React 函数组件中处理副作用的核心工具。本章详细介绍了:
- useEffect 的基本语法和执行时机 - 清理函数的编写和作用 - 数据获取的最佳实践和竞态条件处理 - 多个 useEffect 的分离原则 - 依赖数组的正确使用 - useEffect 与 useLayoutEffect 的区别 - 自定义 Hook 的封装 - 常见陷阱和最佳实践
掌握 useEffect 需要理解 React 的渲染流程和数据流。建议在开发时:
- 保持依赖数组的完整性,使用 ESLint 辅助检查 - 将不相关的副作用分离到不同的 useEffect 中 - 合理处理清理逻辑,避免内存泄漏 - 对于复杂逻辑,考虑封装成自定义 Hook - 优先使用 useEffect,只在必要时使用 useLayoutEffect
useEffect 的强大之处在于它将副作用逻辑按照功能组织在一起,而不是分散在不同的生命周期方法中,这使得代码更加清晰、易于维护。随着实践的深入,你会越来越熟练地运用这一强大的工具来构建高质量的 React 应用。