☰
ASP检测手机客户端并跳转:UA判定、302与缓存避坑
2026/11/3 11:27:45 网站建设 项目流程

去年帮朋友收拾一个老站点的烂摊子,页面是十几年前用 ASP 写的,老板突然要求"手机打开要出手机版"。需求一句话,实现三行代码,上线第二天投诉就来了——几个用平板的老用户打开永远是手机版,还有一批 PC 用户被莫名其妙推到了 /m/ 目录下,客服电话直接被打爆。后来把日志翻出来一看,问题根本不在"检测手机客户端"这个动作本身,而在判定顺序、跳转状态码和缓存策略这三件配套的事上,一件没做对,整条链路就歪了。

这篇就围绕"ASP 检测手机客户端并跳转到相应页面"这个具体场景,把从 UA 读取、设备判定、跳转实现到缓存治理的完整链路拆开讲。不管你是接手了经典 ASP 老项目的维护者,还是在 ASP.NET 里想加一层轻量设备分流的开发者,又或者只是被"跳转死循环"折腾过想搞清楚原理的人,下面这些内容都能直接抄。文章里会给出可直接跑的函数和规则,也会重点讲那些文档里不会写、只有踩过才知道的地方。

1. 先想清楚要"跳"还是"不跳"

在动手写Response.Redirect之前,有一个更前置的问题必须先回答:这个项目到底需不需要服务端跳转。很多人一上来就写代码,结果做到一半发现其实一套响应式布局就能解决,前面的活儿全白干。我见过太多这种返工,所以这一节先把路线选清楚。

1.1 三条技术路线的成本对照

设备分流这件事,行业里主流就三条路,成本、体验、维护难度差别很大:

路线实现方式首屏体验维护成本适合场景
独立移动站 + 服务端跳转两套模板,UA 判定后 302 跳转好,移动站按手机屏幕专门优化高,改一处要改两遍老站改不动、移动站已存在、需要独立统计
响应式一套代码CSS 媒体查询 + 弹性布局中等,大图和大表格在手机上仍吃力低,一套代码一套逻辑中小型站点、内容型站点
同 URL 动态渲染服务端按 UA 输出不同模板最好,无跳转无白屏中高,需要缓存分层配合有 SSR 能力的中大型站点

表格里最右边一列是关键。判断标准其实很简单:如果你手里已经有一个跑着的移动站,或者移动端和 PC 端的交互差异大到没法用同一套 DOM 应付(比如移动站要接底部导航栏、要用下拉刷新、要调摄像头扫码),那服务端跳转就是合理选择。反过来,如果只是字号小了点、图片宽了点,加一段 CSS 就完事,硬上跳转纯属给自己找麻烦。

我个人的经验是:内容型站点优先响应式,功能型站点(表单、下单、扫码、上传)优先独立移动站。因为功能型页面的移动端交互逻辑和 PC 端根本不是一个东西,用媒体查询硬挤在一起,代码会烂得没法看。

1.2 跳转方案必须提前定死的四个决策

如果确定要走跳转这条路,有四件事在写第一行代码之前就得定下来,不然后面每一件都会变成事故。

第一件,移动站是子目录还是独立域名。子目录(/m/xxx)在同域下共享 Cookie 和 Session,实现简单;独立域名(m.xxx.com)统计清晰、可独立部署,但要处理跨域 Cookie 和登录态同步。老项目改造我一律推荐子目录,改造成本低一个数量级。

第二件,跳转是"默认跳"还是"提示跳"。默认跳用户无感知,但一旦误判就是灾难;提示跳(顶部横条"检测到移动设备,切换到手机版?")体验差点但绝不误伤。用户量小的站点我更倾向后者,风险可控。

第三件,平板算哪一边。这是最容易被忽略的一项。早期很多实现直接把 iPad 归到手机,结果平板用户拿到一个被拉伸到满屏的手机版,体验极差。我的默认策略是:平板归 PC 端,因为平板屏幕足够宽,PC 布局撑得开。

第四件,用户手动切换后怎么记住。必须要有这个开关,因为 UA 判定永远会有误判,给用户一个"切回电脑版"的逃生口是底线。实现上用 Cookie 记录,优先级压过 UA 判定。

这四件事定完,剩下的才是技术实现。反过来说,如果你连"平板算哪边"都没想清楚就写if InStr(ua, "mobile"),那这个函数从诞生那天起就是错的。

2. UA 到底能读出多少信息

设备检测的全部信息来源就是一个 HTTP 请求头:User-Agent。这个字段是浏览器自己填的,服务端只是读,所以它本质上是客户端的一句自述,不是事实。理解这一点非常重要,后面所有的误判、绕过、伪装问题,根源都在这里。

2.1 经典 ASP 与 ASP.NET 的两个取法

经典 ASP(VBScript)里通过 ServerVariables 集合拿:

<% Dim ua ua = Request.ServerVariables("HTTP_USER_AGENT") If IsNull(ua) Then ua = "" ua = LCase(ua) ' 统一转小写,后面比较省心 %>

注意IsNull判断不能省。某些非标准客户端、部分健康检查探针、以及一些 IoT 设备的请求根本不带这个头,直接拿去做字符串比较会抛类型错误。我见过线上的 500 错误就是因为这个,一个监控探针每 30 秒打一次,日志里堆了几万条。

ASP.NET(C#)里稍微友好一点,Request.UserAgent拿不到时返回 null:

string ua = Request.UserAgent ?? string.Empty;

另外 ASP.NET 自带一个Request.Browser.IsMobileDevice,这是基于App_Browsers目录下的浏览器定义文件做出来的。听着很方便,但我实测下来不推荐依赖它:一是默认的浏览器库更新很慢,新机型基本认不出来;二是它的判定逻辑不透明,出了问题你根本不知道是哪条规则命中的。自己写判定函数,可控性高得多。

2.2 UA 里真正有价值的 token 只有那么几个

一条典型的移动端 UA 长这样:

Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1

看着一长串,其实判断设备只需要盯住括号里那一段和后面的Mobile标记。下面这张表是我这些年总结下来真正有用的 token:

token含义备注
Mobile移动设备标记最通用的判据,但部分安卓平板也带
Android安卓系统必须配合 Mobile 一起判断才能区分手机和平板
iPhone / iPod苹果手机/播放器稳定可靠
iPad苹果平板新版本 iPadOS 会伪装成 Macintosh
Windows Phone / IEMobile早期 Windows 移动系统现在占比极低,但加上不亏
BlackBerry / BB10黑莓同上,历史包袱
Opera Mini / Opera MobiOpera 移动版这类浏览器会做服务端渲染,UA 特征明显
HarmonyOS / OpenHarmony鸿蒙系部分机型 UA 里不含 Android 字样

要特别留意的反而是噪音 token:Safari、Chrome、Gecko、AppleWebKit这些在 PC 和手机上都会出现,拿它们做判据必然误判。我见过有人写if InStr(ua, "Safari")来判断苹果设备,结果所有 Mac 用户全被推到手机站,这个坑相当经典。

2.3 别把 UA 当成事实:它的三个不可靠之处

第一,它可以被随意伪造。任何 HTTP 客户端都能把 UA 改成任意字符串,用 curl 一行命令的事。这不是安全问题,但意味着你不能拿 UA 做任何权限相关的判断。

第二,它会被浏览器主动简化。新版 iPadOS 的 Safari 默认请求"桌面版网站",UA 里直接是Macintosh,根本没有iPad字样;安卓上的部分浏览器也有类似行为。这意味着服务端可能完全检测不到这是一台平板。

第三,厂商自定义五花八门。国产定制系统、折叠屏、双屏设备、车机、智能电视,UA 格式各写各的。我见过一台设备 UA 里只有Linux和Android,没有Mobile,按标准逻辑会被判成平板,实际是个手机。

所以工程上的正确姿势是:UA 判定给出一个"大概率",再用其他信号兜底。兜底信号里最有价值的是Sec-CH-UA-Mobile这个客户端提示头(新版本浏览器支持,值为?1表示移动设备),但它不是所有客户端都发,只能作为加分项。至于"用 JavaScript 读屏幕宽度再跳转"这种方案,我不推荐——它会带来一次额外的白屏闪烁,而且首屏渲染时拿不到宽度会导致页面先按 PC 排版再跳到手机版,视觉上很难看。

3. 写一个敢放生产环境的判定函数

这一节给一份可以直接拿去用的经典 ASP 实现。之所以用 VBScript 写,是因为还在用 ASP 的项目基本都是这个技术栈,改成 C# 逻辑是一模一样的。

3.1 判定顺序错了,后面全白搭

先看一个很多人会写的版本:

Function IsMobile(ua) IsMobile = (InStr(1, ua, "mobile", vbTextCompare) > 0) End Function

两行搞定,但问题一堆。第一,安卓平板的 UA 里经常带Mobile字样(部分厂商为了兼容性故意加上的),于是平板被判成手机;第二,搜索引擎的移动版爬虫 UA 里也带Mobile,于是爬虫被导到移动站,收录全乱;第三,某些桌面浏览器的扩展会改写 UA,导致误判。

正确的判定顺序应该是四层,从窄到宽:

<% ' 第一层:爬虫白名单,直接放行 Function IsBot(ua) Dim tokens, i tokens = Array("spider", "bot", "crawler", "slurp", "archiver", "facebookexternalhit", "monitoring") IsBot = False For i = 0 To UBound(tokens) If InStr(1, ua, tokens(i), vbTextCompare) > 0 Then IsBot = True Exit Function End If Next End Function ' 第二层:平板,归到 PC 侧 Function IsTablet(ua) IsTablet = False If InStr(1, ua, "ipad", vbTextCompare) > 0 Then IsTablet = True : Exit Function If InStr(1, ua, "tablet", vbTextCompare) > 0 Then IsTablet = True : Exit Function ' 安卓但不在 Mobile 分支里的,基本是平板 If InStr(1, ua, "android", vbTextCompare) > 0 Then If InStr(1, ua, "mobile", vbTextCompare) = 0 Then IsTablet = True End If End If End Function ' 第三层:手机 Function IsPhone(ua) Dim tokens, i tokens = Array("iphone", "ipod", "windows phone", "iemobile", _ "blackberry", "bb10", "opera mini", "opera mobi", _ "harmonyos", "symbian", "webos") IsPhone = False For i = 0 To UBound(tokens) If InStr(1, ua, tokens(i), vbTextCompare) > 0 Then IsPhone = True Exit Function End If Next ' 安卓手机:同时含 android 和 mobile If InStr(1, ua, "android", vbTextCompare) > 0 Then If InStr(1, ua, "mobile", vbTextCompare) > 0 Then IsPhone = True End If End If End Function %>

顺序为什么不能反?假设你先判手机再判平板,那Android ... Mobile的平板就会在第一步被拦下来,永远走不到平板分支。这是一个非常隐蔽的逻辑错误,因为它在大部分设备上表现正常,只在特定平板上出错,测试时很容易漏掉。

3.2 用数组遍历替代一长串 Or

新手常写的另一个版本是这样的:

If InStr(ua, "iphone") > 0 Or InStr(ua, "android") > 0 _ Or InStr(ua, "ipod") > 0 Or InStr(ua, "windows phone") > 0 Then

能跑,但加一个关键字就要改一次条件,改三次以后自己都不认识这行代码了。用数组 + 循环,加关键字只要往数组里塞一项,可维护性差好几个量级。

数组方案有一个性能顾虑:每次请求都要构造一次 Array。在经典 ASP 里 Array 是函数调用,开销虽小但确实存在。我的做法是把它挪到Application对象里初始化一次:

<% If IsEmpty(Application("MOBILE_TOKENS")) Then Application.Lock Application("MOBILE_TOKENS") = Array("iphone", "ipod", "windows phone", _ "iemobile", "blackberry", "bb10", "opera mini", "opera mobi", _ "harmonyos", "symbian", "webos") Application.Unlock End If %>

这样全站只初始化一次,后面所有请求复用。同理也可以把爬虫列表、平板列表都放进去。这个优化在日请求量几十万的老站点上能省下可观的 CPU。

3.3 判定结果要缓存,别每个请求都重新算

同一个用户的 UA 在一次会话里是不会变的,所以判定结果完全可以在 Cookie 或 Session 里缓存起来。更彻底的方案是在 Cookie 里直接存一个dev=m/dev=p的标识,服务端优先读 Cookie,读不到才跑判定:

<% Function DetectDevice(ua) Dim cached cached = Request.Cookies("dev_type") If cached = "m" Or cached = "p" Then DetectDevice = cached Exit Function End If If IsBot(ua) Then DetectDevice = "p" ElseIf IsTablet(ua) Then DetectDevice = "p" ElseIf IsPhone(ua) Then DetectDevice = "m" Else DetectDevice = "p" End If Response.Cookies("dev_type") = DetectDevice Response.Cookies("dev_type").Expires = DateAdd("d", 7, Now()) End Function %>

注意:这个 Cookie 只是缓存判断结果,不能当作"用户已选择"的标记。用户手动点了"切回电脑版",写的应该是另一个独立的偏好 Cookie(下面第 5 节会讲),两者优先级不同,混在一起会出逻辑冲突。

缓存 7 天是有讲究的。太短起不到作用,太长则用户换了设备还留着旧标识(虽然概率低,但同一台设备上浏览器 UA 变化是可能的,比如系统升级后 UA 变了)。7 天是个经验值,实际可以根据站点访问频次调整。

4. 跳转动作本身才是事故高发区

函数写对了,只完成了工作的一半。剩下那一半——真正执行跳转——是我见过事故最多的地方。这一节把这块拆开讲。

4.1 Response.Redirect 该用哪种写法

经典 ASP 里跳转有三种写法,行为差别不小:

' 写法一:最简,但无法控制后续 Response.Redirect "/m/index.asp" ' 写法二:先写状态码,再写头,通过 End 终止 Response.Status = "302 Object moved" Response.AddHeader "Location", "/m/index.asp" Response.End ' 写法三:Redirect 的第二参数控制是否立即结束 Response.Redirect "/m/index.asp", False Response.End

写法一内部其实就是跳转到状态码,但它在某些老版本的 IIS 上会把响应体也写进去,导致前面已经输出的 HTML 被拼到 302 响应里,客户端解析可能出问题。写法二最稳妥,因为你能精确控制写哪些头。写法三也常用,但要记得手动Response.End。

Response.End不能省。这个函数的作用是清空缓冲区并终止后续页面执行。如果漏掉,跳转指令之后的代码继续跑,可能又输出一段 HTML,也可能触发另一次跳转,最终响应头里出现两个Location,浏览器行为不可预测。

还有一个细节:跳转必须发生在任何内容输出之前。如果页面顶部已经输出了 HTML(哪怕是一个空格),再设置 302 头就会失败,IIS 会报"HTTP 头已发送"的错误。所以这类检测代码必须放在页面最顶部,通过<!--#include file="device_check.asp"-->的方式引入,而且 include 文件里%>前面不能有任何空行或空格。

4.2 死循环的完整现场还原

这是最高发的事故,我亲历过一次,把当时的排查过程完整还原一下。

现象:手机访问首页,控制台里能看到连续十几次 302,最后浏览器报ERR_TOO_MANY_REDIRECTS。

第一步,用 curl 看响应头:

curl -sI -A "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Mobile/15E148" http://127.0.0.1/index.asp

输出里Location: /m/m/m/index.asp。看到这个m/m/m就明白了——移动站的页面也 include 了同一个检测文件,检测到是手机,于是又拼了一次/m前缀,无限嵌套。

第二步,确认移动站目录结构,确实是/m/index.asp,而这条页面里也引用了device_check.asp。

第三步,定根因:检测逻辑没有排除"当前已经在移动站内"这个条件。

修复方案有三种,按推荐程度排序:

' 方案 A:判断当前路径前缀(最简单) Dim curPath curPath = LCase(Request.ServerVariables("URL")) If Left(curPath, 3) = "/m/" Or curPath = "/m" Then ' 已经在移动站里,不再跳转 Exit Function End If
' 方案 B:用一个约定变量控制(最灵活) ' 在 /m/ 目录下所有页面顶部第一行加: <% bInMobileSite = True %> ' 检测函数里: If bInMobileSite Then Exit Function
' 方案 C:在移动站页面里根本不 include 检测文件(最彻底)

我实际都用方案 C 配合方案 A。方案 C 从根本上杜绝了重复检测,方案 A 作为兜底防止有人误加 include。方案 B 的问题在于依赖人工约定,新同事加页面忘了写这一行就炸。

同一个坑还有一个变种:PC 端页面检测到"用户选了电脑版",跳转到?force=pc,然后 PC 页面又因为 UA 是手机再跳一次,参数被丢掉,形成循环。这个要靠下面第 5 节讲的 Cookie 优先级来解决,而不是靠 URL 参数。

4.3 路径、参数、锚点怎么搬运

跳转不能只跳首页,用户点进某个详情页,跳转后应该还在那个详情页。这就要求把路径和参数完整搬过去。

<% Function BuildMobileUrl(basePath) Dim path, qs, url path = Request.ServerVariables("URL") ' 例如 /news/2024/detail.asp qs = Request.ServerVariables("QUERY_STRING") ' 例如 id=123&from=list ' 处理默认页,避免拼出 /m/default.asp 这种冗余地址 If LCase(Right(path, 11)) = "/default.asp" Then path = Left(path, Len(path) - 11) End If url = "/m" & path If Len(qs) > 0 Then url = url & "?" & qs End If BuildMobileUrl = url End Function %>

三个必须注意的点:

第一,锚点(# 后面的部分)在服务端拿不到。浏览器不会把#section2发给服务器,所以服务端跳转天生会丢锚点。如果页面上有目录锚点导航,跳转后会回到顶部。这个没法在服务端解决,只能在移动站页面里用 JavaScript 判断location.hash再做一次滚动。

第二,QueryString 要原样搬。不能只搬部分参数,否则后台的统计、来源追踪、分页全断。Request.ServerVariables("QUERY_STRING")拿到的就是原始串,直接拼上去最省事。

第三,路径要防注入。Request.ServerVariables("URL")是服务端解析后的路径,相对安全,但如果配合参数拼进Location头,要过滤掉换行符(Chr(13)、Chr(10)),否则可能被构造成响应头注入。虽然现代 IIS 对此有防护,但显式过滤一下不费事:

url = Replace(url, vbCr, "") url = Replace(url, vbLf, "")

5. 缓存会把一次正确跳转放大成一场事故

这一节是我最想强调的部分,因为它最容易被忽视,而后果最严重。一个逻辑完全正确的跳转,配上错误的缓存策略,会变成一场灾难。

5.1 301 的诱惑与代价

很多文章会告诉你"跳转要用 301,对 SEO 友好"。在这个场景下,这句话是错的。

301 是永久重定向,浏览器和中间代理会长期缓存这个结果。用户用手机访问example.com/news/1,服务器返回 301 到/m/news/1,浏览器记住这个映射。然后用户换了台电脑、或者同一台手机切到"桌面版网站",再次访问example.com/news/1——浏览器直接读缓存,不再问服务器,直接跳/m/news/1。用户被永久锁在手机版里,没有任何办法出来,除非手动清缓存。

我见过一次,某站点用 301 做设备分流,上线三天后搜索流量掉了四成,原因就是搜索爬虫访问时被 301 到了移动站,之后所有搜索结果都指向移动站 URL,PC 用户点进去看到的是手机版。

所以这个场景的正确选择是302(Found),也就是临时重定向。302 意味着"这次是这个结果,下次再来重新问"。它不会被长期缓存,语义上也准确——同一个 URL 对不同设备返回不同目标,本来就是临时的判断结果,不是永久搬家。

如果确实需要 307,也可以用,它和 302 的区别只是强制保持原请求方法(POST 不会变成 GET)。设备分流一般是 GET 请求,302 够用。

5.2 Vary 头:让缓存层知道同一个 URL 有两种面孔

只用 302 还不够。现代站点前面通常有 CDN 或反向代理,它们缓存的是 URL 维度的响应。如果 CDN 缓存了"手机用户拿到的 302",然后一个 PC 用户请求同一个 URL,CDN 直接把缓存的 302 吐给他,PC 用户就被送到手机站了。

解决办法是加Vary响应头,告诉缓存层:"这个响应跟 User-Agent 有关,不同的 UA 要分开缓存":

Response.Status = "302 Object moved" Response.AddHeader "Location", mobileUrl Response.AddHeader "Vary", "User-Agent" Response.AddHeader "Cache-Control", "private, max-age=0, no-store" Response.End

三个头的分工:

  • Vary: User-Agent告诉缓存层按 UA 分桶。
  • Cache-Control: private表示这个响应只给单个用户,代理不要共享。
  • no-store表示连浏览器本地都不要存。

三个都给最保险。如果只给 302 不给 Vary,CDN 层的串味问题会非常隐蔽——因为源站日志里看到的全是正常请求,问题只发生在 CDN 节点上。我排查过一次这种故障,足足花了两天,因为本地怎么测都是对的。

另外还有个细节:Vary: User-Agent会导致缓存命中率下降,因为 UA 组合太多了。如果 CDN 支持,更好的做法是用Vary: Sec-CH-UA-Mobile或者按设备类别自定义一个缓存键,但这需要 CDN 侧配合,不是所有环境都支持。纯 IIS 环境就不用操这个心,IIS 本身不做这种页面级缓存。

5.3 用户手动切换后,Cookie 的优先级要压过 UA

这是整套方案里最容易被漏掉的一环,但它是用户的"逃生通道"。

设计两个 Cookie,职责分明:

Cookie 名作用写入时机有效期
dev_type缓存 UA 判定结果首次判定后7 天
site_pref用户手动选择的偏好点击切换按钮时30 天

判定时的优先级:site_pref>dev_type> 实时 UA 判定。

<% Function ResolveDevice(ua) Dim pref pref = Request.Cookies("site_pref") If pref = "pc" Then ResolveDevice = "p" : Exit Function ElseIf pref = "mobile" Then ResolveDevice = "m" : Exit Function End If ResolveDevice = DetectDevice(ua) End Function %>

切换按钮的实现,PC 页面上放一个"手机版"链接指向switch.asp?to=m,移动站页面上放一个"电脑版"链接指向switch.asp?to=pc:

<% Dim to to = Request.QueryString("to") If to = "m" Or to = "pc" Then Response.Cookies("site_pref") = (to = "m" ? "mobile" : "pc") Response.Cookies("site_pref").Expires = DateAdd("d", 30, Now()) Response.Cookies("site_pref").Path = "/" End If Response.Redirect Request.ServerVariables("HTTP_REFERER") %>

注意Path = "/"必须写,否则 Cookie 默认只对当前目录有效,用户在/news/下点了切换,回到首页又不生效了。这个坑相当经典。

另外Request.ServerVariables("HTTP_REFERER")为空时要有个兜底跳首页,否则切换动作会跳到空白页。

6. 把判断提前到 IIS:URL Rewrite 规则怎么写

前面讲的都是在 ASP 代码里做判断。但如果服务器装了 URL Rewrite 模块,可以把这一步提前到请求进入 ASP 引擎之前,好处是省掉了脚本引擎的启动开销,静态资源也能被覆盖到。

6.1 一条能直接用的最小规则

在web.config的system.webServer节点下加:

<rewrite> <rules> <rule name="MobileDeviceRedirect" stopProcessing="true"> <match url="^(?!m/).*" /> <conditions logicalGrouping="MatchAll"> <add input="{HTTP_USER_AGENT}" pattern="(iPhone|iPod|Android.+Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini|HarmonyOS)" /> <add input="{HTTP_USER_AGENT}" pattern="(iPad|Tablet|spider|bot|crawler)" negate="true" /> <add input="{HTTP_COOKIE}" pattern="site_pref=pc" negate="true" /> </conditions> <action type="Redirect" url="/m/{R:0}" redirectType="Found" appendQueryString="true" /> </rule> </rules> </rewrite>

逐条解释,因为这段配置里有几个地方特别容易写错:

<match url="^(?!m/).*" />这一行用了负向预查,意思是"匹配所有不以 m/ 开头的路径"。这是防死循环的第一道闸。注意{R:0}拿到的是去掉前导斜杠的路径,所以拼出来是/m/news/1,不会出现双斜杠。

Android.+Mobile而不是单独一个Android,是为了把安卓平板排除掉。这个正则的意思是"Android 后面还有内容,并且包含 Mobile"。安卓手机的 UA 通常长这样:Android 13; SM-G991B) AppleWebKit/... Mobile Safari/...,能匹配上;平板的 UA 里没有Mobile,匹配不上。

<add input="{HTTP_COOKIE}" pattern="site_pref=pc" negate="true" />是本条规则的关键,它让用户的手动选择在 IIS 层就生效,请求根本不会进 ASP。

redirectType="Found"对应 302,appendQueryString="true"保留原始查询串。这两个都不能省。

6.2 代码层和 IIS 层怎么分工

两层都能做判定,那到底该放哪一层?我的分工原则是这样的:

职责放在哪一层理由
基础的手机/PC 分流IIS 规则性能好,静态资源也能覆盖
平板、爬虫的精细判定代码层规则太长难维护,正则可读性差
用户偏好 Cookie两层都做IIS 层先拦一道,代码层兜底
特殊业务页面的例外代码层需要结合业务逻辑判断
日志和统计打点代码层IIS 层拿不到业务上下文

一个现实的情况是:很多老服务器根本没装 URL Rewrite 模块,或者运维不让你改 web.config。所以代码层的实现必须存在,IIS 规则是锦上添花,不是替代品。

6.3 规则调试的土办法

IIS 的 rewrite 规则出错时,浏览器里基本看不出原因(就是跳转不对或者 404)。我的调试方法是直接看服务器的 rewrite 日志,在 web.config 里打开:

<rewrite> <rules>...</rules> <rewriteLog enabled="true" directory="C:\logs\rewrite" maxLogFileSize="5242880"> <rewriteLogFile path="C:\logs\rewrite\rw.log" /> </rewriteLog> </rewrite>

日志里能清楚看到每个请求的输入变量、匹配了哪条规则、最终动作是什么。定位"为什么这个 UA 没被跳转"这类问题,比在浏览器里反复试快得多。

另一个土办法是用 URL Rewrite 模块自带的测试工具,在 IIS 管理器里选中规则点"测试模式",可以直接粘贴 UA 字符串模拟请求,看规则是否命中。这个功能用过的人不多,但非常省事。

7. 六个真实踩坑与对应的验证手段

前面几节讲的是"应该怎么做",这一节讲"实际会出什么问题"。这些都是我在不同项目里真金白银踩出来的。

7.1 iPad 与新版 iPadOS 的"伪装"

现象:iPad 用户拿到的是 PC 版(这是对的,符合我们的策略),但反馈说页面在竖屏下排版错乱。

排查:抓 UA 一看,Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 ...,完全就是一个 Mac 桌面浏览器的样子。这是新版 iPadOS 的默认行为——Safari 默认请求桌面版网站。

这意味着你的平板判定逻辑根本没有生效,因为它压根走的是"默认 PC"分支。好在结果是一样的(平板归 PC 侧),所以这个问题在我们这里反而没造成误判。但如果你的策略是"平板归手机侧",那就全错了。

应对:如果确实需要区分 iPad,唯一可靠的信号是Sec-CH-UA-Platform客户端提示头(值为"iPad"),但也需要浏览器支持。我的建议是接受这个现实——平板上给 PC 版布局 + 响应式 CSS 补丁,比试图精确识别要实用得多。

7.2 精简 UA、折叠屏与鸿蒙系设备

现象:部分新机型访问时永远进 PC 版。

排查:抓 UA 发现是Mozilla/5.0 (Linux; Android 12; XXX) AppleWebKit/537.36 ...,没有 Mobile 字样。这是一类被厂商精简过的 UA,为了某些兼容性考虑去掉了 Mobile 标记。

还有人反馈鸿蒙设备被漏判,UA 里同时有HarmonyOS和Android,但没有Mobile。这种情况靠"Android + Mobile"的组合判据是识别不出来的。

应对:

  1. 把harmonyos单独加进手机 token 列表(前面代码里已经加了)。
  2. 接受一定比例的漏判,用"提示跳"作为兜底——在 PC 页面顶部显示一条"检测到可能是移动设备,切换到手机版"的提示条。这条提示条的成本极低,但能覆盖所有漏判场景。
  3. 不要为了追求 100% 准确率而把 UA 规则写得无比复杂。规则越复杂,误判面越大。

7.3 爬虫误伤导致的收录异常

现象:上线设备分流后,搜索引擎收录的页面标题全变成了手机站的标题,PC 搜索结果点进去全是移动版。

排查:爬虫 UA 里带Mobile字样(移动版爬虫确实会带),被判定成手机,301/302 到移动站。

应对:爬虫白名单必须在判定链的最前面,而且是显式放行,不是靠"它不含 mobile 关键字"这种被动方式。另外,如果你的站点有独立的移动站,正确的做法是在 PC 页面上加<link rel="alternate" media="only screen and (max-width: 640px)" href="移动站URL">标注,并确保爬虫能同时抓取两套页面。

还有一个坑:如果用了Vary: User-Agent又没处理好,爬虫可能拿到缓存的错误版本。这个在 CDN 环境下要特别注意。

7.4 用 curl 做一套回归脚本

设备分流这类逻辑的问题,靠手工点浏览器是测不全的。我习惯写一个简单的 curl 脚本,每次改动后跑一遍:

#!/bin/bash HOST="http://127.0.0.1" check() { local name="$1" local ua="$2" local expect="$3" local code code=$(curl -s -o /dev/null -w "%{http_code}|%{redirect_url}" -A "$ua" "$HOST/news/1.html") echo "[$name] $code (期望: $expect)" } check "iPhone" "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Mobile/15E148" "302 -> /m/news/1.html" check "Android手机" "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Mobile Safari/537.36" "302 -> /m/news/1.html" check "Android平板" "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Safari/537.36" "200 不跳" check "iPad" "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) Mobile/15E148" "200 不跳" check "PC" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" "200 不跳" check "蜘蛛" "Mozilla/5.0 (compatible; SomeBot/2.0)" "200 不跳" check "空UA" "" "200 不跳" check "已在移动站" "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Mobile/15E148" "200 不跳"

最后一条要单独请求/m/路径,验证不会二次跳转。这套脚本覆盖了绝大多数边界情况,五分钟跑完,比手工点设备模拟器靠谱得多。

一个容易忽略的测试点:空 UA 和不带 UA 的请求。很多监控探针、健康检查、部分内网工具都不发 UA,如果代码里没做空值判断,这类请求会直接 500,而且因为量小,往往在日志里被淹没。

7.5 静态资源和接口请求也会被误跳

现象:移动端页面上的图片加载失败,接口返回 302 到 HTML 页面。

原因:检测代码放在了一个被所有请求 include 的公共文件里,包括.jpg、.ajax接口。图片请求被 302 到/m/xxx.jpg(不存在),接口请求被 302 到移动站首页,前端拿到一段 HTML 去JSON.parse,直接报错。

应对:检测代码只在页面级入口引入,不要放进全局 include。另外在跳转前加一条判断,只对特定扩展名的请求做跳转:

Dim ext ext = LCase(Mid(path, InStrRev(path, ".") + 1)) If ext <> "asp" And ext <> "aspx" And ext <> "html" And ext <> "htm" Then Exit Function ' 非页面请求不跳转 End If

这个判断还有个好处:静态资源请求根本不进 ASP 引擎(IIS 直接处理),但如果你用了 URL Rewrite 规则,正则^(?!m/).*会匹配到所有文件,必须在规则里加{REQUEST_FILENAME}的条件限制,或者用match url="(.*\.(aspx?|html?))$"收窄匹配范围。

7.6 反向代理下的 IP 与协议陷阱

现象:跳转后地址栏从 https 变成 http,或者跳到内网 IP。

原因:站点部署在反向代理后面,Request.ServerVariables("HTTP_HOST")和HTTPS变量拿到的是代理和后端之间的值,不是用户实际看到的。如果代码里拼了绝对 URL("http://" & host & "/m/"),就会出这个问题。

应对:一律用相对路径做 Location,即/m/news/1而不是https://site.com/m/news/1。相对路径由客户端自己补全协议和域名,天然不会错。如果确实需要绝对 URL(比如跳转到独立域名的移动站),要从X-Forwarded-Proto和X-Forwarded-Host里取值,而不是从SERVER_PORT和HTTP_HOST里取。

我见过最诡异的一次就是这个问题:测试环境全对,生产环境跳转后丢登录态。查了半天才发现生产有反代,Cookie 的 Domain 和 Path 对不上,用户在 A 域下登录,跳到 B 域下没带 Cookie。这种问题在纯内网测试环境里永远复现不出来。

把手头这套逻辑整理成一份可以直接复用的 include 文件,几个字段分清楚——空 UA 兜底、爬虫优先放行、平板归 PC、手机走移动站、用户偏好最高优先级、非页面请求不跳转——基本能覆盖 95% 的实际场景。剩下那 5% 靠一条提示切换的横条兜住,成本最低,也最不容易出事。真正上线过的项目都会明白,设备检测这件事从来不追求"判得准",追求的是"判错了也能救回来"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询