Refs 与 DOM 操作最佳实践

192 阅读3分钟

React 版本:18.x 难度:中级

考察要点

  1. useRef 的使用场景
  2. forwardRef 的应用
  3. DOM 操作的最佳实践
  4. 性能优化考虑

解答:

1. 概念解释

基本定义

  • Ref:访问 DOM 节点或 React 元素的引用
  • useRef:创建可变引用的 Hook
  • forwardRef:转发 ref 到子组件

工作原理

  • 在组件渲染周期外保持值
  • 不触发组件重新渲染
  • 支持命令式操作

应用场景

  • 焦点管理
  • 文本选择
  • 动画控制
  • 媒体播放
  • 第三方库集成

2. 代码示例

基础示例:
import React, { useRef, useEffect } from 'react';

interface InputProps {
  autoFocus?: boolean;
  placeholder?: string;
  onFocus?: () => void;
}

// 👉 基础输入组件
const FocusInput: React.FC<InputProps> = ({ 
  autoFocus, 
  placeholder,
  onFocus 
}) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (autoFocus && inputRef.current) {
      inputRef.current.focus();
    }
  }, [autoFocus]);

  return (
    <input
      ref={inputRef}
      type="text"
      placeholder={placeholder}
      onFocus={onFocus}
      className="focus-input"
    />
  );
};

// 👉 可转发 ref 的输入组件
const ForwardedInput = React.forwardRef<
  HTMLInputElement,
  InputProps
>((props, ref) => (
  <input
    ref={ref}
    type="text"
    {...props}
    className="forwarded-input"
  />
));

// 👉 使用示例
export const InputExample: React.FC = () => {
  const forwardedRef = useRef<HTMLInputElement>(null);

  const handleFocusClick = () => {
    forwardedRef.current?.focus();
  };

  return (
    <div>
      <FocusInput 
        autoFocus 
        placeholder="Auto focused input"
      />
      
      <ForwardedInput
        ref={forwardedRef}
        placeholder="Forwarded ref input"
      />
      
      <button onClick={handleFocusClick}>
        Focus Forwarded Input
      </button>
    </div>
  );
};
进阶示例:
import React, { useRef, useEffect, useState } from 'react';

interface MediaPlayerProps {
  src: string;
  onTimeUpdate?: (currentTime: number) => void;
  onEnded?: () => void;
}

// 👉 自定义 Hook 用于媒体控制
const useMediaControls = (videoRef: React.RefObject<HTMLVideoElement>) => {
  const [isPlaying, setIsPlaying] = useState(false);
  const [duration, setDuration] = useState(0);
  const [currentTime, setCurrentTime] = useState(0);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const handleTimeUpdate = () => {
      setCurrentTime(video.currentTime);
    };

    const handleDurationChange = () => {
      setDuration(video.duration);
    };

    video.addEventListener('timeupdate', handleTimeUpdate);
    video.addEventListener('durationchange', handleDurationChange);

    return () => {
      video.removeEventListener('timeupdate', handleTimeUpdate);
      video.removeEventListener('durationchange', handleDurationChange);
    };
  }, [videoRef]);

  const togglePlay = () => {
    const video = videoRef.current;
    if (!video) return;

    if (video.paused) {
      video.play();
      setIsPlaying(true);
    } else {
      video.pause();
      setIsPlaying(false);
    }
  };

  const seek = (time: number) => {
    const video = videoRef.current;
    if (!video) return;

    video.currentTime = time;
  };

  return {
    isPlaying,
    duration,
    currentTime,
    togglePlay,
    seek
  };
};

// 👉 媒体播放器组件
export const MediaPlayer: React.FC<MediaPlayerProps> = ({
  src,
  onTimeUpdate,
  onEnded
}) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const {
    isPlaying,
    duration,
    currentTime,
    togglePlay,
    seek
  } = useMediaControls(videoRef);

  useEffect(() => {
    onTimeUpdate?.(currentTime);
  }, [currentTime, onTimeUpdate]);

  return (
    <div className="media-player">
      <video
        ref={videoRef}
        src={src}
        onEnded={onEnded}
      />
      
      <div className="controls">
        <button onClick={togglePlay}>
          {isPlaying ? 'Pause' : 'Play'}
        </button>
        
        <input
          type="range"
          min={0}
          max={duration}
          value={currentTime}
          onChange={(e) => seek(Number(e.target.value))}
        />
        
        <span>
          {Math.floor(currentTime)}/{Math.floor(duration)}
        </span>
      </div>
    </div>
  );
};

3. 注意事项与最佳实践

❌ 常见错误示例

// ❌ 错误示范:过度使用 ref 操作 DOM
const BadComponent: React.FC = () => {
  const divRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // 错误:应该使用 state 控制样式
    if (divRef.current) {
      divRef.current.style.backgroundColor = 'red';
    }
  }, []);

  return <div ref={divRef} />;
};

// ❌ 错误示范:在渲染期间访问 ref
const AnotherBadComponent: React.FC = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  
  // 错误:渲染时访问 ref
  const value = inputRef.current?.value || '';

  return <input ref={inputRef} defaultValue={value} />;
};

✅ 正确实现方式

// ✅ 正确示范:合理使用 ref
const GoodComponent: React.FC = () => {
  const [backgroundColor, setBackgroundColor] = useState('white');
  const inputRef = useRef<HTMLInputElement>(null);

  const handleFocus = () => {
    inputRef.current?.focus();
  };

  return (
    <div style={{ backgroundColor }}>
      <input ref={inputRef} />
      <button onClick={handleFocus}>Focus Input</button>
    </div>
  );
};

// ✅ 正确示范:结合 useImperativeHandle
interface TextInputHandle {
  focus: () => void;
  clear: () => void;
}

const TextInput = React.forwardRef<TextInputHandle, {}>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);

  React.useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current?.focus();
    },
    clear: () => {
      if (inputRef.current) {
        inputRef.current.value = '';
      }
    }
  }));

  return <input ref={inputRef} />;
});

4. 性能优化

import React, { useRef, useCallback, useState } from 'react';

// 👉 优化的滚动容器
const OptimizedScrollContainer: React.FC = () => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isNearBottom, setIsNearBottom] = useState(false);

  // 使用 useCallback 缓存滚动处理函数
  const handleScroll = useCallback(() => {
    const container = containerRef.current;
    if (!container) return;

    const { scrollTop, scrollHeight, clientHeight } = container;
    const threshold = 100;
    
    setIsNearBottom(
      scrollHeight - scrollTop - clientHeight < threshold
    );
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    container.addEventListener('scroll', handleScroll);
    return () => {
      container.removeEventListener('scroll', handleScroll);
    };
  }, [handleScroll]);

  return (
    <div
      ref={containerRef}
      style={{ height: '300px', overflow: 'auto' }}
    >
      {/* 内容 */}
      {isNearBottom && (
        <div>Near bottom!</div>
      )}
    </div>
  );
};

5. 测试策略

import { render, screen, fireEvent } from '@testing-library/react';

describe('FocusInput', () => {
  it('should focus input on mount when autoFocus is true', () => {
    render(<FocusInput autoFocus />);
    
    const input = screen.getByRole('textbox');
    expect(document.activeElement).toBe(input);
  });

  it('should forward ref correctly', () => {
    const ref = React.createRef<HTMLInputElement>();
    render(<ForwardedInput ref={ref} />);
    
    expect(ref.current).toBeInstanceOf(HTMLInputElement);
  });
});

这个实现展示了 Refs 和 DOM 操作的完整使用方案,包括:

  1. 基础和进阶的实现方式
  2. 错误处理和最佳实践
  3. 性能优化策略
  4. 测试方法

关键是要理解何时使用 refs,以及如何正确处理 DOM 操作来提升组件的可维护性和性能。