- ระหว่างสร้าง 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 และพฤติกรรมการเลื่อน
- ถ้าเป็น 1 ล้านแถว × 40px ก็จะได้
- ช่วงที่ต้องแสดงคำนวณได้ดังนี้
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 ให้เหลือครั้งเดียวต่อเฟรม
- เก็บแค่ตำแหน่งเลื่อนล่าสุด แล้วจอง
requestAnimationFramecallback ไว้เพียงตัวเดียว - ต่อให้มี 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 แต่ก็เป็น การแลกเปลี่ยนอย่างชัดเจน ที่คำนึงถึงประสบการณ์ผู้ใช้และต้นทุนการพัฒนา
ยังไม่มีความคิดเห็น