
วิธีสตรีมรายงานสดโดยไม่ให้หน้าจอกระพริบ
เมื่อการประชุมของคุณจบลง สรุปของ GeekBye จะค่อย ๆ เติมเต็มแบบสด ๆ แทนที่จะให้คุณนั่งจ้องวงกลมหมุน ๆ การทำให้ UI แบบสตรีมรู้สึกสงบแทนที่จะกระตุก เราต้องแก้อาการกระพริบถึงสองครั้ง — ครั้งหนึ่งสำหรับฟิลด์ที่มีโครงสร้าง อีกครั้งสำหรับ markdown — และทางแก้ครั้งที่สองคือเรนเดอเรอร์ที่เรามีอยู่แล้ว
ก่อนรีลีสนี้ การจบการประชุมใน GeekBye หมายถึงการนั่งจ้องวงกลมหมุน ๆ แอปจะส่งบันทึกคำพูดทั้งหมดของคุณไปวิเคราะห์ รอสรุปทั้งชุด — คะแนน ประเด็นสำคัญ รายการสิ่งที่ต้องทำ ครบทุกอย่าง — แล้วค่อยทิ้งมันลงบนหน้าจอทีเดียว มันใช้งานได้ก็จริง แต่มันรู้สึกช้า เพราะคุณได้แต่จ้องความว่างเปล่าในระหว่างที่มันทำงาน
GeekBye v1.6.13–v1.6.15 เปลี่ยนมันให้กลายเป็นรายงานที่ไหลเข้ามาแบบสด ทีละฟิลด์ ในขณะที่มันกำลังถูกสร้างขึ้น ส่วนที่น่าสนใจไม่ใช่ตัวการสตรีมเอง — แต่คือทุกสิ่งที่เราต้องทำเพื่อไม่ให้การสตรีมดูกระตุก การสตรีมที่สงบและการสตรีมที่สะดุดคือฟีเจอร์เดียวกัน เพียงแต่ลงมือทำต่างกันอย่างสิ้นเชิง
กระพริบ ตอนที่หนึ่ง: อย่าปล่อยให้ทุกชังก์มาเขย่า React
สรุปไม่ได้เป็นก้อนเดียว — มันคือฟิลด์ที่มีโครงสร้าง (คะแนนรวม ประเด็นสำคัญ รายการสิ่งที่ต้องทำ และอื่น ๆ) ที่แบ็กเอนด์ส่งออกมาทีละตัวผ่าน server-sent events วิธีเรนเดอร์แบบซื่อ ๆ คือการอัปเดต React state ทุกครั้งที่มีอีเวนต์มาถึง
ทำแบบนั้นแล้วคุณก็จะได้อาการกระพริบ ทุกฟิลด์ที่มาถึงจะกระตุ้นการ re-render ของตัวเอง คอมโพเนนต์ที่กำลังแสดง "ว่าง" จะถูกเมานต์ แล้วถูกถอด แล้วถูกรีเมานต์เป็น "โหลดแล้ว" เลย์เอาต์กระตุกในทุกแพ็กเก็ต รายงานประกอบตัวเองขึ้นมาต่อหน้าคุณในแบบที่แย่ที่สุดเท่าที่จะเป็นได้ — เห็นชัดและกระวนกระวาย
ทางแก้คือวินัยสองส่วน ส่วนแรก สะสมฟิลด์ที่เข้ามาไว้ใน ref — ตัวเก็บค่าที่เปลี่ยนแปลงได้แต่ไม่กระตุ้นการเรนเดอร์ — แล้วเผยแพร่ไปยัง React state ครั้งเดียวต่อหนึ่งชังก์ด้วยสำเนาใหม่ เพื่อให้ต้นไม้อัปเดตอย่างตั้งใจ ไม่ใช่ในทุกอีเวนต์เล็ก ๆ ส่วนที่สอง เรนเดอร์ต้นไม้คอมโพเนนต์ที่เสถียรเพียงต้นเดียวตลอดเวลา ไม่ว่าจะกำลังสตรีมหรือเสร็จแล้ว โดยมีตัวยึดตำแหน่งแบบอินไลน์สำหรับฟิลด์ที่ยังมาไม่ถึง:
// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })
// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>
คอมโพเนนต์ที่แสดงคะแนนจะไม่เคยถูกถอด — มันแค่แสดง … จนกว่าตัวเลขจะมาถึง แล้วจึงแสดงตัวเลข ไม่มีอะไรกระตุก และวัตถุที่ประกอบเสร็จสมบูรณ์ก็ยังถูกแคชลงฐานข้อมูลในเครื่องเมื่อจบ ดังนั้น UI แบบสตรีมและที่จัดเก็บถาวรจึงอยู่ร่วมกันได้โดยไม่ตีกัน
กระพริบ ตอนที่สอง: เรนเดอเรอร์ที่เรามีอยู่แล้ว
อาการกระพริบแบบที่สองนั้นแนบเนียนกว่า และอยู่ในตัว markdown เอง รายงานเรนเดอร์ markdown ที่ครบเครื่อง — หัวเรื่อง ตัวหนา ลิสต์ บล็อกโค้ด แต่ markdown ที่มาถึงกลางสตรีมนั้น ในทุกชั่วขณะ ล้วนยังไม่เสร็จ ลิสต์ที่ตอนนี้มีหัวข้อย่อยเพียงหัวเดียว โค้ดเฟนซ์ที่เปิดไว้แต่ยังไม่ปิด เครื่องหมายตัวหนาที่ยังไม่มีคู่
เรนเดอเรอร์ markdown แบบดั้งเดิมจะพาร์สทั้งสตริงใหม่ในทุกโทเคน และสถานะที่ยังไม่เสร็จนั้นก็พาร์สออกมาเป็นคนละอย่างในทุกครั้ง — ลิสต์จึงกระพริบ บล็อกโค้ดวาบเปิดแล้วปิด เลย์เอาต์กระโดดไปมาขณะที่โทเคนทยอยเสร็จ มันคือการประกอบตัวแบบกระวนกระวายแบบเดียวกับเมื่อกี้ เพียงแต่ต่ำลงไปอีกชั้น
ทางแก้อยู่ใกล้แค่เอื้อมจนแทบน่าอาย: เราใช้เรนเดอเรอร์ markdown ที่รู้ทันการสตรีมอยู่แล้วในแชต AI สด เรนเดอเรอร์ที่สร้างมาให้ทนกับโทเคนที่ไม่สมบูรณ์ — เรนเดอร์ markdown ที่ยังไม่เสร็จได้อย่างเสถียรและปล่อยให้มันลงตัวก็ต่อเมื่อโทเคนเสร็จเท่านั้น แทนที่จะพาร์สใหม่ทั้งหมดในทุกครั้ง รายงานแค่ต้องใช้ตัวเดียวกันนั้น เราแก้ปัญหานี้แบบเป๊ะ ๆ ให้แชตไปตั้งหลายเดือนก่อนแล้ว "ทางแก้" ของรายงานคือการรู้ตัวว่าเรามีเครื่องมือนั้นอยู่แล้วและเล็งมันไปที่พื้นผิวที่สอง การนำกลับมาใช้ชนะการสร้างใหม่
ของแถม: การคัดลอกแบบริชคือปัญหาของรูปแบบคลิปบอร์ด
เมื่อรายงานดูดีแล้ว ผู้คนก็อยากวางมัน — ลงในเอกสาร ในอีเมล — และคงรูปแบบกับภาพหน้าจอไว้ สัญชาตญาณคือการซีเรียลไลซ์รายงานให้เป็นสตริง markdown แล้วหวังว่าปลายทางจะเรนเดอร์มันออกมา ซึ่งส่วนใหญ่มันไม่ทำ
คำตอบที่แท้จริงคือ คลิปบอร์ดเก็บการนำเสนอหลายรูปแบบพร้อมกันได้ เราจึงเขียนสองแบบ: เวอร์ชัน HTML ที่รูปแบบยังอยู่ครบและภาพหน้าจอถูกฝังเป็นรูปแบบอินไลน์ และเวอร์ชันข้อความล้วนเป็นทางสำรอง สำหรับปลายทางที่ไม่รับ HTML วางลงในโปรแกรมแก้ไขแบบริชแล้วคุณจะได้รายงานที่จัดรูปแบบพร้อมภาพ วางลงในกล่องข้อความล้วนแล้วคุณจะได้ข้อความที่สะอาดตา "การคัดลอกแบบริช" ไม่เคยเป็นปัญหาของการซีเรียลไลซ์ — มันเป็นปัญหาของการเลือกรูปแบบคลิปบอร์ดให้ถูก
รีลีสเดียวกันนี้ยังให้ป้ายกำกับสไตล์แชต Me / Them แก่รายงาน โดยจัดผู้พูดสองคนให้อยู่คนละฝั่งตรงข้าม บันทึกคำพูดจึงอ่านได้เหมือนบทสนทนาอย่างที่มันเป็น และย้ายภาพหน้าจอของคุณเองไปอยู่ฝั่งของมันเองให้เข้าคู่กัน (รีลีสหนึ่งในช่วงนี้ คือ v1.6.14 เป็นการรีบิลด์ล้วน ๆ — ไม่มีเรื่องราวอะไร และการพูดตรง ๆ แบบนี้ก็ซื่อสัตย์ดี)
สามสิ่งที่ UI แบบสตรีมสอนเรา
- บัฟเฟอร์ฟิลด์ที่สตรีมเข้ามา แล้วเผยแพร่ครั้งเดียวต่อหนึ่งชังก์ การปล่อยให้ทุก server-sent event ขับเคลื่อนการอัปเดต state ของตัวเองนั่นแหละคืออาการกระพริบ ตัวสะสมแบบ ref บวกกับการเผยแพร่ครั้งเดียวต่อชังก์ ที่ไหลเข้าสู่ต้นไม้ที่ไม่เคยถูกถอด จะเปลี่ยนการประกอบที่กระวนกระวายให้เป็นการเติมเต็มที่สงบ
- อะไรก็ตามที่สตรีม ต้องมีเรนเดอเรอร์ที่รู้ทันการสตรีม เรนเดอเรอร์ที่พาร์สทั้งสตริงใหม่ต่อทุกโทเคนจะกระพริบกับ markdown ที่ยังไม่เสร็จ ใช้ตัวที่สร้างมาสำหรับอินพุตที่ไม่สมบูรณ์ — และถ้าคุณมีตัวหนึ่งสำหรับพื้นผิวอื่นอยู่แล้ว จงนำกลับมาใช้ก่อนที่จะสร้างตัวที่สอง
- การคัดลอกแบบริชเป็นเรื่องของรูปแบบคลิปบอร์ด ไม่ใช่การซีเรียลไลซ์ เขียน
text/htmlที่ฝังรูปภาพ และ ทางสำรองtext/plainคลิปบอร์ดถูกออกแบบมาให้พกพาทั้งสอง จงใช้มัน
นี่คือบทที่ห้าของเรื่องราวความน่าเชื่อถือและการขัดเกลาที่จะกลายเป็น GeekBye v2 บทก่อนหน้า ดูได้ที่บั๊กหูฟัง (v1.6.12) ส่วนที่แนวคิด server-sent-events เดียวกันต่อมากลายเป็นช่องทางสำรองทั้งช่องทาง ดูได้ที่การถอดเสียงสดเมื่อไฟร์วอลล์บล็อก WebSocket (v2.0.8) และสำหรับภาพรวมทั้งเส้นเรื่อง ดูได้ที่กายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ