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

如何解决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;
      }
    }
  }
`;

问题根源分析

  1. 半受控状态冲突:当前组件给第一个Radio按钮设置了defaultChecked,同时父组件通过onChange维护selectedValue,导致组件处于半受控模式。首次点击时,React会修正Radio的选中状态,触发额外重渲染,引发布局抖动。
  2. Legend默认样式不稳定:浏览器对<legend>有默认的内外边距样式,首次重渲染时可能因布局计算变化导致位移。
  3. 动态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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.06 10:40:17