BML2017主视觉技术剖析

少女dtysky

世界Skill

时刻2017.06.12

今年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单例来管理所有资源,它构造时接受一个数组,数组中的每个对象可用参数配置srctype、是否需要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进行translatescalemargin的变换,在这里我强行开了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-eased3-interpolate来实现起始到终止态之间的插值;onEnd是一个回调,其在每次动画结束后触发;最后是这个animation属性,它是整个组件的核心,用于动画的触发,触发方式是要求两次传入的值不同,这个值可以是一个数字、一个数组或是一个对象。触发动画后,组件的children(要求是一个函数)将会每个一段时间(取决于requestAnimationFrame)接收到一个经过插值计算后的值,然后通过这个值进行组件的渲染。

有了这个组件,轮播的动画逻辑就很清晰了:用户每次操作时算出每个场次的下一个位置,扔到一个数组里(这里是next.styles),然后根据每一次的styles进行渲染,并监听onEnd事件,在动画结束后完成DOM的删除和插入即可。

World-地域选择和分享

第三屏是地域选择和分享,其可以划分为两个主要部分——“地区选择器”和“分享图片生成”。

地区选择器

地区选择器说来也简单,其实就是三个关联选择框,其核心是数据来源。这方面我是选择了网上找到的一个地区区号和名字的列表的xml文件,之后自己转成了两个json文件locationListTable.jsonlocationLookupTable.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也来不及了。

说到底还是经验不太够,这次成长了很多,下次大型活动我们再见。

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。