当前位置: 首页 > 科技观察

前端必学的动画实现思路!_0

时间:2023-03-20 19:27:22 科技观察

合理的动画是良好用户体验的重要组成部分。我们平时是怎么写动画的?CSS中的动画和过渡,以及requestAnimationFrame?示例请看下面的示例:这是一个可以添加的随机打乱的数字列表。首先想一想,我们的第一直觉可能是这样做:用绝对定位对这些数字的DOM节点进行布局,计算数字变化后top和left的值,然后配合transition来实现动画。这种方式看似简单,但实际上需要在内部维护各种位置信息,所有坐标都需要手动管理,相当复杂,非常不利于后期扩展。如果将这些节点换成高度不固定的图片,计算量可想而知。那么有没有更好的方法来实现呢?当然,这里有一个闪亮的概念:FLIP。提前预览:https://minjieliu.github.io/react-flip-demoFLIPFLIP其实是几个词的缩写:First,Last,Invert,Play。让我们分解一下:首先涉及动画元素的初始状态(如位置、比例、透明度等)。Last指的是被动画化的元素的最终状态。Invert这一步是核心,就是找出这个元素是怎么变化的。比如元素在First和Last之间向右移动50px,需要在X方向平移X(-50px),让元素看First的位置。这里有一个知识点值得注意。DOM元素属性(如left、right、transform等)的变化会集中延迟到浏览器下一帧统一渲染出来,所以我们可以得到这样一个中间时间点:DOM位置信息发生了变化,但浏览器尚未呈现它[1]。也就是说在一定时间内,我们可以获取到DOM变化的位置,但是在浏览器中的位置没有变化。经测试,这个过程超过10ms就会变得不稳定。所以setTimeout(fn,0)、ReactuseEffect和Vue$nextTick都可以实现Invert过程。Play从Invert返回到最终状态。有了两点的位置信息,就可以使用transition实现中间的过渡动??画了。本文使用WebAnimationAPI[2]实现,动画执行时不会在DOM中加入CSS,相当干净。这里的实现主要是使用React的方式来实现这个效果,其他框架原理相同,供参考。一个连续有5个孩子的列表:.list{display:flex;弹性包装:包装;宽度:400px;}.item{显示:flex;对齐项目:居中;证明内容:居中;宽度:80px;高度:80px;border:1pxsolid#eee;}functionListShuffler(){const[data,setData]=useState([0,1,2,3,4,5]);}constlistRef=useRef(null);返回({data.map((item)=>({item}

))}
);}首先我们需要记录First和Last的位置信息,用来计算Invert的偏移差,所以用Map对象存储是最合适的。通过这个方法,我们可以用它来生成之前和之后的快照:}constelements=Array.from(nodes.childNodes)asHTMLElement[];//使用节点作为Map的key存放当前快照,下次使用节点引用值,相当方便returnnewMap(elements.map((node)=>[node,node.getBoundingClientRect()]));}点击添加时记录第一个快照://使用ref存储DOM的先前位置InformationconstlastRectRef=useRef>(newMap());functionhandleAdd(){//添加一项到顶部,让后面的节点移动setData((prev)=>[prev.length,...prev]);//并存储变化前的DOM快照lastRectRef.current=createChildElementRectMap(listRef.current);}接下来,DOM更新后,需要变化的快照。在React中,这里可以使用useEffect或useLayoutEffectGet:useLayoutEffect(()=>{//改变后的DOM快照,此时UI不更新constcurrentRectMap=createChildElementRectMap(listRef.current);},[data]);现在,我们可以对上一个快照进行遍历,实现Invert和Play://遍历上一个快照lastRectRef.current.forEach((prevRect,node)=>{//前后快照的DOM引用相同,你可以直接得到constcurrentRect=currentRectMap.get(node);//Invertconstinvert={left:prevRect.left-currentRect.left,top:prevRect.top-currentRect.top,};constkeyframes=[{transform:`translate(${invert.left}px,${invert.top}px)`,},{transform:'translate(0,0)'},];//Play执行动画node.animate(keyframes,{duration:800,easing:'cubic-bezier(0.25,0.8,0.25,1)',});});你完成了!在这里,每个节点都有单独的动画,每个节点之间不冲突,也就是说不管节点位置有多复杂,都可以从容处理。比如图片乱码,只需要从lodash引入shuffle修改数据即可达到完美显示。import{shuffle}from'lodash-es';functionshuffleList(){setData(shuffle);//并存储变化前的DOM快照lastRectRef.current=createChildElementRectMap(listRef.current);}上面的大致思路是First->Last->Invert->Play转换过程。预览:您是否注意到每次完成操作时都需要手动更新快照。作为开发者,你不能忍。我们得偷懒到极致,好好包装。直白需求:数据变化后自动执行动画,不需要关心任何动画逻辑,不限制DOM结构使用,保持简单,性能更好!在React更新模型中,执行顺序是:setState->render->layoutEffect。因此,setState生成快照的步骤可以放在render中,从而与操作解耦。(如果动画放在useLayoutEffect中,位置计算会经常不准确)useMemo(()=>{//executelastRectRef.current.forEach((item)=>{item.rect=item.node.getBoundingClientRect();});},[数据]);再加上之前useLayoutEffect的逻辑,我们可以把它抽取出来做成一个独立的组件(Flipper),用flipKey来控制。只要flipKey一变,动画就会执行,也就是1.2两点。Flipper.tsxexport默认函数Flipper({flipKey,children}:FlipperProps){constlastRectRef=useRef>(newMap());constuniqueIdRef=useRef(0);//通过ref创建一个函数,传递上下文以避免渗透渲染current.delete(flipId);},nextId(){return(uniqueIdRef.current+=1);},});useMemo(()=>{lastRectRef.current.forEach((item)=>{item.rect=item.node.getBoundingClientRect();});},[flipKey]);useLayoutEffect(()=>{constcurrentRectMap=newMap();lastRectRef.current.forEach((item)=>{currentRectMap.set(item.flipId,item.node.getBoundingClientRect());});lastRectRef.current.forEach(()=>{//之前的FLIP代码});},[flipKey]);返回{children}</FlipContext.Provider>;}初始方法是通过native方法遍历DOM,所以我们只能限制一级子节点,操作方法也脱离了React的写模型。为了改进它,我们可以使用Context来通信和存储:FlipContext.tsimportReact,{createContext}from'react';exporttypeFlipItemType={//子组件的唯一标识flipId:number;//子组件通过ref得到的节点node:HTMLElement;//子组件位置快照rect?:DOMRect;};exportinterfaceIFlipContext{//挂载后执行addadd:(item:FlipItemType)=>void;//unout后执行removeremove:(flipId:number)=>void;//自动递增唯一IDnextId:()=>number;}exportconstFlipContext=createContext(undefinedasunknownasReact.MutableRefObject,);最后需要实现每个动画元素的节点集合,用自定义组件Flipped包裹动画节点,并且cloneElement(children{ref})劫持ref,mount时将子组件ref添加到Context中,unmount时移除。React-photo-view[3]以同样的方式换行。即,实现了3、4两点。Flipped.tsximportReact,{cloneElement,memo,useContext,useLayoutEffect,useRef,}from'react';从'./FlipContext'导入{FlipContext};exportinterfaceFlippedProps{children:React.ReactElement;innerRef?:React.RefObject;}functionFlipped({children,innerRef}:FlippedProps){//Flipper.tsx通过Context传递ref避免穿透渲染constctxRef=useContext(FlipContext);constref=useRef(null);常量currentRef=innerRef||参考;useLayoutEffect(()=>{constctx=ctxRef.current;constnode=currentRef.current;//生成唯一IDconstflipId=ctx.nextId();if(node){//挂载后添加节点ctx.add({flipId,node});}return()=>{//unmout后删除节点ctx.remove(flipId);};},[]);returncloneElement(children,{ref:currentRef});}exportdefaultmemo(Flipped);好吧,让我们看看如何使用它。总共只有两个API。从原始的JSX开始,只需将其包裹起来就会有动画:{data.map((item)=>({item}
))}
是不是超级简单!最后,还有一个很重要的指标就是性能问题,因为每个节点都是一个独立的动画,数据量大的时候渲染肯定会卡顿。经过测试,5000个DIV节点的数字数组的随机动画需要2秒左右的时间才能完成更新,这是非常难以接受的。我们只能让屏幕上的节点有动画,其他节点就跳过。我们只需要判断这两个状态不在屏幕上即可,可以节省2/3的时间:constisLastRectOverflow=rect.right<0||rect.left>innerWidth||矩形底部<0||rect.top>innerHeight;常量isCurrentRectOverflow=currentRect.right<0||currentRect.left>内部宽度||currentRect.bottom<0||currentRect.top>innerHeight;if(isLastRectOverflow&&isCurrentRectOverflow){return;}//node.animate()...记得react-beautiful-dnd[4]库刚出来的时候,拖动动画让很多人着迷.但是现在有了FLIP和react-dnd[5],这样的动画可以轻松实现,功能更是碾压。然而,react-motion[6]等动画库实现这个动画要复杂得多,因为它使用了绝对定位控制的类型。下面的例子只是用新打包好的Flipper包裹的:下面是源码:https://github.com/MinJieLiu/react-flip-demo里面的Flipper组件目录可以直接复制到项目中使用,100行代码相当轻巧