八、客户端检测

  浏览器提供商虽然在实现公共接口方面投入了很多精力,但结果仍然是每一种浏览器都有各自的长处,也都有各自的缺点。即使是那些跨平台的浏览器,虽然从技术上看版本相同,也照样存在不一致性问题。

  面对普遍存在的不一致性问题,开发人员要么采取迁就各方的 “最小公分母” 策略,要么(也是更常见的)就得利用各种客户端检测方法,来突破或者规避种种局限性。

  迄今为止,客户端检测仍然是 Web 开发领域中一个饱受争议的话题。一谈到这个话题,人们总会不约而同地提到浏览器应该支持一组最常用的公共功能。

  在理想状态下,确实应该如此。但是,在现实当中,浏览器之间的差异以及不同浏览器的“怪癖”(quirk),多得简直不胜枚举。

  因此,客户端检测除了是一种补救措施之外,更是一种行之有效的开发策略。

  检测 Web 客户端的手段很多,而且各有利弊。但最重要的还是要知道,不到万不得已,就不要使用客户端检测。只要能找到更通用的方法,就应该优先采用更通用的方法。

  一言以蔽之,先设计最通用的方案,然后再使用特定于浏览器的技术增强该方案。

1、能力检测

  最常用也最为人们广泛接受的客户端检测形式是能力检测(又称特性检测)。

  能力检测的目标不是识别特定的浏览器,而是识别浏览器的能力。

  采用这种方式不必顾及特定的浏览器如何如何,只要确定浏览器支持特定的能力,就可以给出解决方案。能力检测的基本模式如下:

if (object.propertyInQuestion){
    // 使用 object.propertyInQuestion
} 

  举例来说,IE5.0 之前的版本不支持 document.getElementById() 这个 DOM 方法。尽管可以使用非标准的 document.all 属性实现相同的目的,但 IE 的早期版本中确实不存在 document.getElementById()。于是,也就有了类似下面的能力检测代码:

function getElement(id){
    if (document.getElementById){
        return document.getElementById(id);
    } else if (document.all){
        return document.all[id]; 
    } else {
        throw new Error("No way to retrieve element!");
    }
} 

  这里的 getElement() 函数的用途是返回具有给定 ID 的元素。因为 document.getElementById() 是实现这一目的的标准方式,所以一开始就测试了这个方法。如果该函数存在(不是未定义),则使用该函数。否则,就要继续检测 document.all 是否存在,如果是,则使用它。如果上述两个特性都不存在(很有可能),则创建并抛出错误,表示这个函数无法使用。

  要理解能力检测,首先必须理解两个重要的概念。第一个概念就是先检测达成目的的最常用的特性。对前面的例子来说,就是要先检测 document.getElementById(),后检测 document.all。先检测最常用的特性可以保证代码最优化,因为在多数情况下都可以避免测试多个条件。

  第二个重要的概念就是必须测试实际要用到的特性。一个特性存在,不一定意味着另一个特性也存在。示例:

function getWindowWidth(){
    if (document.all){ // 假设是 IE
        return document.documentElement.clientWidth; // 错误的用法!!!
    } else {
        return window.innerWidth;
    }
} 

  上述代码是一个错误使用能力检测的例子。getWindowWidth() 函数首先检查 document.all是否存在,如果是则返回document.documentElement.clientWidth。
  我们知道,IE8 及之前版本确实不支持 window.innerWidth 属性。但问题是 document.all 存在也不一定表示浏览器就是 IE。实际上,也可能是 Opera;Opera 支持 document.all,也支持 window.innerWidth。

1.1、更可靠的能力检测

  能力检测对于想知道某个特性是否会按照适当方式行事(而不仅仅是某个特性存在)非常有用。上一节中的例子利用类型转换来确定某个对象成员是否存在,但这样你还是不知道该成员是不是你想要的。
  看下面的函数,它用来确定一个对象是否支持排序。

// 不要这样做!这不是能力检测——只检测了是否存在相应的方法
function isSortable(object){
    return !!object.sort;
} 

  这个函数通过检测对象是否存在 sort() 方法,来确定对象是否支持排序。问题是,任何包含 sort 属性的对象也会返回 true。

var result = isSortable({ sort: true }); 

  检测某个属性是否存在并不能确定对象是否支持排序。更好的方式是检测 sort 是不是一个函数。

// 这样更好:检查 sort 是不是函数
function isSortable(object){
    return typeof object.sort == "function";
} 

  这里的 typeof 操作符用于确定 sort 的确是一个函数,因此可以调用它对数据进行排序。

  在可能的情况下,要尽量使用 typeof 进行能力检测。特别是,宿主对象没有义务让 typeof 返回合理的值。最令人发指的事儿就发生在 IE 中。大多数浏览器在检测到 document.createElement() 存在时,都会返回 true。

 // 在 IE8 及之前版本中不行
function hasCreateElement(){
    return typeof document.createElement == "function";
} 

  在 IE8 及之前版本中,这个函数返回 false,因为 typeof document.createElement 返回的是 "object",而不是 "function"。
  如前所述,DOM 对象是宿主对象,IE 及更早版本中的宿主对象是通过 COM 而非 JScript 实现的。因此 document.createElement() 函数确实是一个 COM 对象,所以 typeof 才会返回 "object"。IE9 纠正了这个问题,对所有 DOM 方法都返回 "function"。

  关于 typeof 的行为不标准,IE 中还可以举出例子来。ActiveX 对象(只有 IE 支持)与其他对象的行为差异很大。例如,不使用 typeof 测试某个属性会导致错误,如下所示。

// 在 IE 中会导致错误
var xhr = new ActiveXObject("Microsoft.XMLHttp");
if (xhr.open){ // 这里会发生错误
    // 执行操作
} 

  像这样直接把函数作为属性访问会导致 JavaScript 错误。使用 typeof 操作符会更靠谱一点,但 IE对 typeof xhr.open 会返回 "unknown"。这就意味着,在浏览器环境下测试任何对象的某个特性是否存在,要使用下面这个函数。

// 作者:Peter Michaux
function isHostMethod(object, property) {
    var t = typeof object[property];
    return t == 'function' || (!!(t=='object' && object[property])) || t=='unknown';
} 

// 可以像下面这样使用这个函数
result = isHostMethod(xhr, "open"); // true
result = isHostMethod(xhr, "foo"); // false 

  目前使用 isHostMethod() 方法还是比较可靠的,因为它考虑到了浏览器的怪异行为。不过也要注意,宿主对象没有义务保持目前的实现方式不变,也不一定会模仿已有宿主对象的行为。所以,这个函数——以及其他类似函数,都不能百分之百地保证永远可靠。作为开发人员,必须对自己要使用某个功能的风险作出理性的估计。

1.2、能力检测,不是浏览器检测

  检测某个或某几个特性并不能够确定浏览器。下面给出的这段代码(或与之差不多的代码)可以在许多网站中看到,这种 “浏览器检测” 代码就是错误地依赖能力检测的典型示例。

// 错误!还不够具体
var isFirefox = !!(navigator.vendor && navigator.vendorSub);

// 错误!假设过头了
var isIE = !!(document.all && document.uniqueID); 

  这两行代码代表了对能力检测的典型误用。以前,确实可以通过检测 navigator.vendor 和 navigator.vendorSub 来确定 Firefox 浏览器。但是,Safari 也依葫芦画瓢地实现了相同的属性。于是,这段代码就会导致人们作出错误的判断。
  为检测 IE,代码测试了 document.all 和 document.uniqueID。这就相当于假设 IE 将来的版本中仍然会继续存在这两个属性,同时还假设其他浏览器都不会实现这两个属性。
  最后,这两个检测都使用了双逻辑非操作符来得到布尔值(比先存储后访问的效果更好)。

  实际上,根据浏览器不同将能力组合起来是更可取的方式。如果你知道自己的应用程序需要使用某些特定的浏览器特性,那么最好是一次性检测所有相关特性,而不要分别检测。示例:

// 确定浏览器是否支持 Netscape 风格的插件
var hasNSPlugins = !!(navigator.plugins && navigator.plugins.length);

// 确定浏览器是否具有 DOM1 级规定的能力
var hasDOM1 = !!(document.getElementById && document.createElement && document.getElementsByTagName); 

  上述例子展示了两个检测:一个检测浏览器是否支持 Netscapte 风格的插件;另一个检测浏览器是否具备 DOM1 级所规定的能力。得到的布尔值可以在以后继续使用,从而节省重新检测能力的时间。

  在实际开发中,应该将能力检测作为确定下一步解决方案的依据,而不是用它来判断用户使用的是什么浏览器。

2、怪癖检测

  与能力检测类似,怪癖检测(quirks detection)的目标是识别浏览器的特殊行为。
  但与能力检测确认浏览器支持什么能力不同,怪癖检测是想要知道浏览器存在什么缺陷(“怪癖” 也就是 bug)。这通常需要运行一小段代码,以确定某一特性不能正常工作。
  例如,IE8 及更早版本中存在一个 bug,即如果某个实例属性与[[Enumerable]] 标记为 false 的某个原型属性同名,那么该实例属性将不会出现在 fon-in 循环当中。可以使用如下代码来检测这种“怪癖”。

var hasDontEnumQuirk = function(){
    var o = { toString : function(){} };
    for (var prop in o){ 
       if (prop == "toString"){
           return false;
       }
    }
    return true;
}(); 

  上述代码通过一个匿名函数来测试该 “怪癖”,函数中创建了一个带有 toString() 方法的对象。在正确的 ECMAScript 实现中,toString 应该在 for-in 循环中作为属性返回。

  另一个经常需要检测的 “怪癖” 是 Safari 3 以前版本会枚举被隐藏的属性。可以用下面的函数来检测该 “怪癖”。

var hasEnumShadowsQuirk = function(){
    var o = { toString : function(){} };
    var count = 0;
    for (var prop in o){
        if (prop == "toString"){
            count++;
        }
    }
    return (count > 1);
}(); 

  如果浏览器存在这个 bug,那么使用 for-in 循环枚举带有自定义的 toString() 方法的对象,就会返回两个 toString 的实例。

  一般来说,“怪癖” 都是个别浏览器所独有的,而且通常被归为 bug。在相关浏览器的新版本中,这些问题可能会也可能不会被修复。由于检测 “怪癖” 涉及运行代码,因此我们建议仅检测那些对你有直接影响的“怪癖”,而且最好在脚本一开始就执行此类检测,以便尽早解决问题。

3、用户代理检测

  第三种,也是争议最大的一种客户端检测技术叫做用户代理检测
  用户代理检测通过检测用户代理字符串来确定实际使用的浏览器。在每一次 HTTP 请求过程中,用户代理字符串是作为响应首部发送的,而且该字符串可以通过 JavaScript 的 navigator.userAgent 属性访问。
  在服务器端,通过检测用户代理字符串来确定用户使用的浏览器是一种常用而且广为接受的做法。而在客户端,用户代理检测一般被当作一种万不得已才用的做法,其优先级排在能力检测和(或)怪癖检测之后。
  提到与用户代理字符串有关的争议,就不得不提到电子欺骗(spoofing)。
  所谓电子欺骗,就是指浏览器通过在自己的用户代理字符串加入一些错误或误导性信息,来达到欺骗服务器的目的。要弄清楚这个问题的来龙去脉,必须从 Web 问世初期用户代理字符串的发展讲起。

3.1、用户代理字符串的历史

  HTTP 规范(包括 1.0 和 1.1 版)明确规定,浏览器应该发送简短的用户代理字符串,指明浏览器的名称和版本号。RFC 2616(即 HTTP 1.1 协议规范)是这样描述用户代理字符串的:
    “产品标识符常用于通信应用程序标识自身,由软件名和版本组成。
  使用产品标识符的大多数领域也允许列出作为应用程序主要部分的子产
  品,由空格分隔。按照惯例,产品要按照相应的重要程度依次列出,以
  便标识应用程序。”

  上述规范进一步规定,用户代理字符串应该以一组产品的形式给出,字符串格式为:标识符/产品版本号。
  但是,现实中的用户代理字符串则绝没有如此简单。

3.2、用户代理字符串检测技术

  考虑到历史原因以及现代浏览器中用户代理字符串的使用方式,通过用户代理字符串来检测特定的浏览器并不是一件轻松的事。
  因此,首先要确定的往往是你需要多么具体的浏览器信息。一般情况下,知道呈现引擎和最低限度的版本就足以决定正确的操作方法了。例如,我们不推荐使用下列代码:

if (isIE6 || isIE7) { // 不推荐!!!
    // 代码
} 

  这个例子是想要在浏览器为 IE6 或 IE7 时执行相应代码。这种代码其实是很脆弱的,因为它要依据特定的版本来决定做什么。如果是 IE8 怎么办呢?只要 IE 有新版本出来,就必须更新这些代码。
  不过,像下面这样使用相对版本号则可以避免此问题:

if (ieVer >=6){
    // 代码
} 

  这个例子首先检测 IE 的版本号是否至少等于 6,如果是则执行相应操作。这样就可以确保相应的代码将来照样能够起作用。我们下面的浏览器检测脚本就将本着这种思路来编写。

1. 识别呈现引擎

  如前所述,确切知道浏览器的名字和版本号不如确切知道它使用的是什么呈现引擎。
  如果 Firefox、Camino 和 Netscape 都使用相同版本的 Gecko,那它们一定支持形同的特性。类似的,不管是什么浏览器,只要它跟 Safari 3 使用的是同一个版本的 WebKit,那么该浏览器也就跟 Safari 3 具备同样的功能。
  因此,我们编写的脚本将主要检测五大呈现引擎:IE、Gecko、Webkit、KHTML 和 Opera。

  为了不在全局作用域中添加多余的变量,我们将使用模块增强模式来封装检测脚本。检测脚本的基本代码结构如下所示:

var client = function(){

    var engine = {
        // 呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        // 具体的版本号
        ver: null
    };

    // 在此检测呈现引擎、平台和设备

    return {
        engine : engine
    };
}();

  这里声明了一个名为 client 的全局变量,用于保存相关信息。匿名函数内部定义了一个局部变量 engine,它是一个包含默认设置的对象字面量。
  在这个对象字面量中,每个呈现引擎都对应着一个属性,属性的值默认为 0。如果检测到了哪个呈现引擎,那么就以浮点数值形式将该引擎的版本号写入相应的属性。
  而呈现引擎的完全版本(是一个字符串),则被写入 ver 属性。作这样的区分可以支持像下面这样编写代码:

if (client.engine.ie) { // 如果是 IE,client.ie 的值应该大于 0
    // 针对 IE 的代码
} else if (client.engine.gecko > 1.5){
    if (client.engine.ver == "1.8.1"){
        // 针对这个版本执行某些操作
    }
} 

  在检测到一个呈现引擎之后,其 client.engine 中对应的属性将被设置为一个大于 0 的值,该值可以转换成布尔值 true。
  这样,就可以在 if 语句中检测相应的属性,以确定当前使用的呈现引擎,连具体的版本号都不必考虑。
  鉴于每个属性都包含一个浮点数值,因此有可能丢失某些版本信息。例如,将字符串 "1.8.1" 传入 parseFloat() 后会得到数值 1.8。不过,在必要的时候可以检测 ver 属性,该属性中会保存完整的版本信息。

  要正确地识别呈现引擎,关键是检测顺序要正确。由于用户代理字符串存在诸多不一致的地方,如果检测顺序不对,很可能会导致检测结果不正确。
  为此,第一步就是识别 Opera,因为它的用户代理字符串有可能完全模仿其他浏览器。我们不相信 Opera,是因为(任何情况下)其用户代理字符串(都)不会将自己标识为 Opera。

  要识别 Opera,必须得检测 window.opera 对象。Opera 5 及更高版本中都有这个对象,用以保存与浏览器相关的标识信息以及与浏览器直接交互。
  在 Opera 7.6 及更高版本中,调用 version() 方法可以返回一个表示浏览器版本的字符串,而这也是确定 Opera 版本号的最佳方式。
  要检测更早版本的 Opera,可以直接检查用户代理字符串,因为那些版本还不支持隐瞒身份。不过,2007 底 Opera 的最高版本已经是 9.5 了,所以不太可能有人还在使用 7.6 之前的版本。那么,检测呈现引擎代码的第一步,就是编写如下代码:

if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
} 

  这里,将版本的字符串表示保存在了 engine.ver 中,将浮点数值表示的版本保存在了 engine.opera 中。如果浏览器是 Opera,测试 window.opera 就会返回 true;否则,就要看看是其他的什么浏览器了。

  应该放在第二位检测的呈现引擎是 WebKit。因为 WebKit 的用户代理字符串中包含 "Gecko" 和 "KHTML" 这两个子字符串,所以如果首先检测它们,很可能会得出错误的结论。
  不过,WebKit 的用户代理字符串中的 "AppleWebKit" 是独一无二的,因此检测这个字符串最合适。下面就是检测该字符串的示例代码:

var ua = navigator.userAgent;
if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} 

  代码首先将用户代理字符串保存在变量 ua 中。然后通过正则表达式来测试其中是否包含字符串 "AppleWebKit",并使用捕获组来取得版本号。
  由于实际的版本号中可能会包含数字、小数点和字母,
所以捕获组中使用了表示非空格的特殊字符(\S)。用户代理字符串中的版本号与下一部分的分隔符是一个空格,因此这个模式可以保证捕获所有版本信息。
  test() 方法基于用户代理字符串运行正则表达式。如果返回 true,就将捕获的版本号保存在 engine.ver 中,而将版本号的浮点表示保存在engine.webkit 中。

  接下来要测试的呈现引擎是 KHTML。同样,KHTML 的用户代理字符串中也包含 "Gecko",因此在排除 KHTML 之前,我们无法准确检测基于 Gecko 的浏览器。
  KHTML 的版本号与 WebKit 的版本号在用户代理字符串中的格式差不多,因此可以使用类似的正则表达式。此外,由于Konqueror 3.1 及更早版本中不包含 KHTML 的版本,故而就要使用 Konqueror 的版本来代替。下面就是相应的检测代码。

var ua = navigator.userAgent;
if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} else if (/KHTML\/(\S+)/.test(ua) || /Konqueror\/([^;]+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.khtml = parseFloat(engine.ver);
} 

  与前面一样,由于 KHTML 的版本号与后继的标记之间有一个空格,因此仍然要使用特殊的非空格字符来取得与版本有关的所有字符。然后,将字符串形式的版本信息保存在 engine.ver 中,将浮点数
值形式的版本保存在 engin.khtml 中。
  如果 KHTML 不在用户代理字符串中,那么就要匹配 Konqueror 后跟一个斜杠,再后跟不包含分号的所有字符。

  在排除了 WebKit 和 KHTML 之后,就可以准确地检测 Gecko 了。但是,在用户代理字符串中,Gecko 的版本号不会出现在字符串 "Gecko" 的后面,而是会出现在字符串 "rv:" 的后面。这样,我们就必须使用一个比前面复杂一些的正则表达式,如下所示。

var ua = navigator.userAgent;
if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} else if (/KHTML\/(\S+)/.test(ua)) {
    engine.ver = RegExp["$1"];
    engine.khtml = parseFloat(engine.ver);
} else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.gecko = parseFloat(engine.ver);
}

  Gecko 的版本号位于字符串 "rv:" 与一个闭括号之间,因此为了提取出这个版本号,正则表达式要查找所有不是闭括号的字符,还要查找字符串 "Gecko/" 后跟 8 个数字。如果上述模式匹配,就提取出
版本号并将其保存在相应的属性中。

  最后一个要检测的呈现引擎就是 IE 了。IE 的版本号位于字符串 "MSIE" 的后面、一个分号的前面,因此相应的正则表达式非常简单,如下所示:

var ua = navigator.userAgent;
if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} else if (/KHTML\/(\S+)/.test(ua)) {
    engine.ver = RegExp["$1"];
    engine.khtml = parseFloat(engine.ver);
} else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.gecko = parseFloat(engine.ver);
} else if (/MSIE ([^;]+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.ie = parseFloat(engine.ver);
}

  以上呈现引擎检测脚本的最后一部分,就是在正则表达式中使用取反的字符类来取得不是分号的所有字符。IE 通常会保证以标准浮点数值形式给出其版本号,但有时候也不一定。因此,取反的字符类[^;]
可以确保取得多个小数点以及任何可能的字符。

2. 识别浏览器

  大多数情况下,识别了浏览器的呈现引擎就足以为我们采取正确的操作提供依据了。
  可是,只有呈现引擎还不能说明存在所需的 JavaScript 功能。苹果公司的 Safari 浏览器和谷歌公司的 Chrome 浏览器都使用 WebKit 作为呈现引擎,但它们的 JavaScript 引擎却不一样。
  在这两款浏览器中,client.webkit 都会返回非 0值,但仅知道这一点恐怕还不够。对于它们,有必要像下面这样为 client 对象再添加一些新的属性。

var client = function(){
    var engine = { 

        // 呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        // 具体的版本
        ver: null
    };

    var browser = {

        // 浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        // 具体的版本
        ver: null
    };

    // 在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser
    };
}(); 

  代码中又添加了私有变量 browser,用于保存每个主要浏览器的属性。
  与 engine 变量一样,除了当前使用的浏览器,其他属性的值将保持为 0;如果是当前使用的浏览器,则这个属性中保存的是浮点数值形式的版本号。
  同样,ver 属性中在必要时将会包含字符串形式的浏览器完整版本号。由于大多数浏览器与其呈现引擎密切相关,所以下面示例中检测浏览器的代码与检测呈现引擎的代码是混合在一起的。

// 检测呈现引擎及浏览器
var ua = navigator.userAgent;
if (window.opera){
    engine.ver = browser.ver = window.opera.version();
    engine.opera = browser.opera = parseFloat(engine.ver);
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);

    // 确定是 Chrome 还是 Safari
    if (/Chrome\/(\S+)/.test(ua)){
        browser.ver = RegExp["$1"];
        browser.chrome = parseFloat(browser.ver);
    } else if (/Version\/(\S+)/.test(ua)){
        browser.ver = RegExp["$1"];
        browser.safari = parseFloat(browser.ver);
    } else {
        // 近似地确定版本号
        var safariVersion = 1; 
        if (engine.webkit < 100){
            safariVersion = 1;
        } else if (engine.webkit < 312){
            safariVersion = 1.2;
        } else if (engine.webkit < 412){
            safariVersion = 1.3;
        } else {
            safariVersion = 2;
        }

        browser.safari = browser.ver = safariVersion;
    }
} else if (/KHTML\/(\S+)/.test(ua) || /Konqueror\/([^;]+)/.test(ua)){
    engine.ver = browser.ver = RegExp["$1"];
    engine.khtml = browser.konq = parseFloat(engine.ver);
} else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.gecko = parseFloat(engine.ver);

    // 确定是不是 Firefox
    if (/Firefox\/(\S+)/.test(ua)){
        browser.ver = RegExp["$1"];
        browser.firefox = parseFloat(browser.ver);
    }
} else if (/MSIE ([^;]+)/.test(ua)){
    engine.ver = browser.ver = RegExp["$1"];
    engine.ie = browser.ie = parseFloat(engine.ver);
} 

  对 Opera 和 IE 而言,browser 对象中的值等于 engine 对象中的值。
  对 Konqueror 而言,browser.konq 和 browser.ver 属性分别等于 engine.khtml 和 engine.ver 属性。
  为了检测 Chrome 和 Safari,我们在检测引擎的代码中添加了 if 语句。提取 Chrome 的版本号时,需要查找字符串 "Chrome/" 并取得该字符串后面的数值。而提取 Safari 的版本号时,则需要查找字符串 "Version/" 并取得其后的数值。
  由于这种方式仅适用于 Safari 3 及更高版本,因此需要一些备用的代码,将 WebKit 的版本号近似地映射为 Safari 的版本号。
  在检测 Firefox 的版本时,首先要找到字符串 "Firefox/",然后提取出该字符串后面的数值(即版本号)。当然,只有呈现引擎被判别为 Gecko 时才会这样做。
  有了上面这些代码之后,我们就可以编写下面的逻辑。

if (client.engine.webkit) { // if it’s WebKit
    if (client.browser.chrome){
        // 执行针对 Chrome 的代码
    } else if (client.browser.safari){
        // 执行针对 Safari 的代码
    }
} else if (client.engine.gecko){
    if (client.browser.firefox){
        // 执行针对 Firefox 的代码
    } else {
        // 执行针对其他 Gecko 浏览器的代码
    }
} 
3. 识别平台

  很多时候,只要知道呈现引擎就足以编写出适当的代码了。但在某些条件下,平台可能是必须关注的问题。
  那些具有各种平台版本的浏览器(如 Safari、Firefox 和 Opera)在不同的平台下可能会有不同的问题。
  目前的三大主流平台是 Windows、Mac 和 Unix(包括各种 Linux)。为了检测这些平台,还需要像下面这样再添加一个新对象。

var client = function(){
    var engine = { 

        // 呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        // 具体的版本
        ver: null
    };

    var browser = {

        // 浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        // 具体的版本
        ver: null
    };

    var system = {
        win: false,
        mac: false,
        x11: false
    }; 

    // 在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser,
        system: system 
    };
}(); 

  显然,上面的代码中又添加了一个包含 3 个属性的新变量 system。
  其中,win 属性表示是否为Windows 平台,mac 表示 Mac,而 x11 表示 Unix。
  与呈现引擎不同,在不能访问操作系统或版本的情况下,平台信息通常是很有限的。对这三个平台而言,浏览器一般只报告 Windows 版本。
  为此,新变量 system 的每个属性最初都保存着布尔值 false,而不是像呈现引擎属性那样保存着数字值。在确定平台时,检测 navigator.platform 要比检测用户代理字符串更简单,后者在不同浏览器
中会给出不同的平台信息。而 navigator.platform 属性可能的值包括 "Win32"、"Win64"、"MacPPC"、"MacIntel"、"X11" 和 "Linux i686",这些值在不同的浏览器中都是一致的。
  检测平台的代码非常直观,如下所示:

var p = navigator.platform;
system.win = p.indexOf("Win") == 0;
system.mac = p.indexOf("Mac") == 0;
system.x11 = (p.indexOf("X11") == 0) || (p.indexOf("Linux") == 0); 

  以上代码使用 indexOf() 方法来查找平台字符串的开始位置。虽然 "Win32" 是当前浏览器唯一支持的 Windows 字符串,但随着向 64 位 Windows 架构的迁移,将来很可能会出现 "Win64" 平台信息值。
  为了对此有所准备,检测平台的代码中查找的只是字符串 "Win" 的开始位置。
  而检测 Mac 平台的方式也类似,同样是考虑到了 MacPPC 和 MacIntel。
  在检测 Unix 时,则同时检查了字符串"X11"和"Linux" 在平台字符串中的开始位置,从而确保了代码能够向前兼容其他变体。

4. 识别 Windows 操作系统

  正则表达式可以成功地匹配 Windows ME、Windows XP 和 Windows Vista 的字符串。具体来说,第一个捕获组将会匹配 95、98、9x、NT、ME 或 XP。第二个捕获组则只针对 Windows ME 及所有 Windows NT 的变体。这个信息可以作为具体的操作系统信息保存在 system.win属性中,如下所示。

if (system.win){
    if (/Win(?:dows )?([^do]{2})\s?(\d+\.\d+)?/.test(ua)){
        if (RegExp["$1"] == "NT"){
            switch(RegExp["$2"]){
                case "5.0":
                    system.win = "2000";
                    break;
                case "5.1":
                    system.win = "XP";
                    break;
                case "6.0":
                    system.win = "Vista";
                    break;
                case "6.1":
                    system.win = "7";
                    break;
                default:
                    system.win = "NT";
                    break;
            }
        } else if (RegExp["$1"] == "9x"){
            system.win = "ME";
        } else {
            system.win = RegExp["$1"];
        }
    }
}

  如果 system.win 的值为 true,那么就使用这个正则表达式从用户代理字符串中提取具体的信息。
  鉴于 Windows 将来的某个版本也许不能使用这个方法来检测,所以第一步应该先检测用户代理字符串是否与这个模式匹配。
  在模式匹配的情况下,第一个捕获组中可能会包含"95"、"98"、"9x" 或 "NT"。如果这个值是 "NT",可以将 system.win 设置为相应操作系统的字符串;如果是 "9x",那么 system.win就要设置成 "ME";如果是其他值,则将所捕获的值直接赋给 system.win。有了这些检测平台的代码后,我们就可以编写如下代码。

if (client.system.win){
    if (client.system.win == "XP") {
        // 说明是 XP
    } else if (client.system.win == "Vista"){
        // 说明是 Vista
    }
} 

  由于非空字符串会转换为布尔值 true,因此可以将 client.system.win 作为布尔值用在 if 语句中。而在需要更多有关操作系统的信息时,则可以使用其中保存的字符串值。

5. 识别移动设备

  四大主要浏览器都推出了手机版和在其他设备中运行的版本。要检测相应的设备,第一步是为要检测的所有移动设备添加属性,如
下所示。

var client = function(){
    var engine = { 

        // 呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        // 具体的版本
        ver: null
    };

    var browser = {

        // 浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        // 具体的版本
        ver: null
    };

    var system = {
        win: false,
        mac: false,
        x11: false,

        // 移动设备
        iphone: false,
        ipod: false,
        ipad: false,
        ios: false,
        android: false,
        nokiaN: false,
        winMobile: false 
    }; 

    // 在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser,
        system: system 
    };
}(); 

  然后,通常简单地检测字符串 "iPhone"、 "iPod" 和 "iPad",就可以分别设置相应属性的值了。

system.iphone = ua.indexOf("iPhone") > -1;
system.ipod = ua.indexOf("iPod") > -1;
system.ipod = ua.indexOf("iPad") > -1; 
6. 识别游戏系统

  除了移动设备之外,视频游戏系统中的 Web 浏览器也开始日益普及。任天堂 Wii 和 Playstation 3 或者内置 Web 浏览器,或者提供了浏览器下载。Wii 中的浏览器实际上是定制版的 Opera,是专门为 Wii
Remote 设计的。Playstation 的浏览器是自己开发的,没有基于前面提到的任何呈现引擎。这两个浏览器中的用户代理字符串如下所示:

Opera/9.10 (Nintendo Wii;U; ; 1621; en)
Mozilla/5.0 (PLAYSTATION 3; 2.00) 

  在检测这些设备以前,我们必须先为 client.system 中添加适当的属性,如下所示:

var client = function(){
    var engine = { 

        // 呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        // 具体的版本
        ver: null
    };

    var browser = {

        // 浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        // 具体的版本
        ver: null
    };

    var system = {
        win: false,
        mac: false,
        x11: false,

        // 移动设备
        iphone: false,
        ipod: false,
        ipad: false,
        ios: false,
        android: false,
        nokiaN: false,
        winMobile: false 

        //游戏系统
        wii: false,
        ps: false 
    }; 

    // 在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser,
        system: system 
    };
}(); 

  检测前述游戏系统的代码如下:

system.wii = ua.indexOf("Wii") > -1;
system.ps = /playstation/i.test(ua); 

3.3、完整的代码

  以下是完整的用户代理字符串检测脚本,包括检测呈现引擎、平台、Windows 操作系统、移动设备和游戏系统。

var client = function(){
    
    // 呈现引擎
    var engine = { 
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        // 完整的版本号
        ver: null
    };

   // 浏览器
    var browser = {

        // 主要浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        // 具体的版本号
        ver: null
    };

    // 平台、设备和操作系统]
    var system = {
        win: false,
        mac: false,
        x11: false,

        // 移动设备
        iphone: false,
        ipod: false,
        ipad: false,
        ios: false,
        android: false,
        nokiaN: false,
        winMobile: false 

        //游戏系统
        wii: false,
        ps: false 
    }; 

    // 检测呈现引擎和浏览器
    var ua = navigator.userAgent;
    if (window.opera){
        engine.ver = browser.ver = window.opera.version();
        engine.opera = browser.opera = parseFloat(engine.ver);
    } else if (/AppleWebKit\/(\S+)/.test(ua)){
        engine.ver = RegExp["$1"];
        engine.webkit = parseFloat(engine.ver);
  
        // 确定是 Chrome 还是 Safari
        if (/Chrome\/(\S+)/.test(ua)){
            browser.ver = RegExp["$1"];
            browser.chrome = parseFloat(browser.ver);
        } else if (/Version\/(\S+)/.test(ua)){
            browser.ver = RegExp["$1"];
            browser.safari = parseFloat(browser.ver);
        } else {
            // 近似地确定版本号
            var safariVersion = 1; 
            if (engine.webkit < 100){
                safariVersion = 1;
            } else if (engine.webkit < 312){
                safariVersion = 1.2;
            } else if (engine.webkit < 412){
                safariVersion = 1.3;
            } else {
                safariVersion = 2;
            }

            browser.safari = browser.ver = safariVersion;
        }
    } else if (/KHTML\/(\S+)/.test(ua) || /Konqueror\/([^;]+)/.test(ua)){
        engine.ver = browser.ver = RegExp["$1"];
        engine.khtml = browser.konq = parseFloat(engine.ver);
    } else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){
        engine.ver = RegExp["$1"];
        engine.gecko = parseFloat(engine.ver);

        // 确定是不是 Firefox
        if (/Firefox\/(\S+)/.test(ua)){
            browser.ver = RegExp["$1"];
            browser.firefox = parseFloat(browser.ver);
        }
    } else if (/MSIE ([^;]+)/.test(ua)){
        engine.ver = browser.ver = RegExp["$1"];
        engine.ie = browser.ie = parseFloat(engine.ver);
    } 

    // 检测浏览器
    browser.ie = engine.ie;
    browser.opera = engine.opera; 

    // 检测平台
    var p = navigator.platform;
    system.win = p.indexOf("Win") == 0;
    system.mac = p.indexOf("Mac") == 0;
    system.x11 = (p == "X11") || (p.indexOf("Linux") == 0);

    // 检测 Windows 操作系统
    if (system.win){
        if (/Win(?:dows )?([^do]{2})\s?(\d+\.\d+)?/.test(ua)){
            if (RegExp["$1"] == "NT"){
                switch(RegExp["$2"]){
                    case " 5.0":
                        system.win = "2000";
                        break;
                    case "5.1":
                        system.win = "XP";
                        break;
                    case "6.0":
                        system.win = "Vista";
                        break;
                    case "6.1":
                        system.win = "7";
                        break;
                    default:
                        system.win = "NT";
                        break;
                  }
              } else if (RegExp["$1"] == "9x"){
                  system.win = "ME";
              } else {
                  system.win = RegExp["$1"];
              }
         }
    } 

    // 移动设备
    system.iphone = ua.indexOf("iPhone") > -1;
    system.ipod = ua.indexOf("iPod") > -1;
    system.ipad = ua.indexOf("iPad") > -1;
    system.nokiaN = ua.indexOf("NokiaN") > -1; 

    // windows mobile
    if (system.win == "CE"){
        system.winMobile = system.win;
    } else if (system.win == "Ph"){
        if(/Windows Phone OS (\d+.\d+)/.test(ua)){;
            system.win = "Phone";
            system.winMobile = parseFloat(RegExp["$1"]);
        }
    } 

    // 检测 iOS 版本
    if (system.mac && ua.indexOf("Mobile") > -1){
        if (/CPU (?:iPhone )?OS (\d+_\d+)/.test(ua)){
            system.ios = parseFloat(RegExp.$1.replace("_", "."));
        } else {
            system.ios = 2; //不能真正检测出来,所以只能猜测
        }
    } 

    // 检测 Android 版本
    if (/Android (\d+\.\d+)/.test(ua)){
        system.android = parseFloat(RegExp.$1);
    } 

    // 游戏系统
    system.wii = ua.indexOf("Wii") > -1;
    system.ps = /playstation/i.test(ua); 
  
    // 返回这些对象
    return {
        engine: engine,
        browser: browser,
        system: system 
    };
}(); 

3.4、使用方法

  我们在前面已经强调过了,用户代理检测是客户端检测的最后一个选择。只要可能,都应该优先采用能力检测和怪癖检测。
  用户代理检测一般适用于下列情形。

  • 不能直接准确地使用能力检测或怪癖检测。例如,某些浏览器实现了为将来功能预留的存根(stub)函数。在这种情况下,仅测试相应的函数是否存在还得不到足够的信息。
  • 同一款浏览器在不同平台下具备不同的能力。这时候,可能就有必要确定浏览器位于哪个平台下。
  • 为了跟踪分析等目的需要知道确切的浏览器。

小结

  客户端检测是 JavaScript 开发中最具争议的一个话题。由于浏览器间存在差别,通常需要根据不同浏览器的能力分别编写不同的代码。

  有不少客户端检测方法,但下列是最经常使用的。

  • 能力检测:在编写代码之前先检测特定浏览器的能力。例如,脚本在调用某个函数之前,可能要先检测该函数是否存在。这种检测方法将开发人员从考虑具体的浏览器类型和版本中解放出来,让他们把注意力集中到相应的能力是否存在上。能力检测无法精确地检测特定的浏览器和版本。
  • 怪癖检测:怪癖实际上是浏览器实现中存在的 bug,例如早期的 WebKit 中就存在一个怪癖,即它会在 for-in 循环中返回被隐藏的属性。怪癖检测通常涉及到运行一小段代码,然后确定浏览器是否存在某个怪癖。由于怪癖检测与能力检测相比效率更低,因此应该只在某个怪癖会干
    扰脚本运行的情况下使用。怪癖检测无法精确地检测特定的浏览器和版本。
  • 用户代理检测:通过检测用户代理字符串来识别浏览器。用户代理字符串中包含大量与浏览器有关的信息,包括浏览器、平台、操作系统及浏览器版本。用户代理字符串有过一段相当长的发展历史,在此期间,浏览器提供商试图通过在用户代理字符串中添加一些欺骗性信息,欺骗网站相信自己的浏览器是另外一种浏览器。用户代理检测需要特殊的技巧,特别是要注意 Opera 会隐瞒其用户代理字符串的情况。即便如此,通过用户代理字符串仍然能够检测出浏览器所用的呈现引擎以及所在的平台,包括移动设备和游戏系统。

  在决定使用哪种客户端检测方法时,一般应优先考虑使用能力检测。怪癖检测是确定应该如何处理代码的第二选择。而用户代理检测则是客户端检测的最后一种方案,因为这种方法对用户代理字符串具
有很强的依赖性。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,599评论 18 139
  • 本章内容 使用能力检测 用户代理检测的历史 选择检测方式 9.1 能力检测 能力检测的基本模式如下: 举例子,IE...
    闷油瓶小张阅读 239评论 0 1
  • 客户端检测是JavaScript开发中最具争议的一个话题。由于浏览器间存在差别,通常需要根据不同浏览器的能力分别编...
    shanruopeng阅读 427评论 2 1
  • 说到阿房宫,就不得不说杜牧的《阿房宫赋》:“覆压三百余里,隔离天日。骊山北构而西折,直走咸阳。二川溶溶,流...
    未央的视界阅读 587评论 0 1
  • 我有一个梦想,如果有一天,我和朋友一起去玩耍,无论旅行还是喝酒,都可以心无旁骛的享受当下的快乐,然而现实却总是不...
    志輝阅读 326评论 0 0