摘要 :通过「识别设备类型 → 按端渲染对应内容」的两步策略,用同一个 URL 让 PC 打开展示 Web 应用、手机打开展示 H5 应用。核心手段分三层:UA 字符串判断跳转 (最简单)、框架 Context/Inject 传递设备信息 (组件级条件渲染)、响应式 Hook/媒体查询(纯 CSS 驱动)。一句话概括------先问"你是谁",再给"你该看的"。
一、为什么需要同一链接双端适配
传统做法是为 PC 和 mobile 分别部署两个域名(如 www.example.com 和 m.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 检测的最大盲区。解决方案:
- 组合策略:UA 定初始值 + resize 监听动态调整(如第六章实战代码)
- 纯 media query 方案:彻底不用 UA,只看宽度
- 提示用户:检测到异常时提示"请横屏获得更好体验"
八、记忆口诀
css
同一链接双端显,先问设备再渲染。
UA 跳转最简单,Context 注入更优雅,
媒体查询看宽度,三层组合最强健。
服务端做第一道,前端渲染第二道,
Resize 兜底第三道,一道更比一道好。
一句话总结:同一链接适配 PC/H5 的本质是「设备识别 + 条件渲染」,从简单到推荐的路径是:UA 跳转 → 框架 Context 传递 → 响应式 Hook,生产环境建议 Nginx(服务端分流)+ Context(组件渲染)+ Media Query(layout 微调)三层组合使用。