【乾貨】從零實現 react-redux

  • 2020 年 3 月 31 日
  • 筆記

1. 前言

在 React 誕生之初,Facebook 宣傳這是一個用於前端開發的界面庫,僅僅是一個 View 層。前面我們也介紹過 React 的組件通信,在大型應用中,處理好 React 組件通信和狀態管理就顯得非常重要。為了解決這一問題,Facebook 最先提出了單向數據流的 Flux 架構,彌補了使用 React 開發大型網站的不足。

Flux:

隨後,Dan Abramov 受到 Flux 和函數式編程語言 Elm 啟發,開發了 Redux 這個狀態管理庫。Redux 源碼非常精簡,實現也很巧妙,這篇文章將帶你從零手寫一個 Redux 和 react-redux 庫,以及告訴你該如何設計 Redux 中的 store。在開始前,我已經將這篇文章的完整代碼都整理到 GitHub 上,大家可以參考一下。

Redux:simple-redux

React-redux:simple-react-redux

2. 狀態管理

2.1 理解數據驅動

在開始講解狀態管理前,我們先來了解一下現代前端框架都做了些什麼。以 Vue 為例子,在剛開始的時候,Vue 官網首頁寫的賣點是數據驅動、組件化、MVVM 等等(現在首頁已經改版了)。那麼數據驅動的意思是什麼呢?不管是原生 JS 還是 jQuery,他們都是通過直接修改 DOM 的形式來實現頁面刷新的。而 Vue/React 之類的框架不是粗暴地直接修改 DOM,而是通過修改 data/state 中的數據,實現了組件的重新渲染。也就是說,他們封裝了從數據變化到組件渲染這一個過程。

原本我們用 jQuery 開發應用,除了要實現業務邏輯,還要操作 DOM 來手動實現頁面的更新。尤其是涉及到渲染列表的時候,更新起來非常麻煩。

var ul = document.getElementById("todo-list");  $.each(todos, function(index, todo) {      var li = document.createElement('li');      li.innerHTML = todo.content;      li.dataset.id = todo.id;      li.className = "todo-item";      ul.appendChild(li);  })

所以後來出現了 jQuery.tpl 和 Underscore.template 之類的模板,這些讓操作 DOM 變得容易起來,有了數據驅動和組件化的雛形,可惜我們還是要手動去渲染一遍。

<script type="text/template" id="tpl">      <ul id="todo-list">          <% _.each(todos, function(todo){ %>              <li data-id="<%=todo.id%>" class="todo-item">                  <%= todo.content %>              </li>          <% }); %>      </ul>  </script>

如果說用純原生 JS 或者 jQuery 開發頁面是原始農耕時代,那麼 React/Vue 等現代化框架則是自動化的時代。有了前端框架之後,我們不需要再去關注怎麼生成和修改 DOM,只需要關心頁面上的這些數據以及流動。所以如何管理好這些數據流動就成了重中之重,這也是我們常說的「狀態管理」。

2.2 什麼狀態需要管理?

前面講了很多例子,可狀態管理到底要管理什麼呢?在我看來,狀態管理的一般就是這兩種數據。

  1. Domain State Domain State 就是服務端的狀態,這個一般是指通過網絡請求來從服務端獲取到的數據,比如列表數據,通常是為了和服務端數據保持一致。
{      "data": {          "hotels": [              {                  "id": "31231231",                  "name": "希爾頓",                  "price": "1300"              }          ]      }  }
  1. UI State UI State 常常和交互相關。例如模態框的開關狀態、頁面的 loading 狀態、單(多)選項的選中狀態等等,這些狀態常常分散在不同的組件裏面。
{      "isLoading": true,      "isShowModal": false,      "isSelected": false  }

2.3 全局狀態管理

我們用 React 寫組件的時候,如果需要涉及到兄弟組件通信,經常需要將狀態提升到兩者父組件裏面。一旦這種組件通信多了起來,數據管理就是個問題。結合上面的例子,如果想要對應用的數據流進行管理,那是不是可以將所有的狀態放到頂層組件中呢?將數據按照功能或者組件來劃分,將多個組件共享的數據單獨放置,這樣就形成了一個大的樹形 store。這裡更建議按照功能來劃分。

這個大的 store 可以放到頂層組件中維護,也可以放到頂層組件之外來維護,這個頂層組件我們一般稱之為「容器組件」。容器組件可以將組件依賴的數據以及修改數據的方法一層層傳給子組件。我們可以將容器組件的 state 按照組件來劃分,現在這個 state 就是整個應用的 store。將修改 state 的方法放到 actions 裏面,按照和 state 一樣的結構來組織,最後將其傳入各自對應的子組件中。

class App extends Component {      constructor(props) {          this.state = {              common: {},              headerProps: {},              bodyProps: {                  sidebarProps: {},                  cardProps: {},                  tableProps: {},                  modalProps: {}              },              footerProps: {}          }          this.actions = {              header: {                  changeHeaderProps: this.changeHeaderProps              },              footer: {                  changeFooterProps: this.changeFooterProps              },              body: {                  sidebar: {                      changeSiderbarProps: this.changeSiderbarProps                  }              }          }      }        changeHeaderProps(props) {          this.setState({              headerProps: props          })      }      changeFooterProps() {}      changeSiderbarProps() {}      ...        render() {          const {              headerProps,              bodyProps,              footerProps          } = this.state;          const {              header,              body,              footer          } = this.actions;          return (              <div className="main">                  <Header {...headerProps} {...header} />                  <Body {...bodyProps} {...body} />                  <Footer {...footerProps} {...footer} />              </div>          )      }  }

我們可以看到,這種方式可以很完美地解決子組件之間的通信問題。只需要修改對應的 state 就行了,App 組件會在 state 變化後重新渲染,子組件接收新的 props 後也跟着渲染。

這種模式還可以繼續做一些優化,比如結合 Context 來實現向深層子組件傳遞數據。

const Context = createContext(null);  class App extends Component {      ...      render() {          return (              <div className="main">                  <Context.Provider value={...this.state, ...this.events}>                      <Header />                      <Body />                      <Footer />                  </Context.Provider>              </div>          )      }  }  const Header = () => {      // 獲取到 Context 數據      const context = useContext(Context);  }

如果你已經接觸過 Redux 這個狀態管理庫,你會驚奇地發現,如果我們把 App 組件中的 state 移到外面,這不就是 Redux 了嗎?沒錯,Redux 的核心原理也是這樣,在組件外部維護一個 store,在 store 修改的時候會通知所有被 connect 包裹的組件進行更新。這個例子可以看做 Redux 的一個雛形。

3. 實現一個 Redux

根據前面的介紹我們已經知道了,Redux 是一個狀態管理庫,它並非綁定於 React 使用,你還可以將其和其他框架甚至原生 JS 一起使用,比如這篇文章:如何在非 React 項目中使用 Redux (https://segmentfault.com/a/1190000009963395)

Redux 工作原理:

在學習 Redux 之前需要先理解其工作原理,一般來說流程是這樣的:

  1. 用戶觸發頁面上的某種操作,通過 dispatch 發送一個 action。
  2. Redux 接收到這個 action 後通過 reducer 函數獲取到下一個狀態。
  3. 將新狀態更新進 store,store 更新後通知頁面重新渲染。

從這個流程中不難看出,Redux 的核心就是一個 發佈-訂閱 模式。一旦 store 發生了變化就會通知所有的訂閱者,view 接收到通知之後會進行重新渲染。

Redux 有三大原則:

  • 單一數據源 前面的那個例子,最終將所有的狀態放到了頂層組件的 state 中,這個 state 形成了一棵狀態樹。在 Redux 中,這個 state 則是 store,一個應用中一般只有一個 store。
  • State 是只讀的 在 Redux 中,唯一改變 state 的方法是觸發 action,action 描述了這次修改行為的相關信息。只允許通過 action 修改可以使應用中的每個狀態修改都很清晰,便於後期的調試和回放。
  • 通過純函數來修改 為了描述 action 使狀態如何修改,需要你編寫 reducer 函數來修改狀態。reducer 函數接收前一次的 state 和 action,返回新的 state。無論被調用多少次,只要傳入相同的 state 和 action,那麼就一定返回同樣的結果。

關於 Redux 的用法,這裡不做詳細講解,建議參考阮一峰老師的《Redux 入門》系列的教程:Redux 入門教程

3.1 實現 store

在 Redux 中,store 一般通過 createStore 來創建。

import { createStore } from 'redux';  const store = createStore(rootReducer, initalStore, middleware);

先看一下 Redux 中暴露出來的幾個方法。

其中 createStore 返回的方法主要有 subscribedispatchreplaceReducergetState

createStore 接收三個參數,分別是 reducers 函數、初始值 initalStore、中間件 middleware。

store 上掛載了 getStatedispatchsubscribe 三個方法。

getState 是獲取到 store 的方法,可以通過 store.getState() 獲取到 store

dispatch 是發送 action 的方法,它接收一個 action 對象,通知 store 去執行 reducer 函數。

subscribe 則是一個監聽方法,它可以監聽到 store 的變化,所以可以通過 subscribe 將 Redux 和其他框架結合起來。

replaceReducer 用來異步注入 reducer 的方法,可以傳入新的 reducer 來代替當前的 reducer。

3.2 實現 getState

store 的實現原理比較簡單,就是根據傳入的初始值來創建一個對象。利用閉包的特性來保留這個 store,允許通過 getState 來獲取到 store。之所以通過 getState 來獲取 store 是為了獲取到當前 store 的快照,這樣便於打印日誌以對比前後兩次 store 變化,方便調試。

const createStore = (reducers, initialState, enhancer) => {      let store = initialState;      const getState = () => store;      return {          getState      }  }

當然,現在這個 store 實現的比較簡單,畢竟 createStore 還有兩個參數沒用到呢。先別急,這倆參數後面會用到的。

3.3 實現 subscribe && unsubscribe

既然 Redux 本質上是一個 發佈-訂閱 模式,那麼就一定會有一個監聽方法,類似 jQuery 中的 $.on,在 Redux 中提供了監聽和解除監聽的兩個方法。實現方式也比較簡單,使用一個數組來保存所有監聽的方法。

const createStore = (...) => {      ...      let listeners = [];      const subscribe = (listener) => {          listeners.push(listener);      }      const unsubscribe = (listener) => {          const index = listeners.indexOf(listener)          listeners.splice(index, 1)      }  }

3.4 實現 dispatch

dispatch 和 action 是息息相關的,只有通過 dispatch 才能發送 action。而發送 action 之後才會執行 subscribe 監聽到的那些方法。所以 dispatch 做的事情就是將 action 傳給 reducer 函數,將執行後的結果設置為新的 store,然後執行 listeners 中的方法。

const createStore = (reducers, initialState) => {      ...      let store = initialState;      const dispatch = (action) => {          store = reducers(store, action);          listeners.forEach(listener => listener())      }  }

這樣就行了嗎?當然還不夠。如果有多個 action 同時發送,這樣很難說清楚最後的 store 到底是什麼樣的,所以需要加鎖。在 Redux 中 dispatch 執行後的返回值也是當前的 action。

const createStore = (reducers, initialState) => {      ...      let store = initialState;      let isDispatch = false;      const dispatch = (action) => {          if (isDispatch) return action          // dispatch必須一個個來          isDispatch = true          store = reducers(store, action);          isDispatch = false          listeners.forEach(listener => listener())          return action;      }  }

至此為止,Redux 工作流程的原理就已經實現了。但你可能還會有很多疑問,如果沒有傳 initialState,那麼 store 的默認值是什麼呢?如果傳入了中間件,那麼又是什麼工作原理呢?

3.5 實現 combineReducers

在剛開始接觸 Redux 的 store 的時候,我們都會有一種疑問,store 的結構究竟是怎麼定的?combineReducers 會揭開這個謎底。現在來分析 createStore 接收的第一個參數,這個參數有兩種形式,一種直接是一個 reducer 函數,另一個是用 combineReducers 把多個 reducer 函數合併到一起。

可以猜測 combineReducers 是一個高階函數,接收一個對象作為參數,返回了一個新的函數。這個新的函數應當和普通的 reducer 函數傳參保持一致。

const combineReducers = (reducers) => {      return function combination(state = {}, action) {      }  }

那麼 combineReducers 做了什麼工作呢?主要是下面幾步:

  1. 收集所有傳入的 reducer 函數
  2. 在 dispatch 中執行 combination 函數時,遍歷執行所有 reducer 函數。如果某個 reducer 函數返回了新的 state,那麼 combination 就返回這個 state,否則就返回傳入的 state。
const combineReducers = reducers => {      const finalReducers = {},          nativeKeys = Object.keys      // 收集所有的 reducer 函數      nativeKeys(reducers).forEach(reducerKey => {          if(typeof reducers[reducerKey] === "function") {              finalReducers[reducerKey] = reducers[reducerKey]          }      })      return function combination(state, action) {          let hasChanged = false;          const store = {};          // 遍歷執行 reducer 函數          nativeKeys(finalReducers).forEach(key => {              const reducer = finalReducers[key];              // 很明顯,store 的 key 來源於 reducers 的 key 值              const nextState = reducer(state[key], action)              store[key] = nextState              hasChanged = hasChanged || nextState !== state[key];          })          return hasChanged ? nextState : state;      }  }

細心的童鞋一定會發現,每次調用 dispatch 都會執行這個 combination 的話,那豈不是不管我發送什麼類型的 action,所有的 reducer 函數都會被執行一遍?如果 reducer 函數很多,那這個執行效率不會很低嗎?但不執行貌似又無法完全匹配到 switch...case 中的 action.type。如果能通過鍵值對的形式來匹配 action.type 和 reducer 是不是效率更高一些?類似這樣:

// redux  const count = (state = 0, action) => {      switch(action.type) {          case 'increment':              return state + action.payload;          case 'decrement':              return state - action.payload;          default:              return state;      }  }  // 改進後的  const count = {      state: 0, // 初始 state      reducers: {          increment: (state, payload) => state + payload,          decrement: (state, payload) => state - payload      }  }

這樣每次發送新的 action 的時候,可以直接用 reducers 下面的 key 值來匹配了,無需進行暴力的遍歷。天啊,你實在太聰明了。小聲告訴你,社區中一些類 Redux 的方案就是這樣做的。以 rematch 和 relite 為例:rematch:

import { init, dispatch } from "@rematch/core";  import delay from "./makeMeWait";    const count = {    state: 0,    reducers: {      increment: (state, payload) => state + payload,      decrement: (state, payload) => state - payload    },    effects: {      async incrementAsync(payload) {        await delay();        this.increment(payload);      }    }  };    const store = init({    models: { count }  });    dispatch.count.incrementAsync(1);

relite:

const increment = (state, payload) => {      state.count = state.count + payload;      return state;  }  const decrement = (state, payload) => {      state.count = state.count - payload;      return state;  }

3.6 中間件 和 Store Enhancer

考慮到這樣的情況,我想要打印每次 action 的相關信息以及 store 前後的變化,那我只能到每個 dispatch 處手動打印信息,這樣繁瑣且重複。createStore 中提供的第三個參數,可以實現對 dispatch 函數的增強,我們稱之為 Store EnhancerStore Enhancer 是一個高階函數,它的結構一般是這樣的:

const enhancer = () => {      return (createStore) => (reducer, initState, enhancer) => {          ...      }  }

enhancer 接收 createStore 作為參數,最後返回的是一個加強版的 store,本質上是對 dispatch 函數進行了擴展。logger:

const logger = () => {      return (createStore) => (reducer, initState, enhancer) => {          const store = createStore(reducer, initState, enhancer);          const dispatch = (action) => {              console.log(`action=${JSON.stringify(action)}`);              const result = store.dispatch(action);              const state = store.getState();              console.log(`state=${JSON.stringify(state)}`);              return result;          }          return {              ...state,              dispatch          }      }  }

createStore 中如何使用呢?一般在參數的時候,會直接返回。

const createStore = (reducer, initialState, enhancer) => {      if (enhancer && typeof enhancer === "function") {          return enhancer(createStore)(reducer, initialState)      }  }

如果你有看過 applyMiddleware 的源碼,會發現這兩者實現方式很相似。applyMiddleware 本質上就是一個 Store Enhancer

3.7 實現 applyMiddleware

在創建 store 的時候,經常會使用很多中間件,通過 applyMiddleware 將多個中間件注入到 store 之中。

const store = createStore(reducers, initialStore, applyMiddleware(thunk, logger, reselect));

applyMiddleware 的實現類似上面的 Store Enhancer。由於多個中間件可以串行使用,因此最終會像洋蔥模型一樣,action 傳遞需要經過一個個中間件處理,所以中間件做的事情就是增強 dispatch 的能力,將 action 傳遞給下一個中間件。那麼關鍵就是將新的 store 和 dispatch 函數傳給下一個中間件。

來看一下 applyMiddleware 的源碼實現:

const applyMiddleware = (...middlewares) => {      return (createStore) => (reducer, initState, enhancer) => {          const store = createStore(reducer, initState, enhancer)          const middlewareAPI = {              getState: store.getState,              dispatch: (action) => dispatch(action)          }          let chain = middlewares.map(middleware => middleware(middlewareAPI))          store.dispatch = compose(...chain)(store.dispatch)          return {            ...store,            dispatch          }        }  }

這裡用到了一個 compose 函數,compose 函數類似管道,可以將多個函數組合起來。compose(m1, m2)(dispatch) 等價於 m1(m2(dispatch))。使用 reduce 函數可以實現函數組合。

const compose = (...funcs) => {      if (!funcs) {          return args => args      }      if (funcs.length === 1) {          return funcs[0]      }      return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))  }

再來看一下 redux-logger 中間件的精簡實現,會發現兩者恰好能匹配到一起。

function logger(middlewareAPI) {    return function (next) { // next 即 dispatch      return function (action) {        console.log('dispatch 前:', middlewareAPI.getState());        var returnValue = next(action);        console.log('dispatch 後:', middlewareAPI.getState(), 'n');        return returnValue;      };    };  }

至此為止,Redux 的基本原理就很清晰了,最後整理一個精簡版的 Redux 源碼實現。

// 這裡需要對參數為0或1的情況進行判斷  const compose = (...funcs) => {      if (!funcs) {          return args => args      }      if (funcs.length === 1) {          return funcs[0]      }      return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))  }    const bindActionCreator = (action, dispatch) => {      return (...args) => dispatch(action(...args))  }    const createStore = (reducer, initState, enhancer) => {      if (!enhancer && typeof initState === "function") {          enhancer = initState          initState = null      }      if (enhancer && typeof enhancer === "function") {          return enhancer(createStore)(reducer, initState)      }      let store = initState,          listeners = [],          isDispatch = false;      const getState = () => store      const dispatch = (action) => {          if (isDispatch) return action          // dispatch必須一個個來          isDispatch = true          store = reducer(store, action)          isDispatch = false          listeners.forEach(listener => listener())          return action      }      const subscribe = (listener) => {          if (typeof listener === "function") {              listeners.push(listener)          }          return () => unsubscribe(listener)      }      const unsubscribe = (listener) => {          const index = listeners.indexOf(listener)          listeners.splice(index, 1)      }      return {          getState,          dispatch,          subscribe,          unsubscribe      }  }    const applyMiddleware = (...middlewares) => {      return (createStore) => (reducer, initState, enhancer) => {          const store = createStore(reducer, initState, enhancer);          const middlewareAPI = {              getState: store.getState,              dispatch: (action) => dispatch(action)          }          let chain = middlewares.map(middleware => middleware(middlewareAPI))          store.dispatch = compose(...chain)(store.dispatch)          return {            ...store          }        }  }    const combineReducers = reducers => {      const finalReducers = {},          nativeKeys = Object.keys      nativeKeys(reducers).forEach(reducerKey => {          if(typeof reducers[reducerKey] === "function") {              finalReducers[reducerKey] = reducers[reducerKey]          }      })      return (state, action) => {          const store = {}          nativeKeys(finalReducers).forEach(key => {              const reducer = finalReducers[key]              const nextState = reducer(state[key], action)              store[key] = nextState          })          return store      }  }

4. 實現一個 react-redux

如果想要將 Redux 結合 React 使用的話,通常可以使用 react-redux 這個庫。看過前面 Redux 的原理後,相信你也知道 react-redux 是如何實現的了吧。react-redux 一共提供了兩個 API,分別是 connect 和 Provider,前者是一個 React 高階組件,後者是一個普通的 React 組件。react-redux 實現了一個簡單的發佈-訂閱庫,來監聽當前 store 的變化。兩者的作用如下:

  1. Provider:將 store 通過 Context 傳給後代組件,註冊對 store 的監聽。
  2. connect:一旦 store 變化就會執行 mapStateToProps 和 mapDispatchToProps 獲取最新的 props 後,將其傳給子組件。

使用方式:

// Provider  ReactDOM.render({      <Provider store={store}></Provider>,      document.getElementById('app')  })  // connect  @connect(mapStateToProps, mapDispatchToProps)  class App extends Component {}

4.1 實現 Provider

先來實現簡單的 Provider,已知 Provider 會使用 Context 來傳遞 store,所以 Provider 直接通過 Context.Provider 將 store 給子組件。

// Context.js  const ReactReduxContext = createContext(null);    // Provider.js  const Provider = ({ store, children }) => {      return (          <ReactReduxContext.Provider value={store}>              {children}          </ReactReduxContext.Provider>      )  }

Provider 裏面還需要一個發佈-訂閱器

class Subscription {      constructor(store) {          this.store = store;          this.listeners = [this.handleChangeWrapper];      }      notify = () => {          this.listeners.forEach(listener => {              listener()          });      }      addListener(listener) {          this.listeners.push(listener);      }      // 監聽 store      trySubscribe() {          this.unsubscribe = this.store.subscribe(this.notify);      }      // onStateChange 需要在組件中設置      handleChangeWrapper = () => {          if (this.onStateChange) {            this.onStateChange()          }      }      unsubscribe() {          this.listeners = null;          this.unsubscribe();      }  }  

將 Provider 和 Subscription 結合到一起,在 useEffect 裏面註冊監聽。

// Provider.js  const Provider = ({ store, children }) => {      const contextValue = useMemo(() => {          const subscription = new Subscription(store);          return {              store,              subscription          }      }, [store]);      // 監聽 store 變化      useEffect(() => {          const { subscription } = contextValue;          subscription.trySubscribe();          return () => {              subscription.unsubscribe();          }      }, [contextValue]);      return (          <ReactReduxContext.Provider value={contextValue}>              {children}          </ReactReduxContext.Provider>      )  }

4.2 實現 connect

再來看 connect 的實現,這裡主要有三步:

  1. 使用 useContext 獲取到傳入的 store 和 subscription。
  2. 對 subscription 添加一個 listener,這個 listener 的作用就是一旦 store 變化就重新渲染組件。
  3. store 變化之後,執行 mapStateToProps 和 mapDispatchToProps 兩個函數,將其和傳入的 props 進行合併,最終傳給 WrappedComponent。

先來實現簡單的獲取 Context。

const connect = (mapStateToProps, mapDispatchToProps) => (WrappedComponent) => {      return function Connect(props) {          const { store, subscription } = useContext(ReactReduxContext);          return <WrappedComponent {...props} />      }  }

接下來就要來實現如何在 store 變化的時候更新這個組件。我們都知道在 React 中想實現更新組件只有手動設置 state 和調用 forceUpdate 兩種方法,這裡使用 useState 每次設置一個 count 來觸發更新。

const connect = (mapStateToProps, mapDispatchToProps) => {      return (WrappedComponent) => {          return (props) => {              const { store, subscription } = useContext(ReactReduxContext);              const [count, setCount] = useState(0)              useEffect(() => {                  subscription.onStateChange = () => setCount(count + 1)              }, [count])              const newProps = useMemo(() => {                  const stateProps = mapStateToProps(store.getState()),                      dispatchProps = mapDispatchToProps(store.dispatch);                  return {                      ...stateProps,                      ...dispatchProps,                      ...props                  }              }, [props, store, count])              return <WrappedComponent {...newProps} />          }      }  }

react-redux 的原理和上面比較類似,這裡只作為學習原理的一個例子,不建議用到生產環境中。

5. 如何設計 store

在開發中,如果想要查看當前頁面的 store 結構,可以使用 [Redux-DevTools][14] 或者 [React Developer Tools][15] 這兩個 chrome 插件來查看。前者一般用於開發環境中,可以將 store 及其變化可視化展示出來。後者主要用於 React,也可以查看 store。關於 Redux 中 store 如何設計對初學者來說一直都是難題,在我看來這不僅是 Redux 的問題,在任何前端 store 設計中應該都是一樣的。

5.1 store 設計誤區

這裡以知乎的問題頁 store 設計為例。在開始之前,先安裝 React Developer Tools,在 RDT 的 Tab 選中根節點。

然後在 Console 裏面輸入 $r.state.store.getState(),將 store 打印出來。

可以看到 store 中有一個 entities 屬性,這個屬性中分別有 users、questions、answer 等等。

這是一個問題頁,自然包括問題、回答、回答下面的評論 等等。

一般情況下,這裡應該是當進入頁面的時候,根據 question_id 來分批從後端獲取到所有的回答。點開評論的時候,會根據 answer_id 來分批從後端獲取到所有的評論。所以你可能會想到 store 結構應當這樣設計,就像俄羅斯套娃一樣,一層套着一套。

{      questions: [          {              content: 'LOL中哪個英雄最能表達出你對刺客的想像?',              question_id: '1',              answers: [                  {                      answer_id: '1-1',                      content: '我就是來提名一個已經式微的英雄的。沒錯,就是提莫隊長...'                      comments: [                          {                              comment_id: '1-1-1',                              content: '言語精鍊,每一句話都是一幅畫面,一組鏡頭'                          }                      ]                  }              ]          }      ]  }

看圖可以更直觀感受數據結構:

這是初學者經常進入的一個誤區,按照 API 來設計 store 結構,這種方法是錯誤的。以評論區回復為例子,如何將評論和回復的評論關聯起來呢?也許你會想,把回復的評論當做評論的子評論不就行了嗎?

{      comments: [          {              comment_id: '1-1-1',              content: '言語精鍊,每一句話都是一幅畫面,一組鏡頭',              children: [                  {                      comment_id: '1-1-2',                      content: '我感覺是好多畫面,一部電影。。。'                  }              ]          },          {              comment_id: '1-1-2',              content: '我感覺是好多畫面,一部電影。。。'          }      ]  }

這樣挺好的,滿足了我們的需求,但 children 中的評論和 comments 中的評論數據亢余了。

5.2 扁平化 store

聰明的你一定會想到,如果 children 中只保存 comment_id 不就好了嗎?展示的時候只要根據 comment_id 從 comments 中查詢就行了。這就是設計 store 的精髓所在了。我們可以將 store 當做一個數據庫,store 中的狀態按照領域(domain)來劃分成一張張數據表。不同的數據表之間以主鍵來關聯。因此上面的 store 可以設計成三張表,分別是 questions、answers、comments,以它們的 id 作為 key,增加一個新的字段來關聯子級。

{      questions: {          '1': {              id: '1',              content: 'LOL中哪個英雄最能表達出你對刺客的想像?',              answers: ['1-1']          }      },      answers: {          '1-1': {              id: '1-1',              content: '我就是來提名一個已經式微的英雄的。沒錯,就是提莫隊長...',              comments: ['1-1-1', '1-1-2']          }      },      comments: {          '1-1-1': {              id: '1-1-1',              content: '言語精鍊,每一句話都是一幅畫面,一組鏡頭',              children: ['1-1-2']          },          '1-1-2': {              id: '1-1-2',              content: '我感覺是好多畫面,一部電影。。。'          }      }  }

你會發現數據結構變得非常扁平化,避免了數據亢余以及嵌套過深的問題。在查找的時候也可以直接通過 id 來查找,避免了通過索引來查找某一具體項。

6. 推薦閱讀

  • 解析Twitter前端架構 學習複雜場景數據設計
  • JSON數據範式化(normalizr)
  • React+Redux打造「NEWS EARLY」單頁應用

最後推薦我一個好朋友sheen花了大半年時間寫了一本前端進階教程,內容全面,從基礎知識到框架原理,涵蓋初中級前端進階必備知識,本文也收錄其中。如果你覺得本文寫得不錯,亦或者希望有大佬手把手帶你搞定業務代碼,不妨考慮這本專欄!可以點擊[閱讀原文]進行購買!