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 จะถูกผูกอยู่กับค่าตัวเริ่มต้น '' เมื่อใช้อาร์กิวเมนต์ []
คุณไม่จำเป็นต้องทำแบบนี้ แค่อยากให้คุณรู้ว่าทำไมวิธีแก้ปัญหาที่สองของคุณถึงใช้ไม่ได้
