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

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 />
}

问题根源

  1. Isotope实例与React渲染周期冲突:GridLayout组件的useEffect清理函数中调用reloadItems(),会在组件更新/卸载前强制重新加载所有DOM元素,而此时React已经根据最新的notes数组重新渲染了DOM,导致Isotope同时识别新旧DOM元素,出现重复。
  2. 未同步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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.01 17:44:56