React新手求助:为Experience组件实现点击弹窗展示对应选项
嘿,别慌!作为React新手遇到这种需求很正常,我来一步步带你实现点击体验项弹出对应操作弹窗的功能~咱们分几个关键步骤来改:
第一步:整理每个类别的操作选项数据
首先,咱们得把每个体验项对应的操作选项(带图片和文字)整理清楚。你已经导入了各个类别的数据,比如gastronomiaExperiences,假设这些数据里包含了操作的图片和标题,咱们先把它们和体验项对应起来:
// 在组件内部定义这个映射(根据你的实际数据结构调整) const experienceActions = { 'GASTRONOMÍA': gastronomiaExperiences, 'PRODUCTOS': productosExperiences, 'GIFT CARD': giftcardExperiences, 'DIVERSIÓN': diversionExperiences, 'DEPORTE': deporteExperiences, 'BELLEZA': bellezaExperiences, };
(如果你的数据结构不是直接包含img和title字段,记得调整成弹窗能直接使用的格式)
第二步:修改状态,记录当前点击的体验项
原来的状态只存了弹窗的锚点,现在咱们要多存一个当前选中的类别,这样才能知道要渲染哪个操作选项:
// 替换原来的useState const [anchorEl, setAnchorEl] = React.useState(null); const [currentCategory, setCurrentCategory] = React.useState(null);
然后修改点击事件,同时设置锚点和当前类别,还要处理重复点击关闭弹窗的逻辑:
const handleClick = (event, category) => { // 点击同一个项就关闭弹窗,否则打开并设置当前类别 if (anchorEl === event.currentTarget && currentCategory === category) { setAnchorEl(null); setCurrentCategory(null); } else { setAnchorEl(event.currentTarget); setCurrentCategory(category); } };
还要修正experience组件里的重复onClick问题(原来的代码写了两个onClick会冲突):
const experience = useCallback((img, title) => ( <div className="experience" aria-describedby={id} onClick={(e) => handleClick(e, title)} // 传入当前体验项的标题作为类别标识 > <img data-sizes="auto" className="lazyload" data-src={img} alt={title} /> <div className="experience-title"> <Typography color="textSecondary" variant="subtitle2" className="highlight highlight1" display="inline" > { title } </Typography> </div> </div> ), []);
第三步:修改Popper弹窗内容,渲染对应操作选项
首先给弹窗加个美观的样式,在useStyles里添加:
paper: { backgroundColor: theme.palette.background.paper, border: '1px solid rgba(0,0,0,0.1)', padding: theme.spacing(1), display: 'flex', gap: theme.spacing(2), flexWrap: 'wrap', maxWidth: '300px', boxShadow: theme.shadows[3], }, actionItem: { textAlign: 'center', cursor: 'pointer', '& img': { width: '60px', height: '60px', objectFit: 'cover', borderRadius: '8px', }, '& p': { marginTop: theme.spacing(0.5), fontSize: '12px', }, },
然后修改Popper的内容,根据currentCategory渲染对应操作:
<Popper id={id} open={open} anchorEl={anchorEl} placement="top"> {currentCategory && ( <div className={classes.paper}> {experienceActions[currentCategory].map((action, index) => ( <div key={index} className={classes.actionItem}> <img src={action.img} alt={action.title} /> <Typography variant="caption">{action.title}</Typography> </div> ))} </div> )} </Popper>
可选优化:点击外部关闭弹窗
为了提升用户体验,咱们可以添加点击弹窗外部关闭的逻辑:
React.useEffect(() => { const handleOutsideClick = (event) => { if (anchorEl && !anchorEl.contains(event.target) && !event.target.closest(`#${id}`)) { setAnchorEl(null); setCurrentCategory(null); } }; document.addEventListener('mousedown', handleOutsideClick); return () => document.removeEventListener('mousedown', handleOutsideClick); }, [anchorEl, id]);
完整修改后的代码
把所有修改整合起来,完整组件代码如下:
import React, { memo, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/styles'; import Typography from '@material-ui/core/Typography'; import clsx from 'clsx'; import Popper from '@material-ui/core/Popper'; import gastronomia from 'assets/experiences/gastronomia.jpg'; import productos from 'assets/experiences/productos.jpg'; import giftcard from 'assets/experiences/giftcard.jpg'; import diversion from 'assets/experiences/diversion.jpg'; import deporte from 'assets/experiences/deporte.jpg'; import belleza from 'assets/experiences/belleza.jpg'; import gastronomiaExperiences from 'data/gastronomia'; import productosExperiences from 'data/productos'; import giftcardExperiences from 'data/giftcard'; import diversionExperiences from 'data/diversion'; import deporteExperiences from 'data/deporte'; import bellezaExperiences from 'data/belleza'; // Proptypes definitions to the component. const propTypes = { /** Custom root className. */ className: PropTypes.string, }; // Default props definitions. const defaultProps = { className: null, }; // Component's styles const useStyles = makeStyles(theme => ({ root: { display: 'block', margin: '0 auto', maxWidth: '50%', [theme.breakpoints.down('md')]: { maxWidth: '70%', }, [theme.breakpoints.down('sm')]: { maxWidth: '100%', }, '& .experiences-column': { display: 'inline-block', verticalAlign: 'top', textAlign: 'center', '&.col1': { width: '36.31%', [theme.breakpoints.down('sm')]: { width: 'initial', }, }, '&.col2': { width: '63.69%', [theme.breakpoints.down('sm')]: { width: 'initial', }, }, '& .experience': { padding: 2, position: 'relative', cursor: 'pointer', // 添加鼠标指针提示可点击 '& img': { width: '100%', display: 'block', }, '& .experience-title': { position: 'absolute', bottom: 30, left: 0, right: 0, textAlign: 'center', }, }, }, }, paper: { backgroundColor: theme.palette.background.paper, border: '1px solid rgba(0,0,0,0.1)', padding: theme.spacing(1), display: 'flex', gap: theme.spacing(2), flexWrap: 'wrap', maxWidth: '300px', boxShadow: theme.shadows[3], }, actionItem: { textAlign: 'center', cursor: 'pointer', '& img': { width: '60px', height: '60px', objectFit: 'cover', borderRadius: '8px', }, '& p': { marginTop: theme.spacing(0.5), fontSize: '12px', }, }, }), { name: 'ExperiencesStyle' }); /** * Component used to render a grid of experiences. * * @param {object} props - The component's props. * @returns {object} React element. */ const Experiences = memo( (props) => { const { className } = props; const classes = useStyles(props); const [anchorEl, setAnchorEl] = React.useState(null); const [currentCategory, setCurrentCategory] = React.useState(null); // 映射每个体验类别对应的操作选项 const experienceActions = { 'GASTRONOMÍA': gastronomiaExperiences, 'PRODUCTOS': productosExperiences, 'GIFT CARD': giftcardExperiences, 'DIVERSIÓN': diversionExperiences, 'DEPORTE': deporteExperiences, 'BELLEZA': bellezaExperiences, }; const handleClick = (event, category) => { // 点击同一项目则关闭,否则打开弹窗并设置当前类别 if (anchorEl === event.currentTarget && currentCategory === category) { setAnchorEl(null); setCurrentCategory(null); } else { setAnchorEl(event.currentTarget); setCurrentCategory(category); } }; const open = Boolean(anchorEl); const id = open ? 'experience-popper' : undefined; // 点击外部关闭弹窗 useEffect(() => { const handleOutsideClick = (event) => { if (anchorEl && !anchorEl.contains(event.target) && !event.target.closest(`#${id}`)) { setAnchorEl(null); setCurrentCategory(null); } }; document.addEventListener('mousedown', handleOutsideClick); return () => document.removeEventListener('mousedown', handleOutsideClick); }, [anchorEl, id]); const experience = useCallback((img, title) => ( <div className="experience" aria-describedby={id} onClick={(e) => handleClick(e, title)} > <img data-sizes="auto" className="lazyload" data-src={img} alt={title} /> <div className="experience-title"> <Typography color="textSecondary" variant="subtitle2" className="highlight highlight1" display="inline" > { title } </Typography> </div> </div> ), []); return ( <div className={clsx(classes.root, className)}> <div className="experiences-column col1"> {experience(gastronomia, 'GASTRONOMÍA')} {experience(giftcard, 'GIFT CARD')} {experience(deporte, 'DEPORTE')} </div> <div className="experiences-column col2"> {experience(productos, 'PRODUCTOS')} {experience(diversion, 'DIVERSIÓN')} {experience(belleza, 'BELLEZA')} </div> <Popper id={id} open={open} anchorEl={anchorEl} placement="top"> {currentCategory && ( <div className={classes.paper}> {experienceActions[currentCategory].map((action, index) => ( <div key={index} className={classes.actionItem}> <img src={action.img} alt={action.title} /> <Typography variant="caption">{action.title}</Typography> </div> ))} </div> )} </Popper> </div> ); }, ); // Component proptypes. Experiences.propTypes = propTypes; // Component default props. Experiences.defaultProps = defaultProps; export default Experiences;
一些注意事项
- 如果你的
gastronomiaExperiences等数据结构不包含img和title,记得根据实际数据调整弹窗里的渲染逻辑。 - 弹窗的位置(
placement属性)、样式都可以根据你的需求自由修改。 - 慢慢理解状态管理和事件处理的逻辑,React的核心就是状态驱动视图,多练几次就熟练啦~
内容的提问来源于stack exchange,提问作者NaNodine
相关产品推荐
相关产品推荐

