DnD-mag / magazine

magazine
3 stars 0 forks source link

深入探討 redux 與 react-redux #20

Closed kjj6198 closed 6 years ago

kjj6198 commented 6 years ago

深入探討 Redux 與 react-redux

前言

官方文件寫得很好,不過 redux 的學習曲線其實不低,尤其是在剛入門 React 時,很容易就會陷入不知道為什麼要這麼做的窘境。設定一個 redux 的環境超級麻煩,要先幫 action, reducer, container 建立資料夾,寫一堆 action creator。 最後還要搭配 react-reduxmapStateToPropsbindActionCreators 等等,只為了寫出一個 Todo App。

這種繁瑣的設定是有其原因的,不過為了保持簡單性,todo app 範例為了保持簡單,很難讓人體會到 redux 的亮眼之處。redux 作者本人也曾經寫過 You might not need Redux,來分析怎樣的情境下可能不需要 Redux。

思考取捨是工程師的職責與專業,盲目崇拜框架只會讓自己寫出綁手綁腳、難以維護的程式碼。

mapStateToProps(state, [ownProps]): stateProps

許多人都是直接把 store 裡頭全部的東西一股腦兒塞進去 root component 裏頭。其實搭配 mapStateToProps 傳入有許多好處:

1. 存取 redux store
const mapStateToProps = (state) => ({});

這是 mapStateToProps 的函式簽名,回傳的 object 會被塞到 connect 的 Component 當中。這樣子不僅可以拿我們想要的資料就好,也能夠把資料轉換成 Component 需要的格式。例如下面的 Component:

const Profile = ({ name, age }) => {
  return (
    <div>
      <span>{name}</span>
      <span>{age}</span>
    </div>
  )
}

如果今天想要把資料塞進去 Profile 裏頭:

const Profile = ({ name, age }) => {
  return (
    <div>
      <span>{name}</span>
      <span>{age}</span>
    </div>
  )
}

const mapStateToProps = (state) => ({
  name: state.user.username,
  age: state.user.age,
});

export default connect(
  mapStateToProps
)(Profile);

從這裡就能看到,不需要修改 Profile 本身的 prop,而是透過 mapStateToProps 的方式把資料傳進來。這樣之後如果回傳的資料有變化(例如 username 改成 name),只需要修改 mapStateToProps 即可。不一定每個連接到 redux store 的 component 都要另外在建立一個 container,像上述的範例,直接用 stateless component 連接也可以。

2. 與 Component props 搭配使用

mapStateToProps 的第二個參數是 ownProps

這讓我們可以把傳入 Component 的 props 也一起傳進來,例如今天想要根據 UI 的某個狀態來 filter 資料:

class PostContainer extends React.Component {
  renderPosts() {
    this.props.posts.filter(post => post.author.indexOf(this.state.filter))
        .map(post => <Post post={post} />)
  }
  render() {
    return (
      <div>
        {this.renderPosts()}
      </div>
    )
  }
}

我們看到在 renderPosts 裡面做了 filter 邏輯,雖然在這裡只是簡單用 indexOf 判斷,不過在實務上可能會有比較複雜的 filter 邏輯。我們希望讓 View 裡頭的邏輯越乾淨越好。

透過 ownProps 可以這樣做:

class PostContainer extends React.Component {
  renderPosts() {
    return this.props.posts.map(post => (<Post post={post} />))
  }

  render() {
    return (
      <div>
        {this.renderPosts()}
      </div>
    )  
  }
}
const mapStateToProps = (state, ownProps) => ({ // ownProps from PostWrapper
  posts: state.posts.filter(post => post.indexOf(ownProps.filter))
});

export default connect(mapStateToProps)(PostContainer);
class PostWrapper extends React.Component {
  state = {
    filter: 'all',
  };

  setFilter = (filter) => e => {
    this.setState({ filter: filter.split(':')[1] });
  }

  render() {
    return (
      <div>
        <button onClick={this.setFilter('author:kalan')}>author: kalan</button>
        <PostContainer filter={this.state.filter} />
      </div>
    )
  }
}

❗️注意:並不需要為了使用這些參數而故意這麼做,選擇適當的做法才是最重要的

mapDispatchToProps(dispatch, [ownProps])

你可能不需要 bindActionCreators

許多人都習慣在 Root component 上引入所有的 actions,然後再透過 bindActionCreators 傳入。

不過大部分的使用上,幾乎只會使用到某些特定的 action,其實不用全部傳入,而且透過 Component 層層傳遞 actions 的話,就喪失了 redux 的初衷了,一旦讓 Component 知道太多 actions 的存在,之後要修改 Component 的階層就會非常困難。

mapDispatch 可以直接傳入 object,它會自動跟 dispatch 組裝。例如結合按鈕:

import { fetchPosts } from './actions';

const mapStateToProps = (state) => ({
  loading: state.posts.isLoading,
});

const mapDispatchToProps = {
  onClick: fetchPosts,
};

const Button = ({ text, onClick, loading }) => (
  <button
    disabled={loading}
    onClick={onClick}>
    {text}
  </button>
);

export default connect(mapStateToProps, mapDispatchToProps)(Button);

不需要再一層 container 的包裝或是修改 Component 的內容就可以復用 Button 這個元件。或者想要傳入 action 在 componentDidMount 或是其他生命週期呼叫時,dispatchToProps 是個相當有用的函數,而且除了傳入 function(dispatch) 之外,也能夠直接傳 object,redux 內部會幫我們與 dispatch 綁定,相當方便。

❗️注意:如果沒有傳入這個參數,會直接把 dispatch 函數當作 props 傳入

mergeProps(stateProps, dispatchProps, ownProps): stateProps

這是 connect 的第三個參數,比較少人使用,不過如果在 dispatch 時想要根據 ownProps 的某些屬性綁定參數,或是透過當前 state 來建立客製化的 dispatch,這個函數就相當方便,可以讓 component 更乾淨。

import { remove } from '/path/to/actions'
const PostContainer = ({ remove, posts, selectedPosts }) => (
  <div>
    <Posts posts={posts} />
    <button onClick={()=> remove(selectedPosts)}>create</button>
  </div>
);

const mapStateToProps = {
  posts: state.posts,
  selectedPosts: state.posts.filter(selectedIds),
};

const mapDispatchToProps = (dispatch) => ({
  removePosts: remove,
});

export default connect(mapStateToProps, mapDispatchToProps)(PostContainer);

上述的例子當中我們另外傳入了 selectedPosts 到 component 當中作為 action 的參數之一,搭配 mergeProps

import { remove } from '/path/to/actions'
const PostContainer = ({ removePosts, posts }) => (
  <div>
    <Posts posts={posts} />
    <button onClick={()=> remove(selectedPosts)}>create</button>
  </div>
);

const mapStateToProps = {
  posts: state.posts,
  selectedPosts: state.posts.filter(selectedIds),
};

const mapDispatchToProps = (dispatch) => ({
  removePosts: posts => dispatch(remove(posts)),
});

const mergeProps = (stateProps, dispatchProps, ownProps) => ({
  posts: stateProps.posts,
  removePosts: dispatchProps.removePosts(stateProps.selectedPosts)
});

export default connect(mapStateToProps, mapDispatchToProps, mergeProps);

❗️注意:mergeProps 的預設值為 Object.assign(stateProps, dispatchProps, ownProps),所以要注意 props 名稱有沒有不小心覆蓋掉哦

options

options 是 connect 的第四個參數(原來還有第四個參數)。是專門為了調整效能用的,大部分的場景很少遇到,畢竟大部分的情況下,效能都不是最大的瓶頸

以下一一介紹

pure(boolean)

預設為 true,會根據以下 function 比較 stateProps, dispatchProps, mergeProps 是否相當來決定是否要 re-render。基本上預設的 function 符合 99% 的使用情景,不過如果計算整個 state 的代價相當昂貴,可以考慮使用以下的 function 來改善效能。

❗️注意:只有當 pure 這個選項為 ture 的時候,下面的 function 才有用

areStatesEqual(function(next, prev)): boolean

回傳 boolean 值來決定 state 是否相等,這邊的 state 是指 redux store 的 state 而不是 container 的 state。

Default 值為 ===。也就是比較 nextState 與 prevState 的 reference 是否相同。

使用時機:

const LargePostContainer = ({ posts }) => (
  return (
    <Posts posts={posts} />
  )
);

const mapStateToProps = (state) => ({
  posts: state.manyPosts
});

export default connect(mapStateToProps, null, {
  pure: true, // pure 一定要是 true,不然下面的 function 就沒用了
  areStatesEqual: (next, prev) => next.manyPosts === prev.manyPosts
})

這漾一來,這個 Container 就只會關注 manyPosts 的變化,而不會因為其他 state 的改變而更新。

storeKey(string)

在 redux 當中可以傳入多個 store(咦,這不是跟 redux 的哲學不一樣嗎?),因此官方不推薦你使用多個 store,除非你已經有一個相當大的 app 以及 store,想要分離 store 來改善效能問題

善用 middleware

在 redux 當中的 middleware 函式簽名長這樣:

import { createStore, applyMiddleware } from redux;
const middleware = (store) => action => next => next(action);
const middlewares = [];
const createStoreEnhanced = applyMiddleware(...middlewares)(createStore);

任何會有 side effect 或是需要存取外部狀態(非 redux store)、跟 app 本身無關的行為(如 log、錯誤處理)時,就可以考慮用 middleware 來完成。

錯誤處理

const errorReportMiddleware = (store) => action => next => {
  try {
    return next(action)
  } catch(e) {
    if (process.env.NODE_ENV === 'production') {
        return fetch('/api/log', {
            method: 'POST',
            body: JSON.stringify({
            message: e.message,
            state: store.getState()
        });
    }
    throw e;
  }
}

存取 cookie, localStorge 狀態

const authMiddleware = store => action => next => {
  const preference = localStorge.getItem('prefrence');
  if (typeof action === 'function') {
    return action(preference);
  }
  return next(action);
}

const setVideoQuality = (preference) => ({
  hd: preference.hd,
  volume: preference.volume,
});

hijack API request

比如我希望對任何的 API call 作統一逾時的動作,在 action creator 的 convention 有 FETCH_ 開頭就是 API call。

const timeoutAPIMiddleware = store => action => next => {
  if (action.type.indexOf('FECTH_')) {
    setTimeout(() => {
      return next({
        type: 'FETCH_REQEUST_TIMEOUT',
      })
    }, action.meta.timeout || DEFAULT_TIMEOUT);
  }

  return next(action);
}

當然為了示範用,這並不是一個很好的 timeout 方式,他只是在一段時間後送出一個 FETCH_REQUEST_TIMEOUT 的 action,而沒有真正取消 API request。

offline support

const offlineSupportMiddleware = store => action => next => {
  if (action.meta && action.meta.offline) {
    if (!navigator.onLine) {
      const pendingActions = JSON.parse(localStorge.getItem('pendingActions'));
      pendingActions.push(action);
      localStorge.setItem('pendingActions', JSON.stringify(pendingActions));
    } else {
      const pendingActions = JSON.parse(localStorge.getItem('pendingActions'));
      pendingActions.forEach(action => store.dispatch(action));
    }
  }

  return next(action);
}

以上只是相當簡略的實作,在 offline 的時候我們希望把任何需要網路的操作儲存起來,並且在連上線的時候把 pending 的 actions 送出。

小結

透過 middleware 可以幫助我們做到許多事情,以上的舉例只是其中一部份而已,善用 middleware 可以簡化許多 action 的操作。

善用 combineReducers

如果一個 reducer 的 switch case 太多,已經多到開始讓人煩燥時,或許是該拆分 reducer 的時候了,redux 本身就是將多個 store 透過 combineReducers 組合起來,每個 reducer 只專注在特定的 actions 上。

重複地寫 actions 與 reducer 是件重複又無聊的事,可以參考像是 redux-actions 的 library 簡化。

import { combineReducers } from 'redux';
import posts from './reducers/posts';
import search from './reducers/search';

export default combineReducers({
  posts,
  search,
});

compose

使用像是 relay 或是 apollo 的 API 時,有時候希望能夠把 server 端回傳資料拿到 redux store 做處理,讓我們也能夠透過自行定義 action 的方式來操作資料。

在 apollo 1 的時候,預設會在 ApolloProvider 裡頭建立一個 redux store,Apollo 會送出自己的 redux action。不過在 Apollo 2 的時候已經移除這種方式了。官方文件提到:

The 2.0 moves away from using Redux as the caching layer in favor of Apollo maintaining its own store through the provided cache passed when creating a client. This allows the new version to be more flexible around how data is cached, and opens the storage of data to many new avenues and view integrations. — Apollo Client

透過 compose,我們可以把 redux 與 graphql 串接起來,例如:

import { compose, connect } from 'react-redux';
import { graphql } from 'apollo-client';

compose(
  connect(mapStateToProps, mapDispatchToProps),
  graphql(myGraphqlQuery, options)
)(PostContainer);

注意這邊的順序,如果你希望先拿取 graphql 的資料,再跟資料與 redux 做連接的話,那麼 graphql 要先放在下面。接下來就可以透過 mapStateToProps 的第二個參數(ownProps)拿取,graphql 的狀態。

這樣子就不用硬把資料放在 redux store 了。

題外話:如果你是使用 redux-observable 時,可以透過 createEpicMiddleware 的第二個參數傳入 dependencies

import { createEpicMiddleware } from 'redux-observable';
import { ApolloClient } from 'apollo-client';
import rootEpic from '../rootEpic';

const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
});

const middleware = createEpicMiddleware(rootEpic, {
  dependencies: client, // passing dependencies.
});

const epic = (action$, store, client) =>
  action$.ofType('ACTION')
    .mergeMap(action => 
      Observable.from(client.query({ fetchPolicy: 'network', query: gql`graphqlQuery`}))
    );

這樣子就可以透過 epic 發送 query,同時使用 reducer 來接收資料。(兩種方式各有優劣)

不一定只能有一個 Container

為了方便傳遞 props,在許多開發情景上許多工程師都只用一個 Container 來接 redux store 的資料,但 container 的階層也有可能是很多層的,因此在 Container 裡面只要有需要,隨時都可以在放一個 Container 來接 redux store,來避免 props 層層傳遞的問題。

幾個 redux 常見的問題

Q. 要不要把 UI State 放在 redux?

it depends.

使用 this.setState() 來管理 UI 狀態很簡單,也非常直觀,不需要大費周章地寫 action, reducer 來操作,但一旦使用 state,也就很難被外部控制。因此如果 Component 確定 UI State 都只會由自己管理,不會讓其他 component 操作時,放在 component state 就是個不錯的選擇。

很久以前 React 還有 setProps 的時候很難決策到底要怎麼使用 props 還是 state。不過移除後使用時機就非常明顯了:

但像是 modal 這種經常在其他地方使用的 Component,放在 redux 就是不錯的選擇,我們可以在其他 Component (或 Container) 裡頭呼叫 OPEN_MODAL 的 action,就可以直接操作 modal,而不需要層層的 callback 或是 setState

總結

  1. 你可能不需要 Redux。如果你覺得寫 action 與 reducer 實在太麻煩而且讓你很痛苦,或者反而拖慢開發腳步的話,或許該停下來思考是否需要 redux 了。
  2. 不一定要用 container 來接收 redux store 的狀態。
  3. 善用 mapStateToProps 來讓 redux 發揮 store 統一以及 connect
  4. mapDispatchToProps 並不一定每次都要傳 bindActionCreators,可以根據需要傳入。mapDispatchToProps 也接受直接傳入 Object 的方式,看起來更簡潔
  5. connect 最後一個參數可以用來改善效能,詳細的 API 與使用時機可以參考文件
  6. react-redux 還提供了一個 connectAdvanced 的 API,把所有的 實作都交到你手上,提供很大的彈性給開發者修改,實現自己的 connect 邏輯。