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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.12 08:15:43