flexible方案是手淘经过多年的摸索和实战,总结出来的一套移动端适配方案。这个方案在多屏幕适配以及相关bug修复上做的还是不错的。这也是在读了源码之后才有了更深一层的理解,后面会详细解读。
首先来说一下之前做的一个项目,是关于腾讯众创空间的H5活动页面的制作。因为那会是刚去实习没多久,算是刚刚熟悉了公司的业务流程,接着项目组长就给我分配了这样一个任务。说实话,当时刚刚接手这个任务的时候,心理还是有点小兴奋的,毕竟之前理论知识学习了这么久,现在能有机会来个实战,这对于我这个入职不就的实习生来说会是一个不错的实践机会。
在拿到设计部门给的设计稿和思路稿之后,便开始了整个页面的制作。因为毕竟第一次接触移动端的开发,刚开始有点小不适应,上手之后就好了。由于这个项目催的急,自己匆匆忙忙的赶着做,在历经4天时间终于把它搞定了(扯了这么白话,下面来具体说说项目的整个实现流程)。
当时设计人员给的设计稿是基于iphone5(640×1136)的。整个页面的布局工作还算比较轻松,比较麻烦是的关键帧动画的延迟时间的控制和背景音乐的按时播放问题(主要是时间轴把握不好)。当时项目组长跟我说前一个动画开始的时间加上这个动画的执行时间就是下一个动画的开始时间。尝试了好多次,最后终于搞定了。在把所有的静态页面都完成之后,剩下的一个最大的任务就是移动端的适配工作了。
当时采用的方法是:首先通过JS获取到当前设备屏幕的宽度(通过document.documentElement.clientWidth获取到),然后求出当前屏幕的宽度和设计稿宽度的比例(高度的处理方法一致)。最后在脚本文件中,获取到页面的所有图片,根据移动设备的不同,动态修改每一张图片的宽度和高度,当时也结合了CSS3中的vw和vh特性来进行适配。当时由于时间比较紧张,在匆匆忙忙完成适配之后便把所有页面打包发给组长了。至此,自己的第一个H5页面告一段落。
后来在手机端测试:页面在普通屏幕下是没有问题,但是在retina屏幕下就会出现图片模糊的情况,这是什么鬼?
经过一番网上查阅资料和思考,得到一个结论:是因为位图像素点不够,从而导致图片模糊。因为自己之前做适配的时候,就拿设计稿的尺寸来说640×1136,而iPhone5的屏幕尺寸是320×568。根据之前的方案,求出的页面缩放比为0.5,而这样做相当于把图片的尺寸缩小了一半,结果就导致1个位图像素对应于4个设备物理像素,就会导致图片模糊(后来想想,这么做就把设计稿大小要×2的效果给破坏了)。
理论上:1个位图像素对应于1个物理像素,图片才能得到完美清晰的展示。
关于移动端像素的知识,在这里不多说了,详情见我的这篇博客H5移动端开发学习总结
对于dpr=2的retina屏幕而言,1个位图像素对应于4个物理像素,由于单个位图像素不可以再进一步分割,所以只能就近取色,从而导致图片模糊。
所以,对于图片高清问题,比较好的方案就是两倍图片(@2x)。
如:200×300(css pixel)img标签,就需要提供400×600的图片。
如此一来,位图像素点个数就是原来的4倍,在retina屏幕下,位图像素点个数就可以跟物理像素点个数形成 1 : 1的比例,图片自然就清晰了。
原理:在所有资源加载之前执行这个JS。执行这个JS后,会在元素上增加一个data-dpr属性,以及一个font-size样式。JS会根据不同的设备添加不同的data-dpr值,比如说2或者3,同时会给html加上对应的font-size的值,比如说75px。如此一来,页面中的元素,都可以通过rem单位来设置。他们会根据html元素的font-size值做相应的计算,从而实现屏幕的适配效果。
之前也用这个方案写过几个小Demo,最近又找时间把里面的实现原理梳理了一下。
;(function(win, lib) {
//源码部分
})(window, window['lib'] || (window['lib'] = {}));
这个插件也采用了传统插件的封装形式,采用了匿名函数自执行的方式将代码封装起来。这样做的好处是可以避免全局变量的污染,此外将window作为实现传入匿名函数中,这样一来可以减少全部变量的查找,提高性能。
另外一个参数window[‘lib’] || (window[‘lib’] = {} –> 如果lib已经定义(window[‘lib’]能获取到),就传这个lib,如果没有定义就给lib赋值空对象,并传入lib。为了避免重复定义。
这个时候在flexible.js里面的lib其实就已经是window.lib了(js中对象按引用传递)。
var doc = win.document;//获取到document
var docEl = doc.documentElement;//获取到html
var metaEl = doc.querySelector('meta[name="viewport"]');//获取到视口标签
var flexibleEl = doc.querySelector('meta[name="flexible"]');//获取手动设置的meta来控制dpr值
var dpr = 0;//设备缩放比
var scale = 0;//屏幕缩放比 dpr与scale是倒数关系
var tid;//定时器变量
var flexible = lib.flexible || (lib.flexible = {});
这段代码对相应的dom元素进行了缓存获取,这样可以减少dom的访问次数,毕竟dom操作太昂贵,我们在实际编程中应该尽量减少dom操作。
//如果页面中存在meta标签
if (metaEl) {
console.warn('将根据已有的meta标签来设置缩放比例');
var match = metaEl.getAttribute('content').match(/initial\-scale=([\d\.]+)/);
// console.log(match);
if (match) {
scale = parseFloat(match[1]);
// console.log(scale);
dpr = parseFloat(1 / scale);//两者是倒数关系
// console.log(dpr);
}
} else if (flexibleEl) {
/*
这里是判断是否存在手动设置的meta标签
其中initial-dpr会把dpr强制设置为给定的值。如果手动设置了dpr之后,不管设备是多少的dpr,都会强制认为其dpr是你设置的值。
在此不建议手动强制设置dpr,因为在Flexible中,只对iOS设备进行dpr的判断,对于Android系列,始终认为其dpr为1。
*/
var content = flexibleEl.getAttribute('content');
if (content) {
var initialDpr = content.match(/initial\-dpr=([\d\.]+)/);
var maximumDpr = content.match(/maximum\-dpr=([\d\.]+)/);
if (initialDpr) {
dpr = parseFloat(initialDpr[1]);
scale = parseFloat((1 / dpr).toFixed(2));
}
if (maximumDpr) {
dpr = parseFloat(maximumDpr[1]);
scale = parseFloat((1 / dpr).toFixed(2));
}
}
}
这段代码首先会判断页面中是否已经存在相应的meta标签,如果存在,将会给出一个警告:将根据已有的meta标签来设置缩放比例。
/*
在Flexible中,只对iOS设备进行dpr的判断,对于Android系列,始终认为其dpr为1。
*/
if (!dpr && !scale) {
var isAndroid = win.navigator.appVersion.match(/android/gi);
var isIPhone = win.navigator.appVersion.match(/iphone/gi);
var devicePixelRatio = win.devicePixelRatio;//获取设备缩放比
if (isIPhone) {
// iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {
dpr = 3;
} else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
dpr = 2;
} else {
dpr = 1;
}
} else {
// 其他设备下,仍旧使用1倍的方案
dpr = 1;
}
//设置缩放比例
scale = 1 / dpr;//scale和dpr成倒数关系
}
下面这段代码在页面中不存在相应的meta标签时,会自动创建一个meta标签,并会根据页面的dpr来设置相应的页面缩放比。个人觉得这一点设计的很人性化,开发人员可以自己定义meta标签,如果没有定义,则代码会自动帮你根据不同的设备生成相应的meta标签,这个很不错。
//给html标签设置自定义属性data-dpr
docEl.setAttribute('data-dpr', dpr);
//通过JS来动态改写meta标签
//如果不存在metaEl,则动态创建meta标签
if (!metaEl) {
metaEl = doc.createElement('meta');
metaEl.setAttribute('name', 'viewport');
metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
if (docEl.firstElementChild) {
// console.log(docEl.firstElementChild);//head
//这里是将新创建的meta标签插入到head标签中
docEl.firstElementChild.appendChild(metaEl);
} else {
//如果没有head标签,则新创建一个包裹元素
var wrap = doc.createElement('div');
wrap.appendChild(metaEl);
doc.write(wrap.innerHTML);
}
}
在dpr为2的时候,scale为0.5
在dpr为3的时候,scale为0.3333333
这样做目的:当然是为了保证页面的大小与设计稿尺寸的一致性,比如设计稿如果是750的横向分辨率,那么实际页面的device-width,以iphone6来说,也等于750,这样的话设计稿上标注的尺寸只要除以基准值就能够转换为rem了。
//刷新当前页面的rem基准值
function refreshRem(){
//获取设备的宽度
// console.log(docEl.getBoundingClientRect());
var width = docEl.getBoundingClientRect().width;
if (width / dpr > 540) {
//给屏幕设置最大的宽度值(1080,dpr是2),防止页面在PC端展示遭到破坏
width = 540 * dpr;
}
var rem = width / 10;//Flexible会将视觉稿分成100份(主要为了以后能更好的兼容vh和vw)
// console.log(rem);
//设置html元素的字体大小作为基准值
docEl.style.fontSize = rem + 'px';
//当前页面的rem基准值
flexible.rem = win.rem = rem;
}
getBoundingClientRect();该方法获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置以及这个元素的宽和高,这个方法返回的是一个对象,即Object,该对象有是个属性:top,left,right,bottom,width和height。
此外,手淘对于页面大小设置了一个临界点,当设备竖着时横向物理分辨率大于1080时,html的font-size就不会变化了,原因是:这样的分辨率已经可以去访问电脑版页面了,防止移动端页面在PC端展示遭到破坏。
//监听resize事件
//当设备屏幕尺寸发生变化时,更新当前页面的rem基准值
win.addEventListener('resize', function() {
clearTimeout(tid);
tid = setTimeout(refreshRem, 300);
}, false);
//监听pageshow事件
win.addEventListener('pageshow', function(e) {
if (e.persisted) {
clearTimeout(tid);
tid = setTimeout(refreshRem, 300);
}
}, false);
火狐和Opera有一个特性,名叫”往返缓存”(back-forward cache,或bfcache),可以在用户使用浏览器的”后退”和”前进”按钮时加快页面的转换速度。这个缓存中不仅保存着页面数据,还保存了DOM和JavaScript的状态;实际上是将整个页面都保存在了内存里。如果页面位于bfcache中,那么再次打开该页面时就不会触发load事件。
此外,火狐还提供了一些新事件:
pageshow事件:这个事件在页面显示时触发,无论该页面是否来自bfcache。在重新加载的页面中,pageshow会在load事件触发后触发;而对于bfcache中的页面,pageshow会在页面状态完全恢复的那一刻触发。
另外要注意:虽然这个事件的目标是document,但必须将其事件处理程序添加到window。pageshow事件的event对象还包含一个名为persisted的布尔值属性。如果页面被保存在了bfcache中,则这个属性的值为true,否则这个属性值为false。
/*
针对不同的浏览器做domReady兼容
IE6,7,8都不支持DOMContentLoaded事件
*/
if (doc.readyState === 'complete') {//针对不支持DOMContentLoaded事件做兼容
//根据不同的dpr来设置不同的字体大小,因为防止页面设置了缩放scale属性值而导致不同设备上字体大小不一致
doc.body.style.fontSize = 12 * dpr + 'px';
} else {//如果支持DOMContentLoaded事件,则直接使用
doc.addEventListener('DOMContentLoaded', function(e) {
// alert("DOMContentLoaded");
doc.body.style.fontSize = 12 * dpr + 'px';
}, false);
}
window的load事件会在页面中的一切都加载完毕时触发,但这个过程可能会因为要加载的外部资源过多而颇费周折。而DOMContentLoaded事件则在形成完整的DOM树之后就会触发,不理会图片、js文件、css文件或者其他资源是否已经下载完毕。
与load事件不同,DOMContentLoaded支持在页面下载的早期添加事件处理程序,这就意味着用户能够尽早地与页面进行交互。
document.readyState:返回当前文档的状态
//把rem转换为px
flexible.rem2px = function(d) {
var val = parseFloat(d) * this.rem;
if (typeof d === 'string' && d.match(/rem$/)) {
val += 'px';
}
return val;
}
//把px转换为rem
flexible.px2rem = function(d) {
var val = parseFloat(d) / this.rem;
if (typeof d === 'string' && d.match(/px$/)) {
val += 'rem';
}
return val;
}
上面的代码是用于px和rem之间的转换的,当然我们也可以采用less和sass这样的css处理器中的混合宏来实现。
less使用举例:
//定义一个变量和一个mixin
@baseFontSize: 75;//基于视觉稿横屏尺寸/100得出的基准font-size
.px2rem(@name, @px){
@{name}: @px / @baseFontSize * 1rem;
}
//使用示例:
.container {
.px2rem(height, 240);
}
//less翻译结果:
.container {
height: 3.2rem;
}
scale = 1 / dpr;
metaEl = doc.createElement('meta');
metaEl.setAttribute('name', 'viewport');
metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
var width = docEl.getBoundingClientRect().width;
var rem = width / 10;
docEl.style.fontSize = rem + 'px';
目前手淘已经给我们提供了一个开源的解决方案,具体请查看:传送门
我们正常的写css,像这样border: 1px;,在retina屏幕下,会有什么问题呢?
注:图片来源于传送门
对于一条1px宽的直线,它们在屏幕上的物理尺寸的确是相同的,不同的其实是屏幕上最小的物理显示单元,即设备物理像素,所以对于一条直线,iphone5它能显示的最小宽度其实是图中的红线圈出来的灰色区域,用css来表示,理论上说是0.5px。
所以,设计师想要的retina下border: 1px;,其实就是1物理像素宽,对于css而言,可以认为是border: 0.5px;,这是retina下(dpr=2)下能显示的最小单位。
然而,无奈并不是所有手机浏览器都能识别border: 0.5px;,ios7以下,android等其他系统里,0.5px会被当成为0px处理,那么如何实现这0.5px呢?
对于iphone5(dpr=2),添加如下的meta标签,设置viewport(scale 0.5):
<meta name="viewport" content="width=640,initial-scale=0.5,maximum-scale=0.5, minimum-scale=0.5,user-scalable=no">
这样,页面中的所有的border: 1px都将缩小0.5,从而达到border: 0.5px;的效果。
假如我们拿到的是一个针对iphone6的高清视觉稿 750×1334,如果有一个区块,在psd文件中量出:宽高750×300px的div,那么如何转换成rem单位呢?
公式如下:
rem = px / 基准值;
对于一个iphone6的视觉稿,它的基准值就是75。所以,在确定了视觉稿(即确定了基准值)后,通常我们会用less写一个mixin(混合宏),像这样:
// 例如: .px2rem(height, 80);
.px2rem(@name, @px){
@{name}: @px / 75 * 1rem;
}
所以,对于宽高750×300px的div,我们用less就这样写:
.px2rem(width, 750);
.px2rem(height, 300);
转换成css,就是这样:
width: 10rem; // -> 750px
height: 4rem; // -> 300px
最后因为dpr为2,页面scale了0.5,所以在手机屏幕上显示的真实宽高应该是375×150px,就刚刚好(达到了一个CSS像素对应一个设备物理像素的效果)。
感觉这部分的知识太烧脑,整理了一下午,真心佩服那些大牛能够坚持写高质量的博文,自己也要好好加油了,多总结多思考。上面的总结如有错误,欢迎大家交流指正,共同学习,共同进步。
相关参考博文:
从网易与淘宝的font-size思考前端设计稿与工作流
移动端高清、多屏适配方案
使用Flexible实现手淘H5页面的终端适配