← บทความทั้งหมด

ฟอนต์ไทยบนเว็บโดยไม่ให้หน้ากระโดด

เว็บไทยที่โหลดเว็บฟอนต์จะมีช่วงเวลาหนึ่ง ระหว่างที่หน้าวาดครั้งแรกกับตอนฟอนต์มาถึง ที่ข้อความถูก จัดด้วยฟอนต์สำรอง พอเว็บฟอนต์มาถึง ทุกบรรทัดตัดใหม่ และทุกอย่างใต้ย่อหน้าแรกก็ขยับ 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 ธรรมดา คุณเขียนเองได้: วัดอัตราส่วนใน console เขียนกฎ @font-face แล้วรักษาให้ตรงกับฟอนต์ที่ใช้อยู่เสมอ ที่ยากคือการดูแลต่อ ทุกฟอนต์ใหม่ ทุกน้ำหนักใหม่ ฟอนต์สำรอง ของทุกแพลตฟอร์ม คือการวัดใหม่ทั้งหมด และ page builder กับธีมส่วนใหญ่ก็ยินดีจะเพิ่ม request Google Fonts อีกชุดลับหลังคุณ

Design Controller ทำทั้งรายการนี้เป็นเรื่องปกติ: ฟอนต์ที่เลือกจาก Google Fonts หรือ Bunny Fonts ถูกดาวน์โหลดครั้งเดียวลง Font Library ของ WordPress เป็น woff2 ตัด subset ไทยและ ละติน ให้ WordPress พิมพ์เองพร้อม preload ไม่เกินสองไฟล์ และจับคู่กับฟอนต์สำรองที่ปรับ metric ซึ่งคำนวณจาก metric ของฟอนต์เอง request Google Fonts ของธีมถูกตัดทิ้งเมื่อตระกูลเดียวกัน ถูกโฮสต์แล้ว และ Design Doctor เตือนเรื่องฟอนต์โหลดซ้ำ ฟอนต์ที่ไม่มีอักษรไทย และ line-height ไทยที่แน่นเกินไป กฎในคู่มือนี้คือกฎที่มันใช้ ต่างกันแค่ไม่ต้องมีใครจำ

ลองกับเว็บของคุณเอง

Design Controller ให้ WordPress มีหน้าจอเดียวสำหรับระบบดีไซน์ ทั้งฟอนต์ไทยและละติน สี ระยะห่าง และคอมโพเนนต์ โดยไม่ต้องเขียน CSS ตัวฟรีอยู่บน WordPress.org

ดูว่า Design Controller ทำอะไรได้  ดูราคา