现在在手机等移动端设备访问的人越来越多,我们前端开发者一直致力于将设计稿还原成页面,供用户访问。但除高度还原设计稿外,交互上的良好体验也是我们应该做到的。
1. 即时反馈
我们在玩游戏的过程中,通常都会遇到一个词:“打击感
”,通俗的理解就是我们做出的每一个操作,都有很强烈的反馈,比如视觉上的动画变化,听觉上产生的声音,或者移动设备的震动感等。
1.1 按钮的即时反馈
在前端页面中,也应当像游戏中的打击感
一样,用户任何的操作都应当予以即时的反馈,告诉用户他的操作是有效的,系统已收到他的操作,内部正在处理中。
例如用户在点击页面中的按钮时,按钮最好有一种被按下的效果:
button:active {
transform: translateY(4px);
}
若按钮被下压的效果不太适合页面整体的风格,您也可以做一个背景颜色上的变化。
1.2 持续性的反馈
每个用户的设备型号、网络状态等情况都不一样,我们不能要求每个用户都在良好的 WiFi 下操作我们的页面。
若用户的某个行为产生了网络请求,并要根据请求返回的结果,反馈给用户。这种情况,页面都应当给用户一种持续性的反馈
,表示一个动作正在后台执行。如果没有这种效果,即使已经在请求接口了,用户也会认为点击没有反应,会多次的去点击按钮,以期望得到响应。
我们可以在这里给自己定下一条规则:
凡是有网络请求的情形,均要有 loading 效果的持续性反馈。
我们通常可以在用户触发的按钮上展示 loading 效果,也可以在全局页面上展示 loading 效果,这个根据每个页面的风格自行选择即可。
例如页面上有个红包需要点击按钮开启,当用户点击按钮后,按钮就可以展示出一个旋转的 loading 效果,待接口返回结果再打开红包,展示具体的金额,或者其他的结果。
1.3 页面初始化
在现在大部分前后端分离的场景下(同时没有使用同构直出方案),都是先展示出一个没有数据的前端页面,然后请求数据,待数据返回后再渲染页面。
这种情形和上面 1.2 中是一样的,不过这个是在刚进入页面就触发的!这里我们也是要展示出 loading 效果的,只不过是 loading 展示的时机的问题。
- 先一个全局 loading 的开启页,在数据没有返回回来时,看不到任何相关活动元素;
- 先用初始化的假数据或者兜底数据,渲染一个基本框架,然后在某个位置展示 loading 效果,并请求数据,数据返回后再替换假数据进行渲染。
这两种方式也是各有不同的使用场景,就我个人而言,我更喜欢第 2 种方式,能够第一时间将页面中的元素展示给用户;但如果页面布局因接口的数据改变较大,建议还是采用第 1 种方式,这样 loading 结束时,不会出现页面大幅度闪动的感觉。
1.4 数据的展示
我们拿到接口的数据后,通常会有两种展示状态:
- 无数据,进行“暂无数据”之类的提示;
- 有数据,正常展示数据;
比如一个展示奖品列表中数据中,这里我们通常会初始化一个 list 变量来接收接口返回的数据:
const List = () => {
const [list, setList] = useState([]);
useEffect(() => {
// 设置数据
// setList([]);
}, []);
return (
<div className="list">
{list.length ? (
<div className="container">
{list.map((item) => (
<div key={item.key}>{item.title}</div>
))}
</div>
) : (
<div className="nothing">暂无数据</div>
)}
</div>
);
};
在请求接口的过程中,页面会展示什么?“暂无数据”,给用户的第一视觉感受就是:我的奖品丢了。等过一会儿接口返回数据了,然后又重新将数据展示出来。
这里,我们就忽略了一个很重要的状态:loading状态
。因为“暂无数据”,也是一种结果,不是过程,是要告诉用户,您当前是没有数据的。因此,不能把“暂无数据”作为 loading 状态来展示。
const List = () => {
const [loading, setLoading] = useState(true);
const [list, setList] = useState([]);
useEffect(() => {
// 设置数据
// setList([]);
setLoading(false); // 请求完接口,再把loading状态取消,该展示什么结果就展示什么
}, []);
if (loading) {
return (
<div className="list">
<div className="loading">请求数据中...</div>
</div>
);
}
return (
<div className="list">
{list.length ? (
<div className="container">
{list.map((item) => (
<div key={item.key}>{item.title}</div>
))}
</div>
) : (
<div className="nothing">暂无数据</div>
)}
</div>
);
};
2. 行为跟随
这里我也不太想好用个什么名字,概况来说,告诉用户刚才发生了什么,将用户操作可视化, 来增强用户对操作行为的感知度, 同时也能对元素内容的认知。
因用户行为产生的新交互,应当与当前用户的行为相关。
2.1 点击按钮后呼起弹窗
用户点击按钮后,会弹出一个弹窗,弹窗可以从按钮所在的方向或者位置,弹出到整个页面的中心。
给到用户的感受就是该弹窗与按钮是相关的。
2.2 列表中有对象变动时
例如在一个表格或者列表中,有新增、修改或者删除一行(一列)的行为,可以用一个动画和背景色来区分该元素, 过一段时间再恢复正常。
2.3 丝滑的滑动跟随
在不添加任何 CSS 属性时,滑动有滚动条区域时,总感觉有一种卡顿感,就是手指滑动时页面就跟着滑动,手指离开则页面停止滑动。
这里我们添加上一个属性即可:
body {
-webkit-overflow-scrolling: touch;
}
3. 考虑移动设备的握持姿势
在现在手机屏幕越来越大的趋势下,单手握持手机时,大模板只能在以左下角或者右下角为中心的区域活动。因此,在底部区域操作的情况越来越多,例如底部区域的导航,弹窗中点击空白区域即可关闭等等。
3.1 避免滚动穿透
在一个可滚动的页面中,呼起一个弹窗,这个弹窗中的内容也比较多,也需要滚动,如果不加处理的话,可能会造成两个区域同时滚动,体验不好。也就是避免滚动穿透。
这里我们就要把底层的滚动锁住,只可以滚动处在最上层的区域。这里的原理我就不多讲解,推荐一个我一直在使用的组件tua-body-scroll-lock,该组件导出了 2 个方法:
- lock: 锁定区域,传入 dom 元素,则表示该 dom 区域内是可以滚动的;
- unlock: 解除锁定,当弹窗消除时,需要解除被锁定的区域;
在 react 中的使用方式:
useEffect(() => {
// 锁定body的滚动,只在弹窗内部滚动
// 只有需要设置可以滚动区域时,才使用该方法
if (props.scrollContainer) {
lock(props.scrollContainer);
}
return () => {
if (props.scrollContainer) {
unlock(props.scrollContainer);
}
};
}, [props.scrollContainer]);
同时的,我们最好在遮罩区域添加可以关闭弹窗的操作,避免用户伸手够弹窗右上角的关闭按钮。
3.2 原生 select 标签的使用
在移动端开发中,下拉框我们使用原生 select 标签时,iOS 和 Android 的表现是不一样的,iOS 会出现在屏幕的底部,滚动选择某个选项;而 Android 中,则是屏幕中间弹出一个弹层,然后可以进行选择。
如果图方便的话,其实可以使用原生的 select 标签。但这种方式,总感觉与页面元素之间产生了割裂,因此如果可以的话,尽量模拟出一个 select 标签。
4. 良好的兜底策略
每个用户的设备型号、网络状态等情况都不一样。总会因为各种各样的原因,导致页面展示异常。因此,我们应当做好提示和一些兜底策略。
4.1 全屏沉浸式页面应当保持关闭操作
通常情况下,在移动端 APP 中打开的页面,顶部都会有一个白色的标题栏。但有些活动页面为了更好地沉浸式体验,会把白色标题栏去掉,同时还去掉了右划退出的操作,只能点击自定义的返回按钮才能退出。
例如这个页面,左上角的返回按钮是页面本身自定义的。而这个页面必须是接口正常返回数据后才展示出来,在最开始时,如果有异常时,会展示错误信息,但没有返回按钮。这就导致用户无法退出该活动,只能杀掉 APP 再重新进入。
体验非常不好,这里我们就要保证:全屏沉浸式页面不管是哪种状态,应当全程保持关闭操作!
当然,现在已经没有这个问题了。
4.2 永远不要相信后台一直很稳定
后台经常说的一句话是“不要相信任何从前端传过来的数据”,我们也一样:
永远不要相信后台一直很稳定。
我们要做好接口服务可能会挂掉的预案:
- 设置请求接口的超时时间,不要让用户无限制等待;
- 良好的提示;
- 有条件时,可以自动重试,或者让用户手动尝试重试请求接口;
- 采用兜底策略遮盖;
前 3 种我们都可以理解,当接口异常并无法继续后续的操作时,应当告知用户有服务有异常了,可以稍后重试。
对于第 4 种,通常可能会发生在高并发的抽奖过程中,越是让用户重试,并发量就越高。因此在抽奖异常时,可以直接告诉用户未中奖,而不是“服务异常”之类的话术。要不然,一方面会引起用户的不满,另一方面会造成用户的大量重试。
这个百度在春晚发红包中,就有用到过,在服务器短时间内承受到高并发量时,则直接告诉用户未抽中红包;同时,对于一些抽奖会同时发放多个奖品时,也要做好每个奖品服务都可以会挂掉的准备,比如同时会发放 3 个奖品:
- 服务都正常,正常发放;
- 2 个正常,就只发放 2 个奖品,左右排列;
- 只有 1 个服务正常,则只发放 1 个奖品,居中排列;
- 均异常,则告诉用户未中奖;
千万不要留有空间或者槽位告诉用户“该位置本应该有奖品,但实际上没有”的感觉。
4.3 懒加载
懒加载是一个老生常谈的话题,这里我们只针对图片懒加载来进行梳理。
在页面中图片比较多时,请尽量使用图片懒加载,并考虑好图片加载失败的情况,可以先创建一个 Image 来先加载图片,加载城后再给到页面中的 dom 元素,否则使用兜底图片:
// 判断图片是否可以加载成功
const loadImage = (imgUrl: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = imgUrl;
if (img.complete) {
return resolve(img);
}
img.onload = () => {
resolve(img);
};
img.onerror = reject;
});
};
// IntersectionObserver的回调,当dom元素进入到可是区域内时
const targetExposeCallback = async (dom: HTMLElement) => {
let original = dom.getAttribute('data-original');
if (original) {
try {
await loadImage(original);
} catch (err) {
// 1x1的图片
original = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
}
setLoading(false);
if (dom.tagName.toLowerCase() === 'img') {
dom.setAttribute('src', original);
} else {
// eslint-disable-next-line
dom.style.backgroundImage = `url("${original}")`;
}
dom.setAttribute('data-original', '');
}
};
同时,我们在体验的过程中发现,在有些华为手机里,图片还没加载完毕时,会展示一个裂开的图片,如果该图片 alt 注释,也把 alt 注释显示出来,稍过一会儿,等图片加载完毕了,就正常展示图片了。
这种情况,我们也可以使用图片懒加载,或者将图片设置为背景图片,避免出现图片裂开的状态。
5. 总结
我们在移动端开发的过程中,总会有多种解决方案。如果我们站在用户的角度多想一想,就能让产品的交互体验变的更好。
感谢您的阅读,欢迎关注我的公众号: