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

React+Joy-UI自定义输入组件单次输入即失焦问题排查求助

自定义输入组件输入单个字符后失焦问题

我用React、Joy-UI和Storybook构建UI组件,当前自定义输入组件每次只能输入一个字符就失去焦点,推测是重复渲染导致,但不确定具体诱因。


TextInput组件代码

import { FormControl, FormHelperText, Input, InputProps, styled } from "@mui/joy";
import Image from "next/image";
import React from "react";
import errorhelper from "../../../../public/components/common/errorhelper.svg";
import search from "../../../../public/components/common/search.svg";
import { InnerInput } from "./styles/InnerInput";

type TextInputBaseProps = Pick<InputProps, "disabled" | "error" | "onChange" | "type" | "value"> & {
    /**If true the icon is shown in the start of the textfield */
    showIcon: boolean;
    /**If true the textfield is optional */
    optional: boolean;
    /**Shows the textfield in focus state */
    focus?: boolean;
    /**The helper text for input field in default state*/
    helperText?: string;
    /**The helper text for input field in error state */
    errorHelperText?: string;
    /**The label for the input field */
    label: string;
};

/** A custom textfield component using Joy-UI's Input. */
export const TextInput = ({
    label,
    errorHelperText,
    helperText,
    focus,
    showIcon,
    optional,
    ...props
}: TextInputBaseProps) => {
    return (
        <FormControl>
            <Input
                {...props}
                onChange={props.onChange}
                startDecorator={showIcon ? <Image src={search} alt={""} width={16} height={16} /> : null}
                endDecorator={optional ? "(Optional)" : null}
                sx={{
                    ...(focus && {
                        outline: "0.125rem solid var(--colorSystemColorsFocus500)",
                        outlineOffset: "-0.0625rem"
                    })
                }}
                slots={{
                    input: () => <InnerInput {...props} showIcon={showIcon} label={label} />
                }}
            />
            {helperText && !props.error && <FormHelperText>{helperText}</FormHelperText>}
            {errorHelperText && props.error && (
                <FormHelperText sx={{ color: "var(--colorSystemColorsDanger500)" }}>
                    <Image src={errorhelper} alt={""} width={16} height={16} /> {errorHelperText}
                </FormHelperText>
            )}
        </FormControl>
    );
};

InnerInput样式及组件代码

import { styled } from "@mui/joy";
import React from "react";

// The StyledInput, StyledLabel and InnerInput is taken from Joy-UI floating label example
export const StyledInput = styled("input")({
    border: "none",
    minWidth: 0,
    outline: 0,
    padding: 0,
    paddingTop: "1em",
    flex: 1,
    color: "inherit",
    backgroundColor: "transparent",
    fontFamily: "inherit",
    fontSize: "inherit",
    fontStyle: "inherit",
    fontWeight: "inherit",
    lineHeight: "inherit",
    textOverflow: "ellipsis",
    "&::placeholder": {
        opacity: 0,
        transition: "0.1s ease-out"
    },
    "&:focus::placeholder": {
        opacity: 1
    },
    "&:focus ~ label": {
        color: "var(--colorTextSubtle1)",
        top: "0.5rem",
        fontSize: "0.75rem"
    },
    "&:-webkit-autofill": {
        alignSelf: "stretch"
    },
    "&:-webkit-autofill:not(* + &)": {
        marginInlineStart: "calc(-1 * var(--Input-paddingInline))",
        paddingInlineStart: "var(--Input-paddingInline)",
        borderTopLeftRadius: "calc(var(--Input-radius) - var(--variant-borderWidth, 0px))",
        borderBottomLeftRadius: "calc(var(--Input-radius) - var(--variant-borderWidth, 0px))"
    }
});

export const StyledLabel = styled("label")(({ theme }) => ({
    position: "absolute",
    lineHeight: 1,
    top: "calc((var(--Input-minHeight) - 1em) / 1.5)",
    color: "var(--colorTextSubtle1)",
    fontWeight: theme.vars.fontWeight.md,
    transition: "all 150ms cubic-bezier(0.4, 0, 0.2, 1)"
}));

export const InnerInput = React.forwardRef<
    HTMLInputElement,
    React.JSX.IntrinsicElements["input"] & { showIcon: boolean; label: string }
>(function InnerInput({ showIcon, label, ...props }, ref) {
    const id = React.useId();
    console.log(props.value);
    return (
        <React.Fragment>
            <StyledInput {...props} ref={ref} id={id} />
            <StyledLabel
                sx={{
                    marginLeft: showIcon ? "1.55rem" : 0,
                    fontSize: props.value ? "0.75rem" : "1rem",
                    top: props.value ? "0.5rem" : null
                }}
                htmlFor={id}>
                {label}
            </StyledLabel>
        </React.Fragment>
    );
});

组件使用示例

import AgentTableComponent from "@/components/custom/agent/AgentTable";
import AssetTableComponent from "@/components/custom/asset/AssetTable";
import NotificationsComponent from "@/components/custom/Notifications";
import ProtocolTableComponent from "@/components/custom/protocol/ProtocolTable";
import AssetTabsView from "./AssetTabs.view";
import { TextInput } from "@/components/common/input/TextInput.component";
import { useState } from "react";

const cardStyle = {
    backgroundColor: "lightGrey",
    borderRadius: "1rem",
    padding: "1rem",
    overflow: "auto",
    maxHeight: "30rem",
    marginBottom: "2rem",
    color: "black"
};

export default function DebugListView() {
    const [name, setName] = useState("");

    const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
        setName(e.target.value);
    };

    return (
        <>
            <div style={{ display: "flex" }}>
                <div
                    style={{
                        ...cardStyle,
                        flexBasis: "calc(50% - 2rem)",
                        marginRight: "2rem"
                    }}>
                    <h3 style={{ marginBottom: "0.5rem" }}>Notifications</h3>
                    <NotificationsComponent></NotificationsComponent>
                </div>
                <div style={{ ...cardStyle, flexBasis: "50%", minHeight: "30rem" }}>
                    <h3 style={{ marginBottom: "0.5rem" }}>Assets</h3>
                    <AssetTableComponent />
                    <TextInput onChange={handleInput} showIcon={true} optional={false} label={"name"} />
                </div>
            </div>

            <div style={{ display: "flex" }}>
                <div
                    style={{
                        ...cardStyle,
                        flexBasis: "calc(50% - 2rem)",
                        marginRight: "2rem"
                    }}>
                    <ProtocolTableComponent />
                </div>
                <div style={{ ...cardStyle, flexBasis: "50%" }}>
                    <h3 style={{ marginBottom: "0.5rem" }}>Protocols</h3>

                    <AssetTabsView />
                </div>
            </div>
            <div style={cardStyle}>
                <h3 style={{ marginBottom: "0.5rem" }}>Agents</h3>
                <AgentTableComponent></AgentTableComponent>
            </div>
        </>
    );
};

问题分析与修复方案

核心问题

  1. 组件重复挂载:TextInput中Input的slots.input使用匿名函数返回<InnerInput>,每次TextInput渲染都会生成新的组件实例,React会卸载旧组件、挂载新组件,直接导致焦点丢失。
  2. ID不稳定:InnerInput用useId()生成ID,组件重新挂载时会生成新ID,使<input>和<label>的关联失效,加剧焦点问题。
  3. 未受控状态:使用TextInput时未传递value,属于未受控模式,输入状态管理不规范,容易引发渲染异常。

修复步骤

1. 稳定slots.input组件引用

不要用匿名函数创建组件,直接传递InnerInput,通过slotProps传参:

// 修改TextInput中的Input部分
<Input
  {...props}
  onChange={props.onChange}
  startDecorator={showIcon ? <Image src={search} alt="" width={16} height={16} /> : null}
  endDecorator={optional ? "(Optional)" : null}
  sx={{
    ...(focus && {
      outline: "0.125rem solid var(--colorSystemColorsFocus500)",
      outlineOffset: "-0.0625rem"
    })
  }}
  slots={{
    input: InnerInput // 直接传组件引用
  }}
  slotProps={{
    input: { showIcon, label } // 通过slotProps传递参数
  }}
/>

2. 固定ID生成逻辑

优先使用外部传入的ID,避免组件重建时ID变化:

// 修改InnerInput组件
export const InnerInput = React.forwardRef<
  HTMLInputElement,
  React.JSX.IntrinsicElements["input"] & { showIcon: boolean; label: string; id?: string }
>(function InnerInput({ showIcon, label, id: propsId, ...props }, ref) {
  // 优先用外部传入的ID,没有则用useId(组件挂载后ID不会变)
  const id = propsId ?? React.useId();
  return (
    <React.Fragment>
      <StyledInput {...props} ref={ref} id={id} />
      <StyledLabel
        sx={{
          marginLeft: showIcon ? "1.55rem" : 0,
          fontSize: props.value ? "0.75rem" : "1rem",
          top: props.value ? "0.5rem" : null
        }}
        htmlFor={id}>
        {label}
      </StyledLabel>
    </React.Fragment>
  );
});

3. 改为受控组件

使用TextInput时传入value,绑定状态:

// 修改DebugListView中的TextInput调用
<TextInput 
  value={name}
  onChange={handleInput} 
  showIcon={true} 
  optional={false} 
  label={"name"} 
/>

验证效果

完成以上修改后,输入组件会保持稳定挂载,不会因渲染重建组件,焦点可正常保持,同时受控模式也能保证输入状态的一致性。


内容的提问来源于stack exchange,提问作者Arne Klopp

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.17 22:20:53