如何解决React Radio Group组件首次加载选择时的页面位移问题
Radio Group组件首次选择时出现位移问题
我有一个可复用的Radio Group组件,功能运行正常,但首次加载后用户选择选项时,组件会出现快速位移(疑似重渲染导致)。后续操作不再出现该问题,但无法找到根本原因。
相关代码
radio-button.stories.tsx
import { ComponentMeta } from '@storybook/react'; import { useState } from 'react'; import RadioButtonGroup from './radio-button-group'; import { IOptionGroup } from './radio-button.types'; export default { title: 'Components/RadioButton', component: RadioButtonGroup, } as ComponentMeta<typeof RadioButtonGroup>; const Template = ({ ...args }: IOptionGroup) => { const options = [ { label: 'Coffee', name: 'button-types', }, { label: 'Tea', name: 'button-types', }, { label: 'Disabled option', name: 'button-types', disabled: true, }, ]; const [selectedValue, setSelectedValue] = useState<string>(options[0].label); function radioGroupHandler(event: React.ChangeEvent<HTMLInputElement>) { setSelectedValue(event.target.value); } console.log('selectedValue: ', selectedValue); const label = 'Select an option:'; return ( <RadioButtonGroup {...args} options={options} label={label} onChange={radioGroupHandler} horizontal /> ); }; export const button = Template.bind({});
radio-button-group.tsx
import RadioButton from './radio-button'; import { Legend, Fieldset, Horizontal, Vertical } from './radio-button.styles'; import { IOption, IOptionGroup } from './radio-button.types'; export const RadioButtonGroup = ({ label, options, horizontal, onChange }: IOptionGroup) => { function getRenderedOptions() { return options.map(({ label, name, disabled }: IOption, index) => { const shortenedOptionLabel = label.replace(/\s+/g, ''); const optionId = `radio-option-${shortenedOptionLabel}`; return ( <RadioButton value={label} label={label} key={`${optionId}-${index}`} id={optionId} name={name} disabled={disabled} defaultChecked={index === 0} onChange={onChange} /> ); }); } const renderedOptions = horizontal ? ( <Horizontal>{getRenderedOptions()}</Horizontal> ) : ( <Vertical>{getRenderedOptions()}</Vertical> ); return ( <Fieldset> <Legend>{label}</Legend> {renderedOptions} </Fieldset> ); }; export default RadioButtonGroup;
radio-button.tsx
import { StyledRadioButton, Radio, Wrapper } from './radio-button.styles'; import { RadioButtonProps } from './radio-button.types'; export const RadioButton = ({ label, id, disabled, ...rest }: RadioButtonProps) => { return ( <Wrapper> <Radio type="radio" id={id} disabled={disabled} {...rest} /> <StyledRadioButton htmlFor={id}>{label}</StyledRadioButton> </Wrapper> ); }; RadioButton.displayName = 'RadioButton'; export default RadioButton;
radio-button.types.ts
import { InputHTMLAttributes } from 'react'; export type option = string | number; export interface RadioButtonProps extends InputHTMLAttributes<HTMLInputElement> { label: string; id: string; key: string | number; disabled?: boolean; } export interface IOption { label: string; name?: string; disabled?: boolean; } export interface IOptionGroup { label: string; options: IOption[]; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; horizontal?: boolean; }
radio-button.styles.ts
import styled from 'styled-components'; export const Fieldset = styled.fieldset` border: none; `; export const Vertical = styled.div``; export const Horizontal = styled.div` display: flex; gap: 1rem; `; export const StyledRadioButton = styled.label<{ disabled?: boolean }>` font-weight: 400; font-size: 16px; color: black; `; export const Legend = styled.legend` font-weight: 400; font-size: 16px; color: black; `; export const Wrapper = styled.div` display: flex; gap: 1rem; margin-top: 0.5rem; margin-bottom: 0.5rem; align-items: center; `; export const Radio = styled.input` -webkit-appearance: none; appearance: none; margin: 0; width: 1.5em; height: 1.5em; border: 1px solid grey; font-size: 16px; font-style: normal; border-radius: 50%; cursor: pointer; transition: all 0.1s ease-in-out; ::after { content: ''; display: block; border-radius: 50%; width: 0.75em; height: 0.75em; margin: 4.26px; } :checked { ::after { background-color: blue; } border: 1px solid green; } :hover { border: 1px solid red; } border: 1px solid red; :disabled { cursor: not-allowed; border: 1px solid grey; background-color: grey; :checked { ::after { background-color: grey; } } } `;
问题根源分析
- 半受控状态冲突:当前组件给第一个Radio按钮设置了
defaultChecked,同时父组件通过onChange维护selectedValue,导致组件处于半受控模式。首次点击时,React会修正Radio的选中状态,触发额外重渲染,引发布局抖动。 - Legend默认样式不稳定:浏览器对
<legend>有默认的内外边距样式,首次重渲染时可能因布局计算变化导致位移。 - 动态key的潜在影响:Radio按钮的key使用
${optionId}-${index},虽然当前选项固定,但这种组合方式不如直接用唯一的optionId稳定,可能触发不必要的组件更新。
修复方案
1. 改为完全受控组件
移除defaultChecked,通过checked绑定父组件的selectedValue,确保状态完全受控:
修改radio-button-group.tsx:
export const RadioButtonGroup = ({ label, options, horizontal, onChange, selectedValue }: IOptionGroup) => { function getRenderedOptions() { return options.map(({ label, name, disabled }: IOption) => { const shortenedOptionLabel = label.replace(/\s+/g, ''); const optionId = `radio-option-${shortenedOptionLabel}`; return ( <RadioButton value={label} label={label} key={optionId} id={optionId} name={name} disabled={disabled} checked={selectedValue === label} onChange={onChange} /> ); }); } // 其余代码不变 };
更新radio-button.types.ts的IOptionGroup接口:
export interface IOptionGroup { label: string; options: IOption[]; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; horizontal?: boolean; selectedValue: string; // 添加选中值参数 }
在Storybook模板中传递selectedValue:
return ( <RadioButtonGroup {...args} options={options} label={label} onChange={radioGroupHandler} horizontal selectedValue={selectedValue} /> );
2. 固定Legend布局
为<Legend>设置明确的外边距和布局属性,消除浏览器默认样式的影响:
export const Legend = styled.legend` font-weight: 400; font-size: 16px; color: black; margin: 0 0 0.5rem 0; padding: 0; display: block; `;
3. 优化Radio样式稳定性
为Radio按钮添加box-sizing: border-box,确保边框变化不会影响元素尺寸:
export const Radio = styled.input` -webkit-appearance: none; appearance: none; margin: 0; width: 1.5em; height: 1.5em; border: 1px solid grey; font-size: 16px; font-style: normal; border-radius: 50%; cursor: pointer; transition: all 0.1s ease-in-out; box-sizing: border-box; // 添加此行 ::after { content: ''; display: block; border-radius: 50%; width: 0.75em; height: 0.75em; margin: 4.26px; box-sizing: border-box; // 添加此行 } // 其余样式不变 `;
内容的提问来源于stack exchange,提问作者Leo Messi
相关产品推荐
相关产品推荐

