logo头像

总有人间一两风,填我十万八千梦

如何将用户体验做到极致?

理论

最权威的互联网无障碍规范

Web 内容无障碍指南(WCAG)2.1涵盖了使 Web 内容更易于访问的各种建议。遵循这些准则将使更多残疾人更容易获取网站内容,其中包括失明和弱视、耳聋和听力丧失、运动受限、言语障碍、光敏性和多种残疾组合的残疾人,以及有学习障碍和认知局限的残疾人; 但不会满足这些残疾用户的所有需求。这些准则旨在解决台式机,笔记本电脑,平板电脑和移动设备上的 Web 内容的无障碍问题。遵循这些准则通常也会使网站内容对用户更有用。

为什么速度很重要?

https://web.dev/why-speed-matters/

HTML img 标签 vs CSS background-image

英文原文:HTML img tag vs CSS background-image
中文译文:[译] HTML img 标签 vs CSS background-image
StackOverflow 问题链接:When to use IMG vs. CSS background-image?

使用 img 标签:

  1. 如果是和内容有关——不仅仅是一个设计辅助元素
  2. 如果你需要被搜索引擎索引到。谷歌不会自动索引到背景图片,否则图片搜索结果将充满雪碧图。
  3. 图片标签可以添加描述:文字(alt)和标题属性(title),这些信息可以被屏幕阅读器和搜索引擎获取。
  4. 如果你有很多图片要声明在你的 css 中,浏览器将花费更长的时间去解析 css 文件并下载图片,这会延迟整个页面的加载。而如果换成 img 标签,请求是在解析 HTML 时发出的,所以文档中在这个标签前面的任何内容都是用户可以开始阅读的信息,这对于性能来说是很好的一点。
  5. 内联图片可以利用诸如 picturefill 和懒加载这样的工具来进行更多的性能优化。
  6. 如果你计划会有用户打印你的页面并且你希望图片默认包含在打印内容中。
  7. 如果你依赖浏览器缩放来呈现与文本大小成比例的图像。

什么时候使用 CSS background-image?

  1. 如果纯粹是用来设计,并不是内容的一部分;
  2. 对于小的图片,如果你需要提高下载时间,和雪碧图一样。
  3. 如果你计划会有人打印你的页面并且不希望图偏默认包含在打印内容中。
  4. 重复图片(比如博客作者图标,日期图标会被重复用于每一篇文章)

如果你需要在 HTML img 标签和 CSS background-image 之间做选择时——仅仅问自己一个问题:这个图片能帮助用户更好地理解我的内容吗?如果答案是肯定的——使用 img 标签。如果答案是否定的——把它做成背景图。最后——如果两种方式都可以提供相同的视觉结果——你只需要定义在你的具体情况中哪个更有意义。

移动端黄金 3 秒

来自:技术轮回,静态 Web 再度成为新趋势?

Neil Patel 是 SEO 社区中的知名人物,他制作了一个关于这个话题的非常全面的信息图。在报告中他估计有 40%的用户(和 53%的移动用户)会在网页加载时间超过 3 秒时直接关掉页面。

文章每一行长度

来自:Full-Bleed Layout Using CSS Grid

Research has shown that the ideal line length is about 65 characters. Anywhere between 45 and 85 is generally seen as acceptable, in the context of a roman alphabet. Reading is a complex process, and we should strive to make it as easy as possible.

研究表明,理想的线条长度约为 65 个字符。在罗马字母的背景下,45 到 85 之间的任何位置通常被认为是可以接受的。阅读是一个复杂的过程,我们应该努力让它尽可能简单。

CLS

参考:优化 Cumulative Layout Shift 累积布局偏移

累积布局偏移 (CLS):核心 Web 指标 中的一项指标,通过计算未在用户输入 500 毫秒内发生的布局偏移的偏移分数总和来测量内容的不稳定性。该项指标查看可视区域中可见内容的位移量以及受影响元素的位移距离。

CLS 较差的最常见原因为:

  • 无尺寸的图像
  • 无尺寸的广告、嵌入和 iframe
  • 动态注入的内容
  • 导致不可见文本闪烁 (FOIT)/无样式文本闪烁 (FOUT) 的网络字体
  • 在更新 DOM 之前等待网络响应的操作

具体如何解决这些问题,可以仔细阅读上面的参考文章,这里就点到为止了。

click、hover、mousedown 三者时间关系

我们都知道 iphone 判断双击会存在 300ms 的延迟,那为什么是 300ms 呢?可以通过这个页面测试一下:http://instantclick.io/click-test,可以发现 Click - Hover 在正常点击情况下大约为 300 ms:

instantClick 也是在这个理论的基础上,即当 mousedown 时就开始加载网页来做到点击效果的优化(预加载),不过这个库已经好几年没更新过了;但这种思想在一些场景下还是得到了一定程度的应用,比如分页表格加载场景:

RAIL

Chrome 团队提出了一个以用户为中心的性能模型被称为 RAIL,它为工程师提供一个目标,只要达到目标的网页,用户就会觉得很流畅;它将用户体验拆解为一些关键操作,例如:点击,加载等;并给这些操作规定一个目标,例如:点击一个按钮后,多长时间给反馈用户会觉得流畅。

RAIL 将影响性能的行为划分为四个方面,分别是:Response 响应、Animation 动画、Idle 空闲与 Load 加载。

响应 Response

研究表明,100ms 内对用户的输入操作进行响应,通常会被人类认为是立即响应。时间再长,操作与反应之间的连接就会中断,人们就会觉得它的操作有延迟。例如:当用户点击一个按钮,如果 100ms 内给出响应,那么用户就会觉得响应很及时,不会察觉到丝毫延迟感。(这里有一个 demo,可以体验一下)

动画 Animation

现如今大多数设备的屏幕刷新频率是 60Hz,也就是每秒钟屏幕刷新 60 次;因此网页动画的运行速度只要达到 60FPS,我们就会觉得动画很流畅。

FFramesPPerSSecond 指的画面每秒钟传输的帧数,60FPS 指的是每秒钟 60 帧;换算下来每一帧差不多是 16 毫秒:

1
(1 秒 = 1000 毫秒) / 60 帧 = 16.66 毫秒/帧

但通常浏览器需要花费一些时间将每一帧的内容绘制到屏幕上(包括样式计算、布局、绘制、合成等工作),所以通常我们只有 10 毫秒来执行 JS 代码。

空闲 Idle

为了更好的性能,通常我们会充分利用浏览器空闲周期(Idle Period)做一些低优先级的事情。例如:在空闲周期预请求一些接下来可能会用到的数据或上报分析数据等。

RAIL 规定,空闲周期内运行的任务不得超过 50ms,当然不止 RAIL 规定,W3C 性能工作组的 Longtasks 标准也规定了超过 50 毫秒的任务属于长任务,那么 50ms 这个数字是怎么得来的呢?

浏览器是单线程的,这意味着同一时间主线程只能处理一个任务,如果一个任务执行时间过长,浏览器则无法执行其他任务,用户会感觉到浏览器被卡死了,因为他的输入得不到任何响应。

为了达到 100ms 内给出响应,将空闲周期执行的任务限制为 50ms 意味着,即使用户的输入行为发生在空闲任务刚开始执行,浏览器仍有剩余的 50ms 时间用来响应用户输入,而不会产生用户可察觉的延迟。如下图所示:

这个理论已经应用到 requestAnimationFrame 中:如果浏览器的工作比较繁忙的时候,不能保证它会提供空闲时间去执行 requestidlecallback 的回调,而且可能会长期的推迟下去。所以如果你需要保证你的任务在一定时间内一定要执行掉,那么你可以给 rIC 传入第二个参数 timeout(一般为 50ms)。
这会强制浏览器不管多忙,都在超过这个时间之后去执行 rIC 的回调函数。不过要谨慎使用,

加载 Load

如果不能在 1 秒钟内加载网页并让用户看到内容,用户的注意力就会分散。用户会觉得他要做的事情被打断,如果 10 秒钟还打不开网页,用户会感到失望,会放弃他们想做的事,以后他们或许都不会再回来。

小结

通过 RAIL,我们可以判断出我们的网页是否丝滑。RAIL 从用户感知角度出发规定了一些指标,只要我们的网页符合标准,则我们的网页是丝滑的,用户会觉得我们的网页很流畅:

RAIL 关键指标 用户操作
响应(Response) 小于 100ms 点击按钮。
动画(Animation) 小于 16ms 滚动页面,拖动手指,播放动画等。
空闲(Idle) 小于 50ms 用户没有与页面交互,但应该保证主线程足够处理下一个用户输入。
加载(Load) 1000ms 用户加载页面并看到内容。

官方 API

Shared Element Transitions

Shared Element Transitions 是一个新的 script 提案,它可以帮助我们在 SPA 或者 MPA 页面中实现元素过渡效果。

这个 API 从 Chrome 92 版本中开始试用,你可以通过在 Chrome 的 about:flags 中搜索 #document-transition 来开启这项试用。

你可以测试下 document 上是否存在 documentTransition 来验证 API 是否支持。

1
2
3
if ("documentTransition" in document) {
// Feature supported
}

这个提案主要分为两部分,第一个是完整的根过渡,第二个是指定一组共享元素进行过渡。顾名思义,根过渡的意思就是转换整个页面的根节点,下面我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// When the user clicks on a link/button:
async function navigateToSettingsPage() {
// Capture the current state.
await document.documentTransition.prepare({
rootTransition: "cover-left",
});

// This is a function within the web app that updates the DOM:
updateDOMForSettingsPage();

// Start the transition.
await document.documentTransition.start();
// Transition complete!
}

执行一次根过渡,只需要上面几行代码:

调用 documentTransition.prepare() 函数捕获当前页面的视觉状态
调用一个更新 DOM 的函数(比如改变页面的背景色),上面例子中用的是 updateDOMForSettingsPage() 函数
调用 documentTransition.start() 函数执行转换
另外,你还可以通过 rootTransition 属性来改变过渡的方向。

然后,你就拥有了一个非常丝滑的过渡效果:

-webkit-tap-highlight-color

既然有前缀,说明还没有成为浏览器标准,这个属性只用于 iOS (iPhone 和 iPad),用来设置点击链接的时候出现的高亮颜色。比如在移动端经常会出现点击链接出现蓝色(或灰色)背景的情况,就可以这样去掉:

1
2
3
.test {
-webkit-tap-highlight-color: transparent;
}

scroll-behavior

当用户手动导航或者 CSSOM scrolling API 触发滚动操作时,CSS 属性 scroll-behavior 为一个滚动框指定滚动行为,其他任何的滚动,例如那些由于用户行为而产生的滚动,不受这个属性的影响

user-select

用来控制用户能否选中文本,一般我们会将按钮的 user-select 属性设置为 none,这样可以避免用户点击时按钮文字被选中,从而出现样式问题(比如按钮文字变成了默认被选中的蓝色)

input-for

一般情况下,对于 checkbox 或者 radio 这种选择框,用户都倾向于点击 label 文本来选择和取消选择;但默认情况下,都只能点击很小的一块儿区域来进行选择:

这样的话体验很差,而解决方式其实非常简单,我们只需要增加一个 for 属性:

1
<input type="checkbox" id="option1" /> <label for="option1">Option 1</label>

或者你可以把 input 标签放到 label 标签里:

1
2
3
4
<label for="option1">
Option 1
<input type="checkbox" id="option1" />
</label>

如果再添加一个 padding 到 label 标签上就锦上添花了,这样的话可点击区域就变更大了:

hyphens

CSS 属性 hyphens 告知浏览器在换行时如何使用连字符连接单词。可以完全阻止使用连字符,也可以控制浏览器什么时候使用,或者让浏览器决定什么时候使用。

1
2
3
.element {
hyphens: auto;
}

onerror

参考:前端资源加载失败优化

我们可以给 单独的 script 标签添加上 onerror 属性,这样在加载失败时触发事件回调,从而捕捉到异常:

1
<script onerror='onError(this)'></script>

并且,借助构建工具( 如 webpack 的 script-ext-html-webpack-plugin 插件) ,我们可以轻易地完成对所有 script 标签自动化注入 onerror 标签属性,不费吹灰之力。

1
2
3
4
5
6
7
new ScriptExtHtmlWebpackPlugin({
custom: {
test: /\.js$/,
attribute: "onerror",
value: "onError(this)",
},
});

上述方案已然不错,但我们也试想是否可以减少 onerrror 标签大量注入呢?类比脚本错误 onerror 的全局监控方式,是否也可以通过 window.onerror 去全局监听加载失败呢?

答案否定的,因为 onerror 的事件并不会向上冒泡,window.onerror 接收不到加载失败的错误。冒泡虽不行,但捕获可以!我们可以通过捕获的方式全局监控加载失败的错误,虽然这也监控到了脚本错误,但通过 !(event instanceof ErrorEvent) 判断便可以筛选出加载失败的错误:

1
2
3
4
5
6
7
8
9
window.addEventListener(
"error",
(event) => {
if (!(event instanceof ErrorEvent)) {
// todo
}
},
true
);

当然,其实更多地情况下我们遇到的是图片加载失败的问题,而 onerror 也有效地解决了这个问题,通常我们通过占位图的方式来解决图片加载失败问题:

1
<img src="xxx.png" alt="图片的 alt 信息" onerror="this.src='break.svg';" />

然后配合 CSS:

1
2
3
img[src$="break.svg"] {
object-fit: contain;
}

就可以保证占位图的横宽比例是正常的。然而上面这种实现方式有一个比较致命的问题,那就是用户并不清楚无法显示的图片具体表示的含义是什么。对于用户而言,内容和功能绝对比视觉表现更重要。
原生的图片出错会显示图片的 alt 信息,这样,用户是能够知道图片描述的内容是什么,而这里使用占位图片兜底处理后,这些信息都没有了。
因此,传统的图片出错的处理方法可以有进一步的优化。为了便于维护,图像加载 error 的时候不再是替换 src 地址,而是新增一个错误类名,例如 .error:

1
<img src="zxx.png" alt="CSS新世界封面" onerror="this.classList.add('error');" />

然后配合使用如下所示的 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
28
29
30
img.error {
display: inline-block;
transform: scale(1);
content: "";
color: transparent;
}
img.error::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #f5f5f5 url(break.svg) no-repeat center / 50% 50%;
}
img.error::after {
content: attr(alt);
position: absolute;
left: 0;
bottom: 0;
width: 100%;
line-height: 2;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 12px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

此时就可以看到失败的图片和 alt 文字信息同时出现的效果了:

关于图片加载失败优化这部分来自于张鑫旭的文章:图片加载失败后 CSS 样式处理最佳实践,具体讲解欢迎看原文。

isInputPending

React Fiber 架构配合 Scheduler 实现了 Concurrent Mode 的底层 — “异步可中断的更新”。

那么,现在,其实我们不仅仅是在使用 React 的时候才能享受到这个优化策略。

在 Chrome 87 版本,React 团队和 Chrome 团队合作,在浏览器上加入了一个新的 API isInputPending。这也是第一个将中断这个操作系统概念用于网页开发的 API。

即便不使用 React,我们也可以利用这个 API,来平衡 JS 执行、页面渲染及用户输入之间的优先级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while (workQueue.length > 0) {
if (navigator.scheduling.isInputPending()) {
break;
}
let job = workQueue.shift();
job.execute();
}

while (workQueue.length > 0) {
if (navigator.scheduling.isInputPending(["mousedown", "mouseup", "keydown", "keyup"])) {
break;
}
let job = workQueue.shift();
job.execute();
}

从上面的代码示例可以看到,通过合理使用 isInputPending 方法,我们可以在页面渲染的时候及时响应用户输入,并且,当有长耗时的 JS 任务要执行时,可以通过 isInputPending 来中断 JS 的执行,将控制权交还给浏览器来执行用户响应。

代码片段

巧妙解决滚动条出现导致元素跳动的问题

小 tip:CSS vw 让 overflow:auto 页面滚动条出现时不跳动

我们经常会遇到这个问题,就是这个页面的内容是动态加载的,当没有很多内容时不会有滚动条,而当内容加载完成之后会出现滚动条,滚动条的出现会“挤压”页面主体内容,导致出现跳动的现象;那如何解决呢?

  1. 添加一个 left 间距:
1
2
3
4
.wrap-outer {
/* 换成 padding 也可以 */
margin-left: calc(100vw - 100%);
}

.wrap-outer 指的是居中定宽主体的父级,100vw 相对于浏览器的 window.innerWidth,是浏览器的内部宽度,注意,滚动条宽度也计算在内!而 100% 是可用宽度,是不含滚动条的宽度
于是,calc(100vw - 100%) 就是浏览器滚动条的宽度大小(如果有,如果没有滚动条则是 0)!左右都有一个滚动条宽度(或都是 0)被占用,主体内容就可以永远居中浏览器啦,从而没有任何跳动!

不过这个方法有一点小瑕疵,浏览器宽度比较小的时候,左侧留的白明显与右边多,说不定会显得有点傻。此时,可能需要做点响应式处理会更好一点:

1
2
3
4
5
@media screen and (min-width: 1150px) {
.wrap-outer {
margin-left: calc(100vw - 100%);
}
}
  1. 终极解决方案(具体原理未知):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
html {
overflow-y: scroll;
}

:root {
overflow-y: auto;
overflow-x: hidden;
}

:root body {
position: absolute;
}

body {
width: 100vw;
overflow: hidden;
}

使用伪元素来优化图片占位图显示

小 tip-一种图片加载状态效果的实现

一般,我们会通过先加载占位图后加载完整图片的方式来优化图片加载带来的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
loadImg (imgId) {
const img = new Image()
// 真实图片地址
const realSrc = 'http://a.com/real.png'
img.src = realSrc
img.onload = () => {
this.imgList.some(imgObj => {
if (imgObj.id === imgId) {
imgObj.src = realSrc
this.$forceUpdate()
return true
}
return false
})
}
}

那我们能不能不用 JS,而是把这部分替换的工作交给 CSS 呢?那关键就是不进行替换 img 标签 src 的操作,这样一下子就能节省大半的 js 操作,以下述 DOM 结构为例:

1
2
3
<div class="img-item">
<img :src="item.realUrl" alt="" />
</div>

item.realUrl 就是当前图片的真实地址,拿到就直接赋值上去,后续也不会再操作 src 这个属性了,因为大部分工作移到了 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
28
29
30
31
32
33
34
35
36
37
38
39
.img-item {
position: relative;
width: 80px;
height: 80px;
background-color: #aeaeae;
}
.img-item::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 22px;
height: 22px;
margin-left: -11px;
margin-top: -11px;
/* 这是加载效果的图片 */
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABAUlEQVQ4T6WSzyrEYRSGn2crF6GmJJTsLLGyEVM2lAshdriUsbAwsWeWlpJ/Kck1KNtXM330G8b84due8z7fed9z5J/P3/RJNoCDUt9XT3r1/gAkmVSfktwB00V0r84MBCS5AJaAc6A2EiDJOPBW+WUb2KtaSDKr3lYn6bKQ5AxYBS7V5WHy/QIkWSmCV/VhGHG7pwNIsg6cFlFdbfbZzlRHqI9VwCbQKKIt9bgPYKEArqqAMWCriBrq+0gWPpuTzAG7wKF68x2SpKY+99tC2/sa0FTr1cYkE8ALMK9ef9a+r7F9RDvAkdpK0ip+F0vY/SfoMXIXYOApDxvcrxn8BfABIiRjEYfmQAcAAAAASUVORK5CYII=")
no-repeat;
background-position: center center;
background-size: contain;
animation: loadpic 0.5s infinite linear;
z-index: 1;
}

@keyframes loadpic {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}

img {
position: relative;
height: 100%;
width: 100%;
z-index: 2;
}

.img-item 是包裹单个 img 的父元素,它的作用有两个:

  1. 撑开占据的空间
  2. 用它的 ::before 伪元素来加载用于标识图片正在加载的动效 loading 图,也就是说,将 loading 图放到了 ::before 上;::before 会一直存在,直到 .box 内的 img 资源下载完毕后显示出来,将其遮盖住,因为 img 元素的宽高与 .box 相同,并且 img 的 z-index 更大,所以只要 img 没有加载完毕,那么就不会显示,那么就会显示 ::before 元素,也就是显示 loading 状态,而只要 img 加载完毕了,那么就会显示出来,就会遮盖掉 ::before

下载和渲染网络字体可能通过两种方式导致布局偏移:

  • 后备字体替换为新字体(FOUT:无样式文本闪烁)
  • 新字体完成渲染前显示”不可见”文本(FOIT:不可见文本闪烁)

以下属性可以帮助您最大程度地减少这种情况:

  • font-display 能够通过使用 auto、swap、block、fallback 和 optional 值来修改自定义字体的渲染行为。不过,所有这些值(除 optional 外)都可能通过上述某种方式导致重排。
  • 字体加载 API 可以减少获取必要字体所需的时间。

从 Chrome 83 开始,推荐以下做法:

  • 在关键网络字体上使用<link rel=preload>:预加载的字体将有更大几率在首次绘制中出现,而在这种情况下将不会发生布局偏移。
  • <link rel=preload>font-display: optional 结合使用

可以阅读 通过预加载可选字体来防止布局偏移和不可见文本闪烁 (FOIT) 了解更多详情。

使用图像长宽比来防止 CLS 便宜

图片加载一般都会落后于网页加载,所以建议始终在图片和视频元素上设置 width 和 height 属性。或者通过使用 CSS 长宽比容器 预留所需的空间。这种方法可以确保浏览器能够在加载图像期间在文档中分配正确的空间大小。

那如何指定 width 和 height 呢,之前我们都是通过这种方式:

1
<img src="puppy.jpg" width="640" height="360" alt="小狗与气球" />

上方的 width 和 height 不包括单位。这些”像素”尺寸可以确保一块 640x360 的保留区域。无论图像的真实尺寸是否匹配,该图像都会被拉伸成保留区域的大小。

响应式网页设计得到引入后,开发者开始省略 width 和 height,并取而代之开始使用 CSS 来调整图像大小:

1
2
3
4
img {
width: 100%; /* or max-width: 100%; */
height: auto;
}

这种方法的一个缺点是,只有在图像开始下载且浏览器可以确定其尺寸后才能为图像分配空间。随着图像的加载,页面会随着每个图像出现在屏幕上而进行重排,因此导致文本常常突然出现在屏幕上。这与良好的用户体验相距甚远。

这种情况下就需要用到长宽比。图像的长宽比是图像宽度与高度的比例。我们通常用由冒号分隔的两个数字来表示长宽比(例如 16:9 或 4:3)。x:y 的长宽比表示图像的宽度为 x 单位,高度为 y 单位。

也就是说,如果我们知道其中一个维度,就可以确定另一个维度。对于 16:9 的长宽比:

  • 如果 puppy.jpg 的高度为 360px,则宽度为 360 x (16 / 9) = 640px
  • 如果 puppy.jpg 的宽度为 640px,则高度为 640 x (9 / 16) = 360px

在知道长宽比的情况下,浏览器就能够进行计算,并为高度和其关联区域预留足够的空间。感谢 CSS 工作组的努力,我们现在通过简单地设置就可以实现这种效果:

1
2
3
4
img {
height: auto;
width: 100%;
}

如果图片在容器中,可以使用 CSS 将图片大小调整为该容器的宽度。我们需要设置 height: auto; 来避免图像高度为某个固定值(例如 360px )。

srcset 和 picture 处理响应式图片

书接上文;处理响应式图片时,srcset 定义了允许浏览器选择的图片以及每个图片的大小。为了保证 <img> 的宽度和高度属性可以进行设置,每个图片都应该采用相同的长宽比。

1
2
3
4
5
6
7
<img
width="1000"
height="1000"
src="puppy-1000.jpg"
srcset="puppy-1000.jpg 1000w, puppy-2000.jpg 2000w, puppy-3000.jpg 3000w"
alt="小狗与气球"
/>

当然,还有另外一种情况,我们可能会想要在窄可视区域中包含一张剪裁后的图片,并在桌面上显示完整图像:

1
2
3
4
5
<picture>
<source media="(max-width: 799px)" srcset="puppy-480w-cropped.jpg" />
<source media="(min-width: 800px)" srcset="puppy-800w.jpg" />
<img src="puppy-800w.jpg" alt="小狗与气球" />
</picture>

这些图片很可能具有不同的长宽比,而浏览器仍然在评估这种情况下最有效的解决方案,比如是否应该在所有图片来源中写明尺寸。在确定解决方案前,该情况下仍然可能会进行重排。

使用伪元素扩大可点击区域

一般情况下,我们都会通过改变元素的宽高或者增加 padding 的方式来扩大可点击区域,但是有时候我们是不能更改元素的尺寸的,那这种前提下如何扩大可点击区域呢?答案是伪元素!

伪元素其实是其父元素的一部分,所以当我们指定了其伪元素的宽高时,它上面的 click、touch、hover 等事件会“冒泡”到它的父元素;所以我们可以添加一个伪元素,并设置一下样式:

1
2
3
4
5
6
7
8
9
10
.menu-2:after {
content: "";
position: absolute;
left: 55px;
top: 0;
width: 50px;
height: 50px;
background: #e83474;
/*Other styles*/
}

上门的 left: 55px 是为了更好地展示伪元素和父元素的大小关系,而我们在真正实现的时候是需要将这个伪元素居中的:

Flexbox 和长文本的溢出问题

There is a behavior that happens with flexbox and long content that causes an element to overflow its parent. Consider the following example:

当我们使用 flex 布局时,长文本有时候会导致溢出,比如下面这个例子:

1
2
3
4
5
6
<div class="user">
<div class="user__meta">
<h3 class="user__name">Ahmad Shadeed</h3>
</div>
<button class="btn">Follow</button>
</div>
1
2
3
4
5
6
7
8
9
10
.user {
display: flex;
align-items: flex-start;
}

.user__name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}

如果「用户名」这个文本比较短,是没有任何展示问题的:

但是如果是一个长文本,就会溢出:

这个问题出现的原因是 flex 布局下的元素是无法缩小到其内容最小尺寸的;为了解决这个问题,我们需要设置 min-width: 0:

1
2
3
4
.user__meta {
/* other styles */
min-width: 0;
}

更多细节见:Min and Max Width/Height in CSS

工具

jQuery-menu-aim

官网:https://github.com/kamens/jQuery-menu-aim

jQuery 插件,用来实现类似亚马逊菜单这样的流畅体验

AMP

AMP 简介

AMP 全称 Accelerated Mobile Pages,顾名思义是为了加速移动网络的网页加载从而提升体验。AMP 官网 上称其可在很多平台上提供卓越的用户体验。AMP 网页采用 3 大核心组件构建而成:

  1. AMP HTML
  2. AMP JS
  3. AMP Cache

整体上和原生开发没有什么区别,只不过需要符合它的一些规范;那 AMP 是如何提升加载速度的呢?

  • 仅允许异步脚本
  • 确定静态资源的大小
  • 内联 css 并且有大小限制
  • 仅运行 GPU 加速动画
  • AMP Cache,这是最重要的:

Google AMP Cache 是一种基于代理的内容交付网络,用于交付所有有效的 AMP 文档。它可提取 AMP HTML 网页,对这些网页进行缓存,并自动改进网页性能。使用 Google AMP Cache 时,文档,所有 JS 文件及所有图片都从使用 HTTP 2.0 的同一来源加载,从而可实现最高效率。

除了上述提到的规范,AMP 提供了一套完整的组件库,包括 amp-ifame 这样的 UI 组件,以及 amp-bind,amp-mustache 这样的功能组件。

另外,国内包括百度,360,搜狗,微博,QQ 空间等都支持 AMP,并且都有自己的 cdn 用来支持 AMP Cache,不同于搜索引擎,在微博和 QQ 空间上,并不会主动去抓取 AMP 页面,比如你分享了一个 AMP 页面到 QQ 空间当中,QQ 空间不会主动去抓取,但是只要有一个用户在 QQ 空间中点开了这个页面,QQ 空间就会收录缓存这个页面,当后面有人点击时,就是实现秒开的效果。

范例

百度的安全验证

其实这个一定程度上不算是提升用户体验的范例,不过第一次看到的时候感觉挺新鲜的,通过转动图片的方式进行安全验证,不知道这种验证方式相比滑块、按顺序点选、数字等传统的验证方式有什么优势:

transak 选取月份时的 UI 设计

众所周知,目前基本上每个平台都要求用户登记自己的生日信息,而生日这种三联选择经常搞的人很头疼,比如年份从 1900 年开始等等,不过 transak 在选取生日月份时的 UI 设计很贴心,将下拉改成了点选,同时每个月份都占了很大的一块儿空间,根本不用担心选错:

每日优鲜会发送重量误差这种短信提示

我在每日优鲜上买菜或者水果的时候,经常会收到重量误差的短信,然后会返还几毛钱,虽然你也不知道是不是真的有误差,但是给用户的感觉就是:这个平台很严谨,而且真的有称重,用着就会放心一些:

闲鱼在聊天页的兜底文案

其实对于大部分产品来说,空状态的兜底文案都是非常边界的 case,所以大家一般都会设置成“当前内容为空,XXX”类似的这种文案,不过我偶然发现了闲鱼在聊天页的兜底文案,感觉还蛮有趣的:

亚马逊对菜单滑动进行优化,实现二级菜单秒出

Breaking down Amazon’s mega dropdown

我们把鼠标 hover 到亚马逊的下拉菜单上面时,会发现二级菜单都是秒出的流畅体验,快到难以置信:

为什么说「难以置信」呢?因为大部分网站的下拉菜单,如果有二级菜单,都会有一些延迟:

那为什么会有这个延迟呢?其实这是因为可以避免我们将鼠标从一级菜单往二级菜单移动时,菜单消失的问题,比如 bootstrap 官网:

但是亚马逊的下拉菜单没有这个延迟,而且子菜单也不会在不应该消失的时候消失。它是怎样做到这一点的呢?答案是通过预测鼠标移动的方向和轨迹

想象在鼠标当前的位置和子菜单的左上角和左下角之间画一个三角形。如果鼠标在这个三角形的范围之内移动,那用户很有可能是在把鼠标从主菜单向子菜单里挪,所以不要立刻更新子菜单。但是如果鼠标挪动到这个三角形之外,则可以马上更新子菜单。这就是亚马逊主页反应速度超快的下拉菜单背后的算法。

webnovel 通过三层图片来实现图片渲染优化

webnovel 是一个小说社区,在 小说的详情页,需要展示设计师很用心制作的书封,为了避免影响正文的展示,不得不选择类似图片 Lazyload 的机制,让这个图片延迟加载,而这就需要一个表示 Loading 态的占位图;但等图片加载完成,占位图会一瞬间被我们的高清书封替换,于是此时就出现了我们不期望的闪动。

当用户一看到闪动,他们就会去思考你这个加载是快还是慢。但是我们要的是 「Don’t make me think 」。我们不希望让用户去思考除开我们产品之外的东西。所以我们得想个办法弱化甚至去除这种闪动。

讲到这里呢就不得不科普以下我们浏览器本身就有的图片缓存机制了。在一定时间段内你浏览器访问过的图片,会被浏览器缓存。当你再一次看到这个图片的时候,这个图片就直接读取浏览器缓存的内容一瞬就打开,不会有闪动问题。

于是我们就想是不是可以利用这个机制,解决之前闪动的问题。但是现在难点是,我们详情页的书封是比首页的书封大的。因为尺寸不一样所以是不同的图片,于是就没有缓存这个概念。
如果我们让详情页也用首页的书封,详情页小图被拉伸就会看起来很模糊。如果首页用详情页的大书封,首页的图片数据加载开销又太大。

我们这边采用的解决方案是,三层叠加法。我们把占位图,小书封,大书封,按照上图的层级完美的叠在了同一个位置。当用户从首页进入到我们详情页的时候,我们占位图和小书封都是直接使用浏览器缓存瞬间呈现的,因为小书封是叠在占位图之上的,所以用户是看不到占位图的。而此时大书封正在加载,当大书封加载好了之后,就会盖在小书封之上。等用户仔细看这个书封的时候,这个大书封其实很有可能已经加载完成。整个过程就从之前的占位图到大书封的闪现,变成了现在的小书封到大书封的渐变。其实这是很难被用户发现的。

看到这里可能有同学会问说,既然这里都看不到占位图,为什么还需要加载这个图呢?其实原因很简单,因为不是每个人都是从首页进入到详情页的。有可能用户是直接打开的这个链接。那么此时占位图就回到了最初的逻辑。先看到占位图然后再看到书封。当然我也得承认对于这样的用户,我们其实是多加载了一个小书封的资源的。但是对于体验上的优化来说,这一点资源的消耗我个人认为还是可以接受的。
还有一个好玩儿的点是,在这个地方因为同时加载了,占位图,小书封,大书封,它们作为图片也都会被浏览器缓存,当用户跳转到其它页面的时候,如果有相同的图片,那又是瞬开的。这样我们就充分的利用了浏览器缓存,让用户在我们网站上的体验得到了进一步的提升。

闲鱼对分享画报的探索

参考:对闲鱼分享组件升级后,才知道什么叫灵活可扩展…

闲鱼对分享画报的布局做了充分地考虑和设计:

对于画报的生成,其要解决的就是将给定的若干张图片拼接成一张定宽的长图,我们可以通过归纳法来解决这个问题:

  1. 通过图片的宽高大小关系来将图片分成横图(宽 ≥ 长)和长图(长 > 宽);
  2. 处理只有 1 张图的情况,我们只需要将这个图片缩放到所需的定宽即可;
  3. 处理只有 2 张图的情况,这个时候我们排列方式如下:

以第一张图为标准确定排列方式,将第二张图进行缩放;

  1. 对于张数为 n 的图片,我们都可以分解成 n=2∗x+1∗yn = 2 _ x + 1 _ yn=2∗x+1∗y 的形式,其中 x 、 y 指代张数为 2 和 1 的图片分组排列;
  2. 但如果仅仅将图片分成张数为 1 和 2 的组进行排列拼接,整体的拼图会略下单调,所以我们又添加了张数为 3 的图片分组,它们的排列如下:
  1. 对于张数为 n 的图片,我们都可以分解成 n=3∗x+2∗y+1∗zn = 3 _ x + 2 _ y + 1 * zn=3∗x+2∗y+1∗z 的形式,其中 x 、 y 、z 指代张数为 3 、2 和 1 的图片分组排列;

  2. 同时为了排版的美观,我们还增加了额外的规则:

  • 尽可能的按照 3 张为一组进行划分;
  • 最后一组尽可能为 2 张;
  1. 最后我们将分组好的图片进行对应的拼接,最后将所有的分组拼成一张长图即可;

iOS 键盘主动调整热区

这是一个可用不可见的例子。iOS 的键盘很特别,它会根据你的上一个动作,主动调整每个字母的点击热区。比如:你输了 Ant Desig 之后,那么 n 出现的可能性会大于旁边的 b 和 m,让你更容易点击。这一切,非常自然,自然到我们完全意识不到它的存在。

支付宝收款码自动旋转

在使用支付宝的收款码时,当你的设备旋转达到一定角度之后,界面会自然翻转。此时,对面的人通过扫一扫,就能看到人的正面。仔细想想这个细节,非常自然。

Tumblr 自动识别全景图

如果用户在 Tumblr 上传了一张超大宽幅的图片,Tumblr 会自动将文案中的「照片」一词变为「全景图(panorama)」。

自动识别地址等信息

Delivery Status 会在启动时检测剪切板是否有快递单号,提示用户使用:

当然,现在很多应用都会读取剪贴板,然后如果有特定信息会触发相关逻辑,比如中通快递识别地址、招商银行识别银行卡号等等

Shoppingᵁᴷ 的 icon 变化

当列表超过 10 项,底部的 icon 会从菜篮变成推车:

锤子手机隐藏工资信息

锤子手机收到工资类短信时,会自动隐藏金额,在部分场景下起到了保护用户隐私的作用。

虾米音乐祝你生日快乐

虾米音乐:在你生日这一天,会在播放列表中插入生日快乐歌。对,你男朋友忘记的,他记得。

Instagram 提示没有音量

当该用户在播放时使用音量键,如果当前视频本来就没有音量时,就会自动提示「No Sound」。

淘票票对凌晨电影票的提示

买电影票时如果你选择凌晨,系统提示你「今天晚上就要出发了」:

YouTube Lite Embed 动态加载视频

它接受一个 YouTube 视频 ID,并显示一个最小的缩略图和播放按钮。点击元素会动态加载完整的 YouTube 嵌入式代码,意味着从未点击播放的用户不需要支付获取和处理它的成本。

谷歌的一些网站也在开发中使用了类似的技术。在老的 Android.com 上,不是急于加载 YouTube 视频嵌入播放器,而是向用户展示一个带有假播放按钮的缩略图。当他们点击它时,会加载一个模态,它使用全屏的 YouTube 嵌入式播放器自动播放视频。

除了视频加载以外,还有各种认证的场景,应用程序可能需要通过客户端的 JavaScript SDK 来支持与服务的认证。这些 SDK 有时会很大,JS 执行成本很高,如果用户不打算登录,我宁可不急于在前期加载它们。相反在用户点击”登录”按钮时动态导入认证库,在初始加载时保持主线程更多的空闲状态。

在 React 中,我们可以通过 React.lazy 的方式对 React 应用进行代码拆分从而使用动态导入,同时也可以与 Suspense 组件结合用来处理加载状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { lazy, Suspense } from 'react';
import MessageList from './MessageList';
import MessageInput from './MessageInput';

const EmojiPicker = lazy(
() => import('./EmojiPicker')
);

const Channel = () => {
...
return (
<div>
<MessageList />
<MessageInput />
{emojiPickerOpen && (
<Suspense fallback={<div>Loading...</div>}>
<EmojiPicker />
</Suspense>
)}
</div>
);
};

在 Vue.js 中,可以通过几种不同的方式来实现类似的交互式导入模式。一种方法是使用动态导入包装在一个函数中,即 ()=>import("./Emojipicker") 来动态导入 Emojipicker Vue 组件。通常,这样做会让 Vue.js 在需要渲染组件时进行懒加载。

然后,我们可以在用户交互后面对懒加载进行把关。通过在 picker 的父 div 上使用条件 v-if,当用户点击按钮时,可以有条件地获取并渲染 Emojipicker 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
<button @click="show = true">Load Emoji Picker</button>
<div v-if="show">
<emojipicker></emojipicker>
</div>
</div>
</template>

<script>
export default {
data: () => ({ show: false }),
components: {
Emojipicker: () => import("./Emojipicker"),
},
};
</script>

Google Docs 延迟加载脚本

在许多场景下,在可交互时延迟导入功能代码是一种常用的模式。Google Docs 通过延迟到用户交互时才加载共享功能脚本来节省 500KB 的加载量:

Twitter 加载更多

在某些情况下,动态添加内容是用户体验的一个重要部分。例如,加载更多的产品到项目列表或更新实时反馈内容。在这些情况下,有几种方法可以 避免意外布局偏移

  • 在一个固定尺寸的容器中用新内容替换旧内容,或者使用轮播,在过渡后删除旧内容。请记得在过渡完成之前禁用任何链接和控件,防止在新内容进入时发生意外点击或触摸。
  • 让用户主动加载新内容,这样他们就不会对偏移(例如出现”加载更多”或”刷新”按钮)感到惊讶。我们建议在用户交互前预取内容,以便立即进行显示。这里需要提醒一下,在用户输入后 500 毫秒内发生的布局偏移不计入 CLS。
  • 无缝加载屏幕外的内容,并向用户叠加一个通知,说明内容已经可用(例如,显示一个 “向上滚动 “按钮)。

下面左图:Twitter 上的实时内容加载。右图:Chloé 网站上的”加载更多”示例;通过额外的元素来提示有新内容,没有造成意外的布局偏移。

Medium 渐进式图片加载

Medium 在文章详情页会通过一个非常平滑的渐进式图片加载提升用户体验:

而实现原理也相对比较简单:

  1. 先渲染一个空 div 作为占位;
  2. 加载一个 20% 质量的 jpeg 格式的图片;
  3. 上面那张小体积的图片加载完成之后,用 canvas 进行绘制,同时开始触发 blur 和加载真正的原图;
  4. 原图加载完成之后,隐藏上面的那个 canvas。

具体实现原理见这篇解析文章:How Medium does progressive image loading

还有其他网站使用了类似的思路,比如 Kent C. Dodds 的个人博客,文章头部有一张图片,首先渲染一张只有两种颜色的图片(体积更小),然后这样就可以保证在弱网环境下的体验,可以正常看到图片的基本轮廓

外链跳转时提示用户

目前很多网站,在有跳转到外部网站的链接时,当用户点击跳转时可以进行提示,下面的截图来自简书:

图片循环滑动

在最后一张图片切换到第一张图片的时候,期待平滑无感知;基本的解决方法是:在最后多加一张图片 A,图片 D 运动到最后一个 A 以后,视窗从最后一个位置 A 迅速切换到第一个位置 A,此过程用户无感知,然后开启下一次的循环。

Youtube 使用 siri 建议进行搜索推广

在 iOS12 或更高版本中,Siri 可以学习用户的习惯来进行常用习惯建议,具体文档见:https://support.apple.com/zh-cn/guide/iphone/iph6f94af287/ios。这个功能可以用来帮助 App 的重度用户缩短进入 app 的路径,帮助强化产品认知;Youtube 就使用了这个规范来进行搜索场景的推广

更多

有太多产品和太多的人性化细节供我们学习和参考:

反例

蚂蚁点击用户协议被隐藏

具体看下方两张图,第一张图是刚进页面时,「点击一键登录」会提示你同意协议,但页面上也没有个按钮啊?我找了半天结果把页面滚动一下才看到在最底部。。

其他

参考文章

支付宝打赏 微信打赏

听说赞过就能年薪百万