Expo自定义底部TabBar响应卡顿,如何进行优化?
Expo自定义底部TabBar动画延迟问题排查与优化
我跟着YouTube教程实现了一款Expo自定义底部TabBar,但点击标签后,动画启动前有大约1秒的延迟。我猜测可能是路径插值(interpolate path)没优化,但作为Expo和动画开发新手,没法确定问题根源。附上TabBar样式图和完整代码,求帮忙排查卡顿原因并给出优化方案:

CustomBottomTab组件代码
import React, { FC, useEffect } from "react"; import { StyleSheet, View } from "react-native"; import Svg, { Path } from "react-native-svg"; import Animated, { runOnJS, useAnimatedProps, useSharedValue, withTiming, Easing, } from "react-native-reanimated"; import { interpolatePath } from "react-native-redash"; import { useRouter, usePathname } from "expo-router"; import { SCREEN_WIDTH } from "@/src/constants/screen"; import usePath from "@/src/hooks/usePath"; import { getPathXCenter } from "@/src/utils/path"; import TabItem from "./tabItem"; import AnimatedCircle from "./animatedCircle"; import HeadphoneIcon from "@/src/components/icons/headphoneIcon"; import HomeIcon from "@/src/components/icons/homeIcon"; import HistoryIcon from "@/src/components/icons/historyIcon"; import ProfileIcon from "@/src/components/icons/profileIcon"; import SettingsIcon from "../icons/settingsIcon"; const AnimatedPath = Animated.createAnimatedComponent(Path); const TABS = [ { name: "home", keyword: "home", label: "Home", icon: HomeIcon }, { name: "playlistsList", keyword: "playlist", label: "Playlist", icon: HeadphoneIcon }, { name: "history", keyword: "history", label: "History", icon: HistoryIcon }, { name: "profile", keyword: "profile", label: "Profile", icon: ProfileIcon }, { name: "settings", keyword: "settings", label: "Settings", icon: SettingsIcon }, ]; interface CustomBottomTabProps { state: { index: number; routes: Array<{ name: string }>; }; navigation: { navigate: (name: string) => void; }; } export const CustomBottomTab: FC<CustomBottomTabProps> = ({ state, navigation }) => { const { containerPath, curvedPaths, tHeight } = usePath(); const circleXCoordinate = useSharedValue(0); const progress = useSharedValue(1); const router = useRouter(); const pathname = usePathname(); const currentIndex = TABS.findIndex(tab => { if (tab.keyword === 'home' && pathname === '/') { return true; } return pathname.includes(tab.keyword); }); const handleMoveCircle = (currentPath: string) => { circleXCoordinate.value = getPathXCenter(currentPath); }; const selectIcon = (routeName: string, isActive: boolean) => { const color = isActive ? "#000000" : "#BABABA"; const IconComponent = TABS.find(tab => tab.name === routeName)?.icon; return IconComponent ? <IconComponent color={color} /> : null; }; useEffect(() => { if (currentIndex >= 0) { progress.value = withTiming(currentIndex + 1, { duration: 500, easing: Easing.bezier(0.25, 0.1, 0.25, 1), }); } }, [pathname]); const animatedProps = useAnimatedProps(() => { console.log('progress.value', progress.value); const currentPath = interpolatePath( progress.value, Array.from({ length: curvedPaths.length }, (_, index) => index + 1), curvedPaths, ); runOnJS(handleMoveCircle)(currentPath); return { d: `${containerPath} ${currentPath}`, }; }); const handleTabPress = (index: number, path: string) => { console.log('press', index); if (path === "home") { router.push(`/(auth)/(tabs)/`); } else { router.push(`/(auth)/(tabs)/${path}`); } progress.value = withTiming(index, { duration: 500, easing: Easing.bezier(0.25, 0.1, 0.25, 1), }); }; return ( <View style={styles.tabBarContainer}> <Svg width={SCREEN_WIDTH} height={tHeight} style={styles.shadowMd}> <AnimatedPath fill={"white"} animatedProps={animatedProps} /> </Svg> <AnimatedCircle circleX={circleXCoordinate} /> <View style={[ styles.tabItemsContainer, { height: tHeight, }, ]} > {TABS.map((tab, index) => ( <TabItem key={index.toString()} label={tab.label} icon={selectIcon(tab.name, currentIndex === index)} activeIndex={currentIndex + 1} index={index} onTabPress={() => handleTabPress(index + 1, tab.name)} /> ))} </View> </View> ); }; const styles = StyleSheet.create({ tabBarContainer: { position: "absolute", bottom: 0, zIndex: 2, }, tabItemsContainer: { position: "absolute", flexDirection: "row", width: "100%", }, shadowMd: { elevation: 3, shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 3, shadowOffset: { width: 0, height: 3 }, } });
usePath Hook代码
import { useMemo } from "react"; import { curveBasis, line } from "d3-shape"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SCREEN_WIDTH } from "@/src/constants/screen"; import { parse } from "react-native-redash"; type GenerateTabShapePath = ( position: number, adjustedHeight: number, ) => string; const NUM_TABS = 5; const SCALE = 0.6; const TAB_BAR_HEIGHT = 50; const generateTabShapePath: GenerateTabShapePath = ( position, adjustedHeight, ) => { const adjustedWidth = SCREEN_WIDTH / NUM_TABS; const tabX = adjustedWidth * position; const lineGenerator = line().curve(curveBasis); const tab = lineGenerator([ [tabX - 100 * SCALE, 0], [tabX - (65 + 35) * SCALE, 0], [tabX - (50 - 10) * SCALE, -4 * SCALE], [tabX - (50 - 10) * SCALE, (adjustedHeight - 14) * SCALE], [tabX + (50 - 15) * SCALE, (adjustedHeight - 14) * SCALE], [tabX + (50 - 10) * SCALE, -4 * SCALE], [tabX + (65 + 35) * SCALE, 0], [tabX + 100 * SCALE, 0], ]); return `${tab}`; }; const usePath = () => { const insets = useSafeAreaInsets(); const tHeight = TAB_BAR_HEIGHT + insets.bottom; const adjustedHeight = tHeight - insets.bottom; const containerPath = useMemo(() => { return `M0,0L${SCREEN_WIDTH},0L${SCREEN_WIDTH},0L${SCREEN_WIDTH},${tHeight}L0,${tHeight}L0,0`; }, [tHeight]); const curvedPaths = useMemo(() => { return Array.from({ length: NUM_TABS }, (_, index) => { const tabShapePath = generateTabShapePath(index + 0.5, adjustedHeight); return parse(`${tabShapePath}`); }); }, [adjustedHeight]); return { containerPath, curvedPaths, tHeight }; }; export default usePath;
卡顿原因排查
- 路由跳转阻塞动画启动:
handleTabPress中先执行router.push再触发动画,页面跳转的JS线程开销会延迟动画启动。 - 跨线程调用频繁:
useAnimatedProps中每次都通过runOnJS调用handleMoveCircle,频繁在UI/JS线程间切换,增加性能损耗。 - 重复计算与查找:
selectIcon每次渲染都遍历TABS数组查找图标,currentIndex计算未做缓存,导致不必要的重渲染。 - 日志占用资源:
animatedProps中的console.log会占用JS线程资源,拖慢动画响应。
优化方案
1. 调整动画与路由顺序,优先启动动画
const handleTabPress = (index: number, path: string) => { // 先触发动画,再执行路由跳转 progress.value = withTiming(index, { duration: 500, easing: Easing.bezier(0.25, 0.1, 0.25, 1), }); const targetPath = path === "home" ? `/(auth)/(tabs)/` : `/(auth)/(tabs)/${path}`; router.push(targetPath); };
2. 移除跨线程调用,预计算圆心坐标
// 预计算每个tab的圆心X坐标 const tabCenterXs = useMemo(() => { return curvedPaths.map(path => getPathXCenter(path)); }, [curvedPaths]); // 预定义插值输入范围 const inputRange = useMemo(() => { return Array.from({ length: curvedPaths.length }, (_, index) => index + 1); }, [curvedPaths.length]); const animatedProps = useAnimatedProps(() => { const currentPath = interpolatePath( progress.value, inputRange, curvedPaths, ); // 直接在UI线程插值获取圆心坐标,无需跨线程调用 circleXCoordinate.value = interpolate( progress.value, inputRange, tabCenterXs ); return { d: `${containerPath} ${currentPath}`, }; });
3. 优化图标查找逻辑,避免重复遍历
// 预创建图标映射对象 const tabIconMap = useMemo(() => { return TABS.reduce((map, tab) => { map[tab.name] = tab.icon; return map; }, {} as Record<string, React.ElementType>); }, []); // 用useCallback缓存函数,避免重渲染 const selectIcon = useCallback((routeName: string, isActive: boolean) => { const color = isActive ? "#000000" : "#BABABA"; const IconComponent = tabIconMap[routeName]; return IconComponent ? <IconComponent color={color} /> : null; }, [tabIconMap]);
4. 优化currentIndex计算与缓存
const currentIndex = useMemo(() => { if (pathname === '/') return TABS.findIndex(tab => tab.keyword === 'home'); return TABS.findIndex(tab => pathname.includes(tab.keyword)); }, [pathname]);
5. 移除不必要的日志
删除animatedProps中的console.log('progress.value', progress.value),减少JS线程负担。
内容的提问来源于stack exchange,提问作者Neolex
相关产品推荐
相关产品推荐

