移动端页面布局

  开发 PC 站点时,相对来说,布局会简单一些。目前多数站点都会采用固定宽度布局,居中显示,两边留白。这样在绝大部分屏幕上显示都没问题,即便是极少数的窄屏上,最多出现横向滚动条,也不至于有大的问题。再多做一步的话,可以做成响应式的,设置几个最大宽度的临界点,判断当前屏幕宽度小于某一临界值时,使用更小一级的最大宽度。临界值的判断可以使用 CSS 媒体查询,若低版本浏览器不支持媒体查询时,可借助 JS 实现。采用固定宽度布局时,基本可以做到百分之百还原视觉稿。

  但开发移动端站点时,会有些麻烦。由于移动设备屏幕千奇百怪,仅苹果全家桶从 iPhone 4 到 iPhone 6 plus 就有 320、375、414 像素的多种屏幕宽度(此处说的是 CSS 像素,也叫逻辑像素或设备独立像素,不同于物理像素,下同),Android 设备就更不用说了,碎片化相当严重。虽然 PC 的屏幕也是各种尺寸,但如上面所说,可以通过固定宽度布局然后居中显示来解决。PC 的屏幕大,为了不同屏幕上显示的一致性,只利用一部分屏幕区域没有问题。但移动端设备屏幕本身就很小,如果也采用固定宽度布局的话,在稍大屏上会导致两边留白,不能充分利用有限的屏幕空间;在稍小屏上又会出现横向滚动条,移动端的横向滚动条会导致体验很差。所以此方案行不通。

  想在手机上访问 PC 站点,如果不做任何处理,体验很差。因为常规手机 300 多 CSS 像素宽度的屏幕上肯定显示不下常规网站 1000px 左右宽度的内容,必然会出现水平滚动条。但在智能机刚流行的年代,根本没有专门为移动端设备适配的站点,而人们又有在移动设备上访问网站的需求。为了让用户能在移动设备上访问 PC 站点有很好的体验,设备厂商想了个办法:整出了个 layout viewport(布局视口)的概念,在这个虚拟视口中显示网页。iPhone 下,设置的 layout viewport 的宽度是 980px(至于为什么是 980px,可能是由于当时大部分的 PC 页面在这个宽度范围吧),Android 下大部分是 800px,不同机型可能会不同。由于屏幕实际的 CSS 像素要小得多,所以显示 PC 内容时,layout viewport 会被自动缩小,这样大部分 PC 站点可以在手机上完全展示(个别宽度大于 980px 的站点还是会有水平滚动条)。其实我觉得这也只是一个自欺欺人的做法,因为虽然这时页面基本可以完全展示,但由于默认进行了缩放,基本不具有可读性,还需要用户手动放大内容,才能方便阅读。于是为了方便用户放大被自动缩小的页面,苹果又带头整了个新的功能:双击屏幕放大内容。由于放大了内容,这时横向滚动条依旧会出现。看,绕了一圈又回到了原点,而且由于提供了双击放大的功能,又带来了一个新的问题:如果双击区域内有可点击的内容怎么办?如有个链接,怎么才能知道用户是想双击放大,还是想点击链接跳转。正常情况下点击事件会立刻触发,而用户手指双击时,两次点击总会有时间间隔。如不加处理,在第一次点击屏幕时,就会触发点击链接事件跳转走了,而不会触发双击了。所以为了解决此问题,苹果又带头引入了 300ms 延迟方案,即单击屏幕后,延后 300ms 才触发 click 事件。之所以使用 300ms,可能是由于统计发现大多数人的双击操作会在 300ms 内完成吧。如果在此时间段内双击了屏幕,就会缩放屏幕,而不会触发 click 事件。这一连串问题,真是按下葫芦浮起瓢啊。

  由上述可知,手机端浏览器直接加载 PC 页面体验很不好,所以越来越多的站点会提供移动端的版本。其实移动端站点和 PC 站点没有什么本质区别,都是 html 页面,无非是访问设备的屏幕尺寸尤其是宽度有差异罢了。但访问设备的不同,导致了开发方式也不同。首先需要为移动站点页面添加特殊的标记,标识当前页面的宽度是特定的。这样的话,layout viewport 会被重置成标识中的特定宽度,并占满全屏。否则没有此标识的话,layout viewport 会被自动设置成默认的宽度(iOS 下是 980px,Android 下是 800px 或其它值),由于远大于窗口实际宽度,而被自动缩小显示。

  标识移动端页面的方式是为页面增加特殊的 meta 标签:

  <meta name="viewport" content="width=xxx">

  这样浏览器就会以 meta 标签中width=xxx的值来设置 layout viewport 的宽度。可以使用width=320这样的固定值,也可以是width=device-width这样的适配当前屏幕宽度的相对值。早期不少移动端站点会使用固定值,如之前所在的公司携程的移动站点使用的就是width=320(不过现在早已变成了width=device-width)。使用固定值的好处是,适配比较简单,可以直接使用 px 作单位,视觉稿上标多大,页面上就可以设置成多大,基本可以百分之百还原视觉稿。但坏处是不同屏幕的手机下显示效果有差别,可以想象 360px 宽的屏幕被当成 320px 用,会有内容被放大的感觉,而小屏幕上会感觉到内容被缩小。好在当时屏幕尺寸差别没有现在这么夸张,虽有差别但不大,所以使用一种常见的屏幕宽度来布局,问题不大。

  但随着移动设备屏幕越来越多样化,如从 iPhone 4 到 iPhone 7 plus,屏幕宽度跨度就从 320px 到 414px,变化很大,更不要提五花八门的 Android 设备了。所以设置 layout viewport 为固定宽度已无法适配各种屏幕了。目前的移动端站点基本都通过width=device-width来设置 layout viewport,即用当前设备的宽度来作为 layout viewport 的宽度。这样页面在各种设备上看起来比例都还好,不会有被缩放的感觉。但使用width=device-width又会带来新的问题:通常只会有一套特定宽度的视觉稿,但屏幕宽度却各种各样,如何适配它们呢?

  仅仅使用 px 作为单位很多时候是行不通了,这就可能导致要么出现横向滚动条,要么页面留白。使用 CSS3 中的 flex 布局,外加 CSS 媒体查询,再使用 px 作为单位,倒是可以解决一些布局问题,但仍不是一个通用的方案。CSS3 中增加的 rem 单位可以弥补 px 的一些不足。rem 是相对于 html 根元素的 font-size 的,能够随着根元素字体大小的变化而变化。所以可以根据屏幕尺寸动态设置 html 元素的 font-size 来达到适配不同屏幕的目的。

  也许你会问:使用 rem 和使用 em 有何不同?虽然 rem 与 em 从名字上看很像,也都是相对单位,但它们有着本质区别:em 是相对于当前元素的字体大小的,而 rem 是相对于根元素(即 html 元素)的字体大小的。如果当前元素没有显式设置字体大小,则使用从父元素继承的,所以使用 em 时,需要时刻关注父级元素的影响,而 rem 不存在这种影响。

  也许你又会问:使用 rem 和使用百分比布局有什么区别?百分比也是相对布局,但相对的是父元素,当元素嵌套比较多时,计算百分比将相当麻烦,而且元素嵌套结构一变化,之前的计算将乱套,无法维护。

  也许你还会问:使用 rem 和给 meta 设置width=320这类固定宽度布局有什么不同?诚然,如果给所有元素都使用 rem 作为单位,这和将 layout viewport 设置为设计稿匹配的固定宽度(如 640px 的视觉稿,则设置width=320;750px 的视觉稿,则设置width=375),然后使用 px 作为单位,从效果上看,也没什么不同,都是等比缩放视觉稿。只不过全部使用 rem 时,layout viewport 的宽度随着屏幕的不同而变化,而其中的内容随着 layout viewport 的变化而等比例变化;使用固定宽度时,是先缩放 layout viewport 为固定值,然后内容在固定宽度容器内按比例排列。

  所以,即便使用 rem 作单位很实用,但也不是放之四海而皆准,还需要具体情况具体分析。例如,一般情况下,文本字体大小就不推荐使用 rem 作为单位,一是由于点阵字体推荐使用偶数来设置大小,奇数有时会影响渲染效果,而使用 rem 时,不仅可能会有奇数,甚至会计算出小数,影响效果。二是由于使用 rem 设置文本字体时,字体大小会随着屏幕 CSS 像素的增大而增大。例如假设在 320px 的屏幕上(将屏幕宽度看成是 10rem 的话,1rem = 32px)字体在 16px 大小时效果正好,如果用 rem 作为单位的话,即为 0.5rem,假设这时每行可以显示 20 个字。而如果用 640px 的屏幕设备加载页面,1rem = 64px,这时的字体大小 0.5rem 实际的像素值就变成了 32px,这时每行也仅显示了 20 个字。显然这不是我们想要的,我们希望在大屏下可以显示更多的文字。虽然一般情况下,设备有更大的 CSS 像素宽度时,都会有着更大的像素密度,导致相同像素的字体大小在大的 CSS 像素宽度的设备屏幕上会看起来更小些,需要增大字体大小,才能与小的 CSS 像素宽度的设备屏幕上看到的效果一致,但也仅仅需要适当的增大一点点,绝不是成比例的增大。所以从这方面看,字体大小也不宜使用 rem 作为单位。

  所以合理的方案应该同时使用 px、rem 甚至百分比等作为单位(vw、vh 也是很好的选择,如果不存在兼容性问题的话),同时以 flex 甚至 float 等辅助,来完成布局。具体怎么选择,应该具体问题具体分析,没有一劳永逸的方案。

  如果要用到 rem 的话,那么该如何使用呢?既然 rem 是以页面根元素(即 html 元素)的 font-size 为基准的,那么关键点就落在了如何设置 html 的 font-size 上了。肯定不能写死为特定的一个值,rem 最大的特点就在于其动态性,这样使用 rem 就失去了意义,和 px 没有什么区别了,合理的使用姿势应该根据屏幕宽度动态设置。有两种设置方式:使用 CSS3 的媒体查询或使用 JS 动态计算。使用媒体查询的好处是简单,缺点是有限的几个临界值不能满足各种尺寸的屏幕,屏幕尺寸在临界值之间时,计算结果不够准确。JS 计算虽有些复杂,但更准确。JS 计算的方式可以如下:

  假设将屏幕宽度分成十等份,将每一等份看成 1rem,即屏幕宽度为 10rem。可以通过如下方式动态设置 html 元素的字体大小:

var docEl = document.documentElement;
var rem = docEl.getBoundingClientRect().width / 10;
docEl.style.fontSize = rem + 'px';

  上述脚本应该尽早执行,如果执行时机较晚的话,如在页面初次渲染之后,会造成页面重绘,出现闪动。所以应该使用内连脚本,并放在 head 元素中计算。实际开发中遇到了一个问题:我们是 hybrid App,前端代码打包在 App 中,发现在 Android 下,执行上述脚本时,有时候 html 的 font-size 没有被正确的设置,查询下来发现是没有获取到屏幕的宽度。也许是 Android 下 webview 的 bug,在 DOM 未 ready 时,有一定概率下会获取不到屏幕宽度。使用如下的方式解决:

function initRootFontSize() {
  var docEl = document.documentElement;
  var width = docEl.getBoundingClientRect().width;
  if (width) {
    docEl.style.fontSize = width / 10 + 'px';
    return true;
  }
  return false;
}

// 在Android下发现偶尔会发生进入页面时无法获取页面宽度的情况,即使在DOMContentLoaded事件中也有可能获取不了,只能如此修复
if (!initRootFontSize()) {
  var handler = setInterval(function() {
    if (initRootFontSize()) {
      clearInterval(handler);
    }
  }, 10);
}

  正确的设置好 rem 的基准后,书写页面时,将设计稿也看成十等份,假设设计稿是 750px 宽度,则一等份为 75px。那么页面上的 1rem 就对应了设计稿上的 75px,假设设计稿上某一区域的宽高皆为为 150px,则样式可以这样写:

width: 2rem;
height: 2rem;

  此时,页面在不同大小的屏幕上访问时,此块区域始终占据着相同比例的空间。这样,通过设置根元素的 font-size,建立了页面与设计稿之间的联系。上面的方案是将屏幕宽度分成十等份,总宽度当成 10rem(天猫、淘宝的移动站点就是这么处理的)。这样做只是为了方便理解,理论上可以分成任意等份,如 15 或 100。但分成 100 份这样大的数值是有问题的。如在 320px 的屏幕下,计算得到 html 元素的 font-size 为 3.2px(320/100)。在 PC 端 webkit 内核的浏览器中,限制最小的中文字体为 12px,当设置更小值时,会被强制转化成 12px。虽然很早前可以通过设置-webkit-text-size-adjust: none;来解决,但带来了无法缩放的问题,后来被 webkit 当成 bug 解决了,-webkit-text-size-adjust 也就失效了。虽然网上都说移动端 webkit 浏览器上支持设置小于 12px 的字体,试了 iOS 下确实可以,但在 Android 上,至少在我的手机上的 Chrome(版本为56)不支持,仍旧将小于 12px 的字体显示为 12px。其它 Android 系统及其它浏览器没有试,但只要有一个常见的不满足,就是个问题,需要避免这样使用。另外,需要说明的是,在 Windows 上最新的 Chrome(版本为 59)中,发现居然可以支持小于 12px 的字体了,现在将限制降低到了 8px(但 Mac 下同为最新的 59 版本的 Chrome 依然不支持小于 12px 的字体)。同时我将手机上的 Chrome 升级到 59 版本后,发现也最小支持到了 8px 的字体。这个世界变化太快,网上的那些文章又要过时了,还是需要自己多测试。

  上述设置 rem 基数的方式虽直观,便于理解,但不便于计算(如假设 750px 的视觉稿,则其中某块区域宽 32px,对应到页面上应该是 32/75rem,需要计算出结果写到样式中)。好在很多大些的项目都会使用 Sass、LESS、PostCSS 等 CSS 预处理器,可以写一个 px2rem 之类的函数用于计算元素的 rem 值。我们目前的项目中使用的是一个叫 px2rem-loader 的 webpack 的 loader,写样式时仍然使用 px 作单位,打包时统一转换成 rem。当然,如果想计算时简单点的话,也可以换一种方式:将视觉稿中的 100px 看成对应于页面中的 1rem,这样只需要将视觉稿中标记的大小除以 100 即得到对应的 rem 的值(页面总宽度根据设计稿确定,如假设是 750px 的设计稿,则页面宽度为 7.5rem。同时根据页面实际像素宽度除以 rem 数值,得到每一 rem 对应的像素值,设置为 html 元素的字体大小。网易的移动端站点即是这样做的)。

300ms 延迟触发 click 事件问题

  由于 click 事件在移动端会有 300ms 的延迟,导致直接使用时会有体验问题,因为交互后 100ms 的延迟反馈,就会被用户感知到,会产生卡顿的感觉。既然双击操作放大需求引发了 300ms 延迟触发 click 事件,而在移动端设备访问 PC 站点的需求又带来了双击放大操作需求,那么如果不能够或不需要双击放大操作,是不是就可以不用延迟触发 click 事件了?理论上是可以的,只要侦测到当前页面是针对移动端设计的,应该就可以这么做。Chrome for Android 就是这么实现的。如下两种方式,在移动端的 Chrome 上,click 事件都会无延迟,立刻触发:

// 禁用页面缩放
<meta name="viewport" content="user-scalable=no">
<meta name="viewport" content="initial-scale=1,maximum-scale=1">

// 下述两种方式在 Android Chrome 32 版本开始都有效
// 设置布局窗口宽度为当前设备宽度
// 此方式在 iOS 平台下从 iOS 9.3 也开始有效
<meta name="viewport" content="width=device-width">
// 设置布局窗口宽度小于等于当前设备宽度的特定的值
<meta name="viewport" content="width=320">

  上述两种方式,第一种方式有缺陷,会禁用页面的缩放,即使双手缩放也被禁止。而第二种方式可以禁用掉双击缩放,但保留双指缩放,可用性更高。然而这些都是浮云,毕竟即便是 Android 平台,也只是 Chrome、Firefox 等浏览器做了这方面的优化,其它小众浏览器未知,可能存在兼容性问题。而 iOS 平台下更保守些,在 iOS 9.3 之后,才仅支持 width=device-width 这种方式下的 click 事件立即触发。iOS 下此方面保守的原因可能是 iOS 下的浏览器中,双击操作除了缩放页面外,当在头尾部双击时,还会触发滚动,如果要禁用掉双击操作的话,那双击滚动也就失效了。

  所以目前的状况是,无法通过简单设置完全解决各个平台下 300ms 延迟触发 click 事件的问题,但不解决的话又会影响用户体验。所以目前最常用的方案是引入第三方工具:FastClick。FastClick 会在 touchend 事件时,通过自定义事件 API 触发自定义的 click 事件,同时取消掉 300ms 后延迟触发的原生的 click 事件。FastClick 除了解决了 click 事件的延迟触发外,同时也解决了 300ms 延迟触发 click 事件带来的另一问题:点击穿透,此处就不深入介绍了。

一像素边框问题

  阿里有一个工具库 lib-flexible,用于方便的使用 rem,其实所做的事情就是根据屏幕宽度,动态的设置 html 元素的 font-size。除此之外,还做了另外一件事情:试图解决一像素边框问题。之所以说“试图”,是因为解决的并不彻底,较完美的解决了 iOS 下的问题,但直接忽略了 Android。

  所谓的一像素边框问题,是从苹果引入 Retina 屏幕开始出现的。早期物理分辨率和代码可操作的逻辑分辨率是一一对应的。但从 Retina 出现开始,屏幕的物理分辨率越来越高,如 iPhone 3 和 iPhone 4 的屏幕尺寸都为 3.5 英寸,但 iPhone 4 的物理分辨率为 640x960,是 iPhone 3 (320x480)单方向上的两倍,有着更高的像素密度。如果不加处理,同样大小的字体在 iPhone 4 上将只有 iPhone 3 上大小的一半。所以,为了让高像素密度的设备显示正常,设备厂商做了降级处理,使物理像素不再和逻辑像素一致了。如在 iPhone 4 上,同一方向上两个物理像素对应于一个逻辑像素,这样就保证了和同尺寸的 iPhone 3 有相同的逻辑像素,代码中设置的字体大小,在两个设备上保持一致。到了 iPhone 6 plus 时代,像素密度更高了,同一方向上三个物理像素才对应于一个逻辑像素(其实 6 plus 的物理像素并没有达到正好逻辑像素的三倍那么高,而是差一点,苹果为了使 6 plus 的逻辑像素不致于低于屏幕尺寸小一些的 iPhone 6,硬生生的拉高了逻辑像素的设置)。为了获取物理像素与逻辑像素的关系,JS 中可以通过 window.devicePixelRatio 来获取其比值(iPhone 4 ~ 6 为 2,iPhone 6 plus 为 3)。

  好了,介绍完历史可以描述下问题了。当 CSS 中写下 border: 1px solid black; 后,此处的 1px 代表的是代码可操作的逻辑像素而非物理像素(在 iPhone 4 ~ 6 上,对应了两物理像素的宽度,iPhone 6 plus 上则约等于三个物理像素)。虽然此边框宽度在 iPhone 3 和 iPhone 4 上实际宽度(占据的物理空间)是一致的,但由于在高清屏下理论上可以显示出更细的边框(一物理像素的边框,虽然正常代码中操作不了物理像素),并且拥有像素眼的设计师通常也会觉得更细的边框更加性感(这个世界没救了,代码中都要以瘦为美),所以如何显示一物理像素的边框,就成为了前端开发中常见的一个问题。

  描述完问题可以找解决方案了。最简单的方案可能要数使用 0.5px 边框了,结合 CSS 媒体查询使用。在 window.devicePixelRatio 为 2 的高清屏中 0.5px 正好对应了一物理像素,虽然在 window.devicePixelRatio 为 3 或更高的高清屏上,0.5px 不能与物理像素一一对应,要大于一物理像素,但毕竟已经很苗条了,基本都能通过设计师的法眼了。而且由于像素密度更高,一物理像素的边框可能由于更细而看不清了,所以并不需要一一对应。0.5px 边框方案最大的问题在于兼容性。iOS 8 开始支持,所以目前 iOS 平台基本不存在问题,但 Android 上基本不支持。毕竟解决一像素边框问题,算是一个视觉上的优化,能够简单的解决 iOS 下的问题也是一种折衷的进步。

  另一种方案是使用 CSS3 中的 transform: scale()。思路是在待设置 1px 边框的元素内增加一个子元素(一般是直接使用 before 或 after 伪元素代替,而非真正使用子元素),然后放大子元素(2 倍 或 3 倍,可以视 window.devicePixelRatio 的值而定,或固定 2 倍),同时设置子元素的边框宽度为 1px,最后使用 transform: scale(0.5) 缩小子元素。可参见如下示例代码:

.1px-border {
  position: relative;
}

.1px-border:after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  border: 1px solid #000;
  width: 200%;
  height: 200%;
  -webkit-transform: scale(0.5);
  transform: scale(0.5);
  -webkit-transform-origin: left top;
  transform-origin: left top;
}

  上述代码通过 after 伪元素设置了四条边框,这种方式是有些问题的:此时伪元素大小和待设置边框的元素大小一致,同时伪元素覆盖在其上,这就导致如果待设置边框的元素内有可点击的内容,或其它操作,是点击不到的。如果仅仅是块展示的内容,不需要点击等操作,倒没问题,否则的话,只能依次给单条边设置。但这样的话,一个元素的 before 和 after 伪元素只能设置两条边,如果四条边都需要设置的话,需要再插入子元素,会较麻烦。此方案的好处是相对来说简单些,兼容性还好。

  另外还有其它解决一像素边框的方案,如通过 box-shadow、背景图片、背景渐变等,都各有优劣,同时也都较复杂,此处就不一一介绍了。

  还有一种方案就是上面提到的 lib-flexible 里提供的方案,根据 window.devicePixelRatio 设置页面的初始缩放,如下:

// window.devicePixelRatio = 2
<meta name="viewport" content="initial-scale=0.5, maximum-scale=0.5, minimum-scale=0.5, user-scalable=no">
// window.devicePixelRatio = 3
<meta name="viewport" content="initial-scale=0.3333333333333333, maximum-scale=0.3333333333333333, minimum-scale=0.3333333333333333, user-scalable=no">

  由于页面被缩小,为了能正常显示,书写样式时,需要同比放大相应的倍数。此时代码中的 1px 其实就对应了一物理像素。此方案本质上和上面介绍的 transform: scale() 类似。采用此方案时,因为有样式的缩放,会稍微增加书写代码时的复杂度。lib-flexible 库在实现此方案时,只针对 iOS 做了处理,直接忽略了 Android。不清楚为何,猜测可能是由于 Android 上某些设备的 window.devicePixelRatio 并不能准确反映设备像素比,会导致某些情况下边框显示有问题吧。