今年BML宣发页面的主视觉由我负责,使用了比较新的技术栈,包含大量动画技巧以及视频运用,下面大概分析一下这些技术和遇到的一些坑。
对IE的处理
将这一点放在文章之首是为了充分表达我个人(应该也是世界上所有有追求的前端工程师)对IE的厌恶,经过一番较为艰难的沟通,我们终于就“在此活动干掉IE全家”达成了共识,所以如果用IE访问此页面,你应该会看到一个配有33娘背景图的页面,其上的文字敦促你去下载现代浏览器,我觉得这和苹果强推HTTPS一样,有一定影响力的公司应当为业界的技术革新产生一些贡献。
为了实现这一点,我用了两个手段,对于IE9以下(包括自身)的IE,可以在HTML加入一段这样的注释
<!--[if IE]>
<div class="ie-must-die">
<div class="ie-container">
<h1>
您正在使用IE
</h1>
<p>
少女H也曾依赖过IE,借此来窥探这个世界。
<br />
但IE毕竟已然老去,早已无法适应这个绚丽的时代。
<br />
他的职责既已完成,不如就任其安然睡去吧——
<br />
毕竟,这个时代是属于现代浏览器的。
</p>
<a href="https://browsehappy.com" target="_blank">
您可以点击此处,下载Chrome后再次打开本页面。
</a>
</div>
</div>
<![endif]-->
这段代码在IE中会取消注释并插入到DOM中,如果想判断IE版本,可以在if
条件处修改,比如要在IE8以下显示,可以写if lt IE9
。
但这种方法毕竟只能支持IE10以下的版本,诚然IE10以上的IE已然友善许多,但还是有很多奇怪的坑(尤其是有很多动画的时候),这时候就需要别的方法来对其进行屏蔽。由于我使用的是React,所以只需要设定一个状态,并更具这个状态决定要渲染的DOM即可:
// config.ts
const isIE = !!window.ActiveXObject || 'ActiveXObject' in window;
// App.tsx -> render
if (config.isIE) {
return (
<div className={cx('ie-must-die')}>
......
</div>
);
}
Preload
结构
IE说完,下面切到正文。BML主视觉属于大型活动页面,视频和图像资源比较多,在对首屏展示有着严格要求的情况下,预加载是必须的。我写了一个ResourceManager
单例来管理所有资源,它构造时接受一个数组,数组中的每个对象可用参数配置src
、type
、是否需要preload
以及preload时的权重weight
,除此之外,还可以设置一个超时时间timeout
来强制一定时间内加载完成(即使资源尚未真正加载完),这是为了确保访问体验的下限。
{name: 'world', src: resSrc.world, type: 'video', preload: isPC, weight: 20},
{name: 'guide-img', src: resSrc.guideImg, type: 'image'},
const resourceManager = new ResourceManager(resources, 8000);
当单例初始化结束后,便可以在任何地方调用它的load
方法来进行加载,在期间可以用loadDone
这个访问器来确定是否已经加载完成。除此之外,我还定义了一个registerOnProgress
方法来注册一个回调,此回调在加载进度变更的时候会被调用,它可以灵活得被用于和React组件结合刷新视图:
resourceManager.registerOnProgress((progress: number) => {this.setState({progress}));
resourceManager.load();
而在使用时,只需要调用getSrc(name)
方法即可拿到资源的url。
数据结构
加载队列中的每个资源都有相同的的数据结构,其为:
interface IResourceElement extends IResourceEntry {
preload?: boolean;
name: string;
src: string;
// image or video
type: 'image' | 'video';
weight?: number;
element?: HTMLImageElement | HTMLVideoElement;
progress?: number;
}
其中progress
即为该资源的加载进度,取0 ~ 1
,当不需要预加载时,其恒定为1。
当资源加载进度变更时,通过progress
访问器可以获取到资源的实际进度,其基本思路就是遍历所有资源对象,将每一个的progress
乘以权重weight
相加,最后除以权重和。
图像预加载
图像预加载比较简单,只要创建Image
对象,绑上onload
事件并设置src
,在事件执行时将其对应的progress
设为1即可。
// element为一个Image对象
const element = this.resources[name].element;
element.onload = () => {
this.resources[name].progress = 1;
if (!this.loaded) {
this.onProgress(this.progress);
}
};
element.src = this.resources[name].src;
视频预加载
视频预加载比起图像麻烦的一点在于,我们无法通过向new Video()
这样的方法创建一个视频对象然后如图像那样处理,而是必须用document.createElement('video')
并用其API,此外,为了兼容,还必须将其插入到DOM中来保证其正常加载,于是代码就变成了这样:
// element为视频对象
this.resources[name].element.addEventListener('canplaythrough', this.handleVideoProgress(name));
this.resources[name].element.muted = 'muted';
this.resources[name].element.preload = 'auto';
this.resources[name].element.src = this.resources[name].src;
this.resources[name].element.style.position = 'fixed';
this.resources[name].element.style.transform = 'scale(-10000)';
this.resources[name].element.style.width = '0';
this.resources[name].element.style.height = '0';
document.body.appendChild(this.resources[name].element);
this.resources[name].element.play();
即,将视频绑事件,插入到DOM中并使其不可见,然后播放它。我这里绑的事件是canplaythrough
,这个事件将会在每一次视频可以一段时间
时触发,这个机制也影响到了对应回调函数的实现。
视频和图像不同,我们可以拿到其总长和加载期间已经加载了多少
:
// 总时长
const duration = this.resources[name].element.duration;
// 已经加载的时长
const buffered = this.resources[name].element.buffered.end(0);
通过这二者便可以算出加载的比例,也就是progress
的值:
this.resources[name].progress = buffered / this.resources[name].element.duration;
算完后触发onProgress
回调,基本就完成了,别忘了在加载结束时将element
从DOM中移除www
Guide-首屏
前置处理做完,接下来便是视图的展示了。
首先是首屏,也就是一进来的那段酷炫的特效,其原理很简单,其实就是背景视频 + SVG + 几个DOM。下面分PC和移动两个平台来阐释其中的重点。
PC
PC的H5视频支持基本完美,需要注意的只有。
而这视频虽然有两段,其实是一个视频,只不过我监听了timeupdate
方法,在视频播放进度变更的时候设置不同的状态来确保新视图的渲染,跳过功能也不过是修改video.currentTime
而已。这里比较需要注意的是样式问题,因为是将视频作为背景,所以我们需要一个类似于background-size: cover
的效果,当知道原始视频宽高比的情况下,样式可以这么写:
.cover-video {
@media (min-aspect-ratio: 16 / 9) {
width: 100%;
height: auto;
}
@media (max-aspect-ratio: 16 / 9) {
width: auto;
height: 100%;
}
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
}
至于SVG动画,由于一开始还试图考虑兼容IE,这里使用的是Vivus库,不过它也有一些问题,能用原生的SVG动画还是原生吧。
移动端
移动端的背景视频播放绝对是超级大坑,由于移动平台设备奇多,良莠不齐,所以我付出很多努力后,最后还是在指示下将其取消了,但我研究出的这个方法应该还是可以兼容绝大多数现代设备的,所以就将经验写在这里吧。
第一个问题是有些版本的设备不支持背景视频,很多设备即使支持,浏览器也会劫持视频,对于这些设备和浏览器我建议直接放弃治疗,禁掉视频吧。比如5.0以下的安卓啦,移动端的QQ浏览器(不包括QQ和微信的内置浏览器以及HD版本)。
对于iOS设备,10是可以直接支持背景视频的(Safari或者基于原生Webview的),但8和9需要一个插件来支持iphone-inline-video。此外,还需要在video
标签上加上playsInline
属性。
对于QQ和微信内置浏览器,只需要在video
标签上再加上两个属性即可:
video.setAttribute('webkit-playsinline', 'true');
video.setAttribute('x5-video-player-type', 'h5');
下面给出我用的几个平台的判据:
const isQQ = /TBS/.test(navigator.userAgent);
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isIpad = /iPad/.test(navigator.userAgent);
const isAndroid = /Android/.test(navigator.userAgent);
const isQQBroswer = !isIpad && /MQQBrowser/.test(navigator.userAgent) && !isQQ
const getAndroidVersion = (ua: string) => {
const u = ua || navigator.userAgent;
const match = u.match(/Android\s([0-9\.]*)/);
return match ? parseFloat(match[1]) : 4;
};
Home-分会场选择
酷炫的特技后是分会场选择页面,此页面结构虽看似简单,但其实是最耗性能的一页,这也怪我当时想的太简单全用DOM动画吧(用canvas可能麻烦点但性能应该会好很多)。
该页面主要由背景的星轨
、前面的无限轮播图
和其他一些小按钮构成。那些小按钮暂且不论,星轨和轮播图的动画相对复杂,值得单独拿出来说一说。
星轨
星轨分两部分,一个是在完全进入页面后的行星转动动画,一个是在从别的页面切换回来时的逐环扩散效果。
转动
前者比较简单,用无限循环的keyframes
实现即可,要注意这里最好让行星自己动,不要带轨道一起,性能会好点。但既然要行星自己动,那么行星自己的transform-origin
就要特别注意,其需要注意的样式为:
.star-item {
transform-origin: @star-size / 2 (@rail-size + @rail-width + @star-size) / 2;
left: (@rail-size - @star-size) / 2;
top: -(@star-size + @rail-width) / 2;
}
其中star-size
是行星直径,rail-size
是轨道直径,rail-width
是轨道的border宽度。这个再加上keyframes
,整个效果就出来了(当然轨道居中什么的还有些额外样式,很简单,这里不多说)。
@keyframes rotate-stars {
from {transform: rotate(0deg) translateZ(0);}
to {transform: rotate(360deg) translateZ(0);}
}
.star-item {
animation: rotate-stars @step * 4s linear 0s infinite normal;
}
扩散
扩散的效果就比较不友好了,由于我想使用纯CSS实现,所以又不想借助于ReactCssTransitionGroup
,所以又只能用keyframes
,这里利用了它的一个特性,就是keyframes
(所在的class
)被新加入到DOM上时,其必定会被触发一遍,这一点在带有这种样式的DOM被新插入页面中时也一样,利用这一点,我们就可以使用keyframes
加上不同的插入时点
、或者利用其本身的阶段特性
,来实现这样的动画效果。我选用的是后者,九个星轨实际上是九个DOM,每一个都拥有自己独立的动画,而它们是同时插入到页面中的,也拥有同样的时间:
.star-rail {
animation: 1.6s ~'home-star-@{step}' ease-out;
}
然后针对不同的step
写动画:
@keyframes home-star-0 {
0% {opacity: 0;}
5% {opacity: 1;}
}
......
@keyframes home-star-5 {
0% {opacity: 0;}
40% {opacity: 0;}
60% {opacity: 1;}
}
......
@keyframes home-star-9 {
0% {opacity: 0;}
80% {opacity: 0;}
100% {opacity: 1;}
}
不错,核心就是肝疼的微调,也正因为此,渲染性能消耗变得很高。
轮播
轮播和星轨一样,其实也是分两部分的,入场时是一个额外的DOM,结束后展示的是另外一个DOM,只不过这两个DOM切换时完全重合,所以造成了一种无缝的错觉。
入场
入场和星轨的入场一样,也是利用keyframes
分阶段实现的,这个比较简单,基本就是微调样式,对四个场子的bg进行translate
和scale
、margin
的变换,在这里我强行开了translateZ
来进行GPU加速,但在一些机器上还是会有些卡和抖动,这个大概是transform
变换中的渲染性能消耗和计算出的小数导致。
在入场完成后,js这边控制状态切换到入场后的DOM,实现无缝切换。
入场后
入场后会有一段前景左侧图片和右侧文字的小动画,以及其他一些小按钮的动画,这些都是keyframes
实现的。同时,在进行轮播的左右切换之时你会发现前景和背景、前景的图像和文字之间都有明显的视差(即不是同步移动),这个也是通过添加和删除keyframes
相关的样式实现的。
而轮播自身,因为是无限循环的轮播,所以每次切换结束总是要将移动侧边缘的DOM删除后插入到另一边,这也导致无法用纯CSS实现,必须上JS,于是这就涉及到了React中的JS动画实现问题。在这里我选用了和ReactMotion相似的原理,自己写了一个Animation
组件,用于实现需求的动画。
<Animation
enable={enableAnimation}
animation={next.styles}
duration={duration}
easing={easing}
onEnd={this.handleAnimationEnd}
>
styles => (
......
)
</Animation>
此组件用法如上,enable
表示是否允许动画;duration
是起始到终止态的时间;easing
是缓动函数的类型,这里我们使用了d3-ease和d3-interpolate来实现起始到终止态之间的插值;onEnd
是一个回调,其在每次动画结束后触发;最后是这个animation
属性,它是整个组件的核心,用于动画的触发,触发方式是要求两次传入的值不同,这个值可以是一个数字、一个数组或是一个对象。触发动画后,组件的children
(要求是一个函数)将会每个一段时间(取决于requestAnimationFrame
)接收到一个经过插值计算后的值,然后通过这个值进行组件的渲染。
有了这个组件,轮播的动画逻辑就很清晰了:用户每次操作时算出每个场次的下一个位置,扔到一个数组里(这里是next.styles
),然后根据每一次的styles
进行渲染,并监听onEnd
事件,在动画结束后完成DOM的删除和插入即可。
World-地域选择和分享
第三屏是地域选择和分享,其可以划分为两个主要部分——“地区选择器”和“分享图片生成”。
地区选择器
地区选择器说来也简单,其实就是三个关联选择框,其核心是数据来源。这方面我是选择了网上找到的一个地区区号和名字的列表的xml文件,之后自己转成了两个json文件locationListTable.json
和locationLookupTable.json
:
const locationListTable = {'11': {name: '北京', children: {'1101': {name: '北京', children: null}}}, ......}
const locationLookupTable = {"11":{"id":"11","name":"北京","parent":null},"1101":{"id":"1101","name":"北京","parent":"11"}, ......}
第一个用于生成关联选择器,第二个用于利用id
反向查询名字和父级。有了这两个表,地区选择器的实现基本就是搬砖难度了。
分享
分享图是根据用户选择的地区实时生成的,基于canvas
。原理是先载入一张背景图,利用drawImage
方法将其绘制到canvas中并将canvas引用保存。之后每次用户点击生成时,只需要用特定的字体将地区的名字用fillText
方法绘制上去,之后再用toDataUrl
方法即可生成预览图。这是第一步。
生成完预览图后我们并不能直接分享,因为几乎所有社交应用的分享接口都是不支持base64
接口的,所以这里由后端提供了一个通过base64
为参数上传图片的接口。用户确认后再点击一次生成才会真正得生成图像供用户分享。这也大大降低了后端的压力,提升了用户的体验。
其他
你可能会注意到这个分享流程在任意次访问中只会走一遍,但换个浏览器却又要重新输入。这是由于我此处使用了一个变量来保存用户的访问状态,而此变量存到了localStorage
内。在使用localStorage
的时候,一定要注意用户是否是在隐身模式下,如果是则需要降级(这里的降级方案是直接存到内存,至少保证该次访问正常),下面是判断用户localStorage
是否可用的方法:
let storeEnabled = true;
try {
localStorage.setItem('test', 'test');
} catch (e) {
storeEnabled = false;
}
Comments-讨论区
最后就是讨论区了,你们一定以为我只是主站评论区换了个皮肤吧www
错,由于要把评论区改造成贴吧,为了可控和与现在ts + react的技术栈相容,我将评论区重写了一遍,并在外面套了一层别的东西做成了这种微博式的体验。得益于ts和less的优秀,这一部分只花了一周基本就搞定www
虽说一周搞定,但其实讨论区的逻辑工作量是最大的,还好我最先开发的是这一块,那时候脑子最清楚(后面都被各种样式、兼容、媒体查询搞的精疲力竭)
这一块没什么特别的心得,大部分都是业务逻辑和后端对接口,要说真有什么建议的话:
先把类型和数据结构定好再写代码,像这样子都写好回来查后来改都好弄,还有类型校验防止SB错误,你们还有什么理由不用TS!
export interface IMember {
mid: string;
uname: string;
sex: string;
avatar?: string;
face: string;
sign: string;
rank: string;
level_info: {
current_level: number
};
}
结语
基本就这些了,要说遗憾也有,这次本来想上WEBGL的后来觉得不太稳也没上,由于一开始没想太多所以后面发现性能有问题想改成canvas也来不及了。
说到底还是经验不太够,这次成长了很多,下次大型活动我们再见。