特征检测而不是浏览器检测!

正如上一篇文章所述,最近趁着有空把 jQuery 的浏览器模块抽取出来,再修改成独立简洁的一个对象 —— support,可以方便地使用 JavaScript 进行特征检测。

是的,这里要介绍的,是特征检测而不是浏览器检测!

由于 JavaScript 在不同的浏览器中存在差异,尤其是不同内核的浏览器之间的差异更是明显,因此在传统的前端开发中,在实现某些功能的过程中会判断浏览器的型号和版本,再实现一个跨浏览器的解决方案。然而这样做有一个危险的潜在问题——判断浏览器是为了使用不同的属性,但传统的 JavaScript 判断浏览器方式往往是根据 user-agent 判断,这只能间接判断出浏览器是否支持相应的功能,但结果并不一定准确。而且随着浏览器的升级,浏览器对于不同功能的支持情况也会变更,最终就会给开发带来隐患。因此,更为直接有效的办法应该是直接检测相应的特征是否被支持,对于一些稳定的属性,更可以用于倒推浏览器型号和版本。

这样描述似乎很乱?还是写几个例子吧。

例子一:JavaScript 改变元素透明度

改变元素的透明度首先想到了 opacity 属性,但 IE8 及以下版本并不支持 opacity 属性,需要用 filter 滤镜代替,传统开发中,可能会这样做

// 获取元素
var ex = document.getElementById('test');

// 首先要判断浏览器类型
// 获取浏览器类型
// ...
// 根据结果进入不同的逻辑
if( theBrowser == 'IE8' || theBrowser == 'IE7' || theBrowser == 'IE6' ){

	ex.style.filter = 'alpha(opacity=50)';

} else {

	ex.style.opacity = '0.5';
}

这样的写法没有问题,效果也没有问题,但是为了改变透明值,首先需要判断出浏览器型号和版本,这部分实际上作出了大量不必要的处理,相当浪费。因为根据例子的需求,只需要识别 IE8 及以下版本即可,但实际上利用 user-agent 等传统方法并不能单独识别 IE8 及以下版本,必须加入其他处理,从而实际上已经识别出大量浏览器了,造成浪费。

如果改为特征检测,则不会有那么多不必要的处理:

// 获取元素
var ex = document.getElementById('test');

// 判断是否支持 opacity
if( 'opacity' in ex.style ){

	ex.style.opacity = '0.5';

} else {

	ex.style.filter = 'alpha(opacity=50)';
}

这里需要注意,Kayo 建议使用 "'prop' in element" 作为特征检测的形式,而不是 "element.prop != undefined" 的形式,因为前者会有更高的效率,同时还能避免在某些浏览器下的内存泄漏问题。

例子二:JavaScript 使用 transform 属性

CSS3 transform 的浏览器支持情况比较复杂,Firefox, Opera 和 IE9+ 可以直接支持,webkit 内核则需要添加私有前缀而由 webkit 派生出的 Blink 则不需要,因此在 Chrome 27 及以下版本(webkit 内核)需要添加私有前缀以支持 transform,高级版本(Blink 内核)则不需要,这时特征检测方式将明显更加有效,因为它不会随浏览器对 transform 的支持情况发生改变而导致原有的判断失效。

// 获取元素
var ex = document.getElementById('test');

var adaptTransform = function(){
	
	// 直接检测 transform 是否被支持,支持则直接返回 'transform'
	if( 'transform' in document.body.style ){
			
		return 'transform';
			
	} else {
			
		var _result;
		
		// 不直接支持 transform 属性则加上前缀再检测
		['webkit', 'moz', 'o', 'ms'].forEach(function(prefix){
		 
			if( prefix + 'Transform' in document.body.style ){
				_result = prefix + 'Transform';
			}
		});
		    
		return _result;			
	}

}();

ex.style[adaptTransform] = 'scale(.5)';

这样直接检测属性是否被支持,不但准确,也不需要复杂的浏览器判断(包括浏览器型号和版本的判断),由于是直接判断属性是否被支持,因而当浏览器对这个属性的支持情况发生改变时也不需要改变原有的逻辑。

接下来,再介绍利用属性倒推浏览器版本和型号的原理。

不同于 transform 等较新的属性,浏览器对于某些稳定的属性会有固定的表现,因此可以利用一些稳定属性判断浏览器版本和型号(不能完全区分但实用,可以看下面例子),这样得出的浏览器版本会更加准确,实现逻辑也会更加简洁。

但是上面不是介绍了直接特征检测已经不错了,为什么又要反推浏览器版本呢?

因为一个项目中需要用到的属性有很多,如果每个具有浏览器差异的属性都进行判断,那么必然会造成大量的多余消耗,而这些属性中确实有很多属性具有相同的浏览器兼容情况,因此判断浏览器版本再使用某些属性会减轻消耗。

jQuery.support 模块就是采取这种实现方式,并且为了使到这个检查过程比较简单而精确,它做了很多简单有效的测试,根据一些 JavaScript 的在不同浏览器中的不同表现间接判断出浏览器类型,或者直接根据判断结果得知浏览器是否支持相应的方法。jQuery 源代码中选出了大概 30 个特性(不同的版本略有差异)作为功能检测的特征值。

但是,这并不意味着对所有开发者来说,直接把 jQuery 中的这部分抽取出来使用就是最好的。相反,由于这是 jQuery 的内部类,因此对于一些外部代码,这些代码会显得有些冗长,所以才需要自行修改这部分代码,对于 Kayo 修改的代码,主要包括以下几个方面:

  • 由于把代码从原本的一个 jQuery 模块抽取成一个独立的 JavaScript 对象,因此部分依赖于 jQuery 的代码必须使用原生 JavaScript 重写。
  • 表明每个特征检测的原理和结果(原本 jQuery 的代码中也有用注释表明原理,但是有些地方写得比较短,也不表明判断的结果,因此按照 jsDoc 的 JavaScript 注释规范重写注释)。
  • 由于这是 jQuery 中的内部对象,所以其中包含了很多 jQuery 内部实现时使用的特征检测,这些代码中包括某些属性是否被浏览器支持的判断,或者是避免一些浏览器 bugs 的影响。而对于独立于 jQuery 的项目,这部分代码也是不需要的,因而需要重新调整其中的逻辑,并把不需要的代码精简掉。
  • 删掉多余的特征检测。由于 Kayo 的目的是为了能得到一个用于浏览器判断的方案,因此一些判断结果相同的特征检测就删去了。

接下来,就是上代码了,关于每个特征的工作原理代码中都有注释,最后会有例子说明具体如何使用。

support = (function( support ){

    var all, a, input, select, fragment, opt,
        div = document.createElement("div");

    // 准备到用于特性测试的元素
    div.setAttribute( "className", "t" );
    div.innerHTML = "  <link/><table></table><a href='/a'>a</a><input type='checkbox'/>";

    all = div.getElementsByTagName("*") || [];
    a = div.getElementsByTagName("a")[ 0 ];
    if ( !a || !a.style || !all.length ){
        return support;
    }

    // 第一批测试元素
    select = document.createElement("select");
    opt = select.appendChild( document.createElement("option") );
    input = div.getElementsByTagName("input")[ 0 ];
 
    a.style.cssText = "top:1px;float:left;opacity:.5";

	/**
	 * @name getSetAttribute
	 * @description {Boolean} 利用上面设置 class 来测试 get/setAttribute('class', 'class') 方法是否被支持,
	 *     在 ie6/7 中不支持(使用 get/setAttribute('className', 'class'))
	 *     因此 ie6/7 中会返回 false,其他浏览器中返回 true
	 * @const
	 */
    support.getSetAttribute = div.className !== "t";

	/**
	 * @name leadingWhitespace
	 * @description {Boolean} IE8- 会去掉开头的空格,所以 nodeType 不是 3(文本),即 IE8+ 中返回 true
	 * @const
	 */
    support.leadingWhitespace = div.firstChild.nodeType === 3;

	/**
	 * @name tbody
	 * @description {Boolean} 保证 tbody 元素不会被自动插入
	 *     IE8- 会自动在空格 table 元素中插入,因此 length 不为 0,即 IE8- 中返回 false,其他浏览器返回 true
	 * @const
	 */
    support.tbody = !div.getElementsByTagName("tbody").length;

	/**
	 * @name htmlSerialize
	 * @description {Boolean} IE8- 不允许用这种方式插入 link 元素,即 IE8- 中返回 false,其他浏览器返回 true
	 * @const
	 */
    support.htmlSerialize = !!div.getElementsByTagName("link").length;

	/**
	 * @name style
	 * @description {Boolean} 使用 getAttribute 获取 style 信息
	 *     (IE8- 使用 .cssText 代替)
	 *     IE8- 中返回 false,其他浏览器返回 true
	 * @const
	 */
    support.style = /top/.test( a.getAttribute("style") );

	/**
	 * @name hrefNormalized
	 * @description {Boolean} 确保 URLs 不会被自动转换
	 *     IE6/7 默认会转换成  http:// 的形式
	 *     IE6/7 返回 false,其他浏览器返回 true
	 * @const
	 */
    support.hrefNormalized = a.getAttribute("href") === "/a";

	/**
	 * @name opacity
	 * @description {Boolean} 判断元素的 opacity 属性存在
	 *     IE8- 使用 filter 代替
	 *     另外使用正则表达式测试是为了避免一个 Webkit 下的错误
	 *     IE8- 返回 false,其他返回 true
	 * @const
	 */
    support.opacity = /^0.5/.test( a.style.opacity );

	/**
	 * @name cssFloat
	 * @description {Boolean} 验证 cssFloat 是否存在
	 *     (IE8- 会使用 styleFloat 代替 cssFloat)
	 *     IE8- 返回 false,其他浏览器返回 true
	 * @const
	 */
    support.cssFloat = !!a.style.cssFloat;

	/**
	 * @name checkOn
	 * @description {Boolean} 检查 checkbox/radio 的默认值,Webkit(不包括 Blink) 默认是 '',其他是 'on'
	 *     即 Webkit 返回 false ,其他浏览器中返回 true
	 * @const
	 */
    support.checkOn = !!input.value;

	/**
	 * @name optSelected
	 * @description {Boolean} 这个 select 只有一个 option 元素,所以渲染时,这个 option 是默认选中的。此时 selected 应该是 true
	 *     但 IE 中为 false
	 * @const
	 */
    support.optSelected = opt.selected;

	/**
	 * @name html5Clone
	 * @description {Boolean} 判断创建一个 HTML5 元素是否会出现问题
	 *     IE6 为 false
	 * @const
	 */
    support.html5Clone = document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav></:nav>";
 
    // 第二批测试特性
    
	/**
	 * @name deleteExpando
	 * @description {Boolean} IE8- 中返回 false
	 * @deprecated 不推荐使用,可以使用 leadingWhitespace 等相同效果的属性
	 */
    support.deleteExpando = true;

    support.noCloneEvent = true;
 
    // 判断 checked 状态被正确复制,IE 下返回 false,其他浏览器返回 true
    input.checked = true;
    support.noCloneChecked = input.cloneNode( true ).checked;
 
    // IE8- 中返回 false
    try {
        delete div.test;
    } catch( e ) {
        support.deleteExpando = false;
    }

	/**
	 * @name input
	 * @description 检查是否能获取到 value 属性值,IE8 中返回 false,其他浏览器返回 true
	 * @const
	 */
    input = document.createElement("input");
    input.setAttribute( "value", "" );
    support.input = input.getAttribute( "value" ) === "";

	/**
	 * @name radioValue
	 * @description 检查 input 在改变为 radio 类型后能否保持它的值,IE 中返回 false,其他浏览器返回 true
	 * @const
	 */
    input.value = "t";
    input.setAttribute( "type", "radio" );
    support.radioValue = input.value === "t";
 
    fragment = document.createDocumentFragment();
    fragment.appendChild( input );

	/**
	 * @name checkClone
	 * @description 在 fragments 中 Webkit(不包含 Blink ) 和 IE6/7 不会复制 checked 状态
	 *     因此 Webkit(不包含 Blink )和 IE6/7 中返回 false,其他浏览器返回 true
	 * @const
	 */
    support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked;

	/**
	 * @name noCloneEvent
	 * @description Opera 不会复制事件(另外 typeof div.attachEvent === undefined)
	 *     IE9/10 必然通过 attachEvent 复制事件,但是它们不能触发 .click()
	 *     IE8- 下返回 false,其他浏览器返回 true
	 * @const
	 */
    if ( div.attachEvent ) {
        div.attachEvent( "onclick", function() {
            support.noCloneEvent = false;
        });
 
        div.cloneNode( true ).click();
    }
 
    // Null elements to avoid leaks in IE
    div = all = select = fragment = opt = a = input = null;
 
    return support;
})({});

可以看出,这段代码体积很小,并且经过调整,代码的逻辑变得简单,方便开发者根据自身项目需求增删代码,从而改变这个 support 的功能。另外,由于 support 是根据浏览器特征进行判断,因而实际使用时可以根据项目需求自由组合,下面补上几个例子:

// 判断为 IE8 或其以下版本就执行操作
if( support.leadingWhitespace == false ){

	// 自定义操作
}

// 判断为 IE 就执行操作
if( support.optSelected == false ){

	// 自定义操作
}

// 判断为 IE6/7 就执行操作
if( support.leadingWhitespace == false && support.html5Clone == false ){

	// 自定义操作
}

本文由 Kayo Lee 发表,本文链接:http://kayosite.com/determine-browser-by-javascript-feature-detection.html

评论列表

  • 评论者头像
    回复

    表示兼容需要有很多耐性,已经抛弃了IE8以下(包括IE8)

    • 评论者头像
      回复

      @kn007 是的,很多时候,做跨浏览器和平台的时间实际上比实现功能还要长,如果项目不必要,我也不再兼容IE8-了!

  • 评论者头像
    回复

    特性检测比user agent靠谱一些

  • 评论者头像
    回复

    越来越厉害了。提前祝你新年快乐~!! :mrgreen:

  • 评论者头像
    回复

    ie 11, 我表示这个useragent好蛋疼,

    • 评论者头像
      回复

      @牧风 同感,特别是移动端的ie11,那些判断都失效了。认成ie6,不知怎么搞的

  • 评论者头像
    回复

    最近开了下大大的博客,很多文章都不错
    可以学到很多东西
    写的很详细,也容易理解。
    致谢~~

    • 评论者头像
      回复

      @骨头 这些文章多半是自己的一些心得分享,能对其他人有帮助我也很高兴!

  • 评论者头像
    回复

    太专业了,掠过

  • 评论者头像
    回复

    高级货啊

  • 评论者头像
    回复

    很专业,学习了。博客界面很好看。

  • 评论者头像
    回复

    UA啥的好烦躁,尤其是有一款坑爹的路由器,不能正确的识别。。

    • 评论者头像
      回复

      @大发 UA烦躁而且还不一定准确,现在都习惯用特征判断了!

  • 评论者头像
    回复

    连续3篇文章~~我表示不明觉厉!

    • 评论者头像
      回复

      @KKSTX 其实这三篇文章是分开写的,只是一直没有写好,等到那几天有空整理了再一次过发布!

  • 评论者头像
    回复

    一般都网上搜一大堆东西来改 :oops:

  • 评论者头像
    回复

    真厉害啊你的心得很不错有所启发

回复

你正在以游客身份访问网站,请输入你的昵称和 E-mail
:wink: :roll: :oops: :mrgreen: :idea: :cry: :?: :-| :-o :-P :-? :) :( 8-O