ריאקט, רידאקס וטפסים דינמיים (חלק שני)

פוסט זה כולל טיפ קצר בנושא פיתוח Front End. אם אתם רוצים ללמוד יותר לעומק על פיתוח Front End מהבסיס ועד הנושאים המתקדמים תשמחו לשמוע שבניתי קורס וידאו מקיף בנושא זה הכולל מעל 50 שיעורי וידאו והמון תרגול מעשי.
למידע נוסף והצטרפות לקורס בקרו בדף קורס Front End באתר.
 

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

1. אז מה היתה הבעיה

הקוד הבעייתי היה הפונקציה הבאה מתוך קוד הטופס הגנרי:

  shouldComponentUpdate(nextProps, nextState) {
    return nextProps.model.hasChanged();
  }

מודל Backbone מסומן כ״מלוכלך״ בין קריאה ל set לקריאה ל save. אלו שני מודלים שונים של ניהול שינויים ובעצם יש כאן ערבוב בין שתי משמעויות שונות של ניהול שינויים. מבחינת Backbone המודל שונה אם בצד הלקוח הערך שלו שונה מהערך שהגיע מהשרת, אבל מבחינת React המשמעות של שינוי במודל היא שצריך לבצע שוב render.

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

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

2. שמירת מידע באמצעות Redux ו Immutable Data

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

נניח שהטופס שלנו מקבל את המידע כמשתנה בשם person, אז מימוש shouldComponentUpdate הולך להראות בערך כך:

  shouldComponentUpdate(nextProps, nextState) {
    return nextProps.person !== this.props.person;
  }

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

בפועל קוד Redux משתמש בספריה react-redux כדי לממש עבורכם באופן אוטומטי את shouldComponentUpdate ולכן אין צורך אפילו לכתוב אותה. כך נראה המימוש של טופס דינמי עם רידאקס בתור ספריית ניהול המידע, מימוש שפותר את בעיית הרינדור הכפול באמצעות שימוש ביכולות וב Best Practices של ריאקט.

קוד? ברור. נתחיל עם המודל:

// file: models/person_redux.js
import Immutable from 'immutable';
import { createStore } from 'redux';
import validate from 'validate.js';

const personSchema = {
  firstName: { presence: true },
  lastName: { presence: true },
  age: { 
    presence: true,
    numericality: {
      greaterThanOrEqualTo: 0,
      lessThanOrEqualTo: 120,
    },
  },
};

const symbols = {
  RESET: 'person-reset',
  SET_DATA: 'person-set-data',
};

const actions = {
  setData: (info) => ({ type: symbols.SET_DATA, payload: info }),
  reset: (people) => ({ type: symbols.RESET, payload: people, }),
};

const initialState = Immutable.fromJS({
  people: [],
  fields: [
      { key: 'firstName', label: 'First Name', inputType: 'text' },
      { key: 'lastName', label: 'Last Name', inputType: 'text' },
      { key: 'age', label: 'Age', inputType: 'number' },
  ],
});

function reducer(state = initialState, action) {
  switch(action.type) {
    case symbols.RESET:
      return state.set('people', Immutable.fromJS(action.payload.map(personData)));

    case symbols.SET_DATA:
      return setData(state, action.payload);

    default:
      return state;
  }
}


function personData(info) {
  const errors = validate(info, personSchema);
  return Immutable.fromJS({ info, errors, id: info.id });
}

function setData(state, payload) {
  const index = state.get('people').findIndex((item) => item.get('id') === payload.id);
  return state.setIn(['people', index], personData(payload));
}

const store = createStore(reducer);
export { actions, store };

נמשיך לתצוגת הטופס:

// file views/form_redux.jsx
import React from 'react';
import $ from 'jquery';
import {} from 'jquery-serializejson';
import { connect } from 'react-redux';
import { actions } from '../models/person_redux';

function DataForm(props) {
  function handleSubmit(ev) {
    ev.preventDefault();
    const info = Object.assign({}, $(ev.target).serializeJSON(), { id: props.id });
    props.dispatch(actions.setData(info));
  }

  function renderField(field, info, errors) {    
    const key = field.get('key');
    const hasError = (errors && errors.get(key));
    const errorMessage = hasError && errors.get(key);
    const errorCls = hasError ? "invalid" : "";

    return (<label ><span className="label-text">{field.get('label')}</span>
      <input 
        className={errorCls}
        defaultValue={info.get(key)} 
        name={key} 
        type={field.get('inputType')} 
      />

      {hasError && <span className="error-text">{errorMessage}</span>}
    </label>);
  }

  const { info, errors, fields } = props;

  console.count('item render');

  return (<form onSubmit={handleSubmit} >
    {fields.map((field) => (
      <div key={field.get('key')} className="input-field">
        {renderField(field, info, errors)}
      </div>
    ))}
    <input type="submit" value="Save" />
  </form>)
}

DataForm.propTypes = {
  info: React.PropTypes.object,
  errors: React.PropTypes.object,  
  fields: React.PropTypes.arrayOf(React.PropTypes.shape({
    inputType: React.PropTypes.string.isRequired,
    label: React.PropTypes.string.isRequired,
    key: React.PropTypes.string.isRequired,
  })).isRequired,
  dispatch: React.PropTypes.func.isRequired,
};


function mapStateToProps(state, ownProps) {
  return {
    info: ownProps.model.get('info'),
    errors: ownProps.model.get('errors'),
    fields: state.get('fields'),
    id: ownProps.model.get('id'),
  }
}

export default connect(mapStateToProps)(DataForm);

רשימת טפסים עבור כל האנשים בזכרון:

// file views/form_collection.jsx

import React from 'react';
import $ from 'jquery';
import { connect } from 'react-redux';

function CollectionView(props) {
    const { collection } = props;
    const template = React.Children.only(props.children);

    return (<div>
      {collection.map((model) => (
        React.cloneElement(template, { model, key: model.cid })
      ))};
    </div>);
}

CollectionView.propTypes = {
  collection: React.PropTypes.object.isRequired,
  children: React.PropTypes.node,
};

function mapStateToProps(state, ownProps) {
  return {
    collection: ownProps.collection,
    children: ownProps.children,
  }
}

export default connect(mapStateToProps)(CollectionView);

וכמובן הקובץ main ממנו הסיפור מתחיל:

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom';

import Form from './views/form_redux';
import { store, actions } from './models/person_redux';
import { Provider, connect } from 'react-redux';
import Collection from './views/form_collection';


const people = [
  { id: 0, firstName: 'John', lastName: 'Doe', age: 7},
  { id: 1, firstName: 'Jane', lastName: 'Dane', age: 5},
  { id: 2, firstName: 'Bill', lastName: 'Mace', age: 10},
  { id: 3, firstName: 'Brad', lastName: 'Shoe', age: 7},
  { id: 4, firstName: 'Jade', lastName: 'Nim', age: 7},
];

window.people = people;
store.dispatch(actions.reset(people));

function mapStateToProps(state) {
  return { people: state.get('people') };
}

const App = connect(mapStateToProps)(function(props) {
  return <div>
    <h1>Person Form Demo</h1>
    <Collection collection={props.people}>
      <Form />
    </Collection>
    </div>;
});

ReactDOM.render(<Provider store={store}><App /></Provider>, document.querySelector('main'));