‘React Navigation’ 在RN的实践(RN路由管理实战方案)

1,549 阅读6分钟

背景

本文可能会有些门槛,需要读者比较了解 react-navigation

最近公司有个需求,让我与 IOS 同事各自使用 React NativeFlutter 开发一个带有 TabBottom + Header + route + 项目文件目录与基础配置(就是用两种 Native 开发框架各自开发一个脚手架工具),在学习了几天 React Native 之后思考了一套 RN 中的 Route 方案,在这里记录并分享一下,如有更好的方案或者文章中有错误欢迎各位大佬讨论与斧正。

如果阅读该文章有疑惑,感兴趣可以先去了解下 React Navigation 官方文档

1. 前期准备

# 安装 `yarn` + `RN脚手架`  
npm install -g yarn react-native-cli
# 安装 Android Studio (注意安装必须的 Android SDK)
# 如果需要一次性打两种移动平台安装包推荐直接使用苹果电脑
# 准备调试设备(虚拟机或真机)
# ...以上前期准备可按照官方文档逐步配置, 这里就不细讲了
# 最后执行 react-native 命令创建项目
react-native init yourProject

2. 项目结构

// RN 项目目录结构
yourProject
    ├-- __tests__   // 单元测试文件
    ├-- android     // 安卓文件夹(app相关配置可在此处修改)
    ├-- ios         // 苹果文件夹(app相关配置可在此处修改)
    ├-- .buckconfig
    ├-- .eslintrc.js    // eslint 前端代码规范
    ├-- .gitattributes  // git 相关
    ├-- .gitgnore       // git 相关
    ├-- .prettierrc.js  // 格式化工具, 在 VSCode 配合 Prettier 组件使用, 可按配置自动格式化文件中的代码, 与 eslint 绝配
    ├-- .watchmanconfig
    ├-- app.json        // app 配置, 默认值为 app 名称, 该处配置将在 index.js 入口文件使用, "name" 字段必须与 android 和 ios 文件夹中的 app 名称相同
    ├-- App.tsx         // 项目入口文件, 其实 index.js 文件才是真正的入口文件
    ├-- babel.config.js // 顾名思义 babel 配置 
    ├-- index.js        // 入口文件
    ├-- jest.config.js  // 单元测试配置(默认使用的 jest)
    ├-- metro.config.js // RN 的分包配置
    ├-- package-lock.json   // 依赖包版本锁
    ├-- package.json    // npm 包依赖关系文件
    ├-- tsconfig.json   // TS配置文件, 建议使用 TypeScript 开发
    └-- yarn.lock       // yarn 管理的依赖包版本锁

3. 开始配置

# 安装 react-navigation (推荐使用 3.x 版本, 4.x版本我在使用时会打不开 app , 直接闪退)
yarn add react-navigation@3.0.0-rc.5

这个框架可以理解为在 Native 中每个 page 都在一个 stack, 路由切换其实就是堆栈操作, 内部自己管理堆栈的操作记录。

主要思路
react-navigator 中每个 page 都是调用 createStackNavigator 方法创建并返回的一个高阶 React 组件
同时在 react-navigation 中导航页是使用 createBottomTabNavigator 创建
对于 react-navigation 来讲, 不管哪个方法创建的 navigator 都是最后调用 createAppContainer 方法得到一整个 stack

# 在项目根目录新建 src 文件夹
cd yourProject && mkdir src
# 规划项目内路由、视图、组件与静态资源文件结构
src
 ├-- assets         // 静态资源文件夹(一般为图片之类的资源)
 ├-- components     // 组件
 ├-- pages          // 页面
 └-- route          // 路由文件
       ├-- RouteStack
       |     └-- Route.tsx  // 所有路由
       └-- TabBarStack
             └-- TabBar.tsx // 导航页路由

4. 代码编写

// TabBar.tsx
import React from 'react';
import { View, Text, Button } from 'react-native'
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons' // Icon 组件
import { fromRight, fromLeft, zoomIn } from 'react-navigation-transitions' // 动画组件
import HomeScreen from '../../pages/Home'

import {
  createBottomTabNavigator,
  createStackNavigator
} from 'react-navigation';

type NavigationAny = {
  navigation: any
}

class SettingsScreen extends React.Component<NavigationAny> {
  render() {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        {/* other code from before here */}
        <Button
          title="Settings go to Details"
          onPress={() => this.props.navigation.navigate('List')}
        />
      </View>
    );
  }
}

const HomeStack = createStackNavigator({
  Home: {
    screen: HomeScreen,
    navigationOptions: {
      header: null,
    }
  },
});

const SettingsStack = createStackNavigator({
  Settings: {
    screen: SettingsScreen,
    navigationOptions: {
      header: null,
    }
  },
});

const TabNavigator = createBottomTabNavigator(
  {
    Home: {
        screen: HomeStack,
        navigationOptions: {
          tabBarLabel: '首页'
        }
    },
    Settings: {
      screen: SettingsStack,
          navigationOptions: {
              tabBarLabel: '设置'
          }
      },
  },
  {
    defaultNavigationOptions: ({ navigation }) => ({ // 根据 TabBar 的路由设置 Icon 激活状态
      tabBarIcon: ({ focused, horizontal, tintColor }) => {
        const { routeName } = navigation.state;
        let iconName = '',
          color = tintColor || '';
        if (routeName === 'Home') {
          iconName = `home`;
        } else if (routeName === 'Settings') {
          iconName = `setting`;
        }
        iconName += `${focused ? '' : '-outline'}`

        // You can return any component that you like here!
        return (<MaterialCommunityIcons 
          name={iconName} 
          size={25} 
          color={color} 
        />)
      },
    }),
    tabBarOptions: {
      activeTintColor: '#8e1bff',
      inactiveTintColor: '#888',
    },
  }
)

export default TabNavigator
// 页面切换过渡动画的帮助方法
const handleCustomTransition = ({ scenes }: { scenes: any}) => { 
  const prevScene = scenes[scenes.length - 2];
  const nextScene = scenes[scenes.length - 1];

  if (prevScene
    && prevScene.route.routeName === 'Tabs'
    && nextScene.route.routeName === 'List') {
    return zoomIn();
  } else if (prevScene
    && prevScene.route.routeName === 'Tabs'
    && nextScene.route.routeName === 'Details') {
    return fromRight();
  }
  return fromLeft();
}

export {
  handleCustomTransition
}
// Route.tsx
import React from 'react';
import { View, Text } from 'react-native'
import ListScreen from '../../pages/List'

class DetailsScreen extends React.Component {
  render() {
    return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>Details!</Text>
    </View>
    );
  }
}

export default {
  List: ListScreen,
  Details: DetailsScreen
}
App.tsx 为核心代码,统合整个 Route 文件夹中的配置,导出一个统一的 APPContainer
// App.tsx
import TabNavigator, { handleCustomTransition, } from './src/route/TabBarStack/TabBar'
import Route from './src/route/RouteStack/Route'

import {
  createStackNavigator,
  createAppContainer,
} from 'react-navigation';

const MainStack = createStackNavigator({
  // 此处让 TabBarStack 与其他路由平级,就可不用在路由切换改变状态时判断是否显示底部 TabBar
  // 该写法还有另外的好处,如果需要添加 Drawer Navigation 只需要少量的改动,添加一个新的文件就可实现,可扩展性强
  // Drawer : {
  //    screen: DrawerNavigator,
  //    // code
  //}
  Tabs: {
    screen: TabNavigator,
    navigationOptions: {
      header: null // 该写法为不显示 Header
    }
  },
  ...Route // 利用 ES6 的解构写法,可完美解耦该处其他的路由配置,用一个单独的文件管理其余路由利于维护
}, {
  transitionConfig: (nav) => handleCustomTransition(nav)
})

export default createAppContainer(MainStack);
// index.js 入口文件(该文件无须改动,这里将代码贴出来是为了方便读者理解)
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';

AppRegistry.registerComponent(appName, () => App);

在该方案之前, TabBarStack 的规划考虑过 React Navigation 官方给的实例,发现感觉并不优雅,官方其实也并不推荐如下写法,为方便读者理解,官方的实例如下

// 如果配置如下:
const FeedStack = createStackNavigator({
  FeedHome: FeedScreen,
  Details: DetailsScreen,
});

const TabNavigator = createBottomTabNavigator({
  Feed: FeedStack,
  Profile: ProfileScreen,
});

const AppNavigator = createSwitchNavigator({
  Auth: AuthScreen,
  Home: TabNavigator,
});

// 当我们从 Feed 主页面跳转到 Details 页面时,
// 如果想要隐藏 Tab Bar,我们无法在 DetailsScreen 的 navigationOptions 中配置 tabBarVisible: false,
// 因为这些选项只能在FeedStack中应用。 但是,我们可以执行以下操作:
const FeedStack = createStackNavigator({
  FeedHome: FeedScreen,
  Details: DetailsScreen,
});

FeedStack.navigationOptions = ({ navigation }) => {
  let tabBarVisible = true;
  if (navigation.state.index > 0) {
    tabBarVisible = false;
  }

  return {
    tabBarVisible,
  };
};

// 只要我们离开 Feed 主页面,Tab Bar 就会被隐藏。 
// 我们可以根据路由名称切换 Tab bar 的可见性, 但是在路由跳转时,反复切换 Tab Bar 的可见性,会看起来很奇怪。
// 我们应该只设置原本 Tab Bar 就是可见的页面。(这个方案官方也不推荐,写出这种例子只是为了让开发者了解该 API)
可以见到,如果以以上方式编写代码,每添加一个 page 就需要多编写一次 navigationOptions 配置,代码十分容易冗余,但是该方案也有一种优化方式,只是本人的一个猜想,并未实际实现,如果有读者感兴趣的话可以尝试一下,代码如下:
// createNavigator.js
import { createStackNavigator } from 'react-navigation'

// 该方案也有缺点,可扩展性不强,代码也有冗余,并不能满足所有需求
export default (pageName, page, predicate) => {
  let screen = createStackNavigator({
    [pageName]: page
  })
  screen.navigationOptions = predicate({ navigation })
  return screen
}

// Route.js
import createNavigator from './createNavigator'
import Home from './pages/Home'

let navigationOptionHandler = (navigation) => {
  let tabBarVisible = true;
  if (navigation.state.index > 0) {
    tabBarVisible = false;
  }
  return {
    tabBarVisible,
  };
}
const HomeScreen = createNavigator('Home', Home, navigationOptionHandler)

package.json

{
  "dependencies": {
    "react": "16.8.6",
    "react-native": "0.60.5",
    "react-native-gesture-handler": "^1.4.1",
    "react-native-reanimated": "^1.2.0",
    "react-native-swiper": "^1.5.14",
    "react-native-vector-icons": "^6.6.0",
    "react-navigation": "3.0.0-rc.5",
    "react-navigation-transitions": "^1.0.12"
  },
  "devDependencies": {
    "@babel/core": "^7.5.5",
    "@babel/runtime": "^7.5.5",
    "@react-native-community/eslint-config": "^0.0.5",
    "@types/jest": "^24.0.18",
    "@types/react": "^16.9.2",
    "@types/react-native": "^0.60.11",
    "@types/react-native-vector-icons": "^6.4.1",
    "@types/react-navigation": "^3.0.8",
    "@types/react-test-renderer": "^16.9.0",
    "babel-jest": "^24.9.0",
    "eslint": "^6.3.0",
    "eslint-config-prettier": "^6.2.0",
    "eslint-plugin-prettier": "^3.1.0",
    "jest": "^24.9.0",
    "metro-react-native-babel-preset": "^0.56.0",
    "prettier": "^1.18.2",
    "react-test-renderer": "16.8.6",
    "typescript": "^3.6.2"
  },
  "jest": {
    "preset": "react-native"
  }
}

5. 结束语

该方案并不完美,目前的架构已经足够满足需求,没有完美的架构,只有合适的架构,如果有读者对该方案感兴趣可以多交流讨论,希望我的思路能给各位一点启发,希望能帮助到大家,共勉