Material UI useMediaQuery 深入解析:用 React Hook 实现响应式媒体查询
【免费下载链接】material-uiMaterial UI: Comprehensive React component library that implements Google's Material Design. Free forever.项目地址: https://gitcode.com/GitHub_Trending/ma/material-ui
本篇指南围绕 MUI Material 的useMediaQueryHook 展开:它如何监听 CSS 媒体查询并按匹配结果渲染组件、如何结合主题断点(breakpoints)使用、如何在测试与 SSR 环境下正确 mockmatchMedia,以及noSsr、ssrMatchMedia等选项的底层行为。读完本文,你可以直接在生产项目中完成从基础媒体查询、断点辅助函数到服务端渲染的完整响应式方案设计,并理解该 Hook 在源码中"监听而非轮询"的实现机制。
一、功能定位与特性总览
useMediaQuery是 Material UI(@mui/material)提供的响应式设计核心 Hook。它监听 CSS 媒体查询的匹配状态,根据查询是否匹配来决定组件的渲染分支。官方文档(use-media-query.md)列出了它的几个关键特性:
- 符合 React 习惯的 API:以 Hook 形式返回一个布尔值,天然适配组件的声明式渲染;
- 高性能:它通过观察(observe)文档来检测媒体查询的变化,而不是周期性轮询值;
- 体积小:文档标注约 1.1 kB(gzipped);
- 支持服务端渲染(SSR):可通过
ssrMatchMedia选项在服务器上注入自定义的matchMedia实现。
从源码结构看,Material 侧的实现非常薄,实际逻辑下沉在 System 层。packages/mui-material/src/useMediaQuery/index.js 仅有一行核心代码:
import { unstable_createUseMediaQuery } from '@mui/system/useMediaQuery'; import THEME_ID from '../styles/identifier'; const useMediaQuery = unstable_createUseMediaQuery({ themeId: THEME_ID }); export default useMediaQuery;它调用 System 层的工厂函数并传入 Material 的THEME_ID,用于在主题作用域(theme scoping)场景下正确读取 Material 主题。真正的实现在 packages/mui-system/src/useMediaQuery/useMediaQuery.ts,TypeScript 声明(index.d.ts)则把类型收敛为接受 Material 的Theme对象。
二、基础媒体查询用法
第一个参数接收任意合法的 CSS 媒体查询字符串,返回值是一个布尔值:
import useMediaQuery from '@mui/material/useMediaQuery'; export default function SimpleMediaQuery() { const matches = useMediaQuery('(min-width:600px)'); return <span>{`(min-width:600px) matches: ${matches}`}</span>; }对应仓库中的演示文件为 SimpleMediaQuery.js。媒体查询字符串也可以是系统偏好类查询,例如'(prefers-color-scheme: dark)',可参考 暗色模式文档 中"系统偏好"一节。
关于print查询的限制:文档明确警告,使用'print'查询来修改打印样式是不支持的,因为重渲染做出的改动可能无法被准确反映到打印输出中。源码中对此有直接印证,useMediaQuery.ts 会在检测到查询中包含print时输出警告,并建议改用sx属性的displayPrint字段,详见 System Display 文档。
另外有一个容易忽略的细节:实现层会对查询字符串做归一化,执行query.replace(/^@media( ?)/m, '')(见 useMediaQuery.ts),也就是说传入@media (min-width: 600px)与传入(min-width: 600px)效果一致,前导的@media会被自动剥除。
三、结合断点辅助函数(breakpoints)使用
Material 的断点辅助函数可以生成与主题一致的媒体查询字符串。有两种等价写法:
写法一:先取主题,再拼接查询
import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; function MyComponent() { const theme = useTheme(); const matches = useMediaQuery(theme.breakpoints.up('sm')); return <span>{`theme.breakpoints.up('sm') matches: ${matches}`}</span>; }写法二:直接传入回调函数(第一参数为 theme)
import useMediaQuery from '@mui/material/useMediaQuery'; function MyComponent() { const matches = useMediaQuery((theme) => theme.breakpoints.up('sm')); return <span>{`theme.breakpoints.up('sm') matches: ${matches}`}</span>; }对应演示文件为 ThemeHelper.js,其中外层用ThemeProvider注入了createTheme()创建的主题。
重要前提:没有默认主题。当以函数形式传参时,上下文中必须存在父级ThemeProvider,否则开发环境会报错。这一点在源码中得到确认:useMediaQuery.ts 在非生产环境下检查到queryInput是函数而theme为null时,会输出如下错误:
MUI: The `query` argument provided is invalid. You are providing a function without a theme in the context. One of the parent elements needs to use a ThemeProvider.测试文件 useMediaQuery.test.js 中也有对应的"invalid query argument"警告用例。断点取值范围与 CSS 输出细节可继续参考 breakpoints 文档。
四、使用 JavaScript 语法生成查询
如果偏好以对象而非字符串描述查询条件,可以借助json2mq库把 JS 对象转换为媒体查询字符串:
import json2mq from 'json2mq'; import useMediaQuery from '@mui/material/useMediaQuery'; export default function JavaScriptMedia() { const matches = useMediaQuery( json2mq({ minWidth: 600, }), ); return <span>{`{ minWidth: 600 } matches: ${matches}`}</span>; }对应演示文件为 JavaScriptMedia.js。本质上json2mq只是字符串生成工具,最终交给useMediaQuery的仍然是一个合法媒体查询字符串。
五、单元测试:mockmatchMedia
useMediaQuery依赖浏览器 API matchMedia,而 jsdom 尚未实现它,因此测试环境需要自行 polyfill。官方推荐使用css-mediaquery库进行模拟:
import mediaQuery from 'css-mediaquery'; function createMatchMedia(width) { return (query) => ({ matches: mediaQuery.match(query, { width, }), addEventListener: () => {}, removeEventListener: () => {}, }); } describe('MyTests', () => { beforeAll(() => { window.matchMedia = createMatchMedia(window.innerWidth); }); });仓库自身的测试 useMediaQuery.test.js 是这套方案的"生产级"版本,值得注意的几个细节:
- 实例缓存:测试中的
createMatchMedia用Map按 query 缓存MediaQueryList实例,保证同一查询多次调用返回同一对象(浏览器真实行为即如此),并维护监听器列表以便模拟查询变化(见 useMediaQuery.test.js); - 绑定 window:真实
window.matchMedia对this有要求,Hook 内部会执行fallbackMatchMedia.bind(window)(useMediaQuery.ts),测试用例should bind the default matchMedia to window专门验证了这一点; - 降级行为:测试专门覆盖"删除
window.matchMedia"的场景,验证 Hook 在无matchMedia环境下不会崩溃、返回false(useMediaQuery.test.js),这与源码中supportMatchMedia的防御性检查一致; - 监听而非轮询:
should observe the media query用例手动翻转模拟实例的matches并触发监听器,断言组件只重渲染了一次(useMediaQuery.test.js),印证了文档"observe instead of polling"的性能声明。
六、客户端专属渲染:noSsr选项
为了完成服务端水合(hydration),Hook 需要渲染两次:第一次用defaultMatches(即服务端使用的值),第二次用解析后的真实值。这个"双通道"渲染周期的代价是更慢。如果你的返回值只在客户端使用,可以把noSsr设为true:
const matches = useMediaQuery('(min-width:600px)', { noSsr: true });也可以通过主题全局开启:
const theme = createTheme({ components: { MuiUseMediaQuery: { defaultProps: { noSsr: true, }, }, }, });两点补充:
- 文档提示
noSsr在使用 React 18 的createRoot()API(纯客户端渲染)时没有实际效果——因为根本没有服务端渲染需要跳过; - 从源码看,"双通道"的具体实现是
React.useSyncExternalStore的三快照机制。useMediaQuery.ts 中useMediaQueryNew分别构建了getSnapshot(客户端快照,读取mediaQueryList.matches)、getServerSnapshot(服务端快照,优先noSsr && matchMedia,其次ssrMatchMedia,最后退回defaultMatches),由 React 在水合阶段自动从服务端快照切换到客户端快照,从而产生"先渲染服务端值、再渲染真实值"的行为。测试用例也量化了这一点:不开noSsr时 hydrate 渲染两次,开启后只渲染一次(useMediaQuery.test.js)。
值得注意的是,当window.matchMedia不存在时(如 jsdom),React 18+ 会走useSyncExternalStore分支并直接返回服务端快照值;React 17 则回退到useState + useEnhancedEffect的旧实现(useMediaQueryOld,见 useMediaQuery.ts)。
七、服务端渲染(SSR)
文档对 SSR 场景持"务实的悲观"态度:服务端渲染与客户端媒体查询从根本上是矛盾的,支持只能是部分性的。官方建议优先依赖客户端 CSS 媒体查询方案,例如:
<Box display>- 主题断点的
theme.breakpoints.up(x)输出的 CSS 媒体查询 sxprop
只有在上述方案都不可行时,才需要走useMediaQuery的 SSR 路径,共三步:
- 猜测客户端特征:在服务器端根据 User Agent(推荐
ua-parser-js解析)或 Client Hints(注意其浏览器支持不完整)估算请求方的设备信息; - 提供
matchMedia实现:把基于估算结果的matchMedia模拟实现注入 Hook; - 两端保持一致:客户端必须提供相同的自定义实现,才能保证水合匹配(hydration match)。
文档给出的服务端完整示例(使用css-mediaquery模拟):
import * as ReactDOMServer from 'react-dom/server'; import parser from 'ua-parser-js'; import mediaQuery from 'css-mediaquery'; import { createTheme, ThemeProvider } from '@mui/material/styles'; function handleRender(req, res) { const deviceType = parser(req.headers['user-agent']).device.type || 'desktop'; const ssrMatchMedia = (query) => ({ matches: mediaQuery.match(query, { // The estimated CSS width of the browser. width: deviceType === 'mobile' ? '0px' : '1024px', }), }); const theme = createTheme({ components: { // Change the default options of useMediaQuery MuiUseMediaQuery: { defaultProps: { ssrMatchMedia, }, }, }, }); const html = ReactDOMServer.renderToString( <ThemeProvider theme={theme}> <App /> </ThemeProvider>, ); // … }对应演示文件为 ServerSide.js。该方案在仓库测试中有两条对应验证:should use the SSR match media implementation用宽度 3000 的ssrMatchMedia渲染(min-width:2000px)得到true,should work with theme scoping验证了在主题作用域(通过[THEME_ID]键注入 Material 主题)下同样生效(useMediaQuery.test.js)。
八、从withWidth()迁移
MUI 早期的withWidth()高阶组件会向组件注入页面宽度值('xs' | 'sm' | ...)。用useMediaQuery可以等价复刻为useWidthHook:
import { ThemeProvider, useTheme, createTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; /** * Be careful using this hook. It only works because the number of * breakpoints in theme is static. It will break once you change the number of * breakpoints. */ function useWidth() { const theme = useTheme(); const keys = [...theme.breakpoints.keys].reverse(); return ( keys.reduce((output, key) => { const matches = useMediaQuery(theme.breakpoints.up(key)); return !output && matches ? key : output; }, null) || 'xs' ); } function MyComponent() { const width = useWidth(); return <span>{`width: ${width}`}</span>; }对应演示文件为 UseWidth.js。官方在源码注释中明确提示:这个 Hook 之所以合法,是因为主题中断点数量是静态的(循环内调用 Hook 的数量恒定);一旦改变断点数量就会违反 Hook 规则,这一点迁移时务必注意。
九、API 参考
useMediaQuery(query, [options]) => matches
参数
query(string|func):表示要处理的媒体查询的字符串;或一个接受上下文 theme 并返回字符串的回调函数。options(object,可选):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
defaultMatches | bool | false | 由于window.matchMedia()在服务器端不可用,Hook 在首次挂载时返回该默认匹配值 |
matchMedia | func | — | 提供自定义的matchMedia实现,可用于处理 iframe 的 content window 等场景 |
noSsr | bool | false | 跳过双通道 SSR 渲染流程(仅当返回值只在客户端使用时) |
ssrMatchMedia | func | — | 提供自定义的matchMedia实现,专门用于服务端渲染阶段 |
上述四个选项的完整 JSDoc 类型定义见 UseMediaQueryOptions。
修改默认选项:可以通过主题的 default props 功能,使用MuiUseMediaQuery键全局修改默认值。从源码看,这一合并由getThemeProps({ name: 'MuiUseMediaQuery', props: options, theme })完成(useMediaQuery.ts)——主题components.MuiUseMediaQuery.defaultProps中的字段与调用时传入的options合并,调用参数优先。
返回值
matches:当前文档匹配该媒体查询时为true,不匹配时为false。
最小示例
import * as React from 'react'; import useMediaQuery from '@mui/material/useMediaQuery'; export default function SimpleMediaQuery() { const matches = useMediaQuery('(min-width:600px)'); return <span>{`(min-width:600px) matches: ${matches}`}</span>; }十、小结:行为速查
结合文档与源码,useMediaQuery的运行时行为可以归纳为:
| 场景 | 行为 |
|---|---|
| 浏览器中正常渲染 | 通过useSyncExternalStore订阅MediaQueryList的change事件,查询变化时触发重渲染 |
查询字符串带@media前缀 | 自动剥除前缀后处理 |
查询包含print | 开发时打印警告,建议改用sx的displayPrint |
| 函数形式 query 但上下文无主题 | 开发时报错,提示需要ThemeProvider |
| 服务器端渲染 | 依次取noSsr && matchMedia→ssrMatchMedia→defaultMatches作为快照值 |
window.matchMedia不存在(如 jsdom) | 不崩溃,返回false(或ssrMatchMedia/defaultMatches的值) |
自定义matchMedia(如 iframe 场景) | 优先于window.matchMedia使用 |
主题MuiUseMediaQuery.defaultProps | 与调用参数合并,作为所有实例的默认选项 |
掌握以上内容后,你既可以在客户端用theme.breakpoints构建响应式布局分支,也可以在 SSR 应用中通过ssrMatchMedia+ 水合一致的 mock 实现安全的跨端渲染,并在测试中用css-mediaquery稳定地模拟任意视口宽度。
【免费下载链接】material-uiMaterial UI: Comprehensive React component library that implements Google's Material Design. Free forever.项目地址: https://gitcode.com/GitHub_Trending/ma/material-ui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考