在 React + React Router v7 SSR 项目里做多端适配,我踩的两个坑

前言

最近帮人维护一个 SSRReact 项目,需要在同一套代码里适配 PC、手机和平板。页面里大量逻辑是基于 deviceTypedesktop | tablet | mobile)来做布局和交互差异的。

刚开始看上去只是"判断一下宽度 + 写点媒体查询"的小需求,但在 SSR + iOS 真机 这两个维度叠加之后,踩了不少兼容性的坑,特别是:

  1. SSR 环境下拿不到 window,导致页面在客户端首次渲染时闪烁;
  2. iOS 真机上 window.innerWidth 获取存在延迟,导致偶发性识别错误。

这篇文章主要记录一下这两个问题的具体表现、解决思路,以及最终抽出来的一个 useDeviceType Hook

如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。

问题背景

因为是 SSR 项目,服务端初始会把页面 HTML 渲染出来,再由客户端进行 hydration

同时,我们希望做到:

  • PC 端:展示完整布局;
  • 手机端:展示精简布局,组件层级也有差异;
  • 平板端:布局/交互介于两者之间。

项目里有大量类似下面这样的逻辑:

tsx 复制代码
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,无法用宽度判断设备;
  • 初始渲染的 HTMLdesktop 版,客户端 hydration 时才"意识到"这是手机;
  • 由于初始 state 和实际设备不匹配,导致:
    • UI 闪烁;
    • 以及 hydration mismatch 警告。

解决思路:同构版的 useLayoutEffect

  • 在浏览器端 :使用 useLayoutEffect,在浏览器绘制前完成 DOM 调整,尽量减少闪烁;
  • **在 SSR 端:不要使用 useLayoutEffect,避免警告,退化为普通的 useEffect

实现方式很简单,封装一个Hook

typescript 复制代码
import { useEffect, useLayoutEffect } from "react";

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后在需要做首屏布局调整的地方,统一用 useIsomorphicLayoutEffect 替换 useLayoutEffect

再配合一个关键点:初始值保持一致

为了避免 hydration mismatch,初始 state 必须在 SSR 和客户端首次渲染时保持一致,所以可以这么写:

typescript 复制代码
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
  deviceType: "desktop",  // 服务端 & 客户端初始都认为是 desktop
  isMinWidth: false,
  currentWidth: 1024,
});

挂载后,再通过 window.innerWidth + UA 去纠正这个默认值。

iOS 真机上 innerWidth 延迟导致设备误判

这个坑比第一个更隐蔽一些。

问题表现

在某些 iOS 版本的 Safari / PWA 中,出现了这样的情况(我的是 iOS26):

  1. 页面刚加载时,window.innerWidth 返回的值偏大(类似平板或桌面宽度);
  2. 在初次识别 deviceType 时,逻辑会认为是 "tablet""desktop"
  3. 用户一滚动/点击,触发重排(reflow)后,innerWidth 才变成实际的手机宽度;
  4. 于是 resize 事件触发,又重新判了一次设备类型,这次才变成 "mobile"
  5. 最终结果:页面偶发性地先渲染成平板布局,体验非常差。

解决思路:UA 为主,宽度为辅

单纯依赖 window.innerWidthiOS 上不够稳。

更稳妥的方式是:

  1. 使用 UA 作为第一判断依据
    利用 ua-parser-js 获取设备类型,如果 UA 明确告诉你是 mobile,那就直接按 mobile 处理,明确是 tablet,就直接当平板。
typescript 复制代码
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
  1. 针对 iPad Propad 设备做特殊识别
    iPad Pro+Air UA 会伪装成 Mac,因此需要结合 OS + 能否触摸判断:
typescript 复制代码
const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;

在这种情况下,即使 UA 看起来像 Mac 电脑,也要当平板处理。

  1. UA 不明确时,再用宽度兜底
    比如 UA 显示为桌面,或者 UA 不可靠时,可以退回到宽度判断:
typescript 复制代码
if (width < 768) {
  return "mobile";
} else if (width >= 768 && width < 1280) {
  return "tablet";
}
return "desktop";
  1. 监听 resize 做矫正
    即使初次判断不完美,也可以在后续 resize(包括横竖屏切换、窗口变化等)时重新计算设备类型进行纠正。

最终实现:useDeviceType Hook

下面是一个最终整合后的 Hook,实现了:

  • SSR 环境兼容(useIsomorphicLayoutEffect);
  • UA + 宽度组合判断设备类型;
  • 动态设置 viewport / minWidth / 安全区域样式;
  • 监听 resize 自动更新。

设备类型判断逻辑

typescript 复制代码
// 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 主体

typescript 复制代码
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;
};

使用时非常简单:

tsx 复制代码
const { deviceType, currentWidth, isMinWidth } = useDeviceType();

if (deviceType === "desktop") {
  // ...
}

一些兼容性和架构层面的思考

即便上面这一套 UA + 宽度 + resize 的方案在大部分情况下能跑得比较稳,但仍有一些潜在问题:

  • UA 本身不可靠:被伪装、被浏览器修改;
  • 新设备 / 新系统版本出现新的 UA 组合,需要不断维护规则;
  • 某些嵌入式 WebView 或特殊浏览器的行为不确定。

从长期维护成本和复杂度来看,如果业务允许,我更推荐下面这种方式:

  • PCMobile/Pad 分两套代码
  • Mobile 内部用媒体查询区分 Phone / Tablet
相关推荐
weixin79893765432...1 小时前
Electron + React + Vite 实践
react.js·electron·vite
q***d1731 小时前
React桌面应用开发
前端·react.js·前端框架
8***29311 小时前
解决 Tomcat 跨域问题 - Tomcat 配置静态文件和 Java Web 服务(Spring MVC Springboot)同时允许跨域
java·前端·spring
0***141 小时前
React计算机视觉应用
前端·react.js·计算机视觉
Q***K551 小时前
React高级
前端·react.js·前端框架
c***97981 小时前
React语音识别案例
前端·react.js·语音识别
q***57742 小时前
WebSpoon9.0(KETTLE的WEB版本)编译 + tomcatdocker部署 + 远程调试教程
前端
Q***l6872 小时前
Vue增强现实案例
前端·vue.js·ar
十里-2 小时前
前端监控1-数据上报
前端·安全