You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何消除React+Tailwind中输入框转Span的文本闪烁问题

解决可展开输入框过渡时文本闪烁问题

问题背景

实现了一个可展开的输入框组件,失焦时转为Span元素,聚焦时展开为输入框。宽度动画正常,但过渡期间文本出现不必要的闪烁:

文本闪烁效果

当前表现

  • 输入框获取焦点时展开
  • 失焦时转换为Span并收缩
  • 宽度动画期间文本内容出现闪烁

期望表现

  • 输入框与Span状态间平滑过渡
  • 动画期间无文本闪烁

原组件代码:

import React, { useState, useRef, useEffect } from 'react'

// Simple className merger function to replace cn()
const cn = (...classes: (string | undefined)[]) => {
  return classes.filter(Boolean).join(' ')
}

interface EditableProps {
  className?: string
  inputClassName?: string
  inlineTextClassName?: string
  placeholder?: string
  maxLength?: number
  isBold?: boolean
  onFocusChange?: (focused: boolean) => void
  inlineText?: string
}

export default function Editable({
  className,
  inputClassName,
  inlineTextClassName,
  placeholder = 'Enter your text here...',
  maxLength = 50,
  isBold = false,
  onFocusChange,
  inlineText = 'inline text'
}: EditableProps) {
  const [text, setText] = useState(placeholder)
  const [isFocused, setIsFocused] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
        setIsFocused(false)
        onFocusChange?.(false)
      }
    }
    document.addEventListener('mousedown', handleClickOutside)
    return () => document.removeEventListener('mousedown', handleClickOutside)
  }, [onFocusChange])

  const handleFocus = () => {
    setIsFocused(true)
    onFocusChange?.(true)
    setTimeout(() => inputRef.current?.select(), 0)
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value.slice(0, maxLength))
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') inputRef.current?.blur()
  }

  return (
    <div className={cn('flex items-center w-full', className)}>
      <div 
        ref={containerRef}
        className={cn(
          'transition-[flex] duration-300 ease-in-out',
          isFocused ? 'flex-1' : 'flex-initial'
        )}
      >
        {isFocused ? (
          <div className="flex items-center bg-gray-100 rounded-md w-full">
            <input
              ref={inputRef}
              value={text}
              onChange={handleChange}
              onBlur={() => setIsFocused(false)}
              onKeyDown={handleKeyDown}
              className={cn(
                'input-ghost focus:outline-none px-2 py-1 border-none bg-gray-100 rounded-md w-full',
                isBold ? 'font-bold' : 'font-normal',
                inputClassName
              )}
              style={{ fontWeight: isBold ? 'bold' : 'normal' }}
            />
            <div className="flex-shrink-0 px-2 text-sm text-gray-400">
              {text.length}/{maxLength}
            </div>
          </div>
        ) : (
          <span
            onClick={handleFocus}
            className={cn(
              'cursor-pointer hover:opacity-80 px-2 py-1 inline-block',
              isBold ? 'font-bold' : 'font-normal',
              inputClassName
            )}
          >
            {text}
          </span>
        )}
      </div>
      <span className={cn('ml-2 text-sm text-gray-500 flex-shrink-0', inlineTextClassName)}>{inlineText}</span>
    </div>
  )
}

问题原因

闪烁的核心原因是条件渲染导致DOM元素的卸载/挂载:当isFocused切换时,React会完全移除输入框DOM、创建Span元素,这个过程中浏览器会触发重绘,加上动画期间的布局变化,就会出现文本闪烁。

解决方案

不要切换DOM元素,而是同时保留输入框和Span,通过CSS控制它们的可见性与交互能力,确保两者样式完全对齐,避免布局偏移。具体修改如下:

修改后的代码

import React, { useState, useRef, useEffect } from 'react'

const cn = (...classes: (string | undefined)[]) => {
  return classes.filter(Boolean).join(' ')
}

interface EditableProps {
  className?: string
  inputClassName?: string
  inlineTextClassName?: string
  placeholder?: string
  maxLength?: number
  isBold?: boolean
  onFocusChange?: (focused: boolean) => void
  inlineText?: string
}

export default function Editable({
  className,
  inputClassName,
  inlineTextClassName,
  placeholder = 'Enter your text here...',
  maxLength = 50,
  isBold = false,
  onFocusChange,
  inlineText = 'inline text'
}: EditableProps) {
  const [text, setText] = useState(placeholder)
  const [isFocused, setIsFocused] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
        setIsFocused(false)
        onFocusChange?.(false)
      }
    }
    document.addEventListener('mousedown', handleClickOutside)
    return () => document.removeEventListener('mousedown', handleClickOutside)
  }, [onFocusChange])

  const handleFocus = () => {
    setIsFocused(true)
    onFocusChange?.(true)
    setTimeout(() => inputRef.current?.select(), 0)
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value.slice(0, maxLength))
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') inputRef.current?.blur()
  }

  // 统一文本显示的基础样式
  const textBaseClasses = cn(
    'px-2 py-1 font-normal',
    isBold ? 'font-bold' : '',
    inputClassName
  )

  return (
    <div className={cn('flex items-center w-full', className)}>
      <div 
        ref={containerRef}
        className={cn(
          'relative transition-[flex] duration-300 ease-in-out',
          isFocused ? 'flex-1' : 'flex-initial'
        )}
      >
        {/* 输入框容器 - 聚焦时显示 */}
        <div 
          className={cn(
            'flex items-center bg-gray-100 rounded-md w-full absolute inset-0',
            isFocused ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
          )}
        >
          <input
            ref={inputRef}
            value={text}
            onChange={handleChange}
            onBlur={() => setIsFocused(false)}
            onKeyDown={handleKeyDown}
            className={cn(
              'focus:outline-none border-none bg-transparent rounded-md w-full',
              textBaseClasses
            )}
          />
          <div className="flex-shrink-0 px-2 text-sm text-gray-400">
            {text.length}/{maxLength}
          </div>
        </div>

        {/* 静态Span - 失焦时显示 */}
        <span
          onClick={handleFocus}
          className={cn(
            'cursor-pointer hover:opacity-80 inline-block',
            textBaseClasses,
            isFocused ? 'opacity-0 pointer-events-none' : 'opacity-100 pointer-events-auto'
          )}
        >
          {text}
        </span>
      </div>
      <span className={cn('ml-2 text-sm text-gray-500 flex-shrink-0', inlineTextClassName)}>{inlineText}</span>
    </div>
  )
}

关键修改点

  1. 统一DOM结构:同时保留输入框容器和Span,通过opacity控制可见性,pointer-events控制是否可交互,避免DOM卸载/挂载
  2. 样式对齐:提取textBaseClasses统一文本的内边距、字体样式,确保输入框和Span的文本布局完全一致
  3. 定位处理:给输入框容器设置absolute inset-0,让它和Span在容器内完全重叠,过渡期间不会出现位置偏移
  4. 交互控制:聚焦时让输入框容器可交互、可见,Span不可见;失焦时反之,保持逻辑不变但避免DOM切换

这样修改后,宽度动画会保留,同时因为DOM元素始终存在,不会出现文本闪烁的问题。

内容的提问来源于stack exchange,提问作者Yehezkiel L

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 03:36:00