- Linear เป็นเครื่องมือเพิ่มประสิทธิภาพการทำงานที่จัดการงานติดตาม issue ด้วยฐานข้อมูลในเบราว์เซอร์และการซิงก์แบบ local-first ทำให้การอัปเดต issue สะท้อนบน UI ได้ภายในไม่กี่มิลลิวินาที
- ฐานข้อมูลจริงที่ UI อ่านอยู่ใน IndexedDB โดยการเปลี่ยนแปลงจะถูกนำไปใช้ในเครื่องก่อน แล้วค่อยส่งไปยังเซิร์ฟเวอร์แบบ asynchronous และกระจาย delta กลับผ่าน WebSocket
- การโหลดครั้งแรกใช้กลยุทธ์ลดเวลารอเครือข่าย เช่น ส่ง JavaScript·CSS ให้น้อย, แบ่งโค้ดอย่างหนัก, ใช้ modulepreload, service worker precache และ inline app shell
- ซิงก์เอนจินจะ hydrate ข้อมูลจาก IndexedDB เข้าไปยัง MobX object pool, เก็บการเปลี่ยนแปลงไว้ใน transaction queue และเรนเดอร์ใหม่เฉพาะเซลล์ที่จำเป็นด้วยสถานะแบบ observable ระดับฟิลด์
- ความเร็วที่รับรู้ได้เกิดจากผลลัพธ์ของ การออกแบบระบบ ที่รวมทั้งการป้อนข้อมูลเน้นคีย์บอร์ด, global command palette, แอนิเมชันที่เป็นมิตรกับ GPU และเวลาเปลี่ยนผ่านที่สั้น
ฐานข้อมูลภายในเบราว์เซอร์
- เว็บแอป CRUD แบบดั้งเดิมจะผ่านขั้นตอนคำขอ HTTP หลังผู้ใช้คลิก, การค้นหาฐานข้อมูลบนเซิร์ฟเวอร์, การรับคำตอบ และการรีเพนต์ของเบราว์เซอร์ ทำให้เกิดสปินเนอร์, skeleton และ UI ค้างนานหลายร้อยมิลลิวินาที
- Linear วางฐานข้อมูลจริงที่ UI อ่านไว้ใน IndexedDB ของเบราว์เซอร์ นำการเปลี่ยนแปลงไปใช้ในเครื่องก่อน แล้วส่งไปยังเซิร์ฟเวอร์แบบ asynchronous จากนั้นเซิร์ฟเวอร์จะ broadcast delta ไปยังไคลเอนต์อื่นผ่าน WebSocket
- คอขวดที่ใหญ่ที่สุดของเว็บแอปที่เร็วคือเครือข่าย และการส่งข้อมูลระหว่างไคลเอนต์กับเซิร์ฟเวอร์มีต้นทุนระดับหลายร้อยมิลลิวินาที
- แกนหลักของแนวทาง Linear คือทำให้คำขอเครือข่ายมองไม่เห็นสำหรับผู้ใช้ และกำจัดสถานะการโหลดให้ได้มากที่สุด
// Linear
issue.title = "Faster app launch";
issue.save();
issue.title = "Faster app launch" คือการอัปเดต data store ในหน่วยความจำ และในกรณีของ Linear ใช้ MobX observable
issue.save(); คือการที่ซิงก์เอนจินจัดการแบบ batch แล้วนำธุรกรรมเข้าไปต่อคิวเพื่อ flush ไปยังเซิร์ฟเวอร์
- UI จะเรนเดอร์ใหม่แบบ synchronous โดยอิงจากการเปลี่ยนแปลงในหน่วยความจำฝั่งเครื่อง และการซิงก์ข้อมูลเกิดขึ้นในเบื้องหลัง จึงไม่จำเป็นต้องมีสปินเนอร์
- Tuomas กล่าวในงานคอนเฟอเรนซ์ปี 2024 ว่าโค้ดชุดแรกที่เขาเขียนใน Linear คือซิงก์เอนจิน และใช้ถ้อยคำว่านี่เป็นแนวทางที่ไม่ค่อยพบในสตาร์ตอัป
- แอปส่วนใหญ่ไม่จำเป็นต้องสร้างซิงก์เอนจินเองแบบ Linear และเพียงใช้ optimistic update ของ TanStack Query กับ SWR ก็สามารถให้ความรู้สึกเร็วได้ใกล้เคียงมาก
- optimistic request ให้ผลการปรับปรุงสูงผ่านการตัดสปินเนอร์ที่ไม่จำเป็น, อัปเดตสถานะทันที, ตรวจสอบในเบื้องหลัง และ rollback เมื่อจำเป็น
- ความตอบสนองของ UI ไม่ควรขึ้นอยู่กับ network latency และความเร็วที่ผู้ใช้รับรู้นั้นถูกกำหนดโดยความเร็วในการตอบสนองของอินเทอร์เฟซมากกว่าความเร็วการตอบกลับของเซิร์ฟเวอร์
-
แอบดูสแต็กของ Linear
- Linear สร้างขึ้นบนสแต็กที่เรียบง่ายอย่าง React, TypeScript, MobX, Postgres และ CDN
- ฟรอนต์เอนด์ใช้ React และ
react-dom, MobX, TypeScript, Rolldown-Vite กับ plugin-react-oxc, ProseMirror กับ y-prosemirror, Radix UI primitives, Emotion กับ StyleX, Comlink, idb, graphql-request, Sentry และ Inter Variable
- แบ็กเอนด์ใช้ Node.js และ TypeScript, PostgreSQL บน Cloud SQL, Memorystore Redis, turbopuffer, Kubernetes บน GCP และ Cloudflare Workers
- เดสก์ท็อปไคลเอนต์ใช้ Electron ส่วนมือถือมีการเขียนใหม่ทั้งหมดแยกต่างหากด้วย Swift สำหรับ iOS และ Kotlin
- เว็บไซต์การตลาดใช้ Next.js, styled-components และ inline SVG sprite
- Linear ยังคงใช้การเรนเดอร์ฝั่งไคลเอนต์ และเป็นตัวอย่างว่า CSR ก็ให้ความรู้สึกฉับไวได้ หากมีสถาปัตยกรรมและการออกแบบที่ถูกต้อง
- การคงทั้งแอปไว้ฝั่งไคลเอนต์ช่วยให้มี mental model ที่เรียบง่ายขึ้น โดยลดความซับซ้อนอย่างการแยกเซิร์ฟเวอร์·ไคลเอนต์, การเข้าถึง
window ได้หรือไม่ และการตั้งค่า cache header
ทำให้การโหลดครั้งแรกรู้สึกว่าเกิดขึ้นทันที
- เวลาที่ใช้กว่าจะเริ่มทำงานจริงได้ในเครื่องมือเพื่อการเพิ่มประสิทธิภาพเป็นรายละเอียดสำคัญ
- การโหลดเริ่มต้นของแอปฝั่งไคลเอนต์อาจช้าจากลำดับของการขอ
index.html, การขอ JavaScript และ CSS, การประมวลผลการยืนยันตัวตน, และการขอ API เพื่อแสดงแอป
-
โฟลว์บันเดลเลอร์ของ Linear: Parcel, Rollup, Vite, Rolldown
- ความเร็วที่รู้สึกได้ทันทีเริ่มต้นตั้งแต่ build time ก่อน runtime และสิ่งสำคัญสำหรับการโหลดที่เร็วคือการลดปริมาณ JavaScript และ CSS ที่ส่งออกไป
- Linear เขียน build pipeline ใหม่ตามลำดับ Parcel → Rollup → Vite → Rolldown โดยแต่ละการย้ายมีเป้าหมายเพื่อลดปริมาณ JavaScript·CSS และปรับปรุงประสบการณ์ของนักพัฒนา
- ตัวเลขการปรับปรุงตามบล็อกของ Linear
- โค้ดที่ส่งลดลง 50%
- ขนาดหลังบีบอัดลดลง 30%
- การโหลดหน้าแบบ cold cache ดีขึ้น 10~30%
- Time-to-first-paint ของมุมมอง active-issues บน Safari ลดลง 59%
- การใช้หน่วยความจำลดลง 70~80%
- ส่วนสำคัญของการปรับปรุงมาจากการผสมกันของการตัดสินใจรองรับเฉพาะเบราว์เซอร์สมัยใหม่, dead-code elimination ที่ดีกว่า, และการแยกโค้ดอย่างเข้มข้น
- การยุติการรองรับระบบเก่าทำให้ได้ประโยชน์มากจากการลบ polyfill, ES5 transpile, และ
nomodule fallback
- แม้หลังการปรับแต่ง Linear ก็ยังส่ง JavaScript ที่ minified แล้วราว 21MB แต่ใช้วิธีแยกอย่างเข้มข้นเป็นชังก์ระดับ route หลายร้อยชิ้นแล้วดึงมาเมื่อจำเป็น
- หัวใจสำคัญไม่ใช่การเลือกบันเดลเลอร์ตัวใดตัวหนึ่ง แต่คือการตัดเบราว์เซอร์รุ่นเก่าออก, เปลี่ยนไปใช้ native ESM, และทำ code splitting อย่างจริงจัง
- เมื่อขั้นตอนเหล่านี้สะสมกัน JavaScript สำหรับการโหลดครั้งแรกของ Linear จะลดลงเหลือประมาณครึ่งหนึ่ง และเวลา build ลดลงไม่ใช่แค่ระดับหลักหน่วย แต่ลดลงถึงอีกหนึ่งลำดับขั้น
-
Preload หลังการโหลดเริ่มต้น
- เมื่อแบ่ง JavaScript เป็นชังก์เล็ก ๆ จะเกิดปัญหาการโหลดแบบน้ำตกที่แต่ละชังก์ต้อง import ชังก์อื่นต่อกันไป
- Linear จัดให้เบราว์เซอร์เห็นรายการทั้งหมดและเริ่ม request แบบขนานก่อนที่ JavaScript จะรัน เพื่อให้เมื่อ entry script ไปถึง
import แรก ชังก์ที่เกี่ยวข้องอยู่ในแคชแล้ว
- โดยทำให้ค่า
modulepreload ใน <head> และค่า crossorigin ของ entry script ตรงกัน เพื่อให้เบราว์เซอร์ไม่มอง preload กับ import เป็นทรัพยากรคนละตัว และสามารถใช้ fetch ที่แคชไว้ซ้ำได้
- ไทม์ไลน์การโหลดแบบ cold load จึงเปลี่ยนจากลำดับแบบน้ำตกเป็นการส่งชุดขนานเพียงครั้งเดียว โดยงานเครือข่ายยังมีอยู่แต่ทำพร้อมกันทั้งหมด
- งานนี้จะถูกรันเบื้องหลังเมื่อผู้ใช้มาถึงหน้าเข้าสู่ระบบครั้งแรก และไม่กี่วินาทีต่อมาแอปทั้งหมดก็ถูกเก็บไว้ในแคชพร้อมเสิร์ฟทันที
-
Service worker เพื่อความเร็วที่สูงขึ้นและความสามารถออฟไลน์
- ชังก์ระดับ route ของมุมมองที่ผู้ใช้ยังไม่เคยเปิดจะถูกแคชเบื้องหลังโดย service worker
- Service worker มี precache manifest ที่ฝังอยู่ในซอร์ส ซึ่งครอบคลุม asset แบบ hash ราว 1,200 รายการ ทั้ง route chunk, ไอคอน และฟอนต์
- โครงสร้างนี้ทำให้ภายในไม่กี่วินาทีหลังถึงหน้าล็อกอิน แอปทั้งหมดจะเข้าไปอยู่ในแคช
- หลังจากนั้นการนำทางจะข้ามเครือข่ายไปทั้งหมด และ service worker จะตอบกลับจากแคชของตัวเองโดยตรงโดยไม่ผ่าน HTTP cache
- เมื่อทำงานร่วมกับ sync engine แบบ local-first และข้อมูลผู้ใช้ที่เก็บอยู่ใน IndexedDB แล้ว Linear ก็สามารถใช้งานแบบออฟไลน์ได้
- รองรับการอ่าน issue, สร้าง issue ใหม่, แก้ไขชื่อและคำอธิบาย, และเปลี่ยนสถานะ
- ทุกการทำงานจะถูกเข้าคิวไว้ใน local transaction store และ flush เมื่อการเชื่อมต่อกลับมา
modulepreload คือกลไกที่ดึงสิ่งที่ต้องใช้ตอนนี้มาแบบขนานเพื่อไม่ให้เบราว์เซอร์ติดอยู่กับ serial import chain
- Service worker คือกลไกที่เตรียมสิ่งที่จะต้องใช้ถัดไป
- ขั้นตอนการโหลดเร็วของ Linear คือการตัดโค้ดออกให้ได้มากที่สุด, แบ่งเป็นชิ้นเล็ก ๆ, และทำ background precache โดยมีเป้าหมายเพื่อทำให้ network request เร็วขึ้นหรือกำจัดมันไปเลย
-
การจัดโครงสร้าง vendor bundle
- แต่ละแพ็กเกจที่ Linear ใช้จะถูกแยกเป็นชังก์ของตัวเองและแคชอย่างอิสระ
vendor.js แบบดั้งเดิมจะทำให้แคชของ dependency graph ทั้งหมดใช้ไม่ได้ทันทีแม้อัปเดตเพียง dependency ตัวเดียว
- การแยกชังก์ของ Linear สร้าง vendor caching แบบละเอียดแทนไฟล์ใหญ่ไฟล์เดียว ดังนั้นเมื่ออัปเดต dependency เฉพาะตัว จะมีเพียงชังก์นั้นที่ถูก invalidated ส่วนที่เหลือยังคงอยู่ในแคช
-
การโหลดไฟล์ฟอนต์ขนาดใหญ่
- การโหลดฟอนต์ที่ผิดพลาดอาจทำให้ข้อความหายไปชั่วคราว, เกิด layout shift เมื่อสลับเป็นฟอนต์จริง, และเกิด fetch ซ้ำจาก preload ที่ไม่ตรงกัน
- Linear preload ฟอนต์ Inter Variable ใน
<head> และทำ preconnect ไปยัง static.linear.app
<link rel="preload"
href="https://static.linear.app/fonts/InterVariable.woff2?v=4.1"
as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preconnect" href="https://static.linear.app" crossorigin>
- Variable font จัดการแกน weight ทั้งช่วง 100~900 ด้วย woff2 ไฟล์เดียว จึงไม่ต้อง request แยกตามแต่ละ weight
font-display: swap จะเรนเดอร์ fallback stack ทันที แล้วค่อยสลับเป็น Inter หลังโหลดเสร็จ
crossorigin="anonymous" ในแท็ก preload เป็นการตั้งค่าหลักที่ทำให้เมื่อ CSS อ้างถึงฟอนต์เดียวกันในภายหลัง เบราว์เซอร์สามารถใช้ทรัพยากรที่แคชไว้ซ้ำได้
- หากไม่มี
crossorigin โหมด CORS ของ preload กับการอ้างอิงใน CSS จะต่างกัน ทำให้เบราว์เซอร์ต้องดึงฟอนต์ซ้ำอีกครั้ง
-
Inline app shell
- Linear ใส่ CSS แบบ inline ไว้ใน
<head> มากพอสำหรับวาดสถานะกำลังโหลด เพื่อให้แสดง app shell ได้โดยไม่ต้องรอ request ของ stylesheet ภายนอก
- JavaScript แบบ inline จะตัดสินใจเงื่อนไขที่จำเป็นต่อประสบการณ์เริ่มต้นได้ทันที
- ตรวจจับ Electron และ Linear user agent แล้วเพิ่มคลาส
electron
- ถ้าไม่มี
localStorage.ApplicationStore ก็เพิ่มคลาส logged-out
- กู้คืน shell token เช่น พื้นหลัง sidebar, ความกว้าง sidebar, และ dark mode จาก
localStorage.splashScreenConfig
- หากผู้ใช้ตั้งค่าให้เปิดลิงก์ในแอปเดสก์ท็อป จะปรับความกว้าง sidebar เป็น
8px
- ก่อนที่ JavaScript bundle แรกจะมาถึงผ่านเครือข่าย หน้าจอโหลดก็จะมีธีม ขนาด และตำแหน่งที่ตรงกับสถานะการล็อกอินอยู่แล้ว
- วิธีที่เร็วที่สุดในการทำให้ผู้ใช้รู้สึกว่าแอปพร้อมทันทีที่กด Enter หลังพิมพ์ URL คือส่ง app shell ไปพร้อมกับการตอบกลับ
index.html ตั้งแต่แรก
-
เรนเดอร์ก่อนแล้วค่อยยืนยันตัวตนทีหลัง
- โฟลว์การยืนยันตัวตนทั่วไปจะเป็นลำดับ HTML fetch, โหลด bundle, ตรวจสอบ session, fetch ผู้ใช้, fetch workspace, แล้วค่อย render ซึ่งอาจใช้เวลา 1~3 วินาทีกว่าผู้ใช้จะเห็นอะไรบางอย่าง
- Linear จัดการการยืนยันตัวตนแบบเดียวกับการประมวลผลการเปลี่ยนแปลง คือสมมติว่าเป็นเส้นทางปกติไว้ก่อนแล้วตรวจสอบในเบื้องหลัง
- แอป CRUD ส่วนใหญ่จะเก็บ session จริงไว้ใน HttpOnly cookie และเพิ่ม cookie แยกที่ JavaScript อ่านได้หรือเพิ่ม request ไปที่
/me เพื่อให้ frontend รู้ระหว่างเริ่มต้นว่าผู้ใช้ล็อกอินอยู่หรือไม่
- inline boot script ของ Linear ไม่ใช้สัญญาณยืนยันตัวตนแบบขนาน แต่ตรวจเพียงว่ามี
localStorage.ApplicationStore อยู่หรือไม่
if (localStorage.getItem("ApplicationStore") === null) {
document.documentElement.classList.add("logged-out");
}
- ถ้ามี
ApplicationStore แปลว่าผู้ใช้เคยใช้ Linear บนเบราว์เซอร์นี้ และมีข้อมูล workspace อยู่ใน IndexedDB แล้ว
- ถ้าไม่มีค่า ก็ไม่มีข้อมูลให้เรนเดอร์ ดังนั้น shell จะสลับไปเป็นเลย์เอาต์แบบ logged-out แล้วเข้าสู่โฟลว์การล็อกอินต่อ
- session token จริงอยู่ในคุกกี้ และ bundle จะไม่ตัดสินสถานะ session ล่วงหน้า
- หาก WebSocket handshake, sync delta, หรือ HTTP call ใดก็ตามได้รับ 401 จาก session ที่หมดอายุ ไคลเอนต์จะ redirect ไปหน้าเข้าสู่ระบบ
- แพตเทิร์นทั้งหมดคือเชื่อถือข้อมูลในเครื่องเพื่อเรนเดอร์ได้ทันที, ใช้เซิร์ฟเวอร์เป็นแหล่งความถูกต้อง, และปรับให้ทั้งสองฝั่งสอดคล้องกันแบบอะซิงโครนัส
เอนจินการซิงก์
- ความเร็วของ Linear เริ่มต้นจากการตัดสินใจมองเซิร์ฟเวอร์เป็น sync target ไม่ใช่ source of truth ของ UI
- ความเร็วไม่ใช่ผลจากองค์ประกอบเดียว แต่เป็นผลลัพธ์จากสามแกนที่ทำงานประสานกัน
-
1. มีข้อมูลอยู่แล้ว
- ตอนแอปบูต จะไม่ดึง workspace จากเซิร์ฟเวอร์ แต่ hydrate จาก IndexedDB ไปยัง object pool ของ MobX ในหน่วยความจำ
- ทุก query ของ UI จะชี้ไปที่ object pool ก่อน และเพราะ issue อยู่บนอุปกรณ์ของผู้ใช้อยู่แล้ว จึงไม่มีสถานะ “loading issues”
- ระหว่างการขยายระบบ Linear ได้แบ่งข้อมูลของเอนจินการซิงก์เป็นชังก์ตามหลักการคล้ายกับ JavaScript bundle
- ตารางที่หนักที่สุดสองตารางคือ Issue และ Comment จะไม่ถูกดึงมาทีเดียว แต่จะ lazy-hydrate เมื่อจำเป็น
- วิธีนี้คือ data-level code splitting และทำให้ต้นทุนตอนเริ่มต้นขึ้นกับโครงสร้างของ workspace ไม่ใช่ขนาดของ workspace
- แม้เป็น workspace ที่มี issue 10,000 รายการ ก็ยังบูตได้เร็วแทบไม่ต่างจาก workspace ที่มี issue 100 รายการ
- เมื่อเข้าไปในโปรเจกต์ issue ก็มีอยู่แล้ว และเมื่อกรองตาม assignee ดัชนีก็ถูกสร้างไว้แล้ว
-
2. การเปลี่ยนแปลงไม่รอเครือข่าย
- เมื่อเปลี่ยนสถานะของ issue จะมีสามสิ่งเกิดขึ้นแทบพร้อมกัน
- อัปเดต MobX observable เพื่อสะท้อนการเปลี่ยนแปลงใน UI
- บันทึกการเปลี่ยนแปลงลงใน durable transaction queue ของ IndexedDB
- เพิ่มการเปลี่ยนแปลงเข้าไปในคิวส่งไปยังเซิร์ฟเวอร์
- ณ จุดนี้ เครือข่ายยังไม่ได้ถูกใช้งาน
- ผู้ใช้ไม่ต้องรอเพื่อเห็นการเปลี่ยนแปลงของตัวเอง ส่วนการ retry, rollback และ reload across durability จะถูกจัดการทั้งหมดในเบื้องหลัง
- หากเซิร์ฟเวอร์ปฏิเสธ observable จะถูกย้อนกลับและเกิด flicker สั้น ๆ แต่การเปลี่ยนแปลงที่ไม่ถูกต้องส่วนใหญ่จะถูกจับได้ก่อนสร้าง transaction
- โฟลว์ของ Linear เริ่มจากการเปลี่ยนแปลงในเครื่อง และมองเซิร์ฟเวอร์เป็นขั้นตอนยืนยัน ไม่ใช่ขั้นตอนอนุญาต
-
3. หนึ่งเดลตา, หนึ่งเซลล์
- เมื่อเซิร์ฟเวอร์ยืนยันการเปลี่ยนแปลงของผู้ใช้หรือของคนอื่น JSON envelope ขนาดเล็กที่บอกว่ามีอะไรเปลี่ยนจะถูกส่งกลับมาที่ไคลเอนต์
- ไคลเอนต์จะนำการเปลี่ยนแปลงไปใช้ด้วยการเขียนค่าเข้า MobX observable ที่เกี่ยวข้อง
- ทุกคุณสมบัติของโมเดลใน Linear เป็น observable แยกกัน และทุกคอมโพเนนต์ที่อ่านคุณสมบัตินั้นจะถูกครอบด้วย
observer()
- MobX จึงรู้ได้อย่างแม่นยำว่าคอมโพเนนต์ใดขึ้นต่อฟิลด์ใด
- การเปลี่ยนแปลงหนึ่งฟิลด์ของ issue หนึ่งรายการจะ re-render เฉพาะคอมโพเนนต์ที่อ่านฟิลด์นั้น และจะไม่ re-render รายการแม่หรือ sidebar ทั้งหมด
- การอัปเดต issue 50 รายการจึงไม่ใช่การ re-render ทั้งลิสต์ แต่เป็นการ re-render 50 เซลล์
- แม้อยู่ใน workspace ที่วุ่นวายซึ่งมี 10 คนแก้ไขพร้อมกัน ต้นทุนของการรับอัปเดตก็จะเพิ่มตามจำนวนรายการที่เปลี่ยนจริง ไม่ใช่จำนวนรายการทั้งหมดบนหน้าจอ
-
เหตุผลที่ทั้งสามอย่างต้องทำงานร่วมกัน
- ถ้ามีแค่ฐานข้อมูลในเครื่องแต่ไม่มี optimistic write ตอนบันทึกก็ยังต้องเจอสปินเนอร์
- ถ้ามีแค่ optimistic write แต่ไม่มี observable ที่ละเอียดพอ ก็จะยังหน่วงทุกครั้งที่มีอัปเดต
- ถ้ามีแค่ observable ที่ละเอียดพอ แต่ไม่มีฐานข้อมูลในเครื่อง ก็ยังต้องรอตอนโหลดครั้งแรก
- ความเร็วของ Linear ไม่ใช่คุณสมบัติของเลเยอร์ใดเลเยอร์หนึ่ง แต่เป็นคุณสมบัติของทั้งระบบ
- bundler และ loader shell ทำให้ first paint รู้สึกเร็ว ส่วนเอนจินการซิงก์ทำให้ยังรู้สึกเร็วต่อเนื่องหลังเริ่มใช้งาน
การออกแบบเพื่อความเร็ว
- ความเร็วเป็นทั้งปัญหาด้านวิศวกรรมและปัญหาด้านการออกแบบ
- ถ้าเส้นทางไปยังแอ็กชันที่เร็วที่สุดยังต้องใช้เมาส์ เมนูสามชั้น และการคลิก ผู้ใช้ก็ต้องจ่ายต้นทุนของขั้นตอนเหล่านั้นโดยไม่เกี่ยวกับความเร็วของเอนจินภายใน
- อีกแกนหนึ่งของความเร็วใน Linear คือการผสานคีย์บอร์ดให้เป็นเครื่องมือหลักสำหรับการนำทางและการทำงานให้เสร็จ
- งานทั่วไปทุกอย่างมี shortcut, command palette เปิดได้ด้วยการกดปุ่มครั้งเดียว และ right-click menu ก็สร้างขึ้นแบบคัสตอม
-
ทุกแอ็กชันมี shortcut
- ตัวอักษรเดี่ยวใช้แก้ไข issue ที่โฟกัสอยู่ ชุดตัวอักษรสองตัวใช้สำหรับการนำทาง และ modifier ใช้สำหรับการทำงานแบบ global
- ตั้งแต่ช่วงแรกของ Linear นั้น shortcut เป็นองค์ประกอบพื้นฐาน และเอนจินการซิงก์ก็ส่วนหนึ่งถูกออกแบบมาเพื่อให้ทุกแอ็กชันสามารถทำได้ทุกเมื่อ
- shortcut ปรากฏอยู่ทั่วทั้ง UI และ shortcut ที่ใช้บ่อยที่สุดเป็นตัวอักษรเดี่ยว
- เพื่อไม่กีดกันผู้ใช้มือใหม่ ทุกแอ็กชันจึงยังทำได้ด้วยเมาส์
-
Command palette อยู่ห่างออกไปแค่การกดปุ่มครั้งเดียวเสมอ
⌘ k จะเปิด command palette ที่สามารถค้นหาแอ็กชันแทบทั้งหมดใน Linear ได้
- สิ่งที่ค้นหาได้มีทั้ง issue, โปรเจกต์, label, การเปลี่ยนสถานะ, การนำทาง, การสร้าง issue, การตั้งค่า, การสลับธีม และอื่น ๆ
- command palette ค้นหาจาก object pool ของ MobX ในเครื่อง ไม่ใช่จากเซิร์ฟเวอร์ จึงเร็วมาก
- แอปทั้งแอปเข้าถึงได้จาก pane เดียว โดยทั้งการนำทาง การสร้าง issue และการเปลี่ยนสถานะทำได้ผ่านการค้นหา
- command palette จะปรับตามบริบทการทำงานปัจจุบัน และทำหน้าที่สอนแอ็กชันหลักกับ shortcut ของแต่ละ view
- แอปที่เร็วต้องมีทั้งวิศวกรรมที่ยอดเยี่ยมและการออกแบบที่ยอดเยี่ยม โดยความเร็วเชิงวิศวกรรมทำให้แต่ละปฏิสัมพันธ์เกิดขึ้นได้เร็ว ส่วนความเร็วเชิงการออกแบบทำให้เส้นทางไปถึงปฏิสัมพันธ์สั้นลง
- ในเครื่องมือที่ใช้ทั้งวัน ความต่างระหว่าง shortcut กับเส้นทางใช้เมาส์ 2 วินาทีจะสะสมในทุกแอ็กชัน
แอนิเมชัน
- แอนิเมชันที่แย่อาจทำให้มิลลิวินาทีที่อุตส่าห์ลดลงจากการปรับแต่ง initial load, การอัปเดต และการ query ฐานข้อมูล ถูกใช้ทิ้งไปอีกครั้งในขั้นตอนสุดท้าย
- องค์ประกอบอย่าง height animation 500ms สามารถทำลายความพยายามที่จะไม่ให้ผู้ใช้ต้องรอได้
-
มีเพียงไม่กี่ property ที่ควรนำมาแอนิเมต
- การเปลี่ยนแปลง property ในเบราว์เซอร์มีต้นทุน 3 ระดับ ขึ้นอยู่กับตำแหน่งใน rendering pipeline
- composited property อย่าง
transform และ opacity จะส่งงานไปให้ GPU และทำงานโดยแยกจาก main thread
- paint-triggering property อย่าง
color, background-color, border-color, fill จะข้าม layout แต่ทำให้เกิดการวาดพิกเซลใหม่
- layout-triggering property อย่าง
width, height, top, left, margin, padding จะทำให้ต้องคำนวณตำแหน่งของทุกองค์ประกอบถัดไปใหม่ และไม่ควรถูกใช้เป็นเป้าหมายของแอนิเมชัน
/* วิธีของ Linear */
.row:hover {
background-color: var(--color-bg-hover);
transition: background-color 0.12s;
}
.icon-arrow {
transform: translateX(0);
transition: transform 0.15s;
}
- ถ้าแอนิเมต
margin-left layout ของทุก row ใต้ row ที่ hover จะถูกคำนวณใหม่ทุกเฟรมตลอดช่วง transition 200ms
- ในรายการ issue ที่ยาว ความแตกต่างนี้คือสิ่งที่แบ่งระหว่างภาพที่ลื่นไหลกับอาการ jank
- property สำหรับแอนิเมชันของ Linear ส่วนใหญ่เป็น composited property อย่าง
transform และ opacity และบางครั้งใช้ background-color กับ border-color
-
ต้องรู้ว่าเมื่อไรควรยับยั้ง
- ในเครื่องมือที่ใช้งานทุกวัน แอนิเมชันที่ดูดีบนเว็บไซต์การตลาดอาจรบกวนการทำงานได้
- แม้แต่ hover delay เล็กน้อยที่วางผิดจุดก็อาจเป็นสิ่งที่ผู้ใช้สังเกตเห็นได้
- แอนิเมชันจำนวนมากของ Linear ทำงานได้อย่างมีประสิทธิภาพเพราะอ้างอิง origin
- status popover จะ scale out จาก status pill และ agent panel จะ slide in จาก toggle
- motion แบบนี้ไม่ได้เป็นแค่ fade เพื่อความสวยงาม แต่ทำหน้าที่เชิงพื้นที่เพื่อบอกว่าองค์ประกอบใหม่มาจากไหน
-
รักษา duration ให้สั้นและตอบสนองทันที
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;
- design system จำนวนมากตั้งค่า duration เริ่มต้นไว้นานเกินความจำเป็น
- standard duration ของ Material คือ 200ms และ iOS spring อยู่ใกล้ 350ms
- ค่าเริ่มต้นของ Linear อยู่ฝั่งที่สั้นกว่าธรรมเนียมปฏิบัติของอุตสาหกรรม
- Linear ใช้ timing แบบไม่สมมาตรระหว่าง enter และ exit
- hover highlight, popover และ agent panel จะแสดงขึ้นทันทีเมื่อถูกเรียกใช้ และ fade out เป็นเวลา 150ms ตอนปิด
- หน้าต่าง agent จะแสดงขึ้นทันทีและ fade out คล้ายกับ macOS
วิธีที่ Linear ทำให้เร็ว
- ประสิทธิภาพของ Linear ไม่ได้มาจากเคล็ดลับเดียวหรือเทคโนโลยีอย่างใดอย่างหนึ่ง แต่เป็นผลสะสมของการตัดสินใจที่ถูกต้องนับร้อยครั้ง
- แนวทางจำนวนมากนั้นเรียบง่าย และเป็นผลจากการกำหนดสถาปัตยกรรมที่เหมาะกับผู้ใช้ตั้งแต่ต้นแล้วรักษาไว้ โดยไม่ต้องพึ่ง Next, TanStack หรือ framework หรูหรา
- เซิร์ฟเวอร์ไม่ได้ทำหน้าที่เป็น source of truth ของ UI แต่เป็นเป้าหมายสำหรับการ sync
- ฐานข้อมูลอยู่ภายในเบราว์เซอร์ และการเปลี่ยนแปลงจะถูกนำไปใช้ในเครื่องก่อน แล้วค่อยปรับให้ตรงกันในเบื้องหลัง
- การโหลดครั้งแรกจะส่งโค้ดให้น้อยลงแต่แบ่งเป็นชิ้นมากขึ้น และ service worker จะ precache ส่วนที่เหลือขณะที่ผู้ใช้อยู่ในหน้าเข้าสู่ระบบ
- การยืนยันตัวตนตั้งต้นจาก local state โดยสมมติว่าเส้นทางปกติใช้งานได้ แล้วค่อยตรวจสอบภายหลัง
- sync engine จะ hydrate จาก IndexedDB ไปเป็น MobX observable ระดับ per-property ดังนั้นการอัปเดต issue 50 รายการจึงกลายเป็นการ re-render 50 เซลล์ แทนที่จะ re-render ทั้งรายการ
- โมเดลการป้อนข้อมูลเน้น keyboard-first และงานทั่วไปทั้งหมดมี shortcut กับ global command palette
- แอนิเมชันยึดอยู่กับ property ที่เป็นมิตรกับ GPU และไม่แอนิเมต property ที่ trigger layout
- ส่วนที่ยากไม่ใช่แค่ตัวการ implement แต่คือการรักษาความใส่ใจในรายละเอียดด้านคุณภาพตลอดหลายปี ขณะที่ codebase เติบโต ขยายตัว และเจอกับข้อจำกัดใหม่ๆ
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ถ้าอยากใส่ประสบการณ์แบบนี้ลงในแอปพลิเคชัน ลองดู Zero(https://zero.rocicorp.dev/) ได้
เดโมสด: https://gigabugs.rocicorp.dev/
มีรายการทางเลือกอยู่ที่นี่ด้วย: https://zero.rocicorp.dev/docs/when-to-use#alternatives
ถ้าสงสัยว่าภายในทำงานอย่างไร เอกสารออกแบบของ Replicache ก็น่าอ่าน: https://doc.replicache.dev/concepts/how-it-works
Replicache คือรุ่นก่อนหน้าของ Zero และโปรโตคอลแกนหลักก็ยังทำงานในลักษณะเดียวกัน
นอกจากข้อได้เปรียบด้านประสิทธิภาพที่ชัดเจนจากการซิงก์ข้อมูลมาไว้ที่ไคลเอนต์แล้ว ยังน่าทึ่งด้วยว่าโค้ด React เรียบง่ายขึ้นแค่ไหน เมื่อมี sync engine สถานะฝั่งไคลเอนต์ส่วนใหญ่ก็หายไป และทำให้คิดกับโค้ดคอมโพเนนต์ส่วนใหญ่แบบ synchronous ได้
ถ้าไม่ตั้งทีมเฉพาะขึ้นมาเอง นี่น่าจะเป็นตัวเลือกที่ใกล้เคียงที่สุดแล้ว
ได้ยินมาตลอดว่า Linear เร็ว แต่พอใช้ทุกวันจริง ๆ ความตื่นเต้นก็หายไป การค้นหาค่อนข้างช้า และ UI ก็มักจะหน่วง ๆ แม้จะดูดี แต่ “Pulse” ก็เหมือนกระแสข้อมูลรบกวนมหาศาลแม้ในสเกลเล็ก
หาสิ่งที่ต้องการได้ยากจนสุดท้ายต้องใส่ดาวไว้ทั้งหมด สำหรับประสบการณ์ติดตามโปรเจ็กต์แล้ว Trello ยุคแรก ๆ ยังดีที่สุดแบบทิ้งห่าง
ปีที่แล้วมีคนหนึ่ง reverse engineer sync engine ของ Linear แล้วเอาขึ้น GitHub พร้อมคำอธิบายเจ๋ง ๆ
https://github.com/wzhudev/reverse-linear-sync-engine/blob/m...
เว็บแอป local-first sync แบบนี้น่าสนใจมากและอาจมีประโยชน์มาก แต่ผมคิดว่าสมมติฐานตั้งต้นค่อนข้างผิด
เป็นสมมติฐานประมาณว่า “ใช้เวลาไม่กี่มิลลิวินาทีก็อัปเดต issue ใน Linear ได้แล้ว แต่แอป CRUD แบบดั้งเดิมใช้เวลาราว 300ms กับงานเดียวกัน” และ “ข้อมูลทุกชิ้นที่วิ่งไปมาระหว่างไคลเอนต์กับเซิร์ฟเวอร์ต้องจ่ายต้นทุนเป็นหลายร้อยมิลลิวินาที”
แม้จะแก้ปัญหาเวลาไป-กลับระหว่าง HTTP client กับเซิร์ฟเวอร์ที่ยืดออกเพราะความเร็วแสงไม่ได้ แต่ก็ทำให้แบ็กเอนด์อยู่ใกล้ผู้ใช้และทำงานได้เร็วได้
ตัวอย่างเช่น การรันแบ็กเอนด์ของเว็บแอปให้มีเวลาไป-กลับราว 10ms สำหรับผู้ใช้ส่วนใหญ่ และให้แบ็กเอนด์เรนเดอร์คำตอบได้ภายในราว 10ms ก็เป็นเรื่องที่ทำได้สบาย ๆ นั่นคือแอป CRUD แบบดั้งเดิมก็ทำงานเดียวกันได้ใน ประมาณ 30ms ไม่ใช่ 300ms
Linear อาจต้องใช้เวลานานกว่าที่แบ็กเอนด์ด้วยเหตุผลที่สมเหตุสมผล จนต้องพึ่งความช่วยเหลือจากฟรอนต์เอนด์ แต่จะเหมารวมแบบนั้นไม่ได้ JavaScript ทุกชิ้นก็มีต้นทุนของมันเอง
us-west-1 อยู่ที่ 60ms, eu-centra-1 อยู่ที่ 100ms, เอเชียห่างออกไป 200ms และนี่ก็ยังเป็นทราฟฟิกระหว่างดาต้าเซ็นเตอร์ ส่วน latency บนอินเทอร์เน็ตสาธารณะจริงไปจนถึงอินเทอร์เน็ตตามบ้านยิ่งแย่กว่าอีก
ฐานข้อมูลต้องอยู่ใน region เดียวอย่างชัดเจน ไม่ว่าจะวางไว้ที่ไหน ผู้ใช้ส่วนใหญ่ของโลกก็จะอยู่ห่างจากที่นั่นเกิน 100ms
เหตุผลที่ไม่สำคัญว่า endpoint อยู่ที่ไหน ก็เพราะ endpoint ต้องคุยกับฐานข้อมูลเพื่ออ่านและเขียนข้อมูลอยู่ดี ทันทีที่พยายามทำสำเนาข้อมูลไปไว้ใกล้ผู้ใช้ คุณก็ลงเอยด้วยการมี ฐานข้อมูล local-first sync อยู่ดี
ไม่ว่าจะทำเองหรือใช้ของสำเร็จรูป ฐานข้อมูลแบบทำซ้ำนี้ก็มีปัญหาแบบเดียวกับการซิงก์ฝั่งไคลเอนต์ทั้งหมด และก็ยังเหลือ latency เครือข่ายอยู่มากเหมือนเดิม หนีฟิสิกส์ไม่พ้น ดังนั้นคุณมีทางเลือกแค่ให้ผู้ใช้ส่วนใหญ่ได้ commit ที่ 0.25 วินาที หรือไม่ก็เลือก eventual consistency หรือก็คือการซิงก์
แน่นอนว่าคุณอาจวาง “แบ็กเอนด์ตรงกลาง” ไว้บนเครือข่าย CDN edge ทั่วโลกได้ แต่ถึงจุดนั้นคุณก็ต้องจ่ายต้นทุนความซับซ้อนแบบเดียวกับแนวทางนี้ที่เอา “แบ็กเอนด์ตรงกลาง” ไปไว้ในไคลเอนต์
ในกรณีแย่ที่สุด background worker ก็แค่ส่งข้อความว่าอัปเดตล้มเหลว แล้ว UI thread ค่อยรับไปแสดง เส้นทางความสำเร็จก็ยังคงเร็วระดับสายฟ้าเหมือนเดิม
การสร้าง ฐานข้อมูลแบบ eventual consistency นั้นยาก และแม้อาจเหมาะกับกรณีใช้งานของ Linear แต่การไม่รู้ว่าอัปเดตของฉันไปถึงเซิร์ฟเวอร์หรือก็คือทีมแล้วหรือยังเป็นปัญหา
ในโปรเจ็กต์อื่น ๆ ที่เคยร่วมทำมาก่อน ความล่าช้าในการซิงก์ก่อปัญหานับไม่ถ้วน เลยเลือกใช้วิธีแบบ synchronous มาโดยตลอด ฟีเจอร์หวือหวาจะหยิบมาใช้ก็ต่อเมื่อจำเป็นจริง ๆ เท่านั้น ไม่อย่างนั้นขอปรับแต่งเซิร์ฟเวอร์ให้เร็วสุด ๆ แล้วให้ผู้ใช้รับภาระเรื่องความหน่วงเครือข่ายจะดีกว่า
ที่บริษัทใช้ Linear อยู่ ฉันอาจเป็นเสียงส่วนน้อย แต่ ประสบการณ์ผู้ใช้ มันลำบากมาก และก็เรียกได้ไม่เต็มปากว่าเร็ว
ตัวหน้าเพจเองในเชิงเทคนิคก็โหลดได้เร็วพอใช้ แต่ครึ่งหนึ่งของเวลากลับเห็นตัวเลขบนหน้าเปลี่ยนไปโดยไม่มีสัญญาณทางภาพใด ๆ ว่าการโหลดข้อมูลยังดำเนินอยู่
มันแย่ถึงขั้นที่ใน Linear ฉันสร้าง issue พร้อมคำอธิบายแค่ประโยคเดียว แล้วไปกรอกรายละเอียดต่อใน GitHub นั่นคือสิ่งที่ Linear ทำได้ดีและเร็วจริง ๆ
น่าเสียดาย แต่ถ้าบริษัทจะอยู่รอดและขยับขึ้นไปสู่ตลาดระดับบน ก็แทบไม่มีทางอื่นจริง ๆ
ฉันคงไม่ใช้คำว่า “เร็ว” หรอก ในเมื่อแค่โหลดครั้งแรกก็ใช้เวลา 30 วินาทีแล้ว การลดเวลาอัปเดต issue จาก 300ms เหลือ “ไม่กี่” ms ก็ไม่ได้สำคัญนัก
ดีกว่า Jira ก็จริง แต่ก็เพราะมาตรฐานนั้นต่ำมาก
เจ๋งดี ฉันอาจใส่อะไรคล้าย ๆ กันลงไปในเกมเบราว์เซอร์และเอนจินที่กำลังพัฒนาอยู่ เพื่อหลังจากโหลดครั้งแรกแล้วจะได้ตัด สถานะการโหลด ออกไปทั้งหมด ของฉันเป็นโครงสร้างแอสเซ็ตแบบ static ฝั่งไคลเอนต์ล้วน ไม่มีเซิร์ฟเวอร์
ฉันหมกมุ่นกับประสิทธิภาพของเกมนี้มานาน ก่อนสุดสัปดาห์ที่ผ่านมา ฉันยังลำบากกับการรักษา 120fps บน M1 MacBook Pro ขณะจำลองผู้เล่นพร้อมกัน 128 คน โดยประมวลผลการหาเส้นทาง ลอจิกเชิงกลยุทธ์หนัก ๆ และการเรนเดอร์ทั้งหมดภายใน viewport และก็ยังมีเฟรมดรอปเป็นครั้งคราว โดยใช้เวลาเฟรมราว 4ms
ช่วงสุดสัปดาห์ฉันทุ่มทำงานด้านประสิทธิภาพอย่างหนัก และตอนนี้สามารถจำลองผู้เล่นพร้อมกันได้ 2048 คน ด้วยเวลาเฟรมต่ำกว่า 1 มิลลิวินาที ตัวเลขนี้รวมทั้งการเรนเดอร์ ลอจิกทั้งหมด และการสร้างเชิงกระบวนการแล้ว
อีกทั้งเมื่อทำ CPU throttling 11.2 เท่าเพื่อจำลองอุปกรณ์พกพาสเปกต่ำ ก็ยังได้ 60fps ที่เสถียรด้วยเวลาเฟรมราว 5ms ที่จำนวนผู้เล่นพร้อมกัน 256~512 คน ตอนนี้คอขวดหลักคือปัญหาลอจิกเล็กน้อยและเวลาเริ่มต้น/บูตที่ควรปรับปรุงบนอุปกรณ์สเปกต่ำ ซึ่งดูเหมือนจะมีอะไรให้เรียนรู้จาก Linear
ฉันรู้สึกว่า Linear จริง ๆ แล้วค่อนข้างช้า เคยมีช่วงหนึ่งที่เปิดแท็บทิ้งไว้นาน ๆ แล้วมันกิน CPU 100%
ก็น่าสนใจนะ แต่พูดตามตรง ฉันไม่เคยคิดว่า Linear “เร็ว” เลย มันมีความหน่วงแบบเดียวกับเว็บแอปส่วนใหญ่ แต่ถ้าเทียบกับ JIRA ก็เร็วราวกับแสงแน่นอน
ตัว Linear เองยอดเยี่ยม และหลังจากผ่าน การทรมานจาก JIRA มาก็ถือว่าสดชื่นจริง ๆ แต่ถ้าจะพูดถึงการทำ route แบบ optimistic และคำว่า “เร็ว” ฉันว่าควรเริ่มจาก Gmail ก่อนหรือเปล่า
คำตอบของความเร็วคือ การโหลดล่วงหน้า โดยพื้นฐานคือดาวน์โหลดฐานข้อมูลฝั่งไคลเอนต์มาตั้งแต่ตอนเริ่มต้น และมีแนวทางสำหรับการทำ cache invalidation
ฉันสร้าง starfx ขึ้นมาเพื่อจัดการด้าน data synchronization ของพาราไดม์นี้: https://starfx.bower.sh/learn#data-loading-strategy-stale-wh...