React Motion 缓动函数剖析

根据经典力学的观点,世界上所有的原子每时每刻仿佛都会根据当前速度、受力和位置计算出下一刻的速度、受力和位置。上帝有一台超级计算机吗?非也,反而计算机是我们利用原子的这些特性拼装出来的。现在,我们却要用计算机,像上帝那样,再造一个世界。

我不知道这个世界上有没有“仿世学”,但是既然动画是要模仿现实世界,那么实现动画的根本方法就是借鉴上帝的办法——模拟自然规律。本文以 React Motion 实现原理为背景,介绍一种通用的模拟物理规律的方法,以及如何使用这种方法实现 React Motion 的缓动函数。让我们来当一回上帝吧。

什么是缓动函数

动画的原理看似复杂,其实就是每帧不停得渲染。一张静态页面的渲染就是在一帧中渲染。如何渲染每一帧呢?我们可以用最简单,同时也是最繁琐的方法,就像最原始的动画片那样,写 n 张静态页面,然后每隔一帧切换一张。

假如我们已经勤奋地写好了 P_1, P_2, ... P_n 这 n 张页面,我们用它来实现一个简单的动画:

// pages: [P1, P2, P3 ... Pn];
const pageCount = pages.length;

const startAnimation = (currPageIndex) => {
  if (currIndex === pageCount) { return ; }
 
  document.body.innerHTML = (pages[currPageIndex++]);

  setTimeout(startAnimation.bind(null, currPageIndex), frameTime)
}

startAnimation(0);

用这种方法有着显而易见的问题:

  1. 写 n 张页面页面渲染效率十分低下。

  2. 每次重新设置 body.innerHTML,性能太低了。

我们来逐个解决上述问题。

  1. 每一帧的界面都遵循一定的规律,相似性很高,中间必然有很多重复劳动。既然是重复劳动,我们可以放心的交给计算机去完成。写一个渲染函数,只需要向这个函数描述一下当前页面的信息,这个函数就能把页面给渲染出来。

  2. 可以用局部更新的方式来取代块更新,其中 React 的 Virtual DOM 更新方便地解决了这个问题。

我们再以一个左右切换的 toggle 动画为例,写一个渲染函数:

const render = x => `
  <div class="toggle-slider">
    <div class="toggle-box" style="transform: translate3d(${x}, 0, 0)">
  </div>
`

有了这个函数之后,只需要告诉它 x 的当前值,新的页面就开始自动绘制了。由于 toggle 的运动规律,x 的值也不用手动依次给出,我们仍然可以写一个自动计算 x 的函数。这个自动计算 x 的函数,或者说计算页面状态的函数,就是缓动函数。

假设这个 toggle 是匀速运动的,缓动函数便可以写成这样:

$$ distance(总路程) = endX - beginX $$

$$ v = \frac{distance}{duration(总时间)} $$

$$ x = v \cdot t + beginX $$

用代码来表示,

const cal = (beginX, endX, duration, beginTime) => {
    const now = performance.now();
    const passedTime = now - beginTime;

    return (endX - beginX) / duration * passedTime + beginX;
}

最后完成这个 toggle 动画:

const beginX = 0;
const endX = 300;
const duration = 5000;
const frameTime = 1000 / 60;
    
let beginTime = performance.now();

const startAnimation = () => {
  const currX = cal(beginX, endX, duration, beginTime);

  document.body.innerHTML = render(currX);

  setTimeout(startAnimation, frameTime);
}

startAnimation(0);

requestAnimationFrame (raf)

可以看到,上述章节使用 setTimeout 来模拟时间的逝去,然而浏览器为动画过程提供了一个更为专注的 API - requestAnimationFrame

const update = now => {
  // calculate new state...

  // rerender here...

  raf(update);
};

raf(update);

raf 使用起来就像 setTimeout 一样,但有以下优点:

  1. 所有注册到 raf 中的回调,浏览器会统一管理, 在适当的时候一同执行所有回调。

  2. 当页面不可见,例如当前标签页被切换,隐藏在后面的时候,为了减少终端的损耗,raf 就会暂停。(如果像 jQuery 那样, 使用 setTimeout 实现动画,此时页面就会进行没有意义的重绘)。

raf 的这个特性,还可以利用在实时模块中,让标签页隐藏时停止发请求。

在开始使用 raf 前,我们需要一个 raf 的 polyfill ,比如 chrisdickinson/raf

然后,我们尝试用 React 和 raf 来重构一次 Toggle 动画。在数据上,用中介者模式实现一个简单的单向数据流:

const createStoreX = initialX => {
  let currX = initialX;
  let listeners = [];

  return {
    getX: () => currX,

    subscribe: listener => {
      listeners = [...listeners, listener];
    },

    changeX: newX => {
      currX = newX;
      listeners.forEach(listener);
    }
  };
}

const finalCreateStoreX = (createStoreX => initialX => {
  const store = createStoreX(initialX);

  return {
    ...store,
    changeX: newX => {
      store.changeX(newX);
    }
  };
})(createStoreX);

const store = finalCreateStoreX(0);

const View = x => (
  <div className="toggle-slider">
     <div className="toggle-box"
       style={{ transform: `translate3d(${x}, 0, 0)` }}>
  </div>
);

class Page extends React.Component {
  handleChangeX = () => {
    this.setState({
      x: storeX.getX()
    })
  }

  componentDidMount = () => {
    storeX.subscribe(this.handleChangeX)
  }

  render = () => <Page><View x={this.state.x} /></Page>
}

const startAnimation = (beginPos = 0, endPos = 300, duration = 5000, frameTime = 17) => {
  const now = performance.now();

  const loop = () => {
    const passedTime = performance.now() - now;
    const distance = endPos - beginPos;
    const currX = distance/duration*passedTime + beginPos;

    storeX.changeX(currX);
  }

  setTimeout(loop, frameTime);
};

reactDOM.render(<Page />, document.body)

有没有觉得很棒!但仍然有优化的空间。动画是源自现实世界的,人类早已习惯了一个变速运动的物理环境,这样的一个匀速动画会让人相对感觉不适。为了优化用户体验,React Motion 使用了一种常见的变速运动 —— 弹簧运动。

React Motion 缓动原理剖析

React Motion 使动画看起来像一个弹簧那样(一个有空气阻力的弹簧,如果没有空气阻力,弹簧就会不停地做简谐运动了)。大家可以尝试使用 React Motion 的spring-parameters-chooser,配置一个合适的劲度系数和空气阻力。弹簧动画可以使网站增添一些俏皮的元素,让用户体验起来更加舒畅!

下面就让我们进入主题,开始解读 React Motion 的缓动过程。

先模拟弹簧的物理规律,实现弹簧动画。

假设有一个弹簧,弹簧上绑了一个砝码,回到初中物理,根据胡克定律,砝码的受到弹簧的拉力为:

$$ F_{spring} = k\varDelta{x} (k为弹簧的劲度系数)$$

我们假设该砝码受到的空气阻力 kdamping 与砝码当前的速度 vt 呈正相关,其中阻尼系数为 kdamping

对砝码进行受力分析得:

$$ F = F_{spring} - F_{damping} = k_{spring}\varDelta{x} - k_{damping} \times v_{t} $$

设 at 为砝码当前加速度,得:

$$ F = ma_t $$

设 v' 和 x' 分别为经过 $$ dt $$ 时间后,砝码新的速度和位移,得:

$$ a_t = \lim_{dt \to 0} \frac{dv}{dt} = \lim_{dt \to 0} \frac{v^{'} - v_t}{dt} $$

$$ v_t = \lim_{dt \to 0} \frac{dx}{dt} = \lim_{dt \to 0} \frac{x^{'} - x_t}{dt} $$

即:

$$ v^{'} = \lim_{dt \to 0} a_t*d_t + v_t $$

$$ x^{'} = \lim_{dt \to 0} v_t*d_t + x_t $$

我们拿到了计算新状态的公式,但是 dt 是无限趋近于 0,怎么去模拟这个无限趋近于 0 呢?

现在只知道,当 dt 越趋近于 0 时,等式两边的值越接近(极限的单调有界准则可证)。可以把 dt 设为一个非常小的常量,虽然会造成一定的误差,但是不足为虑,只要骗过人类的眼睛就可以了。

这样我们就可以计算得出 v' 和 x' 。对以上过程不断重复,就能计算出任意时刻的位移和速度。

这是个通用的模拟物理规律的缓动过程,是否让你茅塞顿开?看一个同样的模拟物理规律的动画,有没有手痒?

但是,原谅我又说了 “但是”,如果我们要用 raf 实现这个缓动的话,raf 不能设置 callback 的延迟时间,而我们的 dt 是一个固定的非常小的常量。这种情况下,怎么计算新的状态呢?

我们设 raf callback 的延迟时间为 Δt ,第二部分已经说过,这个 Δt 是浏览器自己决定的。

pic

不管 Δt 是多少,可以用几个缓动过程连续叠加(一个缓动过程的时间是 dt )来拼凑出 Δt 。

不过 Δt 往往不是 dt 的整数倍,对于最后多出来的一小块时间,我们可以取一个比例值。

const dt = 1000 / 60;

let preTime = 0
  , initialState = {
      currX: -250,
      currV: 0,
}

const update = () => {
  const currTime = performance.now();
  const deltaTime = currTime - preTime;
  const steps = deltaTime / dt;

  const multiObj = (obj, k) => {
    return Object.keys(obj).reduce((res, key) => {
      return { ...res, [key]: obj[key] * k }
    }, {})
  };

  const getCurrState = (prevState, steps) => {
    if (steps < 1) {
      return multiObj(cal(prevState), steps)
    }

    return getCurrState(cal(prevState), steps - 1)
  };

  render(getCurrState(initialState, steps))

  raf(update);
}

update()

动画漫谈

CSS 动画与 JS 动画的区别是,使用 CSS 动画,不需要写缓动过程。比如在 transition 中,可以使用现成的 cubic bizier 的缓动(其中 ease, ease-in, ease-out 等都是特定参数值的 cubic bizier)。

(值得一提的是,transition的实现也使用了 raf 的机制,当标签页被切换时, transition 动画也会暂停,大家不妨试一试)

CSS 的 animation 使用设置关键帧的方式实现动画,适合完成多步、往返或者不断重复的动画。

那么我们什么时候需要 JS 动画呢——当你对 CSS 提供的缓动函数不满意的时候。打个比方,如果想实现像淘宝网在加购成功后,让商品 logo 沿着弧线运动的动画。

React Motion 所做的事,只不过自己实现了一套缓动函数。如果你不关心缓动过程,用 CSS 动画可以直接替换。

至于 React 当中的 ReactCSSTransitionGroup,是React提供的支持列表动画的 API 。试想一下,当渲染函数发现新的列表状态中,消失了某一项。那么要绘制这一项消失的动画,必须先让这一项暂存在 DOM 中,直到动画结束,再从 DOM 消失。这个实现起来比较麻烦,所以 React 提供了这个 API 帮助我们实现动画。值得注意的是,ReactCSSTransitionGroup 只是对列表的增与删提供动画支持。如果只是对列表项进行修改,不要生硬的套用 ReactCSSTransitionGroup,自己在 state 中管理列表实现起来更加方便。

阅读 8.5k

推荐阅读
pure render
用户专栏

一群志同道合的小伙伴,分享关于 React, Flux 在实践中的经验与想法为 segmentfault 的小伙伴可以看到文...

270 人关注
19 篇文章
专栏主页
目录