「疫情」反复发生,我在校园里待了两个月没有走出校园,我觉得很郁闷,那同理来说或许大家都很郁闷,那是否可以用某种方式来疏解一下郁闷?以弹幕形式的「线上许愿墙」便应运而生,或许你可以在这里查看线上的效果 线上 DEMO (内含音频⚠️,推荐使用微信打开)。

线上许愿墙

动画/动效

这个项目在动画和动效上画的时间会稍微多一点,同时很早之前就想尝试 Lottie (动画跨平台的简易方案) 于  Anyway.FM 播客的一期「欸!UI 设计师不可不了解(違う)的文档输出格式 」。

现如今动画方案已经非常多且成熟,包括但不限于 GSAP (我首页的动画便是基于他),Framer MotionReact Spring ;同时一些框架如 Vue.jsSvelte 都有对动画的原生支持。

而这其中最核心的便是 CSS 动画,无论是你是刚入门或者是饱经风霜的前端, Transition /Animation 都是绕不开的,我很早之前还追寻过各种 CSS 动画的优化方法并翻译过一篇「如何使阴影的动画表现得流畅光滑?

CSS 动画

首先,我们来简单介绍一下比较两个依托 CSS 完成动画的方案。

CSS Transition(过渡)

最早的 CSS 是没有任何时间概念的,你给予的变化都是立即响应的。但是现实中的物体运动都是变化的,状态是连续的,于是 Transition 带来的「时间」的概念。按照时间关系在两个状态中「过渡」,这便是 CSS Transition 的作用,他的好处想必不用说,让动画更自然、生动、更富有交互感。

我们用一个最简单的例子来阐述他的作用:

Hover me(w/o transition).
Hover me(w/ transition).
<div class="demo-block">
  <div id="transition-demo">
      Hover me(w/o transition).
  </div>
  <div id="transition-demo" class="with-transition">
      Hover me(w/ transition).
  </div>
</div>

<style>
#transition-demo {
    width: 150px;
    height: 150px;
    background: pink;
    display: flex;
    align-items: center;
    text-align: center;
}

#transition-demo:hover {
    background: red;
}

#transition-demo.with-transition {
    transition: .3s ease;
}
</style>

事实上,transition 是几个属性的联合简写,在使用 transition 时你至少需要保证 duration 即「过渡」动画的时长的定义:

{
  transition: property | duration | easing function | delay
}

这里将用表格的形式来简要阐述 transition 及其属性:

属性(Property) 描述 可行值 🌰
transition-delay 过渡延迟 1s .6s 600ms transition-delay: 600ms
transition-duration 过渡时间 同上 transition-duration: 600ms
transition-property 需要「过渡」的属性 all(所有属性), 其他值类的属性 transition-property: width, height, color
transition-timing-function 缓动函数 linear, ease-in, ease-out,
ease-in-out, cubic-bezier
transition-timing-function: ease

缓动函数(Timing function)1

当我们告诉浏览器「过渡」的始末后,浏览器需要生成动画的中间状态,而控制中间状态的函数便是「缓动函数」。

像上表总结的,他默认提供了 linear ease-in ease-out ease-in-out 四种默认的缓动函数,让我们来大概看一下,总的来说这是四条各异的「贝塞尔曲线」,如果你对这些曲线有兴趣,可以在 cubic-bezier playground 可视化的调试查看效果。

下面是一个不同缓动函数的例子:

none
linear
ease-in
ease-out
ease-in-out

更多关于 Timing Function 的例子或者有趣的应用可以在这里查看:Transition Timing Functions < CSS | The Art of Web , 另外除了我们上述所提到的「连续」变化的动画,其实 CSS 还提供了一种离散的缓动变化,具体介绍可以查看张鑫旭的 CSS3 animation属性中的steps功能符深入介绍

Transition 的优劣2

其优点主要在于,「简单」、「易于使用」。 但是,它的缺点也很明显:

  1. 只能完成一次,不能多次触发
  2. 无法对动画进行「暂停」、「恢复」、「取消」等操作;
  3. 无法在网页加载时触发(但是可以通过 JavaScript 控制 class 来进行动画)
  4. 一条 transition 只能规定一个「相同」的变化,不能同时多个不同的属性变化。

动画性能

动画的性能主要由 「FPS」(Frames Per Second,每秒帧数)来衡量,「帧」大概可以理解为「一个画面」,而动画由一个个静态画面组成,故 FPS 越高则是动画性能越好。

浏览器与渲染有关的两个流程是「重绘」、「重排」,简单来说 「重排」是对文档流进行重新布局,而「重绘」是对文档流中的元素进行重新渲染(不影响文档流),关于这两个概念你可以在 Understanding Reflow and Repaint in the browser 中尝试获取更深刻的理解。文章 如何使阴影的动画表现得流畅光滑? 便是从重排重绘的角度对动画性能进行优化。

其次,大部分时候浏览器使用 CPU 进行渲染,但是为了动画效果,我们可能需要让浏览器启用「GPU 渲染」。 或许你对 3d transform 会启用 GPU 加速有所耳闻(实际在 Rakunr 也用了这种 hack 加速法)。但实际上我们不需要进行 z-axis 变换,于是这个时候 will-change 应运而生,直白点来讲这个属性就是告诉浏览器 “我要变形了!”。

will-change3,是 CSS 3 新增的属性,通过 will-change 告知浏览器元素要对什么属性进行优化。

{
  will-change: margin-left;
}
w/ will-change
w/o will-change

但是请注意,will-change 虽好,但在大量的 will-change 任务排队时,那渲染的性能还是非常不理想的,所以我们仍然需要遵循最小化影响原则。

CSS Animation(动画)

而 Animation 便是为了解决 Transition 所遇到的困窘,使 Animation 获得 「多次触发」、「管理状态」、「多种变化」 的能力可以归功于 「始终态」 与 「关键帧」自定义功能。

transition 一样,animation 也是多个属性的简写。

<div class="bg-animation"></div>

<style>
.bg-animation {
  width: 120px;
  height: 120px;
  animation: pulse 5s infinite;
}

@keyframes pulse {
  0% {
    background-color: #001F3F;
  }
  100% {
    background-color: #FF4136;
  }
}
</style>

可以从上面的例子看出,animation 相对于 transition 多了一个 @keyframes [[animationName]],而这是 Animation 中最为生命力的地方,你可以在任意时刻插入你的「关键帧」,从而更灵活的控制你的动画。

完整定义一个 Animation 如下所示:

.element {
  // 动画名称
  animation-name: stretch;
  // 动画持续时间
  animation-duration: 1.5s; 
  // 缓动函数
  animation-timing-function: ease-out; 
  // 动画播放延迟
  animation-delay: 0s;

  // 动画播放的方向
  animation-direction: alternate;
  // 循环播放次数
  animation-iteration-count: infinite;
  // 动画播放后结束状态
  animation-fill-mode: none;
  // 动画播放状态
  animation-play-state: running; 
}

对于前四个属性与 transition 大致相同,而后面四个属性是予我们「控制」动画得能力。

属性(Property) 描述 可行值
animation-direction 播放方向 normal alternate reverse alternate-reverse
animation-play-state 播放状态 running paused
animation-fill-mode 结束状态 none:回到动画没开始
backwards:回到第一帧 both:根据 animation-direction 轮流应用 forwards 和 backwards规则
animation-iteration-count 播放次数 数字, infinite

继续阅读:

SVG 动画4 与 Canvas 动画

SVG(Scalable Vector Graphics) 是现在 Web 流行的「矢量图像格式」,除了绘图能力,他还提供了一些高级的动画 API,主要通过 <set>, <animate>, <animateColor>, <animateTransform>, <animateMotion> 几个方法进行动画。在这里不进行过多介绍,详细的可以查看4

<svg width="500" height="100">
  <circle id="orange-circle" r="30" cx="50" cy="50" fill="orange" />
  <animate 
           xlink:href="#orange-circle"
           attributeName="cx"
           from="50"
           to="450" 
           dur="1s"
           begin="click"
           fill="freeze" />
</svg>

Canvas5 是浏览器提供的绘图 API,它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。如果通过 Canvas 实现动画,则需要自己管理每一个状态与绘制图形,是最麻烦但是自由度最高的动画方式。

Lottie

Lottie 是由 Airbnb 开发的实时渲染 After Effects 动画的库,他可以工作在 iOS, Android 和 Web 上。 我们使用 Lottie 的时候仅需要,浏览器中的动画后端可以使用 html, svgcanvas

Lottie 的动画文件主要使用 JSON 格式,你可以在 Lottie files 寻找和自定义你喜欢的动画,然后导出 JSON 文件。

import lottie from 'lottie-web';
import animationData from './animation.json';

let anim = lottie.loadAnimation({
  container: document.queryElementById('lottie'),
  renderer: 'canvas',
  loop: true,
  autoplay: true,
  animationData: animationData,
})

这样,我们就完成了 Lottie 的动画的创建,并且可以使用 anim 对象对动画进行操作。

这个项目在最开始使用 Lottie 的时候,出现了非常严重的 内存泄漏 的情况,于是我收到了求助,并在查阅 issues 时 Memory leak using goToAndStop and destroy 并得知,Lottie 的动画是直接对 animationData 对象进行操作,所以如果使用上述方法,则会导致内存泄漏。

为了解决这个问题,对 animationData 进行一个 深拷贝 就能解决,即 _.cloneDeep(animationData)

除此之外,Vue.js 特别在文档中提示 Avoid Memory Leaks ,所以你还需要在组建卸载的时候对 lottie 实例进行「销毁」,最终的代码大致长这样:

mounted: {
  this.animation = lottie.loadAnimation({
    container: document.queryElementById('lottie'),
    renderer: 'canvas',
    loop: true,
    autoplay: true,
    animationData: _.cloneDeep(animationData),
  })
},
destroyed: {
  this.animation.destory()
  this.animation = null
}

弹幕的实现6

弹幕的滚动

在实现现在这个版本的弹幕之前,学弟做了两个小 DEMO,都是基于两个 CSS 方案的,解决起来也比较粗糙,但是很适合教学。

transition

12312321
12312321
12312321
  <div class="danmaku-block">
    <div class="danmaku">12312321</div>
    <div class="danmaku">12312321</div>
    <div class="danmaku">12312321</div>
  </div>

  <style>
    .danmaku {
      transform: translateX(100%);
      transition: 0s;
    }

    .demo-block:hover .danmaku {
      transform: translateX(-100%);
      transition: 3s linear;
    }
  </style>

  <script>
    document.querySelectorAll('.danmaku').forEach(el => {
      el.addEventListener('transitionend', () => {
        console.log('end!')
      })
    })
  </script>

animation

12312321
12312321
12312321
  <div class="demo-block">
    <div class="animation-danmaku is-paused">12312321</div>
    <div class="animation-danmaku is-paused">12312321</div>
    <div class="animation-danmaku is-paused">12312321</div>
    <button id="danmaku-control">播放</button>
  </div>

  <script>
    const danmakuCtrl = document.querySelector('#danmaku-control')
    const danmakuLists = document.querySelectorAll('.animation-danmaku')

    danmakuLists.forEach((el) => {
      el.addEventListener('mouseenter', (e) => {
        e.target.classList.add('is-paused')
      })

      el.addEventListener('mouseleave', (e) => {
        e.target.classList.remove('is-paused')
      })

      let iterCnt = 0
      el.addEventListener('animationiteration', (e) => {
        e.target.innerHTML = `12312321 ${++iterCnt}`
      })
    })

    danmakuCtrl.addEventListener('click', () => {
      danmakuLists.forEach((el) => el.classList.remove('is-paused'))
    })
  </script>

  <style>
    .animation-danmaku {
      animation: 6s danmaku-move infinite linear;
      transform: translateX(-100%);
    }

    .animation-danmaku.is-paused {
      animation-play-state: paused;
    }

    @keyframes danmaku-move {
      0% {
        transform: translateX(150%);
      }

      100% {
        transform: translateX(-50%);
      }
    }
  </style>

弹道+完整实现

由于本人特别懒,所以直接参考了弹幕实现原理 ,这篇文章考虑的细节还是比较好的,代码逻辑也清晰,所以大家可以直接上。

不过他使用的是 transition 的方案,我这边由于需要实现 悬浮暂停 则切换到了 animation 能够帮助我控制弹幕的进度。

端表现不一致性

万恶的 Safari

在 Safari 下我遇到了两个非常头疼的问题,

  1. var() 失效
  2. flexbox 的 transform 失效
.item {
  --height: 127px;
  display: flex;
}

.item.next-state {
  transform: translateX(-var(--height));
}

上述代码可是把这两个坑踩了个遍,当时在 iOS 设备上,弹幕不正常工作,我还以为是「高度太小」导致程序判断不够塞弹幕,结果实际 debug 的时候发现“弹幕都插入了”,但是没有他的影子。

如果想要在 Safari 中正常使用 var,那么你需要这么做:

:root {
  --height: 127px;
}

.item {
  height: var(--height);
}

万恶的移动端

Android 软键盘改变布局问题

在 iOS 与 Android 下软键盘的表现是不一致的,iOS 的软件是单独的一层,而 Android 的软键盘是会占用底部的,故在两者的软键盘弹出时,Android 的 Webview 的布局高度会变坍塌,而 iOS 则不会。

为了解决问题我们有两种解决思路,核心都是「固定高度」:

  1. 固定高度
<div :style='{{ height: document.body.clientHeight + 'px' }}' />
  1. 监听变换
let height = document.body.clientHeight
window.onresize = () => {
  if (height > document.body.clientHeight) {
    document.body.style.height = height + "px"
  }
}

onClickonMouse* 事件

移动端的「点击」事件与 PC 端表现的不太一致,其主要是为了判断「是否为长按」而对所有的 onClick 进行了 200ms 的延迟;所以你想即想「快速反应」又不想重复触发事件,则需要一点 trick,主要是以下两个方法:

  1. 使用冒泡阻止
node.addEventListener('click', () => {
  console.log('handle click')
})

node.addEventListener('ontouchstart', (e) => {
  e.preventDefault()
  console.log('handle touch')
})
  1. 判断是否支持 「touch」
const clickEvent = (function () {
  return 'ontouchstart' in document.documentElement === true ? 'touchstart' : 'click'
})();

node.addEventListener(clickEvent, (e) => {})

感谢看到这里,实际上这个项目已经以 Apache License 的形式开源在了 Wish-Danmaku-Wall ,如果有什么好的建议或者想法可以随时在 Issues 或者评论区留言。