ข้อเสนอเพิ่ม Signals ให้กับ JavaScript
(github.com/proposal-signals)- ข้อเสนอ JavaScript Signals ของ TC39 เป็นแนวทางระยะแรกเพื่อทำให้ primitive สำหรับ reactive สำหรับติดตามสถานะ UI และสถานะที่คำนวณได้อย่างมีประสิทธิภาพกลายเป็นมาตรฐาน และปัจจุบันยังเป็นร่างระดับ Stage 1
- ข้อเสนอนี้มุ่งเน้นที่ ความหมายหลักของกราฟ Signal และกลไกการติดตามอัตโนมัติที่เฟรมเวิร์กสามารถใช้ร่วมกันได้ มากกว่า API ระดับผิวหน้าที่นักพัฒนาแอปพลิเคชันจะใช้โดยตรง
Signal.State,Signal.Computed,Signal.subtle.Watcherคือ API หลัก โดยการคำนวณมุ่งไปที่ lazy evaluation, การแคช, การติดตาม dependency อัตโนมัติ และการทำงานแบบ glitch-free- เป้าหมายของ Signals ในตัวคือเพิ่ม ความสามารถในการทำงานร่วมกัน ระหว่างเฟรมเวิร์กหลายตัว เช่น Angular, Ember, MobX, Preact, Qwik, Solid, Svelte, Vue และเปิดทางให้ DevTools รองรับการดีบักและการวิเคราะห์ประสิทธิภาพได้
- กลุ่มข้อเสนอตั้งใจจะผ่าน polyfill ระดับ production หลายตัว, การผสานรวมกับเฟรมเวิร์ก, การตรวจสอบกับแอปพลิเคชันขนาดใหญ่ และ benchmark ด้านประสิทธิภาพก่อน Stage 2 โดยการทำให้เป็นมาตรฐานอาจใช้เวลาอย่างน้อย 2–3 ปีขึ้นไป
ตำแหน่งของข้อเสนอ JavaScript Signals
- JavaScript Signals ถูกแนะนำในฐานะข้อเสนอ Stage 1 ของกระบวนการ TC39
- เอกสารปัจจุบันเป็นความพยายามในการปรับทิศทางร่วมของระบบนิเวศ JavaScript คล้ายกับ Promises/A+ ก่อนการทำให้
Promiseเป็นมาตรฐานใน ES2015 - มี polyfill ให้ทดลองใช้งานโดยตรง
- champion ของข้อเสนอและผู้เขียนดั้งเดิมจัดทำร่างปัจจุบันโดยอาศัยข้อมูลการออกแบบจากหลายเฟรมเวิร์กและไลบรารี
ปัญหาที่การทำมาตรฐานมุ่งแก้
- UI ที่ซับซ้อนต้องจัดเก็บค่า คำนวณ ทำให้ค่าไม่ถูกต้อง ซิงก์ และผลักไปยังเลเยอร์ view โดย Signals พยายามจัดหา โครงสร้างพื้นฐานสำหรับการจัดการสถานะ ให้กับงานซ้ำ ๆ เหล่านี้
- ในตัวอย่าง Vanilla JS นั้น
counter,isEven,parity,renderพัวพันกันโดยตรง ทำให้เกิดปัญหาต่อไปนี้- สถานะและระบบเรนเดอร์เชื่อมโยงกันแน่นเกินไป
- เกิดการคำนวณและเรนเดอร์ที่ไม่จำเป็น แม้
parityจะไม่เปลี่ยน เช่น กรณีcounterเปลี่ยนจาก 2 เป็น 4 - เมื่อชิ้นส่วน UI อื่นต้องการ subscribe เฉพาะบางส่วนของ
counter,isEven,parityการจัดการ subscribe และยกเลิกด้วยมือจะซับซ้อนขึ้น - หากเพิ่ม pub/sub หลายระดับ จะเพิ่ม boilerplate และ bookkeeping ของ subscription รวมถึงความเสี่ยง memory leak
- ตัวอย่างที่ใช้ Signals ใช้
Signal.StateและSignal.Computedเพื่อจัดการค่า การคำนวณ และ side effect ด้วยวิธีเดียวกัน- ไม่ต้อง subscribe ด้วยมือ
- computed Signal จะค้นหา Signal ที่ตนพึ่งพาโดยอัตโนมัติ
- การคำนวณจะทำงานเฉพาะเมื่อมีการร้องขอค่าอย่างชัดเจนเท่านั้น
- computed Signal จะแคชค่าล่าสุดไว้
API หลักของข้อเสนอ
Signal<T>ถูกนิยามเป็นค่าที่อ่านได้ซึ่งมีget(): TSignal.State<T>คือ Signal ที่เขียนค่าได้- constructor รับค่าเริ่มต้นและตัวเลือก
- อ่านค่าด้วย
get()และเปลี่ยนค่าด้วยset(t)
Signal.Computed<T>คือ Signal ที่คำนวณได้ จาก Signal อื่น- คำนวณจากค่าที่ callback คืนกลับ
- ติดตาม dependency โดยอัตโนมัติ
- ค่าจะถูกคำนวณแบบ lazy และถูกแคชไว้
Signal.subtleรวม API ขั้นสูงที่ใกล้กับการใช้งานของผู้เขียนเฟรมเวิร์กหรือการ implement DevToolsuntrack(cb)ทำให้อ่าน Signal ได้โดยไม่ถูกติดตามcurrentComputed()คืนค่า computed Signal ที่กำลังถูกติดตามในปัจจุบันintrospectSources,introspectSinks,hasSinks,hasSourcesเป็น API สำหรับสังเกตกราฟWatcherเป็นฐานสำหรับตรวจจับการเปลี่ยนแปลงของ Signal และ implement effect กับ scheduling ในระดับเฟรมเวิร์ก
SignalOptions<T>รองรับฟังก์ชันเปรียบเทียบแบบกำหนดเองequalsและ hookwatched/unwatched
วิธีทำงานและโมเดลการรัน
- Signal แทน data cell ที่เปลี่ยนแปลงได้ตามเวลา และแบ่งเป็น
stateหรือcomputed - computed Signal จะบันทึก Signal ที่อ่านระหว่างการรันโดยอัตโนมัติ และเมื่อถูกอ่านในภายหลังจะตรวจสอบว่า dependency เดิมเปลี่ยนไปหรือไม่
- การคำนวณเป็นแบบ pull-based
- แม้ dependency เปลี่ยน ก็ไม่คำนวณใหม่ทันที
- จะคำนวณใหม่เมื่อจำเป็นในตอนที่มีคนอ่านด้วย
.get()
- การเขียน State Signal จะสะท้อนผลแบบ synchronous
- หลัง
.set()หากอ่าน computed Signal ที่พึ่งพาค่านั้น จะคำนวณใหม่ทันทีเมื่อจำเป็น - ไม่มี batching ในตัว
- หลัง
- callback
notifyของ Watcher อาจถูกเรียกแบบ synchronous ระหว่าง.set()- อย่างไรก็ตาม ระหว่าง
notifyจะไม่สามารถอ่านหรือเขียน Signal ได้ - งานอ่าน/เขียนจริงต้องถูก schedule ในภายหลัง
- อย่างไรก็ตาม ระหว่าง
- หาก callback ของ computed Signal โยน exception ออกมา exception นั้นจะถูกแคชเหมือนค่า และจะถูกโยนอีกครั้งเมื่ออ่าน Signal นั้นใหม่
แรงจูงใจในการทำมาตรฐาน
- การ implement Signal ของแต่ละเฟรมเวิร์กมีกลไกติดตามอัตโนมัติของตนเอง ทำให้ยากต่อการแชร์ model, component และ library ระหว่างเฟรมเวิร์ก
- เป้าหมายของข้อเสนอคือแยก reactive model ออกจาก view สำหรับการเรนเดอร์
- ช่วยให้นักพัฒนาไม่ต้องเขียนโค้ดที่ไม่ใช่ UI ใหม่เมื่อเปลี่ยนเทคโนโลยีการเรนเดอร์
- ทำให้สร้าง reactive model ด้วย JavaScript ที่แชร์ได้ในหลายบริบท
- ด้านประสิทธิภาพและหน่วยความจำ ระบุว่า implementation ในตัวอาจมีประสิทธิภาพกว่าการ implement ด้วย JS ในระดับค่าคงที่เล็กน้อย แต่เอนจินจะไม่ได้เปลี่ยนอัลกอริทึมอย่างวิเศษ
- ด้าน DevTools นั้น Signals ในตัวอาจแสดงข้อมูลต่อไปนี้ได้ดีขึ้น
- call stack ของ chain ของ computed Signal
- กราฟอ้างอิงระหว่าง Signal
- ความสัมพันธ์ dependency ที่จำเป็นต่อการดีบักการใช้หน่วยความจำ
- หากรวมอยู่ใน standard library ก็คาดหวังผลพลอยได้ เช่น ลดขนาด bundle, เพิ่มเสถียรภาพและคุณภาพ, และสร้างคำศัพท์ร่วมระหว่างโปรเจกต์
เป้าหมายและข้อจำกัดด้านการออกแบบ
- ฟังก์ชันหลักรวมถึง Signal ที่เขียนค่าได้, Signal ที่คำนวณได้, การตอบสนองต่อสถานะ dirty, scheduling ของเฟรมเวิร์กเอง,
untrack, และการประกอบกันของหลาย codebase - computed Signal ตั้งเป้าให้เป็น glitch-free
- เพื่อหลีกเลี่ยงการคำนวณที่ไม่จำเป็น จะรันส่วนของกราฟที่อาจ dirty โดยเรียงลำดับเชิง topology
- พยายามกำจัดการคำนวณซ้ำ
- ไม่ใส่ forced scheduling ในตัวแบบสไตล์ Promise เพื่อให้เฟรมเวิร์กสามารถ scheduling เองได้
- เพื่อป้องกันการใช้ callback แบบ synchronous reaction ผิดวิธี จึงห้ามอ่านและเขียน Signal ภายใน
notifyของ Watcher untrackถูกมองเป็นทางออกฉุกเฉินที่ไม่ปลอดภัย- หาก Signal ที่อ่านโดยไม่ถูกติดตามส่งผลต่อผลลัพธ์การคำนวณ เมื่อ Signal นั้นเปลี่ยน computed Signal อาจไม่อัปเดต
- API ให้ความสำคัญกับการเป็นฐานสำหรับ implementation ของเฟรมเวิร์ก และไม่ได้ออกแบบมาให้ ergonomic เป็นพิเศษสำหรับนักพัฒนาแอปทั่วไป
effect และ Watcher
- ข้อเสนอนี้ไม่มีฟังก์ชันในตัวอย่าง
effect() - scheduling ของ effect เกี่ยวพันกับรอบการเรนเดอร์ของเฟรมเวิร์ก, disposal และการจัดการ ownership จึงไม่ใช่สิ่งที่ API มาตรฐาน JavaScript จะแก้โดยตรง
- แต่
Signal.subtle.Watcherให้ฐานระดับต่ำสำหรับ implement effect- เมื่อ dependency ของ Signal ที่ถูก watch เปลี่ยน
notifyจะถูกเรียก - ใช้
getPending()เพื่อตรวจสอบ Signal ที่ยัง dirty อยู่ได้ - effect ที่ต้อง dispose ต้องจัดการด้วย
unwatch
- เมื่อ dependency ของ Signal ที่ถูก watch เปลี่ยน
- Signal ที่ Watcher กำลังดูอยู่อาจยังคงมีชีวิตตราบใดที่ state ภายในยังเข้าถึงได้ ดังนั้นเมื่อ cleanup effect จึงจำเป็นต้องเรียก
Watcher.prototype.unwatch
ฟีเจอร์ที่ยังไม่อยู่ในร่างปัจจุบัน
- Async ยังไม่ถูกรวมไว้ในโมเดลปัจจุบัน
- Signals ถูกมองเป็นค่าที่ประเมินได้แบบ synchronous เสมอ
- มีบางวิธีที่สามารถ model สถานะ loading เป็น exception ได้ และมีการพูดคุยเพื่อปรับปรุงใน Issue #30
- Transactions ก็ยังไม่ถูกรวมไว้
- หากต้องรักษาสถานะ “from” และ “to” พร้อมกันในการเปลี่ยนหน้าจอ จะเกิดปัญหาการ fork สถานะของกราฟ Signal
- การพูดคุยที่เกี่ยวข้องอยู่ใน Issue #73
- convenience methods บางส่วนก็ยังไม่อยู่ในร่างปัจจุบัน
- ฟีเจอร์ที่ขาดไปถูกตัดออกเพราะยังไม่มีฉันทามติระหว่างเฟรมเวิร์กและสามารถ workaround ในเลเยอร์ที่สูงกว่าได้ แต่หลัง prototype แล้วอาจถูกนำกลับมาพิจารณาอีกครั้ง
แผนพัฒนาและกำหนดการมาตรฐาน
- ข้อเสนอนี้ถูกเสนอเป็นวาระ TC39 Stage 1 ในเดือนเมษายน 2024 และเอกสารอธิบายว่าปัจจุบันอาจมองได้คล้าย Stage 0
- ก่อนเสนอ Stage 2 มีแผนทำงานต่อไปนี้
- พัฒนา implementation ของ polyfill ระดับ production หลายตัว
- ทดสอบกับเฟรมเวิร์กหลากหลายและผ่านการทดสอบสไตล์ test262
- ตรวจสอบประสิทธิภาพผ่านชุด benchmark ของ signal/framework ที่ครอบคลุม
- ผสานรวม API ที่เสนอเข้ากับเฟรมเวิร์ก JS ตัวแทนหลายตัวและแอปพลิเคชันขนาดใหญ่บางส่วน
- ทำความเข้าใจความเป็นไปได้ในการขยาย API และตัดสินใจว่าจะรวมไว้หรือไม่
- กลุ่มข้อเสนอจะเดินหน้าอย่างระมัดระวังเพื่อหลีกเลี่ยงการทำ Signals ในรูปแบบที่ผิดให้กลายเป็นมาตรฐานเร็วเกินไป
- FAQ คาดว่า Signals มาตรฐานจะใช้ได้ทั่วเบราว์เซอร์โดยไม่ต้องใช้ polyfill ต้องใช้เวลาอย่างน้อย 2–3 ปี
- polyfill ปัจจุบันใช้งานได้ แต่แนะนำว่าไม่ควรพึ่งพาความเสถียร เพราะ API อาจเปลี่ยนระหว่างกระบวนการ review
โมเดลการใช้งานที่สรุปใน FAQ
- Signals ในตัวเป็นอิสระจากเทคโนโลยีการเรนเดอร์
- อธิบายว่ารองรับได้ทั้งรูปแบบอย่าง Preact ที่ใช้ VDOM, Solid ที่ใช้ native DOM และ Vue ที่ใช้แนวทางผสม
- โดยทั่วไปนักพัฒนาแอปควรใช้ Signals ผ่านเฟรมเวิร์ก
- เฟรมเวิร์กจะจัดการ Watcher,
untrack, ownership, disposal และ scheduling การเรนเดอร์ DOM
- เฟรมเวิร์กจะจัดการ Watcher,
- สามารถใช้ร่วมกับ SSR, hydration และ resumability ได้
- Qwik ใช้ Signals ร่วมกับคุณสมบัติเหล่านี้ และฝ่ายข้อเสนอเห็นว่า resumable Signals ของ Qwik สามารถ model ได้ด้วยการผสม State และ Computed
- Signals และ
Proxyเป็นสิ่งที่เสริมกัน- Proxy ดักจับการทำงานระดับตื้นของ object ส่วน Signals ประสานกราฟ dependency ของ data cell
- หากวาง Signals ไว้หลัง Proxy จะทำให้ nested reactive structure ergonomic ขึ้นได้
- Signals เป็น cell ที่แสดงค่าปัจจุบัน ไม่ใช่ stream
- หากเขียนค่าไปยัง State Signal สองครั้งติดกันโดยไม่ทำอะไรเลย การเขียนครั้งแรกอาจไม่ปรากฏต่อ computed Signal หรือ effect
- เอกสารมองว่านี่เป็นคุณสมบัติอีกด้านของการทำงานแบบ glitch-free และระบุว่าสำหรับ stream นั้นโครงสร้างอื่นอย่าง async iterable หรือ observable เหมาะสมกว่า
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
มีแค่ฉันคนเดียวไหมที่รู้สึกว่าตัวอย่าง JavaScript แบบล้วนๆกลับอ่านและจัดการได้ง่ายกว่า
เขาบอกว่า “การตั้งค่าดูรกและมี boilerplate เยอะ” แต่ตัวอย่าง signals เองก็ดูรกและมี boilerplate เยอะเหมือนกัน แถมยังเพิ่มแนวคิดใหม่ที่มือใหม่เข้าใจได้ยากเข้าไปอีก
ประเด็นที่ว่า “ถ้า counter เปลี่ยนจาก 2 เป็น 4 parity ไม่เปลี่ยน แต่กลับมีการคำนวณและเรนเดอร์ที่ไม่จำเป็น” ฟังดูเหมือน premature memoization
ถ้าส่วนอื่นของ UI อยากเรนเดอร์ตามการอัปเดตของ counter ก็จริงที่ตัวอย่าง strawman นั้นไม่เหมาะ และในกรณีนั้นก็ใช้วิธีอื่นอย่าง signals, event handling หรือ central state store (แนว Redux) ได้
ถ้าส่วนอื่นของ UI พึ่งพาแค่ isEven หรือ parity อย่างเดียว ก็อาจเปลี่ยนแนวทางได้ถ้านั่นเป็นโครงสร้างหลักของแอป แต่ส่วนใหญ่ไม่ใช่แบบนั้น และการที่ “ฟังก์ชัน render ที่พึ่งพา parity อย่างเดียวต้องรู้ว่าต้อง subscribe กับ counter” ก็ไม่ใช่ภาระที่ไม่ยุติธรรมเสมอไป อีกทั้งฟังก์ชันคำนวณแบบล้วนๆ ยังมีข้อดีตรงที่ดูอินพุตได้ง่าย
การพยายาม ทำ signals ให้เป็นมาตรฐาน ซึ่งเป็นแนวคิดที่ถูกใช้มากขึ้นเรื่อยๆ ในการพัฒนา UI ถือว่าน่าชื่นชม ต่อให้พักข้อถกเถียงรายละเอียดอย่าง boilerplate เยอะแค่ไหน หรือจำเป็นต้องสร้างระบบ event เองไหม ถ้าหลายเฟรมเวิร์กใช้ signals ก็อาจมีเหตุผลอยู่ และถึงจะใช้เวลาก็ควรลองทำให้เป็นมาตรฐาน
https://preactjs.com/guide/v10/signals
ใน Preact เวลาส่ง signal ลงไปตาม tree ผ่าน props หรือ context จะส่งแค่การอ้างอิงถึง signal และเพราะคอมโพเนนต์มอง signal ไม่ใช่ค่าของมัน การอัปเดต signal จึงอาจไม่ต้องทำให้คอมโพเนนต์ re-render ใหม่ และสามารถไปยังคอมโพเนนต์ใน tree ที่เข้าถึง
.valueได้โดยตรงนอกจากนี้ signal ยังติดตามได้ว่าค่าเมื่อไรถูกเข้าถึงและเมื่อไรถูกอัปเดต และใน Preact ถ้าเข้าถึง
.valueของ signal ภายในคอมโพเนนต์ คอมโพเนนต์นั้นจะ re-render อัตโนมัติเมื่อค่า signal เปลี่ยนจากประสบการณ์ ข้อดีอย่างหนึ่งที่ใหญ่มากคือสามารถ ทำสถานะแบบ reactive ให้เป็นโมดูลได้ ในสไตล์ imperative จะต้องมีสถานะเพิ่มเพื่อคอยติดตามการเปลี่ยนแปลง และความเป็นโมดูลก็มาจาก abstraction ใช้เฉพาะเวลาที่ต้องใช้ก็พอ
การทำตัวอย่างที่ทั้งเรียบง่ายและยังใช้ได้จริงเป็นเรื่องของการหาสมดุล กรณีที่ reactivity ให้ประโยชน์ชัดเจนมักซับซ้อนกว่า เลยแสดงให้เห็นได้ยากกว่าตัวอย่างที่ง่ายกว่าแต่ใช้ได้จริงน้อยกว่า
ตอนที่ JavaScript เพิ่ม Promises เข้ามา ก็เคยรู้สึกต่อต้านเพราะคิดว่าคงต้องเขียน
new Promiseไปทั่วเอาเข้าจริง จำนวนครั้งที่เขียน
new Promiseเองนับด้วยสองมือยังไหว แต่กลับได้ใช้.thenบ่อยกว่ามาก โดยเฉพาะเวลาจัดการไลบรารีของ third partyสุดท้ายแล้วผลในชีวิตประจำวันจากการที่ Promise ถูกเพิ่มเข้ามาใน JavaScript คือมันให้อินเทอร์เฟซที่ค่อนข้างเรียบง่าย โดยรวมแข็งแรง และแทบจะเป็นสากล สำหรับพฤติกรรมและความสามารถเฉพาะทางมากมายที่ไลบรารี third party มีให้ ไม่ว่าจะเป็นการอ่านไฟล์ การเรียก API หรือ output จากขั้นตอน build แค่ใช้
.then(res => …)ก็เหมือนมาถึงครึ่งทางของสิ่งที่ใช้งานได้แล้วถ้า ข้อเสนอ Signal นี้จะทำหน้าที่คล้ายกันท่ามกลางการระเบิดแบบแคมเบรียนของเฟรมเวิร์ก UI เชิง reactive ก็เห็นด้วย และยิ่งไปกว่านั้น มันอาจช่วยให้ reactivity ขยายออกไปนอก UI ได้ด้วย ฉันมักจินตนาการถึง state tree สำหรับการคำนวณซ้ำแบบค่อยเป็นค่อยไปที่ไม่ได้มีไว้สำหรับสถานะ UI อยู่บ่อยๆ
new Promiseเอง.thenของ Promise ยุคแรกถือว่าดีกว่า delegate แบบซ้อนกันมาก และโอเคสำหรับ chain ที่ไม่ซับซ้อน แต่ถ้าต้อง chain Promise คนละแบบตามเงื่อนไข หรือต้องจัดการข้อผิดพลาดต่างกันในแต่ละ chain หรือต้อง return ก่อนเวลา โค้ดอาจอ่านและจัดการได้ยากขึ้นมากถ้าใช้ async/await ก็สามารถเขียนการเรียกใช้งานให้ดูเหมือนไม่ใช่ Promise ได้ วาง try/catch รอบการเรียก Promise เฉพาะจุดได้ง่าย และการ return ก่อนเวลาก็ดูเป็นธรรมชาติกว่า
ไม่เข้าใจว่าทำไมสิ่งนี้ถึงต้องเป็น ส่วนหนึ่งของภาษา ทำเป็นไลบรารีก็ได้และก็มีไลบรารีแบบนั้นอยู่แล้ว เนื่องจากมันมีขนาดเล็ก การรวมเข้าไปในโค้ดก็ไม่ได้เป็นภาระมากนัก และการเพิ่มอะไรเข้าไปในภาษาไม่ควรกลายเป็นเป้าหมายในตัวมันเอง
การคิดว่าเพียงเพราะไลบรารี UI ของ JS ในปัจจุบันออกแบบ signals ได้ดีมาก จึงควรให้มันกลายเป็นส่วนหนึ่งของภาษา นั่นเป็นความหยิ่งผยองเกินไป signals มีการติดตั้งใช้งานหลายแบบที่มีจุดแลกเปลี่ยนต่างกัน และไม่มีแบบไหนเลยที่สมควรได้สถานะพิเศษในสเปกของ JavaScript
ก่อนที่ไลบรารีเหล่านี้จะใช้ signals พวกมันก็ใช้ virtual DOM กันมาก่อน โชคดีที่ virtual DOM ไม่ได้กลายเป็นส่วนหนึ่งของ JS แล้ว signals ต่างกันตรงไหน? ไม่ต่างเลย เหตุผลที่จะทำให้เป็นมาตรฐานยังอ่อนกว่าสมัย virtual DOM เสียอีก
จะเอาทุกอย่างที่กำลังเป็นกระแสมาใส่ทับถมไว้ในรันไทม์ที่แทบไม่มีทางลบฟีเจอร์ที่ไม่ต้องการอีกต่อไปได้โดยไม่ทำให้เว็บพังอย่างนั้นหรือ? มันสั้นighted มาก
UI แบบ reactive ชนะแล้ว แม้แต่ในแอปเล็ก ๆ พอเริ่มจัดการสถานะ ความซับซ้อนก็พุ่งจนทำให้การใช้ JS ล้วน ๆ ยากขึ้น สำหรับผม เฟรมเวิร์กเชิง reactive แบบไหนก็ยังดีกว่า JS ล้วน ซึ่งอาจแปลว่ายังมีองค์ประกอบบางอย่างที่ขาดอยู่
ตอนนี้ก็ผ่านมาราว 10 ปีแล้ว ถึงเวลาคิดถึงเส้นแบ่งที่พอจะทำให้เป็นมาตรฐานได้แล้ว เหมือน Promise ถ้าทำได้ดีจริงก็อาจลดความซับซ้อนของกรณีใช้งานที่พบบ่อยมากได้
วิธีประเมินที่ดีกว่าคือถามว่า “เฟรมเวิร์ก reactive ที่มีอยู่จะใช้ข้อเสนอนี้ไหม?” ถ้าไม่ใช้ ก็ต้องดูว่าเพราะอะไร อะไรที่ขาด อะไรที่เกินมา และมีอะไรให้เรียนรู้จาก UI และความเป็น reactive ในภาษาอื่นบ้าง ประสบการณ์ที่กระจัดกระจายเหล่านี้มีคุณค่าพอจะนำมากลั่นกรอง
ความจำเป็นของมันยังถกเถียงกันได้ แต่ถ้าจะขยาย standard library การดูจากสิ่งที่ได้รับความนิยมก็ถือเป็นแนวทางที่ดี
signals ไม่ใช่ ตัวแทนของ virtual DOM
ถ้าต้องส่งสัญญาณบางอย่างไปทั่วทั้งแอปพลิเคชัน ก็ใช้อีเวนต์
window.dispatchEvent(new Event('counterChange'));แล้วส่วนไหนของแอปที่อยากตอบสนองก็สมัครรับแบบนี้ได้
window.addEventListener('counterChange', () => { ... do something ... });วิธีนี้มีปัญหาอะไร?
การจัดการอีเวนต์ เละเทะได้ง่ายมาก ถ้าอยากดูให้ลึกกว่านี้ก็ลองดูเรื่อง event bubbling และ propagation
แอปขนาดใหญ่ต้องการการจัดการอีเวนต์ที่แข็งแรง และนี่คือข้อดีอย่างหนึ่งของเฟรมเวิร์กอย่าง Angular, Vue ที่ทุกวันนี้อาจไม่เห็นเด่นชัดนัก
คุณคงไม่อยากใช้ API จัดการอีเวนต์มาตรฐานแบบตรง ๆ โดยไม่มีเฟรมเวิร์ก เพราะพอเริ่มจัดการการเพิ่ม ลบ โคลน จุดชนวน ลบออก หรือเรียกครั้งเดียวสำหรับองค์ประกอบจำนวนมาก ก็อาจเกิดผลข้างเคียงไม่พึงประสงค์อย่างรุนแรงได้
ความต่างจาก signals คือค่าผลลัพธ์จะถูกคำนวณก็ต่อเมื่อผู้ใช้ปลายทางอ่านค่าเท่านั้น มันแยกช่วงเวลาที่มีการเขียนจริงลงไปใน signal ออกจากการจองคิวอัปเดตการเรนเดอร์แบบอะซิงก์ และสายโซ่การคำนวณที่ตัวเฝ้าดูทำจะเกิดขึ้นเพียงครั้งเดียวระหว่างการเรนเดอร์
ค่ากลางที่ถูกส่งเข้า signal จะหายไป จึงยากจะทำอะไรที่น่าสนใจมาก ๆ ในนั้น และโดยพื้นฐานแล้วมันใกล้เคียงกับชั้นนามธรรมระดับสูงสำหรับควบคุมจังหวะของรอบการเรนเดอร์มากกว่า
ประสิทธิภาพก็อาจดีกว่าได้ด้วย เช่น ถ้ามีการคำนวณที่ขึ้นกับสองค่า
result = a ? b : 0เมื่อ a เป็นเท็จ ต่อให้ b เปลี่ยนก็ไม่จำเป็นต้องคำนวณใหม่ ใน signals สิ่งนี้เกิดขึ้นอัตโนมัติ แต่ถ้าเป็น publish/subscribe แบบดั้งเดิมจะต้องเขียนโค้ดค่อนข้างมากและก็ยากที่จะรับประกันว่า listener ทุกตัวจะไม่สร้างการ trigger ต่อเนื่องแบบนั้น
ตลอดหลายสิบปีที่ผ่านมา ผู้คนพยายามทำความเข้าใจว่าทำไม การติดตามสถานะ และการอัปเดต DOM ถึงได้ยากขนาดนั้น
แน่นอนว่าต้องมีวินัยอยู่บ้าง แต่ก็ยังรู้สึกว่าเรียบง่ายกว่าวิธีแก้ที่ออกมาทุก ๆ ไม่กี่ปีมาก ทั้ง Backbone, Knockout, Angular, React หรือแม้แต่การแก้ตัวภาษาเอง ดูเหมือนว่าวิธีคิดของผมน่าจะต่างออกไปตั้งแต่รากฐาน
มันสะท้อนออกมาแม้แต่ในชื่อฟังก์ชัน การอัปเดต
innerTextกลับถูกเรียกว่า “render” ทั้งที่จริง ๆ แล้วมันไม่ได้เรนเดอร์อะไรเลย อย่างมากก็เป็นเบราว์เซอร์ที่เรนเดอร์ และนั่นก็เหมือนกับงานอื่นทั้งหมดที่เกี่ยวกับการ paint ด้วย มันทำให้ผมงงมาก เพราะรู้สึกเหมือนเป็นความพยายามสิ้นหวังที่จะทำให้หนึ่งในฟังก์ชัน DOM ที่ง่ายที่สุดกลายเป็นเรื่องซับซ้อนแต่พอซับซ้อนขึ้น มันก็ไม่ง่ายแล้ว
ความก้าวหน้าของการเขียนโปรแกรมอาจมองได้ว่าเป็นกระบวนการกำจัดระเบียบวินัยแบบพิธีการและเข้มงวดที่จำเป็นต่อการได้ผลลัพธ์ที่ดี
ไม่ได้แปลว่า React คือวิวัฒนาการขั้นถัดไป แต่ signals ชัดเจนว่าเป็นก้าวหนึ่งในทิศทางที่ถูกต้อง
การซิงก์ DOM ให้ตรงกับสถานะข้อมูลนั้นไม่ยากเกินไป แต่การทำให้มันมีประสิทธิภาพสูงมากที่ 60fps นั้นยากมหาศาล โดยเฉพาะเวลาจะออกแบบ API ที่ไม่รั่วและไม่ยุ่งยากจนเกินไป
พูดตามตรง บางทีการวาดพิกเซลลงบน canvas แบบเกมอาจง่ายกว่าการแปลงการเปลี่ยนแปลงไปสะท้อนกับ DOM tree ที่ยังมีชีวิตอยู่ด้วยซ้ำ
Promises เป็นตัวอย่างความสำเร็จที่ดี แต่ถ้าไม่มี async/await ก็ไม่ได้จำเป็นต้องทำให้เป็นมาตรฐาน
ร่างปัจจุบันบอกว่าอิงจากแนวคิดของผู้เขียน/ผู้ดูแล Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue และ Wiz ก็เลยสงสัยว่าผู้เขียนไลบรารีที่มีอยู่มองข้อเสนอนี้อย่างไร และที่น่าสนใจก็คือ React ไม่ได้อยู่ในรายชื่อ
Signals คล้าย channels อยู่บ้าง แต่ต่างกันตรงที่เป็นการกระจายสัญญาณ ไม่ใช่มีผู้รับคนเดียว ถ้าใช้สิ่งนี้ทำให้ web worker สื่อสารผ่าน channel แทน
onMessagecallback ได้ก็คงเจ๋งดี โดยเฉพาะถ้าสามารถselectบน signals/channels/promises ได้แบบ Go ก็น่าจะได้ประโยชน์เชิงไวยากรณ์มากกว่าการจัดการกลไกส่งข้อความพร้อมกันหลายแบบด้วย callback เช่น ทำให้รวม signals เข้าไปในPromise.anyได้x instanceof Promiseใช้งานไม่ได้แบบนั้นเฉย ๆ ถ้าเมธอดthenในไลบรารีของผมรับ catch callback แต่ของอีกไลบรารีไม่รับ มันก็จะไม่ interoperable กันแบบเงียบ ๆ และไม่มีทางตรวจจับได้ว่าเกิดอะไรขึ้นfinallyควรรันเมื่อไร? เราควรคาดหวังระดับความ asynchronous ของ callback ได้แค่ไหน?ถ้าไม่มีมาตรฐาน ทุกไลบรารีที่ใช้ Promise ก็ต้องพก polyfill ของตัวเองมา เพราะเชื่อถือของที่มีอยู่แล้วไม่ได้ และก็ไม่สามารถใช้ Promise ของไลบรารีอื่นได้จริง เพราะไม่มีทางมั่นใจได้ว่ามันจะทำงานตามที่คาด
นี่ไม่ใช่การเดา แต่มันเคยเป็นแบบนั้นจริง ๆ มาหลายปี และเป็นนรกที่หลายคนต้องทนผ่านมันมา
ความรู้สึกเลือนรางของผมคือ signals มันคล้าย
useEffect()แบบทั่วไปมากเกินไป พอเอาเข้า React แล้วอาจยิ่งทำให้สับสนว่าในรอบการเรนเดอร์เกิดอะไรขึ้นบ้าง จะดีหรือไม่ดี React ก็เลือกแนวทางการอัปเดตที่ต่างจาก signals ไปแล้ว แต่อาจเป็นได้ว่าผมมองเรื่องการนำไปใช้ผิดก็ได้useEffectก็แยกพฤติกรรมแบบ imperative ออกมาไว้อย่างเรียบร้อยมันดูคล้าย data binding ของ Ember มาก และสุดท้ายอาจกลายเป็นฝันร้ายแบบ imperative ได้ สภาพตั้งต้นของมันใกล้เคียงกับ “ปืนลั่นใส่เท้าตัวเอง” มาก และถ้าจะกันไม่ให้เป็นแบบนั้นก็ต้องใช้ภาระทางความคิดมหาศาลพร้อม meta pattern อีกมาก
https://nodejs.org/en/learn/asynchronous-work/the-nodejs-eve...
ฉันไม่เข้าใจตัวอย่างใน README ที่ลิงก์ไว้
// A library or framework defines effects based on other Signal primitivesdeclare function effect(cb: () => void): (() => void);เป็นไลบรารีไหน? เฟรมเวิร์กอะไร? ฉันหลงทางตั้งแต่ตรงนี้
effectคืออะไร?effect(() => element.innerText = parity.get());effectรู้ได้อย่างไรว่าต้องเรียกแลมบ์ดานี้ทุกครั้งที่parityเปลี่ยน? มันเรียกแลมบ์ดานี้ทุกครั้งที่ signal เปลี่ยนหรือ? ถ้าอย่างนั้นจะพูดเรื่องแคชไปทำไม? น่าจะไม่ใช่อย่างไรก็ตาม ถ้าฉันเข้าใจสิ่งที่ผู้เขียนพยายามจะสื่อถูกต้อง แนวคิดเรื่อง signal เองก็ดูสมเหตุสมผลดี เพียงแต่ปัญหาใหญ่ของสถาปัตยกรรมแบบแยกส่วนประเภทนี้คือ เมื่อแอปพลิเคชันซับซ้อนมากพอ คุณจะหลงทางตอนพยายามตามว่าเหตุการณ์ใดเกิดขึ้นเพราะอะไร ตามอุดมคติแล้ว signals ควรแก้ไข stack trace เพื่อให้ตอน callback ถูกเรียก มันมี stack trace ของโค้ดที่เป็นตัวกระตุ้น signal ตั้งแต่แรกติดมาด้วยอยู่แล้ว
effectและทำให้สามารถรันโค้ดอะไรก็ได้เพื่อตอบสนองต่อการอัปเดตของ signal ได้ บทนำเรื่อง signals และ effects ในเอกสารของ Preact อธิบายไว้ดี: https://preactjs.com/guide/v10/signals#effectfnเท่าที่ฉันเข้าใจ ฟังก์ชัน effect แบบนี้จะรัน callback หนึ่งครั้งก่อนเพื่อตรวจว่าระหว่างการรันมีการเข้าถึง signal ใดบ้าง แล้วจะเรียก callback ซ้ำทุกครั้งที่ signal ที่ callback นั้นพึ่งพาถูกอัปเดต ถ้าการเข้าถึง signal เป็นแบบ synchronous และ single-threaded ก็สามารถรู้ได้จากข้อเท็จจริงที่ว่ามีการเข้าถึง signal ระหว่างรัน callback ว่า callback นั้นควร subscribe signal นั้น
ทำแบบนี้ผ่าน getter ก็ได้เช่นกัน ฟังก์ชัน effect จะติดตามว่าในเมธอด getter มีการเข้าถึงพร็อพเพอร์ตีใดของ signal บ้าง และเท่าที่รู้ Vue 2 เคยใช้วิธีนี้ในอดีต หรือจะใช้พร็อกซีเพื่อติดตามการเข้าถึงอ็อบเจ็กต์ก็ได้ ตัวอย่างในข้อเสนอนี้มีเมธอด
getที่ใช้เรียกเพื่อเข้าถึงค่า signal และสามารถติดตาม dependency ผ่านการเรียกเมธอดนี้ได้[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
parity.get()จะลงทะเบียนการพึ่งพาของฟังก์ชันที่ส่งให้effect()เมื่อparityถูกอัปเดต ฟังก์ชันนั้นก็จะถูกเรียกมันไม่ได้เรียกทุกครั้งที่ signal เปลี่ยน แต่จะเรียกเฉพาะเมื่อ signal ที่มันพึ่งพาเปลี่ยนเท่านั้น
ในกรณีนี้
parityพึ่งพาisEvenและisEvenพึ่งพาcounterดังนั้นเมื่อcounterถูกอัปเดต โซ่การพึ่งพาทั้งหมดจะถูกทำให้ใช้ไม่ได้ และparityจะถูกทำให้ใช้ไม่ได้ด้วย ทำให้ callback ถูกรันอีกครั้งeffectสมมตินี้ การอ่านจะสร้างเส้นเชื่อมระหว่าง state node ของ signal กับ computation node ของ effect ซึ่งในทางปฏิบัติคือทำให้อย่างหลัง subscribe การเขียนครั้งถัดไปของอย่างแรก เพื่อจะได้ตัดสินใจได้ว่าควรรันการคำนวณใหม่เมื่อใดeffectคือฟังก์ชันอะไรก็ได้ที่คุณอยากเรียกใน signals กลไกติดตาม dependency จะรู้ว่าต้องคำนวณค่าใดใหม่ และผลลัพธ์ก็คือระบบจะรู้ด้วยว่าต้องเรียกฟังก์ชันใดอีกครั้ง
effectที่เกี่ยวข้องกันมี S.js: https://github.com/adamhaile/s
ฉันชอบ signals มาก เวลาเขียน UI ฉันชอบมันมากกว่า primitive อื่นเกือบทั้งหมด ยกเว้นบางทีอาจมีแค่อัลกอริทึมข้อจำกัด cassowary เท่านั้น ในทุกภาษาที่ฉันใช้เล่น ๆ ฉันพยายามเลียนแบบ signals
แต่ฉันไม่คิดเลยว่านี่ควรเข้าไปอยู่ในตัวภาษา JavaScript เอง ปล่อยภาษาไว้เฉย ๆ สักพักน่าจะดีกว่า ทุกวันนี้คนก็ตามได้ยากพออยู่แล้ว และ TC-39 ก็กำลังทำให้คนจำนวนมากกลัวภาษาและถอยห่างออกไปอยู่แล้ว
นี่ดูคล้ายกับ MobX ซึ่งเป็นระบบ effect ของ JS ที่ฉันชอบที่สุดมาก
เวอร์ชันของ MobX เป็นแบบนี้
import { observable, computed, autorun } from 'mobx';const counter = observable.box(0);const isEven = computed(() => (counter.get() & 1) === 0);const parity = computed(() => isEven.get() ? "even" : "odd");autorun(() => { element.innerText = parity.get(); });setInterval(() => counter.set(counter.get() + 1), 1000);ให้ความรู้สึกแบบ “มาอบเฟรมเวิร์กที่ฉันใช้ช่วงนี้ลงไปใน standard library กันเถอะ!”
คล้ายกับการสักชื่อแฟนไว้บนตัว
แต่มันคือการใส่องค์ประกอบพื้นฐานที่เฟรมเวิร์กส่วนใหญ่เริ่มใช้ร่วมกันลงใน standard library
Promises เองก็ถูกใช้กันอย่างแพร่หลายก่อนจะเข้าไปอยู่ใน standard library และนี่ก็คล้ายกัน