React类组件二次点击按钮无法更新state隐藏下拉菜单无报错

问题现象
实现货币下拉菜单组件时,预期交互逻辑为:
- 点击触发按钮展开菜单
- 点击菜单外部区域隐藏菜单
- 再次点击触发按钮时隐藏已展开的菜单
实际运行时,点击触发按钮对应的事件处理函数可正常执行(控制台能打印触发日志),但控制菜单显隐的isActiveCurrency状态未按预期更新,菜单无法触发隐藏逻辑,全程无报错。
原组件代码如下:
//CurrencyDropwon.js import React, { Component } from "react"; import { SelectWrapper,NavbarItem,CurrencyBtn } from "./CurrencyDropdownStyle"; import { getCurrency } from "../../../utls/MakeQuery"; import { connect } from 'react-redux' import { SelectInput } from '../../'; import { toggleCurrency } from '../../../store/reducers/currencyReducers' export class CurrencyDropdown extends Component { constructor() { super(); this.state = { currency: [], isComponentVisible: true, isActiveCurrency:false, ref: React.createRef(null), }; } handleHideDropdown = (event) => { if (event.key === 'Escape') { this.setState({ isComponentVisible: false }); } } handleClickOutside = (event) => { if (this.state.ref.current && !this.state.ref.current.contains(event.target)) { this.setState({ isActiveCurrency:!this.state.isActiveCurrency }) } } componentDidMount() { getCurrency() .then((res) => { this.setState({ currency: res.data.currencies, }); this.props.toggleCurrency("USD+$"); }) .catch((err) => { console.log(err); }); document.addEventListener('keydown', this.handleHideDropdown, true); document.addEventListener('click', this.handleClickOutside, true); } componentWillUnmount(){ document.removeEventListener('keydown', this.handleHideDropdown, true); document.removeEventListener('click', this.handleClickOutside, true); } render() { const handleCurrencyActive = () =>{ console.log("handleCurrencyActive CLICKED"); this.setState({ isActiveCurrency:!this.state.isActiveCurrency }) } return <> <NavbarItem> <CurrencyBtn onClick={()=>{handleCurrencyActive()}}> {this.props.currencySymbol} {this.state.isActiveCurrency ? "⮝":"⮟"} </CurrencyBtn> {this.state.isActiveCurrency && <SelectWrapper ref={this.state.ref}> {this.state.currency ? this.state.currency.map((item,index)=>( <SelectInput key={index} value={`${item.label}+${item.symbol}`} label={`${item.symbol} ${item.label}`}/> )):""} </SelectWrapper> } </NavbarItem> </>; } } const mapStateToProps = (state) =>{ return { currencyState:state.currency.currencyState, currencySymbol:state.currency.currencySymbol, cart:state.cart.cart.cartItems } } export default connect(mapStateToProps,{toggleCurrency}) (CurrencyDropdown);
故障原因
核心问题是事件触发顺序冲突+逻辑书写错误,具体如下:
- 全局点击事件绑定在
document上时设置了第三个参数为true,即事件在捕获阶段触发,执行时机早于React绑定在按钮上的合成事件(冒泡阶段触发)。 - 点击触发按钮时的执行流程为:
- 第一步:捕获阶段触发
handleClickOutside,此时判断点击目标不在下拉菜单的ref容器内(按钮本身确实不在SelectWrapper里),直接将isActiveCurrency取反——原本展开状态为true,这里先被改成false - 第二步:冒泡阶段触发按钮绑定的
handleCurrencyActive,又将isActiveCurrency从false取反回true - 两次取反后状态值和点击前完全一致,视觉上就表现为菜单点了没反应,但日志能正常打印。
- 第一步:捕获阶段触发
- 附带的其他逻辑错误:
- DOM ref不应该存放在state中,ref变化不需要触发组件重渲染,直接挂载在组件实例上即可
- 外部点击处理逻辑错误:不应该点击外部就对状态取反,正确逻辑是点击外部直接关闭菜单,否则会出现点击页面其他位置菜单莫名展开的bug
- ESC键关闭逻辑写错了字段,修改的是未使用的
isComponentVisible,根本不会影响菜单显隐 - 事件处理函数定义在render方法内,每次组件重渲染都会生成新的函数实例,造成不必要的性能消耗
修复方案
按以下几点修改即可:
- 将下拉容器的ref从state中移出,直接挂在组件实例上,同时给触发按钮也绑定ref用于判断点击范围
- 修正全局点击监听逻辑:仅当菜单展开时,点击目标既不在下拉容器内、也不在触发按钮上,才关闭菜单,不要做取反操作
- 修正ESC键监听逻辑,直接修改控制菜单显隐的
isActiveCurrency字段 - 把render中定义的点击处理函数移到类实例上,避免每次渲染都创建新函数
修复后的完整组件代码:
import React, { Component } from "react"; import { SelectWrapper,NavbarItem,CurrencyBtn } from "./CurrencyDropdownStyle"; import { getCurrency } from "../../../utls/MakeQuery"; import { connect } from 'react-redux' import { SelectInput } from '../../'; import { toggleCurrency } from '../../../store/reducers/currencyReducers' export class CurrencyDropdown extends Component { constructor() { super(); // ref直接挂载实例,不存入state this.wrapperRef = React.createRef(null); this.triggerBtnRef = React.createRef(null); this.state = { currency: [], isActiveCurrency:false, }; } handleHideDropdown = (event) => { if (event.key === 'Escape') { // 直接修改控制菜单显隐的字段,固定为关闭状态 this.setState({ isActiveCurrency: false }); } } handleClickOutside = (event) => { // 菜单未展开时不做处理 if (!this.state.isActiveCurrency) return // 点击目标在按钮或下拉容器范围内时不处理 if ( this.wrapperRef.current?.contains(event.target) || this.triggerBtnRef.current?.contains(event.target) ) { return } // 外部点击直接关闭菜单,不做取反操作 this.setState({ isActiveCurrency: false }) } handleCurrencyActive = () => { console.log("handleCurrencyActive CLICKED"); this.setState(prev => ({ isActiveCurrency: !prev.isActiveCurrency })) } componentDidMount() { getCurrency() .then((res) => { this.setState({ currency: res.data.currencies, }); this.props.toggleCurrency("USD+$"); }) .catch((err) => { console.log(err); }); document.addEventListener('keydown', this.handleHideDropdown, true); document.addEventListener('click', this.handleClickOutside, true); } componentWillUnmount(){ document.removeEventListener('keydown', this.handleHideDropdown, true); document.removeEventListener('click', this.handleClickOutside, true); } render() { return <> <NavbarItem> {/* 给触发按钮绑定ref */} <CurrencyBtn ref={this.triggerBtnRef} onClick={this.handleCurrencyActive}> {this.props.currencySymbol} {this.state.isActiveCurrency ? "⮝":"⮟"} </CurrencyBtn> {this.state.isActiveCurrency && <SelectWrapper ref={this.wrapperRef}> {this.state.currency ? this.state.currency.map((item,index)=>( <SelectInput key={index} value={`${item.label}+${item.symbol}`} label={`${item.symbol} ${item.label}`}/> )):""} </SelectWrapper> } </NavbarItem> </>; } } const mapStateToProps = (state) =>{ return { currencyState:state.currency.currencyState, currencySymbol:state.currency.currencySymbol, cart:state.cart.cart.cartItems } } export default connect(mapStateToProps,{toggleCurrency}) (CurrencyDropdown);
内容的提问来源于stack exchange,提问作者sarangkkl
相关产品推荐
相关产品推荐

