React應用架構設計指南

熊建剛發表於2017-12-30

在上一篇我們介紹了Webpack自動化構建React應用,我們的本地開發伺服器可以較好的支援我們編寫React應用,並且支援程式碼熱更新。本節將開始詳細分析如何搭建一個React應用架構。

完整專案程式碼見github

個人部落格

前言

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

專案結構與技術棧

我們這次的實踐不準備使用任何腳手架,所以我們需要自己建立每一個檔案,引入每一個技術和三方庫,最終形成完整的應用,包括我們選擇的完整技術棧。

第一步,當然是建立目錄,我們在上一篇已經弄好,如果你還沒有程式碼,可以從Github獲取:

git clone https://github.com/codingplayboy/react-blog.git
cd react-blog
複製程式碼

生成專案結構如下圖:

React專案初始結構

  1. src為應用原始碼目錄;
  2. webpack為webpack配置目錄;
  3. webpack.config.js為webpack配置入口檔案;
  4. package.json為專案依賴管理檔案;
  5. yarn.lock為專案依賴版本鎖檔案;
  6. .babelrc檔案,babel的配置檔案,使用babel編譯React和JavaScript程式碼;
  7. eslintrceslintignore分別為eslint語法檢測配置及需要忽略檢查的內容或檔案;
  8. postcss.config.js為CSS後編譯器postcss的配置檔案;
  9. API.md為API文件入口;
  10. docs為文件目錄;
  11. README.md為專案說明文件;

接下來的工作主要就是豐富src目錄,包括搭建專案架構,開發應用功能,還有自動化,單元測試等,本篇主要關注專案架構的搭建,然後使用技術棧實踐開發幾個模組。

技術棧

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

  1. react和react-dom庫是專案前提;
  2. react路由;
  3. 應用狀態管理容器;
  4. 是否需要Immutable資料;
  5. 應用狀態的持久化;
  6. 非同步任務管理;
  7. 測試及輔助工具或函式;
  8. 開發除錯工具;

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

  1. react,react-dom;
  2. react-router管理應用路由;
  3. redux作為JavaScript狀態容器,react-redux將React應用與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除錯工具

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

React-Redux專案結構

開發除錯工具

React應用開發目前已經有諸多除錯工具,常用的如redux-devtools,Reactron等。

redux-devtools

redux-devtools是支援熱過載,回放action,自定義UI的一款Redux開發工具。

首先需要按照對應的瀏覽器外掛,然後再Redux應用中新增相關配置,就能在瀏覽器控制檯中檢視到redux工具欄了,詳細文件點此檢視

然後安裝專案依賴庫:

yarn add --dev redux-devtools
複製程式碼

然後在建立redux store時將其作為redux強化器傳入createStore方法:

import { applyMiddleware, compose, createStore, combineReducers } from 'redux'
// 預設為redux提供的組合函式
let composeEnhancers = compose

if (__DEV__) {
  // 開發環境,開啟redux-devtools
  const composeWithDevToolsExtension = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
  if (typeof composeWithDevToolsExtension === 'function') {
    // 支援redux開發工具擴充的組合函式
    composeEnhancers = composeWithDevToolsExtension
  }
}

// create store
const store = createStore(
  combineReducers(...),
  initialState,
  // 組合redux中間價和加強器,強化redux
  composeEnhancers(
    applyMiddleware(...middleware),
    ...enhancers
  )
)
複製程式碼
  1. 在開發環境下獲取redux-devtools提供的擴充組合函式;
  2. 建立store時使用擴充組合函式組合redux中介軟體和增強器,redux-dev-tools便獲得了應用redux的相關資訊;

Reactotron

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

Reactotron

首先安裝:

yarn add --dev reactotron-react-js
複製程式碼

然後初始化Reactotron相關配置:

import Reactotron from 'reactotron-react-js';
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: 'React Blog' })
    .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-js
  // 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元件化開發原則是元件負責渲染UI,元件不同狀態對應不同UI,通常遵循以下元件設計思路:

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

Redux

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

Redux是一個JavaScript應用的可預測狀態管理容器,它不依賴於具體框架或類庫,所以它在多平臺的應用開發中有著一致的開發方式和效率,另外它還能幫我們輕鬆的實現時間旅行,即action的回放。

redux-flow

  1. 資料單一來源原則:使用Redux作為應用狀態管理容器,統一管理應用的狀態樹,它推從資料單一可信來源原則,所有資料都來自redux store,所有的資料更新也都由redux處理;
  2. redux store狀態樹:redux集中管理應用狀態,組織管理形式就好比DOM樹和React元件樹一樣,以樹的形式組織,簡單高效;
  3. redux和store:redux是一種Flux的實現方案,所以建立了store一詞,它類似於商店,集中管理應用狀態,支援將每一個釋出的action分發至所有reducer;
  4. action:以物件資料格式存在,通常至少有type和payload屬性,它是對redux中定義的任務的描述;
  5. reducer:通常是以函式形式存在,接收state(應用區域性狀態)和action物件兩個引數,根據action.type(action型別)執行不同的任務,遵循函數語言程式設計思想;
  6. dispatch:store提供的分發action的功能方法,傳遞一個action物件引數;
  7. createStore:建立store的方法,接收reducer,初始應用狀態,redux中介軟體和增強器,初始化store,開始監聽action;

中介軟體(Redux Middleware)

Redux中介軟體,和Node中介軟體一樣,它可以在action分發至任務處理reducer之前做一些額外工作,dispatch釋出的action將依次傳遞給所有中介軟體,最終到達reducer,所以我們使用中介軟體可以擴充諸如記錄日誌,新增監控,切換路由等功能,所以中介軟體本質上只是擴充了store.dispatch方法。

redux-middleware-enhancer

增強器(Store Enhancer)

有些時候我們可能並不滿足於擴充dispatch方法,還希望能增強store,redux提供以增強器形式增強store的各個方面,甚至可以完全定製一個store物件上的所有介面,而不僅僅是store.dispatch方法。

const logEnhancer = (createStore) => (reducer, preloadedState, enhancer) => {
  const store = createStore(reducer, preloadedState, enhancer)
  const originalDispatch = store.dispatch
  store.dispatch = (action) => {
    console.log(action)
    originalDispatch(action)
  }
  
  return store
}
複製程式碼

最簡單的例子程式碼如上,新函式接收redux的createStore方法和建立store需要的引數,然後在函式內部儲存store物件上某方法的引用,重新實現該方法,在裡面處理完增強邏輯後呼叫原始方法,保證原始功能正常執行,這樣就增強了store的dispatch方法。

可以看到,增強器完全能實現中介軟體的功能,其實,中介軟體就是以增強器方式實現的,它提供的compose方法就可以組合將我們傳入的增強器擴充到store,而如果我們傳入中介軟體,則需要先呼叫applyMiddleware方法包裝,內部以增強器形式將中介軟體功能擴充到store.dispatch方法

react-redux

Redux是一個獨立的JavaScript應用狀態管理容器庫,它可以與React、Angular、Ember、jQuery甚至原生JavaScript應用配合使用,所以開發React應用時,需要將Redux和React應用連線起來,才能統一使用Redux管理應用狀態,使用官方提供的react-redux庫。

class App extends Component {
  render () {
    const { store } = this.props
    return (
      <Provider store={store}>
        <div>
          <Routes />
        </div>
      </Provider>
    )
  }
}
複製程式碼

react-redux庫提供Provider元件通過context方式嚮應用注入store,然後可以使用connect高階方法,獲取並監聽store,然後根據store state和元件自身props計算得到新props,注入該元件,並且可以通過監聽store,比較計算出的新props判斷是否需要更新元件。

更多關於react-redux的內容可以閱讀之前的文章:React-Redux分析

createStore

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

// creates the store
export default (rootReducer, rootSaga, initialState) => {
  /* ------------- Redux Configuration ------------- */
  // Middlewares
  // Build the middleware for intercepting and dispatching navigation actions
  const blogRouteMiddleware = routerMiddleware(history)
  const sagaMiddleware = createSagaMiddleware()
  const middleware = [blogRouteMiddleware, sagaMiddleware]

  // enhancers
  const enhancers = []
  let composeEnhancers = compose

  // create store
  const store = createStore(
    combineReducers({
      router: routerReducer,
      ...reducers
    }),
    initialState,
    composeEnhancers(
      applyMiddleware(...middleware),
      ...enhancers
    )
  )
  sagaMiddleware.run(saga)

  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({
    ...RouterReducer,
    ...AppReducer
  });

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

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

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

const initialState = Immutable.fromJS({
  ids: [],
  posts: {
    list: [],
    total: 0,
    totalPages: 0
  }
})

const AppReducer = (state = initialState, action) => {
  case 'RECEIVE_POST_LIST':
    const newState = state.merge(action.payload)
    return newState || state
  default:
    return state
}
複製程式碼

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

React路由

在React web單頁面應用中,頁面級UI元件的展示和切換完全由路由控制,每一個路由都有對應的URL及路由資訊,我們可以通過路由統一高效的管理我們的元件切換,保持UI與URL同步,保證應用的穩定性及友好體驗。

react-router

React Router是完整的React 路由解決方案,也是開發React應用最常使用的路由管理庫,只要用過它,絕對會喜歡上它的設計,它提供簡單的API,以宣告式方式實現強大的路由功能,諸如按需載入,動態路由等。

  1. 宣告式:語法簡潔,清晰;
  2. 按需載入:延遲載入,根據使用需要判斷是否需要載入;
  3. 動態路由:動態組合應用路由結構,更靈活,更符合元件化開發模式;

動態路由與靜態路由

使用react-router v4版本可以定義跨平臺的應用動態路由結構,所謂的動態路由(Dynamic Routing)即在渲染過程中發生路由的切換,而不需要在建立應用前就配置好,這也正是其區別於靜態路由(Static Routing)所在,動態路由提高更靈活的路由組織方式,而且更方便編碼實現路由按需載入元件。

在react-router v2和v3版本中,開發React應用需要在開始渲染前就定義好完整的應用路由結構,所有的路由都需要同時初始化,才能在應用渲染後生效,會產生很多巢狀化路由,喪失了動態路由的靈活性和簡潔的按需載入編碼方式。

react-router v4.x

在react-router 2.x和3.x版本中,定義一個應用路由結構通常如下:

import React from 'react'
import ReactDOM from 'react-dom'
import { browserHistory, Router, Route, IndexRoute } from 'react-router'

import App from '../components/App'
import Home from '../components/Home'
import About from '../components/About'
import Features from '../components/Features'

ReactDOM.render(
  <Router history={browserHistory}>
    <Route path='/' component={App}>
      <IndexRoute component={Home} />
      <Route path='about' component={About} />
      <Route path='features' component={Features} />
    </Route>
  </Router>,
  document.getElementById('app')
)
複製程式碼

很簡單,但是所有的路由結構都需要在渲染應用前,統一定義,層層巢狀;而且如果要實現非同步按需載入還需要在這裡對路由配置物件進行修改,使用getComponentAPI,並侵入改造該元件,配合webpack的非同步打包載入API,實現按需載入:

  1. 路由層層巢狀,必須在渲染應用前統一宣告;
  2. API不同,需要使用getComponent,增加路由配置物件的複雜性;
  3. <Route>只是一個宣告路由的輔助標籤,本身無意義;

而使用react-router v4.x則如下:

// react-dom (what we'll use here)
import { BrowserRouter } from 'react-router-dom'

ReactDOM.render((
  <BrowserRouter>
    <App/>
  </BrowserRouter>
), el)

const App = () => (
  <div>
    <nav>
      <Link to="/about">Dashboard</Link>
    </nav>
    <Home />
    <div>
      <Route path="/about" component={About}/>
      <Route path="/features" component={Features}/>
    </div>
  </div>
)
複製程式碼

相比之前版本,減少了配置化的痕跡,更凸顯了元件化的組織方式,而且在渲染元件時才實現該部分路由,而如果期望按需載入該元件,則可以通過封裝實現一個支援非同步載入元件的高階元件,將經過高階元件處理後返回的元件傳入<Route>即可,依然遵循元件化形式:

  1. 靈活性:路由可以在渲染元件中宣告,不需依賴於其他路由,不需要集中配置;
  2. 簡潔:統一傳入component,保證路由宣告的簡潔性;
  3. 元件化:<Route>作為一個真實元件建立路由,可以渲染;

路由鉤子方法

另外需要注意的是,相對於之前版本提供onEnter, onUpdate, onLeave等鉤子方法API在一定程度上提高了對路由的可控性,但是實質只是覆蓋了渲染元件的生命週期方法,現在我們可以通過路由渲染元件的生命週期方法直接控制路由,如使用componentDidMountcomponentWillMount 代替 onEnter

路由與Redux

同時使用React-Router和Redux時,大多數情況是正常的,但是也可能出現路由變更元件未更新的情況,如:

  1. 我們使用redux的connect方法將元件連線至redux:connect(Home);
  2. 元件不是一個路由渲染元件,即不是使用Route>元件形式:<Route component={Home} />宣告渲染的;

這是為什麼呢?,因為Redux會實現元件的shouldComponentUpdate方法,當路由變化時,該元件並沒有接收到props表明發生了變更,需要更新元件。

那麼如何解決問題呢?,要解決這個問題只需要簡單的使用react-router-dom提供的withRouter方法包裹元件:

import { withRouter } from 'react-router-dom'
export default withRouter(connect(mapStateToProps)(Home))
複製程式碼

Redux整合

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

react-router-redux

連線React Router與Redux,需要使用react-router-redux庫,而且react-router v4版本需要指定安裝@next版本和hsitory庫:

yarn add react-router-redux@next
yarn add history
複製程式碼

然後,在建立store時,需要實現如下配置:

  1. 建立一個history物件,對於web應用,我們選擇browserHisotry,對應需要從history/createBrowserHistory模組引入createHistory方法以建立history物件;

    點此檢視更多history相關內容

  2. 新增routerReducerrouterMiddleware中介軟體“,其中routerMiddleware中介軟體接收history物件引數,連線store和history,等同於舊版本的syncHistoryWithStore

import createHistory from 'history/createBrowserHistory'
import { ConnectedRouter, routerReducer, routerMiddleware, push } from 'react-router-redux'
// Create a history of your choosing (we're using a browser history in this case)
export const history = createHistory()

// Build the middleware for intercepting and dispatching navigation actions
const middleware = routerMiddleware(history)

// Add the reducer to your store on the `router` key
// Also apply our middleware for navigating
const store = createStore(
  combineReducers({
    ...reducers,
    router: routerReducer
  }),
  applyMiddleware(middleware)
)

return store
複製程式碼

在渲染根元件時,我們抽象出兩個元件:

  1. 初始化渲染根元件,掛載至DOM的根元件,由<Provider>元件包裹,注入store;
  2. 路由配置元件,在根元件中,宣告路由配置元件,初始化必要的應用路由定義及路由物件;
import createStore from './store/'
import Routes from './routes/'
import appReducer from './store/appRedux'

const store = createStore({}, {
  app: appReducer
})

/**
 * 專案根元件
 * @class App
 * @extends Component
 */
class App extends Component {
  render () {
    const { store } = this.props

    return (
      <Provider store={store}>
        <div>
          <Routes />
        </div>
      </Provider>
    )
  }
}

// 渲染根元件
ReactDOM.render(
  <App store={store} />,
  document.getElementById('app')
)
複製程式碼

上面的<Routes>元件是專案的路由元件:

import { history } from '../store/'
import { ConnectedRouter } from 'react-router-redux'
import { Route } from 'react-router'

class Routes extends Component {
  render () {
    return (
      <ConnectedRouter history={history}>
        <div>
          <BlogHeader />
          <div>
            <Route exact path='/' component={Home} />
            <Route exact path='/posts/:id' component={Article} />
          </div>
        </div>
      </ConnectedRouter>
    )
  }
}
複製程式碼

首先使用react-router-redux提供的ConnectedRouter元件包裹路由配置,該元件將自動使用<Provider>元件注入的store,我們需要做的是手動傳入history屬性,在元件內會呼叫history.listen方法監聽瀏覽器LOCATION_CHANGE事件,最後返回react-router<Router >元件,處理作為this.props.children傳入的路由配置,ConnectedRouter元件內容傳送

dispatch切換路由

配置上面程式碼後,就能夠以dispatch action的方式觸發路由切換和元件更新了:

import { push } from 'react-router-redux'
// Now you can dispatch navigation actions from anywhere!
store.dispatch(push('/about'))
複製程式碼

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

redux持久化

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

另外不同於以往的直接儲存資料,啟動應用時本地讀取然後恢復資料,對於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的轉換擴充。

yarn add redux-persist
複製程式碼

持久化store

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

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

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

// Check to ensure latest reducer version
storage.getItem('reducerVersion').then((localVersion) => {
  if (localVersion !== reducerVersion) {
    // 清空 store
    persistStore(store, null, startApp).purge();
    storage.setItem('reducerVersion', reducerVersion);
  } else {
    persistStore(store, null, startApp);
  }
}).catch(() => {
  persistStore(store, null, startApp);
  storage.setItem('reducerVersion', reducerVersion);
})
複製程式碼

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

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

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

  1. 訂閱 redux store,當其發生變化時觸發store儲存操作;
  2. 從指定的StorageEngine(如localStorage)中獲取資料,進行轉換,然後通過分發 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,而本文介紹的以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. React路由相容Immutable;

關於Immutable及Redux,Reselect等的實踐考驗檢視之前寫的一篇文章:Immutable.js與React,Redux及reselect的實踐

Immutable與React路由

前面兩點已經在前面兩節闡述過,第三點react-router相容Immutable,其實就是使應用路由狀態相容Immutable,在React路由一節已經介紹如何將React路由狀態連線至Redux store,但是如果應用使用了Immutable庫,則還需要額外處理,將react-router state轉換為Immutable格式,routeReducer不能處理Immutable,我們需要自定義一個新的RouterReducer:

import Immutable from 'immutable';
import { LOCATION_CHANGE } from 'react-router-redux';

const initialState = Immutable.fromJS({
  location: null
});

export default (state = initialState, action) => {
  if (action.type === LOCATION_CHANGE) {
    return state.set('location', action.payload);
  }
  
  return state;
};
複製程式碼

將預設初始路由狀態轉換為Immutable,並且路由變更時使用Immutable API操作state。

seamless-Immutable

當引入Immutable.js後,對應用狀態資料結構的使用API就得遵循Immutable API,而不能再使用原生JavaScript物件,陣列等的操作API了,諸如,陣列解構([a, b] = [b, c]),物件擴充符(...)等,存在一些問題:

  1. Immutable資料輔助節點較多,資料較大:
  2. 必須使用Immutable語法,和JavaScript語法有差異,不能很好的相容;
  3. 和Redux,react-router等JavaScript庫寫協作時,需要引入額外的相容處理庫;

針對這些問題,社群有了seamless-immutable可供替換選擇:

  1. 更輕:相對於Immutable.jsseamless-immutable庫更輕小;
  2. 語法:物件和陣列的操作語法更貼近原生JavaScript;
  3. 和其他JavaScript庫協作更方便;

非同步任務流管理

最後要介紹的模組是非同步任務管理,在應用開發過程中,最主要的非同步任務就是資料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 { HomeSaga } from './Home/flux.js'
import { AppSaga } from './Appflux.js'

const sagas = [
  ...AppSaga,
  ...HomeSaga
]

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

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

saga例項

以AppSaga為例,我們期望在應用啟動時就發起一些非同步請求,如獲取文章列表資料將其填充至redux store,而不等待使用資料的元件渲染完才開始請求資料,提高響應速度:

const REQUEST_POST_LIST = 'REQUEST_POST_LIST'
const RECEIVE_POST_LIST = 'RECEIVE_POST_LIST'

/**
 * 請求文章列表ActionCreator
 * @param {object} payload
 */
function requestPostList (payload) {
  return {
    type: REQUEST_POST_LIST,
    payload: payload
  }
}

/**
 * 接收文章列表ActionCreator
 * @param {*} payload
 */
function receivePostList (payload) {
  return {
    type: RECEIVE_POST_LIST,
    payload: payload
  }
}

/**
 * 處理請求文章列表Saga
 * @param {*} payload 請求引數負載
 */
function * getPostListSaga ({ payload }) {
  const data = yield call(getPostList)
  yield put(receivePostList(data))
}

// 定義AppSaga
export function * AppSaga (action) {
  // 接收最近一次請求,然後呼叫getPostListSaga子Saga
  yield takeLatest(REQUEST_POST_LIST, getPostListSaga)
}
複製程式碼
  1. takeLatest:在AppSaga內使用takeLatest方法監聽REQUEST_POST_LISTaction,若短時間內連續發起多次action,則會取消前面未響應的action,只發起最後一次action;
  2. getPostListSaga子Saga:當接收到該action時,呼叫getPostListSaga,並將payload傳遞給它,getPostListSaga是AppSaga的子級Saga,在裡面處理具體非同步任務;
  3. getPostListgetPostListSaga會呼叫getPostList方法,發起非同步請求,拿到響應資料後,呼叫receivePostList ActionCreator,建立並分發action,然後由reducer處理相應邏輯;

getPostList方法內容如下:

/**
 * 請求文章列表方法
 * @param {*} payload 請求引數
 *  eg: {
 *    page: Num,
 *    per_page: Num
 *  }
 */
function getPostList (payload) {
  return fetch({
    ...API.getPostList,
    data: payload
  }).then(res => {
    if (res) {
      let data = formatPostListData(res.data)
      return {
        total: parseInt(res.headers['X-WP-Total'.toLowerCase()], 10),
        totalPages: parseInt(res.headers['X-WP-TotalPages'.toLowerCase()], 10),
        ...data
      }
    }
  })
}
複製程式碼

put是redux-saga提供的可分發action方法,take,call等都是redux-saga提供的API,更多內容檢視API文件

之後便可以在專案路由根元件注入ActionCreator,建立action,然後saga就會接收進行處理了。

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, Redux應用和專案工程實踐都有了更深的理解及思考,在大前端成長之路繼續砥礪前行。

注:文中列出的所有技術棧,博主計劃一步一步推進,目前原始碼中使用的技術有React,React Router,Redux,react-redux,react-router-redux,Redux-saga,axios。後期計劃推進Immutable,Reactotron,Redux Persist。

完整專案程式碼見github

參考

  1. React
  2. Redux
  3. React Router v4
  4. redux-saga
  5. Redux Persist

相關文章