[譯] 從零開始,在 Redux 中構建時間旅行式除錯

LeviDing發表於2018-06-16

在這篇教程中,我們將從零開始一步步構建時間旅行式除錯。我們會先介紹 Redux 的核心特性,及這些特性怎麼讓時間旅行式除錯這種強大功能成為可能。接著我們會用原生 JavaScript 來構建一個 Redux 核心庫以及實現時間旅行式除錯,並將它應用到一個簡單的不含 React 的 HTML 應用裡面去。

[譯] 從零開始,在 Redux 中構建時間旅行式除錯

使用 Redux 進行時間旅行的基礎

時間旅行式除錯指的是讓你的應用程式狀態(state)向前走和向後退的能力,這就使得開發者可以確切地瞭解應用在其生命週期的每一點上發生了什麼。

Redux 是使用單向資料流的 flux 模式的一個擴充。Redux 在 flux 的思路體系上額外加入了 3 條準則。

  1. 唯一的狀態來源。應用程式的全部狀態都儲存在一個 JavaScript 物件裡面。
  2. 狀態是隻讀的。這就是不可變的概念了。狀態是永遠不能被修改的,不過每一個動作(action)都會產生一個全新的狀態物件,然後用它來替換掉舊的(狀態物件)。
  3. 由純函式來產生修改。這意味著任何時候生成一個新的狀態,都不會產生其他的副作用。

Redux 應用程式的狀態是在一個線性的可預測的時間線上生成的,藉助這個概念,時間旅行式除錯進一步擴充,將觸發的每一個動作(action)所產生的狀態樹都做了一個副本儲存下來。

UI 介面可以被當做是 Redux 狀態的一個純函式(譯者注:純函式意味著輸入確定的 Redux 狀態肯定產生確定的 UI 介面)。時間旅行允許我們給應用程式狀態設定一個特定的值,從而在那些條件下產生一個準確的 UI 介面。這種應用程式的視覺化和透明化的能力對開發者來說是極為有用的,可以幫他們透徹地理解應用程式裡面發生了什麼,並顯著地減少除錯程式耗費的精力。

使用 Redux 和時間旅行式除錯搭建一個簡單的應用

我們接下來會搭建一個簡單的 HTML 應用,它會在每次點選的時候產生一個隨機的背景顏色並使用 Redux 將顏色的 RGB 值存下來。我們還會建立一個時間旅行擴充,它可以幫我們回放應用程式的每一個狀態,並讓我們視覺化地看到每一步的背景色變化。

搭建 Redux 核心庫

如果你對搭建時間旅行式除錯感興趣,那我將預設你已熟練掌握 Redux。如果你是 Redux 的新手或者需要對 store 和 reducer 這些概念重溫一下,那建議在接下去的詳細講解前閱讀下這篇文章。在這部分教程中,你將一步步搭建 createStore 和 reducer。

Redux 核心庫就是這個 createStore 函式。Redux 的 store 管理著狀態物件(這個狀態物件代表著應用的全域性狀態)並暴露出必要的介面供讀取和更新狀態。呼叫 createStore 會初始化狀態並返回一個包含 getState()subscribe()dispatch() 等方法的物件。

createStore 函式接受一個 reducer 函式作為必要引數,並接受一個 initialState 作為可選引數。整個 createStore 如下文所示(不可思議的簡短,對吧?):

const createStore = (reducer, initialState) => {
  const store = {};
  store.state = initialState;
  store.listeners = [];
  
  store.getState = () => store.state;
  
  store.subscribe = (listener) => {
    store.listeners.push(listener);
  };
  
  store.dispatch = (action) => {
    store.state = reducer(store.state, action);
    store.listeners.forEach(listener => listener());
  };
  
  return store;
};
複製程式碼

實現時間旅行式除錯

我們將對 Redux 的 store 實現一個新的監聽,並擴充 store 的能力,從而實現時間旅行功能。狀態的每一次改變都將被新增到一個陣列裡,對於應用狀態的每次改變都會給我們一個同步表現。為了清晰起見,我們將把這個狀態的列表列印到 DOM 節點裡面。

首先,我們會對時間軸和歷史中處於活動態的狀態索引進行初始化(第1、2行)。我們還會建立一個 savetimeline 函式,它會將當前狀態新增到時間軸陣列,將狀態列印到 DOM 節點上,並對程式用來渲染的指定狀態樹的索引進行遞增。為了確保我們捕捉到每一次狀態變化,我們將 saveTimeline 函式作為 Redux store 的一個監聽者實施訂閱。

const timeline = [];
let activeItem = 0;

const saveTimeline = () => {
  timeline.push(store.getState());
  timelineNode.innerHTML = timeline
    .map(item => JSON.stringify(item))
    .join('<br/>');
  activeItem = timeline.length - 1;
};

store.subscribe(saveTimeline);
複製程式碼

接著我們在 store 中新增一個新的函式 —— setState。它允許我們向 Redux 的 store 中注入任何狀態值。當我們要通過一個 DOM 上的按鈕(下一節建立)在不同的狀態間進行穿梭時,這個函式就會被呼叫。下面就是 store 裡面這個 setState 函式的實現:

// 僅供除錯
store.setState = desiredState => {
  store.state = desiredState;

  // 假設偵錯程式(譯者注:上文的 saveTimeline )是最後被注入(到 store.listeners )的,
  // 我們並不想在除錯時更新 timeline 中已儲存的狀態,所以我們把它排除掉。
  const applicationListeners = store.listeners.slice(0, -1);
  applicationListeners.forEach(listener => listener());
};
複製程式碼

謹記,我們這麼做僅為了方便學習。僅在此場景下你可以直接擴充 Redux 的 store 或直接設定狀態。

當我們在下一節建立好整個應用,我們也就同時把 DOM 節點給建立好了。現在,你只要知道將會有一個“向前走”和一個“向後走”的按鈕來用來進行時間旅行。這兩個按鈕將更新狀態時間軸的活動索引(從而改變用來展示的活動狀態),允許我們在不同的狀態變化間輕鬆地前進和後退。下面程式碼將告訴你怎麼註冊事件監聽來穿梭時間軸:

const previous = document.getElementById('previous');
const next = document.getElementById('next');

previous.addEventListener('click', e => {
  e.preventDefault();
  e.stopPropagation();

  let index = activeItem - 1;
  index = index <= 0 ? 0 : index;
  activeItem = index;

  const desiredState = timeline[index];
  store.setState(desiredState);
});

next.addEventListener('click', e => {
  e.preventDefault();
  e.stopPropagation();

  let index = activeItem + 1;
  index = index >= timeline.length - 1 ? 
    timeline.length - 1 :   index;
  activeItem = index;

  const desiredState = timeline[index];
  store.setState(desiredState);
});
複製程式碼

綜合起來,可以得到下面的程式碼來建立時間旅行式除錯。

const timeline = [];
let activeItem = 0;

const saveTimeline = () => {
  timeline.push(store.getState());
  timelineNode.innerHTML = timeline
    .map(item => JSON.stringify(item))
    .join('<br/>');
  activeItem = timeline.length - 1;
};

store.subscribe(saveTimeline);

// 僅供除錯
// store 不應該像這樣進行擴充。
store.setState = desiredState => {
  store.state = desiredState;

  // 假設偵錯程式(譯者注:上文的 saveTimeline )是最後被注入(到 store.listeners )的,
  // 我們並不想在除錯時更新 timeline 中已儲存的狀態,所以我們把它排除掉。
  const applicationListeners = store.listeners.slice(0, -1);
  applicationListeners.forEach(listener => listener());
};

// 這裡假定通過這兩個 ID 就可以拿到向前走、向後走兩個按鈕,用以控制時間旅行
const previous = document.getElementById('previous');
const next = document.getElementById('next');

previous.addEventListener('click', e => {
  e.preventDefault();
  e.stopPropagation();

  let index = activeItem - 1;
  index = index <= 0 ? 0 : index;
  activeItem = index;

  const desiredState = timeline[index];
  store.setState(desiredState);
});

next.addEventListener('click', e => {
  e.preventDefault();
  e.stopPropagation();

  let index = activeItem + 1;
  index = index >= timeline.length - 1 ? timeline.length - 1 : index;
  activeItem = index;

  const desiredState = timeline[index];
  store.setState(desiredState);
});
複製程式碼

搭建一個含時間旅行式除錯的應用程式

現在我們開始建立視覺上的效果來理解時間旅行式除錯。我們在 document 的 body 上新增事件監聽,事件觸發時會建立三個 0-255 間的隨機數,並分別作為 RGB 值存到 Redux 的 store 裡面。將會有一個 store 的訂閱函式來更新頁面背景色並把當前 RGB 色值展現在螢幕上。另外,我們的時間旅行式除錯會對狀態變化進行訂閱,把每個變化記錄到時間軸裡。

我們以下面的程式碼來初始化 HTML 文件並開始我們的工作。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
  </head>
  <body>
    <div>My background color is <span id="background"></span></div>
    <div id="debugger">
      <div>
        <button id="previous">
          previous
        </button>
        <button id="next">
          next
        </button>
      </div>
      <div id="timeline"></div>
    </div>
    <style>
      html, body {
        width: 100vw;
        height: 100vh;
      }

    #debugger {
        margin-top: 30px;
      }
    </style>
    <script>
      // 應用邏輯將會被新增到這裡……
    </script>
  </body>
</html>
複製程式碼

注意我們還建立了一個 <div> 用於除錯。裡面有用於不同狀態間穿梭的按鈕,還有一個用來列舉狀態每一次變化的 DOM 節點。

在 JavaScript 裡,我們先引用 DOM 節點,引入 createStore

const textNode = document.getElementById('background');
const timelineNode = document.getElementById('timeline');

const createStore = (reducer, initialState) => {
  const store = {};
  store.state = initialState;
  store.listeners = [];

  store.getState = () => store.state;

  store.subscribe = listener => {
    store.listeners.push(listener);
  };

  store.dispatch = action => {
    console.log('> Action', action);
    store.state = reducer(store.state, action);
    store.listeners.forEach(listener => listener());
  };

  return store;
};
複製程式碼

接著,我們建立一個用於跟蹤 RGB 色值變化的 reducer 並初始化 store。初始狀態將是白色背景。

const getInitialState = () => {
  return {
    r: 255,
    g: 255,
    b: 255,
  };
};

const reducer = (state = getInitialState(), action) => {
  switch (action.type) {
    case 'SET_RGB':
      return {
        r: action.payload.r,
        g: action.payload.g,
        b: action.payload.b,
      };
    default:
      return state;
  }
};

const store = createStore(reducer);
複製程式碼

現在我們對 store 新增訂閱函式,用於設定頁面背景色並把文字形式的 RGB 色值新增到 DOM 節點上。這會讓狀態的每一個變化都可以在我們的 UI 介面上表現出來。

const setBackgroundColor = () => {
  const state = store.getState();
  const { r, g, b } = state;
  const rgb = `rgb(${r}, ${g}, ${b})`;

  document.body.style.backgroundColor = rgb;
  textNode.innerHTML = rgb;
};

store.subscribe(setBackgroundColor);
複製程式碼

最後我們新增一個函式用於生成 0-255 間的隨機數,並加上一個 onClick 的事件監聽,事件觸發時將新的 RGB 值派發(dispatch)到 store 裡面。

const generateRandomColor = () => {
  return Math.floor(Math.random() * 255);
};

// 一個簡單的事件用於派發資料變化
document.addEventListener('click', () => {
  console.log('----- Previous state', store.getState());
  store.dispatch({
    type: 'SET_RGB',
    payload: {
      r: generateRandomColor(),
      g: generateRandomColor(),
      b: generateRandomColor(),
    },
  });
  console.log('+++++ New state', store.getState());
});
複製程式碼

這就是我們所有的程式邏輯了。我們將上一節的時間旅行程式碼新增到後面,並在 script 標籤的最後面呼叫 store.dispatch({}) 來產生初始狀態。

[譯] 從零開始,在 Redux 中構建時間旅行式除錯

下面是應用程式的完整程式碼。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
  </head>
  <body>
    <div>My background color is <span id="background"></span></div>
    <div id="debugger">
      <div>
        <button id="previous">
          previous
        </button>
        <button id="next">
          next
        </button>
      </div>
      <div id="timeline"></div>
    </div>
    <style>
      html, body {
        width: 100vw;
        height: 100vh;
      }
      #debugger {
        margin-top: 30px;
      }
    </style>
    <script>
      const textNode = document.getElementById('background');
      const timelineNode = document.getElementById('timeline');
      const createStore = (reducer, initialState) => {
        const store = {};
        store.state = initialState;
        store.listeners = [];
        store.getState = () => store.state;
        store.subscribe = listener => {
          store.listeners.push(listener);
        };
        store.dispatch = action => {
          console.log('> Action', action);
          store.state = reducer(store.state, action);
          store.listeners.forEach(listener => listener());
        };
        return store;
      };
      const getInitialState = () => {
        return {
          r: 255,
          g: 255,
          b: 255,
        };
      };
      const reducer = (state = getInitialState(), action) => {
        switch (action.type) {
          case 'SET_RGB':
            return {
              r: action.payload.r,
              g: action.payload.g,
              b: action.payload.b,
            };
          default:
            return state;
        }
      };
      const store = createStore(reducer);
      const setBackgroundColor = () => {
        const state = store.getState();
        const { r, g, b } = state;
        const rgb = `rgb(${r}, ${g}, ${b})`;
        document.body.style.backgroundColor = rgb;
        textNode.innerHTML = rgb;
      };
      store.subscribe(setBackgroundColor);
      const generateRandomColor = () => {
        return Math.floor(Math.random() * 255);
      };
      // 一個簡單的事件用於派發資料變化
      document.addEventListener('click', () => {
        console.log('----- Previous state', store.getState());
        store.dispatch({
          type: 'SET_RGB',
          payload: {
            r: generateRandomColor(),
            g: generateRandomColor(),
            b: generateRandomColor(),
          },
        });
        console.log('+++++ New state', store.getState());
      });
      const timeline = [];
      let activeItem = 0;
      const saveTimeline = () => {
        timeline.push(store.getState());
        timelineNode.innerHTML = timeline
          .map(item => JSON.stringify(item))
          .join('<br/>');
        activeItem = timeline.length - 1;
      };
      store.subscribe(saveTimeline);
      // 僅供除錯
      store.setState = desiredState => {
        store.state = desiredState;
        // 假設偵錯程式(譯者注:上文的 saveTimeline )是最後被注入(到 store.listeners )的,
        // 我們並不想在除錯時更新 timeline 中已儲存的狀態,所以我們把它排除掉。
        const applicationListeners = store.listeners.slice(0, -1);
        applicationListeners.forEach(listener => listener());
      };
      const previous = document.getElementById('previous');
      const next = document.getElementById('next');
      previous.addEventListener('click', e => {
        e.preventDefault();
        e.stopPropagation();
        let index = activeItem - 1;
        index = index <= 0 ? 0 : index;
        activeItem = index;
        const desiredState = timeline[index];
        store.setState(desiredState);
      });
      next.addEventListener('click', e => {
        e.preventDefault();
        e.stopPropagation();
        let index = activeItem + 1;
        index = index >= timeline.length - 1 ? timeline.length - 1 : index;
        activeItem = index;
        const desiredState = timeline[index];
        store.setState(desiredState);
      });
      store.dispatch({}); // 設定初始狀態
    </script>
  </body>
</html>
複製程式碼

總結

我們的時間旅行式除錯的教學示範實現向我們展現了 Redux 的核心準則。我們可以毫不費勁地跟蹤我們應用程式中不斷變化的狀態,便於除錯和了解正在發生的事情。


如果你覺得本文有用,請點選 ❤。訂閱我 可以看到更多關於 blockchain、React、Node.js、JavaScript 和開源軟體的文章!你也可以在 Twittergitconnected 上找到我。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章