我一直觉得前端圈有个奇怪的现象:Redux Toolkit 刚出来那会儿,大家都在用它管理用户信息、购物车、接口缓存,但很少有人把屏幕尺寸这种"看起来很简单"的状态交给它。早期我做响应式页面,要么靠 CSS 媒体查询硬扛,要么在不同组件里各自写一套 window.innerWidth 监听逻辑,直到做了一个数据可视化大屏项目,才彻底改变思路——那个项目里图表、侧边栏、顶部筛选区、卡片栅格全都要跟着视口变化做联动调整,而且大量 JS 业务逻辑要感知"当前到底是不是移动端",CSS 媒体查询根本管不过来,我得在 ECharts 实例上手动调用 resize,在表格列显隐策略里做出判断,在导航折叠状态里做切换。
用 Redux Toolkit 管屏幕尺寸,本质上解决的不是"怎么知道宽度变了",而是"怎么让所有需要响应视口的逻辑,都用同一份可信的状态源"。这篇文章我就从项目里拆出来的方案讲起,把 SSR 水合、resize 监听、防抖节流、Store 性能这些关键点全部过一遍,代码直接照着抄就能跑。
一、为什么屏幕尺寸要进全局 Store:一个真实场景引起的思考
1.1 你觉得够了,其实不够:CSS 媒体查询在复杂页面的局限性
大多数响应式需求用 CSS 媒体查询就够了。你写三套断点,调整字体、间距、栅格列数,浏览器自动帮你切换,这是纯展示层的响应式,根本不需要 JavaScript 参与。
但真实业务远比这复杂。我在大屏项目里遇到的情况是:卡片栅格可以用 CSS Grid 的 repeat(auto-fit, minmax(280px, 1fr)) 撑住,但一旦屏幕变窄,图表的渲染配置不能只靠 CSS。ECharts 需要知道容器实际宽度来重新计算坐标轴刻度密度,如果窗口变小了你还用原来的 grid 配置,x 轴标签会挤成一团;地图组件需要根据可视区域调整缩放级别;前端表格在窄屏下要切换列显隐策略,比如移动端只展示关键三列,桌面端展示全部七列——这些都不是一张媒体查询表能搞定的。
更麻烦的是,多个组件要同步响应同一时刻的视口变化。侧边栏折叠了,顶部导航要跟着改变布局;筛选区组件要从横向排列变成纵向排列,图表要重新计算高度,如果你在每个组件里各自监听 resize,不仅代码重复,还容易出现状态不一致:侧边栏已经认为自己是移动端了,图表还拿着桌面端的宽度在算。
这时候你需要的是一份全局唯一的视口状态,所有组件都从这份状态里读取当前尺寸,而不是各自猜测。
1.2 自定义 Hook、Context、Redux:状态管理方案选型对比
有人会说,我写一个 useViewport() 自定义 Hook,内部监听 resize,然后把宽度暴露出来,每个组件自己调用不就行了?这个方案在小项目里完全成立,但到了复杂项目就有几个问题:
- 每个调用
useViewport()的组件都要各自挂一个 resize 事件监听器。十个组件就挂十个监听器,resize 本身就高频触发,还涉及不同组件的回调执行顺序。 - 组件之间无法通过这个 Hook 共享衍生状态。我要根据宽度算出
isMobile,还要把这个布尔值传给不在组件树里的工具函数或图表配置模块,Hook 就很难优雅传递。 - 用 Hook 的组件每次 resize 都重新渲染,即使它只是用了一个布尔值,但实际上订阅了整个
{ width, height }对象,导致无关的更新也触发重渲染。
用 Context 呢?Context 更适合"低频更新、全局配置"的场景,高频更新下所有消费组件都会重渲染,而且 Context 没有 DevTools 可以观察状态变化。Redux Toolkit 的优势在于:
- 单一 Store,所有订阅者通过 selector 精确获取自己需要的字段,只有字段变化才重渲染。
- DevTools 可以查看每一次尺寸变化的 action,复现问题非常方便。
- 状态逻辑可以完全解耦成纯函数,配合 TypeScript 类型推导很舒服。
所以在"跨组件状态共享 + 高频更新 + 需要逻辑联动"这三个条件同时满足时,把屏幕尺寸放进 Redux Toolkit 是最合理的。
1.3 用 Redux Toolkit 管理尺寸的真正收益
我实际项目的体感是:把屏幕尺寸收进 Store 之后,你不再需要思考"某个组件现在是什么端"。任何一个工具函数、图表配置模块、服务端请求参数组装逻辑,都可以直接 import { store } from '@/store' 然后 store.getState().screenSize.isMobile 来获取。
这种收益在团队协作里体现得最明显:后端接口要传一个 platform 参数,你不需要在页面组件里通过 props 一层层穿下去,直接读 Store;测试环境里想模拟手机端,DevTools 里 dispatch 一个 action 就全局生效了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
二、服务端渲染下的尺寸状态:水合问题是方案的真正难点
2.1 水合不匹配:问题是怎么发生的
Next.js 是服务端渲染框架,组件代码会在 Node.js 环境先执行一次,生成 HTML 字符串发给浏览器,然后浏览器再执行一次 JavaScript,把事件处理器和状态绑上去,这个过程叫水合。
问题来了:Node.js 环境里没有 window,没有 window.innerWidth,你在组件渲染期间直接读取屏幕宽度,服务端和客户端的首次渲染结果一定不一致。React 在检测到这种不一致时会报水合错误,严重情况下直接丢弃客户端渲染结果,页面表现为闪烁甚至白屏。
很多新手第一次在 Next.js 里做响应式,直接在组件里这么写:
tsx复制const [width, setWidth] = useState(window.innerWidth);
服务端渲染时 window 未定义,直接崩溃。退一步,用 typeof window !== 'undefined' 判断后再读,客户端首次渲染的值和服务端渲染的值依然不一致,同样会触发水合警告。
2.2 我的方案:服务端用默认值,客户端 useEffect 再同步
我的做法是把窗口读取逻辑完全放进 useEffect 生命周期。useEffect 只在客户端执行,而且在浏览器完成首次渲染之后才异步执行,所以不会影响 React 在水合阶段对初次渲染结果的比对。
Store 的初始值可以按桌面端来设置,比如:
typescript复制const initialState: ScreenSizeState = {
width: 1024,
height: 768,
isMobile: false,
isTablet: false,
isDesktop: true,
isPortrait: false,
isLandscape: true,
};
服务端渲染 HTML 时,组件拿到的就是上面这组默认值;浏览器端首次渲染也拿这组默认值,两边完全一致,水合不会报错。然后 useEffect 里读取真实窗口尺寸,dispatch 一个 action 更新 Store,React 再触发一次正常更新。这个流程是安全的。
你不需要担心"服务端返回了默认桌面端宽度,用户实际在手机上访问,首屏会不会闪一下"。会在极少数情况下闪一下,因为首屏 HTML 是按桌面端宽度渲染的,然后马上被客户端真实值纠正过来。想彻底避免这个闪烁,可以在全局加一个内联脚本,在 React 水合前就把 document.documentElement.clientWidth 写入一个全局变量,Store 初始值优先取这个变量,再兜底默认值。这个方案在 Next.js App Router 里可以用 dangerouslySetInnerHTML 在 <head> 注入,或者用 next/script 实现,但大多数项目没必要首屏就精确响应,默认桌面端 + useEffect 及时纠正已经足够。
2.3 为什么不用 useSyncExternalStore
React 官方提供的 useSyncExternalStore 确实可以解决外部 store 在并发模式下的 tear 问题,但它不适合作为 Next.js 里跨页面持久化的全局状态。原因很简单:useSyncExternalStore 的 getSnapshot 必须在服务端和客户端返回一致的结果,你依然逃不过"服务端没有 window"这个根本限制。它不是用来解决 SSR 下响应式状态初始化问题的,Redux Toolkit 没有采用它,而是直接用 useEffect 同步,就是为了避开服务端快照不一致的坑。
三、从 Slice 到 Provider:Redux Toolkit 尺寸管理的完整落地
3.1 尺寸 Slice 的设计细节:字段到底该存什么
先说结论:Store 里不要只存 width 和 height 两个原始数字,要把"基于宽高计算出来的衍生状态"也一起存进去。
为什么?因为 isMobile、isPortrait 这些布尔值在多个组件里被频繁读取,如果每次读的时候都现场算一遍,你还要在 selector 里维护断点常量,而且不同组件可能用不同断点,造成逻辑不一致。不如在 dispatch 计算出所有需要的标记位,Store 里直接存最终结果,组件拿来即用。
typescript复制'use client';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface ScreenSizeState {
width: number;
height: number;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isPortrait: boolean;
isLandscape: boolean;
devicePixelRatio: number;
}
const initialState: ScreenSizeState = {
width: 1024,
height: 768,
isMobile: false,
isTablet: false,
isDesktop: true,
isPortrait: false,
isLandscape: true,
devicePixelRatio: 1,
};
const screenSizeSlice = createSlice({
name: 'screenSize',
initialState,
reducers: {
setScreenSize(state, action: PayloadAction<Partial<ScreenSizeState>>) {
Object.assign(state, action.payload);
},
resetScreenSize() {
return initialState;
},
},
});
export const { setScreenSize, resetScreenSize } = screenSizeSlice.actions;
export const selectScreenSize = (state: { screenSize: ScreenSizeState }) =>
state.screenSize;
export const selectIsMobile = (state: { screenSize: ScreenSizeState }) =>
state.screenSize.isMobile;
export const selectIsTablet = (state: { screenSize: ScreenSizeState }) =>
state.screenSize.isTablet;
export const selectIsDesktop = (state: { screenSize: ScreenSizeState }) =>
state.screenSize.isDesktop;
export default screenSizeSlice.reducer;
这里有一个细节值得展开:为什么 useAction 里传 Partial,而不是每次传完整对象?
因为在 reducer 里你可能只想更新一部分字段。比如某个场景你只关心宽度变化,不想重新计算高度相关字段,Partial 类型就给了这个灵活性。但 my 项目实际使用中,我建议还是每次 dispatch 完整对象,因为尺寸变化时宽高一定都变了,没必要制造部分更新的特例。
另一个细节:resetScreenSize() 在没有特殊需求时其实用不上,但在测试环境里非常有用——每个测试用例跑完之后重置 Store,避免用例之间状态污染。
3.2 store 配置:用 configureStore 组装所有模块
Store 配置很简单,但要注意点在于:RootState 类型的导出方式会影响全局 selector 的写法。
typescript复制import { configureStore } from '@reduxjs/toolkit';
import screenSizeReducer from '@/store/features/screenSizeSlice';
export const store = configureStore({
reducer: {
screenSize: screenSizeReducer,
},
devTools: process.env.NODE_ENV !== 'production',
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
RootState 用 ReturnType<typeof store.getState> 可以自动推导,后续添加新 slice 时不需要手动改类型定义。AppDispatch 同理。这就是 Toolkit 相比原生 Redux 的一个优势:类型推导基本零成本。
然后需要一个带类型的 Hooks 封装:
typescript复制import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '@/store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T>(
selector: (state: RootState) => T
): T => useSelector(selector);
这样在各个组件里用的时候,不需要每次都手动标注类型,TypeScript 能自动推导出 selector 返回的类型。
3.3 Provider 挂载:App Router 的根布局必须标 'use client'
Next.js App Router 下,Redux Provider 不能放在 Server Component 里,因为 Provider 内部用了 React Context,而 Context 是客户端机制。所以要做两层布局:
第一层是 Server Component 根布局,保持服务端渲染能力:
tsx复制// app/layout.tsx
import Providers from '@/components/Providers';
import RootLayoutClient from '@/components/RootLayoutClient';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
第二层是客户端 Provider 组件:
tsx复制'use client';
import { Provider } from 'react-redux';
import { store } from '@/store';
export default function Providers({
children,
}: {
children: React.ReactNode;
}) {
return <Provider store={store}>{children}</Provider>;
}
注意:Providers 组件本身没有消费任何尺寸状态,所以即使尺寸 Store 更新了,它也不会重渲染,不会影响整棵组件树。
然后需要一个在客户端触发初始同步的组件,放在 Provider 内部:
tsx复制'use client';
import { useScreenSize } from '@/hooks/useScreenSize';
export default function RootLayoutClient({
children,
}: {
children: React.ReactNode;
}) {
useScreenSize();
return <>{children}</>;
}
这样整个布局树里从顶层开始就订阅了屏幕尺寸,后续的页面组件不需要关心"尺寸监听器挂了吗"这个问题。
四、resize 监听的正确姿势:防抖、节流与生命周期细节
4.1 resize 事件的高频陷阱:为什么不能裸监听
window.resize 事件在拖拽窗口改变尺寸时,触发频率远超想象。尤其在高刷新率显示器上,每秒钟可能触发几十次甚至上百次。如果每次触发都 dispatch 一个 Redux action,会造成大量 Store 更新,然后所有订阅 selector 的组件全部重新渲染,页面直接卡成幻灯片。
处理方式有两种流派:防抖(debounce)和节流(throttle)。防抖是"操作停止后延迟执行",节流是"固定时间间隔内最多执行一次"。
对于 resize 场景,节流是更合适的默认选择,因为用户拖拽窗口时希望看到 UI 实时响应,防抖会让 UI 更新滞后到停止拖拽之后,体验很差。节流可以让 UI 保持跟随,但不会每一帧都更新。
React 生态里最流畅的节流方案是结合 requestAnimationFrame(rAF)。rAF 是浏览器每次重绘前执行的一次回调,天然拥有"每帧最多一次"的频率上限,不会像 setTimeout 那样出现跳帧或挤压。
4.2 rAF 节流 + 清理的完整实现
我的 useScreenSize Hook 完整代码:
typescript复制'use client';
import { useEffect } from 'react';
import { useAppDispatch } from '@/store/hooks';
import { setScreenSize } from '@/store/features/screenSizeSlice';
export const MOBILE_BREAKPOINT = 768;
export const TABLET_BREAKPOINT = 1024;
export function getScreenSizePayload() {
const width = window.innerWidth;
const height = window.innerHeight;
const isMobile = width < MOBILE_BREAKPOINT;
const isTablet = width >= MOBILE_BREAKPOINT && width < TABLET_BREAKPOINT;
const isDesktop = width >= TABLET_BREAKPOINT;
const isPortrait = height > width;
const isLandscape = width >= height;
return {
width,
height,
isMobile,
isTablet,
isDesktop,
isPortrait,
isLandscape,
devicePixelRatio: window.devicePixelRatio || 1,
};
}
export function useScreenSize() {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setScreenSize(getScreenSizePayload()));
let ticking = false;
const handleResize = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
dispatch(setScreenSize(getScreenSizePayload()));
ticking = false;
});
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [dispatch]);
}
几个关键点:
ticking标志位保证同一帧内即使 resize 事件触发了多次,也只 dispatch 一次。requestAnimationFrame回调在浏览器每次重绘前执行,屏幕 60Hz 时就是每秒最多 60 次,150Hz 时就是 150 次,完全匹配视觉刷新率。useEffect返回清理函数,卸载时移除监听器,避免内存泄漏。- 断点常量单独导出,这样组件里写业务逻辑时可以直接引用,不用在多个文件里反复魔数。
这种写法还有一个好处:初始同步也在 useEffect 里完成。刚才说水合阶段要用默认值,useEffect 第一次执行时就会把真实值推进 Store,完成纠正。
4.3 防抖在什么情况下要用:resize 结束后的扩展操作
虽然节流能满足实时跟随,但有些场景你其实想知道的是"用户停止拖拽窗口了"。比如你可能想在 resize 结束后,再让某个重型图表重新计算布局,而不是拖拽过程中反复计算。
这时候可以扩展一下 Hook,把"resize 结束"也作为一个事件发出去:
typescript复制let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const handleResizeDone = () => {
dispatch(setScreenSize(getScreenSizePayload()));
};
const handleResize = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
dispatch(setScreenSize(getScreenSizePayload()));
ticking = false;
});
if (resizeTimer) {
clearTimeout(resizeTimer);
}
resizeTimer = setTimeout(handleResizeDone, 150);
};
150 毫秒的防抖时间既能覆盖"停止拖拽"的语义,又不会让用户觉得太迟钝。这个扩展可以解决"ECharts 在拖拽过程中频繁 resize 导致卡顿,但停止拖拽后图表没有正确恢复"的问题——拖拽过程中用 rAF 节流全量更新太奢侈,停止后用防抖做一次最终精确更新。
4.4 React StrictMode 下 effect 双执行:监听器会重复吗
React 18 的 StrictMode 在开发环境会故意让 effect 执行两次:挂载 -> 卸载 -> 再挂载。这个机制是为了暴露内存泄漏和副作用问题。
所以你的 effect 如果写成:
typescript复制useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
开发环境会看到监听器被添加两次吗?不会。因为第一次挂载后,StrictMode 会立即执行 cleanup(卸载 effect),触发 window.removeEventListener,把第一次添加的监听器移除,然后再重新挂载。只要你的 cleanup 正确,监听器不会堆积。这恰好也是验证 cleanup 是否写对了的机会——如果你在 StrictMode 下发现监听器重复触发,第一件事就检查 return 清理函数。
4.5 移动端的隐藏坑:地址栏伸缩与方向变化
移动浏览器比桌面浏览器复杂得多。地址栏在滚动时收缩 / 展开,会触发 resize 事件,而且高度变化非常剧烈。如果你把内容高度直接绑定在 window.innerHeight 上,用户上下滚动时页面布局会被反复重排,体验极差。
我的处理建议是:
- 移动端布局尽量只依赖宽度相关状态,比如
isMobile、isTablet、isDesktop,不要直接依赖height。 - 如果确实需要
height做页面高度计算,建议加上"高度变化超过某个阈值才更新"的保护,比如上一次高度是 800,这一次是 720,变化幅度只有 10%,就不 dispatch。 - 方向变化(横屏 / 竖屏)时,
orientationchange事件和 resize 事件都会触发,不用特意单独监听orientationchange,只需要确保isPortrait、isLandscape计算逻辑正确。
还有一个易忽略的点:设备像素比(devicePixelRatio)不是固定的。用户拖拽窗口跨屏时,比如从普通显示器拖到 Retina 外接屏,devicePixelRatio 可能会变化。如果你用 canvas 或图片做高清适配,需要自己监听 matchMedia('(resolution: ' + window.devicePixelRatio + 'dppx)') 的变化,这个不是 resize 能覆盖的。
五、组件侧消费与响应式业务逻辑:把尺寸状态用起来
5.1 基础用法:useAppSelector 读取尺寸状态
组件里最直接的用法是:
tsx复制'use client';
import { useAppSelector } from '@/store/hooks';
import {
selectScreenSize,
selectIsMobile,
selectIsDesktop,
} from '@/store/features/screenSizeSlice';
export default function DashboardHeader() {
const isMobile = useAppSelector(selectIsMobile);
const isDesktop = useAppSelector(selectIsDesktop);
return (
<header>
{isMobile ? <MobileNav /> : <DesktopNav />}
{isDesktop && <SearchBar />}
</header>
);
}
你可以用整个 screenSize 对象,也可以拆成单个字段。用哪个取决于组件对更新的敏感度。拆得越细,组件被无关更新打扰的可能性越低,这一点在性能调优部分详细展开。
5.2 响应式业务逻辑:不只是换 CSS 类名
真正让我决定把尺寸放进全局 Store 的,是下面这些业务逻辑:
图表配置改动
ECharts 实例的配置不是纯 CSS 能覆盖的:
tsx复制const screenSize = useAppSelector(selectScreenSize);
const chartOption = useMemo(() => {
const baseOption = {
grid: {
left: screenSize.isMobile ? 20 : 60,
right: screenSize.isMobile ? 20 : 40,
},
xAxis: {
axisLabel: {
interval: screenSize.isMobile ? 'auto' : 0,
rotate: screenSize.isMobile ? 30 : 0,
},
},
};
return baseOption;
}, [screenSize.isMobile, screenSize.width]);
useEffect(() => {
chart.setOption(chartOption);
}, [chartOption]);
useEffect(() => {
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [chart]);
其实 ECharts 的 chart.resize() 本身会监听窗口变化,但如果你同时要根据尺寸修改配置项,最好还是统一从 Store 读取,否则会出现"容器尺寸已经变了,但配置还是旧宽度算出来的"这种中间态。
表格列显隐策略
Ant Design Table 的列配置可以这样动态计算:
tsx复制const isMobile = useAppSelector(selectIsMobile);
const columns = useMemo(() => {
if (isMobile) {
return [nameColumn, statusColumn, actionColumn];
}
return [idColumn, nameColumn, categoryColumn, priceColumn, stockColumn, statusColumn, createdAtColumn, actionColumn];
}, [isMobile]);
这种方式明显比"用 CSS 隐藏某个列"更干净,因为不展示的列直接不渲染,从根源上避免窄屏下表格宽度溢出。
导航折叠
侧边栏从展开变成折叠,传统做法是维护一个 collapsed 布尔状态,但当你希望"视口变窄时自动折叠,视口变宽时自动展开"时,就需要监听尺寸变化并联动:
tsx复制const isDesktop = useAppSelector(selectIsDesktop);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (isDesktop) {
setCollapsed(false);
} else {
setCollapsed(true);
}
}, [isDesktop]);
这个逻辑如果散落在多个页面组件里,很容易出现用户手动展开导航后,窗口 resize 一下又把状态覆盖回默认值的问题。我的经验是:手动操作状态优先级要高于自动响应状态。所以更严谨的写法是记录一个 manuallyControlled 标志,用户点击展开/折叠后清空这个标志,下次 resize 时再重新触发自动逻辑。
5.3 Selector 粒度:关于重渲染的一个真实案例分析
先看一段反面教材:
tsx复制const screenSize = useAppSelector(selectScreenSize);
if (screenSize.isMobile) {
// ...
}
这段代码的问题在于:selectScreenSize 返回的是整个 screenSize 对象,只要宽度、高度、DPR 任何一个字段变了,组件都会重渲染。但你这个组件其实只关心 isMobile 这一个字段。
Redux 的 useSelector 默认做的是全等比较(===)。selectScreenSize 每次都会返回同一个 state 引用(Store 中 screenSize 字段的引用),除非 screenSize 对象本身被替换。但 Object.assign(state, action.payload) 是原地修改 state 对象吗?
是。Object.assign(state, action.payload) 把 payload 的属性拷贝到 state 对象上,state 对象的引用没有变。那 Redux Toolkit 的 Immer 机制怎么处理?
注意到 createSlice 内部使用 Immer,Object.assign(state, action.payload) 会被 Immer 代理捕获。Immer 会记录对 state 的所有修改,然后生成一个新的不可变对象,所以最终 Store 里 screenSize 字段的引用是变化的。也就是说,任何 setScreenSize 都会让 screenSize 对象引用变化,所有订阅整个对象的组件都会重渲染。
要避免这个问题,要么用单个字段的 selector,要么用 useShallowEqualSelector:
typescript复制import { useSelector, shallowEqual } from 'react-redux';
const { isMobile, isTablet } = useSelector(
(state: RootState) => ({
isMobile: state.screenSize.isMobile,
isTablet: state.screenSize.isTablet,
}),
shallowEqual
);
shallowEqual 会比较对象第一层字段是否变化,只有 isMobile 或 isTablet 变化才会触发重渲染。这就是我在 slice 文件里同时导出 selectScreenSize(整体)和 selectIsMobile(原子)的原因。
六、性能陷阱与调优:减少重渲染和事件开销
6.1 事件监听器数量:从源头限制
我碰到过最夸张的情况:一个项目里有十几个组件各自 window.addEventListener('resize', ...),而且每个组件都执行了自己的图表 resize 和状态计算。resize 一触发,几十个回调同时跑,页面卡得不行。
用我前面的方案,全局只在一个 useScreenSize Hook 里挂一个 resize 监听器,其他组件都通过 Redux selector 订阅状态变化,而不是直接监听原生事件。这样事件监听器的数量从 N 变成 1,这是最根本的性能优化。
6.2 更新频率控制:setScreenSize 的防抖节流组合
前面已经讲了 rAF 节流 + 150ms 防抖的组合。这里补充一点:并不是每次 resize 都要 dispatch。如果宽度变了 1 像素,isMobile 的值可能没变,移动端断点判断结果可能完全一样。可以在 getScreenSizePayload 之后加一层比较:
typescript复制const dispatchScreenSize = () => {
const payload = getScreenSizePayload();
const current = store.getState().screenSize;
if (
current.width === payload.width &&
current.height === payload.height &&
current.devicePixelRatio === payload.devicePixelRatio
) {
return;
}
dispatch(setScreenSize(payload));
};
宽度、高度、DPR 都没变,布尔值一定不会变,就不用 dispatch。这个优化在旋转屏幕或者修改 DPR 的场景中很有意义,能避免无意义的 Store 更新和重渲染。
6.3 组件渲染成本隔离:把重型组件从高频更新中摘出来
尽管 rAF 已经把频率控制到每帧一次,但对于一个大型仪表盘页面来说,每帧一次的重渲染依然可能昂贵,尤其是涉及复杂图表、地图、数据表格的页面。我有两种思路:
思路一:在订阅组件外面包一层 memo,让接收 store 字段变化的只有轻量组件。比如 DashboardHeader 订阅 isMobile,它内部的 MobileNav 和 DesktopNav 接收的是 props,只要 isMobile 没变化,React.memo 就能拦住子组件重渲染。
tsx复制const MobileNav = React.memo(() => {
// 复杂的移动端导航组件,不直接订阅 store
return null;
});
const DesktopNav = React.memo(() => {
// 复杂的桌面端导航组件
return null;
});
思路二:把尺寸状态从"驱动渲染"变成"驱动一次副作用"。比如某些图表只需要在尺寸从桌面端跨到移动端时才重新 init 一次,而不是每次都 setOption。这种情况下可以订阅一个 screenSizeCategory(值为 'mobile' | 'tablet' | 'desktop')字段,只有三级变化时才触发初始化逻辑。
tsx复制const screenCategory = useAppSelector(
(state) => {
const s = state.screenSize;
if (s.isMobile) return 'mobile';
if (s.isTablet) return 'tablet';
return 'desktop';
}
);
useEffect(() => {
// 只在类别变化时重新初始化图表
chart.dispose();
chart = echarts.init(containerRef.current);
chart.setOption(buildOption(screenCategory));
}, [screenCategory]);
这种"类别订阅"比直接订阅宽度的粒度更粗,重渲染频率低得多,适合那些不需要连续响应像素级变化的场景。
6.4 Redux DevTools 在这个方案里的一个隐患
Redux DevTools 会把每一个 action 记录下来。如果你用 rAF 节流但还是每帧 dispatch 一次,用户拖拽窗口 3 秒就可能产生 180 个 action,DevTools 会记录全部,内存占用会上升。
我一般在开发环境保留 DevTools,但生产环境会关闭 action 记录,或者直接用 devTools: process.env.NODE_ENV !== 'production' 控制。如果你确实需要在生产环境调试状态,可以给 configureStore 传 devTools: { actionsBlacklist: ['screenSize/setScreenSize'] },把高频 action 排除在记录之外,避免 DevTools 被灌爆。
七、方案边界:什么时候不应该用全局状态
7.1 纯样式响应式:用 CSS 和 Tailwind 就够了
如果用 Redux 管屏幕尺寸只是为了切换 CSS 类名,那完全是杀鸡用牛刀。CSS 媒体查询是浏览器原生能力,性能最优,没有 JavaScript 参与,不需要 Store 更新,不涉及水合问题。Tailwind 的 sm:、md:、lg: 前缀在绝大多展示层需求中已经足够。
我在项目里明确的分工是:
- 纯样式变化(字体大小、间距、栅格列数量、背景色)用 CSS 媒体查询或 Tailwind。
- 需要 JS 参与的逻辑变化(组件切换、图表配置重算、接口参数变化)用 Store 里的尺寸状态。
不要把两者混着用,否则会出现"CSS 觉得是移动端,JS 觉得是桌面端"的撕裂感。
7.2 关键业务决策:必须用全局状态
如果一个响应式逻辑影响多个组件、多个模块,甚至影响接口请求参数,那一定要进全局状态。比如:
- 大屏项目里视口宽度决定地图缩放级别,这个缩放级别要传给多个图表和数据请求。
- 表格列显隐策略影响导出 Excel 的列范围,后端导出接口要读
platform参数。 - 导航折叠状态需要同步给面包屑、页面主体 margin、固定 Header 的宽度。
这些场景里,如果不用全局状态,你就只能在组件树的每个分支里各自传 props 或者各自读窗口,代码会非常分散,排查问题会非常痛苦。
7.3 备选方案:什么时候可以不用 Redux
并不是所有 Next.js 项目都值得引入 Redux Toolkit 管尺寸。如果项目比较小,只是一个个人站点,或者只有一两个页面需要响应式 JS 逻辑,我更推荐用自定义 Hook + 模块级单例:
typescript复制// lib/viewport.ts
let currentSize: ScreenSizeState = { ...initialState };
const listeners = new Set<(size: ScreenSizeState) => void>();
export function getViewportSize() {
return currentSize;
}
export function subscribeViewport(listener: (size: ScreenSizeState) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function updateViewportSize() {
currentSize = getScreenSizePayload();
listeners.forEach((listener) => listener(currentSize));
}
这个方案不需要 Provider,不需要 Slice,几行代码就能用,在小型项目里比 Redux 简洁得多。但一旦项目规模上去,需要 DevTools 时间旅行、需要跨组件共享衍生状态、需要模块间解耦,Redux Toolkit 的成熟生态就是更稳的选择。
7.4 我最后的体会:选型没有银弹,但对的方向值得坚持
回到开头那个大屏项目,最终这套 Redux Toolkit 尺寸管理方案稳定跑了好几个迭代。最让我欣慰的不是它性能多好,而是新成员加入项目时,看到 useAppSelector(selectIsMobile) 这种写法,几乎不需要额外培训就能理解上游状态从哪来。状态来源单一、推导路径清晰,这在团队项目里比任何奇技淫巧都值钱。
有一个小技巧我想单独强调:断点值要收敛在一个文件里。不要把 768、1024 这些魔数散落在各个组件中,统一放在 slice 文件顶部或者一个 constants.ts 里,CSS 媒体查询用同样的变量(Tailwind 的 theme.extend 里配置),Store 里的判断也用同一个变量,保证前端所有层面对"什么是移动端"的认知一致。否则你会遇到"CSS 认为 767 是移动端,JS 里判断却是 768 以下是移动端,差一像素导致布局抖动"这种极其隐蔽的 bug。
尺寸管理看起来是个小需求,但它直接决定了整个前端响应式体验的骨架是否稳固。用 Redux Toolkit 管屏幕尺寸,本质上是在给整个应用建立一套所有人共享的"视口共识",这套共识的可靠程度,决定了你在上面搭建的图表、导航、表格等复杂组件能不能安心地响应每一次窗口变化。希望这篇文章能把这条路上的坑都帮你填平。
