Isotope reloadItems()删除元素时UI重复问题求助
问题描述
使用React、Firebase实时数据库、Material UI和Isotope开发个人笔记项目时,遇到以下问题:添加删除确认模态框后,点击「确认删除」按钮,笔记已从数据库中正确移除,但调用Isotope的reloadItems()重载元素时,UI出现重复渲染的问题——数据库内无重复数据,但界面上会显示重复的笔记卡片。已尝试使用Isotope的reloadItems()和remove()方法,问题仍未解决。
相关代码
List.jsx
import '@/assets/styles/noteList.css' import RenderNotes from '@/components/RenderNotes' import MenuButton from '@/components/buttons/MenuButton' import SearchButton from '@/components/buttons/SearchButton' import { auth, db } from '@/services/firebase.config' import { sortByCreatedAtDate } from '@/utils/helpers' import AddIcon from '@mui/icons-material/Add' import { Box, Fab, Stack } from '@mui/material' import { ref } from 'firebase/database' import { useEffect, useState } from 'react' import { useAuthState } from 'react-firebase-hooks/auth' import { useListVals } from 'react-firebase-hooks/database' import { Link } from 'react-router-dom' import { useDebounce } from 'use-debounce' export default function NoteList () { const [notes, setNotes] = useState([]) const [user] = useAuthState(auth) const [search, setSearch] = useState('') const [values] = useListVals(ref(db, `note-it-db/${user.uid}/notes`)) const [debouncedSearch] = useDebounce(search, 300) useEffect(() => { const filterNotes = values.filter( note => note.content.toLowerCase().includes(debouncedSearch.toLowerCase()) ) setNotes( sortByCreatedAtDate(filterNotes) ) }, [debouncedSearch, values]) return ( <Stack> <Box className='header'> <MenuButton /> <SearchButton updateSearch={setSearch} /> </Box> <Box className='render-notes-container'> <RenderNotes notes={notes} /> </Box> <Fab aria-label='add' size='medium' component={Link} to='/notes' sx={{ position: 'fixed', bottom: '2rem', right: '1rem', background: 'linear-gradient(145deg, #5123f8, #8d70f5, #b8a4fe)', boxShadow: '5px 5px 10px #3517a1, -5px -5px 10px #bdacfc', color: '#ffffff', ':hover': { background: '#b8a4fe', boxShadow: '0 0 5px white' }, transition: 'none', zIndex: 1 }} > <AddIcon /> </Fab> </Stack> ) }
DeleteModal.jsx
import { auth, db } from '@/services/firebase.config' import DeleteIcon from '@mui/icons-material/Delete' import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Slide } from '@mui/material' import { ref, remove } from 'firebase/database' import { forwardRef } from 'react' import { useAuthState } from 'react-firebase-hooks/auth' const Transition = forwardRef(function Transition (props, ref) { return <Slide direction='up' ref={ref} {...props} /> }) export default function DeleteModal ({ isModalOpen, handleOnClose, elementId }) { const [user] = useAuthState(auth) const handleDelete = () => { const noteRef = ref(db, `note-it-db/${user.uid}/notes/${elementId}`) remove(noteRef) } return ( <Dialog aria-describedby='alert-dialog-slide-description' open={isModalOpen} TransitionComponent={Transition} keepMounted onClose={handleOnClose} sx={{ '& .MuiPaper-root': { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', alignItems: 'center', borderRadius: 'var(--card-border-radius)', backgroundColor: 'var(--color-secondary)', padding: '1rem' } }} > <DialogTitle> <DeleteIcon fontSize='large' color='error' /> </DialogTitle> <DialogContent> <DialogContentText id='alert-dialog-slide-description' align='center'> Are you sure you want to delete this note? </DialogContentText> </DialogContent> <DialogActions className='delete-modal__buttons'> <Button variant='outlined' onClick={handleOnClose} sx={{ color: 'var(--color-primary)', borderColor: 'var(--color-primary)', borderRadius: 'var(--card-border-radius)', textTransform: 'capitalize', fontWeight: 'bold', '&:hover': { borderColor: 'var(--color-primary)', backgroundColor: 'rgba(161, 141, 243, 0.15)' } }} > No, keep it </Button> <Button variant='contained' color='error' onClick={handleDelete} sx={{ borderRadius: 'var(--card-border-radius)', textTransform: 'capitalize', fontWeight: 'bold' }} > Yes, I'm sure </Button> </DialogActions> </Dialog> ) }
RenderNotes.jsx
import NoNotesFoundIcon from '@/assets/icons/NoNotesFoundIcon' import '@/assets/styles/noteList.css' import Isotope from 'isotope-layout' import 'isotope-packery' import { useEffect, useRef } from 'react' import { useSearchParams } from 'react-router-dom' import NoteCard from './NoteCard' const GridLayout = ({ notes }) => { const [searchParams] = useSearchParams() const filterBy = searchParams.get('filterBy') const gridRef = useRef(null) const isotopeRef = useRef(null) useEffect(() => { if (gridRef.current && notes) { if (!isotopeRef.current) { isotopeRef.current = new Isotope(gridRef.current, { layoutMode: 'packery', itemSelector: '.grid-item', packery: { gutter: 8 }, filter: filterBy !== null ? `.${filterBy}` : '*' }) } else { isotopeRef.current.arrange({ filter: filterBy ? `.${filterBy}` : '*' }) } } return () => { if (isotopeRef.current) { isotopeRef.current.reloadItems() isotopeRef.current.arrange({ filter: filterBy ? `.${filterBy}` : '*' }) } } }, [filterBy, notes, gridRef, isotopeRef]) return ( <div ref={gridRef} className='grid'> { notes.map(note => ( <div key={note.id} className={`grid-item ${note.favorite ? 'favorites' : ''}`} > <NoteCard note={note} /> </div> )) } </div> ) } export default function RenderNotes ({ notes }) { return notes && notes.length ? <GridLayout notes={notes} /> : <NoNotesFoundIcon /> }
问题根源
- Isotope实例与React渲染周期冲突:
GridLayout组件的useEffect清理函数中调用reloadItems(),会在组件更新/卸载前强制重新加载所有DOM元素,而此时React已经根据最新的notes数组重新渲染了DOM,导致Isotope同时识别新旧DOM元素,出现重复。 - 未同步Isotope实例与React状态:当
notes数组因删除操作缩短时,Isotope没有主动移除已被React删除的DOM元素,而是直接重新加载所有现有元素,导致视觉上的重复。
解决方案
方案1:优化Isotope更新逻辑(推荐)
修改GridLayout组件,在notes数组变化时,先移除Isotope中对应已删除的元素,再更新布局:
import { useEffect, useRef } from 'react' import Isotope from 'isotope-layout' import 'isotope-packery' const GridLayout = ({ notes }) => { const [searchParams] = useSearchParams() const filterBy = searchParams.get('filterBy') const gridRef = useRef(null) const isotopeRef = useRef(null) const prevNotesIdsRef = useRef(new Set()) useEffect(() => { const grid = gridRef.current if (!grid || !notes.length) return const currentNoteIds = new Set(notes.map(n => n.id)) const removedIds = [...prevNotesIdsRef.current].filter(id => !currentNoteIds.has(id)) // 初始化Isotope实例 if (!isotopeRef.current) { isotopeRef.current = new Isotope(grid, { layoutMode: 'packery', itemSelector: '.grid-item', packery: { gutter: 8 }, filter: filterBy ? `.${filterBy}` : '*' }) } else { // 移除已删除的元素 if (removedIds.length > 0) { const removedElements = removedIds.map(id => grid.querySelector(`[data-note-id="${id}"]`)) isotopeRef.current.remove(removedElements) } // 更新过滤条件并重新排列 isotopeRef.current.arrange({ filter: filterBy ? `.${filterBy}` : '*' }) } // 更新缓存的笔记ID集合 prevNotesIdsRef.current = currentNoteIds // 清理函数:销毁Isotope实例 return () => { if (isotopeRef.current) { isotopeRef.current.destroy() isotopeRef.current = null } } }, [filterBy, notes]) return ( <div ref={gridRef} className='grid'> {notes.map(note => ( <div key={note.id} data-note-id={note.id} // 添加data属性用于定位元素 className={`grid-item ${note.favorite ? 'favorites' : ''}`} > <NoteCard note={note} /> </div> ))} </div> ) }
方案2:强制重新初始化Isotope(简单场景适用)
当notes数组变化时,销毁旧的Isotope实例并重新创建,确保实例与最新DOM同步:
const GridLayout = ({ notes }) => { const [searchParams] = useSearchParams() const filterBy = searchParams.get('filterBy') const gridRef = useRef(null) const isotopeRef = useRef(null) useEffect(() => { const grid = gridRef.current if (!grid || !notes.length) return // 销毁旧实例 if (isotopeRef.current) { isotopeRef.current.destroy() } // 创建新实例 isotopeRef.current = new Isotope(grid, { layoutMode: 'packery', itemSelector: '.grid-item', packery: { gutter: 8 }, filter: filterBy ? `.${filterBy}` : '*' }) return () => { if (isotopeRef.current) { isotopeRef.current.destroy() isotopeRef.current = null } } }, [filterBy, notes]) // 渲染部分不变 }
内容的提问来源于stack exchange,提问作者Steven Lara
相关产品推荐
相关产品推荐

