• ระหว่างสร้าง GUI ฐานข้อมูลสำหรับ MongoDB และ PostgreSQL จำเป็นต้องรองรับชนิดข้อมูล BSON·JSONB, คอลัมน์ซ้อน, การค้นหา, การแก้ไข, การตรึง และการลากวาง จึงใช้เวลาราว 1 ปีในการปรับแต่ง virtualization สองแกน และโครงสร้างการจัดการสถานะ
  • สร้าง shadow table ที่คำนวณสตริงสำหรับแสดงผล·ชนิดข้อมูล·พาธที่ flatten แล้ว·ลำดับคอลัมน์·ผลการค้นหาไว้ล่วงหน้าแยกจากเอกสารต้นฉบับ และเรนเดอร์เฉพาะแถวกับคอลัมน์ที่มองเห็นบนหน้าจอด้วย DOM ขนาดคงที่
  • ในเส้นทางการเลื่อน ใช้ passive event listener, requestAnimationFrame, buffer·hysteresis และการติดตามความเร็ว พร้อมใช้ transform และ opacity แทนพร็อพเพอร์ตีที่กระทบ layout เพื่อลดงานบน main thread
  • เปลี่ยนไอคอนรายเซลล์เป็นภาพพื้นหลัง SVG แบบใช้ร่วมกัน และ mount editor เฉพาะเมื่อจำเป็น พร้อมใช้ DOM pooling ที่ติดตามแถวและคอลัมน์ตามตำแหน่งเพื่อตัดการสร้าง node ระหว่างเลื่อน
  • แม้ Canvas จะให้เพดานประสิทธิภาพระดับ 60fps สูงกว่า DOM แต่เสียเปรียบด้านข้อความ·การเลือกข้อความ·การเข้าถึง·การขยายฟีเจอร์ จึงเลือก สถาปัตยกรรมแบบ DOM เพื่อคงการเลือกข้อความจริงและความเร็วในการพัฒนา

เป้าหมายและข้อจำกัดเริ่มต้น

  • เริ่มจากอาร์เรย์สองมิติธรรมดาและลูปซ้อน แต่สุดท้ายกลายเป็นงานปรับแต่งต่อเนื่องเป็นระยะนานเกือบ 1 ปี
  • ตารางใน GUI ฐานข้อมูลไม่ได้มีไว้แค่แสดงผล แต่ต้องรองรับสถานะและการโต้ตอบหลายรูปแบบ
    • ต้องเข้าใจ BSON ทุกชนิด ของ MongoDB และ JSONB ของ PostgreSQL เป็นต้น พร้อมแสดงไอคอนสีตามชนิด
    • ต้องแยกชนิดที่ทำให้ผลลัพธ์ query ต่างกัน เช่น สตริง "123" กับจำนวนเต็ม 123
    • ต้องขยายเอกสารซ้อนเป็นคอลัมน์ย่อยจริง ค้นหาตามพาธซ้อนทั้งหมด และไฮไลต์ส่วนที่ตรงกันภายในเซลล์
    • ต้องมีฟีเจอร์เรียงคอลัมน์ใหม่·ปรับขนาด·ตรึง, แก้ไขภายในเซลล์, และลากค่า·แถว·คอลัมน์ไปยัง visual query builder
  • สถานะของฟีเจอร์เหล่านี้ไม่มีอยู่ในเอกสารต้นฉบับ และต้องคงอยู่หลังการเลื่อน จึงต้องมีโครงสร้างเรนเดอร์แยกต่างหาก

ขั้นที่ 1: เรนเดอร์ทุกอย่างโดยตรง

  • วิธีสร้างทุกเซลล์ด้วยการวนซ้อนตามแถวและฟิลด์นั้นทำงานได้ที่ 100 แถว แต่พังทันทีเมื่อข้อมูลใหญ่ขึ้น
    • 10,000 แถว × 30 คอลัมน์ สร้าง DOM node ราว 300,000 ตัว และระบบ change detection ของเฟรมเวิร์กจะต้องวนตรวจมันซ้ำ ๆ
    • หากคิดว่า DOM node หนึ่งตัวใช้ราว 1KB รวมโครงสร้างภายในเบราว์เซอร์ ก็ต้องใช้หน่วยความจำหลายร้อย MB ก่อนถึงข้อมูลจริง
    • งบเวลาต่อเฟรมที่ 60fps คือ 16.7ms และยังต้องแบ่งให้ style·layout·paint ด้วย
  • ในการทำจริง แค่พยายามเรนเดอร์ 1,000 แถวที่มีราว 20 คอลัมน์ก็ล้มเหลวแล้ว
  • ถ้าจะเรนเดอร์แค่บางส่วน ต้องติดตามแถวและคอลัมน์ที่กำลังมองเห็น, ลำดับ, การขยายฟิลด์ซ้อน, และผลค้นหาแยกต่างหาก

ขั้นที่ 2: แยกสถานะแสดงผลด้วย shadow table

  • เอกสารต้นฉบับมีทั้งโครงสร้างซ้อนและชนิดข้อมูลหลากหลาย จึงไม่เหมาะจะใช้เป็นอินพุตเรนเดอร์โดยตรง
    • MongoDB มีค่า BSON อย่าง ObjectId·Decimal128·timestamp·binary
    • ข้อมูล SQL มี JSONB และ timestamp ที่มี timezone รวมอยู่ และแต่ละเซลล์ต้องตัดสินใจรูปแบบการแสดงผล
  • หากตัดสินรูปแบบในลูปเรนเดอร์ ต้นทุนเดิมจะเกิดซ้ำทุกเฟรม และข้อมูลต้นฉบับก็ไม่มีสถานะของตารางอย่างลำดับคอลัมน์·สถานะการขยาย·ผลการค้นหา
  • shadow table ทำหน้าที่เป็นจุดอ้างอิงของสถานะที่ตารางจริงต้องแสดง โดยไม่แก้ไขเอกสารต้นฉบับ
    • สร้างครั้งเดียวตอนโหลด แล้วอัปเดตเมื่อสถานะเปลี่ยน โดยไม่เปลี่ยนระหว่างการเลื่อน
    • คำนวณสตริงแสดงผลที่ถูกตัดให้สั้น, ชนิดข้อมูลที่ชัดเจน, และพาธที่ flatten แล้วไว้ล่วงหน้าสำหรับแต่ละเซลล์
    • จำกัดความยาวของสตริงแสดงผลเพื่อไม่ให้เอกสาร 16MB สร้างสตริง 16MB ในสถานะเรนเดอร์แบบตรง ๆ
    • ชนิดข้อมูลใช้กำหนดไอคอน·editor·วิธีค้นหา
    • ใช้พาธแบบ flatten อย่าง "address.geo.lat" เป็นคีย์ เพื่อไม่ต้องไล่ tree ใหม่ทุกครั้ง
  • เมื่อขยาย object ที่ซ้อนอยู่ พาธย่อยจะถูกยกระดับเป็นคอลัมน์จริง และผลการเรียง·ผลการค้นหา·ลำดับคอลัมน์·สถานะการขยายก็ถูกเก็บในโครงสร้างเดียวกัน
  • ขั้นนี้ยังไม่ได้ลดจำนวน DOM แต่ปูพื้นฐานให้คำนวณลำดับและความกว้างคอลัมน์ในภายหลังได้เร็วขึ้น

ขั้นที่ 3: virtualization แนวตั้งและพื้นที่เลื่อนหลอก

  • virtualization แนวตั้งจะเรนเดอร์เฉพาะแถวใน viewport กับ buffer เล็กน้อย และทำความสูงที่เหลือเป็นพื้นที่หลอก
  • phantom area คือคอนเทนเนอร์ภายในที่มีความสูง rowCount × rowHeight
    • ถ้าเป็น 1 ล้านแถว × 40px ก็จะได้ div สูง 40 ล้าน px ที่แทบไม่มีเนื้อหาจริง
    • เบราว์เซอร์ใช้ความสูงนี้สร้าง scrollbar แบบ native และพฤติกรรมการเลื่อน
  • ช่วงที่ต้องแสดงคำนวณได้ดังนี้
    • firstRow = floor(scrollTop / rowHeight)
    • lastRow = floor((scrollTop + viewportHeight) / rowHeight)
    • เพิ่ม buffer row ทั้งสองด้านเพื่อไม่ต้องเรนเดอร์ใหม่ทุกครั้งที่ขยับเล็กน้อย
  • แถวที่จะแสดงจะถูกวางในคอนเทนเนอร์ slab ที่ตำแหน่ง firstRow × rowHeight และการใช้ transform ดีกว่า top
  • ผู้ใช้มองเห็นเหมือนมี 1 ล้านแถว แต่ใน DOM มีจริงแค่ราว 40 แถว
  • แต่ถ้ามี 300 คอลัมน์ ต่อให้มีแค่ 40 แถวก็ยังได้ 12,000 เซลล์ จึงต้องทำ virtualization แนวนอนด้วย

ขั้นที่ 4: virtualization แนวนอนสำหรับความกว้างที่แปรผัน

  • collection ในฐานข้อมูลเอกสารอาจมีหลายร้อยฟิลด์ จึงต้องมี column virtualization ด้วย
  • เนื่องจากความกว้างคอลัมน์ไม่เท่ากัน จึงใช้ผลรวมสะสมและ binary search แทนการหารด้วยค่าคงที่
    • ใช้ผลรวมสะสมรูปแบบ position[n] = width[0] + ... + width[n-1] เพื่อหาพิกัด x ของแต่ละคอลัมน์ด้วยการอ่านจากอาร์เรย์ครั้งเดียว
    • คอลัมน์ที่ตรงกับ scroll offset x หาได้ด้วย binary search บนอาร์เรย์ผลรวมสะสม
    • ต่อให้มี 1,000 คอลัมน์ การค้นหาก็จบในระดับไมโครวินาที
  • ผลรวมสะสมจะ rebuild เฉพาะตอนที่ความกว้างเปลี่ยนจริง เช่น ปรับขนาด·ซ่อน·เรียงใหม่ ไม่สร้างระหว่างเลื่อน
  • ใส่ buffer ด้านซ้ายขวาราว 200px เพื่อเรนเดอร์คอลัมน์ถัดไปไว้ก่อนจะโผล่เข้าจอ
  • หลังทำ virtualization สองแกน พื้นที่เรนเดอร์คงอยู่ราว 40 แถว × 12 คอลัมน์ ไม่ขึ้นกับขนาดข้อมูล
    • แม้ collection จะมี 500 คอลัมน์ ก็มีจริงพร้อมกันเพียงราว 12 คอลัมน์ จึงไม่ทำให้เวลาโหลดเพิ่ม
  • แม้สิ่งที่ต้องเรนเดอร์จะลดลง แต่ยังเหลือปัญหาต้องคำนวณช่วงใหม่ทุกครั้งที่เกิด scroll event หลายร้อยครั้งต่อวินาที

ขั้นที่ 5: จัดการงบเวลาต่อเฟรมในเส้นทางการเลื่อน

  • การประมวลผลการเลื่อนต้องแบ่ง งบ 16.7ms ต่อเฟรม ร่วมกับ style·layout·paint จึงต้องทำงานให้น้อยที่สุด
  • ลงทะเบียน passive event listener นอก change detection ของเฟรมเวิร์ก
    • เป็นการบอกเบราว์เซอร์ว่าไม่ได้เรียก preventDefault ทำให้ compositor เลื่อนพิกเซลได้โดยไม่ต้องรอ JavaScript
    • และตัว scroll event เองก็ไม่ไปกระตุ้นการตรวจเรนเดอร์ของเฟรมเวิร์ก
  • รวมหลาย event ให้เหลือครั้งเดียวต่อเฟรม
    • เก็บแค่ตำแหน่งเลื่อนล่าสุด แล้วจอง requestAnimationFrame callback ไว้เพียงตัวเดียว
    • ต่อให้มี 12 event ในหนึ่งเฟรม ก็ประมวลผลคำนวณช่วงแค่ครั้งเดียว
  • นำเทคนิค fast draw exit จากซอร์สของ Handsontable มาใช้
    • ถ้าช่วงแสดงผลใหม่ยังอยู่ใน buffer ที่เรนเดอร์ไว้เดิม ก็เช็กจำนวนเต็มสองครั้งแล้ว return ทันที
  • ใช้ hysteresis กับขอบเขต buffer
    • เมื่อช่วงแสดงผลเข้าใกล้ขอบ buffer ราว 40px จึงค่อย rebuild
    • แล้วย้าย buffer ราว 200px ไปจัดกึ่งกลางตำแหน่งใหม่ เพื่อไม่ให้เกิดขอบว่างหรือ rebuild ซ้ำที่จุดเดิม
  • เพิ่มตัวตรวจจับความเร็วโดยติดตามค่า px/ms ระหว่าง event
    • ใน implementation นี้ ถ้าเกิน 10px/ms จะถือเป็นการ flick เร็วและหยุดเรนเดอร์ชั่วคราว
    • ปล่อยให้ native scroll เคลื่อนบน phantom area ไปก่อน แล้วค่อยเติม slab ใหม่เมื่อความเร็วคงที่
  • แม้ลดงาน JavaScript แล้ว แต่ยังมีเฟรมตกอยู่ โดย bottleneck ถัดไปคือ layout และ paint ที่รวม style·ลายสลับสี·ไอคอนเข้าไปด้วย

ขั้นที่ 6: ตัดต้นทุนของพร็อพเพอร์ตีที่กระทบ layout

  • main thread ของเบราว์เซอร์ทำหน้าที่ style·layout·paint ส่วน compositor จะขยับ layer ที่วาดเสร็จแล้วบน GPU
  • พร็อพเพอร์ตีที่ให้ compositor จัดการ animation ได้คือ transform และ opacity ส่วน top·left·width·height·background-color เป็นต้น จะปลุก main thread
  • เมื่อต้องอัปเดต top เพื่อ sync การเลื่อนของหมายเลขแถวและ panel คอลัมน์ที่ตรึงไว้ ก็เกิด forced layout 60 ครั้งต่อวินาที
    • จึงเปลี่ยนเป็น translate3d เพื่อให้ภาพเหมือนเดิมแต่ตัดต้นทุนบน main thread
  • ลายสลับสีที่เคยทำเป็นพื้นหลังรายแถว เปลี่ยนเป็น repeating-linear-gradient เดียวสำหรับตัวเนื้อหาทั้งหมด
    • ส่งความสูงแถวผ่าน CSS variable
    • เบราว์เซอร์จะ rasterize แค่ tile ขนาดสองแถวหนึ่งชิ้น แล้วคัดลอกจาก GPU texture ซ้ำ ๆ
    • จึงตัด class binding รายแถวออก และทำให้เนื้อหาหลักกับ panel ตรึงใช้ tile เดียวกัน ป้องกันสีเหลื่อมกันด้วย
  • เส้นแบ่งคอลัมน์ก็วาดเฉพาะความสูงของ slab ที่เรนเดอร์อยู่ ไม่วาดเต็ม phantom area สูง 40 ล้าน px
  • การเลื่อนปกติลื่นขึ้นแล้ว แต่ตอนสลับหน้าต่างเรนเดอร์เพื่อสร้างเซลล์ใหม่ ยังมี DOM ที่ไม่จำเป็นสะสมรายเซลล์อยู่

ขั้นที่ 7: ทำให้ไอคอนและ editor เบาขึ้น

  • ไอคอนชนิดข้อมูลใน database grid ไม่ใช่แค่ของตกแต่ง แต่เป็นฟังก์ชันที่ใช้แยกความหมายของค่าอย่าง ObjectId·string·integer·JSONB
  • implementation แรกเพิ่ม element ของ font icon ในทุกเซลล์ แต่ DOM node ที่เพิ่มขึ้นและเส้นทางการเรนเดอร์ข้อความของ glyph ทำให้ต้นทุนสูงมาก
  • จึงย้ายไอคอนมาเป็น background-image ของตัวเซลล์เอง และเข้ารหัสเป็น SVG data URI
    • ทุกเซลล์ที่เป็นชนิดเดียวกันอ้างอิงสตริง URI เดียวกัน
    • เบราว์เซอร์ rasterize ไอคอนต่อชนิดเพียงครั้งเดียว แล้วคัดลอกจาก GPU texture ที่ cache ไว้ซ้ำ ๆ
    • จึงแสดงของตกแต่งซ้ำ ๆ ได้โดยไม่ต้องมี DOM node เพิ่ม
  • สำหรับการแก้ไขเซลล์ ใช้วิธีเรนเดอร์แบบผสม
    • ปกติเซลล์ใช้แค่ข้อความธรรมดาและ span ตัวเดียว
    • จะ mount editor component แบบหนักที่รู้จักชนิดข้อมูลเหนือเซลล์นั้นเหมือนพอร์ทัล ก็ต่อเมื่อดับเบิลคลิกเท่านั้น
  • ถ้าใช้ framework component กับทุกเซลล์ ต้นทุนการสร้าง instance จะสะสม และเมื่อผูก grid library เข้ากับ cell renderer component ก็อาจเสียประสิทธิภาพได้
  • แม้ตัวเซลล์จะเบาลง แต่เมื่อมีคอลัมน์ใหม่โผล่มา เฟรมเวิร์กก็ยังสร้างเซลล์อื่นที่เนื้อหาเหมือนเดิมใหม่ทั้งหมดอยู่ดี

ขั้นที่ 8: นำ DOM กลับมาใช้ซ้ำตามตำแหน่ง

  • เกณฑ์ติดตามอย่าง trackBy ของ Angular หรือ key ของ React และ Vue จะกำหนดว่าเมื่อหน้าต่างข้อมูลเลื่อนแล้วจะ reuse element เดิม หรือทำลายแล้วสร้างใหม่
  • ถ้าแถวไม่ถูก bind กับข้อมูลใหม่ แต่ถูกแทนที่ทุกครั้ง ก็ต้องสร้าง component·DOM node·event listener ใหม่ซ้ำไปเรื่อย ๆ
  • จึงติดตามแถวและคอลัมน์ด้วย ตำแหน่งบนหน้าจอ แทนค่าข้อมูล
    • element ของแถวและคอลัมน์ชุดเดิมราว 40 ชุดจะถูกเก็บไว้ และเปลี่ยนแค่เนื้อหาเป็นค่าใหม่
    • grid ทำงานเหมือน object pool และไม่ allocate DOM ใหม่ระหว่างเลื่อน
  • ขั้นนี้แก้ต้นทุนของการเลื่อนสองแกนและการ rebuild หน้าต่างข้อมูลได้แล้ว แต่ยังเหลืองานเก็บรายละเอียด เช่น อาการสั่นหนึ่งเฟรมตอนปล่อยคอลัมน์ หรือความคลาดระดับครึ่งพิกเซลของหมายเลขแถว

ขั้นที่ 9: เก็บงานปฏิสัมพันธ์รายละเอียดเล็ก ๆ

  • ระหว่างลากคอลัมน์ จะใช้ transform เลื่อนคอลัมน์เดิมให้ดูเหมือนอยู่ในลำดับใหม่ และเมื่อปล่อยจะจัดการ เรียงใหม่และรีเซ็ต transform ใน rendering pass เดียวกัน
    • ทำให้เฟรมสุดท้ายของการลากกับเฟรมแรกหลังเรียงใหม่ตรงกันระดับพิกเซล จนไม่เห็นรอยต่อ
  • tooltip ไม่ได้ bind กับทุกเซลล์ แต่ใช้ delegated hover listener ตัวเดียวที่คอนเทนเนอร์
    • คำนวณ tooltip เฉพาะเซลล์ใต้เคอร์เซอร์เท่านั้น
    • แม้แต่การสร้างสตริงราคาแพง เช่น การ pretty-print ค่า JSONB ของ SQL ก็เลื่อนไปทำตอน hover จริง
  • เส้นขอบ 1px ของคอลัมน์หมายเลขแถวเคยทำให้แถวข้อมูลกับ baseline ของข้อความเหลื่อมกันครึ่งพิกเซลจนเกิดอาการสั่นระหว่างเลื่อน
    • จึงแก้ให้ทั้งสองคอลัมน์ใช้ box model เดียวกัน
  • ลายสลับสีจะกำหนดจากดัชนีแถวแบบสัมบูรณ์ ไม่ใช่ตำแหน่ง DOM ที่นำกลับมาใช้ซ้ำ จึงไม่เกิดการกระพริบเมื่อหน้าต่าง virtualization เปลี่ยน
  • ฟีเจอร์ที่เป็นเหตุผลให้สร้าง grid เองก็ยังถูกรักษาไว้
    • ขยายเอกสารซ้อนเป็นคอลัมน์ย่อยจริง แทนการเก็บเป็นสตริง JSON
    • ไฮไลต์สตริงที่ตรงกันในพาธซ้อนภายในเซลล์
    • ลากค่าที่มีชนิดข้อมูลจาก grid ไปยัง visual query builder ได้โดยตรง
  • ต้นทุนในการยัดฟีเจอร์แบบนี้ลงใน grid library ทั่วไปสูงกว่าต้นทุนของการเป็นเจ้าของ renderer เอง

ขั้นที่ 10: อ่านซอร์สของ grid ประสิทธิภาพสูงที่มีอยู่แล้ว

  • ในการเรียนรู้ประสิทธิภาพฝั่งฟรอนต์เอนด์ นิสัยที่คุ้มค่าที่สุดคือการ อ่านซอร์สโค้ด ของโปรเจกต์อื่น เพราะ optimization ที่ไม่เคยถูกเขียนไว้ในเอกสารยังคงอยู่ในรีโพสาธารณะ
  • AG-Grid ซึ่งเป็น DOM grid ที่ศึกษา มีเทคนิคต่อไปนี้
    • แบ่งเวลางาน DOM ด้วยงบเวลาต่อเฟรมที่กำหนดชัดเจนและคิวงานตามลำดับความสำคัญ
    • แฮช viewport ของคอลัมน์เพื่อให้การเลื่อนที่ไม่เปลี่ยนอะไรจบได้ด้วยการเปรียบเทียบสตริงครั้งเดียว
    • สร้างแถวตามทิศทางการเลื่อน เพื่อให้เนื้อหาด้านที่ผู้ใช้กำลังมุ่งไปแสดงก่อน
    • สร้างเซลล์ใหม่ก่อน แล้วค่อยเลื่อนการทำลายเซลล์เก่าออกไปทีหลัง เพื่อให้เนื้อหาใหม่ถูกวาดก่อนเนื้อหาเก่าจะหาย
    • ใช้ event delegation ระดับคอนเทนเนอร์ เพราะ event listener รายเซลล์เป็น bottleneck ที่ตรวจวัดได้จริง
  • grid แบบ Canvas ข้าม DOM ไปเลย จึงวาดข้อความใหม่ได้หลายร้อยชิ้นต่อเฟรมโดยไม่ต้องคำนวณ layout หรือ style ใหม่
    • สามารถรักษา 60fps ได้แม้มีการใช้งานหนักมาก จึงมีเพดานประสิทธิภาพสูงกว่า DOM grid
    • แต่ข้อความที่ rasterize นอก pixel grid อาจเบลอได้
    • การเลือกจะทำงานได้แค่ระดับเซลล์ และจุดไข่ปลาก็ไม่ปรากฏถ้าไม่วาดเอง
    • ทุกฟีเจอร์ใหม่ของเซลล์ต้องเพิ่มทั้งโค้ดวาดและโค้ดตรวจจับการกดโดน
  • สุดท้ายจึงเลือก DOM เพื่อคงข้อความที่คมชัด, การเลือกข้อความจริง, การเข้าถึง, และความเร็วในการพัฒนาฟีเจอร์
  • แม้จะไปไม่ถึงความลื่นระดับ Canvas แต่ก็เป็น การแลกเปลี่ยนอย่างชัดเจน ที่คำนึงถึงประสบการณ์ผู้ใช้และต้นทุนการพัฒนา

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น