היכרות עם הספריה Immer

01/10/2018

בתחילת השנה מייקל וסטרט התחיל לפרסם ספריית קוד פתוח חדשה לעבודה עם Immutable Data Structures. הספריה נקראת Immer ומאז הפכה לפיתרון יציב ואף זכתה לברכות מדן אברמוב. בואו ניקח כמה דוגמאות כדי להבין מהי ובמה היא טובה יותר מ Immutable.JS הוותיקה.

1. הבעיה עם Immutable

הספריה שכולם משתמשים בה כדי לנהל Immutable Data Structures נקראת Immutable.JS וכמו ריאקט גם היא נכתבה בפייסבוק. אבל Immutable אף פעם לא היתה ספריה נוחה במיוחד לשימוש- הבעיה המרכזית איתה שהיא מכריחה אותנו ללמוד ולהשתמש בממשק ייחודי לה ולכן כמתכנתים קשה לנו להשתמש בידע שכבר צברנו ב JavaScript וצריך ללמוד הכל מחדש.

ניזכר שבקוד JavaScript רגיל היינו יכולים להגדיר את המבנה הבא יחסית בקלות:

const state = {
    items: [
        { id: 1, text: 'demo item', done: true },
        { id: 2, text: 'another item', done: false },
    ],
    filter: 'demo',
};

ואם נניח אתם רוצים לעדכן שפריט מספר 2 הושלם נכתוב את הקוד:

state.items.find(item => item.id === 2).done = true;

זה קוד שקל מאוד לכתוב אותו, ברור מה הוא עושה והבעיה היחידה איתו שהוא משנה את מבנה הנתונים.

נשווה את זה לקוד Immutable.JS, שם בשביל ליצור את האוביקט נצטרך להוסיף את השורה:

const immutableState = Immutable.fromJS(state);

אבל מה שבאמת נורא שבשביל לבצע את אותו השינוי נצטרך להשתמש בקוד אחר לגמרי ולא בטוח שהכי אינטואיטיבי:

const nextState = immutableState.update('items' => items.map(item => item.id === 2 ? item.set('done', true) : item));

השימוש Immutable.JS מצריך מחשבה שונה ושימוש ב APIs שונים מאשר עבודה על מבני נתונים רגילים של JavaScript ולכן מרגע שהקוד שלכם משתמש בספריה אתם "תקועים" איתה ובנוסף למתכנתים חדשים שלא מכירים את הספריה לוקח זמן להבין איך לעבוד איתה נכון.

ספריית Immer שמה לה למטרה לשנות את זה באמצעות פיתוח API שיאפשר שימוש חוזר בידע הקיים שלנו וכניסה יותר קלה של מתכנתים חדשים לפרויקט.

ב Immer אנחנו עובדים עם אוביקטי JavaScript רגילים בשלב הקריאה, ובשלב הכתיבה משתמשים בפונקציה מיוחדת של Immer שנקראת produce כדי לקבל Proxy לאוביקט וכך עדיין יכולים לעבוד עם משהו שמתנהג כמעט כמו אוביקט JavaScript רגיל. את אותה דוגמא נוכל לכתוב ב Immer באופן הבא:

const state = {
    items: [
        { id: 1, text: 'demo item', done: true },
        { id: 2, text: 'another item', done: false },
    ],
    filter: 'demo',
};

const nextState = produce(state, draft => {
    draft.items.find(item => item.id === 2).done = true;
});

// prints: false
console.log(state.items[1].done);

// prints: true
console.log(nextState.items[1].done);

// prints: true
console.log(state.items[0] === nextState.items[0]);

במילים אחרות ה API של Immer בנוי על הרעיון שבמקום לשנות את אופן הגישה למידע עדיף לשנות את מועד הגישה למידע. גישה למידע מתוך הפונקציה produce תאפשר כתיבה ויצירה של אוביקט מידע חדש שגם הוא Immutable.

2. דוגמא 1: פקד פשוט

בעולם של ריאקט קל מאוד להשתמש ב Immer בגלל שה API שלה מאוד דומה לזה שכבר קיים לפקדים. נתחיל עם פקד פשוט בעל שדה מידע יחיד ב State. קוד הפקד:

import produce from 'immer';

class App extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = { clicks: 0 };
    this.inc = this.inc.bind(this);
  }

  inc() {
    this.setState(produce(draft => {
      draft.clicks++;
    }));
  }

  render() {
    return (
      <div>
        <p>Clicks: {this.state.clicks}
          <button onClick={this.inc}>Click Here</button>
        </p>  
      </div>
    )
  }
}

ReactDOM.render(<App />, document.querySelector('main'));

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

נתמקד בפונקציה שנקראת כשלוחצים על הכפתור:

  inc() {
    this.setState(produce(draft => {
      draft.clicks++;
    }));
  }

זה נראה ממש כמו קריאה ל setState רגיל של React אבל בזכות התוספת של produce אפשר להשתמש ב ++ ועדיין לקבל אוביקט חדש ב setState.

שימו לב גם שהפעם produce קיבלה רק פרמטר יחיד, פונקציית הייצור, בניגוד לדוגמא הקודמת שם העברנו לפני הפונקציה את האוביקט המקורי. בהפעלה כזו עם הפונקציה בתור פרמטר ראשון הפונקציה produce מחזירה פונקציה שמקבלת את ה state הישן ומחזירה state חדש. זה אומר שאנחנו בעצם מפעילים כאן setState על פונקציה ולא על אוביקט וכך נהנים מהבטחת סדר פעולות העדכון.

3. דוגמא 2: פקד יחיד עם State מורכב

ככל שהסטייט יותר מורכב כך הקוד יותר מעניין. הנה דוגמא נוספת הפעם מתוך משחק Advent Of Code שאנחנו מריצים בפורום. השורה מתארת רשימה של "טרמפולינות" והסוגריים העגולים מציינים את המקום הנוכחי של השחקן. בכל סיבוב השחקן קופץ לפי המספר שמופיע בטרמפולינה והערך בטרמפולינה עולה ב-1. השאלה היא כמה לחיצות צריך כדי לצאת מהמבוך. הנה הקוד בקודפן:

נתמקד שוב בפונקציה שנקראת בעת לחיצה על הכפתור:

  inc() {
    this.setState(produce(draft => {
      if (draft.index < draft.trampolines.length) {
        draft.index += draft.trampolines[draft.index]++;
        draft.rounds++;
      }
    }));
  }

בהשוואה ל Immutable.JS הקוד שהודבק הוא פינוק אמיתי. אנחנו עובדים עם JavaScript רגיל לגמרי ובזכות הקריאה ל produce הכל מסתדר ומקבלים אוביקט Immutable חדש. הפקד שלנו יורש מ React.PureComponent כך שקיבלנו במתנה את shouldComponentUpdate, כל המידע הוא Immutable למרות השימוש במערכים והכי חשוב אפשר לחזור להשתמש בידע הקיים שלכם מ JavaScript.

4. דוגמא 3: שילוב Immer עם Redux

בשביל הדוגמא האחרונה רציתי לראות איך Immer מסתדר יחד עם Redux, והתשובה כמובן היא כמו כפפה ליד. לפונקציה produce יש עוד שני קסמים בשרוול שאפשר לראות בקטע הקוד הבא:

const reducer = produce((draft, action) => {
  switch(action.type) {
    case '@@play':
      return play(draft, action.payload.i, action.payload.j);

    case '@@newgame':
      return newGame(draft, action.payload.p1Name, action.payload.p2Name);         
  }
}, initialState);

הקסם הראשון הוא שהפונקציה שמועברת כפרמטר יכולה לקבל פרמטרים נוספים אחרי שם האוביקט, ואז היא תחזיר פונקציה שמצפה לפרמטרים אלה. בעצם הכתיב שהודבק למעלה מחזיר פונקציה שמקבלת שני פרמטרים, הראשון הוא ה state הישן והשני הוא ה action. בתוך גוף הפונקציה כל הפעולות על draft נרשמות וכל נושא ה Immutable מטופל אוטומטית בשבילנו (הם משתמשים ב ES6 Proxy לאלה מכם שסקרנים לדעת איך זה עובד).

הקסם השני הוא שאם מעבירים אוביקט בתור פרמטר שני האוביקט הזה יהיה הערך הראשוני שיחזור אם הפונקציה קיבלה state ריק.

שילוב שני הקסמים האלה מייצר קוד הרבה יותר נקי עבור Reducer שעדיין עושה בדיוק את אותה עבודה כמו Reducer שבנוי עם Immutable. הפונקציה הפנימית play בכלל לא צריכה לדעת שהיא עובדת על Immutable Data:

function play(draft, i, j) {
  const currentPlayer = draft.currentPlayer;
  const nextPlayer = (currentPlayer === 'X' ? 'O' : 'X');

  if (draft.board[i][j] !== ' ') {
    return draft;
  }

  draft.board[i][j] = currentPlayer;
  draft.currentPlayer = nextPlayer;

  return draft;
}

תשוו את זה למימוש מקביל עם Immutable.JS:

function play(state, i, j) {
  const currentPlayer = state.get('currentPlayer');
  const nextPlayer = (currentPlayer === 'X' ? 'O' : 'X');

  if (state.getIn(['board', i, j]) !== ' ') {
    return state;
  }

  return state.
    setIn(['board', i, j], currentPlayer).
    set('currentPlayer', nextPlayer);
}

וקל לראות את היתרונות של Immer: אין צורך ללמוד ספריה חדשה כדי להבין מה קורה, קל לתקן קוד של אחרים והכי חשוב קל להחליף ולהשתמש גם בקוד שלא נכתב עבור Immutable Data.

בקודפן הבא תמצאו משחק איקס עיגול מלא שכתבתי עם Immer ו Redux:

לקח לי עשר דקות להבין את ההיגיון של Immer ולכתוב תוכניות מועילות איתה. אני מקווה שזה גם הזמן שייקח לכם לקרוא את הפוסט ולראות את היופי בספריה הזו וב API הנקי שלה לעומת Immutable.JS.