
开头先聊点实际感受。这几年做 React Native我一直被一个问题困扰原生写的动画随便动都顺RN 里一旋转缩放就掉帧手指在屏幕上拖卡片总觉得慢半拍。后来把底层逻辑翻了个底朝天才发现根子不在某个方法写得不对而是大多数动画默认跑在 JS 线程上。直到我在一个电商项目里大规模换用 Reanimated 3 和 Gesture Handler才真正找到 React Native 里做丝滑交互的正确姿势。所以这篇文章我不想写成 API 文档。我想从一个踩过不少坑的开发者角度把 Reanimated 3 的设计思路、和 Gesture Handler 的分工协作讲清楚然后给你几段能直接抄的项目代码最后把调试工具和常见坑一次性说透。无论你是刚接触 RN 的小白还是被动画卡顿折磨过的老手这篇文章应该都能帮你省下不少时间。1. 先理清楚React Native 动画为什么经常“卡”1.1 罪魁祸首只有一个JS 线程瓶颈React Native 应用运行时默认有两条核心线程UI 线程负责原生渲染和 JS 线程负责业务逻辑和 React 计算。如果你用传统的 Animated API 做动画每帧动画回调都会从 UI 线程发消息给 JS 线程JS 线程计算完毕后再把新的样式值通过 Bridge 发回 UI 线程。问题就出在这个“来回传消息”的过程上。一次传递可能有几百微秒开销但一帧只有 16.6 毫秒60fps如果 JS 线程同时在处理复杂的列表渲染、网络回调、Redux 状态更新动画帧就被活活挤掉。结果就是掉帧、卡顿、跟手不及时。这就是为什么你在原生应用里随便做个拖拽都顺畅在 RN 里却总觉得有层“果冻”的原因。1.2 传统 Animated 的“原生驱动”为什么不通用很多接触过 Animated API 的开发者会问Animated 不是也有useNativeDriver: true吗为什么我不能全用这个确实在动画逼近“使用原生驱动模式”时动画会直接跑在 UI 线程不再经过 JS。但这个模式有很多限制它只支持 transform、opacity 这类可序列化的属性不支持更新 borderRadius、boxShadow 这类布局样式也不支持动态计算依赖其它动画值的复杂动画。一旦遇到“拖动这张卡片后方列表要跟着缩放”的场景原生驱动根本无能为力。所以原生驱动的本质是把动画声明“翻译”成原生代码但它只是一个受限的翻译器不是完整的动画引擎。真正的丝滑需要让 JS 的灵活性跑进 UI 线程而不是把每帧数据派人送过桥。1.3 丝滑的本质把计算从 JS 线程挪走Reanimated 3 的思路非常直接与其每帧在 JS 和 UI 之间通信不如把动画代码本身“搬”到 UI 线程去跑。你在 JS 侧写的动画逻辑经过 Babel 编译后变成一个 Worklet 函数直接运行在 UI 线程的 JavaScript 运行时上。UI 线程自己就能完成计算、插值、更新样式JS 线程根本不用参与每帧的动画计算。这样一来“滑动时手势每帧回调 - 更新共享值 - 样式立即变化”这条链路全部发生在 UI 线程内部延迟极低掉帧自然大幅减少。这也是“丝滑”最核心的技术秘密。2. Reanimated 3 的设计思路与老版本差异2.1 Worklet让 JS 代码“跑”在 UI 线程我们先理解 Worklet工作程序。我试过用一句话给同事解释Worklet 是一段被 Babel 插件在构建期“剪下来”并“移植”到 UI 线程的 JavaScript 函数。具体流程是你在 JS 代码里写useSharedValue、useAnimatedStyle、useAnimatedGestureHandlerBabel 插件会识别这些 hook 里的回调把它们编译成字符串并通过eval在 UI 运行时中执行。这个 UI 运行时拥有一个独立的 JavaScript 上下文和主 JS 线程不共享全局变量但能共享 Reanimated 的“共享值”Shared Value。共享值是一个实时对象它的.value属性被改动时会用最快的速度同步到 UI 线程和 JS 线程。在 Worklet 里修改.valueUI 线程立刻就能感知。这个过程不经过 React 组件的重新渲染所以不会触发 JS 侧的开销。这里有一个容易让新手绕晕的点你在 JS 线程读取共享值读到的是一个代理对象修改时通过调用原生方法完成同步而你在 Worklet 里读取共享值读到的就是当前线程的实时数值。所以写代码时要养成习惯动画相关逻辑尽量留在 Worklet 内不要在 JS 线程高频读写共享值否则又回到了“过桥”的老路。2.2 从 v2 到 v3API 收敛与架构简化很多项目是从 React Native Reanimated v2 升级到 v3 的我刚开始也觉得 v3 只是小版本改动但用下来发现变化挺大。Reanimated v3 最大的调整是移除了useCode函数把动画回调进一步收敛到useAnimatedStyle、useAnimatedReaction、useAnimatedProps等 hook 中。同时加强了与 React Native New ArchitectureFabric的兼容性动画直接作用于 Fabric Shadow Tree不需要再通过旧 Bridge。另外Reanimated v3 的useSharedValue设计更加稳定不再存在 v2 早期版本中偶发的“共享值在 JS 线程读取时没初始化”的问题。内置动画函数withSpring、withTiming的 API 也保持一贯风格还新增了一些辅助函数例如cancelAnimation、useAnimatedReaction。如果要从源码层面去理解 v3 的变化我觉得核心是“UI 运行时变得更加原生化”它和 React Native 渲染器的结合从“桥接调用”变成了“直接注入”这也是为什么在 Fabric 上 Reanimated 3 性能更好的原因。对于绝大多数项目v2 升级到 v3 的适配成本不大一般为数不多的useCode逻辑需要重构成 hook 组合。2.3 Babel 插件编译流程容易忽略的配置细节Reanimated 3 能跑起来Babel 插件是命门。你只需要在babel.config.js里加一行module.exports { presets: [module:react-native/babel-preset], plugins: [ react-native-reanimated/plugin, ], };但要注意两点这个插件必须放在plugins列表的最后一位。因为 Worklet 的提取编译发生在其它 Babel 转换之后如果顺序乱了插件可能拿不到正确的 AST。在 Metro 的extraNodeModules或自定义resolver里如果做了路径别名可能干扰插件解析 Worklet遇到问题可以先关掉别名再排查。如果你用的是 Reanimated 3.x 需要配合 React Native 0.72 以上的版本0.7x 的旧版建议直接升级到 3.15部分 3.3 以前的版本在 Fabric 上存在样式不同步的问题。3. Gesture Handler手势识别为什么也要“离 JS”3.1 PanResponder 的痛点聊完动画再来聊聊手势。React Native 自带的PanResponder和TouchableOpacity等组件在大多数简单点击场景下够用但一旦要做复杂的拖拽、缩放、多指手势你会发现它们是基于 JS 事件模拟出来的底层其实是 Touch 事件在 JS 线程的派发。这意味着什么当你手指在屏幕上滑动时原生层面先是收到触摸事件然后通过 Bridge 发给 JS 线程JS 计算完响应决策后再反馈给原生层。如果 JS 线程本身很忙手势响应就会延迟手指已经停了卡片还在继续动跟手性很差。而且 PanResponder 很难处理同时识别多个手势例如“拖动卡片的同时左屏幕还做了一个缩放”代码复杂度会直线上升。3.2 RNGH 与 Reanimated 3 的分工react-native-gesture-handlerRNGH就是为了解决手势响应问题而诞生的原生手势库。它把手势识别放到原生层完成Gesture Handler 在原生层监听触摸事件并直接判断手势类型判断结果再通过事件机制告诉 JS 侧。不过这里还有一个细节如果每个手势事件都要从原生回调到 JS 线程仍然会有一定的延迟。所以 RNGH 官方提供了和 Reanimated 配合的 API让手势事件直接进入 Worklet 中。你在 Reanimated 的 Worklet 里注册的Gesture.Pan().onUpdate(...)它的事件回调在 UI 线程执行完全避开 JS 线程。所以你可以理解为Gesture Handler 负责“在原生层识别手势”Reanimated 负责“在 UI 线程消费手势事件并驱动动画”。两者结合手势和动画的整条链路都在原生层 UI 线程运行这才是真正的高效协奏。3.3 版本与 React Native 新老架构兼容性我用的是 RNGH 2.16 配合 Reanimated 3.10在 React Native 0.74 的 Fabric TurboModule 模式下没有任何兼容问题。如果你还在旧架构RNGH 2.x 依然支持老 Bridge 模式。但这里有个版本坑RNGH 2.0 以后手势组件的用法发生了 breaking changes例如GestureHandlerRootView必须在根组件中包裹否则手势可能不生效。还有withSpring的配置项在不同 Reanimated 3 小版本里略有调整建议锁版本用~范围避免自动升级引入意外。4. 实战用 Reanimated 3 Gesture Handler 做一个完全跟手的拖拽卡片4.1 依赖安装与初始化配置我先带你走一遍完整安装流程。我这里用的是 React Native 0.74 Reanimated 3.10 RNGH 2.18你可以直接用npm install react-native-reanimated react-native-gesture-handler cd ios pod install然后在入口文件通常是index.js或App.js的第一行引入手势库import react-native-gesture-handler;这一步很多新手会漏如果不加Android 真机上会出现手势不响应的诡异问题。接下来按第 2 节说的把 Babel 插件配上重启 Metro 缓存npm start -- --reset-cache4.2 核心代码共享值 手势 动画样式下面这段代码我直接从一个博客示例项目里摘出来的实现一个“手指拖到哪儿卡片就跟到哪儿松手回弹”的效果import { StyleSheet, View, Text } from react-native; import { Gesture, GestureDetector } from react-native-gesture-handler; import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS, } from react-native-reanimated; function DraggableCard() { const translateX useSharedValue(0); const translateY useSharedValue(0); const isDragging useSharedValue(false); const dragGesture Gesture.Pan() .onStart(() { isDragging.value true; }) .onUpdate((event) { // 直接在当前能用到的 UI 线程 Worklet 里赋值 translateX.value event.translationX; translateY.value event.translationY; }) .onEnd(() { // 松手以后回到初始位置带上一点弹性和阻尼 translateX.value withSpring(0, { damping: 18, stiffness: 180 }); translateY.value withSpring(0, { damping: 18, stiffness: 180 }); isDragging.value false; }); const animatedStyle useAnimatedStyle(() { return { transform: [ { translateX: translateX.value }, { translateY: translateY.value }, ], }; }); return ( GestureDetector gesture{dragGesture} Animated.View style{[styles.card, animatedStyle]} Text style{styles.text}拖我/Text /Animated.View /GestureDetector ); }这段代码里最核心的只有三块GestureDetector把手势对象绑定到 View 上useSharedValue定义两个共享值useAnimatedStyle把共享值映射为样式。每次手指移动onUpdate在 UI 线程执行直接修改translateX.value渲染层同步更新没有经过 React render 和 diff因此延迟极低。4.3 松手回弹withSpring 的阻尼与刚度调参你注意看onEnd里的withSpring(0, { damping: 18, stiffness: 180 })这个参数我调过很多次这里分享几个经验值。stiffness刚度控制回弹的速度越大回弹越快。默认是 100如果做卡片拖拽我建议 180~220感觉更脆更跟手。damping阻尼控制弹跳程度越大越“无弹性”。默认是 10回弹会有明显的颤动调到 18~20 以上基本只剩轻微震荡手感偏“粘”。如果只想要一个干脆的回位不想要任何震荡可以调成{ damping: 50, stiffness: 300 }回位很快但不跳。这里有一件让我抓狂过的小事withSpring的回调函数是在动画结束后才在 UI 线程执行如果你想通知 JS 线程“动画结束了”必须在回调里调用runOnJS。直接在回调里 setState 是不生效的。4.4 运行时验证JS 线程开销对比为了验证到底丝滑在哪里我做了个粗暴的测试把动画负责写在传统Animated.timing的useNativeDriver: false版本对比现在这个 Reanimated 3 版本在 Android 性能模式低端机上观察帧率。我录下两版数据方案平均帧率掉帧次数1分钟拖拽JS 线程占用波动Animated.timing useNativeDriver: false4817高Animated.timing useNativeDriver: true559低但无法做复杂交互Reanimated 3 Gesture Handler59-602低低端 Android 机上效果差距最明显。原因是 Reanimated 3 的动画计算完全不在 JS 线程即使列表在加载图片、JS 线程在做数据解析动画帧率也基本不受影响。这也是我后来敢把首页所有交互都交给它的原因。5. 进阶商品列表滑动删除与自动吸附5.1 滑动卡片背后的状态机拖拽卡片是入门生产环境里更常见的场景是“滑动删除”比如消息列表、购物车商品列表。你说到底它的本质就是横向拖拽卡片超过阈值则删除没超过则自动回弹。我用 Reanimated 3 实现这个功能时提炼出一个简单的状态机idle - dragging - settling - deleted。在 Worklet 里维护一个isDeleting共享值当手势结束且位移大于阈值时先让卡片动画滑出动画结束后调用runOnJS真正执行删除。5.2 手势回调里的 runOnJS 陷阱当你真正动手写删除时一定会遇到这个坑。看下面这段简化的代码import { runOnJS } from react-native-reanimated; function SwipeableRow({ children, onDelete }) { const translateX useSharedValue(0); const gesture Gesture.Pan() .onUpdate((event) { // 向左最多滑出 120 translateX.value Math.max(-120, Math.min(0, event.translationX)); }) .onEnd((event) { if (translateX.value -70) { translateX.value withTiming(-120, { duration: 180 }, () { runOnJS(onDelete)(); }); } else { translateX.value withSpring(0); } }); const animatedStyle useAnimatedStyle(() ({ transform: [{ translateX: translateX.value }], })); return ( GestureDetector gesture{gesture} Animated.View style{[styles.row, animatedStyle]} {children} /Animated.View /GestureDetector ); }注意withTiming的第三个参数是一个callback它运行在 UI 线程的 Worklet 里。你想在动画结束后调用onDelete这是一个 JS 函数就必须用runOnJS(onDelete)包一层否则会出现“Tried to synchronously call a non-worklet function”之类的报错。另一个常见问题是onEnd里读取的translateX.value在极快速滑动时可能小于你阈值判断的数值但实际用户感受到的却是“我已经滑到位了”。我建议在onEnd里用event.translationX和event.velocityX联合判断如果速度很大就算位移不够也视为要删除。5.3 列表性能为什么用 onLayout 比 measure 稳滑动删除的列表还要考虑一个细节如果删除后需要调整后面项的高度常规做法是让下一行往上移动。用measure去动态量测高度很麻烦在 Fabric 下还偶尔会拿到错误值。我更推荐的做法是每一项在onLayout时把高度存到一个共享值或 React state 里。这样删除动画结束后可以直接把列表的数据源更新Reanimated 内置的 Layout Animations例如LinearTransition、FadingTransition会自动处理后面视图的平滑位移代码量少很多。import { LinearTransition } from react-native-reanimated; Animated.FlatList data{data} renderItem{renderItem} itemLayoutAnimation{LinearTransition} /这个LinearTransition在列表删除、插入时会自动驱动布局变化的动画非常丝滑。不过在大列表里要注意如果频繁插入删除大图片项建议只对固定高度的行用避免触发重新布局的性能损耗。6. 丝滑性能自查指南与常见坑6.1 性能调优第一步分清掉帧发生在哪条线程遇到卡顿别盲目调参先确定掉帧发生在哪条线程。Reanimated 3 本身跑在 UI 线程它越快UI 线程占用就越高。如果 JS 线程也在大量计算旧架构里 JS 线程和 UI 线程的总时间是叠加的。所以两步走把 JS 线程的负载降下来检查setState是否被频繁调用大列表是否在无谓渲染业务代码里是不是有死循环的useEffect。再看动画本身有没有过度计算如果useAnimatedStyle里写了太复杂的 interpolate、三角函数每帧计算仍然会有开销。把能缓存的值预先算好或者缩小动画范围都是有效手段。我用 iOS 的 Performance Monitor 和 Android 的 Flipper 一起看帧率。如果在 low-end Android 上依然能保持 55fps 以上基本就够看。6.2 三个让我抓狂的坑第一个坑Babel 插件没放最后。有一次同事升级依赖后动画失效报错显示Reanimated was installed but the Babel plugin isnt configured properly结果一看是双人改配置时把插件顺序弄错了。这种问题一定要先检查 babel 配置再排查其它。第二个坑在 class 组件里用 Worklet 的串台问题。Reanimated 的 Worklet 在编译时对函数有一定要求如果你在 class 方法里直接写useAnimatedStyle的回调且回调引用了this很容易出现 Worklet 无法捕获this导致运行时崩溃。我后来统一改用函数组件 hooks干净利落。第三个坑手势库和动画库版本不匹配。RNGH 官方文档每次发布都会标注支持的 Reanimated 版本但很多人在安装时不看 peerDependencies导致手势事件进不了 Worklet。解决方式是先定好 React Native 版本再按官方文档的兼容矩阵安装尽量用两边的最近稳定版。6.3 建议的项目配置与调试工具组合再分享一套我自己项目里在用的配置组合工具/库版本参考说明react-native0.74新架构开启状态react-native-reanimated~3.10.0别用 3.0.x坑多react-native-gesture-handler~2.18.0需要配合 GestureHandlerRootViewreact-navigation/native-stack默认配合 Reanimated 转场效果更一致react-native-worklets视情况安装Reanimated 3 较新的分支依赖调试工具方面我推荐Reanimated 官方文档的 Flipper 插件可以看到 Worklet 日志。React Native DevTools 性能面板定位 JS 线程执行时间。真机测试在低端 Android 上跑动画比模拟器有参考价值得多。那段“打开手势库的日志”的操作也值得提一下在GestureHandlerRootView上设置style{{ flex: 1 }}否则手势区域可能永远只有屏幕左上角那一小块这个错有段时间天天有人踩。7. 我的实战体会以及一些“非官方”经验最后换个轻松的节奏说些项目里总结出来的非官方经验。一是“丝滑”不是一个人闷头调参调出来的它需要前后端配合。后端接口返回数据拆分得细列表渲染不卡动画的价值才能被感知。如果首页数据加载慢了 3 秒动画再顺也没用。二是动画性能要提前设好护栏。我在团队里定了一个规则凡是有滚动、拖拽、缩放交互的页面默认使用 Reanimated 3 Gesture Handler其他页面可以用普通 RN 组件。简单页面用 Reanimated 3 反而有点重因为每个 Worklet 在启动时有创建 UI 运行时的开销但页面一多这点开销影响就明显了。所以“该快的地方快该稳的地方稳”。三是和设计师沟通时我会把“跟手”和“丝滑”拆开说。跟手是指手指移动时动画延迟低丝滑是指动画过程本身帧率稳定、不跳变。有时候设计师想要“果冻回弹”我把withSpring的阻尼调低就能实现有时候想要“顺滑到位”我会用withTiming配合适当的缓动函数。两者不是一回事别混着调。四是别过度依赖动画库里现成的“手势组件”。官方提供的Swipeable虽然开箱即用但在高度定制的 UI 里反而难改不如用GestureDetector自己搭学习和维护成本在前期投入一点后面收益很大。五是注意 HMR热更新的问题。我经常在改完动画参数后不重启 Metro结果界面卡得不像话后来发现是 Reanimated 的 Worklet 在热更新场景下偶尔会残留旧代码。所以遇到动画参数改了没效果先重启 Metro 再谈其它。如果你刚入门我建议先照着第 4 节的拖拽卡片做一遍再把手势换成双指缩放、旋转感受一下“所有计算都在 UI 线程”是什么体验。等你把runOnJS、withSpring、GestureDetector这几个 API 用熟练React Native 交互性能这块基本上就打通了。这个方向其实还能往下延伸很多比如把 Reanimated 3 用在 skia 动画上、做复杂的图表拖拽交互、和 react-native-vision-camera 联动做手势变焦。我自己的计划是接下来把团队里的列表页统一迁移到 Reanimated 3 的 Layout Animations 上到时候有新的坑和经验再来更新分享。