React Native App應用架構設計

熊建剛發表於2017-10-31

在上一篇介紹了React Native開發環境搭建,我們已經可以在本地成功執行一個helloword應用了,本節將開始詳細分析如何搭建一個React Native App應用架構,並支援完整本地執行預覽。

完整程式碼見github

歡迎訪問我的個人部落格

前言

現在已經有很多腳手架工具,如ignite,支援一鍵建立一個React Native App專案結構,很方便,但是享受方便的同時,也失去了對專案架構及技術棧完整學習的機會,而且通常腳手架建立的應用技術架構並不能完全滿足我們的業務需求,需要我們自己修改,完善,所以如果希望對專案架構有更深掌控,最好還是從0到1理解一個專案。

專案結構與技術棧

首先使用react-native-cli工具建立一個React Native應用:

react-native init fuc複製程式碼

生成專案結構如下圖:

RN專案初始結構
RN專案初始結構

  1. andorid和ios目錄分別存放對應原生平臺程式碼;
  2. package.json為專案依賴管理檔案;
  3. index.ios.js為ios平臺入口檔案,index.android.js為android平臺入口檔案,通常用來註冊React Native App根元件;
  4. .babelrc檔案,babel的配置檔案,React Native預設使用babel編譯JavaScript程式碼;
  5. __tests__專案測試目錄。

我們看到並沒有存放React Native原生JavaScript程式碼的目錄,這需要我們自己進行建立了,通常建立一個src目錄作為App應用Javascript部分所有程式碼和資源的根目錄,一個src/constants目錄以儲存全域性共享常量資料,一個src/config目錄儲存全域性配置,一個src/helpers存放全域性輔助,工具類方法,一個src/app.js作為RN部分入口檔案,另外通常還需要建立儲存各模組redux的目錄,redux中介軟體的目錄等。

技術棧

專案架構搭建很大部分依賴於專案的技術棧,所以先對整個技術棧進行分析,總結:

  1. react native + react庫是專案前提
  2. App應用導航(不同於React應用的路由概念)
  3. 應用狀態管理容器
  4. 是否需要Immutable資料
  5. 應用狀態的持久化
  6. 非同步任務管理
  7. 測試及輔助工具或函式
  8. 開發除錯工具

根據以上劃分決定選用以下第三方庫和工具構成專案的完整技術棧:

  1. react-native + react類庫;
  2. react-navigation管理應用導航;
  3. redux作為JavaScript狀態容器,react-redux將React Native應用與redux連線;
  4. Immutable.js支援Immutable化狀態,redux-immutable使整個redux store狀態樹Immutable化;
  5. 使用redux-persist支援redux狀態樹的持久化,並新增redux-persist-immutable擴充以支援Immutable化狀態樹的持久化;
  6. 使用redux-saga管理應用內的非同步任務,如網路請求,非同步讀取本地資料等;
  7. 使用jest整合應用測試,使用lodash,ramda等可選輔助類,工具類庫;
  8. 使用reactotron除錯工具

針對以上分析,完善後的專案結構如圖:

RN專案結構
RN專案結構

如上圖,在專案根目錄下建立src目錄,而在src目錄中依次建立12個目錄與1個React Native部分入口js檔案。

開發除錯工具

React Native App開發目前已經有諸多除錯工具,常用的如atom和Nuclide,移動端模擬器自帶的除錯工具,Reactron等。

Nuclide

Nuclide是由Facebook提供的基於atom的整合開發環境,可用於編寫、執行除錯React Native應用。

模擬器除錯工具

在模擬器啟動執行App後,瀏覽器會自動開啟 http://localhost:8081/debugger-ui頁,可以在控制檯進行js除錯輸出及遠端js斷點除錯;在模擬器終端使用快捷鍵commandD鍵即可開啟除錯工具,包括重新載入應用,開啟熱載入,切換DOM審視器等:

RN應用除錯工具
RN應用除錯工具

Reactotron

Reactotron是一款跨平臺除錯React及React Native應用的桌面應用,能動態實時監測並輸出React應用等redux,action,saga非同步請求等資訊,如圖:

Reactotron
Reactotron

首先初始化Reactotron相關配置:

import Config from './DebugConfig';
import Immutable from 'immutable';
import Reactotron from 'reactotron-react-native';
import { reactotronRedux as reduxPlugin } from 'reactotron-redux';
import sagaPlugin from 'reactotron-redux-saga';

if (Config.useReactotron) {
  // refer to https://github.com/infinitered/reactotron for more options!
  Reactotron
    .configure({ name: 'Os App' })
    .useReactNative()
    .use(reduxPlugin({ onRestore: Immutable }))
    .use(sagaPlugin())
    .connect();

  // Let's clear Reactotron on every time we load the app
  Reactotron.clear();

  // Totally hacky, but this allows you to not both importing reactotron-react-native
  // on every file.  This is just DEV mode, so no big deal.
  console.tron = Reactotron;
}複製程式碼

然後啟使用console.tron.overlay方法擴充入口元件:

import './config/ReactotronConfig';
import DebugConfig from './config/DebugConfig';

class App extends Component {
  render () {
    return (
      <Provider store={store}>
        <AppContainer />
      </Provider>
    )
  }
}

// allow reactotron overlay for fast design in dev mode
export default DebugConfig.useReactotron
  ? console.tron.overlay(App)
  : App複製程式碼

至此就可以使用Reactotron客戶端捕獲應用中發起的所有的redux和action了。

元件劃分

React Native應用依然遵循React元件化開發原則,元件負責渲染UI,元件不同狀態對應不同UI,通常遵循以下元件設計思路:

  1. 佈局元件:僅僅涉及應用UI介面結構的元件,不涉及任何業務邏輯,資料請求及操作;
  2. 容器元件:負責獲取資料,處理業務邏輯,通常在render()函式內返回展示型元件;
  3. 展示型元件:負責應用的介面UI展示;
  4. UI元件:指抽象出的可重用的UI獨立元件,通常是無狀態元件;
展示型元件 容器元件
目標 UI展示 (HTML結構和樣式) 業務邏輯(獲取資料,更新狀態)
感知Redux
資料來源 props 訂閱Redux store
變更資料 呼叫props傳遞的回撥函式 Dispatch Redux actions
可重用 獨立性強 業務耦合度高

跨平臺適應

建立跨平臺應用時,雖然React Native做了大量跨平臺相容的工作,但是依然存在一些需要為不同平臺開發不同程式碼的情況,這時候需要額外處理。

跨平臺目錄

我們可以將不同平臺程式碼檔案以不同目錄區分開來,如:

/common/components/
/android/components/
/ios/components/複製程式碼

common目錄下存放公用檔案,android目錄存放android檔案程式碼,ios存放ios檔案程式碼,但是通常都選擇React Native提供的更好方式,後文介紹。

Platform模組

React Native內建了一個Platform模組,用以區分應用當前執行平臺,當執行在ios平臺下時,Platform.OS值為ios,執行在android平臺下則為android,可以利用此模組載入對應平臺檔案:

var StatusBar = Platform.select({
  ios: () => require('ios/components/StatusBar'),
  android: () => require('android/components/StatusBar'),
})();複製程式碼

然後正常使用該StatusBar元件即可。

React Native平臺檢測

當引用某元件時,React Native會檢測該檔案是否存在.android.ios字尾,如果存在則根據當前平臺載入對應檔案元件,如:

StatusBar.ios.js
StatusBar.indroid.js複製程式碼

同一目錄下存在以上兩個檔案,則可以使用以下方式引用:

import StatusBar from './components/StatusBar';複製程式碼

React將會根據當前平臺載入對應字尾檔案,推薦使用此方式做平臺元件級程式碼適配,而對於區域性小部分需要適配平臺的程式碼可以使用Platform.OS值,如下,若僅僅需要在ios平臺下新增一個更高的margin-top值且不是公用樣式時:

var styles = StyleSheet.create({
  marginTop: (Platform.OS === 'ios') ? 20 : 10,
});複製程式碼

App應用導航與路由

不同於React應用的單頁面路由,React Native通常都是多頁面形式存在,以導航方式在不同頁面和元件間切換,而不是路由方式控制不同元件展示,最常使用的是react-navigation導航庫。

導航和路由

在React web應用中,頁面UI元件展示和切換完全由路由控制,每一個路由都有對應的URL及路由資訊,在React Native應用則不是由路由驅動元件展示,而是由導航控制切換屏展示,每一屏有各自的路由資訊。

或許你已經依賴react-router的單頁面應用路由配置方式,希望建立一個Url驅動的跨平臺App應用,託福於活躍的的開源社群,你可以使用react-router-native,但是並不推薦,因為對於App而言,從互動和體驗考慮,還是更適合使用多頁面(屏)形式。

react-navigation

使用react-navigation可以定義跨平臺的應用導航結構,也支援配置渲染跨平臺的導航欄,tab欄等元件。

內建導航模組

react-navigation提供以下幾個方法支援建立不同的導航型別:

  1. StackNavigator:建立導航屏棧(stack),所有屏(screen)以棧的方式存在,一次渲染一屏,在切換屏時提高變換動畫,當開啟某一屏時,將該屏放置在棧頂;
  2. TabNavigator:建立一個Tab式導航,渲染一個Tab選單欄,使使用者可以切換不同屏;
  3. DrawerNavigator:建立抽屜式導航,從屏的左邊滑出一屏;

StackNavigator

StackNavigator支援跨平臺以變換方式切換不同屏,並且將當前屏放置在棧頂,呼叫方式如下:

StackNavigator(RouteConfigs, StackNavigatorConfig)複製程式碼
RouteConfigs

導航棧路由(route)配置物件,定義route名和route物件,該route物件定義當前路由對應的展示元件,如:

// routes為路由資訊物件
StackNavigator({
  [routes.Main.name]: Main,
  [routes.Login.name]: {
    path: routes.Login.path,
    screen: LoginContainer,
    title: routes.Login.title
  }
}複製程式碼

如上,表明當應用導航至路由routes.Login.name時,渲染LoginContainer元件,由物件screen屬性指定;而導航至路由routes.Main.name值時,對應渲染MainStack,程式碼中Main物件為:

{
  path: routes.Main.path,
  screen: MainStack,
  navigationOptions: {
    gesturesEnabled: false,
  },
}複製程式碼

而MainStack是一個Stacknavigator:

const MainStack = StackNavigator({
  Home: HomeTabs
})複製程式碼

HomeTabs是一個TabNavigator:

{
  name: 'Home Tabs',
  description: 'Tabs following Home Tabs',
  headerMode: 'none',
  screen: HomeTabs
};複製程式碼
StackNavigatorConfig

路由配置物件,可以選擇性配置可選屬性,如:

  1. initialRouteName,初始導航棧預設屏,必須是路由配置物件中的某一鍵名;
  2. initialRouteParams,初始路由的預設引數;
  3. navigationOptions,設定預設的導航屏配置;
    1. title:導航屏頂部標題;
  4. headerMode,是否顯示頂部導航欄:
    1. none:不顯示導航欄;
    2. float:在頂部渲染一個獨立的導航欄,並且在切換屏時伴有動畫,通常是ios的展示模式;
    3. screen:為每一屏繫結一個導航欄,並且伴隨著屏切換淡入淡出,通常是android的展示模式;
  5. mode,導航切換屏時的樣式和變換效果:
    1. card:預設方式,標準的屏變換;
    2. modal:僅在ios平臺有效,使螢幕底部滑出新屏;
{
  initialRouteName: routes.Login.name,
  headerMode: 'none', // 去除頂部導航欄
  /**
   * Use modal on iOS because the card mode comes from the right,
   * which conflicts with the drawer example gesture
   */
  mode: Platform.OS === 'ios' ? 'modal' : 'card'
}複製程式碼

TabNavigator

使用TabNavigator可以建立一屏,擁有TabRouter可以切換不同Tab,呼叫方式如:

TabNavigator(RouteConfigs, TabNavigatorConfig)複製程式碼
RouteConfigs

Tab路由配置物件,格式類似StackNavigator。

TabNavigatorConfig

Tab導航相關配置物件,如:

  1. tabBarComponent: tab選單欄使用的元件,ios平臺預設使用TabBarBottom元件,android平臺預設使用TabBarTop元件;
  2. tabBarPosition:tab選單欄位置,topbottom;
  3. tabBarOptions: tab選單欄配置:
    1. activeTintColor:啟用tab的選單欄項的字型和圖示的顏色
  4. initialRouteName: 初始載入時的預設tabRoute路由的routeName,對應路由配置物件的鍵名
  5. order:tab排序,routeName組成的陣列;
const HomeTabs = TabNavigator(
  {
    Notification: {
      screen: NotificationTabContainer,
      path: 'notification',
      navigationOptions: {
        title: '訊息通知'
      }
    },
    Myself: {
      screen: MyselfTabContainer,
      path: 'myself',
      navigationOptions: {
        title: '我的'
      }
    }
  },
  {
    tabBarOptions: {
      activeTintColor: Platform.OS === 'ios' ? '#e91e63' : '#fff',
    },
    swipeEnabled: true
  }
);複製程式碼

DrawerNavigator

使用DrawerNavigator可以建立抽屜式導航屏,呼叫方式如下:

DrawerNavigator(RouteConfigs, DrawerNavigatorConfig)複製程式碼
const MyDrawer = DrawerNavigator({
  Home: {
    screen: MyHomeDrawerScreen,
  },
  Notifications: {
    screen: MyNotificationsDrawerScreen,
  },
});複製程式碼
RouteConfigs

抽屜式導航路由配置物件,格式類似StackNavigator。

DrawerNavigatorConfig

抽屜式導航屏配置物件,如:

  1. drawerWidth:抽屜屏的寬度;
  2. drawerPosition:抽屜屏位置,leftright
  3. contentComponent:抽屜屏內容元件,如內建提供的DrawerItems
  4. initialRouteName:初始路由的路由名;
import { DrawerItems } from 'react-navigation';

const CustomDrawerContentComponent = (props) => (
  <View style={styles.container}>
    <DrawerItems {...props} />
  </View>
);

const DrawerNavi = DrawerNavigator({}, {
  drawerWidth: 200,
  drawerPosition: 'right',
  contentComponent: props => <CustomDrawerContentComponent  {...props}/>,
  drawerBackgroundColor: 'transparent'
})複製程式碼

RN應用的每一屏將接受一個navigation屬性包含以下方法和屬性:

  1. navigate:導航至其他屏的輔助方法;
  2. setParams:變更路由引數方法;
  3. goBack:關閉當前屏並後退;
  4. state:當前屏的狀態或路由資訊;
  5. dispatch:釋出action;

使用navigate方法導航至其他屏:

navigate(routeName, params, action)複製程式碼
  1. routeName:目標路由名,在App導航路由註冊過的路由鍵名;
  2. params:目標路由攜帶的引數;
  3. action:如果目標路由存在子路由,則在子路由內執行此action;
setParams

改變當前導航路由資訊,如設定修改導航標題等資訊:

class ProfileScreen extends React.Component {
  render() {
    const { setParams } = this.props.navigation;
    return (
      <Button
        onPress={() => setParams({name: 'Jh'})}
        title="Set title"
      />
     )
   }
}複製程式碼
goBack

從當前屏(引數為空)或者指定屏(引數為屏路由鍵名)導航回退至該屏的上一屏,並且關閉該屏;若傳遞null引數,則未指定來源屏,即不會關閉屏。

state

每一屏都有自己的路由資訊,可以通過this.props.navigation.state訪問,其返回資料格式如:

{
  // the name of the route config in the router
  routeName: 'Login',
  //a unique identifier used to sort routes
  key: 'login',
  //an optional object of string options for this screen
  params: { user: 'jh' }
}複製程式碼
dispatch

該方法用來分發導航action至路由,實現導航,可以使用react-navigation預設提供的action建立函式NavigationActions,如下為分發一個navigate導航切換屏action:

import { NavigationActions } from 'react-navigation'

const navigateAction = NavigationActions.navigate({
  routeName: routeName || routes.Login.name,
  params: {},
  // navigate can have a nested navigate action that will be run inside the child router
  action: NavigationActions.navigate({ routeName: 'Notification'})
});

// dispatch the action
this.props.navigation.dispatch(navigateAction);複製程式碼

在使用Redux以後,需要遵循redux的原則:單一可信資料來源,即所有資料來源都只能是reudx store,Navigation路由狀態也不應例外,所以需要將Navigation state與store state連線,可以建立一個Navigation reducer以合併Navigation state至store:

import AppNavigation from '../routes';

const NavigationReducer = (state = initialState, action) => {
  const newState = Object.assign({}, state, AppNavigation.router.getStateForAction(action, state));
  return newState || state;
};

export const NavigationReducers = {
  nav: NavigationReducer
};複製程式碼

這個reducer所做的只是將App導航路由狀態合併入store。

Redux

現代的任何大型web應用如果少了狀態管理容器,那這個應用就缺少了時代特徵,可選的庫諸如mobx,redux等,實際上大同小異,各取所需,以redux為例,redux是最常用的react應用狀態容器庫,對於React Native應用也適用。

react-redux

和React應用一樣,需要將Redux和應用連線起來,才能統一使用redux管理應用狀態,使用官方提供的react-redux庫。

class App extends Component {
  render () {
    return (
      <Provider store={store}>
        <AppContainer />
      </Provider>
    )
  }
}複製程式碼

createStore

使用redux提供的createStore方法建立redux store,但是在實際專案中我們常常需要擴充redux新增某些自定義功能或服務,如新增redux中介軟體,新增非同步任務管理saga,增強redux等:

// creates the store
export default (rootReducer, rootSaga, initialState) => {
  /* ------------- Redux Configuration ------------- */
  const middleware = [];
  const enhancers = [];

  /* ------------- Analytics Middleware ------------- */
  middleware.push(ScreenTracking);

  /* ------------- Saga Middleware ------------- */
  const sagaMonitor = Config.useReactotron ? console.tron.createSagaMonitor() : null;
  const sagaMiddleware = createSagaMiddleware({ sagaMonitor });
  middleware.push(sagaMiddleware);

  /* ------------- Assemble Middleware ------------- */
  enhancers.push(applyMiddleware(...middleware));

  /* ------------- AutoRehydrate Enhancer ------------- */
  // add the autoRehydrate enhancer
  if (ReduxPersist.active) {
    enhancers.push(autoRehydrate());
  }

  // if Reactotron is enabled (default for __DEV__), 
  // we'll create the store through Reactotron
  const createAppropriateStore = Config.useReactotron ? console.tron.createStore : createStore;
  const store = createAppropriateStore(rootReducer, initialState, compose(...enhancers));

  // configure persistStore and check reducer version number
  if (ReduxPersist.active) {
    RehydrationServices.updateReducers(store);
  }

  // kick off root saga
  sagaMiddleware.run(rootSaga);

  return store;
}複製程式碼

redux與Immutable

redux預設提供了combineReducers方法整合reduers至redux,然而該預設方法期望接受原生JavaScript物件並且它把state作為原生物件處理,所以當我們使用createStore方法並且接受一個Immutable物件作應用初始狀態時,reducer將會返回一個錯誤,原始碼如下:

if   (!isPlainObject(inputState)) {
    return   (                              
        `The   ${argumentName} has unexpected type of "` +                                    ({}).toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] +
      ".Expected argument to be an object with the following + 
      `keys:"${reducerKeys.join('", "')}"`   
    )  
}複製程式碼

如上表明,原始型別reducer接受的state引數應該是一個原生JavaScript物件,我們需要對combineReducers其進行增強,以使其能處理Immutable物件,redux-immutable 即提供建立一個可以和Immutable.js協作的Redux combineReducers

import { combineReducers } from 'redux-immutable';
import Immutable from 'immutable';
import configureStore from './CreateStore';

// use Immutable.Map to create the store state tree
const initialState = Immutable.Map();

export default () => {
  // Assemble The Reducers
  const rootReducer = combineReducers({
    ...NavigationReducers,
    ...LoginReducers
  });

  return configureStore(rootReducer, rootSaga, initialState);
}複製程式碼

如上程式碼,可以看見我們傳入的initialState是一個Immutable.Map型別資料,我們將redux整個state樹叢根源開始Immutable化,另外傳入了可以處理Immutable state的reducers和sagas。

另外每一個state樹節點資料都是Immutable結構,如NavigationReducer

const initialState = Immutable.fromJS({
  index: 0,
  routes: [{
    routeName: routes.Login.name,
    key: routes.Login.name
  }]
});

const NavigationReducer = (state = initialState, action) => {
  const newState = state.merge(AppNavigation.router.getStateForAction(action, state.toJS()));
  return newState || state;
};複製程式碼

reducer預設state節點使用Immutable.fromJS()方法將其轉化為Immutable結構,並且更新state時使用Immutable方法state.merge(),保證狀態統一可預測。

redux持久化

我們知道瀏覽器預設有資源的快取功能並且提供本地持久化儲存方式如localStorage,indexDb,webSQL等,通常可以將某些資料儲存在本地,在一定週期內,當使用者再次訪問時,直接從本地恢復資料,可以極大提高應用啟動速度,使用者體驗更有優勢,對於App應用而言,本地持久化一些啟動資料甚至離線應用更是常見的需求,我們可以使用AsyncStorage(類似於web的localStorage)儲存一些資料,如果是較大量資料儲存可以使用SQLite。

另外不同於以往的直接儲存資料,啟動應用時本地讀取然後恢復資料,對於redux應用而言,如果只是儲存資料,那麼我們就得為每一個reducer擴充,當再次啟動應用時去讀取持久化的資料,這是比較繁瑣而且低效的方式,是否可以嘗試儲存reducer key,然後根據key恢復對應的持久化資料,首先註冊Rehydrate reducer,當觸發action時根據其reducer key恢復資料,然後只需要在應用啟動時分發action,這也很容易抽象成可配置的擴充服務,實際上三方庫redux-persist已經為我們做好了這一切。

redux-persist

要實現redux的持久化,包括redux store的本地持久化儲存及恢復啟動兩個過程,如果完全自己編寫實現,程式碼量比較複雜,可以使用開源庫redux-persist,它提供persistStoreautoRehydrate方法分別持久化本地儲存store及恢復啟動store,另外還支援自定義傳入持久化及恢復store時對store state的轉換擴充。

持久化store

如下在建立store時會呼叫persistStore相關服務-RehydrationServices.updateReducers()

// configure persistStore and check reducer version number
if (ReduxPersist.active) {
  RehydrationServices.updateReducers(store);
}複製程式碼

該方法內實現了store的持久化儲存:

// Check to ensure latest reducer version
AsyncStorage.getItem('reducerVersion').then((localVersion) => {
  if (localVersion !== reducerVersion) {
    if (DebugConfig.useReactotron) {
      console.tron.display({
        name: 'PURGE',
        value: {
          'Old Version:': localVersion,
          'New Version:': reducerVersion
        },
        preview: 'Reducer Version Change Detected',
        important: true
      });
    }
    // Purge store
    persistStore(store, config, startApp).purge();
    AsyncStorage.setItem('reducerVersion', reducerVersion);
  } else {
    persistStore(store, config, startApp);
  }
}).catch(() => {
  persistStore(store, config, startApp);
  AsyncStorage.setItem('reducerVersion', reducerVersion);
})複製程式碼

會在AsyncStorage儲存一個reducer版本號,這個是在應用配置檔案中可以配置,首次執行持久化時儲存該版本號及store,若reducer版本號變更則清空原來儲存的store,否則傳入store給持久化方法persistStore即可。

persistStore(store, [config, callback])複製程式碼

該方法主要實現store的持久化以及分發rehydration action :

  1. 訂閱 redux store,當其發生變化時觸發store儲存操作;
  2. 從指定的StorageEngine(如AsyncStorage)中獲取資料,進行轉換,然後通過分發 REHYDRATE action,觸發 REHYDRATE 過程;

接收引數主要如下:

  1. store: 持久化的store;
  2. config:配置物件
    1. storage:一個 持久化引擎,例如 LocalStorage 和 AsyncStorage;
    2. transforms: 在 rehydration 和 storage 階段被呼叫的轉換器;
    3. blacklist: 黑名單陣列,指定持久化忽略的 reducers 的 key;
  3. callback:ehydration 操作結束後的回撥;

恢復啟動

和persisStore一樣,依然是在建立redux store時初始化註冊rehydrate擴充:

// add the autoRehydrate enhancer
if (ReduxPersist.active) {
  enhancers.push(autoRehydrate());
}複製程式碼

該方法實現的功能很簡單,即使用 持久化的資料恢復(rehydrate) store 中資料,它其實是註冊了一個autoRehydarte reducer,會接收前文persistStore方法分發的rehydrate action,然後合併state。

當然,autoRehydrate不是必須的,我們可以自定義恢復store方式:

import {REHYDRATE} from 'redux-persist/constants';

//...
case REHYDRATE:
  const incoming = action.payload.reducer
  if (incoming) {
    return {
      ...state,
      ...incoming
    }
  }
  return state;複製程式碼

版本更新

需要注意的是redux-persist庫已經發布到v5.x,而本文介紹的以v4.x為準,新版本有一些更新,詳細請點選檢視

持久化與Immutable

前面已經提到Redux與Immutable的整合,上文使用的redux-persist預設也只能處理原生JavaScript物件的redux store state,所以需要擴充以相容Immutable。

redux-persist-immutable

使用redux-persist-immutable庫可以很容易實現相容,所做的僅僅是使用其提供的persistStore方法替換redux-persist所提供的方法:

import { persistStore } from 'redux-persist-immutable';複製程式碼

transform

我們知道持久化store時,針對的最好是原生JavaScript物件,因為通常Immutable結構資料有很多輔助資訊,不易於儲存,所以需要定義持久化及恢復資料時的轉換操作:

import R from 'ramda';
import Immutable, { Iterable } from 'immutable';

// change this Immutable object into a JS object
const convertToJs = (state) => state.toJS();

// optionally convert this object into a JS object if it is Immutable
const fromImmutable = R.when(Iterable.isIterable, convertToJs);

// convert this JS object into an Immutable object
const toImmutable = (raw) => Immutable.fromJS(raw);

// the transform interface that redux-persist is expecting
export default {
  out: (state) => {
    return toImmutable(state);
  },
  in: (raw) => {
    return fromImmutable(raw);
  }
};複製程式碼

如上,輸出物件中的in和out分別對應持久化及恢復資料時的轉換操作,實現的只是使用fromJS()toJS()轉換Js和Immutable資料結構,使用方式如下:

import immutablePersistenceTransform from '../services/ImmutablePersistenceTransform'
persistStore(store, {
  transforms: [immutablePersistenceTransform]
}, startApp);複製程式碼

Immutable

在專案中引入Immutable以後,需要儘量保證以下幾點:

  1. redux store整個state樹的統一Immutable化;
  2. redux持久化對Immutable資料的相容;
  3. App Navigation相容Immutable;

Immutable與App Navigation

前面兩點已經在前面兩節闡述過,第三點過於Navigation相容Immutable,其實就是使Navigation路由狀態相容Immutable,在App應用導航與路由一節已經介紹如何將Navigation路由狀態連線至Redux store,如果應用使用了Immutable庫,則需要另外處理,將Navigation router state轉換為Immutable,修改前面提到的NavigationReducer:

const initialState = Immutable.fromJS({
  index: 0,
  routes: [{
    routeName: routes.Login.name,
    key: routes.Login.name
  }]
});

const NavigationReducer = (state = initialState, action) => {
  const newState = state.merge(AppNavigation.router.getStateForAction(action, state.toJS()));
  return newState || state;
};複製程式碼

將預設初始狀態轉換為Immutable,並且合併state時使用merge()方法。

非同步任務流管理

最後要介紹的模組是非同步任務管理,在應用開發過程中,最主要的非同步任務就是資料HTTP請求,所以我們講非同步任務管理,主要關注在資料HTTP請求的流程管理。

axios

本專案中使用axios作為HTTP請求庫,axios是一個Promise格式的HTTP客戶端,選擇此庫的原因主要有以下幾點:

  1. 能在瀏覽器發起XMLHttpRequest,也能在node.js端發起HTTP請求;
  2. 支援Promise;
  3. 能攔截請求和響應;
  4. 能取消請求;
  5. 自動轉換JSON資料;

redux-saga

redux-saga是一個致力於使應用中如資料獲取,本地快取訪問等非同步任務易於管理,高效執行,便於測試,能更好的處理異常的三方庫。

Redux-saga是一個redux中介軟體,它就像應用中一個單獨的程式,只負責管理非同步任務,它可以接受應用主程式的redux action以決定啟動,暫停或者是取消程式任務,它也可以訪問redux應用store state,然後分發action。

初始化saga

redux-saga是一箇中介軟體,所以首先呼叫createSagaMiddleware方法建立中介軟體,然後使用redux的applyMiddleware方法啟用中介軟體,之後使用compose輔助方法傳給createStore建立store,最後呼叫run方法啟動根saga:

import { createStore, applyMiddleware, compose } from 'redux';
import createSagaMiddleware from 'redux-saga';
import rootSaga from '../sagas/'

const sagaMiddleware = createSagaMiddleware({ sagaMonitor });
middleware.push(sagaMiddleware);
enhancers.push(applyMiddleware(...middleware));

const store = createStore(rootReducer, initialState, compose(...enhancers));

// kick off root saga
sagaMiddleware.run(rootSaga);複製程式碼

saga分流

在專案中通常會有很多並列模組,每個模組的saga流也應該是並列的,需要以多分支形式並列,redux-saga提供的fork方法就是以新開分支的形式啟動當前saga流:

import { fork, takeEvery } from 'redux-saga/effects';
import LoginSagas from './LoginSagas';

const sagas = [
  ...LoginSagas,
  ...StartAppSagas
];

export default function * root() {
  yield sagas.map(saga => fork(saga)); 
};複製程式碼

如上,首先收集所有模組根saga,然後遍歷陣列,啟動每一個saga流根saga。

saga例項

以LoginSagas為例,對於登入這一操作,可能在使用者開始登入,登入成功後需要進行一些非同步請求,所以列出loginSaga, loginSuccessSaga,另外使用者退出賬戶時也可能需要進行HTTP請求,所以將logoutSaga放在此處:

...

// process login actions
export function * loginSaga () {
  yield takeLatest(LoginTypes.LOGIN, login);
}

export function * loginSuccessSaga () {
  yield takeLatest(LoginTypes.LOGIN_SUCCESS, loginSuccess);
}

export function * logoutSaga () {
  yield takeLatest(LogoutTypes.LOGOUT, logout);
}

const sagas = [
  loginSaga,
  loginSuccessSaga,
  logoutSaga
];

export default sagas;複製程式碼

在loginSaga內使用takeLatest方法監聽LoginTypes.LOGINaction,當接收到該action時,呼叫login,login本質上還是一個saga,在裡面處理非同步任務:

function * login (action) {
  const { username, password } = action.payload || {};

  if (username && password) {
    const res = yield requestLogin({
      username,
      password
    });

    const { data } = res || {};

    if (data && data.success) {
      yield put(LoginActions.loginSuccess({
        username,
        password,
        isLogin: true
      }));
    } else {
      yield put(LoginActions.loginFail({
        username,
        password,
        isLogin: false
      }));
    }
  } else {
    yield put(LoginActions.loginFail({
      username,
      password,
      isLogin: false
    }));
  }
}複製程式碼

requestLogin方法就是一個登入HTTP請求,使用者名稱和密碼引數從LoginTypes.LOGINaction傳遞的負載取得,yield語句取回請求響應,賦值給res,隨後通過響應內容判斷登入是否成功:

  1. 登入成功,分發LoginActions.loginSuccessaction,隨後將執行監聽此action的reducer及loginSuccessSagasaga;
  2. 登入失敗,分發LoginActions.loginFailaction;

put是redux-saga提供的可分發action方法。

saga與Reactotron

前面已經配置好可以使用Reactotron捕獲應用所有redux和action,而redux-saga是一類redux中介軟體,所以捕獲sagas需要額外配置,建立store時,在saga中介軟體內新增sagaMonitor服務,監聽saga:

const sagaMonitor = Config.useReactotron ? console.tron.createSagaMonitor() : null;
const sagaMiddleware = createSagaMiddleware({ sagaMonitor });
middleware.push(sagaMiddleware);
...複製程式碼

總結

本文較詳細的總結了個人從0到1搭建一個專案架構的過程,對React,React Native, Redux應用和專案工程實踐都有了更深的理解及思考,在大前端成長之路繼續砥礪前行。

完整程式碼見github

參考

  1. react native
  2. react native中文網
  3. react navigation

相關文章