React 18 กับ API ใหม่ ๆ ที่ช่วยให้เราจัดการ Performance ได้ดีขึ้น

React 18 จะทำให้ Web App มีความ Responsive มากขึ้น?

Jetarinc
LifeatRentSpree
7 min readSep 30, 2021

--

Overview

หลายคนคงจะได้ยินมาบ้างแล้วว่า React 18 นี้จะมาใน theme ของ out-of-the-box improvement ที่จะทำให้ React ทำงานได้เร็วขึ้น ตอบสนองต่อ user ได้ดีขึ้นอย่างที่จั่วหัวไว้ โดยที่เราแค่ทำการ opt-in feature ต่างๆเข้ามาใช้ แล้ว React จะทำงานให้มีประสิทธิภาพมากขึ้น ซึ่ง API ในเวอร์ชั่นใหม่ๆ ที่ทำการอัพเดตนั้น ก็จะไม่มี breaking change ใดๆทั้งสิ้นเลย (ยกเว้นกรณีของ Automatic Batching ใน class component ซี่งใน blog นี้จะยกตัวอย่างให้เห็น)

และ Blog นี้จะ walkthrough feature หลักๆใน React 18 ให้ดูเป็นตัวอย่าง และในช่วงท้ายจะมีบทวิเคราะห์ในมุมมองของผมเกี่ยวกับ React 18 ที่จะเข้ามานี้

React 18 มาพร้อมกับ Feature หลักๆ 3 อย่างนี้:

  1. Automatic Batching: มัดรวมการเปลี่ยนแปลงแล้วทำการ render ภายในทีเดียว
  2. startTransition: สามารถกำหนดลำดับความสำคัญ ก่อน/หลัง ของการ render ได้เองเพื่อเลือกอัพเดทการแสดงผลในส่วนที่ critical ก่อน
  3. Suspense for SSR: React.lazy และ Suspense สามารถใช้ได้ใน Server-side rendering แล้ว

1. Automatic Batching

เราอาจจะคุ้นๆกันว่า React 17 สามารถทำ batching การ render ได้อยู่บ้างแล้วโดยที่ถ้าเราดูจากตัวอย่าง Code ข้างล่างซ้ายที่ มีการ setState ถึง 2 ครั้งภายใน event handler ที่ชื่อว่า handleClick ซึ่งพอมีการเรียกฟังก์ชั่น มันก็จะ render แค่ครั้งเดียวตามที่แสดงใน console รูปข้างล่างขวา

ตัวอย่างการ Batching renderใน React 17 — https://codesandbox.io/s/spring-water-929i6?file=/src/index.js

แล้วมันพิเศษตรงไหนหละสำหรับ Automatic Batching ใน React 18 เนี่ย…
ปัญหาคือ ใน React 17 ถ้า function ที่ทำการ setState มันไม่ได้เป็น event handler เฉยๆละก็ มันจะ render ทุกจุดที่เราทำการ setState เลย ดูภาพตัวอย่างข้างล่างเมื่อเป็น promise มันจะ render 2 ครั้ง เมื่อมีการเรียก function handleClick

ในกรณีของ React 18 มันสามารถที่จะมัดรวมการ setState พวกนี้แล้วทำการ render ทีเดียวได้ เพียงแค่เราใช้ createRoot แทนการเรียก render ทีนี้มันจะทำ Automatic Batching ให้เองเลย เราสังเกตุความแตกต่างในการ render ได้จาก 2 รูปด้านล่าง

React 18 createRoot: render เพียงแค่ 1 ครั้ง — https://codesandbox.io/s/morning-sun-lgz88?file=/src/index.js
React 18 legacy render: render ถึง 2 ครั้ง — https://codesandbox.io/s/jolly-benz-hb1zx?file=/src/index.js

ซึ่ง Automatic Batching นั้นจะทำการมัดรวมการ render ให้หมดเลย ไม่ว่า function จะเป็นลักษณะใด ๆ ก็ตาม ดังนี้:

event handler

setTimeout

promise

event listener

ใช้ flushSync เพื่อยกเลิกการ Batching

ในบางกรณีเราอาจจะต้องการอ่านค่าจาก DOM ทันทีหลังจากมีการเปลี่ยน state และ render ภายใน function เราสามารถยกเลิก Automatic Batching ด้วยการ setState ใน callback ของ flushSync()

ใช้ flushSync จาก React DOM เพื่อยกเลิกการ Batching

การ Automatic Batching ของ functional และ class component

Automatic Batching ในกรณีของ functional component ที่ใช้ hooks จะทำงานได้ตาม behavior ที่มันควรจะเป็น

ตามรูปข้างล่าง state ภายในฟังก์ชั่นจะให้ค่าเริ่มต้นเสมอไม่ว่าเราจะเรียกจากจุดแรก หรือหลังการ setState ตรงนี้มันเป็น behavior ของ hooks อยู่แล้ว ไม่ว่าจะมี หรือ ไม่มี Automatic Batching

state ใน hooks

แต่ในกรณีของ class component จะมี use case แปลกๆที่เราสามารถอ่านค่า state ทันทีหลังจากมีการ setState ภายใน function นั้น ตามภาพข้างล่างกรณีที่ยังไม่ใช้ Automatic Batching

class component กรณีที่ไม่มี Automatic Batching

ทีนี้พอมี Automatic Batching การอ่านค่าของ state มันจะต่างไป คล้ายๆกับในกรณีของ hooks ดังภาพข้างล่าง

class component กรณีที่มี Automatic Batching

ถ้าเรา opt-in Automatic Batching เข้ามาใช้ แล้วอยากให้เคส class component นี้ทำงานเหมือนเดิม ก็จะต้องใช้ flushSync เข้ามาช่วยแบบรูปข้างล่างนี้

ใช้ flushSync เพื่อแยกการ render ใน class component

2. startTransition

startTransition จะเป็น API ให้เราเรียกใช้เพื่อที่จะกำหนดให้ application ของเรา render ในส่วนที่สำคัญที่ user จะต้องเห็นการตอบสนองทันทีก่อน (Urgent update) แล้วค่อย render ส่วนอื่นทีหลังได้ (Transition update)

ใน application ทั่วๆไปอาจจะมีเคสที่ user ทำการกรอก input อะไรบางอย่าง แต่มีการเปลี่ยนแปลง หรือการ render มากมายเกิดขึ้นในหน้าจอ บางทีการ render แต่ละครั้งจะกินทรัพยากรอย่างมาก และผลกระทบก็คือ application เราจะเกิดอาการ lag ทำให้ในส่วนที่ควรจะตอบสนองให้ user เห็นอย่างทันทีก็จะค้างไปด้วย ลองดูความแตกต่างข้างล่างใน 4 กรณี:

  1. Fast device / no transition

2. Fast device / with transition

3. Slow device / no transition

4. Slow device / with transition

application นี้จะมีการ render หลักๆอยู่ 2 ส่วนด้วยกัน คือ input slider และ bubbles ตรงกลางจอ ในเคสของ fast device เราจะไม่ค่อยเห็นความแตกต่างของ transition (จะเห็นได้เล็กน้อยว่า slider มันไม่ได้ติดมือขนาดนั้น) แต่ในเคสของ slow device / no transition จะเห็นได้ชัดเลยว่าอาการ lag ของการ render มันทำให้ slider ไม่ได้แสดงผลได้อย่าง responsive เลย

ซึ่ง transition นั้นเข้ามาช่วยให้ slider นั้นแสดงผลได้อย่างทันทีแม้ว่า device นั้นจะช้าหรือเร็ว (Urgent update)
ในส่วนของ bubbles ซึ่งกินทรัพยากรในการ render และเป็นส่วนที่ไม่จำเป็นจะต้อง rensponsive มากจะค่อย render ตามมาทีหลัง (Transition update)

วิธีใช้ก็แค่นำส่วน Transition update ไปใส่ไว้ใน callback ของ startTransition แบบรูปข้างล่างนี้

ใช้ startTransition เพื่อกำหนด Transition update

startTransition ต่างจาก setTimeout ยังไง?

ข้อแตกต่างชัดๆเลยคือ setTransition จะทำการ execute code ในส่วนนั้นทันที แค่ delay ในส่วนของการแสดงผล โดยที่ถ้าระหว่างรอการ render นั้นมีการอัพเดทค่าใหม่เข้ามา React จะทำการ discard ตัว stale render ทิ้ง แล้วไป render ค่าที่อัพเดทใหม่แทน (เสมือนการ debounce การ render)

กรณีของ setTimeout มันคือการ delay code ทั้งชุดนั้นเลย และไม่ได้มีการทำงานในลักษณะของการ debounce เหมือน setTransition และสุดท้ายพอถึงเวลาที่ต้องรันคำสั่งใน setTimeout มันจะต้อง render สิ่งที่กินทรัพยากร แล้วก็จะไป block การ render ของ input อยู่ดี
แถมถ้าเป็นเคสกรณีที่ device ทำงานได้เร็วอยู่แล้ว ก็จะโดน setTimeout หน่วงโดยที่ไม่จำเป็น

สามารถอ่านค่า pending state ในขณะรอ Transition update ได้ด้วย

useTransition hook

การจะใช้ startTransition ก็สามารถเรียกได้จาก hook useTransition และในขณะที่รอ Transition update เราสามารถใช้ค่า isPending จาก hook เพื่อเลือกแสดงผล loading spinner ได้

3. Suspense for SSR

ก่อนจะอธิบายว่าทำไม Suspense ถึงมาช่วยเรื่อง SSR จะเกริ่นเรื่อง SSR คร่าวๆก่อนว่ามีขั้นตอนอะไรบ้าง

SSR under the hood มีลำดับขั้นตอนดังนี้ โดยเริ่มตั้งแต่ user ทำการ request จนกระทั่ง web application สามารถตอบสนอง user ได้

  1. เมื่อ user มีการ request หน้าเพจหนึ่ง ที่ฝั่ง server อาจจะมีการเรียก API call หรือ query database เพื่อมาเป็นคอนเทนต์ให้ HTML ของหน้าเพจนั้นๆ
  2. ที่ฝั่ง server ทำการ render HTML แล้วจึงส่งกลับไปเป็น response
  3. ที่ฝั่ง client จะต้องมีการ load javascript ที่มี logic ของ web application นั้นๆ
  4. ที่ฝั่ง client จะต้อง connect javascript เข้ากับ HTML ที่ได้มาจาก request เพื่อให้ web application ทำงานได้ (hydration: map react DOM เข้ากับ HTML DOM)

ปัญหา bottle-neck ของ SSR

ซึ่งในทุกๆขั้นตอน มันมีโอกาสที่จะก่อให้เกิด bottle-neck ได้ ถ้ามีขั้นตอนใดขั้นตอนหนึ่งทำงานช้ามากๆ และกว่าที่ user จะทำการ interact กับ web application ได้ ก็ต้องรอให้ทำงานจนเสร็จทุกขั้นตอน เคสที่เกิดขึ้นบ่อยๆก็จะขอยกตัวอย่างแบบนี้:

  • มี slow database query หรือ API call ทำให้กว่าจะได้ไปทำขั้นตอนต่อไปก็ต้องรอ operation พวกนี้ก่อน
  • ต้อง load javascript มาให้ครบทั้งหมดก่อนที่จะเริ่มทำ hydration เพราะการ hydration เป็นการทำงานแบบ single pass
  • ต้องทำ hydration ให้เสร็จก่อนที่จะทำให้เวปมี interaction ได้ อันนี้ก็เพราะ single pass hydration อีกเช่นกัน

การแก้ปัญหา bottle-neck ของ SSR ใน React 18

React 18 ทำการแก้ปัญหาที่กล่าวมาข้างต้นด้วยวิธีการแบบนี้

  1. Streaming HTML ในฝั่ง server: ทำให้ส่ง HTML ในส่วนที่ critical ไปก่อนได้ (ที่ user ควรจะเห็นเร็วที่สุด) แล้วค่อยส่งบางส่วนตามไปทีหลัง วิธีการ opt-in ก็คือ เปลี่ยนจาก API renderToString มาเป็น pipeToNodeWritable
  2. Selective Hydration ในฝั่ง client: เลือก hydrate ในส่วนที่ critical (ที่ user ต้องการมี interaction เร็วที่สุด) แล้วค่อย hydrate ส่วนอื่นทีหลัง วิธีการ opt-in ก็คือ ใช้ API createRoot คู่กับ <Suspense/>

Streaming HTML

เราสามารถส่ง HTML ส่วนหนึ่งที่อาจจะไม่ได้มี API call หรือ database query ไปก่อนได้ พร้อมกับ fallback component ของส่วนที่จะส่งตามไปทีหลัง เหมือนรูปข้างล่างนี้ ในขณะที่ <Comments/> กำลังรอ API call อยู่ ส่วนอื่นๆจะถูกส่งไปเป็น HTML เพื่อให้ user เห็นก่อนและมี <Spinner/> ไปแทนในส่วนของ <Comments/>

Streaming HTML และ fallback component

แล้วพอ <Comments/> มีข้อมูลที่พร้อมแล้ว ก็จะถูกส่งตามไปเป็นส่วนหนึ่งของ HTML พร้อมกับ inline javavscript เพื่อที่จะบอกว่าต้องเอาไปเติมในส่วนไหนของ DOM

HTML ในส่วน Comments พร้อม inline javascript

Selective Hydration

ต่อจากข้างบน เวปก็จะมีหน้าตาที่เหมือนจะใช้ได้ทั้งหมดแล้ว แต่มันยังใช้ไม่ได้ เพราะยังขาด hydration ที่นี้พอเรากำหนดส่วนที่ทำการ Suspense ไป React สามารถที่จะ hydrate HTML ส่วนแรกโดยที่ไม่จำเป็นจะต้องรอ <Comments/> ด้วยซ้ำ รูปข้างล่างในส่วนสีเขียนคือส่วนที่ hydrate แล้ว

Selective Hydration ในส่วนที่อยู่นอกเหนือ Suspense

Interaction ในขณะที่ทำการ Hydration

เพราะ Selective Hydatation ทำให้ user มี interaction กับ web application ในบางส่วนที่มีการ hydrate เสร็จแล้วได้ จากที่เมื่อก่อนจะต้องรอให้ hydrate เสร็จทั้งหน้า อย่างเคสข้างล่าง ขณะที่กำลัง hydrate ส่วน Comments ก็ยังสามารถ click ในส่วนอื่นที่มีการ hydrate เสร็จแล้วได้

user สามารถ interact กับส่วนที่ hydrate เสร็จแล้วได้ ในขณะที่บางส่วนยัง hydrate อยู่

Hydration Order

สำหรับ React 18 โดยปกติถ้ามีการใช้ Suspense มากกว่า 1 ที่ ในหน้านั้นๆ React จะทำการ hydrate Suspense ชุดแรกที่เจอก่อน ในลำดับของ DOM แล้วค่อยทำ Suspense ตัวที่เจอทีหลังเป็นลำดับถัดมา

Hydrate ในส่วนของ Sidebar ก่อน Comments

Hydration based on user interaction

ความเทพของ React 18 มันอยู่ตรงที่มันสามารถ prioritize hydataion ในส่วนที่ user มี interaction ก่อนได้ ตัวอย่างตามด้านบน ในขณะที่ React กำลัง hydrate Sidebar อยู่ แต่ user พยายามที่จะ click ในส่วน Comments ก่อน React ก็จะเบนไป hydrate ส่วนนั้นก่อน แล้วจึงค่อยกลับมา hydrate ในส่วน Sidebar ทีหลัง

React hydrate ส่วนที่ user สนใจก่อน

ในเรื่องของ Suspense ใน SSR ค่อนข้างจะซับซ้อน แนะนำให้ไปลองเล่น Demo ของ Dan จะทำให้เห็นภาพมากขึ้น ใน Demo จะมีการ Mock-up delay พวก API call กับ javascript loading ด้วย

My Thoughts About React 18

React 18 นี้ก็ดูจะเป็น big improvement ในด้านของ performance ด้วย
Automatic Batching รวมทั้งการ prioritize การ render ด้วย start transition และ เพิ่มความเร็วของ user interaction ใน SSR ซึ่งรวมๆแล้วมันจะทำให้ web application ของเรามีความ responsive มากขึ้น พร้อมทั้งการ opt-in ที่ไม่ได้วุ่นวาย หลังจากที่อ่าน Github discussion ของ Dan ก็ดู promising มากๆ เลยเอามาสรุปใน Blog นี้ ก็หวังว่าจะได้ลองใช้ใน Production เร็ว ๆ นี้ครับ

แต่อย่างไรก็ตาม React 18 ยังมีจุดที่เราต้องระวังอยู่บ้าง ดังนี้:

Breaking Change ของ Automatic Batching ใน Class component
สำหรับ legacy code ที่ยังมีการใช้ class component อยู่ ก็ยังสามารถอัพเดทเป็นเวอร์ชั่น 18 ได้ แต่การจะเปลี่ยนมาใช้ createRoot ก็จะต้องเช็ค behavior ของ Automatic Batching ด้วย และการที่ใช้ flushSync มาแก้ปัญหาตรงนี้ก็อาจจะสร้างปัญหาเรื่องของ readability อยู่พอสมควร เพราะมันเป็น callback

startTransition เป็นแค่ complementary เท่านั้น มันไม่ใช่ silver bullet
ถึงแม้จะมี startTransition มาช่วยในเรื่องของการ กำหนดการ render ได้ก็ตาม การเขียน code ให้ดีเพื่อหลีกเลี่ยงการ re-render ที่ไม่จำเป็น ก็ยังเป็นสิ่งที่ควรต้องทำอยู่ดี อย่างเช่นเคสตัวอย่าง (slider + bubbles) จริงๆแล้วไม่จำเป็นจะต้องใช้ startTransition ถ้าเราไม่ต้องกำหนด state ให้ slider อาจจะใส่ handler onChange อย่างเดียว
เพราะฉะนั้นถ้าทำการ optimize code ให้ดีแล้ว และอยาก improve เรื่อง user experience เพื่อจัดลำดับความสำคัญของการ render และกำหนด loading state ก็จึงค่อยเลือกใช้ startTransition และแน่นอนว่าการใช้อย่างพร่ำเพื่อและไม่จำเป็นจะต้องมี overhead ในแง่ของ performance และ readability อยู่แล้ว

ผลกระทบต่อ SEO ของ HTML Streaming
ผลดีต่อ SEO ที่จะได้จากการใช้ HTML Streaming ที่เห็นได้ชัดเลยคือการลดระยะเวลาของ Time to First Byte, Time to First Paint และ Time to First Contentful Paint เพราะเราจะ Serve HTML ได้เร็วขึ้น แสดงผล DOM ได้เร็วขึ้น ซึ่งความเร็วพวกนี้ก็เป็นปัจจัยหลักในการจัด Ranking ของ Search Engine

แต่ข้อควรระวังในการใช้ HTML Streaming ก็จะมีในแง่ที่ว่า crawler อาจจะไม่ index ข้อมูลที่ตามมาใน Streaming ชุดหลัง ถ้ามีการ Streaming ที่ใช้เวลานานเกินไป (ถึงแม้ว่า crawler ของบางเจ้าอย่าง Google สามารถที่จะ index ข้อมูลฝั่ง Client-Side Rendering ได้ก็ตาม) ซึ่งตรงนี้ยังไม่ค่อยมีข้อมูลชัดเจนว่า crawler นั้นจะมี timeout ในการรอ content ได้มาก/น้อยเท่าไหร่ ซึ่งใน Github dicussion ก็มีการพูดถึง API อีกตัวที่ใช้เพื่อ delay การ flush stream แต่ถ้ายิ่งนานก็จะยิ่งส่ง first byte ได้ช้าขึ้นและมีโอกาสโดน penalize ranking ได้มากขึ้นเท่านั้น
เพราะฉะนั้นบางจุดที่เป็น critical content ที่อยากให้เป็น SEO friendly แล้วไม่ได้เป็นข้อมูลที่ volatile มากนัก อาจจะเลือกใช้วิธีการ API caching เข้ามาช่วยได้

PS.

  • ในขณะที่เขียน blog นี้อยู่ React 18 ยังอยู่ในช่วงพัฒนา alpha อยู่นะครับ อาจจะมีการเปลี่ยนแปลงหลังจากนี้ได้บ้าง
  • ใน Github discussion ก็เห็น Dan Abramov แอบบอกเราว่า Server Component ที่หลายคนรอคอย จะยังไม่เข้ามาใน major update ของ React 18 นี้ (ซึ่งตัวนี้ก็น่าจะมาช่วยเรื่อง performance ได้อีกเช่นกัน ในเรื่องของการลด bundle size) แต่ก็มีความเป็นไปได้อย่างมากที่จะตามมาใน minor patch

Reference

--

--