现在,用户对于前端页面的要求已经不能满足于实现功能,更要有颜值,有趣味。除了整体 UI 的美观,在合适的地方添加合适的动画效果往往比静态页面更具有表现力,达到更自然的效果。比如,一个简单的 loading 动画或者页面切换效果不仅能缓解用户的等待情绪,甚至通过使用品牌 logo 等形式,默默达到品牌宣传的效果。
React 作为最近几年比较流行的前端开发框架,提出了虚拟 DOM 概念,所有 DOM 的变化都先发生在虚拟 DOM 上,通过 DOM diff 来分析网页的实际变化,然后反映在真实 DOM 上,从而极大地提升网页性能。然而,在动画实现方面,React 作为框架并不会直接给组件提供动画效果,需要开发者自行实现,而传统 web 动画大多数都通过直接操作实际 DOM 元素来实现,这在 React 中显然是不被提倡的。那么,在 React 中动画都是如何实现的呢?
所有动画的本质都是连续修改 DOM 元素的一个或者多个属性,使其产生连贯的变化效果,从而形成动画。在 React 中实现动画本质上与传统 web 动画一样,仍然是两种方式: 通过 css3 动画实现和通过 js 修改元素属性。只不过在具体实现时,要更为符合 React 的框架特性,可以概括为几类:
- 基于定时器或 requestAnimationFrame(RAF) 的间隔动画;
- 基于 css3 的简单动画;
- React 动画插件 CssTransitionGroup;
- 结合 hook 实现复杂动画;
- 其他第三方动画库。
一、基于定时器或 RAF 的间隔动画
最早,动画的实现都是依靠定时器 setInterval
, setTimeout
或者 requestAnimationFrame
(RAF) 直接修改 DOM 元素的属性。不熟悉 React 特性的开发者可能会习惯性地通过 ref
或者 findDOMNode()
获取真实的 DOM 节点,直接修改其样式。然而,通过 ref
直接获取真实 DOM 并对其操作是是不被提倡使用,应当尽量避免这种操作。
因此,我们需要将定时器或者 RAF 等方法与 DOM 节点属性通过 state
联系起来。首先,需要提取出与变化样式相关的属性,替换为 state
,然后在合适的生命周期函数中添加定时器或者 requestAnimationFrame
不断修改 state
,触发组件更新,从而实现动画效果。
示例
以一个进度条为例,代码如下所示:
constructor(props) {
super(props);
this.state = {
percent: 10
};
}
increase = () => {
const percent = this.state.percent;
const targetPercent = percent >= 90 ? 100 : percent + 10;
const speed = (targetPercent - percent) / 400;
let start = null;
const animate = timestamp => {
if (!start) start = timestamp;
const progress = timestamp - start;
const currentProgress = Math.min(parseInt(speed * progress + percent,10),targetPercent);
this.setState({
percent: currentProgress
});
if (currentProgress < targetPercent) {
window.requestAnimationFrame(animate);
}
};
window.requestAnimationFrame(animate);
}
decrease = () => {
const percent = this.state.percent;
const targetPercent = percent < 10 ? 0 : percent - 10;
const speed = (percent - targetPercent) / 400;
let start = null;
const animate = timestamp => {
if (!start) start = timestamp;
const progress = timestamp - start;
const currentProgress = Math.max(parseInt(percent - speed * progress,targetPercent);
this.setState({
percent: currentProgress
});
if (currentProgress > targetPercent) {
window.requestAnimationFrame(animate);
}
};
window.requestAnimationFrame(animate);
}
render() {
const { percent } = this.state;
return (
<div>
<div className="progress">
<div className="progress-wrapper" >
<div className="progress-inner" style = {{width: `${percent}%`}} ></div>
</div>
<div className="progress-info" >{percent}%</div>
</div>
<div className="btns">
<button onClick={this.decrease}>-</button>
<button onClick={this.increase}>+</button>
</div>
</div>
);
}
}
在示例中,我们在 increase
和 decrease
函数中构建线性过渡函数 animation
, requestAnimationFrame
在浏览器每次重绘前执行会执行过渡函数,计算当前进度条 width
属性并更新该 state
,使得进度条重新渲染。该示例的效果如下所示:
这种实现方式在使用 requestAnimationFrame
时性能不错,完全使用纯 js 实现,不依赖于 css,使用定时器时可能出现掉帧卡顿现象。此外,还需要开发者根据速度函数自己计算状态,比较复杂。
二、基于 css3 的简单动画
当 css3 中的 animation
和 transition
出现和普及后,我们可以轻松地利用 css 实现元素样式的变化,而不用通过人为计算实时样式。
示例
我们仍以上面的进度条为例,使用 css3 实现进度条动态效果,代码如下所示:
constructor(props) {
super(props);
this.state = {
percent: 10
};
}
increase = () => {
const percent = this.state.percent + 10;
this.setState({
percent: percent > 100 ? 100 : percent,})
}
decrease = () => {
const percent = this.state.percent - 10;
this.setState({
percent: percent < 0 ? 0 : percent,})
}
render() {
// 同上例, 省略
....
}
}
在示例中, increase
和 decrease
函数中不再计算 width
,而是直接设置增减后的宽度。需要注意的是,在 css 样式中设置了 transition
属性,该属性在其指定的 transition-property
发生变化时自动实现样式的动态变化效果,并且可以设置不同的速度效果的速度曲线。该示例的效果如下图所示,可以发现,与上一个例子不同的是,右侧的进度数据是直接变化为目标数字,没有具体的变化过程,而进度条的动态效果因为不再是线性变化,效果更为生动。
基于 css3 的实现方式具有较高的性能,代码量少,但是只能依赖于 css 效果,对于复杂动画也很难实现。此外,通过修改 state
实现动画效果,只能作用于已经存在于 DOM 树中的节点。如果想用这种方式为组件添加入场和离场动画,需要维持至少两个 state
来实现入场和离场动画,其中一个 state
用于控制元素是否显示,另一个 state
用于控制元素在动画中的变化属性。在这种情况下,开发者需要花费大量精力来维护组件的动画逻辑,十分复杂繁琐。
三、React 动画插件 CssTransitionGroup
React 曾为开发者提供过动画插件 react-addons-css-transition-group
,后交由社区维护,形成现在的 react-transition-group
,该插件可以方便地实现组件的入场和离场动画,使用时需要开发者额外安装。 react-transition-group
包含 CSSTransitionGroup
和 TransitionGroup
两个动画插件,其中,后者是底层 api,前者是后者的进一步封装,可以较为便捷地实现 css 动画。
示例
export default class Tabs extends Component {
constructor(props) {
super(props);
this.state = {
activeId: 1,tabData: [{
id: 1,panel: '选项1'
},{
id: 2,panel: '选项2'
}]
};
}
addTab = () => {
// 添加tab代码
...
}
deleteTab = (id) => {
// 删除tab代码
...
}
render() {
const { tabData,activeId } = this.state;
const renderTabs = () => {
return tabData.map((item,index) => {
return (
<div
className={`tab-item${item.id === activeId ? ' tab-item-active' : ''}`}
key={`tab${item.id}`}
>
{item.panel}
<span className="btns btn-delete" onClick={() => this.deleteTab(item.id)}>✕</span>
</div>
);
})
}
return (
<div>
<div className="tabs" >
<CSSTransitionGroup
transitionName="tabs-wrap"
transitionEnterTimeout={500}
transitionLeaveTimeout={500}
>
{renderTabs()}
</CSSTransitionGroup>
<span className="btns btn-add" onClick={this.addTab}>+</span>
</div>
<div className="tab-cont">
cont
</div>
</div>
);
}
}
.tabs-wrap-enter.tabs-wrap-enter-active {
opacity: 1;
transition: all 500ms ease-in;
}
.tabs-wrap-leave {
opacity: 1;
}
.tabs-wrap-leave.tabs-wrap-leave-active {
opacity: 0.01;
transition: all 500ms ease-in;
}
CSSTransitionGroup
可以为其子节点添加额外的 css 类,然后通过 css 动画达到入场和离场动画效果。为了给每个 tab 节点添加动画效果,需要先将它们包裹在 CSSTransitionGroup
组件中。 当设定 transitionName
属性为 'tabs-wrapper'
, transitionEnterTimeout
为400毫秒后,一旦 CSSTransitionGroup
中新增节点,该新增节点会在出现时被添加上 css 类 'tabs-wrapper-enter'
,然后在下一帧时被添加上 css 类 'tabs-wrapper-enter-active'
。由于这两个 css 类中设定了不同的透明度和 css3 transition 属性,所以节点实现了透明度由小到大的入场效果。400毫秒后 css 类 'tabs-wrapper-enter'
和 'tabs-wrapper-enter-active'
将会同时被移除,节点完成整个入场动画过程。离场动画的实现类似于入场动画,只不过被添加的 css 类名为 'tabs-wrapper-leave'
和 'tabs-wrapper-leave-active'
。该示例效果如下图所示:
其中,入场和离场动画是默认开启的,使用时需要设置 transitionEnterTimeout
和 transitionLeaveTimeout
。值得注意的是, CSSTransitionGroup
还提供出现动画(appear),使用时需要设置 transitionAppearTimeout
。那么,出现动画和入场动画有什么区别呢?当设定 transitionAppear
为 true
时, CSSTransitionGroup
在
初次渲染
时,会添加一个出现阶段。在该阶段中,CSSTransitionGroup
的已有子节点都会被相继添加 css 类 'tabs-wrapper-appear'
和 'tabs-wrapper-appear-active'
,实现出现动画效果。因此, 出现动画仅适用于 CSSTransitionGroup
在初次渲染时就存在的子节点 ,一旦 CSSTransitionGroup
完成渲染,其子节点就只可能有入场动画(enter),不可能有出现动画(appear)。
此外,使用 CSSTransitionGroup
需要注意以下几点:
CSSTransitionGroup
默认在 DOM 树中生成一个span
标签包裹其子节点,如果想要使用其他 html 标签,可设定CSSTransitionGroup
的component
属性;CSSTransitionGroup
的子元素必须添加key
值才会在节点发生变化时,准确地计算出哪些节点需要添加入场动画,哪些节点需要添加离场动画;CSSTransitionGroup
的动画效果只作用于直接子节点,不作用于其孙子节点;- 动画的结束时间不以 css 中 transition-duration 为准,而是以
transitionEnterTimeout
,transitionLeaveTimeout
,TransitionAppearTimeout
为准,因为某些情况下 transitionend 事件不会被触发,详见 MDN transitionend 。
CSSTransitionGroup
实现动画的优点是:
- 简单易用,可以方便快捷地实现元素的入场和离场动画;
- 与 React 结合,性能比较好。
CSSTransitionGroup
缺点也十分明显:
- 局限于出现动画,入场动画和离场动画;
- 由于需要制定
transitionName
,灵活性不够; - 只能依靠 css 实现简单的动画。
四、结合 hook 实现复杂动画
在实际项目中,可能需要一些更炫酷的动画效果,这些效果仅依赖于 css3 往往较难实现。此时,我们不妨借助一些成熟的第三方库,如 jQuery 或 GASP,结合 React 组件中的生命周期钩子方法 hook 函数,实现复杂动画效果。除了 React 组件正常的生命周期外, CSSTransitionGroup
的底层 api TransitonGroup
还为其子元素额外提供了一系列特殊的生命周期 hook 函数,在这些 hook 函数中结合第三方动画库可以实现丰富的入场、离场动画效果。
TransisitonGroup
分别提供一下六个生命周期 hook 函数:
- componentWillAppear(callback)
- componentDidAppear()
- componentWillEnter(callback)
- componentDidEnter()
- componentWillLeave(callback)
- componentDidLeave()
它们的触发时机如图所示:
示例
GASP 是一个 flash 时代发展至今的动画库,借鉴视频帧的概念,特别适合做长时间的序列动画效果。本文中,我们用 TransitonGroup
和 react-gsap-enhancer
(一个可以将 GSAP 应用于 React 的增强库)完成一个图片画廊,代码如下:
constructor(props) {
super(props);
}
componentWillEnter(callback) {
this.addAnimation(this.enterAnim,{callback: callback})
}
componentWillLeave(callback) {
this.addAnimation(this.leaveAnim,{callback: callback})
}
enterAnim = (utils) => {
const { id } = this.props;
return new TimelineMax()
.from(utils.target,1,{
x: +=${( 4 - id ) * 60}px
,autoAlpha: 0,onComplete: utils.options.callback,},id * 0.7);
}
leaveAnim = (utils) => {
const { id } = this.props;
return new TimelineMax()
.to(utils.target,0.5,{
scale: 0,ease: Sine.eaSEOut,(4 - id) * 0.7);
}
render() {
const { url } = this.props;
return (
<div className="photo">
const WrappedPhoto = GSAP()(Photo);
export default class Gallery extends Component {
constructor(props) {
super(props);
this.state = {
show: false,photos: [{
id: 1,url: 'http://img4.imgtn.bdimg.com/it/u=1032683424,3204785822&fm=214&gp=0.jpg'
},url: 'http://imgtu.5011.net/uploads/content/20170323/7488001490262119.jpg'
},{
id: 3,url: 'http://tupian.enterdesk.com/2014/lxy/2014/12/03/18/10.jpg'
},{
id: 4,url: 'http://img4.imgtn.bdimg.com/it/u=360498760,1598118672&fm=27&gp=0.jpg'
}]
};
}
toggle = () => {
this.setState({
show: !this.state.show
})
}
render() {
const { show,photos } = this.state;
const renderPhotos = () => {
return photos.map((item,index) => {
return <WrappedPhoto id={item.id} url={item.url} key={`photo${item.id}`} />;
})
}
return (
<div>
<button onClick={this.toggle}>toggle</button>
<TransitionGroup component="div">
{show && renderPhotos()}
</TransitionGroup>
</div>
);
}
}