同一链接实现 PC Web 与移动 H5 自适应

摘要 :通过「识别设备类型 → 按端渲染对应内容」的两步策略,用同一个 URL 让 PC 打开展示 Web 应用、手机打开展示 H5 应用。核心手段分三层:UA 字符串判断跳转 (最简单)、框架 Context/Inject 传递设备信息 (组件级条件渲染)、响应式 Hook/媒体查询(纯 CSS 驱动)。一句话概括------先问"你是谁",再给"你该看的"。


一、为什么需要同一链接双端适配

传统做法是为 PC 和 mobile 分别部署两个域名(如 www.example.comm.example.com),带来以下问题:

问题 说明
SEO 分散 两个域名各自权重,搜索引擎难以归并
分享割裂 PC 用户分享的链接在手机上打开可能跳到 PC 版,体验差
维护成本高 两套代码、两套部署流程、两套监控
用户困惑 同一个产品不同 URL,品牌认知不统一

核心思路:一个入口 → 识别设备 → 渲染对应视图。

arduino 复制代码
用户访问 https://example.com
        │
        ▼
   ┌─────────┐
   │ 识别设备 │ ← "你是 PC 还是手机?"
   └────┬────┘
        │
   ┌────┴────┐
   ▼         ▼
 PC 端     Mobile 端
 Web 应用   H5 应用
(桌面布局)(移动布局)

二、方案一:User-Agent 判断 + 页面跳转(最简单)

原理

浏览器请求页面时,请求头中携带 User-Agent 字段,包含浏览器、操作系统、设备类型等信息。通过解析 UA 字符串即可判断当前设备类型。

js 复制代码
// 第一步:看看 UA 长什么样
console.log(navigator.userAgent)
// Chrome PC:   "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..."
// iPhone Safari: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 ..."
// Android Chrome: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 ... Mobile ..."

// 第二步:编写判断函数
function isMobile() {
  return /Mobi|Android/i.test(navigator.userAgent)
}

// 第三步:根据结果跳转
if (isMobile()) {
  window.location.href = "/mobile.html"  // 手机 → H5 页面
} else {
  // PC → 继续加载当前 Web 页面(或跳转到 /pc.html)
}

完整增强版(覆盖更多场景)

js 复制代码
/**
 * 设备检测工具
 * 支持检测:手机 / 平板 / PC / 微信内置浏览器
 */
const DeviceDetector = {
  ua: navigator.userAgent,

  isMobile() {
    return /Mobi|Android|iPhone|iPod|BlackBerry|IEMobile/i.test(this.ua)
  },

  isTablet() {
    // 平板:不是手机但包含 Pad / Tablet 关键字,或 iPad
    return /iPad|Tablet|PlayBook|Silk/i.test(this.ua) &&
           !/Mobi|Android|iPhone/i.test(this.ua)
  },

  isPC() {
    return !this.isMobile() && !this.isTablet()
  },

  isWeChat() {
    return /MicroMessenger/i.test(this.ua)
  },

  isIOS() {
    return /iPhone|iPad|iPod/i.test(this.ua)
  },

  isAndroid() {
    return /Android/i.test(this.ua)
  },

  /** 获取设备类型 */
  getType() {
    if (this.isWeChat()) return 'wechat'
    if (this.isMobile()) return 'mobile'
    if (this.isTablet()) return 'tablet'
    return 'pc'
  }
}

// 使用示例
const deviceType = DeviceDetector.getType()
console.log(`当前设备: ${deviceType}`)

if (deviceType === 'mobile') {
  window.location.href = '/h5/index.html'
}

UA 检测的优缺点

优点 缺点
实现简单,几行代码搞定 UA 可被伪造(开发者工具可模拟)
兼容性极好,所有浏览器支持 新设备/新浏览器可能漏判
服务端和客户端都能做 无法检测窗口 resize(PC 缩小窗口不会变 mobile)
可配合服务端 SSR 直出正确页面 正则维护成本高,需要持续更新

三、方案二:框架内 Context / Inject 传递设备信息

当使用 React 或 Vue 等框架时,不需要页面跳转,而是在应用顶层识别一次设备类型 ,然后通过框架的状态管理机制传递给所有子组件,子组件根据设备类型条件渲染不同的 UI

React 实现:Context + Provider

jsx 复制代码
import React, { createContext, useContext } from 'react'

// 1. 创建设备上下文
const DeviceContext = createContext({
  isMobile: false,
  deviceType: 'pc'
})

// 2. 在应用顶层提供设备信息
function DeviceProvider({ children }) {
  const [deviceType, setDeviceType] = React.useState(() => {
    return /Mobi|Android/i.test(navigator.userAgent) ? 'mobile' : 'pc'
  })

  return (
    <DeviceContext.Provider value={{
      isMobile: deviceType === 'mobile',
      deviceType
    }}>
      {children}
    </DeviceContext.Provider>
  )
}

// 3. 子组件中使用
function Header() {
  const { isMobile, deviceType } = useContext(DeviceContext)

  return (
    <header className={deviceType}>
      {isMobile ? (
        <nav>📱 移动端导航(汉堡菜单)</nav>
      ) : (
        <nav>🖥️ PC 端导航(完整菜单栏)</nav>
      )}
    </header>
  )
}

// 4. 入口文件挂载
function App() {
  return (
    <DeviceProvider>
      <Header />
      <MainContent />
    </DeviceProvider>
  )
}

Vue 3 实现:provide / inject

vue 复制代码
<!-- App.vue --- 顶层 provide -->
<script setup>
import { provide, ref } from 'vue'

function isMobile() {
  return /Mobi|Android/i.test(navigator.userAgent)
}

// 非常重要的 API:provide 向所有子组件注入设备类型
provide('deviceType', isMobile() ? 'mobile' : 'pc')
provide('isMobile', isMobile())
</script>

<template>
  <Header />
  <MainContent />
</template>

<!-- Header.vue --- 子组件 inject -->
<script setup>
import { inject } from 'vue'

const deviceType = inject('deviceType')   // 'mobile' | 'pc'
const isMobile = inject('isMobile')       // true | false
</script>

<template>
  <header :class="deviceType">
    <!-- 移动端显示汉堡菜单 -->
    <button v-if="isMobile" class="hamburger">☰</button>

    <!-- PC 端显示完整导航 -->
    <nav v-else class="full-nav">
      <a href="/">首页</a>
      <a href="/products">产品</a>
      <a href="/about">关于</a>
    </nav>
  </header>
</template>

方案一 vs 方案二对比

维度 方案一(UA 跳转) 方案二(Context/Inject)
实现方式 检测后 window.location.href 跳转 检测后存入状态,组件内条件渲染
用户体验 有页面刷新/跳转感 无跳转,同页切换(SPA 体验)
代码复用 PC 和 H5 可能是两套独立代码 一套代码,按条件渲染不同 UI
适用场景 两套完全独立的页面 / SSR 项目 SPA 单页应用 / 组件级差异化
SEO 友好 跳转后 URL 不同,需额外处理 同一 URL,SEO 更友好
复杂度 ⭐ 低 ⭐⭐ 中

四、方案三:响应式 Hook + CSS 媒体查询(推荐)

前两种方案基于 User-Agent (设备类型),第三种方案基于 屏幕宽度(视口大小),更符合响应式设计理念。即使设备被识别为 PC,如果窗口缩得很小,也应该展示移动端布局。

React:react-responsive 库

jsx 复制代码
import React from 'react'
import { useMediaQuery } from 'react-responsive'

function App() {
  // 根据屏幕宽度判断,而非 UA
  const isDesktop = useMediaQuery({ minWidth: 1024 })
  const isMobile = useMediaQuery({ maxWidth: 1023 })
  const isTablet = useMediaQuery({ minWidth: 768, maxWidth: 1023 })

  return (
    <div>
      {/* PC 端:宽屏布局 */}
      {isDesktop && (
        <div className="desktop-layout">
          <h1>Desktop Web Application</h1>
          <p>This content is displayed on desktop devices.</p>
          <aside className="sidebar">侧边栏导航</aside>
          <main className="content">主内容区(三列网格)</main>
        </div>
      )}

      {/* 移动端:窄屏布局 */}
      {isMobile && (
        <div className="mobile-layout">
          <h1>Mobile H5 Application</h1>
          <p>This content is displayed on mobile devices.</p>
          <nav className="bottom-tab">底部 Tab 导航</nav>
          <main className="content">单列内容区</main>
        </div>
      )}

      {/* 平板端:中等布局 */}
      {isTablet && (
        <div className="tablet-layout">
          <h1>Tablet Layout</h1>
          <p>Two-column layout for tablets.</p>
        </div>
      )}
    </div>
  )
}

Vue 3:组合式函数封装

vue 复制代码
<!-- composables/useResponsive.js -->
import { ref, onMounted, onUnmounted } from 'vue'

export function useResponsive(breakpoints = { mobile: 768, tablet: 1024 }) {
  const width = ref(window.innerWidth)

  const update = () => { width.value = window.innerWidth }

  onMounted(() => window.addEventListener('resize', update))
  onUnmounted(() => window.removeEventListener('resize', update))

  const isMobile = () => width.value <= breakpoints.mobile
  const isTablet = () => width.value > breakpoints.mobile && width.value <= breakpoints.tablet
  const isDesktop = () => width.value > breakpoints.tablet

  return { width, isMobile, isTablet, isDesktop }
}
vue 复制代码
<!-- App.vue -->
<script setup>
import { useResponsive } from './composables/useResponsive'

const { isMobile, isDesktop } = useResponsive()
</script>

<template>
  <div class="app">
    <DesktopLayout v-if="isDesktop()" />
    <MobileLayout v-else />
  </div>
</template>

纯 CSS 媒体查询(零 JS)

css 复制代码
/* 默认样式:移动端优先(Mobile First) */
.container {
  display: flex;
  flex-direction: column;   /* 移动端默认单列 */
  padding: 16px;
}

/* 平板及以上 */
@media (min-width: 768px) {
  .container {
    flex-direction: row;
    gap: 24px;
  }

  .sidebar {
    width: 200px;
  }
}

/* PC 端 */
@media (min-width: 1024px) {
  .container {
    max-width: 1200px;
    margin: 0 auto;
  }

  .sidebar {
    width: 250px;
  }

  .content {
    display: grid;
    grid-template-columns: repeat(3, 1fr); /* 三列网格 */
  }
}

三种方案大对比

维度 UA 跳转 Context/Inject 条件渲染 响应式 Hook / 媒体查询
判断依据 User-Agent 字符串 User-Agent 字符串 屏幕宽度 (window.innerWidth)
是否跳转 ✅ 会跳转 ❌ 不跳转 ❌ 不跳转
响应 resize ❌ 不响应 ❌ 不响应 ✅ 实时响应
SPA 友好 一般(破坏单页体验) ✅ 最佳 ✅ 最佳
SSR 友好 ✅ 可在服务端判断 ✅ 可在服务端初始化 ⚠️ 需要客户端 hydrate
实现复杂度 ⭐ 低 ⭐⭐ 中 ⭐⭐~⭐⭐⭐
推荐场景 两套独立页面 / 传统多页应用 SPA 组件级差异 响应式布局 / 渐进增强

五、服务端方案:Nginx / 后端路由分发

除了前端检测,还可以在请求到达前端之前由服务端完成设备识别和分发:

Nginx 基于 UA 的反向代理

nginx 复制代码
# 根据 User-Agent 将移动端请求代理到 H5 服务
server {
    listen 80;
    server_name example.com;

    # 移动端 UA → 转发到 H5 应用
    if ($http_user_agent ~* "(iPhone|iPod|Android|Mobile|BlackBerry)") {
        rewrite ^/(.*)$ http://h5.example.com/$1 redirect;
        # 或者同一服务器不同目录:
        # rewrite ^/(.*)$ /h5/$1 last;
    }

    # PC 端 → 正常转发到 Web 应用
    location / {
        proxy_pass http://web_app:3000;
    }

    location /h5/ {
        proxy_pass http://h5_app:3001;
    }
}

Node.js / Express 中间件

js 复制代码
// Express 中间件:服务端设备检测
function deviceDetect(req, res, next) {
  const ua = req.headers['user-agent'] || ''
  const isMobile = /Mobi|Android|iPhone/i.test(ua)

  // 方式 A:设置响应头,让前端读取
  res.setHeader('X-Device-Type', isMobile ? 'mobile' : 'desktop')

  // 方式 B:直接渲染不同模板
  req.deviceType = isMobile ? 'mobile' : 'desktop'
  next()
}

app.use(deviceDetect)

app.get('/', (req, res) => {
  if (req.deviceType === 'mobile') {
    res.render('mobile/index')   // 渲染 H5 模板
  } else {
    res.render('desktop/index')  // 渲染 PC 模板
  }
})

前后端方案对比

维度 前端检测(JS) 服务端检测(Nginx/Node)
执行时机 页面加载后 请求到达时
首屏速度 先加载再跳转/渲染(有延迟) 直接返回正确内容(更快)
SEO ⚠️ 爬虫可能不执行 JS ✅ 返回正确 HTML
可缓存性 困难(每个客户端结果不同) 可配合 Vary: User-Agent
灵活性 高(可结合交互状态) 低(仅能看 UA)

六、完整实战串联:电商网站同一链接双端适配

下面用一个最小可运行的例子,把上述方案串联起来:

arduino 复制代码
用户访问 https://shop.example.com
        │
        ▼
  ┌──────────────────┐
  │  ① Nginx UA 检测  │  ← 第一道防线:服务端快速分流
  └────┬─────────┬───┘
       │         │
   Mobile UA    Desktop UA
       │         │
       ▼         ▼
  ┌────────┐ ┌────────┐
  │H5 SPA  │ │Web SPA │  ← 第二道防线:框架内条件渲染
  └────┬───┘ └────┬───┘
       │          │
       ▼          ▼
  ┌────────┐ ┌────────┐
  │React   │ │React   │  ← 第三道防线:useMediaQuery 微调
  │Mobile  │ │Desktop │
  │Layout  │ │Layout  │
  └────────┘ └────────┘

代码实现(React 版)

jsx 复制代码
// index.js --- 入口
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { DeviceProvider } from './context/DeviceContext'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <DeviceProvider>
      <App />
    </DeviceProvider>
  </React.StrictMode>
)

// context/DeviceContext.js --- 设备上下文
import { createContext, useContext, useState } from 'react'

const DeviceContext = createContext()

export function DeviceProvider({ children }) {
  const [device, setDevice] = useState(() => ({
    type: /Mobi|Android/i.test(navigator.userAgent) ? 'mobile' : 'desktop',
    width: window.innerWidth
  })

  // 监听窗口变化,允许 resize 时动态调整
  useState(() => {
    const handleResize = () => {
      setDevice(prev => ({
        ...prev,
        width: window.innerWidth
      }))
    }
    window.addEventListener('resize', handleResize)
    return () => window.removeEventListener('resize', handleResize)
  })

  return (
    <DeviceContext.Provider value={device}>
      {children}
    </DeviceContext.Provider>
  )
}

export const useDevice = () => useContext(DeviceContext)

// App.js --- 主应用
import { useDevice } from './context/DeviceContext'
import DesktopLayout from './layouts/DesktopLayout'
import MobileLayout from './layouts/MobileLayout'

export default function App() {
  const { type, width } = useDevice()

  // 组合判断:UA 类型 + 屏幕宽度兜底
  const isMobileView = type === 'mobile' || width < 1024

  return isMobileView ? <MobileLayout /> : <DesktopLayout>()
}

// layouts/MobileLayout.jsx --- H5 布局
export default function MobileLayout() {
  return (
    <div className="mobile-app">
      <header className="mobile-header">
        <span className="logo">🛒 商城</span>
        <button className="search">🔍</button>
      </header>

      <main className="mobile-main">
        {/* 单列商品卡片流 */}
        {[1, 2, 3].map(i => (
          <div key={i} className="product-card">
            <img src={`/img/p${i}.jpg`} alt="商品" />
            <div className="info">
              <h3>商品名称 {i}</h3>
              <p className="price">¥99.00</p>
            </div>
          </div>
        ))}
      </main>

      <nav className="mobile-tabs">
        <span className="active">首页</span>
        <span>分类</span>
        <span>购物车</span>
        <span>我的</span>
      </nav>
    </div>
  )
}

// layouts/DesktopLayout.jsx --- PC 布局
export default function DesktopLayout() {
  return (
    <div className="desktop-app">
      <header className="desktop-header">
        <span className="logo">🛒 商城</span>
        <nav>
          <a href="/">首页</a><a href="/category">分类</a><a href="/cart">购物车</a>
        </nav>
        <input placeholder="搜索商品..." />
      </header>

      <div className="desktop-body">
        <aside className="sidebar">
          <h3>分类</h3>
          <ul><li>电子产品</li><li>服装</li><li>食品</li></ul>
        </aside>

        <main className="desktop-main">
          {/* 三列商品网格 */}
          <div className="product-grid">
            {[1, 2, 3, 4, 5, 6].map(i => (
              <div key={i} className="product-card">
                <img src={`/img/p${i}.jpg`} alt="商品" />
                <h3>商品名称 {i}</h3>
                <p className="price">¥99.00</p>
              </div>
            ))}
          </div>
        </main>
      </div>
    </div>
  )
}

七、面试高频 Q&A

Q1:User-Agent 检测和媒体查询检测有什么区别?应该选哪个?

UA 检测判断的是设备类型 (这是手机还是电脑?),媒体查询判断的是屏幕尺寸(这个窗口有多宽?)。

  • 选 UA:需要区分触摸操作 vs 鼠标操作、需要调用原生 API(相机/定位)时
  • 选媒体查询:纯粹布局差异、希望窗口缩小时自动切换时
  • 最佳实践:两者组合------UA 决定初始加载哪套代码,媒体 query 做 layout 微调兜底

Q2:为什么推荐用 Context/Inject 而不是每个组件都调 isMobile()?

  • 性能:UA 字符串只解析一次,结果全局共享
  • 可维护:设备判断逻辑集中在一处,改规则只需改一处
  • 可测试:可以轻松 mock 设备类型来测试不同端的 UI
  • 一致性:避免不同组件因正则不一致导致判断矛盾

Q3:服务端检测和前端检测怎么选?

  • 对 SEO 要求高的页面(官网、电商详情页)→ 服务端检测,确保爬虫拿到正确 HTML
  • 管理后台、需要登录的系统 → 前端检测即可,简单灵活
  • 生产环境建议 两层都做:Nginx 做粗粒度分流 + 前端做细粒度渲染

Q4:useMediaQuery 和 CSS @media 怎么选?

  • 纯布局差异(间距、列数、字体大小)→ CSS @media,零 JS 开销
  • 需要渲染完全不同的组件树 (PC 显示侧边栏、手机显示底部 Tab)→ useMediaQuery
  • 两者不冲突,可以同时使用

Q5:如何处理"PC 浏览器缩小窗口到手机宽度"的情况?

这是纯 UA 检测的最大盲区。解决方案:

  1. 组合策略:UA 定初始值 + resize 监听动态调整(如第六章实战代码)
  2. 纯 media query 方案:彻底不用 UA,只看宽度
  3. 提示用户:检测到异常时提示"请横屏获得更好体验"

八、记忆口诀

css 复制代码
同一链接双端显,先问设备再渲染。
UA 跳转最简单,Context 注入更优雅,
媒体查询看宽度,三层组合最强健。
服务端做第一道,前端渲染第二道,
Resize 兜底第三道,一道更比一道好。

一句话总结:同一链接适配 PC/H5 的本质是「设备识别 + 条件渲染」,从简单到推荐的路径是:UA 跳转 → 框架 Context 传递 → 响应式 Hook,生产环境建议 Nginx(服务端分流)+ Context(组件渲染)+ Media Query(layout 微调)三层组合使用。

相关推荐
小帅不太帅1 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni2 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学3 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师3 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc883 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn4 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端
Csvn4 小时前
🎨 CSS @layer:用「层叠优先级」终结样式打架的世纪难题
前端
濮水大叔5 小时前
不必把 Vue3 写成“麻花”:从状态碎片到对象协作,重新理解 Zova 的前端心智模型
前端·typescript·vue3·ioc·tsx·zova
Vuji5 小时前
MCP: 一条 tool 调用链路的旅程
前端·人工智能