如何构建一个完美缩放的UI界面

发布于 大漠

这是一个很有意思的话题,仅从标题中我们可以得到两个关键词 完美缩放。对于 Web 前端开发者而言,特别对于 UI 还原方面(天朝称“切图”,国外称“UI Developer”)更多追求的是像素级还原(即“Pixel-Perfect”),但随着更多的移动终端出现之后,特别更多的业务重心放到移动端之后,Web 开发者把更多的重心放在移动端。为此在业内出现了更多有关于移动端适配的方案,比如 Rem适配VW适配 等。但这些都离我们所说的像素级还原目标越来越远。换句话说,如果我们既要(像素级还级)又要(完美缩放或适配),该怎么办呢?如果你对该话题感兴趣,那么接下来的内容值得你花一定时间阅读。

我们先从像素级还原聊起!

像素级还原的故事

不知道你听到“像素级还原”这个词是什么时候?

老一点的前端或者说曾经有过“重构工程师”称号的前端一定听过这个词,即,像素级还原100%还原。或许时至今日,你在工作中还会因为一个像素的偏差和设计师争得面红耳赤,甚至会因为这一个像素不欢而散。说实话,不同的角色站在不同的角度为这一个像素而争都是对的:

  • 设计师:希望自己设计出来的作品呈现给自己用户时完全不打折扣
  • 开发人员:不是我不想像素级还原,而是很多时候也是无耐

换句话说,都是“打工人”,都不容易。暂且抛开这些恩与怨,先来看看什么是像素级还原。

什么是像素级还原

“像素级”这个概念是由设计师客户一起创造出来的,因为他们要求自己的设计稿必须反映出设计,并且要和设计一模一样。当设计师把完成的设计稿交给前端开发人员时,他们相信前端会完全不打折扣地实现他们的设计。他们的工作是通过Web前端的实现来完成的,他们希望Web在实现过程中不要损坏他的设计稿:

而设计稿最终要在浏览器(或客户端)中向用户呈现,因此最终像素级还原(设计师的意图)转嫁到前端中:

指在HTML和CSS能100%的实现设计稿的效果,一个像素也不差。

我也经常听到这样的担忧:

许多Web前端开发人员难以完美再现一个设计,即使是有多年前端经验的Web开发者。

对于Web开发者来说,很多时候他是无辜和无耐的,即使在Web开发时严格的按照设计师提供的设计稿进行还原,精确的字号,准确的间距等,但最终实现出来的总是有一定差异的,比如下图:

看上去似乎是一样,但我们将其叠加到一起,就能看到它们之间的差异:

换张动图可能会看得更清楚一些:

你可以使用浏览器的插件PerfectPixel来验证自己的还原是不是达到了像素级还原。

而这种结果很容易被认为是不完美(不是像素级)的还原。

Ahmad Shadeed 分别在《The State Of Pixel Perfection》和《Comparing Design Mockups To Code Result》 围绕着像素级还原做了详细地阐述!

那么在现实中,像素级的还原是否就是完美的呢?

先把这个问题说清楚。从严格意义上来说,个人认为完美的像素级还原是不可能的。因为Web开发人员在编写HTML和CSS需要在各种设备终端上运行,而时至今日,这些终端设备足以让我们感到眼花缭乱。换句话说,将有很多的变量会影响我们编码出来的Web页面渲染。比如将淘宝PC端在Chrome和Safari浏览器中效果截取出来,并且并排在一起看时,这两张截图似乎非常相似,没啥问题:

但将他们合在一起看时,差异就立马显现出来了:

上图只是其中一个变量的差异(同一系统下不同的两个浏览器)。想想,现实中还有多少因素会影响到Web页面的,比如:

  • 设备类型(台式机电脑、笔记本电脑、平板电脑、手机、手表等)
  • 屏幕(或视窗)大小
  • 屏幕像素密度(比如Retina屏幕和非Retina屏)
  • 屏幕技术(比如,OLED、LCD、CTR等)
  • 用户的操作(比如用户对屏幕进行缩放、调整默认字号等)
  • 性能(比如,设备硬件、服务器负载、网络速度等)
  • 设备色彩样正(比如夜间模式,iOS的暗黑模式等)

这仅是我能想到的,还有很多我想不到的呢?也就是说,我们永远无法确保屏幕上单个像素的RGB值百分百的一致。这是一个不可能的标准。但这也不是真正的重点。

另外,也没有人要求东西在放大镜下看起来是一样的。大多数情况下,设计师希望实现的东西看起来和肉眼一样(像素眼除外),并且收紧明显的错位和松散的间距。他们希望它是像素般接近的,而不是像素般完美的。

没有像素级还原就不完美?

前面两个示例都向大家展示了,Web开发人员在还原设计师提供的设计稿时,总是会存在差异,而且影响这方面的差异的因素非常地多。而这种效果在设计师眼里很容易被认为是不完美的。那么在现实中,它是否真的不完美?

在回答这个问题前,我们把时间拉回十年前。

在2010年,设计师或客户要求前端在还原设计稿时应该是一个像素级还原,这对于Web开发人员可能是可以做到的。因为在那个年代,Web在呈现的终端设备类型毕竟不多,大多在PC台式机上展示,笔记本也不多,更不用说现在这么多的移动终端设备了。Web开发者要考虑的屏幕尺寸数量也不多。简单地说就是PC页面尺寸,比如大家熟悉的960px宽度,或者后来的1024px宽度已经足够用了。然而,随着成千上百万的设备(智能手表,智能手机,平板电脑,笔记本电脑,台式机,电视等),像素级还原将不会是件易事,甚至是不可能的事情了。

这样的话从一名Web开发者口中说出,并不代表说Web开发者没有匠心精神,没有追求。

最近看一个新词“看(外观)和感觉”。放到我们Web设计中来看,“看(外观)”指的是从UI的角度看,一个Web网站的外观如何;而感觉主要是从Web功能和交互性的角度看。

我截了两张图:

上图是截取于Youtube首页,两者不同之处只是我在第二张图上对几个地方的颜色做了些许的调整。如果不专注看的话,这些小小的变化或许就把你忽悠了。感觉上两者并没有差异。

我们再来看另一张图:

左图是原始设计图;右图是Web前端开发出来的页面效果!

你可能已经发现两者之间的差异了吧,而且差异不小,这些差异显著影响了最终结果。上图展示的只是其中一个组件,这些差异只影响了一个卡片组件。或许有人会说这些差异很微小,并不重要,至少没有影响到功能和用户的交互。或许对于一个单一的组件,这样说法是对的。试想一下,如果所有组件都有一点或一些小差异,又将会是什么样的结果。

最终你将实现一个不完美的结果!

作为设计师,他们不可能忽略这些(间距、尺寸和对齐等)差异。这也是为什么在我们开发中存有视觉走查这样的一道生产流程:

我想你也经历过这样的过程!

作为Web开发者而言,要修复这些差异很容易。然而,Web开发人员还原出来的结果和设计稿存有差异却有诸多原因,而这些原因会因不同的原因而不同。比如:

  • 懂不懂设计
  • 有没有足够的项目开发时间
  • CSS方面能力怎么样
  • 是否注重细节

简而言之,不管是什么原因,我始终不认为开发者不知道如何修复还原出来的差异,让最终效果更接近原始设计稿(达到设计师诉求)。对于我来说,我认为每个Web开发者都有一个思维模式。当开发者有一定的设计知识(或能力)的时候,你会发现还原出来的Web页面结果在差异性方面就会小很多;反之,如果Web开发人员对设计方面一点都不懂,那么还原出来的结果和设计稿相比就会差得比较大。

似乎要让 UI 完美的像素级还原并不仅仅是按照设计稿完成UI页面即可,这里面还会涉及到很多和设计相关的话题,但这方面的话题并不是我们今天要讨论的。即使如此也不一定就能完美的像素级还原,因为和我们采用的技术方案也有着紧密的联系。比如说,我们针对移动端采用的布局方案,比如说现在业内比较热门的两种移动端布局方案,即 Rem适配VW适配

说实话,这两种布局方案的基本原理都是根据视窗宽度动态调整CSS值,即可对UI界面的缩放。为此,我们再花点时间来说 UI 缩放相关的话题。

UI 界面的缩放

简单地说, Rem适配VW适配 聊的都是通过 UI 缩放的原理让 UI 界面适配各种不同的移动终端。这两种布局方案的基本原理都是根据视窗宽度动态调整CSS值,事实上,在小站上有关于根据视窗宽度动态调整 CSS的文章还有很多,比如:

不过,这些文章中提到的相关技术方案都是使用 相对的 CSS 单位和无单位值 来实现动态缩放(根据视窗宽度进行缩放)。 但这些方案都有一个共同的致命点,失去了像素的完美性,而且一旦屏幕低于或高于某个阈值,通常就会出现布局的移动或文字内容的溢出。

但是,如果我们要真要实像素级的完美还原 UI 呢?比如说,我们现在所采用的 VW 适配方案,只适合于移动的手机终端,换到平板或PC设备的时候,就会给用户带来不好的体验,而且还不能像 REM布局方案,在宽屏幕时让其在屏幕中水平居中。或者说,如果你要制作一个屏幕展示的数据图表,并且可能在不同的终端上浏览(比如超大的屏幕,会议室电视,老板的平板或手机终端),那又应该怎么办?简单地说,这一切让我们需要更精确的还原。

也就是说,如果我们想统一扩展设计,又该怎么办?当然,我们可以根据文本所涉及的可用宽度,用 CSS 变换来缩放内容。这样,正确的比例就被保留。

然而,我们也可以使用 CSS 中的 px(像素)单位值来实现流体比例缩放的 UI。它们根据设备的屏幕空间进行适当的缩放,同时保留其像素级的完美比例。此外,对于开发者而言,使用像素值更易于和设计相匹配,开发体验也更强。

如何像素级完美还原一个可具缩放的 UI 界面

接下来将以 Georgi Nikoloff 在 Codepen 上写的一个案例为例:

上面这个示例以一种方式对下面这样的设计稿进行了完美地缩放,并保留所有文本的行数、边距、图像尺寸等:

上图这样的设计对于 Web 前端开发者而言应该很熟悉,特别是常开发 PC 端产品或大屏幕的同学而言,更没有什么特殊性,也没有什么花哨的东西。

另外,上图的设计是基于1600px宽度进行设计的。在这个设计尺寸状态下,我们可以获取到设计稿中所有 UI 元素下的像素值,比如元素的宽度、高度,文本的字号等。就拿设计稿中的卡片上的标题字号为例,它是16px。此时,我们可以这样来理解,UI 界面在 1600px 时,卡片标题大小在“理想状态”(和设计稿宽度1600px容器相匹配)下,应该是16px。事实上,设计稿也是这样设计的。

现在我们有了这个宽度的“理想”容器宽度下的字体大小,让我们使用当前“视窗宽度”来相应的调整我们的 CSS 像素值。

/**
* ①:设计师提供设计稿时,容器宽度(理解成页面宽度)是 1600px 
* ②:用户当前设备视窗宽度  
**/

:root {
    --ideal-viewport-width: 1600;    /* ① */
    --current-viewport-width: 100vw; /* ② */
}

.card__heading {
    /**
    * ①:UI 设计希望在 1600px 视窗宽度下卡片标题最理想的字体大小是 16px
    * ②:计算实际的字体大小。计算公式 理想字体大小 x (当前视窗宽度 / 理想视窗宽度)
    */

    --ideal-font-size: 16; /* ① */
    font-size: calc(var(--ideal-font-size) * (var(--current-viewport-width) / var(--ideal-viewport-width)))
}

正如你所看到的,我们将从设计稿中获得的理想字体大小作为一个基数,并将其乘以当前视窗宽度和理想视窗宽度之间的比率。

--current-device-width: 100vw; // 代表理想宽度(设计稿宽度)或屏幕的全宽
--ideal-viewport-width: 1600;  // 理想宽度和当前宽度是相匹配的
--ideal-font-size: 16;

// 相当于
font-size: calc(16 * 1600px / 1600);

// 等同于
font-size: calc(16 * 1px);

// 结果
font-size: 16px

由于我们的视窗宽度和理想宽度完全稳合,字体大小在理想视窗宽度 1600px 下正好是16px。假设你的移动设备(比如笔记本)的视窗宽度是1366px,也就是说在笔记本上浏览这个页面(1600px设计稿下对应的页面)。按照上面的计算方式,那会是:

// 视窗宽度变成 1366px
font-size: calc(16 * 1366px / 1600);

// 等同于
font-size: calc(16 * .85375px);

// 结果
font-size: 13.66px

如果换成在 1920px宽的显示器浏览时,计算就变成:

// 视窗宽度 1920px
font-size: calc(16 * 1920px / 1600);

// 等同于
font-size: calc(16 * 1.2px);

// 最终结果
font-size: 19.2px

尽管我们使用像素值作为参考,但实际上能够根据理想视窗宽度和当前视窗宽度之间的比例来调整 CSS 属性的值。

上面我们演示是 font-size 下的计算方式,但在还原一个 UI 设计稿的时候,有很多元素的 CSS 属性都会用到长度单位的值,比如 widthheightborder-widthpaddingmargin等。那么在这些要采用长度单位属性都可以采用这种方式来做计算。比如说,卡片的宽度在设计稿状态下是690px,那么我们可以像font-size这样来对width进行计算:

:root {
    --ideal-viewport-width: 1600;
    --current-viewport-width: 100vw;
}

.card {
    --ideal-card-width: 690; /* 卡片宽度 */
    width: calcc(var(--ideal-card-width) * (var(--current-viewport-width) / var(--ideal-viewport-width)))
}

当你的设备的视窗宽度刚好和理想的视窗宽度相等,即1600px,那么:

width: calc(690 * 1600px / 1600);

// 等同于
width: calcc(690 * 1px);

// 结果
width: 690px;

你设备视窗宽度从1600px换到1366px时(相当于--current-viewport-width100vw就是1366px),那么:

width: calc(690 * 1366px / 1600);

// 等同于
width: calc(690 * 0.85375px);

// 结果
width: 589.0875px;

同样的,视窗宽度换到1920px时:

width: calc(690 * 1920px / 1600);

// 等同于
width: calc(690 * 1.2px);

// 结果
width: 828px;

文章开头 Georgi Nikoloff 就是采用这种方式对各个元素做了计算,最终看到的效果如下:

按照前面的介绍,我们可以得到一个像素缩放计算的公式:

元素像素缩放计算值 = 设计稿上元素尺寸基数 x  100vw / 设计稿视窗宽度基数
  • 设计稿上元素尺寸基数 :指的是设计稿上 UI 元素尺寸的基数值,不带任何单位。比如设计稿上的某个UI元素的字号是16px,那么代表font-size的基数值是16,该值也被称为理想尺寸值
  • 100vw :代表是访问应用设备当前视窗的宽度
  • 设计稿视窗宽度基数 :指的是设计稿的尺寸宽度,该宽度也被称为理想视窗宽度,比如目前移动端设计稿都是750px宽度做的设计,那么这个理想视窗宽度(设计稿视窗宽度基数)就是750
  • 元素像素缩放计算值 : 指的就是 UI 元素根据计算公式得到的最终计算值,它会随着设备的当前视窗宽度值做缩放。

上面这几个值中,“设计稿上元素尺寸基数”和“设计稿视窗宽度基数”是固定值,除非设计师将设计稿整体尺寸做了调整。100vw大家都应该熟悉,它的单位vwCSS 单位中的视窗单位,会随着用户设备当前视窗宽度做变化。这也造成最终计算出来的值(元素像素缩放计算值)也是一个动态值,会随用户当前设备视窗宽度做出调整。

给像素级缩放加把锁

使用上面这种方式,虽然能让 UI 元素尺寸大小根据视窗大小做出动态计算,从而实现完美的像素级缩放,但其还是略有不完美之处,因为不管视窗的大小,最终都会影响到计算值,也会影响到 UI 界面的最终效果。为此,我们可以给这种方式添加一把锁,类似于 《给CSS加把锁》一文提到的相关原理:

并且随着 CSS 函数 中的 比较函数 的到来,我们可以使用 CSS 的clamp() 函数来控制用户的最小视窗和最大视窗的宽度。比如说,你的最小视窗是320px,最大视窗是3840px。也就是说,我们可以使用clamp(320px, 100vw, 3840px)来替代--current-viewport-width(即100vw)。这就意味着,如果我们在视窗宽度为5000px的设备上浏览Web应用或页面时,整个页面的布局(UI元素的尺寸大小)将被锁定在3840px

如果我们把clamp()放到计算公式中,可以像下面这样来做计算:

:root {
    /* 理想视窗宽度,就是设计稿宽度 */
    --ideal-viewport-width: 1600;
    /* 当前视窗宽度 100vw */
    --current-viewport-width: 100vw;
    /* 最小视窗宽度 */
    --min-viewport-wdith: 320px;
    /* 最大视窗宽度 */
    --max-viewport-width: 3840px;

    /**
    * clamp() 接受三个参数值,MIN、VAL 和 MAX,即 clamp(MIN, VAL, MAX)
    * MIN:最小值,对应的是最小视窗宽度,即 --min-viewport-width
    * VAL:首选值,对应的是100vw,即 --current-viewport-width
    * MAX:最大值,对应的是最大视窗宽度,即 --max-viewport-width
    **/
    --clamped-viewport-width: clamp(
        var(--min-viewport-width), var(--current-viewport-width), var(--max-viewport-width)
    )
}

.card__heading {
    /* 理想元素尺寸基数 */
    --ideal-font-size: 16; /* 1600px 设计稿中卡片标题的字号,理想字号*/
    font-size: calc(
        var(--ideal-font-size) * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    )
}

.card {
    /* 理想元素尺寸基数 */
    --ideal-card-width: 690; /*1600px 设计稿中卡片宽度,理想宽度*/
    width: calc(
        var(--ideal-card-width) * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    )
}

借助编译器让像素缩放变得更简单

看上去很完美了,但你可能已经发现了,如果我们在每个元素上涉及到长度单位的属性都要像下面这样写的话会感到很痛苦:

:root {
    --ideal-viewport-width: 1600;
    --current-viewport-width: 100vw;
    --min-viewport-width: 320px;
    --max-viewport-width: 1920px;
    --clamped-viewport-width: clamp(
        var(--min-viewport-width), var(--current-viewport-width), var(--max-viewport-width)
    )
}

.card {
    font-size: calc(
        14 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    width: calc(
        500 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    border: calc(
        2 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    ) solid rgb(0 0 0 / .8);
    box-shadow: 
        calc(2 * var(--clamped-viewport-width) / var(--ideal-viewport-width)) 
        calc(2 * var(--clamped-viewport-width) / var(--ideal-viewport-width)) 
        calc(6 * var(--clamped-viewport-width) / var(--ideal-viewport-width))   
        calc(10 * var(--clamped-viewport-width) / var(--ideal-viewport-width))   
        rgb(0 0 0 / .5)
}

上面这样的代码除了写起来痛苦之外,维护起来也是非常的蛋疼。不过,我们可以借助 CSS 处理器来辅助我们,让事情变得更简单一些。比如说,可以使用 SCSS的函数特性,编写一个具有像素缩放值的函数:

/**
 * @param {Number} $value - 理想尺寸基数,不带任何单位,设计稿对应的元素尺寸值,eg 设计稿元素宽度是500,$value = 500
 * @param {Number} $idealViewportWidth - 理想视窗宽度基数,不带单位,设计稿的宽度
 * @param {String} $min - 最小视窗宽度
 * @param {String} $max - 最大视窗宽度
**/
@function scalePixelValue($value, $idealViewportWidth: 1600, $min: 320px, $max: 3480px) {
    @return calc(
        #{$value} * (clamp(#{$min}, 100vw, #{$max}) / #{$idealViewportWidth})
    )
}

有了这个函数之后,我们可以像下面这样使用:

.card {
    font-size: #{scalePixelValue(14)};
    width: #{scalePixelValue(500)};
    border: #{scalePixelValue(2)} solid rgb(0 0 0 / .8);
    box-shadow: #{scalePixelValue(2)} #{scalePixelValue(2)} #{scalePixelValue(6)} #{scalePixelValue(10)} rgb(0 0 0 / .5)
}

编译出来的代码如下:

.card {
    font-size: calc( 14 * (clamp(320px, 100vw, 3480px) / 1600) );
    width: calc( 500 * (clamp(320px, 100vw, 3480px) / 1600) );
    border: calc( 2 * (clamp(320px, 100vw, 3480px) / 1600) ) solid rgba(0, 0, 0, 0.8);
    box-shadow: calc( 2 * (clamp(320px, 100vw, 3480px) / 1600) ) calc( 2 * (clamp(320px, 100vw, 3480px) / 1600) ) calc( 6 * (clamp(320px, 100vw, 3480px) / 1600) ) calc( 10 * (clamp(320px, 100vw, 3480px) / 1600) ) rgba(0, 0, 0, 0.5);
}

还可以像下面这样使用scalePixelValue()函数,传你自己想要的值:

.card {
    font-size: #{scalePixelValue(14, 1600, 320px, 1920px)};
    width: #{scalePixelValue(500, 1600, 320px, 1920px)};
    border: #{scalePixelValue(2, 1600, 320px, 1920px)} solid rgb(0 0 0 / .8);
    box-shadow: 
        #{scalePixelValue(2, 1600, 320px, 1920px)} 
        #{scalePixelValue(2, 1600, 320px, 1920px)} 
        #{scalePixelValue(6, 1600, 320px, 1920px)} 
        #{scalePixelValue(10, 1600, 320px, 1920px)} 
        rgb(0 0 0 / .5);
}

编译出来的代码:

.card {
    font-size: calc( 14 * (clamp(320px, 100vw, 1920px) / 1600) );
    width: calc( 500 * (clamp(320px, 100vw, 1920px) / 1600) );
    border: calc( 2 * (clamp(320px, 100vw, 1920px) / 1600) ) solid rgba(0, 0, 0, 0.8);
    box-shadow: 
        calc( 2 * (clamp(320px, 100vw, 1920px) / 1600) ) 
        calc( 2 * (clamp(320px, 100vw, 1920px) / 1600) ) 
        calc( 6 * (clamp(320px, 100vw, 1920px) / 1600) ) 
        calc( 10 * (clamp(320px, 100vw, 1920px) / 1600) ) 
        rgba(0, 0, 0, 0.5);
}

除了上面这样编写一个 SCSS 的函数之外,你还可以编写其他 CSS 处理器的函数。如果熟悉 PostCSS 插件开发的话,还可以编写一个 PostCSS 插件。如果没有编写 PostCSS 插件这方面的经验,可以阅读下面这几篇文章:

如果你感兴趣的话,可以尝试着把上面的 SCSS 编写的scalePixelValue()函数转我成一个PostCSS插件。

有的时候,我们还会使用 JavaScript 方式来改变一个元素的尺寸。比如说,动态改变一个元素的字号,位置等。针对这样的场景,我们可以在 JavaScript 中编写一个函数scalePixelValue()

/**
* @param {Number} value - 元素的理想值基数,对应设计稿上的值
* @param {Number} idealViewportWidth - 理想视窗宽度,对应设计稿宽度
**/
const scalePixelValue = (value, idealViewportWidth = 1600) => {
    return value * (window.innerWidth / idealViewportWidth)
}

移动端上的实战

现在我们经常开发的都是移动端上的页面,大部分采用的都是 Rem适配VW适配 方案。那么我们来尝试今天介绍的方案,在移动端上会是一个什么样的效果。接下来,我会拿下面这样的一个效果来实战一把:

注意,我们接下来只会还原上图中蓝色框框起的那张卡片,即:

目前为止,针对于移动端(移动手机设备)的设计稿都是以 750px 宽度的设计稿为基础。按照上面所介绍的内容,那么--ideal-viewport-width (理想视窗宽度)是 750

:root {
    --ideal-viewport-width: 750;
}

而当前视窗宽度同样是100vw,即--current-viewport-width100vw

:root {
    --ideal-viewport-width: 750;
    --current-viewport-width: 100vw;
}

如果你想给其加把锁,限制缩放在某个范围,可以根据现在主流移动手持设备的屏幕分辨率来做选择:

比如说在 320px ~ 1440px 范围内,即可--min-viewport-width320px--max-viewport-width1440px

:root {
    --ideal-viewport-width: 750;
    --current-viewport-width: 100vw;
    --min-viewport-width: 320px;
    --max-viewport-width: 1440px;
}

如此一来,就可以获得--clamped-viewport-width

:root {
    --ideal-viewport-width: 750;
    --current-viewport-width: 100vw;
    --min-viewport-width: 320px;
    --max-viewport-width: 720px;
    --clamped-viewport-width: clamp(
        var(--min-viewport-width), var(--current-viewport-width), var(--max-viewport-width)
    )
}

有了这些基本参数,就可以根据设计稿的尺寸得出相应的缩放像素值。

<!-- HTML -->
<div class="root">
    <div class="tab__content">
        <div class="card">
            <div class="card__media">
                <div class="media__object">
                <img src="https://picsum.photos/180/180?random=2" alt="">
                </div>
            </div>
            <div class="card__content">
                <h3 class="card__heading">星巴克猫爪杯八个字</h3>
                <p class="card__body">这是一段推荐文字九十一二三四五</p>
                <div class="card__footer">
                    <div class="card__price">
                        <div class="card__price--current">
                        <span>&yen;</span>
                        <strong>1</strong>
                        </div>
                        <del class="card__price--orgion">&yen;280.06</del>
                    </div>
                    <button class="card__button">1元抢</button>
                </div>
                <div class="card__badge">本周特推</div>
            </div>
        </div>
    </div>
</div>

/* CSS */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    width: 100vw;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    background-color: #ffce00;
    color: #fff;
    padding: 20px;
}

.tab__content {
    background: #ff9500;
    border-radius: 36px;
    padding: 22px;
    width: 702px;
}

.card {
    display: flex;
    background: #ffffff;
    position: relative;
    box-shadow: 0 4px 4px 0 #ff5400, inset 0 -2px 0 0 rgba(255, 255, 255, 0.51),
        inset 0 -7px 6px 3px #ffcca4;
    border-radius: 38px;
    padding: 24px;
}

.card__media {
    display: flex;
    justify-content: center;
    align-items: center;
    overflow: hidden;
    width: 170px;
    height: 170px;
    border-radius: 24px;
    margin-right: 20px;
}

.card__media img {
    width: 100%;
    aspect-ratio: 1/1;
    display: block;
    border-radius: 24px;
}

.card__content {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
}

.card__content > * {
    width: 100%;
}

.card__heading {
    color: #000;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    font-weight: 900;
    font-size: 30px;
    margin-bottom: 6px;
}

.card__body {
    color: #5b5b5b;
    font-size: 24px;
}

.card__footer {
    display: flex;
    margin-top: auto;
    align-items: flex-end;
    justify-content: space-between;
}

button {
    display: inline-flex;
    justify-content: center;
    align-items: center;
    font-weight: 600;
    border: none 0;
    color: #fff;
    border-radius: 10rem;
    background-image: linear-gradient(180deg, #f74b4b 0%, #e32828 99%);
    min-width: 210px;
    min-height: 62px;
    padding: 0 20px;
    font-size: 26px;
}

.card__price {
    display: flex;
    color: #5b5b5b;
    align-items: flex-end;
    line-height: 1;
    font-size: 22px;
}

.card__price--current {
    font-weight: 600;
    color: #ff1300;
    font-size: 24px;
}

.card__price--current span {
    margin-right: -4px;
}

.card__price--current strong {
    font-size: 46px;
}

.card__price--orgion {
    text-decoration: none;
    position: relative;
    margin-left: 8px;
    bottom: 0.1325em;
}

.card__price--orgion::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    background-color: currentColor;
    z-index: 2;
    top: 50%;
    transform: translate3d(0, -50%, 0);
    height: 2px;
}

.card__badge {
    position: absolute;
    z-index: 2;
    top: 0;
    left: 0;
    display: inline-flex;
    justify-content: center;
    align-items: center;
    background-image: linear-gradient(180deg, #f74b4b 0%, #e32828 99%);
    font-weight: 600;
    font-size: 24px;
    border-radius: 36px 0 36px 0;
    min-width: 146px;
    min-height: 42px;
}

你将看到一个使用px单位实现的固定尺寸的卡片:

目前没有任何可缩放而言。大家不要急,我们先把示例中使用到固定单位px单独巴拉出来,为后续实现可缩放性做准备:

body {
    padding: 20px;
}

.tab__content {
    border-radius: 36px;
    padding: 22px;
    width: 702px;
}

.card {
    box-shadow: 0 4px 4px 0 #ff5400, inset 0 -2px 0 0 rgba(255, 255, 255, 0.51),
        inset 0 -7px 6px 3px #ffcca4;
    border-radius: 38px;
    padding: 24px;
}

.card__media {
    width: 170px;
    height: 170px;
    border-radius: 24px;
    margin-right: 20px;
}

.card__media img {
    border-radius: 24px;
}

.card__heading {
    font-size: 30px;
    margin-bottom: 6px;
}

.card__body {
    font-size: 24px;
}


button {
    min-width: 210px;
    min-height: 62px;
    padding: 0 20px;
    font-size: 26px;
}

.card__price {
    font-size: 22px;
}

.card__price--current {
    font-size: 24px;
}

.card__price--current span {
    margin-right: -4px;
}

.card__price--current strong {
    font-size: 46px;
}

.card__price--orgion {
    margin-left: 8px;
}

.card__price--orgion::after {
    height: 2px;
}

.card__badge {
    font-size: 24px;
    border-radius: 36px 0 36px 0;
    max-width: 146px;
    min-height: 42px;
}

将前面定义好的基础变量引入进来,并按像素缩放公式将上面的代码替换成:

:root {
    --ideal-viewport-width: 750;
    --current-viewport-width: 100vw;
    --min-viewport-width: 320px;
    --max-viewport-width: 1440px;
    --clamped-viewport-width: clamp(
        var(--min-viewport-width),
        var(--current-viewport-width),
        var(--max-viewport-width)
    );
}

body {
    padding: calc(
        20 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.tab__content {
    border-radius: calc(
        36 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    padding: calc(
        22 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    width: calc(
        702 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card {
    box-shadow: 0
        calc(4 * var(--clamped-viewport-width) / var(--ideal-viewport-width))
        calc(4 * var(--clamped-viewport-width) / var(--ideal-viewport-width)) 0
        #ff5400,
        inset 0
        calc(-2 * var(--clamped-viewport-width) / var(--ideal-viewport-width)) 0 0
        rgba(255, 255, 255, 0.51),
        inset 0
        calc(-7 * var(--clamped-viewport-width) / var(--ideal-viewport-width))
        calc(6 * var(--clamped-viewport-width) / var(--ideal-viewport-width))
        calc(3 * var(--clamped-viewport-width) / var(--ideal-viewport-width))
        #ffcca4;
    border-radius: calc(
        38 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    padding: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__media {
    width: calc(
        170 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    height: calc(
        170 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    border-radius: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    margin-right: calc(
        20 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__media img {
    border-radius: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__heading {
    font-size: calc(
        30 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    margin-bottom: calc(
        6 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__body {
    font-size: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

button {
    min-width: calc(
        210 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    min-height: calc(
        62 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    padding: 0
        calc(20 * var(--clamped-viewport-width) / var(--ideal-viewport-width));
    font-size: calc(
        26 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__price {
    font-size: calc(
        22 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__price--current {
    font-size: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__price--current span {
    margin-right: calc(
        -4 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__price--current strong {
    font-size: calc(
        46 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__price--orgion {
    margin-left: calc(
        8 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

.card__price--orgion::after {
    height: calc(2 * var(--clamped-viewport-width) / var(--ideal-viewport-width));
}

.card__badge {
    font-size: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    border-radius: calc(
        36 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
        )
        0 calc(36 * var(--clamped-viewport-width) / var(--ideal-viewport-width)) 0;
    max-width: calc(
        146 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    min-height: calc(
        42 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

效果如下:

使用开发者工具的响应式审查器,你看到的效果会像下面这个录屏的效果:

注意,你可以使用 SCSS 的函数的重构上面的 CSS 代码。

我们再来验证一下,上面示例在真机上的效果:

注意,并没有测试所有真机设备。

这种技术的缺陷

上面这个示例看上去蛮不错的。但它还是有一定缺陷的。我们先回到Georgi Nikoloff写的示例

这个示例效果在宽屏下效果蛮好的。但随着屏幕变窄之后,效果就不尽人意了。那是因为这种技术方案是 无论用户如何缩放,设计都将保持锁定,就像在100%的缩放下观看一样。效果也有点类似于 ctrl + +ctrl + -的效果。

不过不用担心,我们可以使用 CSS 媒体查询特性,在不同的视窗宽度设置不同的理想值,比如:

.card {
    width: calc(
        500 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    font-size: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    )
}

@media (min-width: 64em) {
    .card {
        width: calc(
            800 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
        );
        font-size: calc(
            42 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
        )
    }
}

除了这种传统的媒体查询方式之外,如果《2021年你可能不知道的 CSS 特性》中提到的容器查询特性得到更多浏览器支持之后,那么还可以使用容器查询来做相应的调整:

Una Kravets 在 Google I/O 开发大会上就分享了容器查询 @container ,她把它称为新的响式布局所需特性之一:​

也就是说,我们将来还可以使用@container来实现:

.card {
    contain: layout inline-size; 
    width: calc(
        500 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
    font-size: calc(
        24 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
    );
}

@container (min-width: 64em) { 
    .card {
        width: calc(
            800 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
        );
        font-size: calc(
            42 * var(--clamped-viewport-width) / var(--ideal-viewport-width)
        )
    }
}

有关于 @container 更多的介绍,我们后面会有专门的内容来阐述!

另外,使用过 Rem适配VW适配 的同学,或多或少都知道他们存在的缺陷,特别是 VW适配 ,在宽屏幕下全只能是全屏,不能像 Rem适配 让其在宽屏下水平居中。但我们今天所介绍的方案,是不存在这样的缺陷的,既能在大屏幕下让其水平居中,又能比 REM 布局的限制更灵活(而且还不需要依赖任何的 JavaScript)。或者说,如果哪一天,你的页面开始要适配折叠设备,不管是 REM 还是 VW 适配方案,都存在一定的缺陷,无法较好的满足。但在折叠设备下,采用这种方案,并且配合 @media 或未来的@container(说不定明天就OK),那么会让事情变得更简单,也更灵活。

如果你对折叠设备相关的技术感兴趣的话,还可以阅读:

小结

回过头来想,这种像素缩放技术和 Bootstap 团队提出的 RFS 技术是非常相似的,只不过 RFS 技术更专注于字号随着视窗大小调整。而我们今天和大家一起探讨的技术更像是百分百缩放的技术。这种技术最大的亮点就是 像素理想值视为纯数值,并将其乘以当前视窗宽度与设计稿理想宽度比率。他虽然能规避 Rem适配VW适配 方案的一些缺陷,但它也会让代码变得更冗余,也更难以理解和维护。不过,我们可以借助 SCSS 或 PostCSS 这样的 CSS 处理器,让开发者编码变得简易,甚至是无脑还原。只是编译出来代码,一些开发者阅读和理解起来还是有一定难度的。

虽然这种技术方案有一定的缺陷,但它更具扩展性,配合CSS媒体查询和容器查询等特性,可能会让不来的布局更具扩展和灵活性,而且也能更好的适配于更多的智能终端。或许说,哪一天它将会是未来的主流适配方案之一。如果大家感兴趣的话,不妨一试。最后提醒大家一下,在一些设备上他还是有一定兼容性的风险,主要是 CSS 自定义属性 和 CSS比较函数,以及CSS的容器查询,支持的设备到目前为止还是有一定的限制。但我还是想说,这不是阻碍我们向前探索新技术的障碍,另外任何新技术,只有更多的人用起来,才会得到更好的支持。

最后再啰嗦一句,如果你有这方面经验,或更好的实战经验,欢迎与我一起探讨!