Kodokon kodokon.com

ไปป์ไลน์การเรนเดอร์: reflow, repaint, compositing

ทำความเข้าใจสี่ระยะของการเรนเดอร์ในเบราว์เซอร์ เพื่อให้คุณรู้แน่ชัดว่าคุณสมบัติ CSS ตัวใดมีต้นทุนสูง และ will-change ทำงานเบื้องหลังอย่างไร

11 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

เบราว์เซอร์เปลี่ยน CSS ของคุณให้กลายเป็นพิกเซลผ่านไปป์ไลน์สี่ระยะ: Style (คำนวณค่า), Layout (เรขาคณิต หรือที่รู้จักกันในชื่อ reflow), Paint (แรสเตอร์ไรซ์ให้เป็นพิกเซล หรือ repaint) และ Composite (ประกอบเลเยอร์ต่าง ๆ บน GPU) กฎทองคือ ยิ่งคุณแตะระยะที่อยู่ต้น ๆ มากเท่าไร ต้นทุนก็ยิ่งสูงเท่านั้น เพราะทุกระยะที่ตามมาต้องถูกประมวลผลใหม่ทั้งหมด การเปลี่ยน width หรือ top จะกระตุ้น layout + paint + composite การเปลี่ยน color หรือ box-shadow จะข้าม layout แต่ยังต้อง repaint มีเพียง transform และ opacity เท่านั้นที่ คอมโพสิเตอร์เธรด สามารถจัดการได้เพียงลำพัง การเคลื่อนไหวจึงยังคงลื่นไหลแม้ว่าเมนเธรดจะถูก JavaScript บล็อกอยู่ก็ตาม

CSS
.slide-bad {
  position: absolute;
  transition: left 0.3s ease;
}
.slide-bad:hover { left: 120px; }

.slide-good {
  transition: transform 0.3s ease;
}
.slide-good:hover { transform: translateX(120px); }
แอนิเมชันสองแบบที่ดูเหมือนกันทุกประการ: แบบแรกบังคับให้เกิด reflow ทุกเฟรม ส่วนแบบที่สองอยู่บนคอมโพสิเตอร์

เบราว์เซอร์นั้นขี้เกียจ: มันจะรวบรวมการเขียนสไตล์ของคุณเป็นชุด และคำนวณ layout ใหม่เฉพาะตอนรีเฟรชครั้งถัดไปเท่านั้น แต่การอ่านค่าบางอย่างใน JavaScript เช่น offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle() บนคุณสมบัติเชิงเรขาคณิต ต้องการค่าที่เป็นปัจจุบัน หากมีการเขียนที่ค้างอยู่ เบราว์เซอร์ต้องคำนวณใหม่ ในทันทีและแบบซิงโครนัส: นั่นคือ forced reflow การสลับกันอ่านและเขียนภายในลูปจะทำให้เกิด layout thrashing: เกิด reflow เต็มรูปแบบหนึ่งครั้งต่อหนึ่งรอบการวนซ้ำ แทนที่จะเกิดเพียงครั้งเดียวสำหรับทั้งชุด

JAVASCRIPT
const items = document.querySelectorAll('.item');

items.forEach((item) => {
  const h = item.offsetHeight;
  item.style.height = h * 2 + 'px';
});

const heights = [...items].map((i) => i.offsetHeight);
items.forEach((item, index) => {
  item.style.height = heights[index] * 2 + 'px';
});
เวอร์ชัน 1: เกิด forced reflow หนึ่งครั้งต่อหนึ่งเอลิเมนต์ เวอร์ชัน 2: อ่านค่าทั้งหมดก่อน แล้วจึงเขียนทั้งหมด เกิด reflow เพียงครั้งเดียว

การทำ compositing จะแบ่งหน้าเว็บออกเป็นเลเยอร์ที่ถูกแรสเตอร์ไรซ์แยกกันและประกอบเข้าด้วยกันโดย GPU will-change: transform เป็นการร้องขอให้เอลิเมนต์ถูกเลื่อนขึ้นไปเป็นเลเยอร์ของตัวเอง ก่อน ที่แอนิเมชันจะเริ่ม จึงหลีกเลี่ยงการแรสเตอร์ไรซ์ที่มีต้นทุนสูงในเฟรมแรก มีรายละเอียดปลีกย่อยในสเปกที่มักถูกมองข้าม: will-change ต้องสร้างผลข้างเคียงแบบเดียวกับคุณสมบัติที่มันประกาศไว้ ดังนั้น will-change: transform จึงสร้าง stacking context และทำให้เอลิเมนต์กลายเป็น containing block สำหรับลูกหลานที่เป็น position: fixed เหมือนกับ transform ของจริงทุกประการ คำใบ้ด้านประสิทธิภาพง่าย ๆ จึงอาจทำให้ลำดับการซ้อน (stacking order) หรือเอลิเมนต์ fixed ของคุณเสียหายได้

JAVASCRIPT
const panel = document.querySelector('.panel');

panel.addEventListener('pointerenter', () => {
  panel.style.willChange = 'transform';
});

panel.addEventListener('transitionend', () => {
  panel.style.willChange = 'auto';
});
รูปแบบที่สเปกแนะนำ: เลื่อนขึ้นเป็นเลเยอร์ก่อนแอนิเมชันเริ่ม แล้วปล่อยทันทีหลังจากแอนิเมชันจบ

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. แอนิเมชันแบบใดที่คอมโพสิเตอร์เธรดสามารถจัดการได้ทั้งหมดโดยไม่ต้อง reflow หรือ repaint?
    • height จาก 0 ไปยัง 200px
    • margin-left จาก 0 ไปยัง 100px
    • transform: translateX(100px)
    • box-shadow เมื่อ hover
  2. เหตุใดการอ่าน offsetHeight ภายในลูปที่มีการแก้ไขสไตล์ด้วยจึงมีต้นทุนสูงมาก?
    • การอ่านแต่ละครั้งบังคับให้เกิด reflow แบบซิงโครนัสหากมีการเขียนที่ค้างอยู่
    • offsetHeight กระตุ้นให้ GPU เกิด repaint ทุกครั้งที่เข้าถึง
    • คุณสมบัตินี้ไม่เคยถูกแคชโดยเอนจิน JavaScript
  3. will-change: transform มีผลข้างเคียงใดร่วมกับ transform ของจริง?
    • มันปิดใช้งาน CSS transitions ของเอลิเมนต์
    • มันสร้าง stacking context และ containing block สำหรับ position: fixed
    • มันบังคับให้เอลิเมนต์กลายเป็น display: block
    • มันเพิ่มความละเอียดการแรสเตอร์ไรซ์เป็นสองเท่า