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

我不知道这个世界上有没有“仿世学”,但是既然动画是要模仿现实世界,那么实现动画的根本方法就是借鉴上帝的办法——模拟自然规律。本文以 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 是浏览器自己决定的。

不管 Δ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 中管理列表实现起来更加方便。

如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的文章

本作品 保留所有权利 。未获得许可人许可前,不允许他人复制、发行、展览和表演作品。不允许他人基于该作品创作演绎作品 。

14 条评论
bd_bai · 2016年01月06日

React 对普通 DOM 元素的动画要怎么处理比较好?

回复

远志致远 · 2016年01月21日

duration 总时间 Get!

回复

远志致远 · 2016年01月21日

请问什么样的劲度系数和空气阻力能使弹簧运动看起来很Q呢?

回复

JasonHuang 作者 · 2016年01月21日

这个问题问得比较大。要看不同的场景,文章最后也提到一些。我最常用的就是 CSS 的 transition。

回复

JasonHuang 作者 · 2016年01月21日

React的动画方案主要就是这些:ReAnimate(https://github.com/recharts/reanimate),React-Motion, React-Velocity, ReactCSSTransitionGroup,css transition,js raf。各有应用场景。感心情可以详细聊。

回复

bd_bai · 2016年01月21日

我用的 React Router,如果要在切换路由的时候淡出淡入,不知道怎么实现比较好。文章最后,包括官方文档,都只提到"列表项"的动画。不知道 ReactCSSTransitionGroup 能不能胜任呢。

回复

JasonHuang 作者 · 2016年01月21日

可以使用React-Motion提供的http://chenglou.me/react-motion/demos/demo5-spring-parameters-chooser/,配置合适的参数。或者看看 React-Motion demo 用的配置参数,我觉得它 demo 里面,比如 todo,运动起来还是蛮 Q 的

回复

bd_bai · 2016年01月21日

谢谢你,刚才回复的时候没看到。今天先休息了,明天再看。晚安。

回复

JasonHuang 作者 · 2016年01月21日

古耐,不好意思迟迟未回复

回复

远志致远 · 2016年01月22日

好的,谢谢楼主啦!

回复

JasonHuang 作者 · 2016年01月22日

回复

bd_bai · 2016年01月22日

非常感谢,我照着例子试了一下,这是个 Demo:
http://bdbai-notedemo-web.coding.io/
https://coding.net/u/bdbai/p/NoteDemo-web/
还有个问题,就是组件重新 render 的时候动画会重新触发一次,有没有办法避免掉?

回复

JasonHuang 作者 · 2016年01月23日

ReactCssTransitionGroup是对每个删除或出现children自动加上相应的className。如果同一个组件出现动画,那这个组件肯定不仅仅是reRender,而是ReMount了。

回复

bd_bai · 2016年01月23日

刚学 React 可能没有说清楚,在动画还没有结束的时候如果调用了 setState 就会出现重复的组件。具体表现就是加载 Note 完成以后出现了两个详细页面。地址和源码都在上面,麻烦您有时间的话帮忙看一下,多谢!

回复

载入中...
JasonHuang JasonHuang

187 声望

发布于专栏

pure render

一群志同道合的小伙伴,分享关于 React, Flux 在实践中的经验与想法 为 segmentfault 的小伙伴可以看到文章,特此同步在知乎上写的专栏 原作请到,http://zhuanlan.zhihu.com/purerender 未加允许,不得转载

218 人关注

系列文章