Fork me on GitHub

移动端适配方案

另一篇文章:移动端像素与Viewport

移动端适配的问题

通常在PC端1个设备独立像素 = 1个设备像素,不用考虑兼容的问题。但在移动端,不同厂商不同型号的设备的PPIDPR是不同的,也就是设计图上的1像素在不同设备上占据的实际物理像素值可能不同,所以同样的设计图在不同设备上展示效果是不尽相同的,分辨率越高,图像越缩小。假如设备的DPR为2,则设计图上的1px在设备上其实应该是4个像素点,则设计图的1像素实际是屏幕的2像素,如果不经过转化放大,那么设计图上的元素在设备上会被缩小1/2。所以我们要找到适用于各种设备的转换方法,使设计图在各个设备上看起来都一样。

几个概念

下面几个都是css单位,就像px一样,只不过他们都是相对单位。

em

作为font-size的单位时,相对于父元素的字体大小单位;作为其他属性单位时,代表自身字体大小。

rem

作用于非根元素时,相对于根元素的字体大小单位;作用于根元素字体大小时,相对于其初始字体大小。

vm/vh

vw视口宽度的 1/100;vh视口高度的 1/100。

rem 布局原理

根据屏幕宽度动态设置html标签的font-size。再将px替换为rem单位来布局,就可以达到适配的目的。

网易的方案

如果设计稿的宽度是640px,根元素的font-size是100px相当于1rem,那么一个占满屏幕的元素的宽度就是6.4rem,6.4rem就是css样式该元素的宽度值。那如果现在要适配iPhone5,iPhone5的设备像素屏幕宽度为320px,如果想让6.4rem的元素以同样比例占满屏幕,则根元素的font-size是多少?

1
2
6.4rem = 320px
1rem = 320 / 6.4 = 50px

就是要把根元素的font-size设为50px。那如果现在要适配iPhone6?

1
2
6.4rem = 375px
1rem = 375 / 6.4 = 58.59375px

同理其他设备,只要通过deviceWidth / 6.4计算出根元素的font-size就可以了。

1、首先通过meta标签设置视口:

1
<meta name="viewport" content="initial-scale=1,maximum-scale=1, minimum-scale=1">

2、算出设计图相对100px的比例。因为假设设计稿根元素font-size是100,拿设计稿横向分辨率除以100得到body元素的宽度:

1
2
750 / 100 = 7.5rem // 设计稿横向分辨率为750
640 / 100 = 6.4rem // 设计稿横向分辨率为640

3、在dom ready后,动态设置根元素的font-size

1
document.documentElement.style.fontSize = document.documentElement.clientWidth / 7.5 + 'px' // 设计稿横向分辨率为750

同理如果设计稿是640就除以6.4。

4、在写css时转换为rem,设计稿上元素尺寸是多少,除以个100就行了,这也是为什么取100作为参照,就是为了写样式时转换rem方便。
也就是:

1
2
3
转换系数 = 设计图宽度 / 100
根元素font-size = deviceWide(设备宽度) / 转换系数
css尺寸 = 设计稿尺寸px / 100

淘宝的 flexible 方案

flexible方案是阿里早期开源的一个移动端适配解决方案,引用flexible后,我们在页面上统一使用rem来布局。
它的核心代码非常简单:

1
2
3
4
5
6
// set 1rem = viewWidth / 10
function setRemUnit () {
var rem = document.documentElement.clientWidth / 10
document.documentElement.style.fontSize = rem + 'px'
}
setRemUnit()

淘宝的做法是将html节点的font-size设置为页面clientWidth(布局视口)的1/10,即1rem就等于页面布局视口的1/10,这就意味着我们后面使用的rem都是按照页面比例来计算的。也就是根元素 font-size = deviceWidth / 10,如果是750的设计稿,根元素的font-size是75px,那么设计稿上一个宽度375px的div就是5rem,占设计稿的50%。若要适配宽度为375的设备,根元素的font-size是37.5px,5rem就是187.5px,仍然占设备宽的50%。

1、动态设置viewportscale,控制页面的渲染比例:

1
2
var scale = 1 / devicePixelRatio
document.querySelector('meta[name="viewport"]').setAttribute('content','initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no')

虽然设备宽度是一定的,但是希望展示设计稿的宽度。比如750px的设计稿需要适配375px、dpr为2的iPhone6,就需要通过scale = 1 / 2改变视口宽度为375px * dpr = 750px。相当于原来375px的元素现在能代表750px的元素。相当于我们把750px的页面放到了750px的设备(通过改变scale值模拟出来的视口)中打开,然后透过375px的设备(当前打开页面的设备)去观看(注意:这里是观看,不是渲染。) 页面。

2、动态设置根元素的font-size

1
document.documentElement.style.fontSize = document.documentElement.clientWidth / 10 + 'px'

3、在写css时转换为rem

1
各元素的css尺寸 = 设计稿标注尺寸 / 根元素font-size = 设计稿标注尺寸 / 设计稿横向分辨率 / 10

也就是:

1
2
html = vp(视口宽度) / 10 = deviceWide(设备宽度) * dpr / 10
css元素尺寸 = 设计稿尺寸px / 根元素font-size

对比网易淘宝的方案

  • 网易是以100px作为参照,任何设计图上元素的尺寸转为rem都是相对于100px做转换的。不同设备的根元素的font-size都需要根据设计图的尺寸做比例转换。转换后我们写的以rem为单位的样式就能还原出设计图的样子。
  • 淘宝的做法就是任何设备宽都是10rem,根元素的font-size都是设备宽 / 10,任何元素的尺寸转为rem后其实是保留了相对于设备宽的比例,这个比例拿到其他设备上就能还原出设计图的样子。
  • 网易不用管dpr,只需知道设计稿宽度。
  • 网易的做法,rem值很好计算,淘宝的做法肯定得用计算器才能用好了 。不过要是你使用了lesssass这样的css处理器,就好办多了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    less

    // 定义一个变量和一个 mixin
    @baseFontSize: 75; // 基于视觉稿横屏尺寸 / 100 得出的基准font-size
    .px2rem (@name, @px) {
    @{name}: @px / @baseFontSize * 1rem;
    }

    // 使用示例:
    .container {
    .px2rem(width, 320);
    }
    // 编译后:
    .container {
    width: 4.26rem;
    }


    sass

    @function px2rem ($px) {
    $baseFontSize: 75px;
    @return ($px / $baseFontSize) + rem;
    }
    .container {
    width: px2rem(320px);
    }

vw、vh 方案

由于viewport单位得到众多浏览器的兼容,上面方案现在已经被官方弃用。现在最流行的是vwvh方案。

vw、vh

vwvh方案即将视觉视口宽度window.innerWidth和视觉视口高度window.innerHeight 等分为100份。
上面的flexible方案就是模仿这种方案,因为早些时候vw还没有得到很好的兼容。

  • vw(Viewport’s width):1vw等于视觉视口的1%。
  • vh(Viewport’s height):1vh为视觉视口高度的1%。
  • vmin:vwvh中的较小值。
  • vmax:选取vwvh中的较大值。

如果视觉视口为375px,那么1vw = 3.75px,这时UI给定一个元素的宽为75px(设备独立像素),我们只需要将它设置为75 / 3.75 = 20vw。该元素在设计图上的是20个vw占屏幕20%,则在任何其他设备上20vw也同样占屏幕20%,达到适配的效果。
这里的比例关系我们也不用自己换算,我们可以使用PostCSSpostcss-px-to-viewport插件帮我们完成这个过程。只需要在配置时指定设计图宽度就可以了,写代码时,我们只需要根据UI给的设计图写px单位即可。
当然,没有一种方案是十全十美的,vw同样有一定的缺陷:

  • px转换成vw不一定能完全整除,因此有一定的像素差。
  • 当容器使用vwmargin采用px时,很容易造成整体宽度超过100vw,从而影响布局效果。当然我们也是可以避免的,例如使用padding代替margin,结合calc()函数使用等等…

postcss-px-to-viewport

首先安装postcss-px-to-viewport插件。该插件主要用来把px单位自动转换为vwvhvminvmax这样的viewport视窗单位,也是vw适配方案的核心插件之一。

可以在.postcssrc.js文件中对postcss插件进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
‘plugins‘: {
‘postcss-px-to-viewport‘: {
viewportWidth: 750,
unitPrecision: 6,
minPixelValue: 1,
viewportUnit: 'vw',
mediaQuery: true,
selectorBlackList: ['html', 'body'],
exclude: /node_modules/
}
}
}

如果用的是vue-cli的话,也可以在vue.config.js文件中进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
css: {
loaderOptions: {
postcss: {
plugins: loader => [
require('postcss-px-to-viewport')({
viewportWidth: 750,
unitPrecision: 6,
minPixelValue: 1,
viewportUnit: 'vw',
mediaQuery: true,
selectorBlackList: ['html', 'body'],
exclude: /node_modules/
})
]
}
}
}
}

其中相关的几个关键参数:

  • viewportWidth:The width of the viewport. 视窗的宽度,对应的是我们设计稿的宽度,一般是750。
  • viewportHeight:The height of the viewport. 视窗的高度,根据750设备的宽度来指定,一般指定1334,也可以不配置。
  • unitPrecision:The decimal numbers to allow the REM - units to grow to. 指定px转换为视窗单位值的小数位数。
  • viewportUnit:Expected units. 指定需要转换成的视窗单位,建议使用vw
  • selectorBlackList:The selectors to - ignore and leave as px. 指定不转换为视窗单位的选择器(如标签、类),可以自定义,可以无限添加,建议定义一至两个通用的类名。
  • minPixelValue:Set the minimum pixel value to replace. 小于或等于1px不转换为视窗单位,你也可以设置为你想要的值。
  • mediaQuery:Allow px to be converted in media - queries. 允许在媒体查询中转换px

我们使用750px宽度的设计稿,那么100vw = 750px,即1vw = 7.5px。那么在实际撸码过程,不需要进行任何的计算,直接按照设计图中的标注写px的值就行,打包后会转换成对应的vw值,因为vw可以代表比例,所以可以适配各种不同的设备。

1像素问题

为了适配各种屏幕,我们写代码时一般使用设备独立像素来对页面进行布局。而在设备像素比大于1的屏幕上,我们写的1px实际上是被多个物理像素渲染,这就会出现1px在有些屏幕上看起来很粗的现象。

border-image

准备一张符合条件的边框背景图作为border-image

1
2
3
4
5
6
7
8
9
10
11
.border_1px {
border-bottom: 1px solid #000;
}
// 媒体查询,当设备的dpr是2的时候,用border-image覆盖上面的border样式
@media only screen and (-webkit-min-device-pixel-ratio:2) {
.border_1px {
border-bottom: none;
border-width: 0 0 1px 0;
border-image: url(1pxline.png) 0 0 2 0 stretch;
}
}

background-image

border-image类似,用边框背景图,模拟在背景上。

1
2
3
4
5
6
7
8
9
.border_1px {
border-bottom: 1px solid #000;
}
@media only screen and (-webkit-min-device-pixel-ratio:2) {
.border_1px {
background: url(1pxline.png) repeat-x left bottom;
background-size: 100% 1px;
}
}

上面两种都需要单独准备图片,而且圆角不是很好处理,但是可以应对大部分场景。

伪类 + transform

基于媒体查询,判断不同的设备像素比对线条进行缩放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.border_1px:before {
content: '';
position: absolute;
top: 0;
height: 1px;
width: 100%;
background-color: #000;
transform-origin: 50% 0%;
}
@media only screen and (-webkit-min-device-pixel-ratio:2) {
.border_1px:before {
transform: scaleY(0.5);
}
}
@media only screen and (-webkit-min-device-pixel-ratio:3) {
.border_1px:before {
transform: scaleY(0.33);
}
}

postcss-write-svg

上面border-imagebackground-image方案都可以模拟1px边框,但是使用的都是位图,还需要外部引入。
借助PostCSSpostcss-write-svg我们能直接使用border-imagebackground-image创建svg1px边框。
比如使用border-image

1
2
3
4
5
6
7
8
9
10
11
12
@svg 1px-border {
height: 2px;
@rect {
fill: var(--color, black);
width: 100%;
height: 50%;
}
}
.example {
border: 1px solid transparent;
border-image: svg(1px-border param(--color #00b1ff)) 2 2 stretch;
}

编译出来的css:

1
2
3
4
.example {
border: 1px solid transparent;
border-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='2px'%3E%3Crect fill='%2300b1ff' width='100%25' height='50%25'/%3E%3C/svg%3E") 2 2 stretch;
}

使用background-image

1
2
3
4
5
6
7
8
9
10
@svg square {
@rect {
fill: var(--color, black);
width: 100%;
height: 100%;
}
}
#example {
background: white svg(square param(--color #00b1ff));
}

编译出来就是:

1
2
3
#example {
background: white url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect fill='%2300b1ff' width='100%25' height='100%25'/%3E%3C/svg%3E");
}

设置viewport

通过设置缩放,让CSS像素等于真正的物理像素。例如:当dpr为3时,缩放前1px是由3x3个物理像素绘制的,我们将页面缩放1/3倍后,这时1px等于一个真正的物理像素。

1
2
3
4
5
6
7
8
var scale = 1 / window.devicePixelRatio
var viewport = document.querySelector('meta[name="viewport"]')
if (!viewport) {
viewport = document.createElement('meta')
viewport.setAttribute('name', 'viewport')
window.document.head.appendChild(viewport)
}
viewport.setAttribute('content', 'width=device-width,user-scalable=no,initial-scale=' + scale + ',maximum-scale=' + scale + ',minimum-scale=' + scale)

或动态插入:

1
2
3
4
5
var scale = 1 / window.devicePixelRatio
var meta = document.createElement('meta')
meta.name = 'viewport'
meta.content = 'width=device-width,initial-scale=' + scale + ',maximum-scale=' + scale + ',minimum-scale=' + scale + ',user-scalable=no'
document.head.appendChild(meta)

这意味着你页面上所有的布局都要按照物理像素来写。而不同设备物理像素不一样,这显然是不现实的,我们可以借助flexiblevwvh来帮助我们进行适配。

横屏适配

很多视口我们要对横屏和竖屏显示不同的布局,所以我们需要检测在不同的场景下给定不同的样式。

JavaScript 检测横屏

window.orientation获取屏幕旋转方向。

1
2
3
4
5
6
7
8
9
10
window.addEventListener('resize', () => {
if (window.orientation === 180 || window.orientation === 0) {
// 正常方向或屏幕旋转180度
console.log('竖屏')
}
if (window.orientation === 90 || window.orientation === -90 ){
// 屏幕顺时钟旋转90度或屏幕逆时针旋转90度
console.log('横屏')
}
})

CSS 检测横屏

1
2
3
4
5
6
@media screen and (orientation: portrait) {
/*竖屏...*/
}
@media screen and (orientation: landscape) {
/*横屏...*/
}

适配 iPhoneX

适配iPhoneX,有了安全区域这个概念:安全区域就是一个不属于上面三个viewport范围。为了保证页面的显示效果,我们必须把页面限制在安全范围内,但是不影响整体效果。

viewport-fit

viewport-fit是专门为了适配iPhoneX而诞生的一个属性,它用于限制网页如何在安全区域内进行展示。

  • contain: 可视窗口完全包含网页内容
  • cover:网页内容完全覆盖可视窗口

默认情况下或者设置为autocontain效果相同。

env、constant

我们需要将顶部和底部合理的摆放在安全区域内,iOS11新增了两个CSS函数envconstant,用于设定安全区域与边界的距离。
函数内部可以是四个常量:

  • safe-area-inset-left:安全区域距离左边边界距离;
  • safe-area-inset-right:安全区域距离右边边界距离;
  • safe-area-inset-top:安全区域距离顶部边界距离;
  • safe-area-inset-bottom:安全区域距离底部边界距离;

注意:我们必须指定viweport-fit后才能使用这两个函数:

1
<meta name="viewport" content="viewport-fit=cover">

constantiOS < 11.2的版本中生效,enviOS >= 11.2的版本中生效,这意味着我们往往要同时设置他们,将页面限制在安全区域内:

1
2
3
4
body {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}

当使用底部固定导航栏时,我们要为他们设置padding值:

1
2
3
4
{
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}