ค้นหาเว็บไซต์

javascript - วิธีการลงทะเบียนเหตุการณ์ด้วย useEffect hooks?


ฉันกำลังติดตามคอร์สของ Udemy เกี่ยวกับการลงทะเบียนอีเวนต์ด้วย hooks ผู้สอนให้โค้ดด้านล่างนี้:

  const [userText, setUserText] = useState('');

  const handleUserKeyPress = event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${userText}${key}`);
    }
  };

  useEffect(() => {
    window.addEventListener('keydown', handleUserKeyPress);

    return () => {
      window.removeEventListener('keydown', handleUserKeyPress);
    };
  });

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );

ตอนนี้มันทำงานได้ดี แต่ผมยังไม่มั่นใจว่านี่คือวิธีที่ถูกต้อง เหตุผลก็คือ ถ้าผมเข้าใจถูกต้อง ทุกครั้งที่มีการเรนเดอร์ใหม่ เหตุการณ์ต่าง ๆ จะถูกลงทะเบียนและถอนการลงทะเบียนซ้ำ ๆ และผมคิดว่านี่ไม่ใช่วิธีที่ถูกต้องที่จะทำ

ดังนั้นฉันจึงทำการปรับเปลี่ยนเล็กน้อยกับ hook useEffect ดังนี้

useEffect(() => {
  window.addEventListener('keydown', handleUserKeyPress);

  return () => {
    window.removeEventListener('keydown', handleUserKeyPress);
  };
}, []);

โดยการมีอาร์เรย์ว่างเป็นอาร์กิวเมนต์ตัวที่สอง ทำให้คอมโพเนนต์เรียกใช้งานเอฟเฟกต์เพียงครั้งเดียว เลียนแบบ componentDidMount และเมื่อฉันลองผลลัพธ์ มันแปลกที่ทุกครั้งที่ฉันพิมพ์ตัวอักษร แทนที่จะต่อท้าย มันถูกเขียนทับแทน

ฉันคาดว่า setUserText($ {userText}$ {key}); จะเพิ่มคีย์ที่พิมพ์ใหม่ต่อสถานะปัจจุบันและตั้งค่าเป็นสถานะใหม่ แต่กลับถูกลืมสถานะเก่าและเขียนทับด้วยสถานะใหม่แทน

มันจริงหรือที่นี่เป็นวิธีที่ถูกต้องที่เราควรลงทะเบียนและยกเลิกการลงทะเบียนเหตุการณ์ทุกครั้งที่มีการเรนเดอร์ใหม่?

คำตอบ #1

วิธีที่ดีที่สุดในการจัดการกับสถานการณ์เช่นนี้คือดูว่าคุณกำลังทำอะไรอยู่ในตัวจัดการเหตุการณ์

หากคุณเพียงแค่ตั้งค่า state โดยใช้ state ก่อนหน้า จะดีที่สุดที่จะใช้รูปแบบ callback และลงทะเบียนตัวฟังเหตุการณ์เฉพาะเมื่อทำการ mount ครั้งแรกเท่านั้น

หากคุณไม่ใช้รูปแบบ callback ตัวอ้างอิงของ listeners พร้อมกับขอบเขต lexical ของมันจะถูกใช้งานโดยตัวฟังเหตุการณ์ แต่จะมีการสร้างฟังก์ชันใหม่พร้อมกับ closure ที่อัปเดตในแต่ละครั้งที่ render; ดังนั้นใน handler คุณจะไม่สามารถเข้าถึงสถานะที่อัปเดตได้

const [userText, setUserText] = useState("");
const handleUserKeyPress = useCallback(event => {
    const { key, keyCode } = event;
    if(keyCode === 32 || (keyCode >= 65 && keyCode <= 90)){
        setUserText(prevUserText => `${prevUserText}${key}`);
    }
}, []);

useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);
    return () => {
        window.removeEventListener("keydown", handleUserKeyPress);
    };
}, [handleUserKeyPress]);

  return (
      <div>
          <h1>Feel free to type!</h1>
          <blockquote>{userText}</blockquote>
      </div>
  );

คำตอบ #2

ประเด็น

[...] ในการเรนเดอร์ซ้ำทุกครั้ง เหตุการณ์จะถูกลงทะเบียนและยกเลิกการลงทะเบียนอยู่เรื่อย ๆ และฉันก็แค่คิดว่าไม่ใช่วิธีที่ถูกต้องที่จะทำแบบนั้น

คุณพูดถูก มันไม่สมเหตุสมผลที่จะเริ่มการจัดการเหตุการณ์ใหม่ภายใน useEffect ในทุกๆ การเรนเดอร์

[...] อาร์เรย์ว่างเป็นอาร์กิวเมนต์ตัวที่สอง ทำให้คอมโพเนนต์ทำเอฟเฟกต์เพียงครั้งเดียว [...] มันแปลกที่ทุกครั้งที่ผมพิมพ์ตัวอักษร แทนที่จะต่อท้าย มันกลับถูกเขียนทับแทน

นี่เป็นปัญหาเกี่ยวกับค่าปิดที่ล้าสมัย

เหตุผล: ฟังก์ชันที่ใช้ภายใน useEffect ควรเป็นส่วนหนึ่งของ dependencies คุณไม่ได้ตั้งค่าอะไรเป็น dependency ([]) แต่ยังคงเรียก handleUserKeyPress ซึ่งเองก็อ่านสถานะ userText

วิธีแก้ปัญหา

0. useEffectEvent (เบต้า)

อัปเดต: นักพัฒนา React ได้นำเสนอ RFC ซึ่งรวมถึง Hook useEvent ใหม่ (ชื่อและฟังก์ชันการทำงานมีการเปลี่ยนแปลงเล็กน้อยตั้งแต่นั้นมา) เพื่อแก้ปัญหาเกี่ยวกับเหตุการณ์ประเภทนี้โดยมีการพึ่งพา

จนกว่าจะถึงตอนนั้น มีทางเลือกขึ้นอยู่กับกรณีการใช้งานของคุณ:

1. ฟังก์ชันอัปเดตสถานะ

setUserText(prev => `${prev}${key}`);

✔ วิธีที่แทรกแซงน้อยที่สุด
✖ เข้าถึงได้เฉพาะสถานะก่อนหน้าของตัวเองเท่านั้น ไม่สามารถเข้าถึง Hooks ของสถานะอื่น

const App = () => {
  const [userText, setUserText] = useState("");

  useEffect(() => {
    const handleUserKeyPress = event => {
      const { key, keyCode } = event;

      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(prev => `${prev}${key}`); // use updater function here
      }
    };

    window.addEventListener("keydown", handleUserKeyPress);
    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []); // still no dependencies

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/[email /umd/react.production.min.js"></script>
<script src="https://unpkg.com/[email /umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

2. useRef/รีฟที่ปรับเปลี่ยนได้

const cbRef = useRef(handleUserKeyPress);
useEffect(() => { cbRef.current = handleUserKeyPress; }); // update each render
useEffect(() => {
    const cb = e => cbRef.current(e); // then use most recent cb value
    window.addEventListener("keydown", cb);
    return () => { window.removeEventListener("keydown", cb) };
}, []);

const App = () => {
  const [userText, setUserText] = useState("");

  const handleUserKeyPress = event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${userText}${key}`);
    }
  };

  const cbRef = useRef(handleUserKeyPress);

  useEffect(() => {
    cbRef.current = handleUserKeyPress;
  });

  useEffect(() => {
    const cb = e => cbRef.current(e);
    window.addEventListener("keydown", cb);

    return () => {
      window.removeEventListener("keydown", cb);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/[email /umd/react.production.min.js"></script>
<script src="https://unpkg.com/[email /umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>

✔ สามารถใช้สำหรับ callback/ตัวจัดการเหตุการณ์ที่ไม่ควรกระตุ้นการเรนเดอร์ใหม่ผ่านการไหลของข้อมูล
✔ ไม่จำเป็นต้องจัดการการพึ่งพา
✖ เป็นแนวทางที่สั่งการมากขึ้น
✖ แนะนำให้ใช้เฉพาะเป็นทางเลือกสุดท้ายตามเอกสารของ React

ดูลิงก์เหล่านี้เพื่อขอข้อมูลเพิ่มเติม: 1 2 3

3. useReducer - "โหมดโกง"

เราสามารถเปลี่ยนไปใช้ useReducer และสามารถเข้าถึง state/props ปัจจุบันได้ - โดยมี API ที่คล้ายกับ useState

แบบแปรผัน 2ก: ตรรกะภายในฟังก์ชันรีดิวเซอร์

const [userText, handleUserKeyPress] = useReducer((state, event) => {
    const { key, keyCode } = event;
    // isUpperCase is always the most recent state (no stale closure value)
    return `${state}${isUpperCase ? key.toUpperCase() : key}`;  
}, "");

const App = () => {
  const [isUpperCase, setUpperCase] = useState(false);
  const [userText, handleUserKeyPress] = useReducer((state, event) => {
    const { key, keyCode } = event;
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      // isUpperCase is always the most recent state (no stale closure)
      return `${state}${isUpperCase ? key.toUpperCase() : key}`;
    }
  }, "");

  useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
      <button style={{ width: "150px" }} onClick={() => setUpperCase(b => !b)}>
        {isUpperCase ? "Disable" : "Enable"} Upper Case
      </button>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/[email /umd/react.production.min.js"></script>
<script src="https://unpkg.com/[email /umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

ตัวแปร 2b: ลอจิกอยู่นอกฟังก์ชันรีดิวเซอร์ - คล้ายกับฟังก์ชันอัปเดตของ useState

const [userText, setUserText] = useReducer((state, action) =>
      typeof action === "function" ? action(state, isUpperCase) : action, "");
// ...
setUserText((prevState, isUpper) => `${prevState}${isUpper ? 
  key.toUpperCase() : key}`);

const App = () => {
  const [isUpperCase, setUpperCase] = useState(false);
  const [userText, setUserText] = useReducer(
    (state, action) =>
      typeof action === "function" ? action(state, isUpperCase) : action,
    ""
  );

  useEffect(() => {
    const handleUserKeyPress = event => {
      const { key, keyCode } = event;
      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(
          (prevState, isUpper) =>
            `${prevState}${isUpper ? key.toUpperCase() : key}`
        );
      }
    };

    window.addEventListener("keydown", handleUserKeyPress);
    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
      <button style={{ width: "150px" }} onClick={() => setUpperCase(b => !b)}>
        {isUpperCase ? "Disable" : "Enable"} Upper Case
      </button>
    </div>
  );
}


ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/[email /umd/react.production.min.js"></script>
<script src="https://unpkg.com/[email /umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

✔ ไม่จำเป็นต้องจัดการ dependencies
✔ เข้าถึงหลาย states และ props ได้
✔ ใช้ API เดียวกันกับ useState
✔ สามารถขยายไปสู่กรณีที่ซับซ้อน/ใช้ reducers ได้
✖ ประสิทธิภาพลดลงเล็กน้อยเนื่องจาก reducer แบบ inline (ค่อนข้างละเลยได้)
✖ ความซับซ้อนของ reducer เพิ่มขึ้นเล็กน้อย

วิธีแก้ปัญหาที่ไม่เหมาะสม

useCallback

แม้ว่ามันจะสามารถนำไปใช้ได้หลายวิธี แต่ useCallback ก็ไม่เหมาะ สำหรับกรณีคำถามเฉพาะนี้

เหตุผล: เนื่องจากการเพิ่ม dependencies - userText ที่นี่ - ตัวฟังเหตุการณ์จะถูกเริ่มใหม่ทุกครั้งที่กดปุ่ม ซึ่งในกรณีที่ดีที่สุดก็จะทำงานได้ไม่ดีนัก หรือแย่กว่านั้นอาจทำให้เกิดความไม่สอดคล้องกัน

const App = () => {
  const [userText, setUserText] = useState("");

  const handleUserKeyPress = useCallback(
    event => {
      const { key, keyCode } = event;

      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(`${userText}${key}`);
      }
    },
    [userText]
  );

  useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, [handleUserKeyPress]); // we rely directly on handler, indirectly on userText

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/[email /umd/react.production.min.js"></script>
<script src="https://unpkg.com/[email /umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>

ประกาศฟังก์ชันตัวจัดการภายใน useEffect

การประกาศฟังก์ชันจัดการเหตุการณ์โดยตรงภายใน useEffect มีปัญหาพอ ๆ กับ useCallback เพียงแต่ตัวหลังทำให้เกิดการพึ่งพิงแบบอ้อมมากขึ้นเล็กน้อย

อีกนัยหนึ่ง: แทนที่จะเพิ่มชั้นของการพึ่งพาเพิ่มเติมผ่าน useCallback เราจะวางฟังก์ชันโดยตรงภายใน useEffect - แต่การตั้งค่าการพึ่งพาทั้งหมดยังคงจำเป็น ทำให้เกิดการเปลี่ยนแปลงตัวจัดการบ่อยครั้ง

ในความเป็นจริง ถ้าคุณย้าย handleUserKeyPress เข้าไปใน useEffect กฎ ESLint เกี่ยวกับ deps แบบครบถ้วนจะแจ้งให้คุณรู้ว่ามี dependencies ที่จำเป็นใดบ้างที่หายไป (userText) หากไม่ได้ระบุ

const App =() => {
  const [userText, setUserText] = useState("");

  useEffect(() => {
    const handleUserKeyPress = event => {
      const { key, keyCode } = event;

      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(`${userText}${key}`);
      }
    };

    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, [userText]); // ESLint will yell here, if `userText` is missing

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/[email /umd/react.production.min.js"></script>
<script src="https://unpkg.com/[email /umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

คำตอบ #3

คำตอบใหม่:

useEffect(() => {
  function handlekeydownEvent(event) {
    const { key, keyCode } = event;
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(prevUserText => `${prevUserText}${key}`);
    }
  }

  document.addEventListener('keyup', handlekeydownEvent)
  return () => {
    document.removeEventListener('keyup', handlekeydownEvent)
  }
}, [])

เมื่อใช้ setUserText ให้ส่งฟังก์ชันเป็นอาร์กิวเมนต์แทนที่จะเป็นอ็อบเจ็กต์, prevUserText จะเป็นสถานะล่าสุดเสมอ.

คำตอบเก่า:

ลองอันนี้ มันทำงานเหมือนกับโค้ดต้นฉบับของคุณ:

useEffect(() => {
  function handlekeydownEvent(event) {
    const { key, keyCode } = event;
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${userText}${key}`);
    }
  }

  document.addEventListener('keyup', handlekeydownEvent)
  return () => {
    document.removeEventListener('keyup', handlekeydownEvent)
  }
}, [userText])

เพราะในเมธอด useEffect() ของคุณ มันขึ้นอยู่กับตัวแปร userText แต่คุณไม่ได้ใส่มันลงไปในอาร์กิวเมนต์ตัวที่สอง มิฉะนั้น userText จะถูกผูกอยู่กับค่าตัวเริ่มต้น '' เมื่อใช้อาร์กิวเมนต์ []

คุณไม่จำเป็นต้องทำแบบนี้ แค่อยากให้คุณรู้ว่าทำไมวิธีแก้ปัญหาที่สองของคุณถึงใช้ไม่ได้

บทความที่เกี่ยวข้อง