React 事件处理与组件化架构:合成事件系统底层原理全解析

课程:尚硅谷 React + TypeScript + 医通项目(2023年10月)
日期:第 2 天
主题:SyntheticEvent 合成事件、React 17 事件系统变革、受控/非受控组件、类组件 vs 函数组件深度对比、组件化架构设计模式


目录

  1. 名词解释:理解核心概念
  2. React 合成事件系统:从委托到合成的设计哲学
  3. React 17 事件系统重大变革
  4. 类组件 vs 函数组件:两种编程范式的深度对比
  5. 受控组件 vs 非受控组件:数据流哲学
  6. 组件化架构设计模式:HOC、Render Props 与自定义 Hook
  7. 实战要点与常见陷阱
  8. 本章小结与记忆口诀
  9. 面试考点精讲
  10. 交互式演示:合成事件系统可视化

1. 名词解释

术语定义类比
SyntheticEvent(合成事件)React 封装的跨浏览器事件对象,包裹原生事件并提供统一 API万能电源适配器,兼容各国插座
Event Delegation(事件委托)将事件监听器绑定在祖先节点,利用事件冒泡统一处理子元素事件前台接待员统一处理所有来访者,而非每个房间派驻员工
Event Pooling(事件池)React 复用 SyntheticEvent 对象以减少 GC 压力(已在 React 17 移除)共享单车:用完归还,下次再借
受控组件(Controlled Component)表单值由 React state 完全掌控,value + onChange 双向绑定木偶:由 React 拉着每根线
非受控组件(Uncontrolled Component)表单值存于 DOM 自身,通过 ref 在需要时读取自由人:自己管理自己,需要时才被查看
事件冒泡(Event Bubbling)事件从触发元素向上逐级传播到根节点水泡从水底升至水面
事件捕获(Event Capture)事件从根节点向下传播到目标元素从总部到分支的命令下达
HOC(高阶组件)接收组件返回增强组件的函数,用于逻辑复用装饰器模式:给原有功能穿上外衣
Render Props通过 render 属性传入函数,共享组件状态的模式把秘密武器借给你用,但你要自己操作
Custom Hook(自定义 Hook)use 开头的函数,封装可复用的有状态逻辑把一套熟练工序打包成工具箱

2. 合成事件系统底层原理

在这里插入图片描述

2.1 为什么需要合成事件?

浏览器原生事件存在严重的兼容性问题

// IE8 时代的噩梦
function handleEvent(event) {
  // IE 需要从 window.event 获取,其他浏览器从参数获取
  event = event || window.event;
  
  // 阻止默认行为:不同浏览器不同方法
  if (event.preventDefault) {
    event.preventDefault();        // 现代浏览器
  } else {
    event.returnValue = false;      // IE
  }
  
  // 停止冒泡:又是两套 API
  if (event.stopPropagation) {
    event.stopPropagation();        // 现代浏览器
  } else {
    event.cancelBubble = true;      // IE
  }
}

React 的解决方案是合成事件(SyntheticEvent):一个包裹原生事件的标准化接口,屏蔽了所有浏览器差异。

2.2 合成事件系统架构图

用户交互(点击按钮)
      │
      ▼
  原生 DOM 事件触发
      │
      ▼
  事件冒泡至 Root 容器(React 17+)
      │
      ▼
┌─────────────────────────────────────────┐
│         React 事件调度器                  │
│  ┌─────────────────────────────────┐    │
│  │  事件插件系统 (Event Plugin Hub)  │    │
│  │  - SimpleEventPlugin             │    │
│  │  - ChangeEventPlugin             │    │
│  │  - SelectEventPlugin             │    │
│  └─────────────────────────────────┘    │
│              │                          │
│              ▼                          │
│  ┌─────────────────────────────────┐    │
│  │  合成事件对象创建                  │    │
│  │  new SyntheticEvent(nativeEvent) │    │
│  └─────────────────────────────────┘    │
│              │                          │
│              ▼                          │
│  ┌─────────────────────────────────┐    │
│  │  遍历 Fiber 树,收集事件处理函数   │    │
│  │  按捕获/冒泡顺序排列执行队列       │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
      │
      ▼
  依次执行用户的 onClick 等回调

2.3 事件委托的工作原理

React 不会在每个 DOM 元素上注册事件监听。以 1000 个按钮为例:

// 你以为 React 这样做(每个按钮一个监听器)
// ❌ 错误理解
button1.addEventListener('click', handler1)
button2.addEventListener('click', handler2)
// ... 1000 个监听器

// ✅ React 实际做法:一个监听器统治全部
rootContainer.addEventListener('click', reactEventHandler)
// 当任意 button 被点击,事件冒泡至 root
// React 从 event.target 找到对应的 Fiber 节点
// 执行该 Fiber 节点上的 onClick 回调

内存节省计算

  • 假设每个事件监听器占 ~200 字节
  • 1000 个按钮 × 200 字节 = 200KB
  • 使用事件委托 = 仅 ~200 字节
  • 节省 99.9% 的内存

2.4 事件池机制(React 16 及以前)

React 16 通过对象池(Object Pool)复用事件对象,避免频繁的内存分配和垃圾回收:

// React 16 的事件池工作机制(伪代码)
class SyntheticEventPool {
  pool = [];
  
  acquire(nativeEvent) {
    const event = this.pool.pop() || new SyntheticEvent();
    event.assign(nativeEvent);  // 初始化属性
    return event;
  }
  
  release(event) {
    event.nullify();            // 所有属性置为 null
    this.pool.push(event);      // 归还到池中
  }
}

// 这就是为什么 React 16 中异步访问 event 会失败:
function handleClick(e) {
  console.log(e.target); // ✅ 同步访问:OK
  
  setTimeout(() => {
    console.log(e.target); // ❌ 异步访问:null!事件已被回收
  }, 0);
  
  // 解决方案:React 16 需要调用 e.persist()
  e.persist();
  setTimeout(() => {
    console.log(e.target); // ✅ persist 后异步访问:OK
  }, 0);
}

React 17 的改变:移除事件池,e.persist() 变为无操作(no-op)。现代浏览器的 GC 已足够高效,事件池的收益不再值得付出的复杂度代价。


3. React 17 事件系统重大变革

3.1 事件挂载点:document → root 容器

在这里插入图片描述

这是 React 17 最重要的底层变化,影响深远:

React 16(及以前)          React 17+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
<html>                        <html>
  <body>                        <body>
    <div id="root">               <div id="root">  ← 事件监听在这里
      <App />                       <App />
    </div>                        </div>
  </body>                      </body>
  ← 事件监听在 document         
</html>                       </html>

3.2 为什么要做这个改变?

场景 1:微前端架构

<!-- 同一页面中混用 React 16 和 React 17 的应用 -->
<div id="legacy-root">
  <!-- React 16 的旧应用 -->
  <LegacyApp />
</div>

<div id="new-root">
  <!-- React 17 的新功能模块 -->
  <NewFeatureModule />
</div>

React 16 中两个应用的事件都挂载到 document,互相干扰。
React 17 中,每个应用的事件只挂载到自己的根容器,完全隔离。

场景 2:与 jQuery 混用

// 过去的痛点
$(document).on('click', function(e) {
  e.stopPropagation(); // 这会阻止 React 收到 click 事件!
});

// 因为 React 16 也在 document 监听
// jQuery 先触发并 stopPropagation,React 的处理器就收不到了

// React 17 后,React 监听在 #root,不受 document 级别的干扰

3.3 代码层面的影响

// 如果你在代码中手动监听了 document 事件
// React 16:React 的事件处理在你之前(事件池还未回收)
// React 17:顺序可能改变,需要注意

// ⚠️ 升级 React 17 时的 Breaking Change
document.addEventListener('click', (e) => {
  // React 16: 此时 React 的合成事件已处理完毕
  // React 17: 此时 React 的合成事件可能还未处理
  console.log('document click');
});

3.4 onScroll 不再冒泡

// React 17 之前:onScroll 会冒泡
// 这会导致滚动内部元素时,外部容器意外触发滚动事件

// React 17 之后:onScroll 匹配原生浏览器行为,不冒泡
<div onScroll={handleOuterScroll}>   {/* React 17: 只有外层自己滚动才触发 */}
  <div style={{overflow: 'scroll', height: 200}}>
    {/* 滚动这个内部元素不再触发外部的 onScroll */}
  </div>
</div>

4. 类组件 vs 函数组件深度对比

4.1 内存模型的根本差异

类组件(Class Component)           函数组件(Function Component)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
实例化时创建对象:                    每次渲染调用函数:

  Counter 实例                         render 1 调用
  ┌─────────────────┐                  ┌──────────────────┐
  │ this.state = {  │                  │ count = 0 (闭包) │
  │   count: 0      │                  │ setCount = ...   │
  │ }               │                  └──────────────────┘
  │ this.render()   │                  
  │ this.handleClick│                  render 2 调用
  └─────────────────┘                  ┌──────────────────┐
         ↓ setState                    │ count = 1 (闭包) │
  ┌─────────────────┐                  │ setCount = ...   │
  │ this.state = {  │                  └──────────────────┘
  │   count: 1      │  ← 同一个对象
  │ }               │     属性变了
  └─────────────────┘

关键结论

  • 类组件:所有渲染共享同一个 this 对象this.state 总是指向最新值
  • 函数组件:每次渲染生成独立的闭包快照count 永远是本次渲染时的值

4.2 著名的"闭包陷阱"演示

// ============ 类组件版本 ============
class ClassCounter extends React.Component {
  state = { count: 0 };
  
  handleAlertClick = () => {
    setTimeout(() => {
      // this.state.count 永远读取最新值!
      // 因为 this 是对象引用,对象的 state 已经被更新了
      alert('你点击时的 count 是: ' + this.state.count);
    }, 3000);
  };
  
  render() {
    return (
      <>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>+1</button>
        <button onClick={this.handleAlertClick}>3秒后弹窗</button>
      </>
    );
  }
}
// 操作:点击"3秒后弹窗",然后快速点击 "+1" 三次
// 类组件结果:弹窗显示 3(最新值)—— 可能是 BUG!

// ============ 函数组件版本 ============
function FunctionCounter() {
  const [count, setCount] = React.useState(0);
  
  const handleAlertClick = () => {
    // count 是本次渲染时的闭包值!
    // 即使 3 秒后 state 变了,这里的 count 不变
    const capturedCount = count;  // 捕获了当前渲染的值
    setTimeout(() => {
      alert('你点击时的 count 是: ' + capturedCount);
    }, 3000);
  };
  
  return (
    <>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={handleAlertClick}>3秒后弹窗</button>
    </>
  );
}
// 操作:点击"3秒后弹窗",然后快速点击 "+1" 三次
// 函数组件结果:弹窗显示 0(点击时的值)—— 捕获了正确的时刻!

4.3 生命周期方法 vs useEffect 对比

// ============ 类组件:生命周期分散管理 ============
class DataFetcher extends React.Component {
  state = { data: null, error: null };
  
  // 挂载后获取数据
  componentDidMount() {
    this.subscription = fetchData(this.props.userId, (data) => {
      this.setState({ data });
    });
    document.title = `用户 ${this.props.userId}`;
  }
  
  // userId 改变时重新获取
  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.subscription.unsubscribe();
      this.subscription = fetchData(this.props.userId, (data) => {
        this.setState({ data });
      });
      document.title = `用户 ${this.props.userId}`;
    }
  }
  
  // 卸载时清理
  componentWillUnmount() {
    this.subscription.unsubscribe();
    document.title = 'App';
  }
  // 问题:同一段逻辑(fetchData + document.title)被分散在三个方法里!
}

// ============ 函数组件:useEffect 统一管理 ============
function DataFetcher({ userId }) {
  const [data, setData] = useState(null);
  
  // 相关逻辑集中在一起!
  useEffect(() => {
    const subscription = fetchData(userId, setData);
    document.title = `用户 ${userId}`;
    
    // 清理函数:组件卸载或 userId 变化时执行
    return () => {
      subscription.unsubscribe();
      document.title = 'App';
    };
  }, [userId]); // 依赖 userId
  
  return <div>{data}</div>;
}

4.4 this 绑定问题(类组件经典 Bug)

class EventDemo extends React.Component {
  state = { message: 'Hello' };
  
  // ❌ 方式1:未绑定,this 为 undefined
  handleClick1() {
    console.log(this.state.message); // TypeError: Cannot read property 'state' of undefined
  }
  
  // ✅ 方式2:构造函数中绑定
  constructor(props) {
    super(props);
    this.handleClick2 = this.handleClick2.bind(this);
  }
  handleClick2() {
    console.log(this.state.message); // 'Hello'
  }
  
  // ✅ 方式3:箭头函数(类字段语法)— 推荐
  handleClick3 = () => {
    console.log(this.state.message); // 'Hello'
  };
  
  // ✅ 方式4:JSX 中内联箭头函数(有性能问题:每次渲染创建新函数)
  render() {
    return (
      <div>
        <button onClick={this.handleClick1}>❌ 未绑定</button>
        <button onClick={this.handleClick2}>✅ 构造绑定</button>
        <button onClick={this.handleClick3}>✅ 类字段</button>
        <button onClick={() => this.handleClick1()}>⚠️ 内联箭头</button>
      </div>
    );
  }
}

// 函数组件:不存在 this 绑定问题
function EventDemoFn() {
  const [message] = useState('Hello');
  
  // 直接使用,不需要任何绑定
  const handleClick = () => console.log(message);
  
  return <button onClick={handleClick}>点击</button>;
}

4.5 错误边界:类组件独有能力

// 错误边界只能用类组件实现(截至 2026 年 React 仍未提供 Hook 版本)
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  // 静态方法:从错误更新 state(渲染阶段,不要有副作用)
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  // 实例方法:记录错误信息(副作用阶段)
  componentDidCatch(error, errorInfo) {
    // 上报到错误监控服务(Sentry, LogRocket 等)
    logErrorToService(error, errorInfo.componentStack);
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>出了点问题</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            重试
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// 使用
<ErrorBoundary>
  <MyRiskyComponent />
</ErrorBoundary>

4.6 全面对比表

特性类组件函数组件
状态管理this.state + this.setState()useState() Hook
生命周期componentDidMount/Update/WillUnmountuseEffect()
this 绑定必须手动绑定,易出错不需要,用闭包
逻辑复用HOC / Render Props(易嵌套地狱)Custom Hooks(简洁优雅)
性能实例开销略大稍轻,React.memo 优化更容易
错误边界✅ 支持❌ 不支持(需配合类组件)
Server Components❌ 不支持✅ 支持
Concurrent 特性❌ 不支持✅ 支持
闭包快照❌ this 永远最新✅ 每次渲染独立快照
代码量较多(constructor, bind等)更少更简洁
官方推荐维护旧代码✅ 新项目首选

5. 受控组件 vs 非受控组件

5.1 两种哲学的对比

受控组件                           非受控组件
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
React State                        DOM 自己管理
     │                                   │
     │ 驱动                              │ ref 读取
     ▼                                   ▼
  <input value={...}                  <input defaultValue="..."
         onChange={...} />                    ref={inputRef} />
     │                            
     │ 每次击键触发                    
     ▼                            
  React 处理 → 更新 state → 重渲染  
  
"React 完全掌控"                  "DOM 自己管理,我按需取值"

5.2 受控组件完整示例

function LoginForm() {
  const [formData, setFormData] = useState({
    username: '',
    password: '',
    rememberMe: false,
  });
  const [errors, setErrors] = useState({});
  
  // 通用 onChange 处理器
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
    
    // 实时清除对应字段的错误
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };
  
  // 表单验证(受控组件的最大优势:实时访问所有值)
  const validate = () => {
    const newErrors = {};
    if (!formData.username) newErrors.username = '用户名不能为空';
    if (formData.password.length < 6) newErrors.password = '密码至少6位';
    return newErrors;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    console.log('提交登录:', formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          type="text"
          name="username"
          value={formData.username}       // ← 受控:value 由 state 控制
          onChange={handleChange}          // ← 受控:每次输入触发更新
          placeholder="用户名"
        />
        {errors.username && <span className="error">{errors.username}</span>}
      </div>
      
      <div>
        <input
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="密码"
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      
      <label>
        <input
          type="checkbox"
          name="rememberMe"
          checked={formData.rememberMe}   // ← checkbox 用 checked
          onChange={handleChange}
        />
        记住我
      </label>
      
      <button type="submit">登录</button>
    </form>
  );
}

5.3 非受控组件与 Ref

// 场景:文件上传(原生 file input 无法受控)
function FileUpload() {
  const fileInputRef = useRef(null);
  const usernameRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 通过 ref 读取 DOM 值
    const username = usernameRef.current.value;
    const file = fileInputRef.current.files[0];
    
    console.log('用户名:', username);
    console.log('文件:', file?.name);
  };
  
  const handleReset = () => {
    // 直接操作 DOM 重置
    usernameRef.current.value = '';
    fileInputRef.current.value = '';
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 非受控:defaultValue 设置初始值,之后 React 不管了 */}
      <input
        ref={usernameRef}
        type="text"
        defaultValue="defaultUser"    // ← 非受控用 defaultValue
      />
      
      {/* file input 天然非受控(value 只读) */}
      <input
        ref={fileInputRef}
        type="file"
        accept=".jpg,.png,.gif"
      />
      
      <button type="submit">上传</button>
      <button type="button" onClick={handleReset}>重置</button>
    </form>
  );
}

5.4 同时支持受控与非受控的灵活组件

这是组件库(Ant Design, MUI)的核心设计模式:

// 实现一个既支持受控又支持非受控的 Input 组件
function FlexibleInput({ value, defaultValue, onChange, ...rest }) {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(defaultValue ?? '');
  
  const currentValue = isControlled ? value : internalValue;
  
  const handleChange = (e) => {
    const newValue = e.target.value;
    
    if (!isControlled) {
      setInternalValue(newValue);  // 非受控模式:自己管理状态
    }
    
    onChange?.(newValue);  // 无论哪种模式,都触发 onChange 回调
  };
  
  // ⚠️ 警告:不能在受控和非受控之间切换
  // React 会在控制台发出警告:"A component is changing an uncontrolled input to be controlled"
  useEffect(() => {
    if (process.env.NODE_ENV !== 'production') {
      if (isControlled && defaultValue !== undefined) {
        console.warn('FlexibleInput: value 和 defaultValue 不能同时使用');
      }
    }
  }, []);
  
  return (
    <input
      value={currentValue}
      onChange={handleChange}
      {...rest}
    />
  );
}

// 受控用法
<FlexibleInput value={name} onChange={setName} />

// 非受控用法  
<FlexibleInput defaultValue="初始值" onChange={console.log} />

6. 组件化设计模式

6.1 高阶组件(HOC)

在这里插入图片描述

HOC 是 React 的装饰器模式实现,用于在不修改组件的前提下增强其功能:

// ============ HOC 示例1:权限控制 ============
function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { isLoggedIn, user } = useAuth();
    
    if (!isLoggedIn) {
      return <Navigate to="/login" />;
    }
    
    // 透传所有 props + 注入 user 信息
    return <WrappedComponent {...props} currentUser={user} />;
  };
}

// 使用
const ProtectedDashboard = withAuth(Dashboard);
const ProtectedProfile = withAuth(Profile);


// ============ HOC 示例2:数据加载状态 ============
function withLoading(WrappedComponent, loadingMessage = '加载中...') {
  return function LoadingComponent({ isLoading, ...props }) {
    if (isLoading) {
      return (
        <div className="loading-container">
          <Spinner />
          <p>{loadingMessage}</p>
        </div>
      );
    }
    return <WrappedComponent {...props} />;
  };
}

const UserListWithLoading = withLoading(UserList, '正在加载用户数据...');


// ============ HOC 示例3:性能监控 ============
function withPerformance(WrappedComponent) {
  return function PerformanceComponent(props) {
    const renderCount = useRef(0);
    const renderTimes = useRef([]);
    
    useEffect(() => {
      renderCount.current++;
      renderTimes.current.push(Date.now());
      
      if (process.env.NODE_ENV === 'development') {
        console.log(
          `[Performance] ${WrappedComponent.displayName || WrappedComponent.name} ` +
          `渲染第 ${renderCount.current} 次`
        );
      }
    });
    
    return <WrappedComponent {...props} />;
  };
}

HOC 的问题

// ⚠️ 嵌套地狱:多个 HOC 叠加时组件树变深
const EnhancedComponent = withRouter(
  withAuth(
    withLoading(
      withErrorBoundary(
        withPerformance(MyComponent)
      )
    )
  )
);

// DevTools 里看到的组件树:
// withRouter
//   └─ withAuth
//        └─ withLoading
//             └─ withErrorBoundary
//                  └─ withPerformance
//                       └─ MyComponent  ← 真正的组件深埋其中

6.2 Render Props 模式

// ============ Render Props:鼠标位置跟踪 ============
class MouseTracker extends React.Component {
  state = { x: 0, y: 0 };
  
  handleMouseMove = (e) => {
    this.setState({ x: e.clientX, y: e.clientY });
  };
  
  render() {
    // 不渲染任何 UI,只提供状态
    // 由 render prop 决定如何展示
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
        {this.props.render(this.state)}
      </div>
    );
  }
}

// 使用方式1:跟随鼠标的猫
<MouseTracker render={({ x, y }) => (
  <img 
    src="/cat.gif" 
    style={{ position: 'fixed', left: x, top: y, transform: 'translate(-50%, -50%)' }}
  />
)} />

// 使用方式2:显示坐标
<MouseTracker render={({ x, y }) => (
  <p>鼠标位置:({x}, {y})</p>
)} />

6.3 自定义 Hook:现代解决方案

自定义 Hook 是解决逻辑复用问题的最佳方案,没有嵌套问题:

// ============ 自定义 Hook:鼠标位置(替代上面的 Render Props) ============
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);
  
  return position;
}

// 使用:干净简洁,无嵌套
function CatFollower() {
  const { x, y } = useMousePosition();
  return <img src="/cat.gif" style={{ position: 'fixed', left: x, top: y }} />;
}

function CoordinateDisplay() {
  const { x, y } = useMousePosition();
  return <p>鼠标位置:({x}, {y})</p>;
}


// ============ 实用自定义 Hook 集合 ============

// 1. useLocalStorage:持久化状态
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });
  
  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

// 2. useDebounce:防抖
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouncedValue;
}

// 3. useFetch:数据获取
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    if (!url) return;
    
    const controller = new AbortController();
    
    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));
    
    // 清理:取消请求(组件卸载或 url 变化时)
    return () => controller.abort();
  }, [url]);
  
  return { data, loading, error };
}

// 4. useEventListener:事件监听
function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef(handler);
  
  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);
  
  useEffect(() => {
    if (!element?.addEventListener) return;
    
    const eventListener = (event) => savedHandler.current(event);
    element.addEventListener(eventName, eventListener);
    
    return () => element.removeEventListener(eventName, eventListener);
  }, [eventName, element]);
}

6.4 容器组件 vs 展示组件(Container/Presentational)

// ============ 展示组件(Presentational):纯 UI ============
// 不关心数据从哪来,只关心怎么展示
function UserCard({ name, avatar, email, onEdit }) {
  return (
    <div className="user-card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>{email}</p>
      <button onClick={onEdit}>编辑</button>
    </div>
  );
}

// ============ 容器组件(Container):数据逻辑 ============
// 负责获取数据和状态管理,不关心 UI 细节
function UserCardContainer({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);
  
  const handleEdit = () => {
    // 打开编辑弹窗等逻辑
  };
  
  if (loading) return <Skeleton />;
  
  // 容器组件将数据注入展示组件
  return (
    <UserCard
      name={user.name}
      avatar={user.avatar}
      email={user.email}
      onEdit={handleEdit}
    />
  );
}

7. 实战要点与常见陷阱

7.1 事件处理常见陷阱

// ❌ 陷阱1:在 render 中调用了函数而非传递引用
<button onClick={handleClick()}>点击</button>   // 立即执行!
<button onClick={handleClick}>点击</button>     // ✅ 正确:传引用

// ❌ 陷阱2:内联箭头函数导致不必要的子组件重渲染
function Parent() {
  return (
    // 每次 Parent 渲染,都会创建新的函数对象
    // 导致 Child 即使 props 没变也会重渲染
    <Child onClick={() => handleAction()} />
  );
}
// ✅ 改进:用 useCallback 记忆函数
function Parent() {
  const handleClick = useCallback(() => handleAction(), []);
  return <Child onClick={handleClick} />;
}

// ❌ 陷阱3:忘记阻止表单默认提交行为
function Form() {
  const handleSubmit = (e) => {
    // 忘记 e.preventDefault() 导致页面刷新!
    console.log('提交');
  };
  return <form onSubmit={handleSubmit}><button type="submit">提交</button></form>;
}
// ✅ 正确
const handleSubmit = (e) => {
  e.preventDefault();  // 阻止默认刷新
  console.log('提交');
};

// ❌ 陷阱4:在原生事件中调用 React 合成事件的 stopPropagation
document.addEventListener('click', () => {
  // React 16: React 的事件在 document 级处理
  // 如果在 React 组件中 stopPropagation,document 级监听器可能看不到
});

// ✅ 理解 React 17 后的行为:
// React 事件在 #root 级别,document 监听器仍在 document 级别
// React 的 stopPropagation 只会阻止 React 内部的冒泡
// 要阻止冒泡到 document,需要 e.nativeEvent.stopImmediatePropagation()

7.2 受控组件的常见问题

// ❌ 陷阱:value 设为 undefined 导致组件在受控/非受控之间切换
function BadInput({ value }) {
  // 如果 value 可能是 undefined,会触发 React 警告
  return <input value={value} onChange={...} />;
}

// ✅ 修复:用空字符串兜底
return <input value={value ?? ''} onChange={...} />;

// ❌ 陷阱:中文输入法问题
// 使用 onChange 时,拼音输入中间状态会频繁触发
// 解决方案:使用 onCompositionStart/End
function ChineseInput({ value, onChange }) {
  const [isComposing, setIsComposing] = useState(false);
  const [innerValue, setInnerValue] = useState(value);
  
  return (
    <input
      value={innerValue}
      onChange={(e) => {
        setInnerValue(e.target.value);
        if (!isComposing) onChange(e.target.value);
      }}
      onCompositionStart={() => setIsComposing(true)}
      onCompositionEnd={(e) => {
        setIsComposing(false);
        onChange(e.target.value);  // 输入法确认后才触发
      }}
    />
  );
}

7.3 事件与 React 批处理

// React 18 以前:只在 React 事件处理器中批处理
function Counter() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  
  // React 事件处理器:批处理(只触发1次渲染)
  const handleClick = () => {
    setCount(c => c + 1);   // 不立即渲染
    setFlag(f => !f);        // 不立即渲染
    // 上面两个 setState 合并为一次渲染
  };
  
  // React 18 以前:setTimeout 中不批处理(触发2次渲染)
  const handleClickAsync = () => {
    setTimeout(() => {
      setCount(c => c + 1);  // 立即渲染一次
      setFlag(f => !f);       // 再渲染一次
    }, 0);
  };
  
  // React 18:自动批处理(Automatic Batching)
  // 无论在哪里(setTimeout、Promise、原生事件),都会批处理
  // 如果不想批处理,用 ReactDOM.flushSync
  const handleClickNoFlush = () => {
    ReactDOM.flushSync(() => {
      setCount(c => c + 1);  // 立即渲染
    });
    // 上面已渲染完成
    setFlag(f => !f);         // 再渲染一次
  };
}

8. 本章小结与记忆口诀

8.1 知识体系总结

知识点核心要点版本关键节点
合成事件跨浏览器包装器,统一 API所有版本
事件委托16→document,17+→root容器React 17
事件池对象复用减少GC,17后移除React 17 移除
类组件this实例,生命周期分散,可做错误边界维护模式
函数组件闭包快照,useEffect统一,支持新特性官方推荐
受控组件state+onChange,实时访问,适合验证推荐
非受控组件ref读取,DOM自管理,简单场景file等特殊场景
HOC装饰器模式,逻辑复用,嵌套问题渐被自定义Hook取代
自定义Hookuse前缀,复用有状态逻辑,无嵌套React 16.8+ 推荐

8.2 记忆口诀

合成事件统浏览器,委托根节十七改;
事件池子十七删,异步访问不再愁;
类用this函数闭,快照陷阱要谨记;
受控value加change,非受控靠ref取;
高阶组件装饰模,自定义Hook更清爽。

口诀拆解

  • “合成事件统浏览器” → SyntheticEvent 跨浏览器兼容
  • “委托根节十七改” → React 17 事件委托从 document 改到 root
  • “事件池子十七删” → React 17 移除事件池,无需 persist
  • “类用this函数闭” → 类组件 this 引用,函数组件闭包快照
  • “快照陷阱要谨记” → 函数组件闭包捕获时刻值,注意过期闭包
  • “受控value加change” → 受控 = value + onChange 双绑定
  • “非受控靠ref取” → 非受控用 ref.current.value 读取
  • “高阶组件装饰模” → HOC 是装饰器模式,嵌套问题
  • “自定义Hook更清爽” → 现代推荐方案,无嵌套,更直观

React 17+ 事件系统源码深度追溯(基于 react@18.2.0)

1. 事件委托入口:listenToAllSupportedEvents

文件:packages/react-dom/src/events/DOMPluginEventSystem.js:393

export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  if (!(rootContainerElement: any)[listeningMarker]) {
    (rootContainerElement: any)[listeningMarker] = true;
    // 遍历 81 个原生事件,全部委托到 root container
    allNativeEvents.forEach(domEventName => {
      // mediaEvents 不冒泡,单独处理
      if (!nonDelegatedEvents.has(domEventName)) {
        listenToNativeEvent(domEventName, false, rootContainerElement);
      }
      // 捕获阶段也注册一次
      listenToNativeEvent(domEventName, true, rootContainerElement);
    });
  }
}

源码精读要点

  1. 挂载点变了:React 17 之前注册在 document,React 17+ 注册在 rootContainerElement(即 createRoot 传入的 DOM 节点)。
  2. 冒泡+捕获双注册:每个事件注册两次——冒泡阶段(false)和捕获阶段(true),这就是 React 同时支持 onClickonClickCapture 的原因。
  3. nonDelegatedEvents 列表:scroll、cancel、close、invalid、load、scroll、toggle、reset、submit 等不冒泡的事件不在 document/root 上委托,而是直接绑定到目标元素上。

2. 合成事件创建:createSyntheticEvent

文件:packages/react-dom/src/events/SyntheticEvent.js:127

function createSyntheticEvent(Interface: EventInterfaceType) {
  function SyntheticBaseEvent(
    reactName: string | null,
    reactEventType: string,
    targetInst: Fiber,
    nativeEvent: { [propName: string]: mixed },
    nativeEventTarget: null | EventTarget,
  ) {
    this._reactName = reactName;
    this._targetInst = targetInst;
    this.type = reactEventType;
    this.nativeEvent = nativeEvent;
    this.target = nativeEventTarget;
    this.currentTarget = null;

    // 把 Interface 定义的属性从 nativeEvent 复制过来
    for (const propName in Interface) {
      if (!Interface.hasOwnProperty(propName)) continue;
      const normalize = Interface[propName];
      if (normalize) {
        this[propName] = normalize(nativeEvent);
      } else {
        this[propName] = nativeEvent[propName];
      }
    }
    // ... isDefaultPrevented / isPropagationStopped 初始化
    return this;
  }
  // ... 原型方法挂载
  return SyntheticBaseEvent;
}

源码精读要点

  1. React 17 取消了事件池:React 16 用对象池复用 SyntheticEvent,但导致 setTimeout(() => console.log(e.target)) 拿不到值(被池化清空)。React 17 直接 new 新对象,每个事件独立。
  2. _targetInst 是 Fiber 引用:通过它能找到触发事件的 React 组件实例,这是 React DevTools 能反向定位组件的关键。
  3. 统一接口:不论浏览器原生 Event 是 KeyboardEvent / MouseEvent / TouchEvent,SyntheticEvent 统一接口,屏蔽浏览器差异。

3. 事件分发:dispatchEventForPluginEventSystem

文件:packages/react-dom/src/events/DOMPluginEventSystem.js:253

export function dispatchEventForPluginEventSystem(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  // ...
  batchedUpdates(() => {
    dispatchEventsForPlugins(
      domEventName,
      eventSystemFlags,
      nativeEvent,
      ancestorInst,
      targetContainer,
    );
  });
}

function dispatchEventsForPlugins(...) {
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = [];

  // 收集事件路径上所有的 listener
  extractEvents(dispatchQueue, ...);

  // 按顺序触发所有 listener
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

源码精读要点

  1. batchedUpdates 包裹:所有事件回调内的 setState 都会被批处理,这是 React 18 之前事件中 setState 异步的根本原因。
  2. dispatchQueue 收集策略:React 模拟原生事件冒泡——从目标 fiber 向上遍历 return 指针,收集所有 onClick/onClickCapture listener,按"捕获从外到内、冒泡从内到外"的顺序触发。
  3. e.stopPropagation() 实现:仅阻止 React 内部 dispatchQueue 后续执行,不影响原生 DOM 上其他 listener。这就是为什么 React 17+ 内的 e.stopPropagation() 不能阻止挂在 document 上的原生 jQuery 事件。

9. 面试考点精讲

Q1:React 合成事件和原生事件的区别?调用 e.stopPropagation() 能阻止原生事件冒泡吗?

合成事件(SyntheticEvent)是 React 对原生浏览器事件的跨平台封装,主要区别:

  1. 执行时机:合成事件是在原生事件冒泡至 React 根节点(React 17+)时才被处理,晚于同层的原生事件处理器
  2. 事件池:React 16 中 SyntheticEvent 被池化复用(异步访问需 persist),React 17+ 已移除
  3. stopPropagation 作用域e.stopPropagation() 阻止的是 React 合成事件系统内部的冒泡,无法阻止冒泡到 React 挂载节点(root 容器)以外的原生事件监听
// 要阻止冒泡到 document,需要:
e.nativeEvent.stopImmediatePropagation();
// 或在原生事件监听时 stopPropagation

React 17 的变化:事件从挂载 document 改为挂载 rootContainer,使多版本 React 共存和跨框架互操作成为可能。


Q2:为什么函数组件 + useState 有"闭包陷阱"?如何解决?

原因:函数组件每次渲染都是一次独立的函数调用,useState 的值通过闭包被捕获在当次渲染的作用域中。如果回调函数在渲染后异步执行(如 setTimeout、事件监听器),它持有的是创建时那次渲染的"快照值",而非最新值。

// 经典陷阱
const [count, setCount] = useState(0);
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 永远打印 0!捕获了初始渲染的闭包
    setCount(count + 1); // 永远只能到 1!
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖,只运行一次

解决方案

// 方案1:函数式更新(不依赖旧值读取)
setCount(prev => prev + 1); // ✅ prev 永远是最新值

// 方案2:useRef 持有最新值
const countRef = useRef(count);
countRef.current = count;
// 在回调中用 countRef.current

// 方案3:将变量加入依赖数组(需重新注册监听)
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // count 是依赖,每次变化重新注册

Q3:受控组件和非受控组件各在什么场景下使用?

场景推荐方案原因
实时表单验证受控每次输入都能立即验证
提交时才验证非受控减少不必要的渲染
动态表单(依赖其他字段)受控方便访问其他字段的值
文件上传非受控<input type="file"> value 只读,无法受控
集成第三方 DOM 库非受控第三方库直接操作 DOM
需要即时反馈(实时搜索)受控每次击键都能触发搜索请求
简单提交表单非受控代码更简洁

总体原则:默认选受控,能更好地集成 React 生态(验证库、状态管理);有性能问题或特殊 DOM 需求时选非受控。


Q4:HOC、Render Props、自定义 Hook 三者如何选择?

三者都解决"逻辑复用"问题,但适用场景不同:

模式优点缺点适用场景
HOC不修改组件,可组合嵌套地狱,props 污染,ref 穿透困难横切关注点(权限、日志)、需要 displayName 的场景
Render Props灵活,明确数据来源JSX 嵌套复杂,有轻微性能开销需要动态决定渲染内容(如鼠标跟踪)
自定义 Hook简洁,无嵌套,TypeScript 友好只能在函数组件中用大多数现代场景的首选

2024 最佳实践:新项目中优先自定义 Hook;需要包裹类组件时用 HOC;需要动态渲染决策时用 Render Props;错误边界必须用类组件。


Q5:React 17 事件系统改到 root 容器有什么实际影响?升级时需要注意什么?

实际影响

  1. 微前端兼容:多个 React 实例可共存,事件互不干扰(React 16 中所有 React 实例都挂载到 document,会互相影响)
  2. stopPropagation 行为变化:React 内部的 stopPropagation() 可以正确阻止事件到达 jQuery 等其他框架的 document 级监听器
  3. 事件顺序变化:document 级别的原生事件监听器与 React 事件的执行顺序可能改变

升级注意事项

// ⚠️ 需要排查:是否有在 document 上绑定事件并依赖在 React 事件前/后执行?
document.addEventListener('click', handler); // 检查这类代码

// ⚠️ 需要排查:是否使用了 document.addEventListener + stopPropagation 阻止 React 事件?
// React 17 后,React 监听在 #root,document 的 stopPropagation 不影响 React

// ✅ 修复方式:将 document 的监听移到 React root 节点
const root = document.getElementById('root');
root.addEventListener('click', handler);

10. 交互式演示

将以下 HTML 保存为 .html 文件,在浏览器中打开即可运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React 事件系统与受控/非受控 交互演示</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: 'Microsoft YaHei', sans-serif; background: #0f1117; color: #e0e0e0; padding: 20px; }
    h1 { text-align: center; color: #61dafb; margin-bottom: 30px; font-size: 1.5rem; }
    
    .tabs { display: flex; gap: 10px; margin-bottom: 20px; justify-content: center; flex-wrap: wrap; }
    .tab-btn { padding: 10px 20px; border: 2px solid #61dafb; background: transparent; color: #61dafb;
               border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.2s; }
    .tab-btn.active { background: #61dafb; color: #0f1117; font-weight: bold; }
    .tab-btn:hover:not(.active) { background: rgba(97, 218, 251, 0.1); }
    
    .demo-panel { display: none; background: #1a1d27; border-radius: 12px; padding: 24px; border: 1px solid #2d3043; }
    .demo-panel.active { display: block; }
    
    .panel-title { color: #61dafb; font-size: 1.1rem; font-weight: bold; margin-bottom: 16px; }
    .panel-desc { color: #8892a4; font-size: 13px; margin-bottom: 20px; line-height: 1.6; }
    
    .event-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 16px; }
    .event-cell { padding: 12px; background: #252836; border-radius: 6px; text-align: center;
                  cursor: pointer; font-size: 12px; transition: all 0.2s; border: 1px solid transparent; }
    .event-cell:hover { border-color: #61dafb; }
    .event-cell.clicked { background: #1e4a62; border-color: #61dafb; animation: flash 0.5s ease; }
    @keyframes flash { 0% { background: #61dafb22; } 100% { background: #1e4a62; } }
    
    .event-log { background: #0f1117; border-radius: 8px; padding: 12px; max-height: 150px; overflow-y: auto;
                 font-family: 'Courier New', monospace; font-size: 12px; }
    .log-entry { margin-bottom: 4px; }
    .log-entry.delegation { color: #4caf50; }
    .log-entry.synthetic { color: #61dafb; }
    
    .version-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
    .version-box { background: #252836; border-radius: 8px; padding: 16px; }
    .version-box h4 { margin-bottom: 12px; font-size: 13px; }
    .version-box.react16 h4 { color: #ff7043; }
    .version-box.react17 h4 { color: #4caf50; }
    
    .dom-tree { font-family: monospace; font-size: 12px; line-height: 1.8; }
    .dom-node { padding: 4px 8px; border-radius: 4px; margin: 2px 0; }
    .dom-node.listener { font-weight: bold; }
    .react16 .dom-node.listener { background: #ff704322; border-left: 3px solid #ff7043; }
    .react17 .dom-node.listener { background: #4caf5022; border-left: 3px solid #4caf50; }
    .dom-indent { margin-left: 20px; }
    
    .form-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
    .form-box { background: #252836; border-radius: 8px; padding: 16px; }
    .form-box h4 { margin-bottom: 12px; font-size: 13px; }
    .form-box.controlled h4 { color: #61dafb; }
    .form-box.uncontrolled h4 { color: #a78bfa; }
    
    .form-field { margin-bottom: 12px; }
    .form-field label { display: block; font-size: 12px; color: #8892a4; margin-bottom: 4px; }
    .form-field input { width: 100%; padding: 8px 12px; background: #0f1117; border: 1px solid #2d3043;
                        border-radius: 6px; color: #e0e0e0; font-size: 14px; outline: none; }
    .form-field input:focus { border-color: #61dafb; }
    .state-display { background: #0f1117; padding: 8px 12px; border-radius: 6px; font-size: 12px;
                     font-family: monospace; color: #4caf50; min-height: 40px; }
    .form-submit { padding: 8px 16px; background: #61dafb; color: #0f1117; border: none; border-radius: 6px;
                   cursor: pointer; font-size: 13px; font-weight: bold; margin-top: 8px; }
    
    .bubble-container { padding: 20px; background: #1e3a4a; border-radius: 12px; border: 2px solid #2196f3; cursor: pointer; }
    .bubble-middle { padding: 16px; background: #1e2a3a; border-radius: 8px; border: 2px solid #4caf50; margin: 10px 0; cursor: pointer; }
    .bubble-inner { padding: 12px; background: #1a1a2e; border-radius: 6px; border: 2px solid #e91e63; cursor: pointer; text-align: center; }
    .bubble-label { font-size: 11px; color: #8892a4; margin-bottom: 8px; }
    
    .event-path { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
    .path-node { padding: 4px 10px; border-radius: 4px; font-size: 12px; transition: all 0.3s; }
    .path-node.outer { background: #2196f322; border: 1px solid #2196f3; color: #2196f3; }
    .path-node.middle { background: #4caf5022; border: 1px solid #4caf50; color: #4caf50; }
    .path-node.inner { background: #e91e6322; border: 1px solid #e91e63; color: #e91e63; }
    .path-arrow { color: #8892a4; font-size: 16px; }
    .path-node.active { transform: scale(1.1); box-shadow: 0 0 10px currentColor; }
    
    .stop-btn { padding: 6px 12px; background: #ff5722; border: none; border-radius: 4px; color: white;
                cursor: pointer; font-size: 12px; margin-top: 8px; }
    .stop-btn.active { background: #4caf50; }
    
    @media (max-width: 600px) {
      .version-compare, .form-compare { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <h1>⚛️ React 事件系统与组件架构 交互演示</h1>
  
  <div class="tabs">
    <button class="tab-btn active" onclick="switchTab('delegation', event)">事件委托演示</button>
    <button class="tab-btn" onclick="switchTab('react17', event)">React 17 变革</button>
    <button class="tab-btn" onclick="switchTab('controlled', event)">受控 vs 非受控</button>
    <button class="tab-btn" onclick="switchTab('bubbling', event)">事件冒泡控制</button>
  </div>
  
  <div id="tab-delegation" class="demo-panel active">
    <div class="panel-title">事件委托(Event Delegation)工作原理</div>
    <div class="panel-desc">
      点击下方任意按钮。React 只在容器上绑定了 <strong>1 个</strong> 监听器,
      通过 event.target 判断是哪个按钮被点击。
    </div>
    <div class="event-grid" id="buttonGrid"></div>
    <div style="display:flex; gap:12px; align-items:center; margin-bottom:12px;">
      <span style="font-size:13px; color:#8892a4;">事件监听器数量:</span>
      <span style="color:#4caf50; font-weight:bold; font-size:18px;">1</span>
      <span style="font-size:13px; color:#8892a4;">(管理 25 个按钮)</span>
    </div>
    <div class="event-log" id="eventLog">
      <div style="color:#8892a4; font-size:12px;">点击按钮查看事件流...</div>
    </div>
  </div>
  
  <div id="tab-react17" class="demo-panel">
    <div class="panel-title">React 17 事件挂载点变化:document → root</div>
    <div class="panel-desc">React 17 将事件监听从 document 迁移到根容器,实现更好的隔离。</div>
    <div class="version-compare">
      <div class="version-box react16">
        <h4>⚠️ React 16(旧行为)</h4>
        <div class="dom-tree">
          <div class="dom-node listener">document ← 监听在此</div>
          <div class="dom-indent">
            <div class="dom-node">└─ html → body</div>
            <div class="dom-indent">
              <div class="dom-node">└─ div#root → App</div>
            </div>
          </div>
        </div>
        <div style="margin-top:10px; font-size:12px; color:#ff9800; line-height:1.6;">
          ⚠️ 多React实例事件互相干扰<br>
          ⚠️ jQuery stopPropagation 可阻断 React<br>
          ⚠️ 微前端场景难以隔离
        </div>
      </div>
      <div class="version-box react17">
        <h4>✅ React 17+(新行为)</h4>
        <div class="dom-tree">
          <div class="dom-node">document → html → body</div>
          <div class="dom-indent">
            <div class="dom-node listener">└─ div#root ← 监听在此</div>
            <div class="dom-indent">
              <div class="dom-node">└─ App</div>
            </div>
          </div>
        </div>
        <div style="margin-top:10px; font-size:12px; color:#4caf50; line-height:1.6;">
          ✅ 多React实例完全隔离<br>
          ✅ 与jQuery等框架互操作正常<br>
          ✅ 微前端架构友好
        </div>
      </div>
    </div>
    <div style="margin-top:16px; background:#252836; padding:16px; border-radius:8px;">
      <div style="color:#61dafb; font-size:13px; font-weight:bold; margin-bottom:10px;">事件池模拟(React 16 vs 17)</div>
      <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; font-size:12px;">
        <div>
          <div style="color:#ff7043; margin-bottom:6px;">React 16(有事件池):</div>
          <div id="pool16Demo" style="background:#0f1117; padding:10px; border-radius:6px; font-family:monospace; min-height:70px; color:#e0e0e0; white-space:pre-wrap;">点击下方按钮模拟...</div>
        </div>
        <div>
          <div style="color:#4caf50; margin-bottom:6px;">React 17+(无事件池):</div>
          <div id="pool17Demo" style="background:#0f1117; padding:10px; border-radius:6px; font-family:monospace; min-height:70px; color:#e0e0e0; white-space:pre-wrap;">点击下方按钮模拟...</div>
        </div>
      </div>
      <button onclick="simulateEventPool()" style="margin-top:10px; padding:8px 16px; background:#61dafb; color:#0f1117; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:bold;">
        模拟异步访问 event
      </button>
    </div>
  </div>
  
  <div id="tab-controlled" class="demo-panel">
    <div class="panel-title">受控组件 vs 非受控组件</div>
    <div class="panel-desc">左:受控——每次输入实时更新 state 并验证。右:非受控——提交时才通过 ref 读取值。</div>
    <div class="form-compare">
      <div class="form-box controlled">
        <h4>受控组件(Controlled)</h4>
        <div class="form-field">
          <label>用户名(实时显示字符数)</label>
          <input type="text" id="ctrl-username" oninput="updateControlled()" placeholder="输入用户名" />
        </div>
        <div class="form-field">
          <label>密码(实时验证长度 ≥ 6)</label>
          <input type="password" id="ctrl-password" oninput="updateControlled()" placeholder="至少6位" />
        </div>
        <div style="font-size:12px; color:#8892a4; margin-bottom:6px;">React State(实时):</div>
        <div class="state-display" id="ctrl-state">{ username: "", password: "" }</div>
        <div id="ctrl-errors" style="font-size:12px; min-height:20px; margin-top:6px;"></div>
        <button class="form-submit" onclick="submitControlled()">提交(受控)</button>
      </div>
      <div class="form-box uncontrolled">
        <h4>非受控组件(Uncontrolled)</h4>
        <div class="form-field">
          <label>用户名(DOM 自管理)</label>
          <input type="text" id="unctrl-username" placeholder="输入用户名" />
        </div>
        <div class="form-field">
          <label>密码(DOM 自管理)</label>
          <input type="password" id="unctrl-password" placeholder="输入密码" />
        </div>
        <div style="font-size:12px; color:#8892a4; margin-bottom:6px;">State(提交时才读取):</div>
        <div class="state-display" id="unctrl-state" style="color:#a78bfa;">未读取</div>
        <div id="unctrl-errors" style="font-size:12px; min-height:20px; margin-top:6px;"></div>
        <button class="form-submit" onclick="submitUncontrolled()" style="background:#a78bfa;">提交(非受控)</button>
      </div>
    </div>
    <div id="submit-result" style="margin-top:16px; padding:12px; background:#252836; border-radius:8px; font-size:13px; display:none;"></div>
  </div>
  
  <div id="tab-bubbling" class="demo-panel">
    <div class="panel-title">事件冒泡与 stopPropagation</div>
    <div class="panel-desc">点击内层(红色),观察事件冒泡。切换"阻止冒泡"后事件停止在内层。</div>
    <div class="bubble-container" onclick="handleBubble('outer', event)">
      <div class="bubble-label">外层(蓝色)</div>
      <div class="bubble-middle" onclick="handleBubble('middle', event)">
        <div class="bubble-label">中间层(绿色)</div>
        <div class="bubble-inner" onclick="handleBubble('inner', event)">
          <p style="font-size:13px;">点击这里(红色)</p>
          <button onclick="toggleStopProp(event)" id="stopPropBtn" class="stop-btn">未阻止冒泡(点击切换)</button>
        </div>
      </div>
    </div>
    <div style="margin-top:12px;">
      <div style="font-size:12px; color:#8892a4; margin-bottom:8px;">事件传播路径:</div>
      <div class="event-path">
        <div class="path-node inner" id="path-inner">内层(红)</div>
        <div class="path-arrow" id="arrow1"></div>
        <div class="path-node middle" id="path-middle">中间层(绿)</div>
        <div class="path-arrow" id="arrow2"></div>
        <div class="path-node outer" id="path-outer">外层(蓝)</div>
      </div>
    </div>
    <div class="event-log" id="bubbleLog" style="margin-top:12px; max-height:120px;">
      <div style="color:#8892a4; font-size:12px;">点击内层查看事件冒泡...</div>
    </div>
  </div>

  <script>
    let stopProp = false;
    let logCount = 0;
    let bubbleLogCount = 0;
    
    function switchTab(tabName, e) {
      document.querySelectorAll('.demo-panel').forEach(p => p.classList.remove('active'));
      document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
      document.getElementById('tab-' + tabName).classList.add('active');
      e.target.classList.add('active');
    }
    
    // Tab 1: Event Delegation
    const grid = document.getElementById('buttonGrid');
    for (let i = 1; i <= 25; i++) {
      const cell = document.createElement('div');
      cell.className = 'event-cell';
      cell.dataset.id = i;
      cell.textContent = '按钮 ' + i;
      grid.appendChild(cell);
    }
    grid.addEventListener('click', function(e) {
      const cell = e.target.closest('.event-cell');
      if (!cell) return;
      document.querySelectorAll('.event-cell').forEach(c => c.classList.remove('clicked'));
      cell.classList.add('clicked');
      const log = document.getElementById('eventLog');
      if (logCount === 0) log.innerHTML = '';
      logCount++;
      log.innerHTML = '<div class="log-entry delegation">[' + new Date().toLocaleTimeString() + '] 容器监听器: event.target = 按钮' + cell.dataset.id + '</div>' +
        '<div class="log-entry synthetic">  ↳ React: 从 Fiber 树找到按钮' + cell.dataset.id + ' 的 onClick,执行回调</div>' + log.innerHTML;
    });
    
    // Tab 2: Event Pool
    function simulateEventPool() {
      document.getElementById('pool16Demo').textContent = '同步访问: e.type = "click" ✅';
      document.getElementById('pool17Demo').textContent = '同步访问: e.type = "click" ✅';
      setTimeout(() => {
        document.getElementById('pool16Demo').textContent += '\n异步(300ms): e.type = null ❌\n→ 事件池已回收,需 e.persist()';
        document.getElementById('pool17Demo').textContent += '\n异步(300ms): e.type = "click" ✅\n→ 无事件池,直接访问即可';
      }, 300);
    }
    
    // Tab 3: Controlled
    function updateControlled() {
      const u = document.getElementById('ctrl-username').value;
      const p = document.getElementById('ctrl-password').value;
      document.getElementById('ctrl-state').textContent = '{ username: "' + u + '", password: "' + '*'.repeat(p.length) + '" }';
      const errs = [];
      if (u.length > 0 && u.length < 3) errs.push('用户名至少3位');
      if (p.length > 0 && p.length < 6) errs.push('密码至少6位');
      const el = document.getElementById('ctrl-errors');
      el.textContent = errs.length ? errs.join(' | ') : (u || p ? '✅ 格式正确' : '');
      el.style.color = errs.length ? '#ef5350' : '#4caf50';
    }
    
    function submitControlled() {
      const u = document.getElementById('ctrl-username').value;
      const p = document.getElementById('ctrl-password').value;
      const res = document.getElementById('submit-result');
      res.style.display = 'block';
      if (!u || !p || u.length < 3 || p.length < 6) {
        res.style.color = '#ef5350';
        res.textContent = '❌ 受控组件验证失败:' + (!u ? '用户名为空' : u.length < 3 ? '用户名太短' : !p ? '密码为空' : '密码太短');
      } else {
        res.style.color = '#4caf50';
        res.textContent = '✅ 受控组件提交成功!用户名: ' + u + ',密码: ' + '*'.repeat(p.length);
      }
    }
    
    function submitUncontrolled() {
      const u = document.getElementById('unctrl-username').value;
      const p = document.getElementById('unctrl-password').value;
      document.getElementById('unctrl-state').textContent = 'ref 读取: { username: "' + u + '", password: "' + '*'.repeat(p.length) + '" }';
      document.getElementById('unctrl-state').style.color = '#4caf50';
      const res = document.getElementById('submit-result');
      res.style.display = 'block';
      if (!u || !p) {
        res.style.color = '#ef5350';
        res.textContent = '❌ 非受控提交失败(只能在提交时发现错误)';
      } else {
        res.style.color = '#a78bfa';
        res.textContent = '✅ 非受控提交成功!通过 ref 读取: ' + u;
      }
    }
    
    // Tab 4: Bubbling
    function handleBubble(layer, e) {
      if (layer === 'inner' && stopProp) e.stopPropagation();
      const colors = { inner: '#e91e63', middle: '#4caf50', outer: '#2196f3' };
      const names = { inner: '内层(红)', middle: '中间层(绿)', outer: '外层(蓝)' };
      const log = document.getElementById('bubbleLog');
      if (bubbleLogCount === 0) log.innerHTML = '';
      bubbleLogCount++;
      let msg = '[' + new Date().toLocaleTimeString() + '] ' + names[layer] + ' onClick 触发';
      if (layer === 'inner' && stopProp) msg += ' → stopPropagation() 已调用,停止冒泡';
      log.innerHTML = '<div class="log-entry" style="color:' + colors[layer] + '">' + msg + '</div>' + log.innerHTML;
      
      const nodes = stopProp ? ['inner'] : ['inner', 'middle', 'outer'];
      nodes.forEach((n, i) => {
        setTimeout(() => {
          const el = document.getElementById('path-' + n);
          el.classList.add('active');
          setTimeout(() => el.classList.remove('active'), 500);
        }, i * 200);
      });
      
      if (layer === 'inner' && stopProp) {
        document.getElementById('path-middle').style.opacity = '0.3';
        document.getElementById('path-outer').style.opacity = '0.3';
        document.getElementById('arrow1').style.color = '#ef5350';
        document.getElementById('arrow2').style.color = '#ef5350';
      } else {
        ['path-middle', 'path-outer'].forEach(id => document.getElementById(id).style.opacity = '1');
        ['arrow1', 'arrow2'].forEach(id => document.getElementById(id).style.color = '#8892a4');
      }
    }
    
    function toggleStopProp(e) {
      e.stopPropagation();
      stopProp = !stopProp;
      const btn = document.getElementById('stopPropBtn');
      btn.textContent = stopProp ? '已阻止冒泡(再点切换)' : '未阻止冒泡(点击切换)';
      btn.classList.toggle('active', stopProp);
    }
  </script>
</body>
</html>

11. 事件系统源码深度解析

理解 React 事件系统的工作方式,最直接的路径是阅读源码。以下分析基于 React 18.x 源码(packages/react-dom/src/events/)。

11.1 事件注册:插件注册表

React 用"插件"体系管理不同类型的事件。每个插件负责一类事件的创建、分发规则:

React 事件插件注册表(eventPluginRegistry)
┌─────────────────────────────────────────────────────────┐
│  SimpleEventPlugin          → click, mousedown, keyup… │
│  EnterLeaveEventPlugin      → mouseenter, mouseleave    │
│  ChangeEventPlugin          → onChange(跨浏览器统一)   │
│  SelectEventPlugin          → onSelect                  │
│  BeforeInputEventPlugin     → onBeforeInput              │
└─────────────────────────────────────────────────────────┘

SimpleEventPlugin 覆盖绝大多数常见事件,其核心是一张从原生事件名到合成事件名的映射表:

// packages/react-dom/src/events/DOMEventProperties.js(简化)
const discreteEventPairsForSimpleEventPlugin = [
  // [原生事件名,    React prop 名]
  ['click',        'onClick'],
  ['dblclick',     'onDoubleClick'],
  ['keydown',      'onKeyDown'],
  ['keyup',        'onKeyUp'],
  ['mousedown',    'onMouseDown'],
  ['mouseup',      'onMouseUp'],
  ['touchstart',   'onTouchStart'],
  ['touchend',     'onTouchEnd'],
  ['focus',        'onFocus'],
  ['blur',         'onBlur'],
  // ... 共 70+ 条映射
];

// 注册时,React 为每个原生事件调用:
// listenToNativeEvent(domEventName, isCapturePhaseListener, rootContainerElement)

【代码注释】这张映射表是 React"声明式事件"得以工作的基础:你在 JSX 里写 onClick,React 内部查这张表找到原生事件名 click,再把委托监听器注册到 root 容器。市面应用:Ant Design 组件库的事件转发、React Native 的跨平台事件桥接,都依赖这套插件机制。

11.2 事件优先级:三档调度

React 18 把所有事件划分成三个优先级档位,对应 Scheduler 的三种任务优先级:

事件优先级(EventPriority)
┌──────────────────────────────────────────────────────────────┐
│  DiscreteEventPriority    → 离散事件(click/keydown/focus…) │
│  ↕  最高优先级,同步处理,阻塞渲染                              │
├──────────────────────────────────────────────────────────────┤
│  ContinuousEventPriority  → 连续事件(mousemove/wheel/drag) │
│  ↕  次优先级,可被打断                                         │
├──────────────────────────────────────────────────────────────┤
│  DefaultEventPriority     → 其他事件 & 非事件触发的更新        │
│  ↕  最低,可由 Scheduler 统一批量调度                          │
└──────────────────────────────────────────────────────────────┘
// packages/react-dom/src/events/ReactDOMEventListener.js(简化)
function getEventPriority(domEventName) {
  switch (domEventName) {
    // 离散事件:用户点击/键盘,必须立即响应
    case 'click':
    case 'keydown':
    case 'keyup':
    case 'mousedown':
    case 'touchstart':
      return DiscreteEventPriority;    // = SyncLane

    // 连续事件:鼠标移动、滚轮,可以稍微延迟
    case 'mousemove':
    case 'mouseover':
    case 'wheel':
    case 'pointermove':
      return ContinuousEventPriority;  // = InputContinuousLane

    default:
      return DefaultEventPriority;     // = DefaultLane
  }
}

【代码注释】这三档优先级直接决定 React 何时刷新 UI。click 触发的 setState 是离散优先级,会在同一个微任务中同步完成;mousemove 触发的更新是连续优先级,可以被 Concurrent Mode 打断,避免界面卡顿。市面应用:拖拽排序(mousemove)和点击确认按钮(click)对用户感受完全不同,React 通过这三档优先级保证点击的即时响应。

11.3 createSyntheticEvent 工厂函数

React 不是直接继承 Event,而是用工厂函数生成 SyntheticEvent 类:

// packages/react-dom/src/events/SyntheticEvent.js(高度简化)
function createSyntheticEvent(Interface) {
  class SyntheticBaseEvent {
    constructor(reactName, reactEventType, targetInst, nativeEvent, nativeEventTarget) {
      this._reactName = reactName;          // 如 'onClick'
      this._targetInst = targetInst;        // 对应的 Fiber 节点
      this.nativeEvent = nativeEvent;       // 原始原生事件对象
      this.type = nativeEvent.type;

      // 遍历 Interface 定义,将原生事件的属性映射到合成事件
      for (const propName in Interface) {
        const normalize = Interface[propName];
        if (normalize) {
          this[propName] = normalize(nativeEvent);   // 经过规范化处理
        } else {
          this[propName] = nativeEvent[propName];    // 直接复制
        }
      }

      // 初始化持久化标志(React 17+ 已无实际作用,保留是向后兼容)
      this.isDefaultPrevented = functionThatReturnsFalse;
      this.isPropagationStopped = functionThatReturnsFalse;
    }

    preventDefault() {
      const event = this.nativeEvent;
      if (event.preventDefault) {
        event.preventDefault();
      } else {
        event.returnValue = false;           // 兼容 IE
      }
      this.isDefaultPrevented = functionThatReturnsTrue;
    }

    stopPropagation() {
      const event = this.nativeEvent;
      if (event.stopPropagation) {
        event.stopPropagation();
      } else {
        event.cancelBubble = true;           // 兼容 IE
      }
      // 注意:只标记了合成事件的传播停止标志
      // 原生事件的 stopPropagation 已调用,阻止冒泡到 root 容器外
      this.isPropagationStopped = functionThatReturnsTrue;
    }

    persist() {
      // React 17+ 的 persist() 是 no-op(空操作)
      // React 16 中会阻止事件对象被归还到事件池
    }
  }
  return SyntheticBaseEvent;
}

// 鼠标事件接口定义(决定 SyntheticMouseEvent 有哪些属性)
const MouseEventInterface = {
  screenX: null,       // null 表示直接复制原生属性
  screenY: null,
  clientX: null,
  clientY: null,
  pageX: null,
  pageY: null,
  ctrlKey: null,
  shiftKey: null,
  altKey: null,
  metaKey: null,
  button: null,
  buttons: null,
  // relatedTarget 需要规范化:IE 用 fromElement/toElement
  relatedTarget: function(event) {
    return event.relatedTarget ||
      (event.fromElement === event.srcElement ? event.toElement : event.fromElement);
  },
};

export const SyntheticMouseEvent = createSyntheticEvent(MouseEventInterface);

【代码注释】Interface 对象是每种事件的"属性规格说明":值为 null 表示直接从原生事件复制该属性;值为函数表示需要规范化处理(处理 IE 兼容或跨浏览器差异)。这种设计使得每种事件类型(鼠标、键盘、触摸、滚轮)只需声明一张接口表,而不用写大量 if/else 兼容代码。市面应用:React Native 用同样的工厂机制创建 SyntheticTouchEvent,保证跨平台 API 一致。

11.4 accumulateTwoPhaseListeners:收集捕获与冒泡处理函数

当一个事件触发时,React 需要从 Fiber 树中收集所有相关节点上注册的处理函数,并按捕获→目标→冒泡顺序排列:

// packages/react-dom/src/events/DOMPluginEventSystem.js(简化)
function accumulateTwoPhaseListeners(targetFiber, reactName) {
  const captureName = reactName + 'Capture';   // 如 'onClickCapture'
  const listeners = [];
  let fiber = targetFiber;

  // 从目标 Fiber 向上遍历到 root
  while (fiber !== null) {
    const { stateNode, tag } = fiber;

    if (tag === HostComponent && stateNode !== null) {
      // 收集捕获阶段(父 → 子方向)的处理函数
      const captureListener = getListener(fiber, captureName);
      if (captureListener != null) {
        // 插入数组开头:捕获阶段先于冒泡阶段执行
        listeners.unshift(captureListener);
      }

      // 收集冒泡阶段(子 → 父方向)的处理函数
      const bubbleListener = getListener(fiber, reactName);
      if (bubbleListener != null) {
        // 追加数组末尾:冒泡阶段后执行
        listeners.push(bubbleListener);
      }
    }
    fiber = fiber.return;   // 向上遍历父 Fiber
  }
  return listeners;
}

【代码注释】这段代码解释了 React 为何能模拟捕获/冒泡两阶段:它遍历 Fiber 树时同时收集两种处理函数,捕获阶段的处理函数用 unshift 插入数组前端,冒泡阶段的用 push 追加到末尾,最终 listeners 数组的顺序天然就是"从根到目标的捕获处理函数,再从目标到根的冒泡处理函数"。市面应用:理解这个机制是排查「为什么 onClickCapture 比 onClick 先触发」等问题的根本依据。


12. PureComponent 与 shouldComponentUpdate 深度解析

12.1 名词解释

术语含义
PureComponent自动实现 shouldComponentUpdate 的类组件基类,对 props/state 做浅比较
shallowEqual只比较对象第一层属性的相等性,不递归深层
shouldComponentUpdate类组件生命周期,返回 false 可跳过渲染
React.memo函数组件版本的 PureComponent,HOC 形式

12.2 shallowEqual 源码实现

React 内部的 shallowEqual 决定了 PureComponent 的比较逻辑:

// packages/shared/shallowEqual.js(完整源码)
function shallowEqual(objA, objB) {
  // 第一步:引用相等,直接返回 true(同一对象或同一原始值)
  if (Object.is(objA, objB)) {
    return true;
  }

  // 第二步:排除非对象类型(null、undefined、原始值)
  if (
    typeof objA !== 'object' || objA === null ||
    typeof objB !== 'object' || objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  // 第三步:键数量不同,直接返回 false
  if (keysA.length !== keysB.length) {
    return false;
  }

  // 第四步:逐个比较每个键的值(只比较第一层!)
  for (let i = 0; i < keysA.length; i++) {
    const key = keysA[i];
    if (
      !Object.prototype.hasOwnProperty.call(objB, key) ||
      !Object.is(objA[key], objB[key])   // 注意:用 Object.is 比较值
    ) {
      return false;
    }
  }

  return true;
}

【代码注释】关键是第四步用 Object.is 而非 === 比较:Object.is(NaN, NaN) 返回 trueObject.is(+0, -0) 返回 false,比 === 更严格。最重要的限制:只比较第一层,对于嵌套对象只比较引用。这是 PureComponent 最大的陷阱:如果 state 是嵌套对象且你直接修改了其内部属性而未替换外层引用,PureComponent 看不到变化,组件不会重渲染。

// ❌ 错误:直接修改嵌套对象,PureComponent 检测不到
this.setState(prevState => {
  prevState.user.name = '新名字';  // 直接修改,引用未变!
  return { user: prevState.user };
});

// ✅ 正确:创建新对象,引用变了
this.setState(prevState => ({
  user: { ...prevState.user, name: '新名字' }  // 新引用
}));

12.3 PureComponent vs Component vs React.memo 对比

组件类型              比较机制              适用范围
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Component             无(每次都渲染)        基础类组件
PureComponent         shallowEqual 自动比较  类组件性能优化
React.memo(Fn)        shallowEqual 自动比较  函数组件性能优化
React.memo(Fn, fn)    自定义比较函数          函数组件,需精细控制
shouldComponentUpdate 完全自定义逻辑          类组件,最灵活
// React.memo 自定义比较函数(areEqual 返回 true 表示相等,跳过渲染)
const MemoizedUser = React.memo(
  function UserCard({ user, onEdit }) {
    return <div>{user.name}<button onClick={onEdit}>编辑</button></div>;
  },
  // 自定义比较:只关心 user.id 和 user.name 是否变化
  (prevProps, nextProps) =>
    prevProps.user.id === nextProps.user.id &&
    prevProps.user.name === nextProps.user.name
);

【代码注释】React.memo 的第二个参数 areEqualshouldComponentUpdate 逻辑相反shouldComponentUpdate 返回 false 跳过渲染,而 areEqual 返回 true 跳过渲染。这是一个常见混淆点。市面应用:大型列表中每一行使用 React.memo,在父组件更新时避免全量重渲染,这是 Ant Design Table、react-virtualized 等组件的核心优化手段。

【实战要点】

  • 经典应用场景:购物车列表、数据表格的每行组件使用 React.memo,父组件状态变化(如筛选条件)时避免全量重渲染,显著提升大列表性能。
  • 常见坑:给 React.memo 包裹的组件传入行内对象或行内函数,每次渲染都会创建新引用,导致 memo 失效。解决方式是配合 useMemouseCallback
  • 性能与最佳实践:不要无脑给所有组件加 memo——比较本身也有开销;只在"组件渲染代价明显 + 父组件频繁重渲染 + props 很少变化"的场景使用。

【本章小结】

对比项PureComponentReact.memo
适用场景类组件函数组件
比较逻辑shallowEqualshallowEqual(默认)或自定义
自定义控制shouldComponentUpdate第二参数 areEqual
注意事项嵌套对象需新引用行内函数/对象会导致失效

【面试考点】

Q:PureComponent 和普通 Component 的区别?shallowEqual 的局限是什么?

A:PureComponent 自动在 shouldComponentUpdate 中对 props 和 state 做浅比较(shallowEqual),如果前后相等则跳过渲染,减少不必要的 re-render。shallowEqual 只比较对象第一层属性的引用,嵌套对象若直接修改内层属性而不替换外层引用,比较结果仍为相等,组件不会更新——这是最常见的陷阱。解决方法是始终遵循不可变数据原则,更新时用展开运算符或 Object.assign 创建新对象。


13. forwardRef 与 useImperativeHandle

13.1 问题背景:ref 不能传给函数组件

// ❌ 这段代码会报 Warning,ref 不会生效
function MyInput(props) {
  return <input {...props} />;
}

function Parent() {
  const inputRef = useRef(null);
  // inputRef.current 是 null,因为函数组件没有实例
  return <MyInput ref={inputRef} />;
}

函数组件没有实例(class 实例),所以直接给函数组件传 ref 无效。forwardRef 解决了这个问题:

// ✅ 用 forwardRef 让函数组件接受 ref
const MyInput = React.forwardRef(function(props, ref) {
  // ref 现在是父组件传入的 ref 对象
  return <input ref={ref} {...props} />;
  //          ↑ 把 ref 绑定到真实 DOM 或自定义对象
});

// 父组件可以获取 input 的 DOM 节点
function Parent() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus();   // 直接操作 DOM
  };

  return (
    <>
      <MyInput ref={inputRef} placeholder="请输入..." />
      <button onClick={focusInput}>聚焦输入框</button>
    </>
  );
}

【代码注释】forwardRef 接受一个渲染函数,其签名多了第二个参数 ref。React 在内部把父组件传来的 ref 通过这个参数传递进来,组件再将其绑定到某个 DOM 元素或组件上。市面应用:Ant Design 的 InputSelect 等表单组件都使用 forwardRef,允许父组件调用 .focus().blur() 等方法。

13.2 useImperativeHandle:精准暴露命令式 API

有时候不希望暴露整个 DOM 节点,只想暴露特定方法:

const SmartInput = React.forwardRef(function(props, ref) {
  const internalRef = useRef(null);
  const [value, setValue] = useState('');

  // 向父组件精准暴露:只有 focus、clear、getValue
  useImperativeHandle(ref, () => ({
    focus() {
      internalRef.current.focus();
    },
    clear() {
      setValue('');
      internalRef.current.value = '';
    },
    getValue() {
      return value;
    },
    // 父组件无法访问 internalRef.current 的其他属性
  }), [value]);  // 依赖 value,value 变化时重建暴露的对象

  return (
    <input
      ref={internalRef}
      value={value}
      onChange={e => setValue(e.target.value)}
      {...props}
    />
  );
});

// 父组件使用
function Form() {
  const smartInputRef = useRef(null);

  const handleSubmit = () => {
    console.log(smartInputRef.current.getValue());  // ✅ 自定义方法
    // smartInputRef.current.focus();               // ✅ 暴露的方法
    // smartInputRef.current.style = ...            // ❌ 未暴露,访问不到
  };

  return (
    <>
      <SmartInput ref={smartInputRef} />
      <button onClick={handleSubmit}>提交</button>
      <button onClick={() => smartInputRef.current.clear()}>清空</button>
    </>
  );
}

【代码注释】useImperativeHandle 的第三个参数是依赖数组,与 useEffect 相同:只有依赖变化时才重建暴露的对象。将 value 放入依赖是关键,否则 getValue() 永远返回初始值(闭包陷阱)。市面应用:富文本编辑器(如 Draft.js、Quill 的 React 封装)通过 useImperativeHandle 暴露 insertText()getContent() 等编辑 API,父组件通过 ref 调用而无需关心内部实现。

【实战要点】

  • 经典应用场景:表单库(react-hook-form)用 ref 统一管理受控组件的 focus/blur/reset 操作;模态框组件暴露 open()close() 命令式 API。
  • 常见坑:在 useImperativeHandle 中暴露函数时忘记把相关 state 加入依赖,导致闭包中读到旧值。
  • 性能与最佳实践useImperativeHandle 应谨慎使用——命令式 API 破坏了 React 的声明式数据流。只在确实需要精细 DOM 控制(动画、聚焦管理、第三方库集成)时才用。

14. Compound Components(复合组件)设计模式

14.1 概念与底层原理

复合组件是一种将多个紧密相关的子组件组合在一起、共享内部状态的设计模式。<select> + <option> 就是浏览器原生复合组件的例子:

// 传统写法(props 地狱)
<Select
  options={[
    { value: 'apple', label: '苹果', disabled: false, icon: '🍎' },
    { value: 'banana', label: '香蕉', disabled: true, icon: '🍌' },
  ]}
  renderOption={(opt) => <>{opt.icon} {opt.label}</>}
/>

// 复合组件写法(直观、灵活)
<Select defaultValue="apple">
  <Select.Option value="apple">🍎 苹果</Select.Option>
  <Select.Option value="banana" disabled>🍌 香蕉</Select.Option>
</Select>

实现原理是通过 React.createContext 在父子组件间共享状态:

// 实现一个 Tabs 复合组件
const TabsContext = React.createContext(null);

function Tabs({ children, defaultActiveKey }) {
  const [activeKey, setActiveKey] = useState(defaultActiveKey);

  return (
    <TabsContext.Provider value={{ activeKey, setActiveKey }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

function Tab({ eventKey, children }) {
  const { activeKey, setActiveKey } = useContext(TabsContext);
  const isActive = activeKey === eventKey;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      className={`tab ${isActive ? 'active' : ''}`}
      onClick={() => setActiveKey(eventKey)}
    >
      {children}
    </button>
  );
}

function TabPanel({ eventKey, children }) {
  const { activeKey } = useContext(TabsContext);
  if (activeKey !== eventKey) return null;
  return <div role="tabpanel">{children}</div>;
}

// 挂载子组件,形成命名空间
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// 使用
<Tabs defaultActiveKey="tab1">
  <Tabs.List>
    <Tabs.Tab eventKey="tab1">基本信息</Tabs.Tab>
    <Tabs.Tab eventKey="tab2">联系方式</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel eventKey="tab1"><ProfileForm /></Tabs.Panel>
  <Tabs.Panel eventKey="tab2"><ContactForm /></Tabs.Panel>
</Tabs>

【代码注释】复合组件通过 Context 在组件树内部传递状态,而无需把状态提升或通过 props 一层层传递。父组件 Tabs 是状态的拥有者,TabTabPanel 通过 useContext 消费状态。市面应用:Ant Design 的 Menu/Menu.ItemForm/Form.Item,Headless UI 的 Disclosure/Disclosure.Button/Disclosure.Panel 都是复合组件模式。

【实战要点】

  • 经典应用场景:导航菜单、折叠面板(Accordion)、步骤条(Steps)、表单套件——任何"父子紧密协作、子组件需要感知兄弟状态"的 UI 结构都适合复合组件。
  • 常见坑:忘记对 useContext 返回值做 null 检查,导致在 Context Provider 外部使用子组件时报错。应在 Context 中内置错误提示:
    function useTabsContext() {
      const ctx = useContext(TabsContext);
      if (!ctx) throw new Error('Tab 必须在 Tabs 内部使用');
      return ctx;
    }
    
  • 性能与最佳实践:Context 值变化会导致所有消费者重渲染,对于频繁变化的状态(如鼠标坐标)应拆分 Context 或配合 useMemo 稳定引用。

【本章小结】

复合组件的核心是"Context 共享 + 组合使用",三步实现:

  1. 创建 Context,父组件提供状态
  2. 子组件通过 useContext 消费状态
  3. 将子组件挂载为父组件的静态属性,形成命名空间 API

【面试考点】

Q:复合组件模式解决了什么问题?与 props 传递有什么本质区别?

A:复合组件解决"props 地狱"和"配置型 API 不灵活"的问题。传统 props 传递把所有配置项都塞进父组件,父组件变成复杂的配置对象;复合组件把"配置"变成"组合"——每个子组件负责自己的关注点,父组件只维护核心状态。本质区别是状态共享方式:props 是从父到子的单向显式传递;复合组件通过 Context 隐式共享,子组件无需知道数据来自哪里,只需消费即可,实现了逻辑与 UI 的解耦。


15. 补充面试考点

Q6:React 合成事件和原生事件的执行顺序?

事件触发完整顺序(React 17+,点击 App 内一个 div):

1. 捕获阶段(document → root):
   document 上的原生捕获监听器
   
2. root 容器上 React 合成事件(捕获阶段):
   onClickCapture handlers(从根到目标)
   
3. 目标元素上的原生事件(若有 addEventListener 直接绑定)

4. root 容器上 React 合成事件(冒泡阶段):
   onClick handlers(从目标到根)

5. 冒泡阶段(root → document):
   document 上的原生冒泡监听器

验证代码:

function App() {
  useEffect(() => {
    // 原生事件:直接绑在 DOM
    document.getElementById('btn').addEventListener('click', () => {
      console.log('3. 原生 click(目标上)');
    });
    document.addEventListener('click', () => {
      console.log('5. 原生 click(document 冒泡)');
    });
    document.addEventListener('click', () => {
      console.log('1. 原生 click(document 捕获)');
    }, true);  // true = 捕获阶段
  }, []);

  return (
    <div
      onClick={() => console.log('4. React onClick(冒泡)')}
      onClickCapture={() => console.log('2. React onClickCapture')}
    >
      <button id="btn">点击我</button>
    </div>
  );
}
// 控制台输出顺序:1 → 2 → 3 → 4 → 5

【代码注释】这段代码揭示了 React 17+ 的精确执行顺序。React 合成事件(捕获和冒泡)都在 root 容器这一层处理,夹在"document 捕获"和"document 冒泡"之间,而目标元素上的原生事件在 React 合成捕获之后、合成冒泡之前触发。市面应用:在旧项目迁移到 React 17 时,如果有依赖事件顺序的 jQuery 代码,必须对照这个顺序排查。

Q7:React 事件为什么要绑定 this?四种方法各有什么利弊?

A:JavaScript 中,类方法的 this调用者决定。将方法作为事件回调传给 React 时,React 内部以普通函数方式调用它(非 instance.method() 形式),因此 thisundefined(严格模式下)。

绑定方式代码示例优点缺点
构造函数 bindthis.handleClick = this.handleClick.bind(this)只创建一次函数代码冗余,每个方法都要写一遍
类字段箭头函数handleClick = () => {}简洁,推荐每个实例创建一个函数(非原型共享)
JSX 行内 bindonClick={this.handleClick.bind(this)}简单直接每次渲染创建新函数,影响子组件性能
JSX 行内箭头函数onClick={() => this.handleClick()}可传参每次渲染创建新函数,影响子组件性能

推荐:类字段箭头函数(方式 2),代码最简洁,每个实例创建一次函数引用,对子组件性能影响可控。

Q8:stopPropagation 只阻止合成事件冒泡,如何同时阻止原生事件?

function Inner() {
  const handleClick = (e) => {
    e.stopPropagation();
    // 只阻止了 React 合成事件系统内部的冒泡
    // 但 root 容器(#app)到 document 的原生冒泡仍然发生!
    
    // 如果需要阻止 document 上的原生监听器:
    e.nativeEvent.stopImmediatePropagation();
    // stopImmediatePropagation 比 stopPropagation 更彻底:
    // 连同一节点上的其他监听器也一并阻止
  };
  return <div onClick={handleClick}>内层</div>;
}

Q9:函数组件每次渲染都重新创建闭包,有性能问题吗?如何优化?

A:确实有代价,但通常可以接受。每次渲染会创建新的函数对象,产生 GC 压力。优化手段:

// useCallback:记忆函数引用,依赖不变时返回同一函数
const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);  // 只有 id 变化时才创建新函数

// useMemo:记忆计算结果,依赖不变时返回缓存值
const expensiveValue = useMemo(() => {
  return computeExpensive(data);
}, [data]);

// 注意:useCallback/useMemo 本身也有开销(比较依赖数组)
// 只在传给 React.memo 子组件或计算代价高时才用

Q10:什么是"状态提升"(Lifting State Up)?何时需要?

A:当两个兄弟组件需要共享同一份状态时,将状态提升到它们最近的公共父组件中管理,由父组件通过 props 下发。

// 提升前:两个组件各自维护温度,无法同步
function Celsius() { const [c, setC] = useState(0); ... }
function Fahrenheit() { const [f, setF] = useState(32); ... }

// 提升后:父组件维护温度,同步两个子组件
function TemperatureConverter() {
  const [celsius, setCelsius] = useState(0);
  const fahrenheit = celsius * 9/5 + 32;

  return (
    <>
      <CelsiusInput value={celsius} onChange={setCelsius} />
      <FahrenheitInput
        value={fahrenheit}
        onChange={f => setCelsius((f - 32) * 5/9)}
      />
    </>
  );
}

状态提升的判断原则:哪个组件需要这个状态?找到它们最近的公共祖先,状态就放在那里。


16. 记忆口诀补充

在第 8 节基础口诀之上,新增以下专项口诀:

16.1 事件系统五字诀

委托根节省内存,
合成包裹兼容好,
捕获冒泡两阶段,
stopProp 只管合成道,
原生阻断 nativeEvent。

逐句解读

  • 委托根节省内存 → 一个监听器管理所有子元素,React 17+ 挂到 root 节点
  • 合成包裹兼容好 → SyntheticEvent 封装原生事件,屏蔽浏览器差异
  • 捕获冒泡两阶段 → onClickCapture(捕获)和 onClick(冒泡)
  • stopProp 只管合成道 → e.stopPropagation() 只阻止合成事件传播
  • 原生阻断 nativeEvent → 要阻止原生事件需用 e.nativeEvent.stopImmediatePropagation()

16.2 受控/非受控口诀

受控三要素:value、onChange、state;
非受控两法宝:ref 和 defaultValue;
文件上传必非控,实时验证必受控。

16.3 this 绑定口诀

类组件 this 四绑法:
构造 bind 最稳妥,
类字段箭头最推荐,
行内两种各有坑。
函数组件无 this 烦,
闭包快照记心间。

16.4 组件复用三模式

HOC 装饰套外衣,Render Props 借武器用;
自定义 Hook 最现代,三种模式各有情。
新项目 Hook 是首选,旧代码 HOC 维护用;
错误边界类组件做,Concurrent 函数组件行。

16.5 性能优化口诀

memo 记忆子组件,callback 记忆函数引;
useMemo 缓存计算值,三者配合效果真;
嵌套对象新引用,shallow 比较才能赢。

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值