那些好看的页面都是怎么做出来的——前端动效研究:JS篇

5,136 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第7天,点击查看活动详情

这篇文章同样来自我们的约稿作者——杨鹏军同学,继上一篇3D地球后,鹏军同学这一期主要要聊的是动效场景~

一、前言

JS动效其实属于前端动效的进阶了,因为大部分网页的效果使用简单的css微动效已经能够达到足够的视觉呈现效果,但是我们有时候去浏览一些品牌页面,会发现他们的页面炫酷程度绝对不是简单的css就能够处理的:

js-animation1_1644549317847.gif

iPhone13Pro的官网页面就呈现出另人惊叹的视觉效果,其中用到了一些诸如渐变,视差错位,图形变换等常用前端动画技术。

结合华为云官网当前的动效场景,我们当前使用的两种核心js动效技术:

1.scroll

2.requestAnimationFrame

我们分节研究。

二、scroll

滚动动画一般是对滚动条滚轮事件的监听,官网当前通过监听滚动条实现入场出场视差滚动动画

2.1 入场出场

入场出场的关键在于如何计算元素何时入场,何时出场。

入场

private isWillEnter(element: any) {
    let { enterClass, enterDiff, customEnterDiffAttr } = this.opts;

    if (element.classList.contains(enterClass)) return false;

    const customEnterDiff = parseFloat(element.getAttribute(customEnterDiffAttr));

    if (!isNaN(customEnterDiff)) {
        enterDiff = customEnterDiff;
    }

    const elementEnterLength = utils.getElementEnterLength(element);

    return elementEnterLength > enterDiff;
}
    

Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。

getElementEnterLength(element) {
    const rect = element.getBoundingClientRect();
    return window.innerHeight - rect.top;
}

getElementEnterLength获得入场元素距离视窗底部的高度,如果这个高度大于我们设定的入场高度,则触发入场动画。

cd153351ba3d1d7aeef69d15b8309433_815x329.png@900-0-90-f_1644549411196.png

出场

private isWillOut(element: any) {
    let { outClass, outDiff, customOutDiffAttr } = this.opts;

    if (element.classList.contains(outClass)) return false;

    const customOutDiff = parseFloat(element.getAttribute(customOutDiffAttr));

    if (!isNaN(customOutDiff)) {
        outDiff = customOutDiff;
    }

    const elementEnterLength = utils.getElementEnterLength(element);

    return elementEnterLength < outDiff;
}

如果这个高度小于我们设定的退场高度,则触发出场动画。

监听滚动条改变元素style

window.addEventListener('scroll', () => {
    this.update();
});

public update() {
    const { enterClass, outClass, disableOut } = this.opts;

    this.elements.forEach((element: any) => {
        if (this.isWillEnter(element)) {
            element.classList.remove(outClass);
            element.classList.add(enterClass);
        } else if (!disableOut && this.isWillOut(element)) {
            element.classList.remove(enterClass);
            element.classList.add(outClass);
        }
    });
}

2.2 视差滚动

视差滚动的基本原理类似,监听滚动条与元素的位置,触发对应的元素样式变更,相对于入场出场,视差滚动还需要关注元素的动效进度信息。

public setInnersStyle() {
    const containerMoveRatio = this.opts.neverEnd
        ? utils.limitNumber(this.getContainerMoveRatio(), 0)
        : utils.limitNumber(this.getContainerMoveRatio(), 0, 1);

    this.dispatchWatcher(containerMoveRatio);
    if (this.oldMoveRatio !== containerMoveRatio) {
        this.oldMoveRatio = containerMoveRatio;
        this.events.emit('progress', this, containerMoveRatio);
    }
}

private getContainerMoveRatio() {
    const top2Window = utils.getTop2Window(this.$container[0]);
    const baseRange = this.opts.endTop - this.opts.startTop;

    return (top2Window - this.opts.startTop) / baseRange;
}

我们需要计算滚动容器当前滚动的距离占总长距离的百分比,然后将百分比转化为元素移动位置的百分比,作出视动画的效果

public update(progress) {
    let { el, startProgress, endProgress, startStyleValues, endStyleValues, styleRenderers } = this;

    if (startProgress === undefined) {
        startProgress = 0;
    }

    let pro = utils.limitNumber(progress, startProgress, endProgress);

    if (pro < startProgress || pro > endProgress) return;

    const ratio = endProgress !== undefined ?
        (pro - startProgress) / (endProgress - startProgress) :
        (pro - startProgress) / (1 - startProgress);

    const currentValues = utils.getStyleValuesByRatio(startStyleValues, endStyleValues, ratio);
    const style = {};

    Object.keys(styleRenderers).forEach(property => {
        style[property] = styleRenderers[property](currentValues[property]);
    });

    $(el).css(style);
}

js-animation2_1644549372162.gif

华为云的品牌页就通过这种原理实现了错位展示效果:

best-practise7_1644549579222.gif

www.huaweicloud.com/about/overv…

三、requestAnimationFrame

引入MDN的解释:

window.requestAnimationFrame()  告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

其实就是递归调用自己不断进行下一帧画面的宣传,因为在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的video里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。

实例(来自MDN):

const element = document.getElementById('some-element-you-want-to-animate');
let start;

function step(timestamp) {
  if (start === undefined)
    start = timestamp;
  const elapsed = timestamp - start;

  //这里使用`Math.min()`确保元素刚好停在200px的位置。
  element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';

  if (elapsed < 2000) { // 在两秒后停止动画
    window.requestAnimationFrame(step);
  }
}

window.requestAnimationFrame(step);

很多炫酷的js动效,包括3D场景动效,其中一个核心原理就是requestAnimationFrame的循环调用。

3.1 PortalAnimation 通用js动效

PortalAnimation的通用js动效又是针对视差动效的进一步升级,即计算progress进度进度百分比配合缓动函数曲线识别计算元素或者对象的属性与样式

外层动画引擎

  public play() {
    this.raf = requestAnimationFrame(t => this.step(t));
  }

  public step(time) {
    const animeInstancesLen = this.instances.length;
    if (animeInstancesLen) {
      for (let i = 0; i < animeInstancesLen; i++) {
        if (this.instances[i]) {
          this.instances[i].tick(time);
        }
      }
      this.play();
    } else {
      cancelAnimationFrame(this.raf);
      this.raf = null;
    }
  }

  public animeEngine() {
    this.play();
  }

遍历初始化PortalAnimation的相关实例,每一次重绘触发动效状态更新

动效执行实例

const baseOptions = {
  start: 0,
  end: 0,
  duration: 1000,
  delay: 0,
  loop: false,
  update: null,
  complete: null,
};

baseOptions主要想表达一个js动画要执行,我们需要告诉动效库实例的初始状态,结束状态,持续时间,延迟多久,是否循环,并且可以在动效更新和结束时加入一些回调函数。

计算动效进度

const eased = isNaN(elapsed) ? 1 : tween.easing(elapsed);
for (let n = 0; n < toNumbersLength; n++) {
  let value;
  const toNumber = tween.to.numbers[n];
  const fromNumber = tween.from.numbers[n] || 0;

  value = fromNumber + eased * (toNumber - fromNumber);

  if (round) {
    if (!(tween.isColor && n > 2)) {
      value = Math.round(value * round) / round;
    }
  }
  numbers.push(value);
}

除了本身的数值百分比,我们还需要根据使用人员选择的缓动函数方式进行二次计算。

部分缓动曲线

const baseEasings = ['Quad', 'Cubic', 'Quart', 'Quint', 'Expo'];

baseEasings.forEach((name, i) => {
	functionEasings[name] = () => time => Math.pow(time, i + 2);
});

// a b参数专属elastic ease
Object.keys(functionEasings).forEach(name => {
const easeIn = functionEasings[name];
eases['easeIn' + name] = easeIn;
eases['easeOut' + name] = (a, b) => time => 1 - easeIn(a, b)(1 - time);
eases['easeInOut' + name] = (a, b) => time => time < 0.5 ? easeIn(a, b)(time * 2) / 2 : 1 - easeIn(a, b)(time * -2 + 2) / 2;
eases['easeOutIn' + name] = (a, b) => time => time < 0.5 ? (1 - easeIn(a, b)(1 - time * 2)) / 2 : (easeIn(a, b)(time * 2 - 1) + 1) / 2;
});

应用

PortalAnimation动效库通过通用动效可以实现非常丰富多样的动效,包括数字动画,帧动画,物体运动动画,我们使用简单的函数调用就可以轻松做到:

数字动画

js-animation3_1644549668792.gif

运动曲线动画

js-animation4_1644549678524.gif

3.2 3D动效

Three.js基础动效也是通过requestAnimationFrame的原理实现的,具体方式就是通过渲染器renderer不断更新场景与摄像机实现:

function animate() {
    requestAnimationFrame( animate );

    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    renderer.render( scene, camera );
};

animate();

js-animation5_1644549718612.gif

当然我们借助一些类似于portal animation的动画插件可以实现更加酷炫的动画效果,这个我们后续在前端动效3D的篇章介绍。

我们是华为云的Web能力中心团队,专注于大前端工具建设,团队业务涉及低代码平台、UI组件库、前端监控、前端门禁、API编排等方向,我们的愿景是将前端技术在更多的领域落地开花,并通过QCon、GMTC等各类平台进行赋能分享,交流学习,如果你也认可我们的“诗和远方”,欢迎关注我们的掘金账号~