בואו נשבור קצת ריאקט

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

1. מה שבור בספריה

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

let player;

player = YouTubePlayer('video-player');

// 'loadVideoById' is queued until the player is ready to receive API calls.
player.loadVideoById('M7lc1UVf-VE');

// 'playVideo' is queue until the player is ready to received API calls and after 'loadVideoById' has been called.
player.playVideo();

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

ניסיון ראשון לשלב את הספריה עם ריאקט יכול להיראות כך-

import { useEffect, useRef, useState } from 'react'
import YouTubePlayer from 'youtube-player'

const YoutubePlayerComponent = () => {
  const [videoId, setVideoId] = useState('efGw88Wlncw');
  const playerDivRef = useRef(null);
  const playerRef = useRef(null);

  useEffect(() => {
    playerRef.current = YouTubePlayer(playerDivRef.current);
    playerRef.current.loadVideoById(videoId);
  }, [])

  const changeVideoId = (e) => {
    const videoId = e.target.value;
    setVideoId(videoId);

    const player = playerRef.current;
    if (player) {
      player.loadVideoById(videoId);
    }
  }


  return (
    <div>
      <div>
        <button onClick={() => playerRef.current.playVideo()}>Play</button>
        <button onClick={() => playerRef.current.pauseVideo()}>Pasue</button>
        <div>
          <input type="text" value={videoId} onChange={changeVideoId} />
        </div>
        <div ref={playerDivRef}></div>
      </div>
    </div>
  )
}
export default YoutubePlayerComponent

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

הקוד נראה נכון אבל הוא לא עובד. הכפתורים לא מתפקדים.

קל לראות שהבעיה היא באפקט שיוצר את הנגן. בדרך כלל אפקטים צריכים לכלול קוד "ניקוי", כלומר האפקט הוא חיבור למשהו חיצוני ו useEffect צריכה להחזיר פונקציה שמבטלת את החיבור הזה. במקרה שלנו אין דרך לנקות את החיבור כי הספריה youtube-player לא מספקת כזו. היא כן מציעה פונקציה אסינכרונית בשם destroy, אבל האסינכרוניות של הפונקציה היא בעייתית. הפונקציה מסירה מה DOM את האלמנט של הנגן בצורה אסינכרונית, ולכן עלולה להסיר אותו הרבה אחרי שהאפקט בוטל. במלים אחרות אם אני מנסה להוסיף את קוד הניקוי הזה:

  useEffect(() => {
    playerRef.current = YouTubePlayer(playerDivRef.current);
    playerRef.current.loadVideoById(videoId);
    return () => {
      playerRef.current.destroy();
    }
  }, [])

הקוד ייצור את ה iframe בשביל הנגן ואז מיד ימחק אותו.

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

2. איך בכל זאת לשלב אותה בפרויקט ריאקט

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

  useEffect(() => {
    if (!playerRef.current) {
      playerRef.current = YouTubePlayer(playerDivRef.current);
      playerRef.current.loadVideoById(videoId);
    }
  }, [])

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

3. איך לשבור אותה אחרי ששילבנו

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

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

import { useEffect, useRef, useState } from 'react'
import YouTubePlayer from 'youtube-player'

const YoutubePlayerComponent = () => {
  const [videoId, setVideoId] = useState('efGw88Wlncw');
  const [hidden, setHidden] = useState(false);
  const playerDivRef = useRef(null);
  const playerRef = useRef(null);

  useEffect(() => {
    if (!playerRef.current) {
      playerRef.current = YouTubePlayer(playerDivRef.current);
      playerRef.current.loadVideoById(videoId);  
    }
  }, [])

  const changeVideoId = (e) => {
    const videoId = e.target.value;
    setVideoId(videoId);

    const player = playerRef.current;
    if (player) {
      player.loadVideoById(videoId);
    }
  }


  return (
    <div>
      <div>
        <input type="checkbox" checked={hidden} onChange={() => setHidden(v => !v)} /> Hide
        <button onClick={() => playerRef.current.playVideo()}>Play</button>
        <button onClick={() => playerRef.current.pauseVideo()}>Pasue</button>
        <div>
          <input type="text" value={videoId} onChange={changeVideoId} />
        </div>
        {hidden ? <></> : <div ref={playerDivRef}></div>}        
      </div>
    </div>
  )
}
export default YoutubePlayerComponent

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

react-dom.development.js:12056 Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
    at removeChild (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:8467:26)
    at commitDeletionEffectsOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17506:21)
    at commitDeletionEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17473:13)
    at recursivelyTraverseMutationEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17670:17)
    at commitMutationEffectsOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17733:15)
    at recursivelyTraverseMutationEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17681:15)
    at commitMutationEffectsOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17733:15)
    at recursivelyTraverseMutationEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17681:15)
    at commitMutationEffectsOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17695:15)
    at recursivelyTraverseMutationEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=782b4cde:17681:15)

ריאקט מנסה למחוק את הנגן אבל מגלה שזה לא הוא שהוסיף את הנגן ולכן מחליט להישבר. נו, ראינו את זה בא כש youtube-player החליטה לשנות את ה DOM אז קשה אפילו לכעוס עליו.

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

import { useEffect, useRef, useState } from 'react'
import YouTubePlayer from 'youtube-player'

const YoutubePlayerComponent = () => {
  const [videoId, setVideoId] = useState('efGw88Wlncw');
  const playerDivRef = useRef(null);
  const playerRef = useRef(null);

  useEffect(() => {
    if (!playerRef.current) {
      playerRef.current = YouTubePlayer(playerDivRef.current);
      playerRef.current.loadVideoById(videoId);  
    }
  }, [])

  const changeVideoId = (e) => {
    const videoId = e.target.value;
    setVideoId(videoId);

    const player = playerRef.current;
    if (player) {
      player.loadVideoById(videoId);
    }
  }

  return (
    <div>
      <div>
        <button onClick={() => playerRef.current.playVideo()}>Play</button>
        <button onClick={() => playerRef.current.pauseVideo()}>Pasue</button>
        <div>
          <input type="text" value={videoId} onChange={changeVideoId} />
        </div>
        <div ref={playerDivRef}></div>
      </div>
    </div>
  )
}

function Toggle() {
  const [hidden, setHidden] = useState(false);
  return (
    <div>
      <input type="checkbox" checked={hidden} onChange={() => setHidden(v => !v)} /> Hide
      {hidden ? <></> : <YoutubePlayerComponent />}
    </div>
  )
}
export default Toggle