react 知識梳理(二):手寫一個自己的 redux

易名發表於2018-05-24

寫在前面的話(示例程式碼在這裡

推薦大家讀一下鬍子大哈老師的 《React.js 小書》

提起 Redux 我們想到最多的應該就是 React-redux 這個庫,可是實際上 Redux 和 React-redux 並不是同一個東西, Redux 是一種架構模式,源於 Flux。具體介紹請看這裡,或者這裡,或者還有這裡。 React-redux 是 Redux 思想與 React 結合的一種具體實現。
在我們使用 React 的時候,常常會遇到元件深層次巢狀且需要值傳遞的情況,如果使用 props 進行值的傳遞,顯然是非常痛苦的。為了解決這個問題,React 為我們提供了原生的 context API,但我們用的最多的解決方案卻是使用 React-redux 這個基於 context API 封裝的庫。
本文並不介紹 React-redux 的具體用法,而是通過一個小例子,來了解下什麼是 redux。

好了,現在我們言歸正傳,來實現我們自己的 redux。

一、最初

首先,我們用 creat-react-app 來建立一個專案,刪除 src 下冗餘部分,只保留 index.js,並修改 index.html 的 DOM 結構:

# index.html
<div id="root">
  <div id="head"></div>
  <div id="body"></div>
</div>
複製程式碼

我們在 index.js 中建立一個物件,用它來儲存、管理我們整個應用的資料狀態,並用渲染函式把資料渲染在頁面:

const appState = {
  head: {
    text: '我是頭部',
    color: 'red'
  },
  body: {
    text: '我是body',
    color: 'green'
  }
}

function renderHead (state){
  const head = document.getElementById('head')
  head.innerText = state.head.text;
  head.style.color = state.head.color;
}
function renderBody (state){
  const body = document.getElementById('body')
  body.innerText = state.body.text;
  body.style.color = state.body.color;
}
function renderApp (state){
  renderHead(state);
  renderBody(state);
}
renderApp(appState);

複製程式碼

此時執行程式碼,開啟頁面,我們可以看到,在 head 中已經出現了紅色字型的‘我是頭部’,在 body 中出現了綠色字型的‘我是body’。

react 知識梳理(二):手寫一個自己的 redux
如果我們把 head 和 body 看作是 root 中的兩個元件,那麼我們已經實現了一個全域性唯一的 state 。這個 state 是全域性共享的,隨處可呼叫的。
我們可以修改 head 的渲染函式,來看下效果:

function renderHead (state){
  const head = document.getElementById('head')
  head.innerText = state.head.text + '--' + state.body.text;
  head.style.color = state.head.color;
  state.body.text = '我是經過 head 修改後的 body';
}
複製程式碼

react 知識梳理(二):手寫一個自己的 redux

我們看到,在 head 渲染函式中,我們不僅可以取用 body 屬性的值,還可以改變他的值。這樣就存在一個嚴重的問題,因為 state 是全域性共用的,一旦在一個地方改變了 state 的值,那麼,所有用到這個值的元件都將受到影響,而且這個改變是不可預期的,顯然給我們的程式碼除錯增加了難度係數,這樣的結果是我們不願意看到的!

二、dispatch

現在看來,在我們面前出現了一個矛盾:我們需要資料共享,但共享資料被任意的修改又會造成不可預期的問題!
為了解決這個矛盾,我們需要一個管家,專門來管理共享資料的狀態,任何對共享資料的操作都要通過他來完成,這樣,就避免了隨意修改共享資料帶來的不可預期的危害! 我們重新定義一個函式,用這個函式充當我們的管家,來對我們的共享資料進行管理:

function dispatch(state, action) {
  switch (action.type) {
    case 'HEAD_COLOR':
      state.head.color = action.color
      break
    case 'BODY_TEXT':
      state.body.text = action.text
      break
    default:
      break
  }
}
複製程式碼

我們來重新修改head 的渲染函式:

function renderHead (state){
  const head = document.getElementById('head')
  head.innerText = state.head.text + '--' + state.body.text;
  head.style.color = state.head.color;
  dispatch(state, { type: 'BODY_TEXT', text: '我是 head 經過呼叫 dispatch 修改後的 body' })
}
複製程式碼

react 知識梳理(二):手寫一個自己的 redux

dispatch 函式接收兩個引數,一個是需要修改的 state ,另一個是修改的值。這時,雖然我們依舊修改了 state ,但是通過 dispatch 函式,我們使這種改變變得可控,因為任何改變 state 的行為,我們都可以在 dispatch 中找到改變的源頭。 這樣,我們似乎已經解決了之前的矛盾,我們建立了一個全域性的共享資料,而且嚴格的把控了任何改變這個資料的行為。
然而,在一個檔案中,我們既要儲存 state, 還要維護管家函式 dispatch,隨著應用的越來越複雜,這個檔案勢必會變得冗長繁雜,難以維護。
現在,我們把 state 和 dispatch 單獨抽離出來:

  • 用一個檔案單獨儲存 state
  • 用另一個檔案單獨儲存 dispatch 中修改 state 的對照關係 changeState
  • 最後再用一個檔案,把他們結合起來,生成全域性唯一的 store

這樣,不僅使單個檔案變得更加精簡,而且在其他的應用中,我們也可以很方便的複用我們這套方法,只需要傳入不同應用的 state 和修改 state 的對應邏輯 stateChange,就可以放心的通過呼叫 dispatch 方法,對資料進行各種操作了:

# 改變我們的目錄結構,新增 redux 資料夾
+ src
++ redux
--- state.js // 儲存應用資料狀態
--- storeChange.js //  維護一套修改 store 的邏輯,只負責計算,返回新的 store
--- createStore.js // 結合 state 和 stateChange , 建立 store ,方便任何應用引用 
--index.js 

## 修改後的各個檔案

# state.js -- 全域性狀態
export const state = {
  head: {
    text: '我是頭部',
    color: 'red'
  },
  body: {
    text: '我是body',
    color: 'green'
  }
}

# storeChange.js -- 只負責計算,修改 store
export const storeChange = (store, action) => {
  switch (action.type) {
    case 'HEAD_COLOR':
      store.head.color = action.color
      break
    case 'BODY_TEXT':
      store.body.text = action.text
      break
    default:
      break
  }
}

# createStore.js -- 建立全域性 store
export const createStore = (state, storeChange) => {
  const store = state || {};
  const dispatch = (action) => storeChange(store, action);
  return { store, dispatch }
}

# index.js 
import { state } from './redux/state.js';
import { storeChange } from './redux/storeChange.js';
import { createStore } from './redux/createStore.js';
const { store, dispatch } = createStore(state, storeChange)
  
function renderHead (state){
  const head = document.getElementById('head')
  head.innerText = state.text;
  head.style.color = state.color;
}
function renderBody (state){
  const body = document.getElementById('body')
  body.innerText = state.text;
  body.style.color = state.color;
}

function renderApp (store){
  renderHead(store.head);
  renderBody(store.body);
}
// 首次渲染
renderApp(store);
複製程式碼

通過以上的檔案拆分,我們看到,不僅使單個檔案更加精簡,檔案的職能也更加明確:

  • 在 state 中,我們只儲存我們的共享資料
  • 在 storeChange 中,我們來維護改變 store 的對應邏輯,計算出新的 store
  • 在 createStore 中,我們建立 store
  • 在 index.js 中,我們只需要關心相應的業務邏輯

三、subscribe

一切似乎都那麼美好,可是當我們在首次渲染後呼叫 dispatch 修改 store 時,我們發現,雖然資料被改變了,可是頁面並沒有重新整理,只有在 dispatch 改變資料後,重新呼叫 renderApp() 才能實現頁面的重新整理。

// 首次渲染
renderApp(store);
dispatch({ type: 'BODY_TEXT', text: '我是呼叫 dispatch 修改的 body' }) // 修改資料後,頁面並沒有自動重新整理
renderApp(store);  // 重新呼叫 renderApp 頁面重新整理
複製程式碼

這樣,顯然並不能達到我們的預期,我們並不想在每次改變資料後手動的重新整理頁面,如果能在改變資料後,自動進行頁面的重新整理,當然再好不過了!
如果直接把 renderApp 寫在 dispatch 裡,顯然是不太合適的,這樣我們的 createStore 就失去了通用性。
我們可以在 createStore 中新增一個收集陣列,把 dispatch 呼叫後需要執行的方法統一收集起來,然後再迴圈執行,這樣,就保證了 createStore 的通用性:

# createStore
export const createStore = (state, storeChange) => {
  const listeners = [];
  const store = state || {};
  const subscribe = (listen) => listeners.push(listen); 
  const dispatch = (action) => {
    storeChange(store, action);
    listeners.forEach(item => {
      item(store);
    })
  };
  return { store, dispatch, subscribe }
}

# index.js
···
const { store, dispatch, subscribe } = createStore(state, storeChange)
··· 
···
// 新增 listeners
subscribe((store) => renderApp(store));
renderApp(store);
dispatch({ type: 'BODY_TEXT', text: '我是呼叫 dispatch 修改的 body' });
複製程式碼

這樣,我們每次呼叫 dispatch 時,頁面就會重新重新整理。如果我們不想重新整理頁面,只想 alert 一句話,只需要更改新增的 listeners 就好了:

subscribe((store) => alert('頁面重新整理了'));
renderApp(store);
dispatch({ type: 'BODY_TEXT', text: '我是呼叫 dispatch 修改的 body' });
複製程式碼

這樣我們就保證了 createStore 的通用性。

四、優化

到這裡,我們似乎已經實現了之前想達到的效果:我們實現了一個全域性公用的 store , 而且這個 store 的修改是經過嚴格把控的,並且每次通過 dispatch 修改 store 後,都可以完成頁面的自動重新整理。
可是,顯然這樣並不足夠,以上的程式碼仍有些簡陋,存在嚴重的效能問題,我們在 render 函式中列印日誌可以看到:

react 知識梳理(二):手寫一個自己的 redux
雖然我們只是修改了 body 的文案,可是,在頁面重新渲染時,head 也被再次渲染。那麼,我們是不是可以在頁面渲染的時候,來對比新舊兩個 store 來感知哪些部分需要重新渲染,哪些部分不必再次渲染呢?
根據上面的想法,我們再次來修改我們的程式碼:

# storeChange.js
export const storeChange = (store, action) => {
  switch (action.type) {
    case 'HEAD_COLOR':
      return { 
        ...store,  
        head: { 
          ...store.head, 
          color: action.color 
        }
      }
    case 'BODY_TEXT':
      return { 
        ...store,
        body: {
          ...store.body,
          text: action.text
        }
      }
    default:
      return { ...store }
  }
}

# createStore.js
export const createStore = (state, storeChange) => {
  const listeners = [];
  let store = state || {};
  const subscribe = (listen) => listeners.push(listen);
  const dispatch = (action) => {
    const newStore = storeChange(store, action);
    listeners.forEach(item => {
      item(newStore, store);
    })
    store = newStore; 
  };
  return { store, dispatch, subscribe }
}

# index.js
import { state } from './redux/state.js';
import { storeChange } from './redux/storeChange.js';
import { createStore } from './redux/createStore.js';
const { store, dispatch, subscribe } = createStore(state, storeChange);
  
function renderHead (state){
  console.log('render head');
  const head = document.getElementById('head')
  head.innerText = state.text;
  head.style.color = state.color;
}
function renderBody (state){
  console.log('render body');
  const body = document.getElementById('body')
  body.innerText = state.text;
  body.style.color = state.color;
}

function renderApp (store, oldStore={}){
  if(store === oldStore) return; 
  store.head !== oldStore.head && renderHead(store.head);  
  store.body !== oldStore.body && renderBody(store.body);  
  console.log('render app',store, oldStore);
}
// 首次渲染
subscribe((store, oldStore) => renderApp(store, oldStore));
renderApp(store);
dispatch({ type: 'BODY_TEXT', text: '我是呼叫 dispatch 修改的 body' });
複製程式碼

以上,我們修改了 storeChange ,讓他不再直接修改原來的 store,而是通過計算,返回一個新的 store 。我們又修改了 cearteStore 讓他接收 storeChange 返回的新 store ,在 dispatch 修改資料並且頁面重新整理後,把新 store 賦值給之前的 store 。而在頁面重新整理時,我們來通過比較 newStore 和 oldStore ,感知需要重新渲染的部分,完成一些效能上的優化。
重新開啟控制檯,我們可以看到,在我們修改 body 時,head 並沒有重新渲染:

react 知識梳理(二):手寫一個自己的 redux

最後

我們通過簡單的程式碼例子,簡單瞭解下 redux,雖然程式碼仍有些簡陋,可是我們已經實現了 redux 的幾個核心理念:

  • 應用中的所有state都以一個object tree的形式儲存在一個單一的store中。
  • 唯一能改store的方法是觸發action,action是動作行為的抽象。

以上,是自己對《React.js 小書》的讀後總結,限於篇幅,在下篇中,我們再來結合 react ,實現自己的 react-redux。

相關文章