管中窥豹之 react-spring

72 阅读6分钟

react-spring.dev/

是什么

React Spring 是一个用于构建互动式、数据驱动和动画化 UI 组件的库。

特点:使用高度优化的物理驱动动画(对比 css 的 timing-function),模拟了真实物理世界中的弹簧模型来驱动动画效果。

而且react-spring 通过直接操作 DOM 属性更新动画,而不会触发 React 组件的重新渲染。

  • 动画的计算和更新逻辑独立于 React 的状态系统,放在外部。
  • 使用 raf(requestAnimationFrame)来驱动动画,而不是依赖 React 的生命周期。

基本使用

最最简单的 use case:

import { useSpring, animated } from '@react-spring/web'

export default function MyComponent() {
  const [springs, api] = useSpring(() => ({
    from: { x: 0 },
  }))

  const handleClick = () => {
    api.start({
      from: {
        x: 0,
      },
      to: {
        x: 100,
      },
    })
  }

  return (
    <animated.div 
     onClick={handleClick}
     style={{width: 80,height: 80,background: '#ff6d6d',borderRadius: 8,...springs,}}
    />
  )
}

核心概念

SpringValue

styles 整体的类型是 SpringValues,包含各种 style 相关信息,比如

  • 位置(如 x, y, z)
  • 透明度(如 opacity)
  • 旋转(如 rotate) 这些值本身是由物理驱动计算得出的,并通过 spring 函数的模拟物理模型(如弹簧的刚度、阻尼等)动态更新的。其中每个属性的值类型都是 SpringValue

每个 useSpring() 方法都实际上是创建了一个 Controller 实例,返回两个参数[springs, api]

  • api 是 SpringRef 类型,可以调用 Controller 的实例方法,进而精细控制动画的 start/pause/set/update
  • springs 中每个value的类型都是 SpringValue,内部有维护 isAnimating、isPaused、from、 to、velocity 等等信息,所以每个属性都可以实现丝滑的转移过程。

targets

像react 支持多个 reconciler,react-spring 也提供了对接多个 target 的能力,对运动过程中的每个指标也抽象了一层--也就是 SpringValue 类 ,最终发布不同target 的包时再分别实例化,同时也根据不同平台注册元素并处理SpringValue 到实际生效动画的映射逻辑。

type CreateHost = (
  // 不同 target 平台注册的组件也不同
  components: AnimatableComponent[] | { [key: string]: AnimatableComponent },
  // config 中包含如何处理props 等方法,比如web环境下 transition 属性需要特殊处理
  config: Partial<HostConfig>
) => {
  animated: WithAnimated
}

以 web 为例,看 CreateHost 的入参

const host = createHost(primitives, {
  applyAnimatedValues,
  // AnimatedStyle是将比如 x,y,转换成css 的 translate3d及 rotate 等transform的属性
  createAnimatedStyle: style => new AnimatedStyle(style),
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  getComponentProps: ({ scrollTop, scrollLeft, ...props }) => props,
});

export const animated = host.animated 

// 直接更新dom 属性,截取部分代码示例
function applyAnimatedValues(instance: Instance, props: Lookup) {
  const {
    className,
    style,
    children,
    scrollTop,
    scrollLeft,
    viewBox,
    ...attributes
  } = props!
  
  if (children !== void 0) {
    instance.textContent = children;
  }
  
   // Apply CSS styles
  for (const name in style) {
    if (style.hasOwnProperty(name)) {
      const value = dangerousStyleValue(name, style[name])
      if (isCustomPropRE.test(name)) {
        instance.style.setProperty(name, value)
      } else {
        instance.style[name] = value
      }
    }
  }

  // Apply DOM attributes
  Object.keys(attributes).forEach((name, i) => {
    instance.setAttribute(name, values[i])
  })

  if (className !== void 0) {
    instance.className = className
  }
}

animated component

  • 通过 style prop 接收 SpringValues,然后直接更新 dom 属性
  • animated.xxElement 都是HOC,传递所有props 到目标元素,包括 SpringValues 的插值逻辑,提取其中所有跟 style 相关的属性特殊处理。 插值:指根据目标状态(如位置、透明度等)和当前的物理模拟状态(如弹簧的刚度、阻尼等)来动态地计算这些属性值的过程

如何避免的react rerender?

  • 通过 ref 可以直接获取到 react node,然后直接更新了 DOM 节点属性,而不再通过更新 style-related state/props 来触发reac的更新机制。

animated 的注册逻辑:

const animated: WithAnimated = (Component: any) => {
  // Component is string tyle
  Component =
    animated[Component] ||
    (animated[Component] = withAnimated(Component, hostConfig))
  
  return Component
}

// withAnimated 是实际的 HOC
const withAnimated = (Component, host) => {
  return forwardRef((givenProps, givenRef) => {
    
    // deps 都是 FluidValue 类型,useSpring 返回的style中每一个value都是该类型
    const [props, deps] = getAnimatedState(givenProps, host)
    
    // 依赖属性变更时的回调
    const callback = () => {
      // 实际更新 DOM style
      const didUpdate = host.applyAnimatedValues(instance, props.getValue(true));

      // Re-render the component when native updates fail.
      if (didUpdate === false) {
        forceUpdate()
      }
    }
    
    // 向组件上绑定 deps(fluidValue类型的属性),打造独立于react之外的订阅-监听体系,当动画属性变化时,对应组件均可以触发更新
    const observer = new PropsObserver(callback, deps)
    const observerRef = useRef<PropsObserver>()
    
    //  node 端是useEffect, clien 端是 useLayoutEffect
    useIsomorphicLayoutEffect(() => {
      observerRef.current = observer

      /**
      * Observe the latest dependencies. 
      * addFluidObserver 的大致逻辑:
      * 1. dep.observers.add(observer),每个dep(FluidValue类) 内部有属性比如observers维护了监听器
      * 2. 每个observer中都有update方法,当 dep值变化时 dep.observers.forEach(observer => observer.update(newValue))
      */
      each(deps, dep => addFluidObserver(dep, observer))

      return () => {
        // Stop observing previous dependencies.
        if (observerRef.current) {
          each(observerRef.current.deps, dep =>
            removeFluidObserver(dep, observerRef.current!)
          )
          raf.cancel(observerRef.current.update)
        }
      }
    })
    
    
    const usedProps = host.getComponentProps(props.getValue())
    
    return <Component {...usedProps} ref={givenRef} />
  })
}

Do your spring

前置物理知识

弹簧力(Spring Force)描述弹簧在受到压缩或拉伸时,产生的回复力,也就是弹簧试图恢复到原始状态的力。 公式: F=−k⋅x, k 越大,移动越快。x是弹簧位移/形变,也就是弹簧伸长或者压缩的量,可以认为 to 是弹簧的原位。

源码中的使用: const springForce = - config.tension * 0.000001 * (position - to);

阻尼力(Damping Force) dampingForce = -config.friction * 0.001 * velocity:这个公式用于计算阻尼力,config.friction 是阻尼常数,velocity 是物体的速度,0.001 是缩放系数。

加速度(Acceleration)

  • acceleration = (springForce + dampingForce) / config.mass:根据牛顿的第二定律,物体的加速度由总力除以物体的质量得出。

更新速度和位置

  • velocity = velocity + acceleration * step:更新物体的速度。
  • position = position + velocity * step:更新物体的位置。

模拟 spring 动画实现

我们先模拟最简单的水平位移场景

class SpringSimulation {
  constructor(config) {
    this.config = config;
    this.position = 0; // 物体初始位置
    this.velocity = 0; // 物体初始速度
    this.running = false; // 动画是否正在运行
  }

  // 设置物体初始位置
  setPosition(position) {
    this.position = position;
  }

  // 设置物体初始速度
  setVelocity(velocity) {
    this.velocity = velocity;
  }

  // 更新位置的计算方法
  updatePosition(step, from, to) {
    // 计算弹簧力
    const springForce = -this.config.tension * 0.000001 * (this.position - to);
    // 计算阻尼力
    const dampingForce = -this.config.friction * 0.001 * this.velocity;
    // 计算加速度
    const acceleration = (springForce + dampingForce) / this.config.mass;

    this.velocity = this.velocity + acceleration * step;
    this.position = this.position + this.velocity * step;

    return this.position;
  }

  // 启动动画并通过 requestAnimationFrame 进行更新
  startAnimation(from, to, applyAnimationValue) {
    // 初始化位置和速度
    this.setPosition(from);
    this.setVelocity(0);

    const timeStep = 16.67; // 每一帧的时间步长,16.67ms 
    const animate = () => {
      // 更新物体位置
      const newPosition = this.updatePosition(timeStep, from, to);
      
      // 可以根据 position 更新 DOM style
      applyAnimationValue?.()

      // 判断是否已经接近目标位置
      if (Math.abs(newPosition - to) > 1) {
        // 如果物体尚未到达目标位置,继续下一帧动画
        requestAnimationFrame(animate);
      } else {
        // 物体到达目标位置,停止动画
        console.log("Reached target position:", newPosition);
      }
    };

    this.running = true;
    requestAnimationFrame(animate);
  }

  // 停止动画
  stopAnimation() {
    this.running = false;
  }
}

// 配置弹簧参数
const config = {
  tension: 170, // 张力
  friction: 26, // 阻尼
  mass: 1, // 物体质量
};

// 创建模拟实例
const springSim = new SpringSimulation(config);
export default springSim;

组件中使用:

codesandbox.io/p/sandbox/f…

const handleClick = () => {
    // 设置初始位置和目标位置
    const from = 0; // 起始位置
    const to = 500; // 目标位置
    
    springSim.startAnimation(from, to, (newPos) => {
      // 仅仅处理水平位移的style,库中还得兼容所有 web style 的赋值,尤其像 transform 这种组合属性的case
      domRef.current.style.left = `${newPos}px`;
    });
};

在 react-spring 中因为动画过程会有多个属性同时变化,还需要考虑所有属性的及时更新。每个 useSpring() 都会创建一个 Controller 实例,在调用 springRef/Api 更新动画(start/update)的时候,内部都会将待更新对象值推到队列中,然后利用 requestAnimationFrame 执行 flushUpdateQueue 操作,promise.all 等待本次 queue 中所有子任务更新后得到新的 springValues, 再去 applyAnimationValues (实际更新到 dom),循环往复。