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> </> ); };
问题分析与修复方案
核心问题
- 组件重复挂载:
TextInput中Input的slots.input使用匿名函数返回<InnerInput>,每次TextInput渲染都会生成新的组件实例,React会卸载旧组件、挂载新组件,直接导致焦点丢失。 - ID不稳定:
InnerInput用useId()生成ID,组件重新挂载时会生成新ID,使<input>和<label>的关联失效,加剧焦点问题。 - 未受控状态:使用
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
相关产品推荐
相关产品推荐

