• בלוג
  • עמוד 256
  • כל מה שאתם צריכים לדעת על טפסים ו React: חלק ראשון

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

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

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

אין ספרית טפסים לריאקט כי ריאקט היא ספרית Views ואחסון המידע ב View הוא רעיון רע. במקום זה בריאקט ממש פשוט לבנות ולנהל מידע בטפסים מרגע שיש לכם שכבת ניהול מידע טובה, כפי שנראה בסדרת פוסטים זו.

1. מה לא עובד

לפני שנבנה שכבת ניהול מידע חשוב לקחת רגע להבין מהו ניהול מידע ב View ולמה זה רעיון רע במערכות אמיתיות. נתבונן בקוד הטופס הבא:

לכאורה הקוד כולל את כל מה שצריך בשביל לייצר טופס ב React ואף מחבר בין המידע בשדות ל State של הפקד וכולל מנגנון Validation. אבל אל תחלמו אפילו להכניס קוד כזה למערכת אמיתית.

הערבוב בקוד בין מבנה הטופס, מנגנון ה Validation והצגת המידע יכריחו אתכם לשכפל את הקוד כמעט במלואו כשתרצו לבנות טופס נוסף, למשל עבור חיית מחמד לאותו בן אדם שהרגע הזנתם את פרטיו. פרט למקרים מאוד ממוקדים ולטפסים מאוד ייחודיים, מומלץ מאוד להפריד בין מנגנון יצירת הטופס למנגנון ניהול המידע. הפרדה זו תאפשר לכם להשתמש באותו פקד React כדי להציג טפסים שונים בעלי אותו מראה ועם מעט מאוד קוד.

2. אז מה כן? הפרדה מבנית בין מידע לתצוגה

יצירת הפרדה ברורה בין מידע לתצוגה היא המפתח לשימוש חוזר בקוד התצוגה בהקשרים שונים. נתבונן בדוגמא הבאה המשתמשת בספריית Backbone לצורך ניהול המידע (הדוגמא מבוססת קודפיקניק. כדי להריץ כתבו במסוף את הפקודה webpack-dev-server --host 0.0.0.0 ולחצו על כפתור Play שנראה כמו משולש. כדי לצפות בתוכן הקבצים פתחו אותם מהרשימה בצד שמאל):

נתבונן יחד בקוד הפעם. הקובץ הראשון להסתכל עליו הוא src/models/person_bb.jsx. הקובץ מגדיר 3 משתנים לייצוא:

export const fields = [
  { key: 'firstName', label: 'First Name', inputType: 'text' },
  { key: 'lastName', label: 'Last Name', inputType: 'text' },
  { key: 'age', label: 'Age', inputType: 'number' },
];

export const Person = Backbone.Model.extend({
  validate(attrs, options) {
    return validate(attrs, personSchema);
  },
});

export const People = Backbone.Collection.extend({
  model: Person,
});

האוביקט fields מגדיר סכימה ליצירת טופס עבור פרטי כל אוביקט במערכת, האוביקט Person אחראי על מנגנון Validation ותקשורת אם יהיה בכך צורך והאוביקט People מגדיר אוסף של אנשים לצוורך הצגתם במסך אוסף.

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

וכאן אפשר להמשיך לקובץ src/views/form_bb.jsx שהוא הקובץ המרכזי בדוגמא זו. הקובץ מתחיל בהגדרת הקלטים שפקד טופס אמור לקבל. שימו לב שאנו מגדירים את מבנה fields שמתאר את השדות בטופס וכמובן המודל ממנו נשלוף את המידע. ההנחה כאן היא שהמודל הוא Backbone Model מבחינת ניהול האירועים. בחלקים הבאים בסידרה אציג טפסים למנגנוני ניהול מידע נוספים:

export default class DataForm extends React.Component {
  static propTypes = {
    model: 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,
  };

  constructor(props) {
    super(props);
    this.state = this.stateFromModel();

    this.handleSubmit = this.handleSubmit.bind(this);
    this.setStateFromFormData = this.setStateFromFormData.bind(this);
  }

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

  componentWillMount() {
    this.props.model.on('change', this.setStateFromFormData, this);
    this.props.model.on('invalid', this.setStateFromFormData, this);
  }

  componentWillUnmount() {
    this.props.model.off(null, null, this);
  }

  setStateFromFormData() {
    this.setState(this.stateFromModel());
  }

  stateFromModel() {
    return { 
      data: this.props.model.toJSON(),
      errors: this.props.model.validationError,
    }
  }

הדרישה היחידה של פקד הטופס מהמודל היא שהוא יתנהג כמו Backbone Model, כלומר ידווח אירועים במועד הנכון ולפי השמות הנכונים, יאפשר שליפה של המידע ושל שגיאות הוולידציה דרך הפונקציות toJSON ו validationError וכן הלאה. אין כאן שום דרישה עם המידע עצמו שנמצא במודל, ולכן אפשר להשתמש בפקד זה לייצג כל סוג מידע שיש לכם במערכת.

החלק האחרון בקובץ מתיחס להצגת המידע ושמירתו באמצעות render ו handleSubmit. כאן השדות בטופס מחושבים דינמית מתוך המשתנה fields:

  handleSubmit(ev) {
    ev.preventDefault();
    this.props.model.set($(this.refs.el).serializeJSON(), { validate: true });
  }

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

  renderField(field, data, errors) {
    const hasError = (errors && errors[field.key]);
    const errorMessage = hasError && errors[field.key];
    const errorCls = hasError ? "invalid" : "";

    return (<label ><span className="label-text">{field.label}</span>
      <input 
        className={errorCls}
        defaultValue={data[field.key]} 
        name={field.key} 
        type={field.inputType} 
      />

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

  render() {
    const { data, errors } = this.state;
    const { fields } = this.props;
    console.count('item render');

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

3. טיפול גנרי באוספים

מערכות מידע רבות מציגות אוספים של מידע עם אפשרות לערוך כל פרטי בטופס משלו. לפעמים נרצה להציג גם מידע מחושב על כל האוסף (למשל מספר המשימות הפתוחות ביישום ניהול משימות). ריאקט עושה את זה מאוד קל לייצר פקדים גנריים לטיפול באוספים באמצעות מנגנון שנקרא Higher Order Components. הקוד נמצא בקובץ src/views/collection_hoc.jsx ושוב נעבור עליו בלוק אחר בלוק.

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

קוד הפונקציה נראה כך:

import React from 'react';

export default function inCollection(component) {
  return class extends React.Component {
    static propTypes = {
      collection: React.PropTypes.object.isRequired,
    };

    constructor(props) {
      super(props);    
      this.handleChange = this.handleChange.bind(this);
    }

    componentWillMount() {
      this.props.collection.on('add', this.handleChange, this);
      this.props.collection.on('remove', this.handleChange, this);
      this.props.collection.on('update', this.handleChange, this);
      this.props.collection.on('reset', this.handleChange, this);
      this.props.collection.on('change', this.handleChange, this);
    }

    handleChange() {
      this.forceUpdate();
    }

    componentWillUnmount() {
      this.props.collection.off(null, null, this);
    }

    render() {
      return React.createElement(component, { collection: this.props.collection }, this.props.children);
    }
  }
}

החלק המעניין הראשון בקוד הוא הגדרת הטיפול באירועים בפונקציות componentWillMount ו componentWillUnmount. בכל מקרה שיש שינוי במידע אנו קוראים ל forceUpdate כדי לרוץ על כל הילדים ולקרוא ל render על מי מהם שהשתנה לפי shouldComponentUpdate. הפונקציה render פשוט יוצרת את הפקד שקיבלנו כקלט ומעבירה לו את פריטי המידע. שימוש במנגנון זה מאפשר מאוד בקלות להגדיר פקדים שמציגים מידע על האוסף- בין אם את האוסף כולו או תוצאת חישוב עליו.

כך הפקד oldest מציג מי הזקן ביותר מבין האנשים באוסף:

import React from 'react';
import inCollection from './collection_hoc';

function Oldest(props) {
    const oldest = props.collection.reduce((a, b) => a.get('age') > b.get('age') ? a : b);
    return (<p>
      The oldest person is: {oldest.get('firstName')} {oldest.get('lastName')} ({oldest.get('age')})
    </p>);
}

export default inCollection(Oldest);

בזכות השורה האחרונה ומנגנון HOC שכתבנו, כל נושא טיפול באירועים ועדכון הפקד לפי שינויי המידע כבר מטופל בפקד העוטף.

או הפקד CollectionView שמציג טופס עריכה עבור כל אחד מהמודלים באוסף:

import React from 'react';
import $ from 'jquery';
import inCollection from './collection_hoc';

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,
};

export default inCollection(CollectionView);

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

4. סיכום ומסקנות (או: מה הרווחנו?)

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

השימוש ב Higher Order Component אפשר לייצר תבנית לפקדים שעובדים על אוספים כך שמאוד קל להוסיף עוד נקודות מבט על אוסף פריטים. במבט שני על הקוד אני חושב שזה עשוי להיות רעיון טוב לייצר מנגנון כזה גם עבור מודל יחיד, לצורך אפשרות לפתח מספר טפסי הצגה לאותו המידע (למשל טופס מקוצר לעריכה מ Mobile, או למשתמשים עם פחות הרשאות).

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