รีดประสิทธิภาพเว็บไปกับ Repaint และ Reflow

Nuttavut Thongjor

ในปี พ.ศ. 2959 ภายใต้การนำของรัฐบาล ประเทศไทยบุกตลาดยางพาราที่ดาวอังคารเป็นผลสำเร็จ แต่เพราะภาษาดาวอังคารออกเสียงยากเกินไปสำหรับคนไทย เราจึงต้องการล่ามเพื่อสื่อสารภาษาไทยกับชาวดาวอังคาร

การสื่อสารผ่านล่ามมันถ่ายทอดไม่ได้ 100% ถ้าล่ามที่คุณเลือกเป็นล่ามที่ถนัดแปลราชาศัพท์ คุณไม่เข้าใจถึงข้อจำกัดนี้ แม้คุณจะขายยางพาราได้ แต่มนุษย์ดาวอังคารอาจงงว่าแค่จะขายยาง จะมากล่าวถวายพระพรกันทำไม

เพื่อให้การเข้าถึงหรือจัดการอีลีเมนต์ต่างๆในหน้าเพจของเรามีประสิทธิภาพมากขึ้น เราจึงต้องเข้าใจพฤติกรรมของเว็บเบราเซอร์ที่เป็นเสมือนล่ามแปลภาษา HTML/CSS ไปสู่การแสดงผลด้วยเช่นกัน

กว่าจะได้มาซึ่งการแสดงผล

เราเขียน HTML เพื่อแสดงผลบนหน้าเพจของเรา และเขียน CSS เพื่อจัดสไตล์ให้หน้าเว็บสวยงาม นั่นคือสิ่งที่เราทำ มันง่ายใช่ไหมหละครับ แต่เบื้องหลังการทำงานของเว็บเบราเซอร์นั้น นี่คือโรงงานนรกดีๆนี่เอง1 1: http://www.html5rocks.com/en/tutorials/internals/howbrowserswork/#The_main_flow

WebKit main flow

ตัวแสดงผลของเบราเซอร์นั้นเมื่อได้รับ HTML เข้ามามันจะทำการแกะ HTML เหล่านั้น จากนั้นจึงแปลงร่างเป็นโครงสร้างต้นไม้ของ DOM (DOM Tree) และเพื่อป้องกันหน้าเว็บจืดชืดดั่งผีดิบ ตัวแสดงผลก็จะทำการแกะสไตล์จาก CSS ด้วยเช่นกัน

เมื่อ DOM Tree และ CSS ที่ประมวลผลแล้วได้มาฟีเจอริ่งกัน จึงเกิดเป็นโครงสร้างต้นไม้ใหม่ขึ้นมาชื่อว่า Render Tree ครับ

Render Tree สำคัญมากครับ มันเป็นโครงสร้างที่ประกอบด้วยชิ้นส่วนที่เรียกว่า Frames โดยเฟรมเหล่านี้คือสิ่งที่จะมองเห็นได้ในหน้าเพจของเรา ดังนั้น <head> จึงไม่อยู่ใน Render Tree ครับ เพราะเรามองไม่เห็นมันในหน้าเพจไง

Render Tree นั้นรู้จักสไตล์ ดังนั้นอีลีเมนต์ต่างๆที่เราใส่ display: none ก็จะไม่อยู่ใน Render Tree เช่นกัน เพราะพวกมันหมดสิทธิ์ได้ไปเดินแคทวอคบนหน้าเพจเราหนะซิ

แม้ DOM Tree จะเป็นผลผลิตขั้นต้นตัวหนึ่งของ Render Tree แต่ต้นไม้ทั้งสองเป็นคนละต้นกัน Dom Tree เก็บทุกอีลีเมนต์ที่มีอยู่ใน HTML แต่ Render Tree คัดสรรเฉพาะเฟรมที่ต้องการใช้เพื่อแสดงผลเท่านั้น

เพื่อให้ Render Tree พร้อมแสดงผลบนหน้าเพจ ข้อมูลที่มีส่วนสัมพันธ์ต่อการแสดงผลต่อไปนี้จึงเก็บไว้ด้วย

  • ลำดับของเฟรมว่าตัวไหนอยู่ก่อนหรืออยู่หลัง
  • เลเยอร์ของ z-index
  • ลักษณะของ CSS โมเดล (CSS Box) เช่นอีลีเมนต์นั้นเป็น inline, block, inline-block หรือ list-item
  • ความกว้าง ความสูง และตำแหน่ง

รู้จักขั้นตอนของ Layout และ Painting

เอาหละทุกอย่างเหมือนจะพร้อมแล้วนะ ตอนนี้เรามี Render Tree ที่บอกว่าแต่ละอีลีเมนต์ HTML ที่เราเขียนมีลำดับก่อนหลังยังไง แต่เรายังไม่รู้ตำแหน่งที่แน่นอนเลยหนิ ว่าสุดท้ายแล้วเฟรมเหล่านั้นจะไปปรากฎอยู่ตำแหน่งไหนของหน้าเพจเรา

ขั้นตอนของ Layout หรือ Reflow คือการคำนวณตำแหน่งและขนาดที่แน่นอนของแต่ละเฟรมจาก Render Tree ว่าสุดท้ายต้องไปปรากฎตัวบนจุดไหนของเพจเรา หรือปรากฎตัวด้วยสีอะไร

เมื่อเล็งเป้าเรียบร้อยว่าจะวางแต่ละเฟรมตรงจุดไหน จะรอช้าทำไมหละครับ ก็จัดการวาดหน้าจอตามแต่ละเฟรมที่คำนวณมาอย่างดีซะซิ เราเรียกขั้นตอนนี้ว่า Painting นั่นเอง

ขั้นตอนของการ Repaint และ Reflow

เมื่อเวลาผ่านไป เราอาจเขียนโค๊ดให้อีลีเมนต์ของเรามีการเปลี่ยนแปลง เช่นเปลี่ยนสีของ div หรือเปลี่ยนความสูงของช่อง input เป็นต้น ในทุกการกระทำต่ออีลีเมนต์ทำให้เกิดสองสิ่งต่อไปนี้

  • หากสิ่งที่ทำเป็นผลให้ตำแหน่ง มิติ ขนาด หรือสไตล์ของอีลีเมนต์เปลี่ยนแปลง เราจะต้องคำนวณเฟรมใน Render Tree ใหม่ เช่นถ้า div ตัวหนึ่งอยู่ซ้อนใต้ div อีกตัว หาก div ที่อยู่ข้างในมีขนาดกว้างขึ้น เราก็ต้องคำนวณขนาดของ div ตัวนอกให้กว้างขึ้นเช่นกัน เพราะ div ตัวนอกครอบตัวในอยู่ จึงต้องขยายขนาดตาม การคำนวณการเปลี่ยนแปลงใหม่นี้เราเรียกว่า Reflow
  • เมื่อหน้าเพจต้องการอัพเดทตัวเองจากผลของการเปลี่ยนแปลงต่างๆ เช่นขนาดอีลีเมนต์เปลี่ยนไป เราก็ต้องทำการวาดหน้าเพจของเราในส่วนนั้นใหม่ เรียกขั้นตอนนี้ว่า Repaint

แน่นอนว่าแค่ฟังก็เหนื่อยแล้วใช่ไหมครับ นี่ถ้าเกิดใครทะลึ่งไปเปลี่ยนสไตล์บนแท็ก body ที่ไปมีผลต่ออีลีเมนต์อื่นๆด้วย ได้ Reflow และ Repaint ตั้งแต่ body ยันตัวล่างเลย ด้วยเหตุนี้เราจึงบอกว่าขั้นตอนของการ Repaint และ Reflow นั้นช้าจนเต่าหลับ

เพื่อเป็นการหลีกเลี่ยงความช้าของทั้ง Repaint และ Reflow เราจะมาดูตัวอย่างกัน ว่าการกระทำไหนทำให้เกิด Repaint หรือ Reflow บ้าง

โปรด Repaint & Reflow ฉันเยี่ยงสัตว์เดรัจฉาน

ตัวอย่างต่อไปนี้ทำให้เกิด Reflow หรือ Repaint

  • การแสดงผล animation นั่นเป็นเพราะมันมีการขยับของอีลีเมนต์ แน่นอนว่าต้องมีการคำนวณตำแหน่งและแสดงผลใหม่
  • เพิ่ม ลบ หรืออัพเดทอีลีเมนต์ต่างๆ
  • เปลี่ยนสไตล์ของ CSS ก้ต้องวาดใหม่เช่นกัน
  • สกอลหน้าเพจก็กระทบ เพราะเราต้องคำนวณอีลีเมนต์ว่าตัวไหนจะนำมาแสดงผลที่ตำแหน่งไหนใหม่อีกรอบ
  • เมื่อสกอลยังไม่รอด คิดหรือว่าย่อขยายหน้าเว็บจะรอด!

สำหรับกรณีของการใส่สไตล์ว่า display: none นั้นแตกต่างจาก visibility: hidden ครับ

display: none จะไม่แสดงผลอีลีเมนต์นั้นบนหน้าเพจ นั่นคืออีลีเมนต์นั้นก็จะไม่ปรากฎบน Render Tree ด้วย เพราะตัวโครงสร้างต้นไม้นี้จัดเก็บเฉพาะเฟรมที่มีการนำไปแสดงผล จึงกล่าวได้ว่าการกำหนด display: none จะทำให้เกิด Reflow (ลบอีลีเมนต์ออกจาก Render Tree) และ Repaint (เมื่อไม่มีอีลีเมนต์จะแสดงก็คำนวณการแสดงผลอีลีเมนต์รอบๆใหม่)

สำหรับ visibility: hidden นั้นต่างออกไป การกำหนดด้วยสไตล์นี้มีผลเพียงซ่อนอีลีเมนต์ไม่ให้ปรากฎให้เห็น แต่อีลีเมนต์ยังปรากฎอยู่ในหน้าเพจเหมือนเดิม เมื่ออีลีเมนต์มันยังมีอยู่และแสดงผล (แต่แสดงผลเป็นการมองไม่เห็นเฉยๆ) มันจึงไม่นำออกจาก Render Tree เป็นผลให้ไม่เกิด Reflow แต่เกิดเฉพาะ Repaint เพื่อนำการแสดงผลออกเท่านั้น

Layout Thrashing

เว็บเบราเซอร์สมัยใหม่เป็นพวกขี้เกียจครับ มันรู้ว่ากระบวนการ Reflow นั้นช้า ด้วยเหตุนี้ตัวเว็บเบราเซอร์จึงมีคิว (Queue) ไว้ใช้งาน เมื่อเกิดการเปลี่ยนแปลงที่ต้อง Reflow มันก็ยังไม่ทำอะไรหรอก แค่ยัดการเปลี่ยนแปลงนั้นลงคิว ส่วนตัวเบราเซอร์ก็ไปยิบกาแฟยามบ่ายอย่างสบายใจ

เมื่อสิ้นสุดคำสั่ง เบราเซอร์ก็จะนำคำสั่งต่างๆในคิวไปทำงานรวดเดียว (batch) ถึงตรงนี้เพื่อนๆก็จะสงสัยต่อว่า การทำงานเป็นชุดแบบนี้ มันดีกว่าเจอคำสั่งไหนก็ไป Reflow ก่อนยังไง?

การ Reflow มักตามมาด้วย Repaint ครับ ดังนั้นถ้าเราเจอการเปลี่ยนแปลงที่ทำให้ต้อง Reflow แล้วทำทันที มันก็จะ Repaint ทันทีเช่นกัน ถ้าเราออกคำสั่งเพื่อ Reflow 10 ครั้ง มันก็จะ Repaint 10 ครั้งตามมาด้วย

แต่ถ้าเราทำเป็นชุดคือรวบรวมข้อคำสั่งทั้งหมดไว้ก่อน แล้วค่อย Reflow รวดเดียว จะทำให้สุดท้ายเกิดการ Repaint ตามมาแค่ครั้งเดียวครับ

JavaScript
1// Reflow แบบเป็นชุด
2element1.color = 'red'
3element2.color = 'green'
4element3.color = 'blue'
5
6console.log(element1.color)
7console.log(element2.color)
8console.log(element3.color)

แต่การเขียนโค๊ดต่อไปนี้ แม้เราจะได้ผลลัพธ์เหมือนกันทุกประการ แต่ประสิทธิภาพที่ได้รับนั้นกลับแย่กว่ามาก

JavaScript
1element1.color = 'red'
2console.log(element1.color)
3
4element2.color = 'green'
5console.log(element2.color)
6
7element3.color = 'blue'
8console.log(element3.color)

แรกเริ่มเรากำหนดค่าสีให้ element1 เป็นสีแดง กระบวนการของคำสั่งนี้แน่นอนว่าทำให้เกิด Reflow เบราเซอร์ควรจะรอคำสั่งที่สามคือการกำหนดค่าสีเขียวให้ element2 ด้วย แต่เบราเซอร์ทำแบบนั้นไม่ได้แล้ว นั่นเป็นเพราะเราดันต้องการทราบว่าสีปัจจุบันของ element1 คือสีอะไร ผ่าน console.log(element1.color)

เมื่อมีการเข้าถึงลักษณะแบบนี้ จึงเป็นการถามไปที่เบราเซอร์ว่าปัจจุบันนี้สีที่ element1 มีคือสีอะไร เบราเซอร์จึงต้อง Reflow ก่อน เพื่อเป็นการอัพเดทค่าสีให้เป็นปัจจุบันที่สุดก่อน จึงจะสามารถตอบคำถามได้ เห็นไหมครับว่าการเขียนแบบนี้มันไม่ดีเอาซะเลย

เทคนิคลดการ Repaint และ Reflow

บางเทคนิคก็เป็นเรื่องเล็กน้อย แต่เชื่อไหมครับว่ามันสามารถช่วยลดการ Repaint และ Reflow ได้ เอาหละเราไปดูกันดีกว่าว่าเทคนิคไหนน่าซื้อบ้าง

ใช้ className

พิจารณาตัวอย่างโปรแกรมต่อไปนี้

JavaScript
1element1.width = '100px'
2element1.height = '200px'
3element1.style.margin = '15px'

ไม่ใช่เบราเซอร์ทุกตัวจะฉลาดพอที่จะ Reflow ทีเดียว หากเบราเซอร์ไม่ฉลาดเพียงพอแล้ว การทำงานของ Reflow ก็ยังคงเกิดอย่างต่อเนื่องถึง 3 ครั้ง

เพื่อเป็นการป้องกันพฤติกรรมดังกล่าว เราสามารถทำให้เกิด reflow เพียงครั้งเดียวได้ โดยการยัดสไตล์ใส่ CSS class แทน ดังนี้

JavaScript
1// CSS
2.card {
3 width: 100px;
4 height: 200px;
5 margin: 15px;
6}
7
8// JavaScript
9element1.className = 'highlight'

หลีกเลี่ยงการคำนวณที่ไม่จำเป็นด้วยการแคช

หากเราต้องการเปลี่ยนความสูงของ element2 และ element3 ให้เป็นสองและสามเท่าของค่า clientHeight ของ element1 ตามลำดับ เราสามารถทำได้ดังนี้

JavaScript
1element2.style.height = element1.clientHeight * 2 + 'px'
2
3element3.style.height = element1.clientHeight * 3 + 'px'

วิธีการนี้ไม่ใช่การกระทำที่ดี นั่นเป็นเพราะเมื่อเราเรียก element1.clientHeight ในแต่ละรอบ มันจะทำการคำนวณเพื่อหาค่าล่าสุดของ element1.clientHeight ก่อน ดังนั้นวิธีที่ดีกว่าคือเราควรแคชค่านี้ไว้ เพื่อหลีกเลี่ยงการคำนวณที่ซ้ำซ้อน

JavaScript
1const e1ClientHeight = element1.clientHeight
2
3element2.style.height = e1ClientHeight * 2 + 'px'
4
5element3.style.height = e1ClientHeight * 3 + 'px'

ใช้ position แบบ fixed หรือ absolute สำหรับ animation

การแสดง animation ด้วยการขยับอีลีเมนต์นั้น หากอีลีเมนต์ขยับไปทิศทางไหนก็จะส่งผลให้อีลีเมนต์ข้างๆขยับตามได้ แต่ถ้าเราตั้งค่า position ให้อีลีเมนต์ดังกล่าวเป็น fixed หรือ absolute จะทำให้การเคลื่อนไหวของอีลีเมนต์นี้ไม่กระทบกับเพื่อนบ้านแต่อย่างใด เป็นผลทำให้ reflow เฉพาะตัวอีลีเมนต์ที่ทำ animation เท่านั้น ไม่ต้อง reflow อีลีเมนต์ใกล้เคียง

แก้ไข DOM แบบออฟไลน์

ถ้าเราแก้ไขอีลีเมนต์โดยตรง ทุกครั้งที่ทำให้เลย์เอาต์เปลี่ยนก็จะเกิด reflow และ repaint หลายรอบ ถ้าเกิดเราคัดลอกอีลีเมนต์นั้นไว้ก่อนโดยลบมันออกจาก DOM จากนั้นเราก็ทำการแก้ไขอีลีเมนต์ที่คัดลอกมานี้ แก้ไขมันให้เต็มที่ไปเลย จากนั้นจึงเอาอีลีเมนต์ที่แก้ไขแล้วนี้ยัดลง DOM ไปอีกครั้ง แบบนี้จะมีประสิทธิภาพมากกว่า

ใช้ requestAnimationFrame

เราบอกว่าถ้าเรารวมกลุ่มของการอ่านและเขียนเข้าไว้ด้วยกันได้ จะมีผลต่อการทำ reflow ให้มีประสิทธิภาพมากขึ้น เช่น

JavaScript
1// Reflow แบบเป็นชุด
2element1.color = 'red'
3element2.color = 'green'
4element3.color = 'blue'
5
6console.log(element1.color)
7console.log(element2.color)
8console.log(element3.color)

แต่โลกความจริงมันเป็นไปได้ยากมากที่ใครจะมาเขียนแบบนี้ โค๊ดระดับโปรแกรมเมอร์มืออาชีพมันต้องเละๆแบบนี้ซิถึงจะถูก

JavaScript
1// Write
2element1.width = '100px'
3// Read
4const e1ClientHeight = element1.clientHeight
5// Write
6element2.style.height = e1ClientHeight * 2 + 'px'

จากตัวอย่างข้างบน การทำงานไม่ได้เป็นแบบ Read ต่อเนื่องหรือ Write ต่อเนื่อง reflow จึงไม่มีประสิทธิภาพเท่าไหร่ ถ้าจะมานั่งจัดให้เป็นระเบียบสำหรับโค๊ดขนาดใหญ่คงเป็นเรื่องยาก ดังนั้นทางออกของเราจึงเป็นการใช้ window.requestAnimationFrame

requestAnimationFrame จะช่วยจัดกลุ่มให้สิ่งที่เราเขียนไว้ได้รับการทำงานในรอบถัดไป

JavaScript
1// Write
2requestAnimationFrame(() => (element1.width = '100px'))
3
4// Read
5const e1ClientHeight = element1.clientHeight
6
7// Write
8requestAnimationFrame(() => (element2.style.height = e1ClientHeight * 2 + 'px'))

เมื่อเราเขียนโค๊ดให้ Write ภายใต้ requestAnimationFrame โค๊ดดังกล่าวทั้งหมดจะทำงานในรอบถัดไป (เฟรมถัดไป) ผลลัพธ์ที่ได้จึงคล้ายกับการทำงานเช่นนี้

JavaScript
1// Read
2const e1ClientHeight = element1.clientHeight
3// Write
4element1.width = '100px'
5// Write
6element2.style.height = e1ClientHeight * 2 + 'px'

เทคนิคในการเพิ่มประสิทธิภาพให้กับ repaint และ reflow ยังมีอีกเยอะครับ เอาเข้าจริงถ้าไม่ใช่ผู้เขียนเฟรมเวิร์กเองก็คงไม่มีใครใส่ใจจุดนี้หรอกมั้งครับ แต่เชื่อเถอะการเก็บรายละเอียดทีละเล็กละน้อยก็มีประโยชน์ อย่างน้อยที่สุดเราก็เข้าใจโครมและหมาไฟมากขึ้น ส่วน IE และ Edge หนะหรือ... บอกเลยเรื่องนี้ริวจะไม่ยุ่ง

เอกสารอ้างอิง

Analyze Runtime Performance. Retrieved September, 27, 2016, from https://developers.google.com/web/tools/chrome-devtools/profile/rendering-tools/analyze-runtime

Mark Wilton-Jones (2006). Efficient JavaScript. Retrieved September, 27, 2016, from https://dev.opera.com/articles/efficient-javascript/?page=3#reflow

Tali Garsiel and Paul Irish (2011). How Browsers Work: Behind the scenes of modern web browsers. Retrieved September, 27, 2016, from http://www.html5rocks.com/en/tutorials/internals/howbrowserswork

Paul Lewis and Paul Irish (2013). High Performance Animations. Retrieved September, 27, 2016, from http://www.html5rocks.com/en/tutorials/speed/high-performance-animations/

Wilson Page (2013). Preventing 'layout thrashing'. Retrieved September, 27, 2016, from http://wilsonpage.co.uk/preventing-layout-thrashing/

@stoyanstefanov (2009). Rendering: repaint, reflow/relayout, restyle. Retrieved September, 27, 2016, from http://www.phpied.com/rendering-repaint-reflowrelayout-restyle/

paulirish. What forces layout/reflow. The comprehensive list.. Retrieved September, 27, 2016, from https://gist.github.com/paulirish/5d52fb081b3570c81e3a

สารบัญ

สารบัญ

  • กว่าจะได้มาซึ่งการแสดงผล
  • รู้จักขั้นตอนของ Layout และ Painting
  • ขั้นตอนของการ Repaint และ Reflow
  • โปรด Repaint & Reflow ฉันเยี่ยงสัตว์เดรัจฉาน
  • Layout Thrashing
  • เทคนิคลดการ Repaint และ Reflow
  • เอกสารอ้างอิง