用原生JS從零到一實現Redux架構


前言

  最近利用業余時間閱讀了胡子大哈寫的《React小書》,從基本的原理講解了React,Redux等等受益頗豐。眼過千遍不如手寫一遍,跟着作者的思路以及參考代碼可以實現基本的Demo,下面根據自己的理解和參考一些資料,用原生JS從零開始實現一個Redux架構。

一.Redux基本概念

  經常用React開發的朋友可能很熟悉Redux,React-Redux,這里告訴大家的是,Redux和React-Redux並不是一個東西,Redux是一種架構模式,2015年,Redux出現,將 Flux 與函數式編程結合一起,很短時間內就成為了最熱門的前端架構。它不關心你使用什么庫,可以把它和React,Vue或者JQuery結合。

二.由一個簡單的例子開始

  我們從一個簡單的例子開始推演,新建一個html頁面,代碼如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Make-Redux</title>
</head>
<body>
<div id="app">
  <div id="title"></div>
  <div id="content"></div>
</div>
<script>
  // 應用的狀態
  const appState = {
    title: {
      text: '這是一段標題',
      color: 'Red'
    },
    content: {
      text: '這是一段內容',
      color: 'blue'
    }
  };

  // 渲染函數
  function renderApp(appState) {
    renderTitle(appState.title);
    renderContent(appState.content);
  }

  function renderTitle(title) {
    const titleDOM = document.getElementById('title');
    titleDOM.innerHTML = title.text;
    titleDOM.style.color = title.color;
  }

  function renderContent(content) {
    const contentDOM = document.getElementById('content');
    contentDOM.innerHTML = content.text;
    contentDOM.style.color = content.color;
  }

  // 渲染數據到頁面上
  renderApp(appState);
</script>
</body>
</html>

 HTML內容很簡單,我們定義了一個appState數據對象,包括title和content屬性,各自都有text和color,然后定義了renderApp,renderTitle,renderContent渲染方法,最后執行renderApp(appState),打開頁面:

這些寫雖然沒有什么問題,但是存在一個比較大的隱患,每個人都可以修改共享狀態appState,在平時的業務開發中也很常見的一個問題是,定義了一個全局變量,其他同事在不知情的情況下可能會被覆蓋修改刪除掉,帶來的問題是函數執行的結果往往是不可預料的,出現問題的時候調試起來非常困難。

那我們如何解決這個問題呢,我們可以提高修改共享數據的門檻,但是不能直接修改,只能修改我允許的某些修改。於是,定義一個dispatch方法,專門負責數據的修改。

function dispatch (action) {
    switch (action.type) {
      case 'UPDATE_TITLE_TEXT':
        appState.title.text = action.text;
        break;
      case 'UPDATE_TITLE_COLOR':
        appState.title.color = action.color;
        break;
      default:
        break;
    }
  }

這樣我們規定,所有歲數據的操作必須通過dispatch方法。它接受一個對象暫且叫它action,規定只能修改title的文字與顏色。這樣要想知道哪個函數修改了數據,我們直接在dispatch方法里面斷點調試就可以了。大大的提高了解決問題的效率。

三.抽離store和實現監控數據變化

  上面我們的appStore和dispatch分開的,為了使這種模式更加通用化,我們把他們集中一個地方構建一個函數createStore,用它來生產一個store對象,包含state和dispatch。

function createStore (state, stateChanger) {
    const getState = () => state;
    const dispatch = (action) => stateChanger(state, action);
    return { getState, dispatch }
  }

 我們修改之前的代碼如下:

let appState = {
    title: {
      text: '這是一段標題',
      color: 'red',
    },
    content: {
      text: '這是一段內容',
      color: 'blue'
    }
  }

  function stateChanger (state, action) {
    switch (action.type) {
      case 'UPDATE_TITLE_TEXT':
        state.title.text = action.text
        break
      case 'UPDATE_TITLE_COLOR':
        state.title.color = action.color
        break
      default:
        break
    }
  }

  const store = createStore(appState, stateChanger)
  // 首次渲染頁面
  renderApp(store.getState());
  // 修改標題文本
  store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: '換一個標題' });
  // 修改標題顏色
  store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'grey' });
   // 再次把修改后的數據渲染到頁面上
  renderApp(store.getState());

 上面代碼不難理解:我們用createStore生成了一個store,可以發現,第一個參數state就是我們之前聲明的共享數據,第二個stateChanger方法就是之前聲明的dispatch用於修改數據的方法。

然后我們調用了來兩次store.dispatch方法,最后又重新調用了renderApp再重新獲取新數據渲染了頁面,如下:可以發現title的文字和標題都改變了。

那么問題來了,我們每次dispatch修改數據的時候,都要手動的調用renderApp方法才能使頁面得以改變。我們可以把renderApp放到dispatch方法最后,這樣的話,我們的createStore不夠通用,因為其他的App不一定要執行renderApp方法,這里我們通過一種監聽數據變化,然后再重新渲染頁面,術語上講叫做觀察者模式。

我們修改createStore如下。

function createStore (state, stateChanger) {
    const listeners = []; // 空的方法數組
    // store調用一次subscribe就把傳入的listener方法push到方法數組中
    const subscribe = (listener) => listeners.push(listener); 
    const getState = () => state;
    // 當store調用dispatch的改變數據的時候遍歷listeners數組,執行其中每一個方法,到達監聽數據重新渲染頁面的效果
    const dispatch = (action) => {
      stateChanger(state, action);
      listeners.forEach((listener) => listener())
    };
    return { getState, dispatch, subscribe }
  }

 

再次修改上一部分的代碼如下:

// 首次渲染頁面
  renderApp(store.getState());
  // 監聽數據變化重新渲染頁面
  store.subscribe(()=>{
    renderApp(store.getState());
  });
  // 修改標題文本
  store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: '換一個標題' });
  // 修改標題顏色
  store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'grey' });

 我們在首次渲染頁面后只需要subscribe一次,后面dispatch修改數據,renderApp方法會被重新調用,實現了監聽數據自動渲染數據的效果。

三.生成一個共享結構的對象來提高頁面的性能

上一節我們每次調用renderApp方法的時候實際上是執行了renderTitle和renderContent方法,我們兩次都是dispatch修改的是title數據,可是renderContent方法也都被一起執行了,這樣執行了不必要的函數,有嚴重的性能問題,我們可以在幾個渲染函數上加上一些Log看看實際上是不是這樣的

 

function renderApp (appState) {
  console.log('render app...')
  ...
}
function renderTitle (title) {
  console.log('render title...')
  ...
}
function renderContent (content) {
  console.log('render content...')
 ...
}

 瀏覽器控制台打印如下:

  

 

解決方案是:我們在每個渲染函數執行之前對其傳入的數據進行一個判斷,判斷傳入的新數據和舊數據是否相同,相同就return不渲染,否則就渲染。

  // 渲染函數
  function renderApp (newAppState, oldAppState = {}) { // 防止 oldAppState 沒有傳入,所以加了默認參數 oldAppState = {}
    if (newAppState === oldAppState) return; // 數據沒有變化就不渲染了
    console.log('render app...');
    renderTitle(newAppState.title, oldAppState.title);
    renderContent(newAppState.content, oldAppState.content);
  }
  function renderTitle (newTitle, oldTitle = {}) {
    if (newTitle === oldTitle) return; // 數據沒有變化就不渲染了
    console.log('render title...');
    const titleDOM = document.getElementById('title');
    titleDOM.innerHTML = newTitle.text;
    titleDOM.style.color = newTitle.color;
  }
  function renderContent (newContent, oldContent = {}) {
    if (newContent === oldContent) return; // 數據沒有變化就不渲染了
    console.log('render content...');
    const contentDOM = document.getElementById('content')
    contentDOM.innerHTML = newContent.text;
    contentDOM.style.color = newContent.color;
  }
  ...
  let oldState = store.getState(); // 緩存舊的 state
  store.subscribe(() => {
    const newState = store.getState(); // 數據可能變化,獲取新的 state
    renderApp(newState, oldState); // 把新舊的 state 傳進去渲染
    oldState = newState // 渲染完以后,新的 newState 變成了舊的 oldState,等待下一次數據變化重新渲染
  })
...

以上代碼我們在subscribe的時候先用oldState緩存舊的state,在dispatch之后執行里面的方法再次獲取新的state然后oldState和newState傳入到renderApp中,之后再用oldState保存newState。

好,我們打開瀏覽器看下效果:

 

控制台只打印了首次渲染的幾行日志,后面兩次dispatch數據之后渲染函數都沒有執行。這說明oldState和newState相等了。

 

通過斷點調試,發現newAppState和oldAppState是相等的。

究其原因,因為對象和數組是引用類型,newState,oldState指向同一個state對象地址,在每個渲染函數判斷始終相等,就return了。

解決方法:appState和newState其實是兩個不同的對象,我們利用ES6語法來淺復制appState對象,當執行dispatch方法的時候,用一個新對象覆蓋原來title里面內容,其余的屬性值保持不變。形成一個共享數據對象,可以參考以下一個demo:

我們修改stateChanger讓它修改數據的時候,並不會直接修改原來的數據 state,而是產生上述的共享結構的對象:

function stateChanger (state, action) {
    switch (action.type) {
      case 'UPDATE_TITLE_TEXT':
        return { // 構建新的對象並且返回
          ...state,
          title: {
            ...state.title,
            text: action.text
          }
        }
      case 'UPDATE_TITLE_COLOR':
        return { // 構建新的對象並且返回
          ...state,
          title: {
            ...state.title,
            color: action.color
          }
        }
      default:
        return state // 沒有修改,返回原來的對象
    }
  }

因為stateChanger不會修改原來的對象了,而是返回一個對象,所以修改createStore里面的dispatch方法,執行stateChanger(state,action)的返回值來覆蓋原來的state,這樣在subscribe執行傳入的方法在dispatch調用時,newState就是stateChanger()返回的結果。

function createStore (state, stateChanger) {
    ...
    const dispatch = (action) => {
      state=stateChanger(state, action);
      listeners.forEach((listener) => listener())
    };
    return { getState, dispatch, subscribe }
  }

 再次運行代碼打開瀏覽器:

發現后兩次store.dispatch導致的content重新渲染不存在了,優化了性能。

 四.通用化Reducer

appState是可以合並到一起的

function stateChanger (state, action) {
    if(state){
      return {
        title: {
          text: '這是一個標題',
          color: 'Red'
        },
        content: {
          text: '這是一段內容',
          color: 'blue'
        }
      }
    }
    switch (action.type) {
      case 'UPDATE_TITLE_TEXT':
        return { // 構建新的對象並且返回
          ...state,
          title: {
            ...state.title,
            text: action.text
          }
        }
      case 'UPDATE_TITLE_COLOR':
        return { // 構建新的對象並且返回
          ...state,
          title: {
            ...state.title,
            color: action.color
          }
        }
      default:
        return state // 沒有修改,返回原來的對象
    }
  }

 再修改createStore方法:

function createStore (stateChanger) {
    let state = null;
    const listeners = []; // 空的方法數組
    // store調用一次subscribe就把傳入的listener方法push到方法數組中
    const subscribe = (listener) => listeners.push(listener);
    const getState = () => state;
    // 當store調用dispatch的改變數據的時候遍歷listeners數組,執行其中每一個方法,到達監聽數據重新渲染頁面的效果
    const dispatch = (action) => {
      state=stateChanger(state, action);
      listeners.forEach((listener) => listener())
    };
    dispatch({}); //初始化state
    return { getState, dispatch, subscribe }
  }

初始化一個局部變量state=null,最后手動調用一次dispatch({})來初始化數據。

stateChanger這個函數也可以叫通用的名字:reducer。為什么叫reducer? 參考阮一峰的《redux基本用法》里面對reducder的講解;

五:Redux總結

以上是根據閱讀《React.js小書》再次復盤,通過以上我們由一個簡單的例子引入用原生JS能大概的從零到一完成了Redux,具體的使用步驟如下:

// 定一個 reducer
function reducer (state, action) {
/* 初始化 state 和 switch case */
}
// 生成 store
const store = createStore(reducer)
// 監聽數據變化重新渲染頁面
store.subscribe(() => renderApp(store.getState()))
// 首次渲染頁面
renderApp(store.getState())
// 后面可以隨意 dispatch 了,頁面自動更新
store.dispatch(...)

按照定義reducer->生成store->監聽數據變化->dispatch頁面自動更新。

下面兩幅圖也能很好表達出Redux的工作流程

使用Redux遵循的三大原則:

1.唯一的數據源store

2.保持狀態的store只讀,不能直接修改應用狀態

3.應用狀態的修改通過純函數Reducer完成

當然不是每個項目都要使用Redux,一些小心共享數據較少的沒必要使用Redux,視項目大小復雜度而定,具體什么時候使用?引用一句話:當你不確定是否使用Redux的時候,那就不要用Redux。

項目完整代碼地址make-redux

六.寫在最后

  每一個工具或框架都是在一定的條件下為了解決某種問題產生的,在閱讀幾遍《React.js》小書之后,終於對React,Redux等一些基本原理有了一些了解,深感作為一個coder,不能只CV,記憶一些框架API會用就行,知其然不可,更要知其所以然,這樣我們在完成項目才能更好的優化又能,是代碼寫的更加優雅。有什么錯誤的地方,敬請指正,技術想要有質的飛躍,就要多學習,多思考,多實踐,與君共勉。

 


 

參考資料:

1.《React.js小書》-胡子大哈

2.React進階之路-徐超

3.Redux 入門教程(一):基本用法-阮一峰

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM