在 React + React Router v7 SSR 项目里做多端适配,我踩的两个坑
前言
最近帮人维护一个 SSR 的 React 项目,需要在同一套代码里适配 PC、手机和平板。页面里大量逻辑是基于 deviceType(desktop | tablet | mobile)来做布局和交互差异的。
刚开始看上去只是“判断一下宽度 + 写点媒体查询”的小需求,但在 SSR + iOS 真机 这两个维度叠加之后,踩了不少兼容性的坑,特别是:
-
SSR环境下拿不到window,导致页面在客户端首次渲染时闪烁; -
iOS真机上window.innerWidth获取存在延迟,导致偶发性识别错误。
这篇文章主要记录一下这两个问题的具体表现、解决思路,以及最终抽出来的一个 useDeviceType Hook。
如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。
问题背景
因为是 SSR 项目,服务端初始会把页面 HTML 渲染出来,再由客户端进行 hydration。
同时,我们希望做到:
-
PC端:展示完整布局; - 手机端:展示精简布局,组件层级也有差异;
- 平板端:布局/交互介于两者之间。
项目里有大量类似下面这样的逻辑:
const { deviceType } = useDeviceType();
return (
<>
{deviceType === "desktop" && <DesktopLayout />}
{deviceType === "tablet" && <TabletLayout />}
{deviceType === "mobile" && <MobileLayout />}
</>
);
这意味着 初始渲染阶段的设备类型判断非常关键,一旦前后不一致,就会导致闪烁、Hydration Mismatch 等问题。
SSR 环境中拿不到 window 导致页面闪烁
问题表现
-
deviceType默认假定为"desktop"; - 用户在 手机 上打开页面时:
-
SSR阶段:服务端根据默认值"desktop"渲染; - 客户端
hydration后:useEffect/useLayoutEffect中根据window.innerWidth判断出其实是"mobile",然后状态更新;
-
- 在这个切换的瞬间,布局会从
desktop版“瞬间”跳为mobile版,非常明显的闪烁。
如果你用的是 useLayoutEffect,在 SSR 环境下还会看到:
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output...
问题根源
-
SSR环境没有window,无法用宽度判断设备; - 初始渲染的
HTML是desktop版,客户端hydration时才“意识到”这是手机; - 由于初始
state和实际设备不匹配,导致:-
UI闪烁; - 以及
hydration mismatch警告。
-
解决思路:同构版的 useLayoutEffect
-
在浏览器端:使用
useLayoutEffect,在浏览器绘制前完成 DOM 调整,尽量减少闪烁; - **在
SSR端:不要使用useLayoutEffect,避免警告,退化为普通的useEffect。
实现方式很简单,封装一个Hook:
import { useEffect, useLayoutEffect } from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
然后在需要做首屏布局调整的地方,统一用 useIsomorphicLayoutEffect 替换 useLayoutEffect。
再配合一个关键点:初始值保持一致
为了避免 hydration mismatch,初始 state 必须在 SSR 和客户端首次渲染时保持一致,所以可以这么写:
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
deviceType: "desktop", // 服务端 & 客户端初始都认为是 desktop
isMinWidth: false,
currentWidth: 1024,
});
挂载后,再通过 window.innerWidth + UA 去纠正这个默认值。
iOS 真机上 innerWidth 延迟导致设备误判
这个坑比第一个更隐蔽一些。
问题表现
在某些 iOS 版本的 Safari / PWA 中,出现了这样的情况(我的是 iOS26):
- 页面刚加载时,
window.innerWidth返回的值偏大(类似平板或桌面宽度); - 在初次识别
deviceType时,逻辑会认为是"tablet"或"desktop"; - 用户一滚动/点击,触发重排(
reflow)后,innerWidth才变成实际的手机宽度; - 于是
resize事件触发,又重新判了一次设备类型,这次才变成"mobile"; - 最终结果:页面偶发性地先渲染成平板布局,体验非常差。
解决思路:UA 为主,宽度为辅
单纯依赖 window.innerWidth 在 iOS 上不够稳。
更稳妥的方式是:
-
使用 UA 作为第一判断依据
利用ua-parser-js获取设备类型,如果UA明确告诉你是mobile,那就直接按mobile处理,明确是tablet,就直接当平板。
import { UAParser } from "ua-parser-js";
const parser = new UAParser(navigator.userAgent);
const result = parser.getResult();
const uaDeviceType = result.device.type; // 'mobile' | 'tablet' | 'console' | 'smarttv' | 'wearable' | undefined
-
针对
iPad Pro等pad设备做特殊识别iPad Pro+AirUA会伪装成Mac,因此需要结合OS+ 能否触摸判断:
const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;
在这种情况下,即使 UA 看起来像 Mac 电脑,也要当平板处理。
-
UA 不明确时,再用宽度兜底
比如UA显示为桌面,或者UA不可靠时,可以退回到宽度判断:
if (width < 768) {
return "mobile";
} else if (width >= 768 && width < 1280) {
return "tablet";
}
return "desktop";
-
监听 resize 做矫正
即使初次判断不完美,也可以在后续resize(包括横竖屏切换、窗口变化等)时重新计算设备类型进行纠正。
最终实现:useDeviceType Hook
下面是一个最终整合后的 Hook,实现了:
- SSR 环境兼容(
useIsomorphicLayoutEffect); -
UA+ 宽度组合判断设备类型; - 动态设置
viewport/minWidth/ 安全区域样式; - 监听
resize自动更新。
设备类型判断逻辑
// hooks/useDeviceType.ts
import { useState, useEffect, useLayoutEffect } from "react";
import { UAParser } from "ua-parser-js";
import type {
DeviceInfo,
DeviceType,
UseDeviceTypeOptions,
} from "~/types/GameDataType";
// SSR 下避免 useLayoutEffect 警告
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// UA + 宽度综合判断设备类型
const getDeviceType = (width: number): DeviceType => {
const parser = new UAParser(navigator.userAgent);
const result = parser.getResult();
const uaDeviceType = result.device.type;
// iPad Pro 桌面模式的特殊处理:Mac OS + 支持触摸
const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;
// 1. UA 明确识别为 mobile
if (uaDeviceType === "mobile") {
return "mobile";
}
// 2. UA 明确识别为 tablet(包括 iPad Pro)
if (uaDeviceType === "tablet" || isIPadPro) {
return "tablet";
}
// 3. 其它情况用宽度兜底
if (width < 768) {
return "mobile";
} else if (width >= 768 && width < 1280) {
return "tablet";
}
return "desktop";
};
Hook 主体
export const useDeviceType = (
options: UseDeviceTypeOptions = {}
): DeviceInfo => {
const {
minWidth = 240,
preventZoom = true,
enableSafeAreas = true,
} = options;
// SSR & 客户端初始保持一致,避免 Hydration Mismatch
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
deviceType: "desktop",
isMinWidth: false,
currentWidth: 1024,
});
useIsomorphicLayoutEffect(() => {
const checkDevice = () => {
const width = window.innerWidth;
const isAtMinWidth = width <= minWidth;
const deviceType = getDeviceType(width);
setDeviceInfo({
deviceType,
isMinWidth: isAtMinWidth,
currentWidth: width,
});
// 如果有需要,可以持久化
// localStorage.setItem("deviceType", deviceType);
};
const setViewport = () => {
let viewport = document.querySelector('meta[name="viewport"]');
if (!viewport) {
viewport = document.createElement("meta");
viewport.setAttribute("name", "viewport");
document.head.appendChild(viewport);
}
if (preventZoom) {
viewport.setAttribute(
"content",
"width=device-width, initial-scale=1.0, " +
"minimum-scale=1.0, maximum-scale=1.0, " +
"user-scalable=no, viewport-fit=cover"
);
} else {
viewport.setAttribute(
"content",
"width=device-width, initial-scale=1.0"
);
}
};
const setMinWidthStyle = () => {
const styleId = "min-width-style";
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) {
styleElement = document.createElement("style");
styleElement.id = styleId;
document.head.appendChild(styleElement);
}
styleElement.textContent = `
body {
min-width: ${minWidth}px;
overflow-x: auto;
}
.container-limit {
min-width: ${minWidth}px;
}
${
enableSafeAreas
? `
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-left {
padding-left: env(safe-area-inset-left);
}
.safe-area-right {
padding-right: env(safe-area-inset-right);
}
`
: ""
}
`;
};
const handleResize = () => {
checkDevice();
setViewport();
setMinWidthStyle();
};
// 挂载后立即执行一次,尽量在首屏绘制前完成
handleResize();
// 监听 resize 做后续矫正,可以加防抖节流
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [minWidth, preventZoom, enableSafeAreas]);
return deviceInfo;
};
使用时非常简单:
const { deviceType, currentWidth, isMinWidth } = useDeviceType();
if (deviceType === "desktop") {
// ...
}
一些兼容性和架构层面的思考
即便上面这一套 UA + 宽度 + resize 的方案在大部分情况下能跑得比较稳,但仍有一些潜在问题:
-
UA本身不可靠:被伪装、被浏览器修改; - 新设备 / 新系统版本出现新的 UA 组合,需要不断维护规则;
- 某些嵌入式
WebView或特殊浏览器的行为不确定。
从长期维护成本和复杂度来看,如果业务允许,我更推荐下面这种方式:
-
PC与Mobile/Pad分两套代码 -
Mobile内部用媒体查询区分Phone/Tablet