第一章:一套代码·多端运行
Hi,大家好~,上周小伙伴们去看云栖大会了吗?我去现场体验了一天,还是有不少收获的。同时我们部门的线上体验窗口 —— 阿里云混合云体验营(以下简称 “体验营” 或 “线上展厅”) 2.0 也在本次大会期间正式上线。
新版体验营相较于原先的 1.0 版本,无论是视觉还是交互体验上都有了质的变化,以响应式图文、三维场景、视频转场等形式有效呈现了混合云产品的 建/管/用 和最佳实践等各方面的能力。
先来一段真实的视频(点击下方图片查看视频):
具体的需求和设计方案介绍可以移步这篇文章:《我们把混合云展厅搬到线上啦》 。在此方案的基础上,综合应用的实际投放场景和实际受众,我们提出了一套面向多终端类型(PC/触屏/手机)、多场景用户(线上访问/展会现场体验)的前端技术方案。核心技术点汇总如下:
从本文开始的系列文章专辑中,我们将根据上图所列之环节、问题一一展开讨论,带来 Web 前端开发和跨端体验相关的一系列技术实践。
核心技术点
三端合一
新版混合云体验营的主要投放方式有三:PC、无线 和 云栖展会现场展示的 触屏版。
其中,触屏版是在 PC 版本基础上的改进版,主要的区别是将一些外链跳出变为带二维码的弹层(Overlay),一方面保证了整体应用不跳出,另一方面,通过让展会现场的线下受众手机扫码,实现用户体验从线下到线上的转变,延伸了产品的服务链路。
对于无线端场景,我们确定了使用一套代码同时适配 PC/触屏 + 无线端(手机)的开发方式:既要保证 PC 端能力在无线端的完全呈现,更要考虑适配无线端的用户体验和运行环境。
下面,重点介绍无线端开发过程中的几个要点和策略。
(1)运行环境识别
一般情况下我们通过浏览器提供的 navigator.userAgent 信息来判断设备类型,而不同的移动端 App 针对通用的 webview 内核都进行了一定的改写。这里给出几个安卓端 App 的不同 UA 信息,通过这些内容我们甚至能知道 web 应用是在具体哪个 App 中运行的(如:微信、钉钉、淘宝等)。
# 某版本微信 webview 的 ua 信息
Mozilla/5.0 (Linux; Android 10; VOG-AL10 Build/HUAWEIVOG-AL10; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/89.0.4389.72 MQQBrowser/6.2 TBS/045811 Mobile Safari/537.36 MMWEBID/7299 MicroMessenger/8.0.15.2020(0x28000F3D) Process/tools WeChat/arm64 Weixin NetType/WIFI Language/zh_CN ABI/arm64
# 某版本钉钉 webview 的 ua 信息
Mozilla/5.0 (Linux; U; Android 10; zh-CN; VOG-AL10 Build/HUAWEIVOG-AL10) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/69.0.3497.100 UWS/3.22.1.161 Mobile Safari/537.36 AliApp(DingTalk/6.3.0) com.alibaba.android.rimet/15239879 Channel/227200 language/zh-CN Hmos/1 UT4Aplus/0.2.25 colorScheme/light
# 某版本淘宝 webview 的 ua 信息
Mozilla/5.0 (Linux; U; Android 10; zh-CN; VOG-AL10 Build/HUAWEIVOG-AL10) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/69.0.3497.100 UWS/3.22.1.171 Mobile Safari/537.36 AliApp(TB/10.5.0) UCBS/2.11.1.1 TTID/227200@taobao_android_10.5.0 WindVane/8.5.0 1080X2265 UT4Aplus/0.2.29
为方便在 React 项目中使用,我们包装了一个名为 useIsMobile 的自定义 hook:
import { useEffect, useState } from 'react';
// 这里使用了第三方库 ismobilejs,一个比较流行的基于 UA 信息判定设备类型的 npm 包
import isMobile from 'ismobilejs';
// 是否移动端
export const isMobileDevice = () => isMobile().any;
// 是否手机端
export const isPhone = () => isMobile().phone;
// 是否平板设备
export const isTablet = () => isMobile().tablet;
// 自定义 hook - 用于判断设备类型
export function useIsMobile() {
const [deviceType, setDeviceType] = useState({
isMobile: isMobileDevice(),
isPhone: isPhone(),
isTablet: isTablet(),
});
useEffect(() => {
const handleResize = () => {
setDeviceType({
isMobile: isMobileDevice(),
isPhone: isPhone(),
isTablet: isTablet(),
});
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return deviceType;
}
基于此 hook 便可轻松实现一套 UI 的多套(端)样式:
import { useIsMobile } from '@/utils/mobile';
export default function MyApp() {
const { isPhone } = useIsMobile();
return (
<div className={`my-app ${isPhone ? 'phone' : 'normal'}`.trim()}>
{/* My UI components... */}
</div>
);
}
(2)无线端尺寸适配 —— 根据设计稿定义 Rem
由于无线端的设计方案与PC端布局相似,都是页面的宽度大于高度,我们基于 CSS transform:rotate 实现了强制横屏(下文会详细谈到)。经过一定的深思熟虑,我们发现在本项目中使用 vw/vh 单位进行页面布局并不现实:考虑旋转后的 vw/vh 经常需要变换着计算,在编码效率和可读性方面都比较蛋疼。于是果断实现了一套简单的 Rem 方案:
import { isMobileDevice } from '@/utils/mobile';
// 设计稿的尺寸
const DESIGN_WIDTH = 1852;
// 设置 rem 的方法
export function setRem() {
// 由于强制横屏效果是通过 transform:rotate 实现的
// 因而取页面的最长边作为实际的页面宽度
const realPageWidth = !isMobileDevice()
? window.innerWidth
: Math.max(window.innerWidth, window.innerHeight);
// Rem 的计算方法:1rem 占页面的比例 = 100px 占设计稿的比例
document.documentElement.style.fontSize = `${
(100 * realPageWidth) / DESIGN_WIDTH
}px`;
}
强制横屏
上图所示为混合云体验营在手机端竖屏模式的展示效果,为了把与 PC 端一致的 “横屏” 体验直接搬到移动端设备,需要实现一套 “强制横屏” 的方案 —— 即页面总是以显示设备的最长边作为 “宽”,以最小边作为 “高”。当设备的宽度小于高度时,我们可以用 CSS transform 旋转 90° 使页面达到 “强制横屏” 效果。
(1)通过窗口尺寸判断横屏状态
// 判断屏幕宽度是否大于高度
function isLandscape() {
const width = window.innerWidth || document.documentElement.clientWidth;
const height = window.innerHeight || document.documentElement.clientHeight;
return width > height;
}
(2)基于横屏状态实现一个强制横屏的 UI 容器
import React, { useEffect, useState } from 'react';
import isLandscape from '@/utils/isLandscape';
import './Orientation.scss';
// 强制模拟手机横屏展示
export default function Orientation({ children }) {
const [landscape, setLandscape] = useState(isLandscape());
useEffect(() => {
const resizeFn = () => {
setLandscape(isLandscape());
};
window.addEventListener('resize', resizeFn);
window.addEventListener('orientationchange', resizeFn);
return () => {
window.removeEventListener('resize', resizeFn);
window.removeEventListener('orientationchange', resizeFn);
};
}, []);
return (
<div
className={`orientation-wrapper ${
landscape ? '' : 'orientation-wrapper-rotate'
}`.trim()}>
{children}
</div>
);
}
/* Orientation.scss */
.orientation-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
&.orientation-wrapper-rotate {
left: 100%;
width: 100vh;
height: 100vw;
transform: rotate(90deg);
transform-origin: top left;
}
}
把组件包裹在页面路由最外层,就能让整个页面实现自动横屏的效果。
横向滚动
新版混合云体验营的页面中广泛使用了横向布局和滚动(滑动),在详情页面中也是用到了不同于浏览器原生滚动特性的模态滚动方案。
以下面视频中无线端的案例详情页为例,图文内容滚动到最底部时总能保证章节标题与 tab 的对齐,这就需要滚动容器根据章节内容的长短动态计算滚动的有效范围,这是浏览器的原生滚动行为所不方便实现的(当章节内容过短时,标题便滚不到顶部)。
在实现跨终端的滚动(滑动)方案时,需要综合设备、浏览器等环境和交互特性的差异,解决好几个主要问题:
(1)PC 端鼠标滚轮横向滚动
这与浏览器原生的的特性是不一致的(一般情况下需要使用键盘辅助键)。
(2)移动端的内容滚动
当移动端设备处于竖屏状态时,由于使用了 CSS transform:rotate 属性来实现 “强制横屏” 效果,此时页面上的滑动效果与系统原生的滑动方向是不一致的。
(3)不同触屏设备特性不一致
云栖展厅的 Windows 触摸屏、安卓、iOS 等不同平台的浏览器中,触屏事件的实现机制是不一样的,为了保证体验的一致,需要一个较为底层且统一的方案。
为了解决这些问题,我基于 PC 端的 wheel 事件和移动端的 touch 事件包装了一个多端通用的 “滚动事件”:
// PC 端基于 wheel 实现,移动端基于 touch 实现
export default function bindScrollEvent(element, callback) {
// 处理 wheel 事件
const handleScroll = (event) => {
// 屏蔽默认行为
if (typeof event.preventDefault === 'function') {
event.preventDefault();
}
// 执行回调
callback(event);
};
// 光标位置缓存
let startX;
let startY;
// 处理 touch 事件 - touch start
const handleTouchStart = (event) => {
const clientCoords = getClientCoords(event);
// 重置光标位置
startX = clientCoords.x;
startY = clientCoords.y;
};
// 处理 touch 事件 - touch end
const handleTouchMove = (event) => {
const clientCoords = getClientCoords(event);
// 执行回调
callback({
deltaX: startX - clientCoords.x,
deltaY: startY - clientCoords.y,
});
// 更新缓存
startX = clientCoords.x;
startY = clientCoords.y;
};
// 绑定 wheel 事件
element.addEventListener('wheel', handleScroll, {
passive: false,
});
// 绑定 touch 事件
element.addEventListener('touchstart', handleTouchStart);
element.addEventListener('touchmove', handleTouchMove);
return () => {
// 解绑 wheel 事件
element.removeEventListener('wheel', handleScroll);
// 解绑 touch 事件
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
};
}
// 获取光标位置的方法,兼容 PC & 移动端
export function getClientCoords(event) {
if (
event &&
typeof event.clientX === 'number' &&
typeof event.clientX === 'number'
) {
return {
x: event.clientX,
y: event.clientY,
};
}
if (event.touches.length > 0) {
return {
x: event.touches[0].clientX,
y: event.touches[0].clientY,
};
}
return {
x: event.changedTouches[0].clientX,
y: event.changedTouches[0].clientY,
};
}
该事件在 React 组件中的使用方式如下:
import React, { useEffect, useRef } from 'react';
export default function MyScrollableComponent() {
const container = useRef(null);
const scrollContentOffset = useRef(0);
useEffect(() => {
if (!container.current) return;
const handleScroll = (event) => {
// 这里偷个懒,取 X、Y 方向上的最大位移作为滚动的 delta,统一处理 横向/纵向 滚动
const delta =
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
// 缓存滚动位置
scrollContentOffset.current += delta;
// 使用 transform:translate 实现模态的横向滚动
container.current.style.transform = `translate(${
-1 * scrollContentOffset.current
}px, 0)`;
};
// 绑定滚动事件
const unbindScrollEvent = bindScrollEvent(
container.current.parentElement,
handleScroll
);
return () => {
// 解绑滚动事件
unbindScrollEvent();
};
}, []);
return (
<div className="my-scrollable-component">
<div className="container" ref={container}>
{/* 需要横向滚动的内容... */}
</div>
</div>
);
}
视频播放
在体验营中存在大量视频播放的场景,如首页视频背景,过场动画视频,运营编辑的内容视频等等。我们使用 <video> 标签渲染视频,这在 PC 端没有什么问题。然而在移动端上,情况则变得相当复杂。究其原因是:当前市场上不同的浏览器(或者 webview)多多少少都对视频播放做了一些自己的处理和劫持。
举个栗子:在 UC、X5 等浏览器中,如未进行特殊声明,则视频将作为原生的视图层(Native Layer)渲染在浏览器视口的最顶层,这已经脱离了 HTML 和 CSS 能够操作的空间。而对于以视频作为背景的页面,在这些浏览器中的效果就是视频直接占满屏幕,使用户根本无从操作页面中的任何内容。
下图所示对照组,是同一个页面的 <video> 在不同浏览器(夸克、QQ)中的表现。可以看出,一个正常渲染,另一个则被劫持。
所幸,我们要适配的几款浏览器内核(X5、UC)都提供了一些非标属性,用于声明 <video> 的渲染方式:
<video
src={url}
// 标准属性,让视频不会全屏播放
webkit-playsinline="true"
playsInline
// UC 内核特有属性,功能同上
renderer="standard"
// X5 内核特有属性,功能同上
x5-video-player-type="h5-page"
/>
如上所注,大多数移动端浏览器都能正常渲染视频背景了。但是仍有少部分浏览器异常顽固(甚至于改为使用 canvas 来绘制视频也无济于事,因为浏览器直接劫持了视频资源并以原生方式渲染)。
针对这些油盐不进的浏览器,我们只好做一些体验上的降级:即通过 UA 信息判断浏览器类型,对于那些在 “黑名单” 中的浏览器,页面的视频背景会降级为图片,转场动画则直接取消。通过这种牺牲部分体验的方式,保障了整体用户流程的完整可用。
总结 & 思考
(1)跨端 & 响应式开发
- 在页面加载和 React 应用的初始化阶段,基于浏览器 UA 判定运行环境并加载不同样式,则 Web 应用便具备了 跨端 的能力。
- 自定义 hook 的实现中,isPhone、isMobile、isTablet 状态的更新是由窗口的 resize 事件所驱动的。这样做的好处主要是方便 本地开发调试:当我们打开 Chrome 的控制台模拟移动端设备时,由于触发了窗口尺寸的变化,页面能及时更新为移动端样式。
(2)一些经验、要点
-
开发一个 PC 和无线端通用的 UI 组件时,需要先研究两个版本的 设计意图,梳理组件在不同端上的体验、视觉效果的 异同。最好是在 PC 和无线端的需求取 并集 后进行。
-
在同一个 UI 组件内基于 设备类型 来加载多端样式和实现一些与设备类型相关的 定制化 逻辑。同时,在增加和重构任何功能逻辑时,都需要覆盖各端进行测试,避免由于一个端的需求引入给其他端的体验带来影响。
-
由于不同设备、浏览器的分辨率不尽相同,无论在做 PC 还是无线端样式时都需要考虑 边界情况 和 稳定态,保证体验的一致和功能的可用。
下集预告
本篇主要介绍了 多端适配 相关的几个关键问题的解决和实现。后面将陆续推出两篇文章,分别从 三维展厅 的实现和围绕无线端展开的 性能优化 等方面进一步介绍新版体验营相关的技术方案及细节。
喜欢文章的朋友们记得持续关注我们哦👇~