ฟอนต์ไทยบนเว็บโดยไม่ให้หน้ากระโดด
เว็บไทยที่โหลดเว็บฟอนต์จะมีช่วงเวลาหนึ่ง ระหว่างที่หน้าวาดครั้งแรกกับตอนฟอนต์มาถึง ที่ข้อความถูก จัดด้วยฟอนต์สำรอง พอเว็บฟอนต์มาถึง ทุกบรรทัดตัดใหม่ และทุกอย่างใต้ย่อหน้าแรกก็ขยับ Google เรียกผลนี้ว่า Cumulative Layout Shift และหักคะแนนหน้า ส่วนผู้อ่านเรียกว่า "หน้ากระโดด" คู่มือนี้ว่าด้วยเหตุผลที่หน้าไทยกระโดดมากกว่าหน้าละติน วิธีดูให้เห็นกับตา และสิ่งที่หยุดมันได้จริง เขียนจากการวัด ไม่ใช่ทฤษฎี: ร้านของเราเองเคยมีปัญหานี้และแก้แล้ว
ทำไมภาษาไทยถึงกระโดดมากกว่า
สองอย่างเกิดพร้อมกันตอนเว็บฟอนต์มาแทนฟอนต์สำรอง
ความกว้างเปลี่ยน ทุกตัวอักษรในเว็บฟอนต์กว้างไม่เท่าตัวเดียวกันในฟอนต์สำรอง บรรทัดจึงตัดคนละที่ สำหรับข้อความละติน ฟอนต์สำรองของเบราว์เซอร์มักเป็นญาติใกล้ ๆ ของเว็บฟอนต์ เช่น Arial สำหรับ sans ต่างกันไม่กี่เปอร์เซ็นต์ แต่สำหรับภาษาไทย ฟอนต์สำรองคือฟอนต์ไทยอะไรก็ตามที่ระบบปฏิบัติการ ให้มา: Thonburi บนอุปกรณ์ Apple, Leelawadee UI หรือ Tahoma บน Windows, Noto Sans Thai บน Android ซึ่งต่างจากเว็บฟอนต์อย่าง Noto Sans Thai, Prompt หรือ Sarabun ห้า สิบ บางทีถึงสิบห้า เปอร์เซ็นต์ ย่อหน้าที่เคยใช้ห้าบรรทัดกลายเป็นหก และทุกอย่างข้างล่างเลื่อนลงหนึ่งบรรทัด
ความสูงเปลี่ยน ภาษาไทยซ้อนสระและวรรณยุกต์ไว้บนและล่างพยัญชนะ ฟอนต์ไทยจึงมี ascent และ descent สูงกว่าฟอนต์ละตินเพื่อเผื่อที่ให้ ถ้า metric ของฟอนต์สำรองต่างกัน กล่องบรรทัดจะโตหรือหด ตอนเว็บฟอนต์มาถึง แม้ความกว้างจะบังเอิญเท่ากันก็ตาม
และยังมีข้อที่สามที่เงียบกว่า: ช่องว่าง ภาษาไทยเว้นวรรคระหว่างวลีไม่ใช่ระหว่างคำ แต่หนึ่งย่อหน้า ก็ยังมีช่องว่างหลายสิบช่อง และช่องว่างของฟอนต์สำรองมักกว้างไม่เท่าของเว็บฟอนต์ บนหน้าสินค้าของเรา ช่องว่างอย่างเดียวกว้างกว่า 13.6% ในฟอนต์สำรอง และมีอยู่ 482 ช่อง
ดูให้เห็น
รัน Lighthouse กับหน้านั้น (มือถือ throttling แบบจำลอง) แล้วเปิด audit Avoid large layout shifts มันจะบอกว่า element ไหนขยับ และตั้งแต่ Lighthouse 12 จะบอกสาเหตุด้วย: web font loaded บรรทัดนั้นคือการวินิจฉัยทั้งหมด
ถ้าอยากรู้ว่าฟอนต์สำรองต่างแค่ไหน วัดข้อความเดียวกันด้วยทั้งสองฟอนต์ผ่าน canvas ใน console ของเบราว์เซอร์:
const text = document.body.innerText; // คำของหน้านั้นเอง
const c = document.createElement('canvas').getContext('2d');
const width = (family) => { c.font = `400 16px ${family}`; return c.measureText(text).width; };
width('"Noto Sans Thai"') / width('Thonburi'); // 0.949 บนหน้าของเรา: Thonburi กว้างกว่า 5.4%
อัตราส่วนนี้คือตัวเลขที่การแก้ต้องใช้
สิ่งที่หยุดมันได้
1. โฮสต์เอง และตัด subset ตามสคริปต์
ทุก request ฟอนต์ไปยังบุคคลที่สามต้องเปิดการเชื่อมต่อก่อนที่ฟอนต์จะเริ่มดาวน์โหลดได้ด้วยซ้ำ
ทำให้ช่วงที่ฟอนต์สำรองอยู่บนจอนานขึ้น โฮสต์ไฟล์เองเป็น woff2 แล้วแยกตามสคริปต์ด้วย
unicode-range: subset ไทยถูกดึงเฉพาะเมื่อมีตัวอักษรไทยถูกจัดวาง subset ละตินเฉพาะละติน
คู่ฟอนต์ไทย + ละตินสองน้ำหนักกลายเป็นไฟล์เล็กราวแปดไฟล์แทนไฟล์ใหญ่สองไฟล์ และหน้าละติน
ไม่ต้องดาวน์โหลดฟอนต์ไทยเลยแม้แต่ไบต์เดียว
2. preload ฟอนต์เดียว ไม่ใช่ทุกตัว
preload เอาไฟล์ไปไว้หน้าคิว ให้ preload ฟอนต์เนื้อหาน้ำหนักปกติ ซึ่งเป็นฟอนต์ที่ข้อความส่วนใหญ่ใช้ แล้วปล่อยตัวอื่นตามมาเมื่อต้องการ การ preload ทุกน้ำหนักและทุก subset มีแต่ทำให้พวกมันแย่งกันเอง และแย่งกับสคริปต์ของหน้า ซึ่ง audit จะบอกเอง
3. ฟอนต์สำรองที่ปรับ metric ให้ตรง
ข้อนี้คือตัวที่กำจัดการกระโดด ไม่ใช่แค่ทำให้สั้นลง CSS ให้ประกาศฟอนต์สำรองที่เป็นฟอนต์ของระบบ แต่ย่อขยายให้ข้อความเดียวกันกว้างเท่าเว็บฟอนต์ พร้อมใช้ metric แนวตั้งของเว็บฟอนต์เอง:
@font-face {
font-family: 'Noto Sans Thai Fallback';
src: local('Thonburi');
size-adjust: 94.92%; /* อัตราส่วนจาก canvas แยกตามน้ำหนัก */
ascent-override: 111.67%; /* ascent ของเว็บฟอนต์ ÷ size-adjust */
descent-override: 47.41%; /* descent ของเว็บฟอนต์ ÷ size-adjust */
line-gap-override: 0%;
unicode-range: U+0E01-0E5B, U+200C-200D, U+25CC;
}
:lang(th) {
font-family: 'Noto Sans Thai', 'Noto Sans Thai Fallback', system-ui, sans-serif;
}
รายละเอียดสามข้อสำคัญ อัตราส่วนเป็นต่อน้ำหนัก: ฟอนต์สำรองตัวหนาไม่ใช่ตัวปกติที่ย่อขยาย
เป็นต่อสคริปต์: อักษรไทยมาจาก Thonburi ตัวละตินมาจาก Arial และช่องว่างต้องมีอัตราส่วน
ของตัวเอง การตั้งค่าจริงจึงเป็น face หลายตัว แต่ละตัวมี unicode-range ของตัวเอง และเป็น
ต่อแพลตฟอร์ม: ฟอนต์ local() ที่ไม่ได้ติดตั้งจะถูกข้ามไปเฉย ๆ stack เดียวจึงถือ face ของ
Thonburi สำหรับอุปกรณ์ Apple และ face ของ Tahoma สำหรับ Windows ได้ โดยแต่ละตัวคาลิเบรต
กับฟอนต์ของตัวเอง
ตอนเราทำแบบนี้บนหน้าสินค้าของเรา สถานะฟอนต์สำรองเปลี่ยนจากทำให้ 177 จาก 190 element ขยับ
เป็นไม่มีอะไรขยับเลยในจอแรก และคะแนน layout shift ของ Lighthouse ลดจาก 0.016 เหลือ 0.000
ที่เหลืออยู่คือข้อจำกัดของวิธี: size-adjust จับความกว้างรวม ไม่ใช่ทีละตัวอักษร ย่อหน้าที่บรรทัด
สุดท้ายเกือบว่างจึงยังอาจได้หรือเสียหนึ่งบรรทัดที่ไหนสักแห่งลึกลงไปในหน้า
4. line-height ที่พ้นวรรณยุกต์
เนื้อหาไทยต้องการ line-height ราว 1.6 ขึ้นไป ให้ตั้งไว้ชัดเจนบน subtree ภาษาไทย
(ของเราคือ :lang(th) { line-height: 1.8 }) แทนที่จะปล่อยให้ metric ของฟอนต์ตัดสิน
กล่องบรรทัดจะสูงเท่ากันไม่ว่าฟอนต์ไหนอยู่บนจอ ส่วนแนวตั้งของการกระโดดจึงเกิดไม่ได้ กฎเดียวกันนี้
ยังกันไม่ให้เครื่องหมายที่ซ้อนกันชนบรรทัดบนด้วย
รักษาให้เป็นแบบนี้ต่อไป
ทุกอย่างข้างบนคือ CSS ธรรมดา และไม่ใช่งานที่ทำครั้งเดียวจบ ทุกฟอนต์ใหม่ ทุกน้ำหนักใหม่ และฟอนต์สำรอง
ของทุกแพลตฟอร์ม คือการวัดใหม่ ส่วน page builder กับธีมส่วนใหญ่ก็ยินดีจะเพิ่ม request Google Fonts
อีกชุดลับหลังคุณ เมื่อเปลี่ยนฟอนต์ ให้ไล่รายการนี้อีกรอบ: ดูในแท็บ network ว่ามี request ฟอนต์ซ้ำไหม
วัดอัตราส่วนของฟอนต์สำรองใหม่ และตรวจว่า line-height ของภาษาไทยยังพ้นวรรณยุกต์อยู่