{T}

客户端检测

虽然浏览器厂商齐心协力想要实现一致的接口,但事实上仍然是每家浏览器都有自己的长处与不足。跨平台的浏览器尽管版本相同,但总会存在不同的问题。这些差异迫使 Web 开发者要么面向最大公约数而设计,要么(更常见地)使用各种方法来检测客户端,以克服或避免这些缺陷。

客户端检测一直是 Web 开发中饱受争议的话题,这些话题普遍围绕所有浏览器应支持一系列公共特性,理想情况下是这样的。而现实当中,浏览器之间的差异和莫名其妙的行为,让客户端检测变成一种补救措施,而且也成为了开发策略的重要一环。

要检测当前的浏览器有很多方法,每一种都有各自的长处和不足。问题的关键在于知道客户端检测应该是解决问题的最后一个举措。任何时候,只要有更普适的方案可选,都应该毫不犹豫地选择。首先要设计最常用的方案,然后再考虑为特定的浏览器进行补救。

能力检测

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

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

比如 IE5 的早期版本中确实不存在 document.getElementById() 这个 DOM 方法,但可以通过 document.all 属性实现同样的功能。为此,可以进行如下能力检测:

javascript
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!");
  }
}

要理解能力检测,首先必须理解两个重要的概念。如前所述,第一个概念就是先检测达成目的的最常用的特性。对前面的例子来说,就是要先检测 document.getElementById(),后检测 document.all

先检测最常用的特性可以保证代码最优化,因为在多数情况下都可以避免测试多个条件

安全能力检测

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

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

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

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

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

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

在可能的情况下,要尽量使用 typeof 进行能力检测。

基于能力检测进行浏览器分析

虽然可能有人觉得能力检测类似于黑科技,但恰当地使用能力检测可以精准地分析运行代码的浏览器。使用能力检测而非用户代理检测的优点在于,伪造用户代理字符串很简单,而伪造能够欺骗能力检测的浏览器特性却很难。

检测特性

可以按照能力将浏览器归类。如果你的应用程序需要使用特定的浏览器能力,那么最好集中检测所有能力,而不是等到用的时候再重复检测。比如

js
// 检测浏览器是否支持 Netscape 式的插件
let hasNSPlugins = !!(navigator.plugins && navigator.plugins.length); 

// 检测浏览器是否具有 DOM Level 1 能力
let hasDOM1 = !!(document.getElementById && document.createElement && document.getElementsByTagName);

这个例子完成了两项检测:一项是确定浏览器是否支持 Netscape 式的插件,另一项是检测浏览器是否具有 DOM Level 1 能力。保存在变量中的布尔值可以用在后面的条件语句中,这样比重复检测省事多了

检测浏览器

可以根据对浏览器特性的检测并与已知特性对比,确认用户使用的是什么浏览器。这样可以获得比用户代码嗅探(稍后讨论)更准确的结果。但未来的浏览器版本可能不适用于这套方案。

下面来看一个例子,根据不同浏览器独有的行为推断出浏览器的身份。这里故意没有使用 navigator.userAgent 属性,后面会讨论它:

js
class BrowserDetector { 
  constructor() { 
    // 测试条件编译
    // IE6~10 支持
    this.isIE_Gte6Lte10 = /*@cc_on!@*/false; 
    
    // 测试 documentMode 
    // IE7~11 支持
    this.isIE_Gte7Lte11 = !!document.documentMode;
    
    // 测试 StyleMedia 构造函数
    // Edge 20 及以上版本支持
    this.isEdge_Gte20 = !!window.StyleMedia; 
    
    // 测试 Firefox 专有扩展安装 API 
    // 所有版本的 Firefox 都支持
    this.isFirefox_Gte1 = typeof InstallTrigger !== 'undefined';
    
    // 测试 chrome 对象及其 webstore 属性
    // Opera 的某些版本有 window.chrome,但没有 window.chrome.webstore 
    // 所有版本的 Chrome 都支持
    this.isChrome_Gte1 = !!window.chrome && !!window.chrome.webstore; 
    // Safari 早期版本会给构造函数的标签符追加"Constructor"字样,如:
    // window.Element.toString(); // [object ElementConstructor] 
    // Safari 3~9.1 支持
    this.isSafari_Gte3Lte9_1 = /constructor/i.test(window.Element); 
    // 推送通知 API 暴露在 window 对象上
    // 使用默认参数值以避免对 undefined 调用 toString() 
    // Safari 7.1 及以上版本支持
    this.isSafari_Gte7_1 = 
      (({pushNotification = {}} = {}) => 
       pushNotification.toString() == '[object SafariRemoteNotification]' 
      )(window.safari); 
    // 测试 addons 属性
    // Opera 20 及以上版本支持
    this.isOpera_Gte20 = !!window.opr && !!window.opr.addons; 
  } 
  isIE() { return this.isIE_Gte6Lte10 || this.isIE_Gte7Lte11; } 
  isEdge() { return this.isEdge_Gte20 && !this.isIE(); } 
  isFirefox() { return this.isFirefox_Gte1; } 
  isChrome() { return this.isChrome_Gte1; } 
  isSafari() { return this.isSafari_Gte3Lte9_1 || this.isSafari_Gte7_1; } 
  isOpera() { return this.isOpera_Gte20; } 
}

这个类暴露的通用浏览器检测方法使用了检测浏览器范围的能力测试。随着浏览器的变迁及发展,可以不断调整底层检测逻辑,但主要的 API 可以保持不变

能力检测的局限

通过检测一种或一组能力,并不总能确定使用的是哪种浏览器。以下“浏览器检测”代码(或其他类似代码)经常出现在很多网站中,但都没有正确使用能力检测:

js
// 不要这样做!不够特殊
let isFirefox = !!(navigator.vendor && navigator.vendorSub); 

// 不要这样做!假设太多
let isIE = !!(document.all && document.uniqueID); 

这是错误使用能力检测的典型示例。过去,Firefox 可以通过 navigator.vendor 和 navigator.vendorSub 来检测,但后来 Safari 也实现了同样的属性,于是这段代码就会产生误报。

为确定 IE,这段代码检测了 document.all 和 document.uniqueID。这是假设 IE 将来的版本中还会继续存在这两个属性,而且其他浏览器也不会实现它们。不过这两个检测都使用双重否定操作符来产生布尔值(这样可以生成便于存储和访问的结果)

用户代理检测

用户代理检测通过浏览器的用户代理字符串确定使用的是什么浏览器。用户代理字符串包含在每个 HTTP 请求的头部,在 JavaScript 中可以通过 navigator.userAgent 访问。在服务器端,常见的做法是根据接收到的用户代理字符串确定浏览器并执行相应操作。而在客户端,用户代理检测被认为是不可靠的,只应该在没有其他选项时再考虑

用户代理字符串最受争议的地方就是,在很长一段时间里,浏览器都通过在用户代理字符串包含错误或误导性信息来欺骗服务器

浏览器分析

想要知道自己代码运行在什么浏览器上,大部分开发者会分析 window.navigator.userAgent 返回的字符串值。所有浏览器都会提供这个值,如果相信这些返回值并基于给定的一组浏览器检测这个字符串,最终会得到关于浏览器和操作系统的比较精确的结果。

相比于能力检测,用户代理检测还是有一定优势的。能力检测可以保证脚本不必理会浏览器而正常执行。现代浏览器用户代理字符串的过去、现在和未来格式都是有章可循的,我们能够利用它们准确识别浏览器。

伪造用户代理

通过检测用户代理来识别浏览器并不是完美的方式,毕竟这个字符串是可以造假的。只不过实现 window.navigator 对象的浏览器(即所有现代浏览器)都会提供 userAgent 这个只读属性。因此,简单地给这个属性设置其他值不会有效:

js
console.log(window.navigator.userAgent); 
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36 

window.navigator.userAgent = 'foobar'; 
console.log(window.navigator.userAgent); 
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)Chrome/65.0.3325.181 Safari/537.36

不过,通过简单的办法可以绕过这个限制。比如,有些浏览器提供伪私有的 __defineGetter__ 方法,利用它可以篡改用户代理字符串:

js
console.log(window.navigator.userAgent); 
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36 

window.navigator.__defineGetter__('userAgent', () => 'foobar'); 
console.log(window.navigator.userAgent); 
// foobar 

对付这种造假是一件吃力不讨好的事。检测用户代理是否以这种方式被篡改过是可能的,但总体来看还是一场猫捉老鼠的游戏。

与其劳心费力检测造假,不如更好地专注于浏览器识别。如果相信浏览器返回的用户代理字符串,那就可以用它来判断浏览器。如果怀疑脚本或浏览器可能篡改这个值,那最好还是使用能力检测

分析浏览器

通过解析浏览器返回的用户代理字符串,可以极其准确地推断出下列相关的环境信息:

  • 浏览器
  • 浏览器版本
  • 浏览器渲染引擎
  • 设备类型(桌面/移动)
  • 设备生产商
  • 设备型号
  • 操作系统
  • 操作系统版本

当然,新浏览器、新操作系统和新硬件设备随时可能出现,其中很多可能有着类似但并不相同的用户代理字符串。因此,用户代理解析程序需要与时俱进,频繁更新,以免落伍。自己手写的解析程序如果不及时更新或修订,很容易就过时了。本书上一版写过一个用户代理解析程序,但这一版并不推荐读者自己从头再写一个。相反,这里推荐一些 GitHub 上维护比较频繁的第三方用户代理解析程序:

  • Bowser
  • UAParser.js
  • Platform.js
  • CURRENT-DEVICE
  • Google Closure
  • Mootools

软件与硬件检测

现代浏览器提供了一组与页面执行环境相关的信息,包括浏览器、操作系统、硬件和周边设备信息。这些属性可以通过暴露在 window.navigator 上的一组 API 获得。不过,这些 API 的跨浏览器支持还不够好,远未达到标准化的程度

识别浏览器与操作系统

特性检测和用户代理字符串解析是当前常用的两种识别浏览器的方式。而 navigator 和 screen 对象也提供了关于页面所在软件环境的信息

navigator.oscpu 属性是一个字符串,通常对应用户代理字符串中操作系统/系统架构相关信息。

根据 HTML 实时标准:oscpu 属性的获取方法必须返回空字符串或者表示浏览器所在平台的字符串,比如 "Windows NT 10.0; Win64; x64" 或 "Linux x86_64"。

比如,Windows 10 上的 Firefox 的 oscpu 属性应该对应于以下加粗的部分:

js
console.log(navigator.userAgent); 
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0" 

console.log(navigator.oscpu); 
// "Windows NT 10.0; Win64; x64" 

navigator.vendor 属性是一个字符串,通常包含浏览器开发商信息。返回这个字符串是浏览器 navigator 兼容模式的一个功能。根据 HTML 实时标准:

navigator.vendor 返回一个空字符串,也可能返回字符串 "Apple Computer, Inc." 或字符串 "Google Inc."。

例如,Chrome 中的这个 navigator.vendor 属性返回下面的字符串 console.log(navigator.vendor); // "Google Inc."

navigator.platform 属性是一个字符串,通常表示浏览器所在的操作系统。根据 HTML 实时标准:navigator.platform 必须返回一个字符串或表示浏览器所在平台的字符串,例如 "MacIntel"、"Win32"、"FreeBSD i386" 或 "WebTV OS"

例如,Windows 系统下 Chrome 中的这个 navigator.platform 属性返回下面的字符串:

js
console.log(navigator.platform);  // "Win32"

screen.orientation

screen.orientation 属性返回一个 ScreenOrientation 对象,其中包含 Screen Orientation API 定义的屏幕信息。这里面最有意思的属性是 angle 和 type,前者返回相对于默认状态下屏幕的角度,后者返回以下 4 种枚举值之一:

  • portrait-primary
  • portrait-secondary
  • landscape-primary
  • landscape-secondary

例如,在 Chrome 移动版中,screen.orientation 返回的信息如下:

js
// 垂直看
console.log(screen.orientation.type); // portrait-primary 
console.log(screen.orientation.angle); // 0 

// 向左转
console.log(screen.orientation.type); // landscape-primary 
console.log(screen.orientation.angle); // 90 

// 向右转
console.log(screen.orientation.type); // landscape-secondary 
console.log(screen.orientation.angle); // 270 

根据规范,这些值的初始化取决于浏览器和设备状态。因此,不能假设 portrait-primary 和 0 始终是初始值。这两个值主要用于确定设备旋转后浏览器的朝向变化

浏览器元数据

navigator 对象暴露出一些 API,可以提供浏览器和操作系统的状态信息。

Geolocation API

navigator.geolocation 属性暴露了 Geolocation API,可以让浏览器脚本感知当前设备的地理位置。这个 API 只在安全执行环境(通过 HTTPS 获取的脚本)中可用。

这个 API 可以查询宿主系统并尽可能精确地返回设备的位置信息。根据宿主系统的硬件和配置,返回结果的精度可能不一样。手机 GPS 的坐标系统可能具有极高的精度,而 IP 地址的精度就要差很多

要获取浏览器当前的位置,可以使用 getCurrentPosition()方法。这个方法返回一个 Coordinates 对象,其中包含的信息不一定完全依赖宿主系统的能力:

js
// getCurrentPosition() 会以 position 对象为参数调用传入的回调函数
navigator.geolocation.getCurrentPosition((position) => p = position); 

这个 position 对象中有一个表示查询时间的时间戳,以及包含坐标信息的 Coordinates 对象:

js
console.log(p.timestamp); // 1525364883361 
console.log(p.coords); // Coordinates {...} 

Coordinates 对象中包含标准格式的经度和纬度,以及以米为单位的精度。精度同样以确定设备位置的机制来判定。

js
console.log(p.coords.latitude, p.coords.longitude); // 37.4854409, -122.2325506 

console.log(p.coords.accuracy); // 58 

Coordinates 对象包含一个 altitude(海拔高度)属性,是相对于 1984 世界大地坐标系(World Geodetic System,1984)地球表面的以米为单位的距离。此外也有一个 altitudeAccuracy 属性,这个精度值单位也是米。为了取得 Coordinates 中包含的这些信息,当前设备必须具备相应的能力(比如 GPS 或高度计)。很多设备因为没有能力测量高度,所以这两个值经常有一个或两个是空的。

js
console.log(p.coords.altitude); // -8.800000190734863 
console.log(p.coords.altitudeAccuracy); // 200 

Coordinates 对象包含一个 speed 属性,表示设备每秒移动的速度。还有一个 heading(朝向)属性,表示相对于正北方向移动的角度(0 ≤ heading < 360)。为获取这些信息,当前设备必须具备相应的能力(比如加速计或指南针)。很多设备因为没有能力测量高度,所以这两个值经常有一个是空的,或者两个都是空的。

注意 设备不会根据两点的向量来测量速度和朝向。不过,如果可能的话,可以尝试基于两次连续的测量数据得到的向量来手动计算。当然,如果向量的精度不够,那么计算结果的精度肯定也不够。

获取浏览器地理位置并不能保证成功。因此 getCurrentPosition() 方法也接收失败回调函数作为第二个参数,这个函数会收到一个 PositionError 对象。在失败的情况下,PositionError 对象中会包含一个 code 属性和一个 message 属性,后者包含对错误的简短描述。code 属性是一个整数,表示以下 3 种错误

  • PERMISSION_DENIED:浏览器未被允许访问设备位置。页面第一次尝试访问 Geolocation API 时,浏览器会弹出确认对话框取得用户授权(每个域分别获取)。如果返回了这个错误码,则要么是用户不同意授权,要么是在不安全的环境下访问了 Geolocation API。message 属性还会提供额外信息
  • POSITION_UNAVAILABLE:系统无法返回任何位置信息。这个错误码可能代表各种失败原因,但相对来说并不常见,因为只要设备能上网,就至少可以根据 IP 地址返回一个低精度的坐标
  • TIMEOUT:系统不能在超时时间内返回位置信息。关于如何配置超时,会在后面介绍
js
// 浏览器会弹出确认对话框请用户允许访问 Geolocation API 
// 这个例子显示了用户拒绝之后的结果 
navigator.geolocation.getCurrentPosition( 
  () => {}, 
  (e) => { 
    console.log(e.code); // 1 
    console.log(e.message); // User denied Geolocation 
  } 
); 
// 这个例子展示了在不安全的上下文中执行代码的结果
navigator.geolocation.getCurrentPosition( 
  () => {}, 
  (e) => { 
    console.log(e.code); // 1 
    console.log(e.message); // Only secure origins are allowed 
  } 
);

Connection State 和 NetworkInformation API

浏览器会跟踪网络连接状态并以两种方式暴露这些信息:连接事件和 navigator.onLine 属性。

在设备连接到网络时,浏览器会记录这个事实并在 window 对象上触发 online 事件。相应地,当设备断开网络连接后,浏览器会在 window 对象上触发 offline 事件。任何时候,都可以通过 navigator.

onLine 属性来确定浏览器的联网状态。这个属性返回一个布尔值,表示浏览器是否联网。

js
const connectionStateChange = () => console.log(navigator.onLine); 
window.addEventListener('online', connectionStateChange); 
window.addEventListener('offline', connectionStateChange); 

// 设备联网时:
// true 

// 设备断网时:
// false 

当然,到底怎么才算联网取决于浏览器与系统实现。有些浏览器可能会认为只要连接到局域网就算“在线”,而不管是否真正接入了互联网。

navigator 对象还暴露了 NetworkInformation API,可以通过 navigator.connection 属性使用。这个 API 提供了一些只读属性,并为连接属性变化事件处理程序定义了一个事件对象

以下是 NetworkInformation API 暴露的属性。

  • downlink:整数,表示当前设备的带宽(以 Mbit/s 为单位),舍入到最接近的 25kbit/s。这个值可能会根据历史网络吞吐量计算,也可能根据连接技术的能力来计算。

  • downlinkMax:整数,表示当前设备最大的下行带宽(以 Mbit/s 为单位),根据网络的第一跳来确定。因为第一跳不一定反映端到端的网络速度,所以这个值只能用作粗略的上限值。

  • effectiveType:字符串枚举值,表示连接速度和质量。这些值对应不同的蜂窝数据网络连接技术,但也用于分类无线网络。这个值有以下 4 种可能

    • slow-2g

      • 往返时间 > 2000ms
      • 下行带宽 < 50kbit/s
    • 2g

      • 2000ms > 往返时间 ≥ 1400ms

      • 70kbit/s > 下行带宽 ≥ 50kbit/s

    • 3g

      • 1400ms > 往返时间 ≥ 270ms

      • 700kbit/s > 下行带宽 ≥ 70kbit/s

    • 4g

      • 270ms > 往返时间 ≥ 0ms

      • 下行带宽 ≥ 700kbit/s

  • rtt:毫秒,表示当前网络实际的往返时间,舍入为最接近的 25 毫秒。这个值可能根据历史网络吞吐量计算,也可能根据连接技术的能力来计算。

  • type:字符串枚举值,表示网络连接技术。这个值可能为下列值之一

    • bluetooth:蓝牙

    • cellular:蜂窝

    • ethernet:以太网

    • none:无网络连接。相当于 navigator.onLine === false

    • mixed:多种网络混合。

    • other:其他。

    • unknown:不确定。

    • wifi:Wi-Fi。

    • wimax:WiMAX。

  • saveData:布尔值,表示用户设备是否启用了“节流”(reduced data)模式。

  • onchange:事件处理程序,会在任何连接状态变化时激发一个 change 事件。可以通过 navigator.connection.addEventListener('change',changeHandler)或 navigator.connection.onchange = changeHandler 等方式使用

Battery Status API

浏览器可以访问设备电池及充电状态的信息。navigator.getBattery() 方法会返回一个 Promise 实例,解决为一个 BatteryManager 对象

js
navigator.getBattery().then((b) => console.log(b)); 

// BatteryManager { ... } 

BatteryManager 包含 4 个只读属性,提供了设备电池的相关信息。

  • charging:布尔值,表示设备当前是否正接入电源充电。如果设备没有电池,则返回 true
  • chargingTime:整数,表示预计离电池充满还有多少秒。如果电池已充满或设备没有电池,则 返回 0
  • dischargingTime:整数,表示预计离电量耗尽还有多少秒。如果设备没有电池,则返回 Infinity
  • level:浮点数,表示电量百分比。电量完全耗尽返回 0.0,电池充满返回 1.0。如果设备没有电池,则返回 1.0

这个 API 还提供了 4 个事件属性,可用于设置在相应的电池事件发生时调用的回调函数。可以通过给 BatteryManager 添加事件监听器,也可以通过给事件属性赋值来使用这些属性。

  • onchargingchange
  • onchargingtimechange
  • ondischargingtimechange
  • onlevelchange
js
navigator.getBattery().then((battery) => { 

 // 添加充电状态变化时的处理程序
 const chargingChangeHandler = () => console.log('chargingchange'); 
 battery.onchargingchange = chargingChangeHandler; 

 // 或
 battery.addEventListener('chargingchange', chargingChangeHandler); 

 // 添加充电时间变化时的处理程序
 const chargingTimeChangeHandler = () => console.log('chargingtimechange'); 
 battery.onchargingtimechange = chargingTimeChangeHandler; 

 // 或
 battery.addEventListener('chargingtimechange', chargingTimeChangeHandler); 

 // 添加放电时间变化时的处理程序
 const dischargingTimeChangeHandler = () => console.log('dischargingtimechange'); 
 battery.ondischargingtimechange = dischargingTimeChangeHandler; 

 // 或
 battery.addEventListener('dischargingtimechange', dischargingTimeChangeHandler); 
// 添加电量百分比变化时的处理程序
 const levelChangeHandler = () => console.log('levelchange'); 
 battery.onlevelchange = levelChangeHandler; 
 // 或
 battery.addEventListener('levelchange', levelChangeHandler); 
}); 

硬件

浏览器检测硬件的能力相当有限。不过,navigator 对象还是通过一些属性提供了基本信息

  • 处理器核心数
    • navigator.hardwareConcurrency 属性返回浏览器支持的逻辑处理器核心数量,包含表示核心数的一个整数值(如果核心数无法确定,这个值就是 1)。关键在于,这个值表示浏览器可以并行执行的最大工作线程数量,不一定是实际的 CPU 核心数。
  • 设备内存大小
    • navigator.deviceMemory 属性返回设备大致的系统内存大小,包含单位为 GB 的浮点数(舍入为最接近的 2 的幂:512MB 返回 0.5,4GB 返回 4)
  • 最大触点数
    • navigator.maxTouchPoints 属性返回触摸屏支持的最大关联触点数量,包含一个整数值