איך לצמצם Boilerplate בקוד Redux - חלק 2

03/08/2020

בחלק הקודם בסידרה הראיתי איך לצמצם Boilerplate בקוד של ה Action Creator ב Redux. היום פוסט המשך בו נטפל ב Reducer ונראה איך לצמצם חלק גדול מה Boilerplate והכיעור של רוב ה Reducers במערכת.

1. על מה אנחנו מדברים כשאנחנו אומרים Boilerplate

קוד ה Reducer מתוך דוגמת ה Todos בתיעוד, שלצערי הרבה אנשים לוקחים אותו בתור תבנית לאיך Redux צריך להיראות, נראה כך:

const todos = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ]
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    default:
      return state
  }
}

export default todos

המאפיין הבולט של רוב ה Reducers הוא מבנה של switch/case בתוך פונקציה. הפונקציה בודקת מה סוג הפעולה ומחזירה סטייט חדש לפי סוג הפעולה והפרמטרים שלה.

זה מבנה יפה כשמסתכלים עליו מלמעלה אבל השטן הוא בפרטים. ככל שיש יותר פעולות פונקציה כזאת הולך להיות מאוד ארוכה ויהיה קשה לקרוא אותה. הפיתרון המוצע בתיעוד, להפריד את ה Reducer למספר Reducers, הוא לא רלוונטי וברוב המקרים רק הופך את הקוד לקשה יותר לקריאה.

אתגר נוסף בקוד הוא שכל פעם שאנחנו מוסיפים פעולה חדשה אנחנו צריכים לכתוב גם את מימוש הפעולה, אבל גם לעטוף את המימוש בעוד בלוק של case ולהשוות את שם ה Action, מה שעוד יותר מאריך את הפונקציה.

2. איך לכתוב פחות קוד

אבל - אם אנחנו מוכנים להתחייב על קונבנציה לשמות של פעולות אפשר לחסוך חלק גדול מהקוד הזה. הנה מה שנעשה:

  1. נחליט שכל פעולה תקבל טיפול בפונקציית עזר קטנה.

  2. שם הפונקציה ייגזר באופן צפוי וידוע מראש משם הפעולה.

  3. נכתוב את כל פונקציות הטיפול בתוך אוביקט ראשי.

  4. נכתוב פונקציה שמקבלת את אותו אוביקט ראשי ויוצרת ממנו בצורה אוטומטית Reducer, אבל במקום switch/case היא תגלה לבד את שם הפונקציה לפי ה action.type.

הקוד אחרי השינוי שלי נראה כך:

import createReducer from "./reducerUtils";

const todos = {
  addTodo(state, { id, text }) {
    return [...state, { id, text, completed: false }];
  },

  toggleTodo(state, { id }) {
    function toggle(todo) {
      return { ...todo, completed: !todo.completed };
    }

    return state.map(todo => (todo.id === id ? toggle(todo) : todo));
  }
};

export default createReducer(todos);

רוצים להוסיף פעולה חדשה? אין בעיה פשוט תוסיפו פונקציית טיפול. לא צריך להוסיף בלוק case חדש או להשוות type. דבר נוסף שאני אוהב במבנה כאן הוא ששמות הפרמטרים מופיעים בתוך הגדרת הפונקציה בזכות כתיב ה Destructuring של JavaScript. ואולי היתרון הכי גדול: אני יכול להשתמש באותו שם משתנה בכל אחת מהפונקציות בלי בעיה (בניגוד לבלוק ה switch/case שם כל בלוק case עובד על אותם משתנים כמו כל האחרים).

את רוב הקוד הגנרי העברתי לקובץ reducerUtils בו אפשר להשתמש מכל אחד מה Reducers במערכת:

function convertActionTypeToName(actionType) {
  return actionType.toLowerCase().replaceAll(/_(\w)/g, v => v[1].toUpperCase());
}

export default function createReducer(handlers) {
  return function reducer(state = [], action) {
    const key = convertActionTypeToName(action.type);
    const handler = handlers[key];
    if (handler) {
      return handler(state, action);
    } else {
      return state;
    }
  };
}

אתם יכולים לשחק עם הקוד ב Sandbox שהכנתי כאן: https://codesandbox.io/s/2020-08-reduce-redux-boilerplate-2-88dnv

בחלק הבא בסידרה אדבר על הקומפוננטות ונראה איך לצמצם קוד Boilerplate בתוך קומפוננטה המחוברת ל Redux.