Next.js集成MUI默认仪表盘:localStorage存储抽屉状态异常排查
问题
我基于MUI默认仪表盘模板开发Next.js应用,实现localStorage存储抽屉open状态时遇到以下问题:
- 刷新页面后,尽管
localStorage中存储的open值为false,抽屉始终处于默认打开状态 - 刷新后第一次点击切换按钮无反应,此时
localStorage会被设为true,第二次点击才生效 - 控制台出现警告:
Warning: Prop className did not match. Server: "MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit MuiIconButton-edgeStart MuiIconButton-sizeMedium css-1johsky-MuiButtonBase-root-MuiIconButton-root" Client: "MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit MuiIconButton-edgeStart MuiIconButton-sizeMedium css-dhvns5-MuiButtonBase-root-MuiIconButton-root"
我的代码:
import * as React from 'react'; import { styled, createTheme, ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import MuiDrawer from '@mui/material/Drawer'; import Box from '@mui/material/Box'; import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import List from '@mui/material/List'; import Typography from '@mui/material/Typography'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; import Badge from '@mui/material/Badge'; import Container from '@mui/material/Container'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import Link from '@mui/material/Link'; import MenuIcon from '@mui/icons-material/Menu'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import NotificationsIcon from '@mui/icons-material/Notifications'; import theme from "../theme"; import { firstListMenu, secondaryListMenu } from './MenuList'; import {useEffect, useState} from "react"; const drawerWidth: number = 200; interface AppBarProps extends MuiAppBarProps { open?: boolean; } type Props = { children?: JSX.Element; } const AppBar = styled(MuiAppBar, { shouldForwardProp: (prop) => prop !== 'open', })<AppBarProps>(({ theme, open }) => ({ zIndex: theme.zIndex.drawer + 1, transition: theme.transitions.create(['width', 'margin'], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), ...(open && { marginLeft: drawerWidth, width: `calc(100% - ${drawerWidth}px)`, transition: theme.transitions.create(['width', 'margin'], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.enteringScreen, }), }), })); const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })( ({ theme, open }) => ({ '& .MuiDrawer-paper': { position: 'relative', whiteSpace: 'nowrap', width: drawerWidth, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.enteringScreen, }), boxSizing: 'border-box', ...(!open && { overflowX: 'hidden', transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), width: theme.spacing(7), [theme.breakpoints.up('sm')]: { width: theme.spacing(9), }, }), }, }), ); const mdTheme = theme; export default function Layout({children}: Props) { const [open, setOpen] = useState(() => { try { const value = window.localStorage.getItem('open'); if (value) { return JSON.parse(value); } else { window.localStorage.setItem('open', JSON.stringify(true)); return true; } } catch (err) { return true; } }); const toggleDrawer = () => { try { window.localStorage.setItem('open', JSON.stringify(!open)); } catch (err) { console.log(err) } setOpen(!open); }; useEffect(() => { console.log(open) }) return ( <ThemeProvider theme={mdTheme}> <Box sx={{ display: 'flex' }}> <CssBaseline /> <AppBar position="absolute" open={open}> <Toolbar sx={{ pr: '24px', // keep right padding when drawer closed }} > <IconButton edge="start" color="inherit" aria-label="open drawer" onClick={toggleDrawer} sx={{ marginRight: '36px', ...(open && { display: 'none' }), }} > <MenuIcon /> </IconButton> <Typography component="h1" variant="h6" color="inherit" noWrap sx={{ flexGrow: 1 }} > Dashboard </Typography> <IconButton color="inherit"> <Badge badgeContent={4} color="secondary"> <NotificationsIcon /> </Badge> </IconButton> </Toolbar> </AppBar> <Drawer variant="permanent" open={open}> <Toolbar sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', px: [1], }} > <IconButton onClick={toggleDrawer}> <ChevronLeftIcon /> </IconButton> </Toolbar> <Divider /> <List component="nav"> {firstListMenu} <Divider sx={{ my: 1 }} /> {secondaryListMenu} </List> </Drawer> <Box component="main" sx={{ backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900], flexGrow: 1, height: '100vh', overflow: 'auto', }} > <Toolbar /> <Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Grid container spacing={3}> {/* Chart */} <Grid item xs={12} md={8} lg={9}> <Paper sx={{ p: 2, display: 'flex', flexDirection: 'column', height: 240, }} > {children} </Paper> </Grid> {/* Recent Deposits */} <Grid item xs={12} md={4} lg={3}> <Paper sx={{ p: 2, display: 'flex', flexDirection: 'column', height: 240, }} > </Paper> </Grid> {/* Recent Orders */} <Grid item xs={12}> <Paper sx={{ p: 2, display: 'flex', flexDirection: 'column' }}> </Paper> </Grid> </Grid> </Container> </Box> </Box> </ThemeProvider> ); }
解决方案
1. 修复SSR导致的localStorage初始化问题
Next.js是服务端渲染框架,服务端不存在window对象,直接在useState初始化函数中访问localStorage会导致服务端渲染的初始状态与客户端不一致,引发 hydration 不匹配,这是刷新后抽屉状态异常、点击无反应的核心原因。
修改状态初始化逻辑,改用useEffect在客户端渲染完成后读取localStorage并同步状态:
const [open, setOpen] = useState(true); // 服务端默认初始值 useEffect(() => { try { const storedValue = window.localStorage.getItem('open'); if (storedValue !== null) { setOpen(JSON.parse(storedValue)); } else { window.localStorage.setItem('open', JSON.stringify(true)); } } catch (err) { console.error('读取localStorage失败:', err); } }, []); // 仅在组件挂载时执行一次
2. 优化toggleDrawer函数的状态同步
原函数中先更新localStorage再更新状态,可能出现状态不同步的情况,改为基于最新状态更新:
const toggleDrawer = () => { setOpen(prevOpen => { const newOpen = !prevOpen; try { window.localStorage.setItem('open', JSON.stringify(newOpen)); } catch (err) { console.error('写入localStorage失败:', err); } return newOpen; }); };
3. 解决className不匹配警告
该警告源于服务端与客户端生成的MUI CSS类名不一致,处理方式:
- Next.js 13+ App Router:在文件顶部添加
'use client';指令,将组件标记为客户端组件 - Next.js Page Router:使用
dynamic导入组件并禁用SSR:import dynamic from 'next/dynamic'; const Layout = dynamic(() => import('./Layout'), { ssr: false }); - 确保服务端与客户端使用完全一致的
theme配置,避免主题差异导致CSS类名生成不同
内容的提问来源于stack exchange,提问作者Jorhanc
相关产品推荐
相关产品推荐

