react:组件基础

这是本文档旧的修订版!


第五章:组件基础

函数组件是定义 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>;

类组件使用 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

组件可以相互组合,形成组件树。

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>
  );
}

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>;
}

为 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'
};

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 类型,获得编译时类型检查。

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(不渲染)

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>
    )} />
  );
}

高阶组件是一个函数,接收一个组件并返回一个新的组件。

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 用途

  • 权限控制
  • 日志记录
  • 数据获取
  • 样式增强

按功能组织

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

本章介绍了组件的基础知识:

  • 函数组件和类组件
  • 组件组合
  • Props 传递和使用
  • 默认 Props 和类型检查
  • children 和 render props
  • 高阶组件

组件是 React 的核心概念,理解组件的各种用法是掌握 React 的关键。

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 的执行时机。

理解 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('每次渲染都会执行');
});

某些副作用需要清理,例如订阅外部数据源、设置定时器、添加事件监听器等。如果在组件卸载时不清理这些副作用,可能会导致内存泄漏。

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>
  );
}

数据获取是 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>
  );
}

与类组件的生命周期方法不同,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 中,难以追踪和维护。

在同一个组件中,多个 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>;
}

依赖数组是 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]);

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>;
}

将常用的 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="搜索..."
    />
  );
}

避免无限循环

// 错误: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>;
}

useEffect 是 React 函数组件中处理副作用的核心工具。本章详细介绍了:

- useEffect 的基本语法和执行时机 - 清理函数的编写和作用 - 数据获取的最佳实践和竞态条件处理 - 多个 useEffect 的分离原则 - 依赖数组的正确使用 - useEffect 与 useLayoutEffect 的区别 - 自定义 Hook 的封装 - 常见陷阱和最佳实践

掌握 useEffect 需要理解 React 的渲染流程和数据流。建议在开发时:

- 保持依赖数组的完整性,使用 ESLint 辅助检查 - 将不相关的副作用分离到不同的 useEffect 中 - 合理处理清理逻辑,避免内存泄漏 - 对于复杂逻辑,考虑封装成自定义 Hook - 优先使用 useEffect,只在必要时使用 useLayoutEffect

useEffect 的强大之处在于它将副作用逻辑按照功能组织在一起,而不是分散在不同的生命周期方法中,这使得代码更加清晰、易于维护。随着实践的深入,你会越来越熟练地运用这一强大的工具来构建高质量的 React 应用。

该主题尚不存在

您访问的页面并不存在。如果允许,您可以使用创建该页面按钮来创建它。

  • react/组件基础.1775526769.txt.gz
  • 最后更改: 2026/04/07 09:52
  • 张叶安