第9章 客户端检测

来源:互联网 发布:淘宝孕妇装秋装 编辑:程序博客网 时间:2024/05/28 18:44
 

浏览器提供商虽然在实现公共接口方面投入了很多精力,但结果仍然是每一种浏览器都有各自的长处,也都有各自的缺点。即使是那些跨平台的浏览器,虽然从技术上看版本相同,也照样存在不一致性问题。面对普遍存在的不一致性问题,开发人员要么采取迁就各方的 "最小公分母" 策略,要么 (也是更常见的) 就得利用各种客户端检测方法,来突破或者规避种种局限性。

迄今为止,客户端检测仍然是 Web 开发领域中一个饱受争议的话题。一谈到这个话题,人们总会不约而同地提到浏览器应该支持一组最常用的公共功能。在理想状态下,确实应该如此。但是,在现实当中,浏览器之间的差异以及不同浏览器的 "怪癖" (quirk) ,多得简直不胜枚举。因此,客户端检测除了是一种补救措施之外,更是一种行之有效的开发策略。

检测Web客户端的手段很多,而且各有利弊。但最重要的还是要知道,不到万不得已,就不要使用客户端检测。只要能找到更通用的方法,就应该优先采用更通用的方法。一言以蔽之,先设计最通用的方案,然后再使用特定于浏览器的技术增强该方案。

9.1 能力检测

最常用也最为人们广泛接受的客户端检测形式是能力检测 (又称特性检测)。能力检测的目标不是识别特定的浏览器,而是识别浏览器的能力。采用这种方式不必顾及特定的浏览器如何如何,只要确定浏览器支持特定的能力,就可以给出解决方案。能力检测的基本模式如下:

if (object.propertyInQuestion) {

// 使用 object.propertyInQuestion

}

举例来说,IE 5.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 。第8章曾经讨论过,IE确实不支持 window.innerWidth 属性。但问题是 document.all 存在也不一定表示浏览器就是 IE。实际上,也可能是 Opera ;Opera 支持 document.all ,也支持 window.innerWidth 。

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

// 错误!还不够具体

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);

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

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

9.2 怪癖检测

与能力检测类似,怪癖检测 (quirks detection) 的目标是识别浏览器的特殊行为。但与能力检测确认浏览器支持什么能力不同,怪癖检测是想要知道浏览器存在什么缺陷("怪癖"也就是 bug)。这通常需要运行一小段代码,以确定某一特性不能正常工作。例如,IE中存在一个 bug ,即如果某个实例属性与标记为 [[DontEnum]] 的某个原型属性同名,那么该实例属性将不会出现在 for-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.0 以前版本会枚举被隐藏的属性。可以用下面的函数来检测该 “怪癖”:

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 。在相关浏览器的新版本中,这些问题可能会也可能不会被修复。由于检测 "怪癖" 涉及运行代码,因此我们建议仅检测那些对你有直接影响的 "怪癖" ,而且最好在脚本一开始就执行此类检测,以便尽早解决问题。

9.3 用户代理检测

第三种,也是争议最大的一种客户端检测技术叫做用户代理检测。用户代理检测通过检测用户代理字符串来确定实际使用的浏览器。在每一次HTTP请求过程中,用户代理字符串是作为响应首部发送的,而且该字符串可以通过 JavaScript 的 navigator.userAgent 属性访问。在服务器端,通过检测用户代理字符串来确定用户使用的浏览器是一种常用而且广为接受的做法。而在客户端,用户代理检测一般被当作一种万不得已才用的做法,其优先级排在能力检测和 (或) 怪癖检测之后。

提到与用户代理字符串有关的争议,就不得不提到电子欺骗 (spoofing) 。所谓电子欺骗,就是指浏览器通过在自己的用户代理字符串加入一些错误或误导性信息,来达到欺骗服务器的目的。要弄清楚这个问题的来龙去脉,必须从 Web 问世初期用户代理字符串的发展讲起。

9.3.1 用户代理字符串的历史

HTTP 规范 (包括 1.0 和 1.1版) 明确规定,浏览器应该发送简短的用户代理字符串,指明浏览器的名称和版本号。RFC 2616 (即 HTTP 1.1协议规范) 是这样描述用户代理字符串的:

“产品标识符常用于通信应用程序标识自身,由软件名和版本组成。使用产品标识符的大多数领域也允许列出作为应用程序主要部分的子产品,由空格分隔。按照惯例,产品要按照相应的重要程度依次列出,以便标识应用程序。”

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

9.3.2 用户代理字符串检测技术

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

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

// 代码

}

这个例子是想要在浏览器为 IE6 或 7 时执行相应代码。这种代码其实是很脆弱的,因为它要依据特定的版本来决定做什么。如果是 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 中,将浮点数值形式的版本保存在 engine.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) || /Konqueror\/([^;])+/.test(ua)) {

engine.ver = RegExp["$1"];

engine.khtml = pareseFloat(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) || /Konqueror\/([^;])+/.test(ua)) {

engine.ver = RegExp["$1"];

engine.khtml = pareseFloat(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 = browser.ver = RegExp["$1"];

engine.ie = browser.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,

konq: 0,

opera: 0,

chrome: 0,

safari: 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);

}

原创粉丝点击