תבניות מחזור חיים של פקד ב React 16

05/08/2018

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

1. תבנית 1: רישום לאירועים חיצוניים שעשויים להשפיע על המצב הפנימי

פונקציות רלוונטיות: componentWillUnmount

קוד הפקד הבא מציג 4 ספרות של שעון:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { m: 0, s: 0 };
  }

  render() {
    const {m, s} = this.state;
    return (
      <div>
        {String(m).padStart(2, '0')}:{String(s).padStart(2, '0')}
      </div>
    );
  }
}

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

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

הפיתרון הראשון שקופץ לראש הוא שימוש ב setInterval של JavaScript: נקרא ל setInterval בבנאי ונחבר אליה בתור קוד טיפול פונקציה שמשנה את ה State ומוסיפה 1 לשניות. יש רק בעיה אחת- מתי מפסיקים את השעון? איפה קוראים ל clearInterval?

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

קוד השעון המלא לכן יראה כך:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { m: 0, s: 0 };
    this.timer = setInterval(this.tick.bind(this), 1000);
  }

  componentWillUnmount() {
    clearInterval(this.timer);
  }

  tick() {
    let { m, s } = this.state;
    if (s < 60) {
      s += 1;
    } else {
      s = 0;
      m += 1;
    }
    this.setState({ m, s });
  }

  render() {
    const {m, s} = this.state;
    return (
      <div>
        {String(m).padStart(2, '0')}:{String(s).padStart(2, '0')}
      </div>
    );
  }
}

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

ויכולים גם לראות את הפקד "חי" בקודפן כאן:

2. תבנית 2: יצירת רכיב מספריה חיצונית (לדוגמא jQuery Plugin)

פונקציות רלוונטיות: componentDidMount

הבנאי של פקד מספק מקום טוב להפעלת קוד שלא צריך DOM Elements, אבל מאחר והבנאי נקרא לפני render אם הקוד שלכם כן צריך DOM Element כדי לעבוד הבנאי כבר לא יתאים.

למצבים כאלה יש לנו פונקצית מחזור חיים בשם componentDidMount. פונקציה זו נקראת מיד אחרי ה render הראשון ותפקידה לאתחל קוד חיצוני שמסתמך על DOM Elements. ניקח לדוגמא jQuery Plugin בשם flatpickr שמציג פקד לבחירת תאריך. בשביל לגרום לשדה input מסוים להפוך לשדה בחירת התאריך המדליק של flatpickr עליכם בסך הכל להפעיל את הפונקציה המתאימה:

$("input").flatpickr();

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

class App extends React.Component {
  componentDidMount() {
    $(this.el).flatpickr();    
  }

  render() {
    return (
      <div>
        <input type='datetime' ref={(el) => { this.el = el; }} />
      </div>
    );
  }
}


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

וזה הקודפן להמחשה:

3. תבנית 3: עדכון מצב פנימי בעקבות שינוי Properties

פונקציות רלוונטיות: getDerivedStateFromProps

התבנית הבאה אומנם קצת פחות נפוצה מהקודמות אבל עדיין חשוב להכיר למקרה שתגיעו למצב דומה. ואני מדבר כאן על גזירת מצב פנימי (State) ראשוני מ props ומתן אפשרות לפקד להמשיך ולעדכן את מצבו הפנימי בלי להשפיע על אותו property ראשוני.

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

class Textbox extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: props.text };
  }

  setText(newText) {
    this.setState({ text: newText });
  }

  render() {
    const initialText = this.props.text;
    const { text } = this.state;
    const cls = (text === initialText ? 'valid' : 'invalid');

    return (
      <input
        type='text'
        value={text}
        className={cls}
        onChange={(e) => this.setText(e.target.value)}
      />
    )
  }
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: 'hello world' };
  }

  render() {
    const {text} = this.state;
    return (
      <div>
        <Textbox text={text} />
        <Textbox text={text} />
        <Textbox text={text} />
        <Textbox text={text} />        
      </div>
    );
  }
}

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

וזה הקודפן להמחשה:

עכשיו נהפוך את העסק לקצת יותר מסובך: נוסיף כפתור שלחיצה עליו משנה את הטקסט הראשוני (של הפקד הראשי). עכשיו השאלה- איך אפשר להודיע לכל הילדים שהטקסט הראשוני השתנה כדי שהם יוכלו להציג מידע מעודכן? התשובה היא הפונקציה getDerivedStateFromProps.

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

הקוד המעודכן נראה כך:

class Textbox extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: props.text };
  }

  static getDerivedStateFromProps(props, state) {
    return {
      text: props.text,
    }
  }

  setText(newText) {
    this.setState({ text: newText });
  }

  render() {
    const initialText = this.props.text;
    const { text } = this.state;
    const cls = (text === initialText ? 'valid' : 'invalid');

    return (
      <input
        type='text'
        value={text}
        className={cls}
        onChange={(e) => this.setText(e.target.value)}
      />
    )
  }
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: 'hello world' };
  }

  randomizeText() {
    const texts = ['one', 'two', 'three', 'four', 'five', 'six', 'seven'];
    this.setState({ text: texts[Math.floor(Math.random() * texts.length)]});
  }

  render() {
    const {text} = this.state;
    return (
      <div>
        <button onClick={() => this.randomizeText()}>Change Text</button>
        <Textbox text={text} />
        <Textbox text={text} />
        <Textbox text={text} />
        <Textbox text={text} />        
      </div>
    );
  }
}

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

והקודפן המתאים להמחשה:

4. תבנית 4: הפעלת פקודה בעקבות שינוי מצב פנימי

פונקציה רלוונטית: componentDidUpdate

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

בדוגמא הבאה נקודת ההתחלה שלנו היא פקד הכולל Youtube Player. זה הקוד שלו:

// 3. This function creates an <iframe> (and YouTube player)
//    after the API code downloads.

const youtubeReady = new Promise(function(resolve, reject) {
  window.onYouTubeIframeAPIReady = function() {
    resolve(YT.Player);
  };
});

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { playing: false };
  }

  toggle() {
    this.setState({ playing: !this.state.playing });
  }

  componentDidMount() {
    youtubeReady.then((Player) => {
      const player = new Player(this.el, {
          height: '390',
          width: '640',
          videoId: 'M7lc1UVf-VE',        
      });
    });
  }

  render() {
    return (
      <div>
        <p>
          <button>Play / Pause</button>
          Current state: {this.state.playing ? 'Play' : 'Stop'}
        </p>

        <div ref={(el) => {this.el = el; }}></div>
      </div>
    );
  }
}

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

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

הפיתרון לשני המצבים האלה הוא הפונקציה componentDidUpdate. הפונקציה נקראת כל פעם שה State או ה Props של פקד משתנה והיא המקום הנכון להפעיל בו תקשורת ל API חיצוני.

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

  componentDidUpdate(prevProps, prevState) {
    if (this.state.playing) {
      this.player.playVideo();
    } else {
      this.player.pauseVideo();      
    }
  }

ואפשר לראות את המנגנון בפעולה בקודפן הבא:

5. תבנית 5: טיפול בשגיאות

פונקציה רלוונטית: componentDidCatch

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

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

class Bomb extends React.Component {
  constructor(props) {
    super(props);
    this.state = { boom: false };
    this.boom = this.boom.bind(this);
  }

  boom() {
    this.setState({ boom: true });
  }

  render() {
    if (this.state.boom) {
      throw 'Boom!';
    }

    return (
      <button onClick={this.boom}>Boom!</button>
    )
  }
}

class Sandbox extends React.Component {
  constructor(props) {
    super(props);
    this.state = { msg: '' };
  }

  componentDidCatch(error, info) {
    this.setState({
      msg: `Error: ${error}; Info: ${JSON.stringify(info)}`
    });
  }

  render() {
    return (
      <div>
        <p>{this.state.msg}</p>
        <Bomb />
      </div>
    )
  }
}

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

אפשר לראות את הקוד בפעולה בקודפן:

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

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

6. לסיכום

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