--- url: /blog-program/getting-to-know-effect-ts.md description: Promise 到底告訴了你什麼?來認識把錯誤跟依賴通通寫進型別的 Effect ( •̀ ω •́ )✧ --- ![getting-to-know-effect-ts](/getting-to-know-effect-ts.webp){.cover} # `Promise` 到底告訴了你什麼?來認識 Effect ( •̀ ω •́ )✧ 大家好,我是鱈魚。%( ´ ▽ \` )ノ% 這篇是上下兩篇的**上篇**,講 Effect 的核心概念,看完你會知道它到底在解什麼問題。 下篇[Effect 接進真實專案](/blog-program/effect-ts-in-real-projects)則是實戰,Vue、NestJS、oRPC 怎麼接,還有效能實測。 其實兩年前我就研究過 Effect 了,翻完文件的結論是「東西很好,但我用不起」,門檻跟心智負擔都太高,於是在新手村就被勸退。%(´\_ゝ\`)% 兩年後我回鍋了,因為當年那些門檻在 AI 協助開發後,基本上都不存在了。 至於卡在哪、現在又怎麼解,[回到兩年前](#回到兩年前)那節會算總帳,好奇的話可以先跳過去看。 不趕時間的話就跟著往下讀,從頭講起比較好懂。%( •̀ ω •́ )✧% ## 先問一個問題 大家有沒有接手過這種函數? ```ts function getUser(id: string): Promise ``` 型別看完,你只能確定一件事,成功的話會拿到 `User`。 其他通通不知道。 * 它會不會失敗?會,但型別沒說 * 失敗有幾種?查無此人?網路斷線?權限不夠?型別還是沒說 * 它需要什麼才跑得起來?資料庫?API 金鑰?型別依然沒說 只好翻進去看,結果它又呼叫了五個函數,於是再一層一層翻下去。%( ´•̥̥̥ ω •̥̥̥\` )% 鱈魚:「翻到最後我已經忘記本來要查什麼了。%(´\_ゝ\`)%」 路人:「...這不是每天都在發生嗎?%(´・ω・\`)%」 今天要聊的 Effect,就是把這三個答案通通寫進型別。 ## Effect 到底是什麼 大家常把 Effect 歸類成「Promise 的升級版」或「管理服務的工具」,它的定位其實更底層。 它是一套**描述程式的型別**,長這樣。 ```ts Effect ``` 三個欄位剛好對應剛才那三個問題。 | 欄位 | 意思 | 對應的問題 | |---|---|---| | `A` | 成功會拿到什麼 | 回傳值是什麼 | | `E` | 可能會怎麼失敗 | 會噴哪些錯 | | `R` | 需要什麼才能跑 | 依賴哪些服務 | `Promise` 只有第一欄。 路人:「另外兩件事寫在 JSDoc 裡不就好了?%(・∀・)%」 鱈魚:「可以啊,然後呢?%(´\_ゝ\`)%」 ```ts /** * @throws {UserNotFound} 查無此人 * @throws {NetworkError} 連線失敗 */ function getUser(id: string): Promise ``` 寫得很認真,問題是**沒有人會檢查它對不對**。 * 少寫一種錯誤,編譯器不會有意見 * 改了實作忘記改註解,註解就開始說謊 * 最麻煩的是它**不會自己長大**,`getUser` 裡面呼叫的函數哪天多噴一種錯誤,這份清單不會跟著更新 註解是寫給人看的備忘錄,型別是編譯器會拿去驗算的東西,差別就在這裡。%( ˘•ω•˘ )% `Effect` 三欄都在,後面兩欄由編譯器算出來,不用手寫,也不會過期。%( •̀ ω •́ )✧% ### Effect 寫好了也不會跑 理解 Effect 的另一個關鍵。 Promise 一建立就開始跑。 ```ts const promise = fetch('/api/user') // 這行跑完,請求已經送出去了 ``` Effect 不會。 ```ts const program = Effect.promise(() => fetch('/api/user')) // 什麼事都沒發生 ``` `program` 只是一段「等一下要去打這支 API」的描述,躺在那裡不動,交給執行器才會動起來。 ```ts Effect.runPromise(program) // 這時候才真的送出去 ``` 路人:「多此一舉吧?直接跑不是比較快?%(・∀・)%」 鱈魚:「因為還沒跑的東西才有辦法加工。%( •̀ ω •́ )%」 已經在跑的 Promise,你能做的事很有限,頂多 `.then` 接一下。 還沒跑的描述就不一樣了,丟去執行之前,你可以先說「失敗重試三次」、「超過五秒放棄」、「跟另外兩個一起併發」。 ```ts program.pipe( Effect.retry({ times: 3 }), Effect.timeout('5 seconds'), ) ``` 到這裡都還是描述,一行都還沒執行。%(ゝ∀・)b% ### Effect 的守備範圍 介紹 Effect 的文章常常只挑其中一欄講,容易讓人以為它就只做那件事,其實它的守備範圍涵蓋一整包平常散落各處的東西。 | 你原本用什麼 | Effect 裡對應的 | |---|---| | `Promise`、`async/await` | `Effect`、`Effect.gen` | | `try/catch` | `E` 欄位、`catchTag` | | 一層一層傳參數,或自己寫的服務容器 | `R` 欄位、`Layer` | | `Promise.all`、`AbortController` | Fiber、`Effect.all`、中斷機制 | | 自己寫的 retry 迴圈 | `Effect.retry`、`Schedule` | | `try/finally` 關資源 | `Scope`、`Effect.acquireRelease` | | `vi.useFakeTimers` | `TestClock` | 一篇文章塞不下這麼多,這次挑最常遇到的兩欄下手,`E` 跟 `R`。%(´,,•ω•,,)% ## 從最小的 Effect 開始長 與其一次看完整範例,不如從最小的形狀開始,一次加一樣東西。 ```ts import { Effect } from 'effect' const program = Effect.succeed(42) // Effect ``` 成功會給 42、不會失敗、不需要任何東西,兩個 `never` 就是這個意思。 接著讓它可能失敗。錯誤得先定義成帶標籤的類別,後面才有辦法靠標籤分辨。 ```ts import { Data, Effect } from 'effect' class UserNotFound extends Data.TaggedError('UserNotFound')<{ readonly id: string; }> {} const program = Effect.fail(new UserNotFound({ id: 'u1' })) // Effect ``` `E` 那欄出現 `UserNotFound` 了。 最後讓它需要一個服務,這裡用 `Effect.Service` 定義。 ```ts interface User { id: string; name: string; } class Database extends Effect.Service()('Database', { effect: Effect.sync(() => ({ findUser: (id: string): Effect.Effect => Effect.succeed({ id, name: '鱈魚' }), save: (user: User): Effect.Effect => Effect.void, })), }) {} const program = Effect.gen(function* () { const database = yield* Database return yield* database.findUser('u1') }) // Effect ``` `R` 那欄從 `never` 變成 `Database` 了。 沒人手動宣告這件事,編譯器看到 `yield* Database` 就自己算出來。%( •̀ ω •́ )✧% 三個欄位到齊,接下來把那個奇怪的星號講清楚。 ## 等等,`yield*` 是什麼鬼 路人:「我從剛剛就想問了,那個星號到底是什麼?%(´・ω・\`)%」 鱈魚:「Effect 看起來最像外星語的地方,先講結論,它就是 `await`。%(ゝ∀・)b%」 先澄清一件事,`yield*` 是 JavaScript 原生語法,2015 年就進標準,跟 Effect 沒關係,正式名稱叫**委派**(delegation),只能用在 generator function 裡面。 ```ts function* getAnswer() { return 42 } function* main() { const answer = yield* getAnswer() console.log(answer) // 42 } ``` `yield*` 把控制權交給另一個 generator,等它跑完,再**把回傳值接回來**,Effect 借的就是這個行為。 ### 心智模型:把它念成 await async 版本跟 Effect 版本擺在一起,對應關係很齊全,一家人整整齊齊。 | async/await | Effect | |---|---| | `async function` | `Effect.gen(function* () {})` | | `await promise` | `yield* effect` | | `Promise` | `Effect` | 同一段邏輯,兩種寫法。 ```ts // async 版本 async function getUser(id: string) { const user = await database.findUser(id) return user } ``` ```ts // Effect 版本 function getUser(id: string) { return Effect.gen(function* () { const database = yield* Database const user = yield* database.findUser(id) return user }) } ``` `async` 換成 `Effect.gen(function* ...)`、`await` 換成 `yield*`,剩下只差 `database` 從 import 進來變成跟環境拿。 讀 Effect 程式碼時,看到 `yield*` 就在心裡念成 `await`,八成不會出錯。%(´,,•ω•,,)% ### 那為什麼不乾脆用 await 路人:「都一樣的話,幹嘛不用 `await` 就好?%(・∀・)%」 鱈魚:「因為 `await` 綁死了,它只認 Promise。%( ˘•ω•˘ )%」 `await` 寫死在語言規格裡,它接受什麼、錯誤怎麼處理,都是 JavaScript 說了算,函式庫插不上手。 `yield*` 剛好相反,交出去之後要做什麼,接手的人自己決定。Effect 接手時就順便做了兩件 `await` 做不到的事。 * **把錯誤記到型別上**。`await` 的錯誤只會 `throw` 出去,型別是 `unknown`;`yield*` 會把錯誤累積到 `E` 那欄 * **把依賴記到型別上**。也就是那個 `R`,`await` 完全沒有對應概念 換句話說,每 `yield*` 一次,編譯器就默默幫你更新一次 `E` 跟 `R` 的清單。%( •̀ ω •́ )✧% ### 為什麼 `yield* Database` 能拿到服務 再回頭看這兩行,長得很像,做的事卻好像不同。 ```ts const database = yield* Database // 拿到服務 const user = yield* database.findUser(id) // 執行動作 ``` 路人:「一個是 class,一個是函數呼叫,為什麼都能 `yield*`?%(´・ω・\`)%」 鱈魚:「因為 `Database` 本身就是 Effect。%(ノ>ω<)ノ%」 `Effect.Service` 生出來的 class 除了當型別標籤,同時也是個「跟環境要一份 `Database`」的 Effect,型別長這樣。 ```ts Effect ``` 成功會給你一份服務、不會失敗、需要環境裡有這個服務。 所以那兩行其實是同一件事,只是一個負責要東西,另一個負責做事情。%(´,,•ω•,,)% ### 忘記寫星號會怎樣 新手最常踩的坑,而且症狀比忘記 `await` 更詭異。 ```ts database.save(user) // 忘了 yield* ``` 忘記 `await`,那個 Promise 至少還是會跑,只是沒人等它。 忘記 `yield*`,那行**根本不會執行**。%(°ㅂ°)% Effect 是惰性的描述,沒交給執行器之前,它就只是躺在那裡的物件,跟你寫了一行 `{ type: 'save' }` 沒什麼兩樣。 路人:「這不是很可怕嗎?資料就這樣人間蒸發了?%(;´༎ຶД༎ຶ\`)%」 鱈魚:「所以這是 Effect 少數得靠習慣防守的地方,寫久了看到沒有 `yield*` 開頭的那行會很刺眼。%( ˘•ω•˘ )%」 ### 流程控制照常寫 Effect 另一種寫法是用 `pipe` 把函數串起來,而 `Effect.gen` 最大的好處是**流程控制照常寫**。 迴圈、`if`、`try` 這些原生語法通通能用。 ```ts Effect.gen(function* () { const failedList: string[] = [] for (const id of idList) { const result = yield* database.findUser(id).pipe(Effect.either) if (result._tag === 'Left') { failedList.push(id) } } return failedList }) ``` 同樣的邏輯用 `pipe` 串起來會非常痛苦,難怪官方文件現在幾乎都拿 `Effect.gen` 當範例。%( •̀ ω •́ )✧% ### `yield*` 的三個限制 不過 `yield*` 有幾條硬規則,踩到會很困惑,先講清楚比較好。 **一、只能寫在 generator 函數的本體裡** 這條最容易中招。上面那段能不能改成 `forEach`? ```ts Effect.gen(function* () { idList.forEach((id) => { const result = yield* database.findUser(id) // [!code error] }) }) ``` **不行,這樣直接編譯失敗。**%(°ㅂ°)% 原因是 `forEach` 的 callback 是**另一個函數**,而 `yield*` 只能出現在 `function*` 自己的函數體內。跨進 callback 就沒有 generator 可以交棒了。 `await` 也有一樣的毛病,只是 `await` 至少可以把 callback 改成 `async`,`yield*` 連這條路都沒有。 所以在 `Effect.gen` 裡面,`for...of` 是你的好朋友,`forEach`、`map`、`filter` 的 callback 裡通通不能 `yield*`。 真的想用宣告式寫法,Effect 有對應的替代品。 ```ts Effect.gen(function* () { // 依序跑,收集結果 const userList = yield* Effect.forEach(idList, (id) => database.findUser(id)) // 併發跑,最多同時五個 const fastList = yield* Effect.forEach(idList, (id) => database.findUser(id), { concurrency: 5, }) return { userList, fastList } }) ``` `Effect.forEach` 的 callback 回傳 **Effect 本身**,callback 裡不需要 `yield*`,由外層那個 `yield*` 統一收。%(ゝ∀・)b% **二、只能 `yield*` Effect 或有實作 Yieldable 的東西** Promise 跟一般的值都不行。 ```ts Effect.gen(function* () { const bad = yield* fetch('/api') // [!code --] 這是 Promise,不是 Effect const good = yield* Effect.promise(() => fetch('/api')) // [!code ++] 先包成 Effect }) ``` Promise 得先用 `Effect.promise` 或 `Effect.tryPromise` 轉換過。 **三、`Effect.gen` 裡面不能用 `async`** ```ts Effect.gen(async function* () { }) // [!code error] ``` `Effect.gen` 收的是同步的 generator,非同步的事情交給 `yield*` 處理就好,不需要也不能加 `async`。 路人:「限制還真不少。%(´・ω・\`)%」 鱈魚:「習慣就好,而且第一條有官方 lint 規則會提醒你。%( ˘•ω•˘ )%」 ## `E` 那一欄,錯誤不再是 unknown 語法講完,回頭看那三個欄位到底解決了什麼。 先看 `E`。原生 `try/catch` 抓到的 `error` 型別是 `unknown`(沒開 strict 的話是 `any`,更慘),想知道是哪一種,只能用 `instanceof` 慢慢猜。 ```ts try { await database.findUser(id) } catch (error) { if (error instanceof UserNotFoundError) { return null } else { throw error // 忘記寫這行,其他錯誤就被吃掉了 } } ``` 漏掉最後那行 `throw`,網路逾時、權限不足這些錯誤就這樣消失了,編譯器不會有意見,因為 `catch` 區塊裡放什麼都合法。%( ´•̥̥̥ ω •̥̥̥\` )% Effect 版本用 `catchTag` 指定要處理哪一種,沒指定到的照樣往外傳。 ```ts database.findUser(id).pipe( Effect.catchTag('UserNotFound', () => Effect.succeed(null)), ) ``` 處理掉的錯誤會從 `E` 那欄消失,剩下沒處理的還留著。 ```ts // 處理前 Effect // 處理後 Effect ``` 想知道呼叫這個函數可能收到什麼錯誤,看型別就好,不用翻進去找 `throw`。%(ゝ∀・)b% ## `R` 那一欄,這個函數需要什麼 接著是 `R`,這欄會直接改變你怎麼寫測試。 先看原生寫法會出什麼事。 某天你寫了一個註冊功能,很單純,把使用者存起來就好。 ```ts // register-user.ts import { database } from './database' export async function registerUser(user: User): Promise { await database.save(user) return user } ``` 測試也很單純,把 `database` mock 掉就行了。 ```ts // register-user.test.ts import { database } from './database' import { registerUser } from './register-user' vi.mock('./database') it('註冊會把使用者存起來', async () => { const user = { id: 'u1', name: '鱈魚' } await registerUser(user) expect(database.save).toHaveBeenCalledWith(user) }) ``` 一切美好,測試綠燈。%(´,,•ω•,,)% 半年後,有人幫註冊流程加了一行寄歡迎信。 ```ts export async function registerUser(user: User): Promise { await database.save(user) await emailService.sendWelcomeEmail(user.email) // [!code ++] return user } ``` 他改完跑了一次測試,綠燈,直接送出 PR。 沒人記得回去改那份半年前寫的測試,因為它跟這次改動看起來八竿子打不著。%(・∀・)% 路人:「等等,`emailService` 沒 mock,測試不是應該壞掉嗎?%(´・ω・\`)%」 鱈魚:「它又不影響 `registerUser` 的回傳值,斷言照樣通過啊。%( ˘•ω•˘ )%」 於是測試維持綠燈,只是每跑一次,就真的往某個不存在的信箱寄一封歡迎信出去。 某天早上 Slack 跳出一封「歡迎加入」,收件人是 `test@example.com`,你才發現這件事已經持續三個月。%(;´༎ຶД༎ຶ\`)% 這裡沒有人偷懶。真正的問題是 `registerUser` 用到哪些東西**藏在函數內部**,型別上完全看不出來。 多加一個 import,型別系統一個字都不會抱怨,它本來就不管這件事。 ### 換成 Effect 同一份邏輯,依賴改成跟環境拿。 這裡的 `Database` 就是前面「從最小的 Effect 開始長」定義過的那個服務,用 `Effect.Service` 生出來的 class。 ```ts import { Effect } from 'effect' import { Database } from './database' // 前面用 Effect.Service 定義的那個 export function registerUser(user: User) { return Effect.gen(function* () { const database = yield* Database yield* database.save(user) return user }) } // Effect ``` 請注意 `Database` 是 import 進來的,但 import 的是**服務的名牌**,不是實作。 實作長什麼樣、連哪個資料庫,這個檔案從頭到尾都不知道,它只說「我需要一個叫 `Database` 的東西」。%( ˘•ω•˘ )% 半年後那位同事一樣加了寄信那行。 ```ts const emailService = yield* EmailService // [!code ++] yield* database.save(user) yield* emailService.sendWelcomeEmail(user.email) // [!code ++] ``` 這兩行一加,`R` 那欄自動變成 `Database | EmailService`。 沒人維護這份清單,全是編譯器算出來的。%( •̀ ω •́ )✧% ### 這件事有個正式名稱 「函數不要自己 import 該用哪個實作,改成由外面塞進去」,這個做法叫做**依賴注入**(Dependency Injection,常縮寫成 DI)。 聽起來很唬人,其實你早就在用了。 ```ts // 自己去拿,這叫相依 function registerUser(user: User) { return database.save(user) } // 由外面給,這就是依賴注入 function registerUser(user: User, database: Database) { return database.save(user) } ``` 第二種寫法讓呼叫端決定要塞哪個 `database` 進去,正式環境塞真的,測試塞假的,函數本身完全不用改。%(´,,•ω•,,)% 路人:「那多加一個參數不就好了,幹嘛搞這麼複雜?%(・∀・)%」 鱈魚:「兩三層還行,十層就哭了。%(´\_ゝ\`)%」 參數傳遞最麻煩的部分在於它會**傳染**。 最底層的函數要用 `database`,呼叫它的人也得先拿到才有辦法往下傳,再上一層又是同樣狀況,一路傳到最外面。 中間那些函數根本用不到,卻得在簽章上多掛一個參數,只因為底下有人要用。%( ˘•ω•˘ )% 於是各家框架發明各種容器來代勞,Effect 則是把這份清單收進 `R` 欄位,要用的人自己跟環境拿,中間那些函數一個字都不用改。 ### 那 Layer 又是什麼 前面一直說「跟環境拿」,那環境裡的東西是誰放進去的?答案是 **Layer**。 一句話講完,**Layer 就是服務的建造說明書**。%(´,,•ω•,,)% ```ts // 這是「名牌」,只講有哪些能力,不講怎麼實作 class Database extends Context.Tag('Database') Effect.Effect; }>() {} // 這是「說明書」,代表「Database 實際上長這樣」 const DatabaseLive = Layer.succeed(Database, { save: (user) => Effect.promise(() => db.insert(user)), // 真的寫進資料庫 }) ``` 這裡改用 `Context.Tag` 而不是 `Effect.Service`,是為了把名牌跟說明書拆成兩段看。兩者的差別等一下講。 `registerUser` 只認名牌,不認說明書。 執行之前用 `Effect.provide` 把說明書交出去,環境才算齊備。 ```ts registerUser(user).pipe( Effect.provide(DatabaseLive), // 把說明書塞進去 ) ``` 之所以要分成兩個東西,是因為同一張名牌可以配好幾份說明書。 | 說明書 | 用在哪 | |---|---| | `DatabaseLive` | 正式環境,真的連資料庫 | | `DatabaseTest` | 測試,回傳假資料 | | `DatabaseMemory` | 本機開發,存在記憶體 | `registerUser` 完全不知道自己拿到哪一份,也不需要知道。%( •̀ ω •́ )✧% 那前面一直用的 `Effect.Service` 又是什麼?它是**把名牌跟說明書綁在一起**的懶人包,會自動生一份叫 `Default` 的說明書。 ```ts Effect.provide(Database.Default) // Effect.Service 自動生的那份 ``` [官方文件](https://effect.website/docs/requirements-management/layers/#effectservice-vs-contexttag)的建議是這樣。 * **應用程式碼、實作只有一種** → 用 `Effect.Service`,少寫樣板 * **函式庫、或沒有哪個實作該當預設** → 用 `Context.Tag`,只發布名牌,讓使用者自己決定 Layer 還能互相組合、互相依賴(`Database` 需要 `Config`,`Config` 需要環境變數),這部分展開會太長,先知道「它是說明書」就夠用了。 ### 那所有套件都要包成服務嗎? 鱈魚:「來吧,讓我們把 lodash、dayjs、zod 通通包起來!%( •̀ ω •́ )✧%」 路人:「太麻煩了吧!%ლ(´口\`ლ)%」 鱈魚:「開玩笑的啦,不用都包起來。%(´,,•ω•,,)%」 判斷標準只有一句話,**你會不會想換掉它**。 | | 直接 import | 包成服務 | |---|---|---| | 例子 | `date-fns`、`zod`、`remeda` | 資料庫、外部 API、寄信、檔案 | | 特徵 | 同樣輸入永遠同樣輸出 | 結果隨環境改變 | | 測試時 | 不會想換掉 | 一定不想真的碰 | 純函數包成服務只是徒增樣板,測試也不會因此變好寫。%( ˘•ω•˘ )% 而且要注意,「把資料庫包成服務」指的是包**你自己那層封裝**,不是幫 `drizzle` 寫一層 Layer。 ```ts import { drizzle } from 'drizzle-orm/node-postgres' // 照常 import,沒在客氣 export class Database extends Effect.Service()('Database', { effect: Effect.sync(() => { const db = drizzle(process.env.DATABASE_URL!) return { findUser: (id: string) => Effect.promise(() => db.query.users.findFirst(/* ... */)), } }), }) {} ``` 套件照常 import,被包起來的是 `findUser` 這個**你定義的介面**。外面只認得介面,不曉得底下是 drizzle、Prisma 還是一包假資料。%( •̀ ω •́ )✧% 至於時間、亂數、環境變數這幾個常見的,Effect 已經內建,不用自己動手。 ```ts const now = yield* Clock.currentTimeMillis // 取代 Date.now() const dice = yield* Random.nextIntBetween(1, 7) // 取代 Math.random() const apiKey = yield* Config.string('API_KEY') // 取代 process.env ``` 測試時搭配 `TestClock` 就能把時間快轉,不用真的等五秒,也就是前面那張守備範圍表裡 `vi.useFakeTimers` 那列。%(ゝ∀・)b% 最後提醒一下,包越多 `R` 那欄就越長,每個呼叫端都要跟著 provide。 判斷不出來的時候先直接 import,等到「這裡測試很難寫」再抽出來也不遲。%(´,,•ω•,,)% ### 測試差異,眼見為憑 原生版本得靠 `vi.mock` 把整個模組換掉,前面已經看過會出什麼包。 Effect 版本改成**換一份說明書**,也就是換一顆 Layer。 ```ts // register-user.test.ts import { expect, it } from '@effect/vitest' import { Effect, Layer } from 'effect' import { Database, registerUser } from './register-user' const DatabaseTest = Layer.succeed(Database, { save: () => Effect.void, }) it.effect('註冊會把使用者存起來', () => Effect.gen(function* () { const user = { id: 'u1', name: '鱈魚' } const result = yield* registerUser(user) expect(result).toEqual(user) }).pipe( Effect.provide(DatabaseTest), )) ``` 有人把 `EmailService` 加進 `registerUser` 那天,這段測試不用等執行,**編譯就先紅了**。 ```text Type 'Effect' is not assignable to type 'Effect'. Type 'EmailService' is not assignable to type 'TestServices'. ``` 紅的是 IDE 裡那條波浪線,只要 CI 有跑型別檢查,這種 PR 就過不了。%( •̀ ω •́ )✧% 路人:「所以 Effect 靠編譯器抓漏 mock,原生只能靠人記得改測試?%(´・ω・\`)%」 鱈魚:「對,這就是最大的差別。原生版本那份清單就算寫進註解也沒人會去對,Effect 搬進型別系統,編譯器每次都幫你對一遍。%(ゝ∀・)b%」 ## 回到兩年前 開頭說我棄坑過,看到這裡你應該有感覺了,該來說說當年的理由。 前面出現過的東西,剛好就是當時勸退我的五樣。 **一、型別錯誤訊息長到看不懂** 還記得前面那段「少 provide 一顆 Layer 就編譯失敗」嗎?文章裡我只貼了三行,實戰版通常是一大串巢狀泛型,`Effect` 裡面包 `Effect`,重點藏在第七行的某個角落。 新手看到那種訊息只有一個反應,關掉。%(°ㅂ°)% **二、API 表面積大到會迷路** 這篇只用到 `Effect`、`Data`、`Layer`、`Context` 而已,實際上還有 `Schedule`、`Stream`、`Scope`、`Queue`、`Fiber`、`STM`、`Schema`⋯⋯每個底下又是幾十上百個函數。 想做一件很簡單的事,光是「該用哪個」就要翻很久。 這點官方自己也認了。[Myths 頁面](https://www.effect.website/docs/v3/additional-resources/myths)有一條就叫「Effect is impossible to learn, there are so many functions and modules!」,開頭第一個字是 **True**。%(°ㅂ°)% > 沒錯,完整的 Effect 生態系相當龐大,有些 module 包含上千個函數。但實際上你不需要全部學會才能開始產出,只要知道 10 到 20 個函數就可以安心開始用,剩下的慢慢認識就好。 後面還附了「常用的 15 個函數」跟「常用的 7 個 module」兩張清單,等於官方直接幫你畫重點。 這個回應很誠實,不過它解的是「要不要全部學會」,沒解到「當下這件事該用哪個函數」。%( ˘•ω•˘ )% **三、命名來自函數式傳統,猜不到** `flatMap`、`tap`、`zipRight`、`acquireRelease`、`provide`。知道的人覺得精準,不知道的人根本不曉得要去哪裡找。 前面 `Effect.either` 那招也是,你不會憑直覺想到「把失敗轉成值」要用這個字。 **四、同一件事有好幾種寫法** `pipe` 還是 `gen`?`Context.Tag` 還是 `Effect.Service`?`Layer.succeed` 還是 `Layer.effect`?選擇障礙直接發作。%(´・ω・\`)% **五、卡住了 Google 不到** 這點最致命。社群還小,Stack Overflow 幾乎沒有答案,遇到問題只能翻文件或去 Discord 問。 對於習慣「卡住就查、查到就繼續」的開發節奏,這等於整條路被切斷。 五點加起來就是所謂的心智負擔。概念本身不算難,難的是**每寫一行都要付出額外的查詢成本**,而那個成本高到讓人懷疑人生。%( ´•̥̥̥ ω •̥̥̥\` )% ### 這五點在 AI 面前都不太成立了 一條一條對回去看,會發現它們有個共通點,全都是**查資料的成本**。%(ノ>ω<)ノ% | 當年的門檻 | 現在怎麼解 | |---|---| | 型別訊息看不懂 | 貼給它,直接告訴你少了什麼 | | 不知道該用哪個函數 | 描述你要做的事,它給你名字 | | 命名猜不到 | 同上,不用先知道叫什麼才找得到 | | Google 不到 | 它讀過文件跟原始碼 | | 選擇障礙 | 把專案慣例講清楚,它會跟著走 | 而查資料剛好是 AI 最擅長的事。 當年那個「用不起」的結論,前提已經不成立了。%( •̀ ω •́ )✧% ### 但 AI 會生出過期的語法 先別急著開心,直接叫 AI 寫 Effect 會踩到一個大雷,**它很容易生出過期的語法**。 原因是 Effect 這幾年改動很大。 * `Effect.gen` 的 adapter 寫法(`function* (_) { yield* _(...) }`)從 2024 年 4 月起就不需要了,但它至今仍然能編譯能執行,所以編譯器不會攔你,而訓練資料裡又到處都是 * 現在 npm 上 `latest` 是 v3,v4 已經進 RC,兩份文件並存 * v4 又把 `Context.Tag`、`Effect.Tag`、`Effect.Service` 通通合併成 `Context.Service` 三個世代的 API 混在一起,AI 想不搞混也難。%(´\_ゝ\`)% ### 官方給的三樣工具 好消息是官方正在處理這件事,有三樣東西可以直接用。 **一、官方 skills** Effect 官方開了 [skills repo](https://github.com/Effect-TS/skills),一行指令裝好。 ```bash npx skills add Effect-TS/skills ``` 裡面兩個 skill,`effect-ts` 負責日常開發,`effect-v3-to-v4` 專門處理升級。 有趣的是 `effect-ts` 本身**幾乎沒有內容**,它只做一件事,叫 agent 去讀 `node_modules/effect/AGENTS.md`。 **二、套件裡自帶的 AI 文件** 這招很聰明。Effect v4 直接把 `AGENTS.md`(17KB)跟 `ai-docs/`(近 150KB、五十幾個 `.ts` 範例)**包進 npm 套件裡**。 ```text node_modules/effect/ ├── AGENTS.md ├── CLAUDE.md └── ai-docs/ ``` 文件永遠跟你裝的版本綁在一起,不會出現「文件講 v4、專案跑 v3」的慘案。%( •̀ ω •́ )✧% 要注意 v3 沒有這些檔案,這是 v4 才有的待遇。 **三、`@effect/language-service`** 這個我覺得最實用,它是 TypeScript 的 LSP plugin,專門抓 Effect 的寫法問題。 ```bash npm install -D @effect/language-service ``` 裡面幾條規則根本是為 AI 生成的程式碼量身訂做。 | 規則 | 抓什麼 | |---|---| | `effectGenUsesAdapter` | 用了 2024 年就淘汰的 adapter 寫法 | | `missingStarInYieldEffectGen` | 前面講過的,`yield` 忘記加星號 | | `floatingEffect` | 建立了 Effect 卻沒有人執行它 | | `outdatedApi` | 用到 v4 已經改名或移除的 API | 還記得前面那個「忘記寫星號,資料就人間蒸發」嗎?`missingStarInYieldEffectGen` 就是專門守這關的。%(ゝ∀・)b% 另外官方部落格有篇 [The One Weird Git Trick That Makes Coding Agents More Effect-ive](https://effect.website/blog/the-one-weird-git-trick-that-makes-coding-agents-more-effect-ive/),主張用 `git subtree` 把 Effect 原始碼放進專案,讓 agent 直接讀原始碼。 文中那句話蠻有意思的,agent 拿到「寫給人看的文件」時,效率明顯比較差。%(´,,•ω•,,)% 路人:「所以現在有 AI 就可以無腦上了?%(・∀・)%」 鱈魚:「當然沒這麼好康,有兩件事 AI 幫不了你。%( ˘•ω•˘ )%」 **第一,你還是得看得懂它寫了什麼。** AI 產出的 Effect 程式碼你 review 不了,那就只是把技術債外包出去,出事還是你要扛。 **第二,心智模型還是要自己建立。** `Effect` 那三欄代表什麼、為什麼要惰性、Layer 怎麼組合,這些理解不會因為 AI 會寫就自動長進你腦袋裡。 AI 砍掉的是**打字跟查資料的成本**,理解的成本它砍不掉。%( ˘•ω•˘ )% 不過話說回來,查資料跟猜 API 的成本一旦消失,當年那個「用不起」的結論就得重算一次。 所以我又回鍋了。%(´,,•ω•,,)% ## 總結 🐟 * `Promise` 只講了成功會拿到什麼,`Effect` 把「會怎麼失敗」跟「需要什麼」也寫進型別,後兩欄由編譯器算,不會像 JSDoc 那樣過期 * Effect 寫好了不會馬上跑,還沒跑的描述才加得上重試、逾時、併發,它也才管得動錯誤、依賴、資源、測試時鐘一整包 * `yield*` 就是 Effect 版的 `await`,每 `yield*` 一次編譯器就順手更新 `E` 跟 `R`,代價是它只能寫在 generator 本體,`forEach` 的 callback 裡放不了 * 那份依賴清單沒人手動維護,測試也就從「換掉整個模組」變成「換一顆 Layer」,少換一顆直接編譯失敗 * 別把所有套件都包成服務,判斷標準只有「你會不會想換掉它」,純函數直接 import 就好 概念的部分到這裡告一段落。 下篇[Effect 接進真實專案](/blog-program/effect-ts-in-real-projects)會把這些東西實際接到 Vue、NestJS 跟 oRPC 上,也有跟 Express、Fastify 的效能實測,最後動手做一支跑得動的 todo API。%( •̀ ω •́ )✧% 有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 --- --- url: /blog-vue/vue-composable-outer-variable-trap.md description: 把 ref 放在 composable 外面共用狀態,方便是方便,但是可能會埋下地雷喔。(´・ω・`) --- ![vue-composable-outer-variable-trap](/vue-composable-outer-variable-trap.webp){.cover} # AI 可能會寫的 Vue Composable 地雷,你踩過了嗎? 大家有沒有請 AI 幫忙寫過 Vue composable? 甚麼?你問現在誰還在手寫程式?抱歉我多問了。%(´;ω;\`) % 請 AI 寫一個「多個元件共用狀態」的 composable,雖然不是每次,但是他有可能會給你這樣的程式碼(尤其是降智的時候 %( ◔ ௰◔)%)。 ```ts import { ref } from 'vue' const count = ref(0) export function useCounter() { function increment() { count.value++ } function decrement() { count.value-- } return { count, increment, decrement } } ``` 把 `ref` 放在函式外面,這樣不管哪個元件呼叫 `useCounter()`,拿到的都是同一份 `count`,完美實現共用狀態。 AI 還會貼心地解釋:「把變數宣告在模組層級,所有呼叫者就能共用同一份響應式狀態。」%...(́⊙◞౪◟⊙‵)% ::: tip 為什麼放外面就能共用? ES Module 只會被執行一次,之後不管多少地方 `import`,拿到的都是同一個模組實例。 所以宣告在模組頂層的變數,自然就變成全域共用的單例(Singleton)。 ::: 看起來很合理對吧?但其實這段程式碼可能在無意間埋下地雷。%ԅ( ˘ω˘ԅ)% ## 這段程式碼有什麼問題? 既然知道這是利用 ES Module 的單例特性,讓我們想想這代表什麼。 `count` 這個 `ref` 從程式啟動到結束,永遠只有一份。 如果你的專案用了 Nuxt 這類有 SSR 的框架,要特別注意。 在 SSR 環境中,server 端的模組被所有請求共用。 ::: tip SPA 就不用擔心了吧? 純 SPA 的確不會有跨請求污染的問題,不過難保未來專案不會加上 SSR,到時候就變成埋在深處的地雷了。 既然有更安全的寫法,不如一開始就避開這顆潛在的地雷。 甚麼?你說你就是想埋?我不知道,我甚麼都沒聽到。%(。-\`ω´-)% ::: 也就是說,Request A 的使用者修改了 `count`,Request B 的使用者進來時,拿到的 `count` 就是 A 改過的值。 ```ts // 模組層級的變數,server 端所有請求共用 const count = ref(0) export function useCounter() { // Request A 呼叫 increment(),count 變成 1 // Request B 進來時,count 已經是 1 了 function increment() { count.value++ } return { count, increment } } ``` 這就是 Vue 官方文件中特別警告的 [**Cross-Request State Pollution**](https://vuejs.org/guide/scaling-up/ssr.html#cross-request-state-pollution)(跨請求狀態污染)。 使用者 A 的資料洩漏到使用者 B 的頁面上,輕則顯示錯誤,重則變成資安事件。 ## 正確的替代方案 經典用法是請出我們可愛的鳳梨 Pinia。( ´ ▽ \` )ノ🍍 Pinia 的 store 綁定在 Vue 的 app instance 上,SSR 時每個請求都會拿到獨立的 store 實例,從根本上解決跨請求污染問題。 ```ts export const useCounterStore = defineStore('counter', () => { const count = ref(0) function increment() { count.value++ } return { count, increment } }) ``` 如果你用的是 Nuxt,官方提供了 `useState` 這個 composable,專門用來建立 SSR 安全的共用狀態。 以剛才的 `useCounter` 為例,可以改寫成: ```ts export function useCounter() { const count = useState('count', () => 0) function increment() { count.value++ } function decrement() { count.value-- } return { count, increment, decrement } } ``` `useState` 底層會將狀態綁定在請求的 context 上,server 端不同請求之間不會互相污染,同時還會自動將 server 端的狀態序列化傳遞給 client 端,避免 hydration mismatch。 ## 一定要用 Pinia 嗎? 有些時候你只是想讓幾個元件共用一個 composable 的狀態,又覺得為了這點小事特地開一顆鳳梨(Pinia store)感覺有點太重了。 這時候可以試試 VueUse 提供的 `createSharedComposable`。 ```ts import { createSharedComposable } from '@vueuse/core' import { ref } from 'vue' function _useCounter() { const count = ref(0) function increment() { count.value++ } return { count, increment } } export const useCounter = createSharedComposable(_useCounter) ``` `createSharedComposable` 會確保不管多少個元件呼叫 `useCounter()`,底層的 `_useCounter` 只會執行一次,所有元件拿到的都是同一份狀態。 而且在 SSR 環境下會自動退回非共用模式,每個請求都拿到獨立的實例,不會有跨請求污染問題。%( •̀ ω •́ )✧% 簡單整理一下選擇方式: | 需求 | 方案 | |---|---| | 各元件獨立狀態 | 變數放函式內部 | | 輕量共用,不想開 store | `createSharedComposable` | | 複雜共用狀態、需要 devtools | Pinia | ## 總結 🐟 * 把 `ref` 放在 composable 函式外面,利用 ES Module 單例特性共用狀態,在 SSR 環境下會造成跨請求狀態污染 * 即使目前是純 SPA,也建議避開這種寫法,為未來可能加入 SSR 預留彈性 * 需要跨元件共用狀態時,優先考慮 Pinia,Nuxt 專案可用 `useState` * 覺得開 store 太重,可以試試 VueUse 的 `createSharedComposable`,輕量且 SSR 安全 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 有錯誤或任何想法還請多多指教。%( ´∀\`)~♥% --- --- url: /blog-program/cyclomatic-complexity-in-the-ai-era.md description: AI 很乖,需求一層一層疊上去,程式疊得比 100 還高 Σ(ˊДˋ;) --- ![cyclomatic-complexity-in-the-ai-era](/cyclomatic-complexity-in-the-ai-era.webp){.cover} # AI 都幫我寫好了,還需要管複雜度嗎?( ・ิω・ิ) 大家好,我是鱈魚。%( ´ ▽ \` )ノ% 你朋友現在寫 code 是不是都把需求丟給 AI,十秒後交件,附測試,全綠,掃一眼就 merge。 先不管那個朋友是不是你自己。%(◐‿◑)% 總之你朋友都把需求丟給 AI,不過 AI 有沒有可能偷懶,只會疊,不會整理,越改越亂? 什麼?你說公司前人寫的祖傳程式碼都超過幾千行,早就習慣了?敬佩敬佩。%(́⊙◞౪◟⊙‵)% 有沒有什麼客觀指標,可以用來表示程式碼複雜度? 搜尋了一下,發現有個 1976 年就發明出來的老指標,叫做圈複雜度(Cyclomatic Complexity)。 這篇拿酷酷元件([chill-component](https://chillcomponent.codlin.me/))裡一支真實函式開刀,最後再回頭看 AI 時代還需不需要這個數字。 ## 數字怎麼來 圈複雜度代表程式裡有幾條分支路徑。 名字裡的圈來自圖論,把程式流程畫成圖之後數獨立迴路有幾個。 聽起來很抽象很學術,實際上就是數分支啦。%(´,,•ω•,,)% 從 1 開始算,每遇到一個決策點就 +1: * `if`、`else if` * `for`、`while`、`do...while` * `case`(每一個都算) * `catch` * 三元運算子 `? :` * 邏輯運算子 `&&`、`||`、`??` * 選擇性串連 `?.` 最多人搞錯 `else` 與 `default`,這兩個都不算。 看個例子。 ```ts function calcShippingFee(order: Order) { // 起手 1 if (order.vip) { // +1 → 2 return 0 } if (order.total > 1000) { // +1 → 3 return 0 } if (order.couponCode && order.total > 500) { // +2 → 5 return 30 } return 60 } ``` 5 分。畫成圖就很清楚,確實有五條走得完的路。 ```mermaid --- title: calcShippingFee 的五條路 --- flowchart TD S((進入)) --> A{vip} A --是--> R1[/回傳 0/] A --否--> B{total 大於 1000} B --是--> R2[/回傳 0/] B --否--> C{有 couponCode} C --否--> R4[/回傳 60/] C --是--> D{total 大於 500} D --是--> R3[/回傳 30/] D --否--> R4 ``` 至於 `else`,加了也不會變。 ```ts function calcShippingFeeWithElse(order: Order) { // 起手 1 if (order.vip) { // +1 → 2 return 0 } else { // 不算 return 60 } } ``` 只有 2 分。`if` 早就把路岔成兩條了,`else` 只是替其中一條取名字。%(ゝ∀・)b% ## 三十秒掃出自己專案的排行榜 ESLint 內建 `complexity` 規則,什麼都不用裝。 ```js // eslint.config.mjs rules: { complexity: ['warn', { max: 10, variant: 'modified' }], } ``` `variant` 是 ESLint 9.12 之後才有的選項,差別只在 `switch`。classic 模式每個 `case` 都 +1,一個十路 `switch` 直接 11 分起跳,可是那種扁平的對照表其實超好讀,被誤殺很冤。modified 模式把整個 `switch` 算成 1 分。 實測同一份程式碼: | 寫法 | classic | modified | |---|---|---| | 三個 `if`(其中一個帶 `&&`) | 5 | 5 | | 五個 `case` 加 `default` 的 `switch` | 6 | 2 | 不想動設定檔的話,臨時加規則跑一次也可以。 ```bash npx eslint . --rule '{"complexity":["warn",{"max":10,"variant":"modified"}]}' ``` 規則設 warn 不設 error,讓它當雷達用,只把超標的函式標出來。設成 error 就變成閘門,超標直接擋下 CI。 我拿酷酷元件跑了一輪,45 個函式超標,前五名長這樣。 | 函式 | 分數 | |---|---| | `compileCreasePattern` | 61 | | `createStem` | 39 | | `createStemList` | 29 | | `btn-attention-seeker` 的 watch | 27 | | `updateAndRender` | 25 | 一掃下去,酷酷元件的程式碼我寫得有多隨便,馬上就露餡了。%(◐‿◑)% 不過光看這一欄其實沒什麼鑑別度,`complexity` 最大的罩門在於它對巢狀沒感覺。 \~~不是因為他腳麻~~,是因為深度要交給 `cognitive-complexity`。%(´・ω・\`)% 三個平行的 `if` 跟三層巢狀的 `if` 同樣都是 4 分,可是後者糾纏得多。 加上 `eslint-plugin-sonarjs`,計算認知複雜度(Cognitive Complexity),它會對巢狀加權,越深加越重。 ```js // eslint.config.mjs rules: { 'complexity': ['warn', { max: 10, variant: 'modified' }], 'sonarjs/cognitive-complexity': ['warn', 15], } ``` 兩欄一起量,就拿排行榜第四名那段 watch 開刀,圈複雜度 27、認知複雜度 5、最深巢狀 2、只有 28 行。 ## 案例:27 分的 watch [btn-attention-seeker](https://chillcomponent.codlin.me/components/btn-attention-seeker/) 是一顆很有事的按鈕,滑鼠靠近會湊過來,卡到視窗上下緣時會探個頭出來刷存在感。 它的核心是這段 watch。 ```ts watch(() => [distanceToFrame, frameBounding], () => { // 視窗邊界探頭 if (isBoundary.value) { if (frameBounding.bottom < props.topOffset) { carrierPositionAnimatable?.x?.(0) carrierPositionAnimatable?.y?.(frameBounding.bottom * -1 + props.topOffset + frameBounding.height * 0.75) carrierPositionAnimatable?.rotate?.(-10) return } else if (frameBounding.top > (windowSize.height - props.bottomOffset)) { carrierPositionAnimatable?.x?.(0) carrierPositionAnimatable?.y?.( windowSize.height - frameBounding.top - frameBounding.height * 0.75 + props.bottomOffset, ) carrierPositionAnimatable?.rotate?.(10) return } } if (isFollowing.value) { const x = frameWithMouse.elementX - frameWithMouse.elementWidth / 2 const y = frameWithMouse.elementY - frameWithMouse.elementHeight / 2 carrierPositionAnimatable?.x?.(x) carrierPositionAnimatable?.y?.(y) return } carrierPositionAnimatable?.x?.(0) carrierPositionAnimatable?.y?.(0) carrierPositionAnimatable?.rotate?.(0) }, { deep: true }) ``` 可是這裡只有四個 `if`。認知複雜度更怪,只有 5 分。 27 對 5,代表這段程式的結構其實很扁平,分數卻高得嚇人。 那 22 分的落差是哪來的?%ლ(´口\`ლ)% 我把所有 `?.` 換成直接呼叫再量一次,圈複雜度立刻剩個位數。 答案是 `carrierPositionAnimatable?.x?.()` 這種選擇性串連。 一開始我覺得指標在亂哀,想想好像也不算。%(́⊙◞౪◟⊙‵)% `?.` 真的是分支,左邊是 `null` 就整條短路。 而這串三行的動畫呼叫,在同一個函式裡重複了四遍,所以問題點其實是多處重複。%(°ㅂ°)% 那就改善重複問題,讓每一段只負責算出目標位置,最後統一送給動畫。 ```ts interface Position { x: number; y: number; rotate: number } const HOME_POSITION: Position = { x: 0, y: 0, rotate: 0 } /** 探頭:視窗上下緣被遮住時,把按鈕推回可視範圍 */ function calcPeekPosition(): Position | undefined { if (frameBounding.bottom < props.topOffset) { return { x: 0, y: frameBounding.bottom * -1 + props.topOffset + frameBounding.height * 0.75, rotate: -10, } } if (frameBounding.top > windowSize.height - props.bottomOffset) { return { x: 0, y: windowSize.height - frameBounding.top - frameBounding.height * 0.75 + props.bottomOffset, rotate: 10, } } } /** 跟隨:滑鼠靠近時,按鈕往滑鼠方向偏移 */ function calcFollowPosition(): Position | undefined { if (!isFollowing.value) { return } return { x: frameWithMouse.elementX - frameWithMouse.elementWidth / 2, y: frameWithMouse.elementY - frameWithMouse.elementHeight / 2, rotate: 0, } } function updateCarrierPosition() { const { x, y, rotate } = calcPeekPosition() ?? calcFollowPosition() ?? HOME_POSITION carrierPositionAnimatable?.x?.(x) carrierPositionAnimatable?.y?.(y) carrierPositionAnimatable?.rotate?.(rotate) } ``` 重新量一次: | 函式 | 圈複雜度 | 認知複雜度 | |---|---|---| | `updateCarrierPosition` | 9 | 0 | | `calcPeekPosition` | 3 | 2 | | `calcFollowPosition` | 2 | 1 | 程式碼變多了,可是四段重複的動畫呼叫收斂成一處,主線變成一行 `??` 串接,兩欄分數也一起掉到個位數。%( •̀ ω •́ )✧% ## AI 都幫我寫好了,還需要這個數字嗎 當初發明圈複雜度,很大一部分理由是人腦一次裝不下太多東西。接下來直接假設最極端的情況,**程式完全交給 AI 寫,沒有人會再打開那個檔案**。「人讀不動」這條理由不再成立,還有其他理由嗎?%( ・ิω・ิ)% 把問題丟給 AI,它講得頭頭是道,複雜度越高越容易改壞別的分支、每次只塞一個 `if` 最後會疊成爛攤子。AI 最擅長瞎掰道理,還是實際實驗看看。%ԅ( ˘ω˘ԅ)% ### 十二個專案,跑二十週 題目是完整的訂單伺服器,NestJS 加 SQLite。12 個專案隨機分成三組,CLAUDE.md 是唯一變因。最小改動派不用任何指標,只做需求要求的事;自律派也不用指標,每次做完要把整份 src 整理到自己滿意;指標派設圈複雜度 10、認知複雜度 15 當門檻,超標 CI 就紅,不准調高也不准略過。 每週開一個全新的 Claude Sonnet 5(2026 年 9 月透過 Claude Code 啟動),只給它專案資料夾、CLAUDE.md 與當週需求,上週的測試我也收走,做完跑 `npm run ci` 就結束,錯了不修也不提示。 關鍵機制叫**全域慣例**。前六週各立一條「之後新增的每一個 X 都適用」的慣例,像是寫入要留稽核紀錄、刪除一律軟刪除、有狀態的資源要有歷程路由。第 7 週起不斷加新資源,需求文件**一次都不再提起這些慣例**。CLAUDE.md 只提醒「有這種全域規則,之後不會再講」,沒有列出是哪幾條,AI 得自己從既有程式碼找出來。 評分不靠人工計算。另外維護一份參考伺服器當標準答案,每週產生 400 條固定種子的請求序列,逐個呼叫比對狀態碼與回應內容,二十週合計 96000 條。 ### 結果:錯了,可是錯在別的地方 %(́⊙◞౪◟⊙‵)% 前四週三組全對,第 5 週開始失誤,第 6 週起 12 個專案週週都有失誤。扣最多分的全是新資源缺少的歷程路由與 `includeDeleted`,三組扣分的地方幾乎一樣;每週明寫在需求裡的路由、欄位、錯誤碼,三組幾乎全做對,240 次交件裡沒有一次把上週做對的東西改壞。 **這些洞沒有一個跟複雜度有關。** 全是加新資源時忘了接上舊慣例,函式太糾纏所以改壞的那一種,二十週一次都沒發生,門檻壓到 5 也擋不掉。%( ˙꒳​˙)% 漏與不漏的規律很清楚。稽核做成守衛、重播檢查做成攔截器,這兩種都是每個請求進來會自動經過的一層,不用每條路由各自呼叫;分頁做成共用函式。新路由都自動套用這幾條,二十週零錯誤。反過來,歷程路由、`includeDeleted` 這種要在每個新資源各接一次線的,三組全滅。會不會漏,取決於慣例能不能做成機制,跟函式多複雜無關。 自律派的準則要求它每週重讀整份 src,三組裡就屬它最接近「回頭檢查」,漏掉的東西照樣跟另外兩組一模一樣。連每週把程式碼從頭讀一遍都救不回來,靠複雜度門檻更不可能。%(´・ω・\`)% ### 那門檻到底有沒有用?還是測不到 前八週表面上指標派贏了,不符數只有最小改動派的 0.61 倍。可是三組漏掉同樣五個洞,每個專案的不符總數只有 786 或 277 兩種值,差別只在有沒有多漏其中一個。0.61 其實只是「多漏的專案三個對一個對一個」,四個樣本跟擲硬幣沒兩樣。%(´・ω・\`)% 更根本的問題是**門檻幾乎沒有東西可擋**,沒設門檻的兩組八週合計 64 次交件只有 11 次寫出超標函式,指標派幾乎沒有需要重構的時候。前八週的比值講不了什麼。 後半段就不一樣了。程式碼長到五千行之後,最小改動派 80 次交件裡超標 46 次、自律派 45 次,指標派 0 次,它的 CI 二十週沒紅過,門檻對它一直是事前的約束,不曾當過事後的閘門。指標派的圈複雜度全程停在 9,最小改動派最高衝到 19、認知複雜度 29。形狀差到這種程度,正確率呢? | 週次 | 最小改動派 | 自律派 | 指標派 | 指標派對最小改動派 | |---|---|---|---|---| | 第 1 到 8 週 | 2635 | 1729 | 1617 | 0.61 | | 第 9 到 20 週 | 14563 | 13384 | 13017 | 0.89 | 另外兩組天天超標之後,比值反而從 0.61 走到 0.89,往「沒有差別」的方向跑。而且後半段剩下的差距也跟門檻無關。到第 20 週,每個專案不符的序列有八成是前幾週漏掉的洞照常扣分,剩下的差距集中在四個專案的兩個洞,都跟慣例無關,最小改動派有兩個專案忘了替商品的版本號加一,指標派有兩個專案算錯訂單狀態。只算漏掉慣例造成的不符,後半段指標派對最小改動派是 0.97。%(´・ω・\`)% 門檻管得住最大值,管不住總量。指標派的函式數比最小改動派多兩成二、行數多 9%,圈複雜度總和 976.5 看起來也是三組最高,可是每個函式起手就有 1 分,扣掉起手分後三組是最小改動派 459.5、自律派 425、指標派 446.5,幾乎一樣。門檻把大函式切小,分支總量沒有變少,只是攤到更多函式。成本三組一樣,指標派的總 token 只比最小改動派多 2%,最貴的反而是每週重讀整份 src 的自律派。而自律派的圈複雜度也只壓到 13.5,離門檻的 10 還很遠,叫 AI 自己判斷,專案一大就不管用了。%(›´ω\`‹ )% ### 讀結論前先打折 這個實驗有幾個地方要先講清楚。%(´・ω・\`)% * **沒有測「把慣例寫進 CLAUDE.md」這一組。** 現實裡最便宜的做法就是列在 CLAUDE.md,這裡刻意不列才逼得出遺漏,所以「AI 會漏舊慣例」只在沒人把慣例寫下來的前提下成立。 * **複雜度最高只到 38。** 這次最小改動派二十週最高只到圈複雜度 19、認知 29,NestJS 的 controller、service、DTO 分層本身就是一道隱性門檻。前幾批純函式的題目有爬到 38,同樣零錯誤。前面那支 61 分的函式從沒出現過,所以「複雜度不影響 AI 正確率」只在 38 分以下有資料。 * **只跑到第 20 週。** 原本設計裡最可能讓「函式太糾纏所以改壞」發生的,是第 24、26 週那種回頭改舊行為的需求,這次沒跑到,「沒有一次改壞上週做對的東西」這句話的份量要跟著打折。 * **第 9 到 20 週是看過前八週結果才決定延長的。** 那一段只能當觀察,不能拿來下定論。 * **兩次環境洩漏。** 第 1 週專案資料夾放在實驗的 git repo 裡,`git show` 讀得到參考伺服器;第 17 週有一位受試者用 grep 看到需求與參考的檔案路徑。兩位都自述沒有進一步查看,細節都在報告裡。 完整設計與每週資料都在[實驗報告](https://github.com/Codfisher/cod-aquarium/blob/main/docs/cyclomatic-complexity-in-the-ai-era.md)。 ## 別人的研究怎麼說 我的實驗有三個發現,逐一去翻論文對照。%(´・ω・\`)% **發現一,有沒有門檻,AI 寫錯的機率量不出差別。** 論文跟這個一致。[Pan 等人](https://arxiv.org/abs/2508.13666)(ICSE 2026)把程式碼的縮排、空白、換行全部拔掉再餵給十個模型,答對率最多只掉 4.2%,模型不太在意程式長什麼樣子。[Xie 等人](https://arxiv.org/abs/2602.07882)(ICML 2026)直接檢驗圈複雜度這類傳統指標能不能預測 LLM 覺得一段程式難不難,結論是沒有一致的關聯。[Ashrafi](https://arxiv.org/abs/2606.00308) 讓六種架構的 AI 代理人寫同一批題目,寫出來的複雜度差了 50% 到 130%,答對率卻沒差。三篇的結論一樣,圈複雜度只能反映人讀起來多累,跟 AI 會不會寫錯無關。 **發現二,明寫的需求不會錯,不再重述的舊慣例會穩定遺漏。** 這一點文獻講得比我更清楚。[Codified Context](https://arxiv.org/abs/2602.20478) 直接指出 AI 代理人沒有跨場次的記憶,每一場都從零開始,忘記專案慣例、重犯已知錯誤。[20574 場真實開發場次的分析](https://arxiv.org/abs/2605.29442)發現 41% 的回合使用者得回頭糾正它,而九成以上的問題要等到人明講才解得掉。[RoadmapBench](https://arxiv.org/abs/2605.15846) 用真實的版本升級當題目,中位數要改 51 個檔案 3700 行,Claude Opus 4.7 只解得掉 39.1%,常見的失敗是完成一部分目標之後卡在整合。我這個實驗把同一件事做得更乾淨,慣例都寫在需求裡、AI 也讀過,隔幾週新資源出現時照樣漏掉。 **發現三,沒有門檻,複雜度就會往上跑。** 最小改動派爬到 16,自律派 13.5,指標派二十週都停在 9。2026 年的 [SlopCodeBench](https://arxiv.org/abs/2603.24755) 跟這個實驗最像,讓 15 個 AI 代理人在 196 個檢查點反覆擴充自己的程式碼,判定結構劣化的門檻剛好也是圈複雜度 10。沒有任何指引時,77% 的專案越跑越劣化;加一段品質指引,劣化少了 57.6%,成本只多 12.1%。劣化歸劣化,答對率沒有跟著掉,這一半跟我「形狀變了、正確率沒變」的結果一樣。另一半我沒複現。那段品質指引在我這裡就是自律派的準則,可是專案長到五千行之後,自律派的複雜度壓不到門檻以內,八十次交件裡照樣超標 45 次。 | 我的發現 | 論文 | 對照 | |---|---|---| | 有沒有門檻,正確率量不出差別 | 傳統複雜度指標與 LLM 表現沒有一致關聯 | 一致 | | 明寫的需求不會錯 | 九成以上的問題要人明講才解得掉 | 一致 | | 不再重述的舊慣例會漏掉 | 代理人沒有跨場次記憶,忘記專案慣例 | 一致 | | 一段文字指引就能壓住劣化 | 77% 專案劣化,一段指引降 57.6% | 我沒複現,專案一大就失效 | 回到標題,AI 都幫我寫好了,還需要管複雜度嗎?拆成三個問題才答得準。 * **能不能讓 AI 少寫錯?量不到。** 我的實驗跟論文都量不到關聯。AI 只會在加新功能時漏掉幾週前立的規矩,這種錯跟函式多複雜無關,另外兩組天天超標之後差距反而更小。要擋這種錯,得把規矩做成攔截器,或者一開始就寫進 CLAUDE.md,門檻幫不上忙。 * **能不能防止程式越疊越亂?能,管得住最大值。** 設門檻那組二十週沒有一次超標,另外兩組後半段週週超標。只是分支總量沒有變少,程式碼反而更多。 * **人還讀不讀得懂?這一條完全沒變。** 圈複雜度本來就是為人設計的,論文沒有推翻這一點,前面幾節那套判斷照用。 ## 總結 🐟 AI 都幫我寫好了,還需要管複雜度嗎?結論是看有沒有人要讀那份程式碼。%(´・ω・\`)% * **有人要讀,就要管,方法照舊。** 圈複雜度就是數分支,`else` 不算,`?.` 與 `&&` 都算,ESLint 三十秒掃出整個專案的排行榜;它對巢狀沒感覺,要搭配認知複雜度一起看,兩欄一起高才代表真的糾纏。 * **沒有人要讀,門檻有沒有用還量不到。** 十二個專案跑二十週,設門檻的那組程式碼最整齊,漏掉的規矩卻跟另外兩組一模一樣;會因為函式太糾纏而改壞的那種錯,二十週一次都沒發生,門檻擋不擋得住也無從量起。 * **AI 錯在別的地方,門檻擋不到。** 它幾乎不會把明寫的需求做錯,也不曾因為函式太長改壞東西,只會在加新功能時漏掉幾週前立的規矩;要擋這種錯,得把規矩做成攔截器。 * **門檻留著不虧,別指望它擋錯。** 它壓得住最大複雜度,程式碼雖然多一成,token 沒有跟著多,也沒有多錯;它只是把分支分散到更多函式,總量不會變少。 有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。%( ´ ▽ \` )ノ% --- --- url: /aquarium/beybattle.md description: 組裝你的戰鬥陀螺,挑戰 AI 對手! --- --- --- url: /aquarium/codstack.md description: 在網頁上組裝 3D 模型像疊積木一樣簡單 (・∀・)9 --- --- --- url: /column-cod-toys/01-origin.md description: '沒有順手的小工具怎麼辦?用 Electron 動手做一個吧!(´,,•ω•,,)' --- ![快樂堆積木的魚](/cod-toys.webp){.cover} # CodToys - EP01:緣起 自己寫文章時習慣在文字中穿插顏文字。%( •̀ ω •́ )✧% 最近較常寫文章分享,輸入顏文字的次數也越來越多了。 雖然 Windows 可以按下「Window + .」也可以快速選擇顏文字 但是身為資深顏文字愛好者,一定有自己的顏文字庫啊! 於是萌生了自己做一個鍵盤快捷鍵小工具的念頭,此專案就這麼誕生了。%ԅ(´∀\` ԅ)% 讓我們搶先看一下成果!%ˋ( ° ▽、° )% ![shortcut-key-selection-options](/cod-toys/shortcut-key-selection-options.gif) 路人:「聽說現在年輕一輩沒人在用顏文字的捏。('◉◞⊖◟◉\` )」 鱈魚:「我就老,我就是要用。ᕕ( ゚ ∀。)ᕗ 」 ## 背景 開始前來簡單了解一下相關背景。 * PowerToys [PowerToys](https://learn.microsoft.com/zh-tw/windows/powertoys/) 是微軟釋出的開源專案,提供了一系列實用功能。例如:[顏色選擇器](https://learn.microsoft.com/zh-tw/windows/powertoys/color-picker)、[找不到命令](https://learn.microsoft.com/zh-tw/windows/powertoys/cmd-not-found)等等。 其中 [PowerToys Run](https://learn.microsoft.com/zh-tw/windows/powertoys/run) 工具是我們的主要靈感來源,本專案~~抄襲~~借鑑其設計。 * Electron [Electron](https://www.electronjs.org/) 是一個可以用 HTML、CSS、JavaScript 建立跨平台桌面應用程式的框架。 此框架由 GitHub 開發,有許多知名應用程式也用 Electron 開發,例如:[VS Code](https://code.visualstudio.com/)、[Slack](https://slack.com/)、[Discord](https://discord.com/) 等等。 ## 特點 目前主要功能規劃與特點如下: * 鍵盤優先 呼叫工具、選擇功能皆使用鍵盤操作,盡可能在不中斷輸入的情況下快速使用 * 關鍵字搜尋 根據輸入的關鍵字過濾,提供候選功能 * 功能:顏文字資料庫 從 Notion Database 取得顏文字資料,選擇後複製至剪貼簿 介面則模仿 [PowerToys Run](https://learn.microsoft.com/zh-tw/windows/powertoys/run) 的啟動器,按下 `Ctrl + Space` 就會跳出類似下圖輸入框。 ![power-toys-run](/cod-toys/power-toys-run.png) 讓我們開始吧!%( •̀ ω •́ )✧% ## 開發 專案基於以下技術或套件開發: * Vue 3、Pinia * [VueUse](https://vueuse.org/functions.html) * [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) * [UnoCSS](https://unocss.dev/) * [Quasar](https://quasar.dev/) * [Electron](https://www.electronjs.org/) ### 在現有 Vue 專案中加入 Electron 雖然官方推薦使用 [Electron Forge](https://www.electronforge.io/) 建立專案,不過我已經有一個設定完成的 Vue 模板,所以這次使用 `vite-plugin-electron` 來整合 Electron。 步驟沒什麼特別的部分,基本上完全依照[文件](https://github.com/electron-vite/vite-plugin-electron)操作。 一陣猛如虎的操作後,我們得到了[初始環境](https://github.com/Codfisher/side-project-cod-toys/tree/init)!%ˋ( ° ▽、° )% ### 開啟視窗 第一步當然是先做一個簡單的輸入框。 `src\pages\index.vue` ```vue ``` 接著讓我們啟動 Electron 魔法,把網頁變成應用程式視窗!%◝( •ω• )◟% `electron\main.ts` ```ts import process from 'node:process' import { app, BrowserWindow, } from 'electron' async function createInputWindow() { const newWindow = new BrowserWindow({ width: 400, height: 100, backgroundColor: '#fff', frame: false, resizable: false, }) // 隱藏預設系統選單 newWindow.setMenu(null) if (process.env.VITE_DEV_SERVER_URL) { await newWindow.loadURL(process.env.VITE_DEV_SERVER_URL) } else { await newWindow.loadFile('dist/index.html') } return newWindow } let mainWindow: BrowserWindow | undefined app.whenReady().then(async () => { mainWindow = await createInputWindow() }) // 當所有視窗關閉時退出應用程式 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) ``` `BrowserWindow` 表示一個視窗,就像是開啟一個網頁一般。 `app.whenReady` 則表示在應用程式準備好時執行。 現在讓我們執行 `npm run dev` 看看效果吧! ![first-window](/cod-toys/first-window.png) 輸入框出現惹!%(/≧▽≦)/% ### 綁定全域快捷鍵 接著是最重要的一步,要綁定全域快捷鍵,當按下 `Ctrl + Space` 時顯示視窗。 這裡需要使用 [`globalShortcut` 模組](https://www.electronjs.org/docs/latest/api/global-shortcut)。 此模組可以註冊全域快捷鍵,即使視窗沒有聚焦,依然可以執行已註冊的按鍵事件。 一開始先保持簡單,按鍵組合先寫死 `Ctrl + Space`,未來有需要再加入設定功能。 `electron\main.ts` ```ts // ... async function createInputWindow() { const display = screen.getPrimaryDisplay() const newWindow = new BrowserWindow({ // 設為螢幕寬度的三分之一 width: display.bounds.width / 3, height: 100, // 一開始隱藏視窗 show: false, // ... }) // ... } let mainWindow: BrowserWindow | undefined app.whenReady().then(async () => { mainWindow = await createInputWindow() const ret = globalShortcut.register('Ctrl+Space', () => { if (!mainWindow) return if (mainWindow?.isVisible()) { mainWindow.hide() return } mainWindow.show() }) if (!ret) { console.error('registration failed') } }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } globalShortcut.unregisterAll() }) ``` 現在讓我們按按看 `Ctrl + Space`。 ![shortcut-open-window](/cod-toys/shortcut-open-window.gif) 可以看到視窗出現與隱藏了!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% 現在讓我們加上一些小細節: * 視窗失去焦點時自動隱藏視窗 * 視窗會出現在滑鼠所在螢幕中往上 1/3 處 * 視窗隱藏後,會自動回復焦點到原本的視窗,這樣才可以繼續輸入 `electron\main.ts` ```ts // ... async function createInputWindow() { // ... newWindow.setMenu(null) // 失去焦點時自動隱藏視窗 newWindow.on('blur', () => { newWindow.hide() }) // ... } app.whenReady().then(async () => { mainWindow = await createInputWindow() const ret = globalShortcut.register('CmdOrCtrl+Space', () => { if (!mainWindow) return if (mainWindow?.isVisible()) { // focusable 設為 false,才可以讓焦點回到原本位置。例如正在輸入的編輯器 mainWindow.setFocusable(false) mainWindow.hide() return } // 取得滑鼠所在螢幕 const cursorPoint = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursorPoint) // 設定滑鼠位置之視窗中間往上 1/3 的位置 const [width, height] = mainWindow.getSize() mainWindow?.setPosition( Math.floor(display.bounds.x + display.bounds.width / 2 - width / 2), Math.floor(display.bounds.y + display.bounds.height / 3 - height / 2), ) mainWindow.setFocusable(true) mainWindow.show() }) // ... }) // ... ``` ![auto-refocus](/cod-toys/auto-refocus.gif) 可以注意到開關輸入框,並不會影響原本的輸入焦點。 看起來是不是很有模有樣啊!%੭ ˙ᗜ˙ )੭% ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/side-project-cod-toys/tree/feat/global-shortcut) * 使用 Electron 開發桌面應用程式 * `globalShortcut` 可以綁定全域快捷鍵 下一章讓我們來實作候選功能的部分吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-cod-toys/02-feature-card.md description: 上一章節我們完成了快捷鍵開關視窗功能,這一章節我們來實作功能選項部分吧! --- ![快樂堆積木的魚](/cod-toys.webp){.cover} # CodToys - EP02:功能選項 上一章節我們完成了快捷鍵開關視窗功能,這一章節我們來實作功能選項部分吧! ## 開發 本章節預計會建立以下內容: * 輸入候選:在輸入框輸入內容時,會顯示候選功能選項 * 功能選項:提供使用者選擇功能,且可以透過滑鼠或鍵盤選擇 * 建立「搜尋顏文字、Google 搜尋」功能 ### 功能選項元件 第一步讓我們新增功能選項元件 `src\components\feature-option.vue` ```vue ``` 此元件負責呈現可用的功能選項,並且可以透過 `action` 來設定點擊後的行為。 滑鼠 `hover` 或使用鍵盤選擇時,會變更背景色,這裡預留 `selected`,晚點來實作鍵盤導航功能。 在使用 `feature-option` 元件前,先讓我們封裝一下目前的輸入框元件。 `src\components\main-input.vue` ```vue ``` 最後在首頁中引入 `feature-option` 與 `main-input` 元件。 `src\pages\index.vue` ```vue ``` 看起來是不是乾淨很多呢?%ヾ(◍'౪\`◍)ノ゙% 現在讓我們打開視窗。 ![feature-option](/cod-toys/feature-option.png) ...會發現「選項 2」被切掉啦!%Σ(ˊДˋ;)% 這是因為視窗高度沒有隨著內容增加而增加,讓我們來修正一下吧。 ### 自動調整視窗高度 由於視窗由 main process 控制,我們需要讓視窗與 main process 通訊並調整視窗大小。 使用 `preload.ts` 來對 renderer process,也就是網頁視窗提供直通 main process 的 API 吧。 ::: tip 甚麼 process?(\*´・д・) Electron 基於 multi-process 架構,[詳細請參考官方文件](https://www.electronjs.org/docs/latest/tutorial/process-model)。 ::: `electron\preload.ts` ```ts import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('main', { updateHeight(height: number) { return ipcRenderer.send('updateHeight', height) }, hideWindow() { return ipcRenderer.send('hideWindow') }, }) ``` [`ipcRenderer`](https://www.electronjs.org/docs/latest/api/ipc-renderer) 模組提供了 `send` 方法,可以將事件發送到 main process。 [`contextBridge`](https://www.electronjs.org/docs/latest/api/context-bridge) 模組則負責將 `main` 物件注入在 window 中,我們可以在網頁中使用 `window.main` 來呼叫 main process 的方法。 接著我們要在 main process 中接收這些事件,使用 `ipcMain` 模組來收聽事件,順便重構一下 main 程式碼。 `electron\main.ts` ```ts import path from 'node:path' import process from 'node:process' import { app, BrowserWindow, globalShortcut, ipcMain, screen, } from 'electron' async function createInputWindow() { const display = screen.getPrimaryDisplay() const newWindow = new BrowserWindow({ width: display.bounds.width / 3, height: 100, show: false, backgroundColor: '#fff', frame: false, resizable: false, webPreferences: { preload: path.join(__dirname, './preload.js'), }, }) // 隱藏預設系統選單 newWindow.setMenu(null) // 失去焦點時自動隱藏視窗 newWindow.on('blur', () => { newWindow.hide() }) if (process.env.VITE_DEV_SERVER_URL) { await newWindow.loadURL(process.env.VITE_DEV_SERVER_URL) } else { await newWindow.loadFile('dist/index.html') } return newWindow } function initGlobalShortcut(mainWindow: BrowserWindow) { const ret = globalShortcut.register('Ctrl+Space', () => { if (mainWindow?.isVisible()) { // focusable 設為 false,才可以讓焦點回到原本位置。例如正在輸入的編輯器 mainWindow.setFocusable(false) mainWindow.hide() return } const cursorPoint = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursorPoint) // 設定滑鼠位置之視窗中間往上 1/3 的位置 const [width, height] = mainWindow.getSize() if (!width || !height) return mainWindow?.setPosition( Math.floor(display.bounds.x + display.bounds.width / 2 - width / 2), Math.floor(display.bounds.y + display.bounds.height / 3 - height / 2), ) mainWindow.setFocusable(true) mainWindow.show() }) if (!ret) { console.error('registration failed') } } function initIpcMain(mainWindow: BrowserWindow) { ipcMain.on('updateHeight', (event, height: number) => { mainWindow.setBounds({ height }) }) ipcMain.on('hideWindow', (event) => { mainWindow.setFocusable(false) mainWindow.hide() }) } app.whenReady().then(async () => { const mainWindow = await createInputWindow() initGlobalShortcut(mainWindow) initIpcMain(mainWindow) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } globalShortcut.unregisterAll() }) ``` 現在我們需要在網頁中使用 `mainApi`,既然是 TypeScript 專案,第一步當然是定義型別啦。 `electron\electron-env.d.ts` ```ts export interface MainApi { updateHeight: (height: number) => void; hideWindow: () => void; } declare global { interface Window { main: MainApi; } } export { } ``` 接著把 `contextBridge` 模組注入的 `main` 物件透過 Composition API 形式提供,保留未來更動彈性。 `src\composables\use-main-api.ts` ```ts /** 提供 Electron main process API */ export function useMainApi() { return window.main } ``` 現在讓視窗根據內容高度自動調整,使用 `useElementBounding` 輕鬆完成!%ヽ(●\`∀´●)ノ% ```vue ``` 現在開啟視窗,可以看到這次選項 2 順利出現了。 ![window-auto-resize](/cod-toys/window-auto-resize.png) 最後我們把原本在 `main.ts` 中的「視窗 blur 時自動隱藏視窗」的邏輯移到首頁中,方便未來依頁面需求自定義。 `electron\main.ts` ```ts // ... async function createInputWindow() { // ... // 失去焦點時自動隱藏視窗 // [!code --] newWindow.on('blur', () => { // [!code --] newWindow.hide() // [!code --] }) // [!code --] // ... } // ... ``` 所以網頁中要怎麼判斷視窗是否 blur?%(´・ω・\`)% 其實很簡單,透過 `useWindowFocus` 輕鬆實現! `src\pages\index.vue` ```vue ``` 感恩 VueUse!讚嘆 VueUse!(\*´∀\`)~♥ ### 鍵盤導航 選項有了,那就來實作鍵盤導航功能吧。 這裡讓 `feature-option` 元件註冊自己的 ID,透過 ID 選擇可用的 option。 建立一個 `feature.store.ts` 來管理並註冊 option。 `src\stores\feature.store.ts` ```ts import { defineStore } from 'pinia' import { pipe, sortBy } from 'remeda' import { computed, ref, shallowRef, triggerRef } from 'vue' interface OptionValue { action: () => void; } export const useFeatureStore = defineStore('feature', () => { const optionMap = shallowRef(new Map()) const optionIdList = computed(() => pipe( [...optionMap.value.keys()], /** 依照數字排序,保證 option 順序 */ sortBy((id) => Number.parseInt(id.replace(/\D/g, ''), 10)), )) function addOption(id: string, value: OptionValue) { optionMap.value.set(id, value) triggerRef(optionMap) } function removeOption(id: string) { optionMap.value.delete(id) triggerRef(optionMap) } const selectedOptionId = ref('') function setOption(id: string) { selectedOptionId.value = id } function nextOption() { const first = optionIdList.value.at(0) const index = optionIdList.value.indexOf(selectedOptionId.value) if (index < 0 && first) { selectedOptionId.value = first return } const target = optionIdList.value[index + 1] if (target || first) { selectedOptionId.value = (target ?? first)! } } function prevOption() { const last = optionIdList.value.at(-1) const index = optionIdList.value.indexOf(selectedOptionId.value) if (index < 0 && last) { selectedOptionId.value = last return } const target = optionIdList.value[index - 1] if (target || last) { selectedOptionId.value = (target ?? last)! } } const currentOption = computed(() => optionMap.value.get(selectedOptionId.value)) return { optionIdList, addOption, removeOption, selectedOptionId, setOption, nextOption, prevOption, currentOption, } }) ``` `feature.store` 主要提供以下邏輯: * 註冊、移除 option * 設定或前後選擇 option 接著在 `feature-option` 新增邏輯: * 註冊自己的 ID 與 `action` * `onUnmounted` 時移除 ID。 * `selected` 依照 `feature.store` 資料判斷 `src\components\feature-option.vue` ```vue ``` 最後讓我們在 `main-input` 中註冊鍵盤事件,實現鍵盤選擇選項功能。 `src\components\main-input.vue` ```vue ``` 現在我們可以透過鍵盤觸發以下功能: * Escape 清空輸入框,若內容為空則隱藏視窗 * 上下鍵選擇選項 * Enter 執行選項。 ![keyboard-selection-option](/cod-toys/keyboard-selection-option.gif) 越來越有模有樣了!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% ### 新增功能卡片 接著讓我們新增功能卡片,功能卡片會自動根據使用者輸入內容提供可用選項。 可預期每個卡片都會有自己的專屬邏輯,所以我們將卡片元件獨立在 `domains` 資料夾中。 #### 搜尋顏文字 `src\domains\feature-card-kaomoji\index.vue` ```vue ``` 因為還沒連接資料庫,所以先暫時寫死 3 個顏文字測試功能。 接著在首頁中引入功能卡片。 `src\pages\index.vue` ```vue ``` 現在輸入 `@` 即可搜尋顏文字,而且選擇後可以直接複製到剪貼簿。 ![select-and-copy-kaomoji](/cod-toys/select-and-copy-kaomoji.gif) #### Google 搜尋 只有一個功能有點孤單,讓我們再新增一個可以快速開始 Google 搜尋的功能卡片吧!%(/≧▽≦)/% 這裡需要在 `mainApi` 新增一個 API,用來開啟瀏覽器。 `electron\electron-env.d.ts` ```ts export interface MainApi { // ... openExternal: (url: string) => void; } // ... ``` `electron\preload.ts` ```ts import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('main', { // ... openExternal(url: string) { return ipcRenderer.send('openExternal', url) }, }) ``` `electron\main.ts` ```ts // ... function initIpcMain(mainWindow: BrowserWindow) { // ... ipcMain.on('openExternal', (event, url: string) => { shell.openExternal(url) }) } // ... ``` 這裡使用了 [`shell`](https://www.electronjs.org/docs/latest/api/shell) 模組,可以讓我們開啟外部瀏覽器。 接著讓我們新增 `feature-card-google` 功能卡片元件。 `src\domains\feature-card-google\index.vue` ```vue ``` 接著在首頁引入功能卡片。 不過每次新增功能卡片都要手動引入好麻煩,讓我們自動引入吧!%( ´ ▽ \` )ノ% `src\pages\index.vue` ```vue ``` ::: tip 這裡使用了 Vite 的 [glob 功能](https://vite.dev/guide/features#glob-import) ::: 現在可以輸入任何內容並開啟瀏覽器跳轉 Google 搜尋了!%◝(≧∀≦)◟% ![google-search](/cod-toys/google-search.gif) 核心功能基本上都完成了,感覺超讚!%(≧∇≦)ノ% ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/side-project-cod-toys/tree/feat/feature-card) * 完成依照關鍵字候選功能選項 * `ipcRenderer` 可以讓網頁與 main process 通訊 * 完成搜尋顏文字與 Google 搜尋功能 下一章讓我們來實作連接 Notion Database 與模糊搜尋吧。%(≖‿ゝ≖)✧% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-cod-toys/03-kaomoji-database.md description: 上一章節我們完成了快捷鍵開關視窗功能,這一章節我們來實作功能選項部分吧! --- ![快樂堆積木的魚](/cod-toys.webp){.cover} # CodToys - EP03:顏文字資料庫 上一章節我們完成了快捷鍵開關視窗功能,這一章節我們來實作功能選項部分吧! ## 開發 本章節預計會建立以下內容: * 產生 Tray:用來查看狀態與開啟設定視窗 * 儲存設定檔:儲存使用者之 Notion API 資料 * 連接 Notion Database * 模糊搜尋顏文字資料 ### 產生 Tray 使用 [Tray 模組](https://www.electronjs.org/docs/latest/api/tray),輕鬆完成。◝( •ω• )◟ 同時將 `app` 的 `window-all-closed` 事件移到 `app.whenReady` 中,減少對於全域變數的依賴。 `electron\main.ts` ```ts import path from 'node:path' import process from 'node:process' import { app, BrowserWindow, globalShortcut, Menu, shell, Tray, } from 'electron' import { version } from '../package.json' function createTray( { mainWindow, }: { mainWindow: BrowserWindow; }, ) { const tray = new Tray( path.join(__dirname, '../public/fish.ico'), ) const contextMenu = Menu.buildFromTemplate([ { label: '暫時停用', type: 'checkbox', click(item) { if (item.checked) { globalShortcut.unregisterAll() } else { initGlobalShortcut({ mainWindow }) } }, }, { label: '詳細設定', submenu: [ { label: '開啟設定視窗', }, ], }, { label: '關於', click: () => shell.openExternal('https://codlin.me/column-cod-toys/01-origin.html'), }, { type: 'separator' }, { label: '退出應用程式', click: () => app.quit(), }, ]) tray.setToolTip(`CodToys v${version}`) tray.setContextMenu(contextMenu) return tray } app.whenReady().then(async () => { const mainWindow = await createInputWindow() initGlobalShortcut({ mainWindow }) initIpcMain({ configStore }) const tray = createTray({ mainWindow }) app.on('window-all-closed', () => { // [!code ++] if (process.platform !== 'darwin') { // [!code ++] app.quit()// [!code ++] }// [!code ++] globalShortcut.unregisterAll()// [!code ++] tray.destroy()// [!code ++] })// [!code ++] }) app.on('window-all-closed', () => { // [!code --] if (process.platform !== 'darwin') { // [!code --] app.quit()// [!code --] }// [!code --] globalShortcut.unregisterAll()// [!code --] })// [!code --] ``` 現在會注意到系統托盤會出現魚的圖示,點擊右鍵會出現更多選單。 ![create-tray](/cod-toys/create-tray.png) ### 儲存使用者設定 這裡使用 [electron-store](https://www.npmjs.com/package/electron-store) 來儲存使用者設定。 安裝套件後,第一步先來定義一下使用者設定型別並新增 `ConfigApi`,用於存取使用者設定。 `electron\electron-env.d.ts` ```ts export interface MainApi { // ... } export interface UserConfig { kaomoji: { databaseId: string; token: string; }; } export interface ConfigApi { get: () => Promise; update: (data: Partial) => Promise; onUpdate: (callback: (config: UserConfig) => void) => void; } declare global { interface Window { main: MainApi; config: ConfigApi; // [!code ++] } } export { } ``` 接著在 main process 初始化 store 並註冊 `ipcMain`。 這裡同時重構一下 `ipcMain` 的部分,window 相關控制改為針對發送的 window,不限定只能控制 `mainWindow`。 `electron\main.ts` ```ts{20-30,36-50,72} import type { UserConfig } from './electron-env' import { app, BrowserWindow, ipcMain, shell, } from 'electron' import Store from 'electron-store' type ConfigStore = Store<{ config: UserConfig }> function initIpcMain( { configStore, }: { configStore: ConfigStore; }, ) { // main ipcMain.on('main:updateHeight', (event, height: number) => { const window = BrowserWindow.fromWebContents(event.sender) window?.setBounds({ height }) }) ipcMain.on('main:hideWindow', (event) => { const window = BrowserWindow.fromWebContents(event.sender) window?.setFocusable(false) window?.hide() }) ipcMain.on('main:openExternal', (event, url: string) => { shell.openExternal(url) }) // config ipcMain.handle('config:get', async () => { return configStore.get('config') }) ipcMain.handle('config:update', async (event, config) => { const data = configStore.get('config') configStore.set('config', { ...data, ...config, }) // 向所有視窗觸發 config:onUpdate 事件 BrowserWindow.getAllWindows().forEach((window) => { window.webContents.send('config:onUpdate', config) }) }) } function createConfigStore(): ConfigStore { // [!code ++] const config: UserConfig = { // [!code ++] kaomoji: { // [!code ++] databaseId: '', // [!code ++] token: '', // [!code ++] }, // [!code ++] } // [!code ++] return new Store({ // [!code ++] name: 'config', // [!code ++] defaults: { config }, // [!code ++] }) // [!code ++] } // [!code ++] app.whenReady().then(async () => { const configStore = createConfigStore() // ... initIpcMain({ configStore }) // ... }) ``` 接著在 preload 實作 API。 `electron\preload.ts` ```ts // ... contextBridge.exposeInMainWorld('config', { get() { return ipcRenderer.invoke('config:get') }, update(data: Partial) { return ipcRenderer.invoke('config:update', data) }, onUpdate(callback: (config: UserConfig) => void) { ipcRenderer.on('config:onUpdate', (event, config) => { callback(config) }) }, }) ``` 現在我們可以儲存設定檔了,還需要一個頁面輸入資料才行。 ### 開啟設定頁面 預計使用 Vue Router,由於 Electron 本身沒有 Web Server,所以沒辦法直接使用 History 模式,這裡我們將 router 改為 Hash 模式解決。 `src\main.ts` ```ts import { createRouter, createWebHashHistory } from 'vue-router/auto' // ... const router = createRouter({ history: createWebHashHistory(import.meta.env.BASE_URL), // [!code highlight] routes, }) // ... ``` 接著新增設定頁面吧。 `src\pages\kaomoji-config\index.vue` ```vue ``` 現在讓我們透過 Tray 的選單開啟此設定頁面吧。 建立 `createConfigWindow` 用於開啟指定頁面並將目標 route path 加在 # 後面即可。 ```ts // ... async function createConfigWindow(route: keyof RouteNamedMap) { const newWindow = new BrowserWindow({ backgroundColor: '#fff', webPreferences: { preload: path.join(__dirname, './preload.js'), }, }) // 隱藏預設系統選單 newWindow.setMenu(null) if (process.env.VITE_DEV_SERVER_URL) { await newWindow.loadURL( `${process.env.VITE_DEV_SERVER_URL}#${route}`, ) // newWindow.webContents.openDevTools() } else { // loadFile 可以指定 hash await newWindow.loadFile('dist/index.html', { hash: route, }) } return newWindow } // ... function createTray( // ... ) { // ... const contextMenu = Menu.buildFromTemplate([ // ... { label: '詳細設定', submenu: [ { label: '開啟設定視窗', click: () => createConfigWindow('/kaomoji-config/'), // [!code ++] }, ], }, // ... ]) // ... } // ... ``` :::tip `RouteNamedMap` 是 `unplugin-vue-router` 自動產生的型別,用於定義所有頁面路由與參數。 ::: 現在讓我們從 Tray 開啟設定頁面看看吧!(/≧▽≦)/ ![kaomoji-config](/cod-toys/kaomoji-config.png) ...可以看到畫面相當簡潔有力,有機會再來慢慢美化吧。%( •̀ ω •́ )✧% ### 連接 Notion Database 現在讓我們透過 Notion API 來連接指定的 Database 吧! 首先需要先建立 Integrations,前往[設定頁面](https://www.notion.so/profile/integrations)並依照步驟建立。 1. 點擊畫面中的「+ New integration」 2. Associated workspace 選擇要使用的 Workspace 3. Type 選擇 Internal 4. 按下 Save 這樣就建立完成了!接著我們需要取得並調整一下 Token 設定。 在 Integrations 頁面中,點擊剛剛建立的 Integration,會進到設定頁面,讓我們限縮以下此 Token 權限,保持最小權限原則。 基本上就是只有 Read 權限,如下圖。 ![notion-token-capabilities](/cod-toys/notion-token-capabilities.png) 複製 Internal Integration Secret 中的 token,並貼到設定頁面中。 接著來建立 Database。 1. 將原本預設的 Name 欄位改名為 value,內容為顏文字 2. 新增一個名為 tags 的多選欄位,用來提供篩選資料 3. 將 database 使用 open as page 開啟 4. 點擊最右上角的三個點,將 Connections 設定為剛剛建立的 Integration 完成後結果會如下圖。 ![notion-connections](/cod-toys/notion-connections.png) 最後目前網頁的 URL 會是 `https://www.notion.so/{workspace}/{databaseId}?xxx`,將此 URL 中的 databaseId 複製下來,貼到設定頁面中後按下儲存即可。 ![save-kaomoji-config](/cod-toys/save-kaomoji-config.png) ### 取得 Notion Database 資料 現在設定資料有了,讓我們串接 Notion API 吧。 安裝 `@notionhq/client` 套件來存取 Notion API 並加入取得資料邏輯。 `src\domains\feature-card-kaomoji\index.vue` ```vue ``` ![get-kaomoji-database-data](/cod-toys/get-kaomoji-database-data.png) 資料進來了!( ´ ▽ \` )ノ 接著新增更新用的按鈕,讓使用者可以手動更新資料。 `src\domains\feature-card-kaomoji\index.vue` ```vue ``` ![refresh-kaomoji-database-data](/cod-toys/refresh-kaomoji-database-data.gif) 由於資料可能很多,最後讓我們實現分頁功能吧! `src\domains\feature-card-kaomoji\index.vue` ```vue ``` ::: warning 程式碼中有個 `` 部分,`:key="pagination.page"` 是為了在換頁時強制重新渲染,才能保證所有 option 順序一致,這個方法會有不必要的性能損號,未來再來改進。 ::: ![pagination-kaomoji-data](/cod-toys/pagination-kaomoji-data.gif) 所有的資料都進來了!✧⁑。٩(ˊᗜˋ\*)و✧⁕。 ### 模糊過濾 最後讓我們來實作模糊過濾功能吧! 我們希望即使只有部分文字符合,也能候選最接近的資料。 這裡使用 [fuse.js](https://fusejs.io/) 實作模糊過濾功能。 這個部份很單純,困難的部分 `fuse.js` 已經幫我們處理好了,我們只要爽爽用就行惹。%ψ(`∇´)ψ% `src\domains\feature-card-kaomoji\index.vue` ```vue // ... ``` ![fuse-search](/cod-toys/fuse-search.gif) 就這麼簡單。(\*´∀\`)~♥ 現在讓我們完整的展示一次功能。 * 輸入 @ 可以開始搜尋顏文字 * 沒有匹配任何功能時,則開啟 Google 搜尋 ![phase-1-completed](/cod-toys/phase-1-completed.gif) 感覺超讚!✧⁑。٩(ˊᗜˋ\*)و✧⁕。 ## 改進與重構 實際上使用後發現有些地方需要改進,讓我們來改進一下吧!%੭ ˙ᗜ˙ )੭% ### 取消滑鼠 hover 效果 滑鼠 hover 效果會讓使用者混淆目前選項,狠心刪除。%ԅ( ˘ω˘ԅ)% `src\components\feature-option.vue` ```vue // ... ``` ### feature-option 重複使用時,選擇順序錯誤問題 剛剛提到 `` 強制更新部分,這裡我們來改進一下。 `featureStore` 註冊 option 時,多提供元件 y 座標位置,只要根據 y 座標排序,選項順序就會同步自然順序了。 `src\stores\feature.store.ts` ```ts import { defineStore } from 'pinia' import { map, pipe, sortBy } from 'remeda' import { computed, shallowRef } from 'vue' interface OptionValue { y: number; // [!code ++] action: () => void; } export const useFeatureStore = defineStore('feature', () => { const optionMap = shallowRef(new Map()) const optionIdList = computed(() => pipe( [...optionMap.value.keys()], // [!code --] /** 依照數字排序,保證 option 順序 */ // [!code --] sortBy((id) => Number.parseInt(id.replace(/\D/g, ''), 10)), // [!code --] [...optionMap.value.entries()], // [!code ++] /** 依照畫面 y 座標排序,保證 option 自然順序 */// [!code ++] sortBy((data) => data[1].y), // [!code ++] map(([id]) => id), // [!code ++] )) // ... }) ``` feature-option 元件註冊時提供 y 座標。 `src\components\feature-option.vue` ```vue // ... ``` ### 數字鍵盤快速選擇 一直按上下鍵有點麻煩,加個使用數字鍵快速選擇功能。( •̀ ω •́ )✧ 使用 VueUse 的 `useMagicKeys` 輕鬆實現。%(´,,•ω•,,)% 開始前先讓我們整理、重構一下內容,讓元件內的程式碼更內聚一點。 元件與資料可以獨立存在,所以首先抽離資料部分。 `src\domains\feature-card-kaomoji\use-kaomoji-data.ts` ```ts // ... export function useKaomojiData( inputText: Ref, ) { const configApi = useConfigApi() const featureStore = useFeatureStore() const { state: config, execute: refreshConfig, } = useAsyncState( // ... ) let notionClient: Client | undefined function initNotionClient(config: UserConfig) { // ... } const updatedAt = shallowRef(dayjs()) const notionData = shallowRef([]) const startCursor = ref('') const { isLoading, execute: getData, } = useAsyncState( // ... ) function refreshData() { // ... } interface ListItem { value: string; tags: string[]; } const list = computed(() => pipe( // ... )) const fuseInstance = new Fuse(list.value, { keys: ['tags'], }) watch(list, (value) => { fuseInstance.setCollection(value) }) const filteredList = computed(() => { // ... }) const pagination = ref({ page: 0, itemsPerPage: 5, }) watch(inputText, () => { pagination.value.page = 0 }) const totalPages = computed(() => Math.ceil( filteredList.value.length / pagination.value.itemsPerPage, )) const paginationList = computed(() => pipe( // ... )) async function nextPage() { // ... } configApi.onUpdate(async (config) => { init(config) }) async function init(config?: UserConfig) { // ... } return { isLoading, updatedAt, list, pagination, paginationList, totalPages, nextPage, init, refreshData, } } ``` 基本上就是把資料的部份抽離,所以具體內容就不贅述了。 現在元件內部變得相當簡潔。 `src\domains\feature-card-kaomoji\index.vue` ```vue ``` 現在讓我們在 `featureStore` 新增一個 method。 `src\stores\feature.store.ts` ```ts // ... export const useFeatureStore = defineStore('feature', () => { // ... function setOptionByIndex(index: number) { selectedOptionId.value = optionIdList.value[index] ?? '' } // ... return { // ... setOptionByIndex, } }) ``` 最後在元件中使用 `useMagicKeys` 並加上快捷鍵提示吧。੭ ˙ᗜ˙ )੭ `src\domains\feature-card-kaomoji\index.vue` ```vue ``` 現在我們可以很快速地選擇目標顏文字了!( •̀ ω •́ )✧ ![shortcut-key-selection-options](/cod-toys/shortcut-key-selection-options.gif) ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/side-project-cod-toys/tree/feat/kaomoji-database) * 串接 Notion API,完成顏文字資料庫功能 * 抽離、重構程式碼,讓程式碼更具內聚性 * 加入快捷鍵功能,讓使用者更快速選擇顏文字 感覺還可以加入很多很有趣的功能,歡迎大家留言或寫信許願喔!(´▽\`ʃ♡ƪ) ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-cod-toys/04-kaomoji-add-llm.md description: 忘記原本的關鍵字怎麼辦?請支援近義詞搜尋 ლ(╹ε╹ლ) --- ![快樂堆積木的魚](/cod-toys.webp){.cover} # CodToys - EP04:請支援近義詞 上一章我們可以很快速地選擇目標顏文字了。 但是日子久了,一不小心就忘記當初顏文字的 tag 是甚麼,導致一時找不到想要的顏文字。%( ´•̥̥̥ ω •̥̥̥\` )% 路人:「自己新增的 tag 還能忘記喔?%(◉◞౪◟◉ )%」 鱈魚:「我就鱈魚腦,你有意見膩?%(;´༎ຶД༎ຶ\`)%」 你是個成熟的程式惹,該自己知道近義詞了!%(。-\`ω´-)% 這樣即使忘記原本的 tag,也可以透過相近的字詞找到想要的顏文字了!%ヾ(◍'౪\`◍)ノ゙% ## 開發 目前想到的實作方式為:「使用 LLM(Language Learning Model)來回答 tag 的中英文近義詞,再對近義詞進行模糊匹配」 本章節預計會建立以下內容: * 在 Electron 中運行 LLM * 讓 LLM 對每個顏文字的 tag 產生近義詞 * 根據近義詞搜尋並顯示結果 ### 來運行 LLM 吧! 這裡依賴 [`node-llama-cpp`](https://github.com/withcatai/node-llama-cpp) 這個套件,藉由此套件我們可以很簡單的在 Node.js 中運行 LLM。 這裡選用小型的模型(大模型也跑不動就是了 (\´・ω・\`)),目前實際測試 `gemma-2-2b-it-Q6_K_L` 和 `Llama-3.2-3B-Instruct.Q6_K`。 程式碼很單純,基本上就是微調官方範例而已。 讓模型回答特定單字的中英文近義詞,並將結果顯示出來。 ```typescript import path from 'node:path' import { fileURLToPath } from 'node:url' import chalk from 'chalk' import { getLlama, LlamaChatSession, resolveModelFile } from 'node-llama-cpp' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const modelsDirectory = path.join(__dirname, '..', 'models') const llama = await getLlama() const qList = [ '快樂的 10 個繁體中文、10 個英文近義詞,全部合併,逗號分隔,不要任何附加資訊,不要標題,不需解釋', '慶祝的 10 個繁體中文、10 個英文近義詞,全部合併,逗號分隔,不要任何附加資訊,不要標題,不需解釋', '發瘋的 10 個繁體中文、10 個英文近義詞,全部合併,逗號分隔,不要任何附加資訊,不要標題,不需解釋' ] { console.log(chalk.yellow('gemma: ')) const modelPath = await resolveModelFile( 'hf_bartowski_gemma-2-2b-it-Q6_K_L.gguf', modelsDirectory ) const model = await llama.loadModel({ modelPath }) const context = await model.createContext() const session = new LlamaChatSession({ contextSequence: context.getSequence() }) console.log() for (const q of qList) { const a = await session.prompt(q) console.log(a) } await model.dispose() } { console.log(chalk.yellow('llama: ')) const modelPath = await resolveModelFile( 'hf_mradermacher_Llama-3.2-3B-Instruct.Q6_K.gguf', modelsDirectory ) const model = await llama.loadModel({ modelPath }) const context = await model.createContext() const session = new LlamaChatSession({ contextSequence: context.getSequence() }) console.log() for (const q of qList) { const a = await session.prompt(q) console.log(a) } await model.dispose() } ``` 以下是運行結果。 gemma: ```text 幸福、欣喜、愉悅、歡樂、開心、滿足、喜悅、興奮、滿足、舒暢 joy, happiness, delight, exhilaration, contentment, pleasure, satisfaction, excitement, bliss, comfort 慶祝、歡慶、慶典、盛會、派對、聚會、節慶、紀念、典禮、嘉年華 celebration, festivities, party, gathering, jubilee, festival, commemoration, gala, ceremony, parade, anniversary 發瘋、瘋狂、失控、歇斯底里、狂躁、衝動、失序、失常、暴躁、躁狂 madness, frenzy, unhinged, manic, agitated, impulsive, chaotic, disordered, irritable, erratic ``` llama: ```text 快樂、愉悅、幸福、欣快、高興、快意、悅目、愉悅、快樂、快暢 慶祝、祝賀、紀念、紀念、祝福、慶典、節日、節慶、紀念、祝賀 發瘋、癲癇、瘋狂、亢進、狂躁、癡呆、癲癇、瘋狂、亢進、狂躁 ``` 可以看出 gemma 的回答品質比 llama 好很多,所以接下來讓我們採用 gemma 實現近義詞搜尋吧。%( ´ ▽ \` )ノ% ### 新增 LLM API 安裝 `node-llama-cpp` 之後,新增自動下載模型的 scripts。 `package.json` ```json { "name": "cod-toys", // ... "scripts": { "dev": "vite --host", "build": "vue-tsc --noEmit --skipLibCheck && vite build", "preview": "vite preview", "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "blueprint": "vite-node scripts/create-blueprint.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.(ts|vue)\" --fix", "postinstall": "npm run models:pull", // [!code ++] "models:pull": "node-llama-cpp pull --dir ./resources/models \"hf:bartowski/gemma-2-2b-it-GGUF/gemma-2-2b-it-Q6_K_L.gguf\"" // [!code ++] }, "dependencies": { // ... }, "devDependencies": { // ... } } ``` 接下來新增一個 preload 的 API,用來執行 LLM。 不過在開始前,我們來重構一下目前 `preload` 的程式碼,讓 `electron-env.d.ts` 的形別自動推導,不要再手動定義一次。 `electron\preload.ts` ```typescript import type { UserConfig } from './electron-env' import { contextBridge, ipcRenderer } from 'electron' export const mainApi = { updateHeight(height: number) { return ipcRenderer.send('main:updateHeight', height) }, hideWindow() { return ipcRenderer.send('main:hideWindow') }, openExternal(url: string) { return ipcRenderer.send('main:openExternal', url) }, } contextBridge.exposeInMainWorld('main', mainApi) export const configApi = { get() { return ipcRenderer.invoke('config:get') }, update(data: Partial) { return ipcRenderer.invoke('config:update', data) }, onUpdate(callback: (config: UserConfig) => void) { ipcRenderer.on('config:onUpdate', (event, config) => { callback(config) }) }, } contextBridge.exposeInMainWorld('config', configApi) ``` `electron-env.d` 只要引入 `preload` 的 API 定義即可。 `electron\electron-env.d.ts` ```typescript import type { configApi, mainApi } from './preload' export interface UserConfig { kaomoji: { databaseId: string; token: string; }; } declare global { interface Window { main: typeof mainApi; config: typeof configApi; } } export { } ``` 現在來新增 LLM API。 `electron\preload.ts` ```typescript // ... export const llmApi = { prompt(message: string): Promise { return ipcRenderer.invoke('llm:prompt', message) }, } contextBridge.exposeInMainWorld('llm', llmApi) ``` `electron\electron-env.d.ts` ```typescript // ... declare global { interface Window { main: typeof mainApi; config: typeof configApi; llm: typeof llmApi; // [!code ++] } } export { } ``` 接著在 `main` 中實作 LLM 的 API。 `electron\main.ts` ```typescript // ... async function initIpcMain( { configStore, }: { configStore: ConfigStore; }, ) { // main ipcMain.on('main:updateHeight', (event, height: number) => { const window = BrowserWindow.fromWebContents(event.sender) window?.setBounds({ height }) }) ipcMain.on('main:hideWindow', (event) => { const window = BrowserWindow.fromWebContents(event.sender) window?.setFocusable(false) window?.hide() }) ipcMain.on('main:openExternal', (event, url: string) => { shell.openExternal(url) }) // config ipcMain.handle('config:get', async () => { return configStore.get('config') }) ipcMain.handle('config:update', async (event, config) => { const data = configStore.get('config') configStore.set('config', { ...data, ...config, }) // 向所有視窗觸發 config:onUpdate 事件 BrowserWindow.getAllWindows().forEach((window) => { window.webContents.send('config:onUpdate', config) }) }) // llm /** * node-llama-cpp 只支援 ESM * * 直接 import 會出現 Error [ERR_REQUIRE_ESM]: require() of ES Module 錯誤 * * https://node-llama-cpp.withcat.ai/guide/troubleshooting#using-in-commonjs */ const { getLlama, LlamaChatSession, resolveModelFile } = await import('node-llama-cpp') const modelPath = await resolveModelFile( 'hf_bartowski_gemma-2-2b-it-Q6_K_L.gguf', modelsDirectory, ) const llama = await getLlama() const model = await llama.loadModel({ modelPath }) const context = await model.createContext() let session: LlamaChatSession | undefined ipcMain.handle('llm:prompt', async (event, message: string) => { if (session) { session.dispose({ disposeSequence: true }) } session = new LlamaChatSession({ contextSequence: context.getSequence(), }) const answer = await session.prompt(message) session.dispose({ disposeSequence: true }) session = undefined return answer }) } // ... app.whenReady().then(async () => { const configStore = createConfigStore() await initIpcMain({ configStore }) const mainWindow = await createInputWindow() initGlobalShortcut({ mainWindow }) const tray = createTray({ mainWindow }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } globalShortcut.unregisterAll() tray.destroy() }) }) ``` 現在讓我們來試試效果。 `src\App.vue` ```vue // ... ``` ![prompt-test](/cod-toys/prompt-test.png) 成功取得 gemma 回應!%( ´ ▽ \` )ノ% ### 重構 main.ts `main.ts` 的東西越來越多了,現在讓我們重構一下,將範疇明確的程式碼分離。 拆分的檔案結構如下: ```txt . └── electron/ ├── app/ │ ├── config.ts │ ├── global-shortcut.ts │ ├── llm.ts │ └── tray.ts ├── electron-env.d.ts ├── main.ts └── preload.ts ``` 內容就不一一列出了,主要就是將 `main.ts` 中的程式碼分別放到 `app` 目錄下的檔案中。 現在 `main.ts` 變得相當精簡。 `electron\main.ts` ```typescript import type { ConfigStore } from './electron-env' import process from 'node:process' import { app, BrowserWindow, globalShortcut, ipcMain, shell, } from 'electron' import { createConfigStore, initConfigIpc, } from './app/config' import { initGlobalShortcut } from './app/global-shortcut' import { initLlmIpc } from './app/llm' import { createTray } from './app/tray' async function createInputWindow() { // ... } async function initIpcMain( { configStore, }: { configStore: ConfigStore; }, ) { // main ipcMain.on('main:updateHeight', (event, height: number) => { const window = BrowserWindow.fromWebContents(event.sender) window?.setBounds({ height }) }) ipcMain.on('main:hideWindow', (event) => { const window = BrowserWindow.fromWebContents(event.sender) window?.setFocusable(false) window?.hide() }) ipcMain.on('main:openExternal', (event, url: string) => { shell.openExternal(url) }) await Promise.all([ initConfigIpc({ configStore }), initLlmIpc({ configStore }), ]) } app.whenReady().then(async () => { const configStore = createConfigStore() await initIpcMain({ configStore }) const mainWindow = await createInputWindow() await initGlobalShortcut({ mainWindow }) const tray = await createTray({ mainWindow }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } globalShortcut.unregisterAll() tray.destroy() }) }) ``` ### 產生 tag 近義詞 現在來對顏文字的 tag 產生近義詞吧。 由於調用 LLM 極度消耗資源,同時希望產生每個 tag 的近義詞後,都立即更新到目前的顏文字列表中。 所以我們需要一個佇列來處理這些非同步任務,這裡使用 `p-queue` 執行非同步任務,一次只執行一個任務。 `src\domains\feature-card-kaomoji\use-kaomoji-data.ts` ```typescript import type { Ref } from 'vue' import PQueue from 'p-queue' import { flatMap, unique } from 'remeda' import { useLlmApi } from '../../composables/use-llm-api' export function useKaomojiData( inputText: Ref, ) { const llmApi = useLlmApi() // ... const tagSynonymMap = shallowRef(new Map()) const tagSynonymQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 5, }) watch(list, piped( // 先清空 tagSynonymQueue tap(() => tagSynonymQueue.clear()), flatMap((item) => item.tags), unique(), forEach((tag) => { tagSynonymQueue.add(async () => { const synonym = await llmApi.prompt( `${tag}的 5 個繁體中文近義詞,5 個英文近義詞,全部合併,逗號分隔,不要任何附加資訊,不要標題,不需解釋`, ) tagSynonymMap.value.set(tag, synonym) triggerRef(tagSynonymMap) }) }), )) // ... } ``` 接著將 tag 與近義詞內容匹配並放入模糊搜尋中。 `src\domains\feature-card-kaomoji\use-kaomoji-data.ts` ```typescript export function useKaomojiData( inputText: Ref, ) { const listWithSynonym = computed(() => pipe( list.value, map((item) => { /** 全部拼在一起即可 */ const synonym = pipe( item.tags, map((tag) => tagSynonymMap.value.get(tag) ?? ''), join(','), ) return { ...item, synonym, } }), )) const fuseInstance = new Fuse(listWithSynonym.value, { // [!code highlight] keys: ['tags', 'synonym'], // [!code highlight] }) watch(listWithSynonym, (value) => { // [!code highlight] fuseInstance.setCollection(value) }) const filteredList = computed(() => { if (inputText.value === '@') { return listWithSynonym.value // [!code highlight] } const text = pipe( inputText.value, (value) => { if (value.startsWith('@')) { return value.slice(1) } return value }, (value) => value.trim(), ) const result = fuseInstance.search(text) return pipe( result, map(prop('item')), ) }) // ... } ``` 最後讓我們把有近義詞的項目用個小圖示標註一下。 `src\domains\feature-card-kaomoji\index.vue` ```vue{26-31} // ... ``` ![synonym-search](/cod-toys/synonym-search.gif) 使用體驗提升!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/side-project-cod-toys/tree/feat/kaomoji-add-llm) 我們成功實作了近義詞搜尋,讓使用者可以更快速地找到想要的顏文字。%(\*´∀\`)~♥% 模型選用或提詞內容其實還可以持續調整,這個我決定留給未來的自己惹。 --- --- url: /blog-program/using-comlink.md description: Web Worker 很棒,但 postMessage 用起來實在太痛苦了,讓 Comlink 來救場吧!( •̀ ω •́ )✧ --- ![using-comlink](/using-comlink.webp){.cover} # Comlink 讓 Web Worker 不再難用 大家有沒有遇過這種情境,點了按鈕後,整個頁面就瞬間凍結,久久沒有回應,就像那個停留在「哈哈去洗澡」的對話框一樣。%( ´•̥̥̥ ω •̥̥̥\` )% 這通常是因為有段很重的計算邏輯跑在主執行緒(Main Thread),把 JavaScript 的單執行緒塞爆了。 解法大家都知道,那就是 Web Worker,把粗活丟給別的執行緒去跑。 不過 Web Worker 原生的 API 用起來嘛... 路人:「和你一樣煩人?」 鱈魚:「說好得尊重呢?%( ˘・з・)%」 今天要來介紹一個神奇小工具 **Comlink**,讓 Web Worker 用起來像呼叫普通的 async function 一樣爽。%( •̀ ω •́ )✧% ## Web Worker 是什麼 Web Worker 說穿了就是瀏覽器提供的「背景打工仔」機制。 主執行緒就像是老闆,專門處理畫面渲染、使用者互動這些重要事情,不能被打擾。 Worker 就是打工仔,被指派去做耗時的重活,例如大量資料運算、影像處理、加解密等等,完成後再把結果回報給老闆。 兩邊溝通使用的是 `postMessage` 與 `onmessage`。 聽起來簡單易懂對吧?%( ・ิω・ิ)% ## 原生 postMessage 有多麻煩 來看看傳統的 Web Worker 用法。 假設我們有個很重的計算,需要把一個大陣列的每個元素做費波那契數列計算。 路人:「和鱈魚一樣重嗎?」 鱈魚:「重...重才值錢啊。%ლ(・´ェ\`・ლ)%」 首先建立打工仔檔案: `src/workers/heavy-calc.worker.ts` ```ts function fibonacci(n: number): number { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) } self.onmessage = (event: MessageEvent) => { const list = event.data const result = list.map((n) => fibonacci(n)) self.postMessage(result) } ``` 然後在主執行緒呼叫: ```ts const worker = new Worker( new URL('../workers/heavy-calc.worker.ts', import.meta.url), { type: 'module' } ) worker.postMessage([40, 41, 42]) worker.onmessage = (event: MessageEvent) => { console.log('結果:', event.data) } ``` 路人:「這樣看起來還好嘛,哪裡麻煩了?」 鱈魚:「問題來了,現實中打工仔通常不只做一件事,老闆也不只發一個命令。」 各位社畜一定很懂吧?%ლ(・´ェ\`・ლ)% 如果我想讓同一個 Worker 支援多種計算,就要自己手刻一套訊息分發機制: ```ts // worker 端 self.onmessage = (event: MessageEvent) => { const { type, payload, id } = event.data if (type === 'fibonacci') { const result = payload.map(fibonacci) self.postMessage({ id, result }) } else if (type === 'sort') { const result = [...payload].sort((a, b) => a - b) self.postMessage({ id, result }) } // 越來越多 else if... } ``` ```ts // 主執行緒 let callId = 0 const pendingMap = new Map void>() worker.onmessage = (event: MessageEvent) => { const { id, result } = event.data pendingMap.get(id)?.(result) pendingMap.delete(id) } function callWorker(type: string, payload: unknown) { return new Promise((resolve) => { const id = callId++ pendingMap.set(id, resolve) worker.postMessage({ type, payload, id }) }) } const result = await callWorker('fibonacci', [40, 41]) ``` 這根本是在自己刻一個 RPC 框架,而且錯誤處理、TypeScript 型別、cleanup 邏輯都還沒算進去。%Σ(ˊДˋ;)% ## Comlink 出場 [Comlink](https://github.com/GoogleChromeLabs/comlink) 是 Google Chrome 團隊開發的小工具,它做的事情很單純,就是把上面那堆 `postMessage` 的麻煩事全部包起來。 讓你可以在主執行緒直接呼叫 Worker 裡面的 function,就像呼叫普通的 async function 一樣。 先安裝: ```bash npm install comlink ``` 先來看改寫後的打工仔: `src/workers/heavy-calc.worker.ts` ```ts import { expose } from 'comlink' function fibonacci(n: number): number { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) } const api = { calcFibonacci(list: number[]): number[] { return list.map((n) => fibonacci(n)) }, sortNumbers(list: number[]): number[] { return [...list].sort((a, b) => a - b) }, } expose(api) export type WorkerApi = typeof api ``` 然後在主執行緒: ```ts import type { WorkerApi } from './workers/heavy-calc.worker' import { wrap } from 'comlink' const worker = new Worker( new URL('./workers/heavy-calc.worker.ts', import.meta.url), { type: 'module' } ) const api = wrap(worker) // 直接呼叫,完全感覺不到 postMessage 的存在 const result = await api.calcFibonacci([40, 41, 42]) const sorted = await api.sortNumbers([3, 1, 4, 1, 5, 9]) ``` 是不是很讚啊!之前那堆 `callId`、`pendingMap`、訊息分發邏輯全部不見了。%( •̀ ω •́ )✧% 而且因為有 `export type WorkerApi`,TypeScript 的型別提示也完整保留了!%ヽ(●\`∀´●)ノ% ## 在 Vue 中整合 Comlink 知道 Comlink 怎麼用之後,所以該怎麼在 Vue 中應用呢? 推薦寫成 Composable,讓 Worker 的生命週期跟著元件走,不用手動管理。 ### 建立 Composable `src/composables/use-heavy-calc-worker.ts` ```ts import type { WorkerApi } from '../workers/heavy-calc.worker' import { wrap } from 'comlink' import { onScopeDispose, shallowRef } from 'vue' export function useHeavyCalcWorker() { const worker = new Worker( new URL('../workers/heavy-calc.worker.ts', import.meta.url), { type: 'module' } ) const api = wrap(worker) const isLoading = shallowRef(false) // 元件解除時自動清理 Worker onScopeDispose(() => { worker.terminate() }) async function calcFibonacci(list: number[]) { isLoading.value = true try { return await api.calcFibonacci(list) } finally { isLoading.value = false } } return { isLoading, calcFibonacci, } } ``` ### 在元件中使用 `src/components/HeavyCalc.vue` ```vue ``` 這樣就完成了!主執行緒完全不會被卡住,使用者點按鈕之後畫面繼續流暢運作,繁重任務在背景處理,默默壓榨 Worker。%( ´థ౪థ)b% ## Worker 共享還是獨立? 有個小細節值得討論,Worker 的建立方式會影響它的生命週期與資源使用。 ### 每個元件自己的 Worker 剛才的做法是每個元件實例都建立一個新的 Worker,優點是元件解除後 Worker 自動跟著清掉,不會有資源洩漏問題。 缺點是如果同一個頁面有很多元件都需要這個 Worker,就會建立很多 Worker 實例,消耗比較多資源。 ### 全域共享 Worker 如果同一個打工仔需要被很多地方使用,可以用 VueUse 提供的 [`createSharedComposable`](https://vueuse.org/shared/createSharedComposable/) 把剛才的 Composable 升級成全域共享版本。 `createSharedComposable` 會讓所有呼叫這個 Composable 的元件共用同一個狀態實例,當最後一個使用它的元件解除後,才會執行清理邏輯。 `src/composables/use-heavy-calc-worker.ts` ```ts import type { WorkerApi } from '../workers/heavy-calc.worker' import { createSharedComposable } from '@vueuse/core' import { wrap } from 'comlink' import { onScopeDispose, shallowRef } from 'vue' function _useHeavyCalcWorker() { // 整個 app 只建立一個 Worker const worker = new Worker( new URL('../workers/heavy-calc.worker.ts', import.meta.url), { type: 'module' } ) const api = wrap(worker) const isLoading = shallowRef(false) onScopeDispose(() => { worker.terminate() }) async function calcFibonacci(list: number[]) { isLoading.value = true try { return await api.calcFibonacci(list) } finally { isLoading.value = false } } return { isLoading, calcFibonacci } } export const useHeavyCalcWorker = createSharedComposable(_useHeavyCalcWorker) ``` 元件的使用方式完全不用改,但現在不管幾個元件呼叫 `useHeavyCalcWorker`,都只會建立一個 Worker 實例。%( •̀ ω •́ )✧% 不過要注意的是,Worker 是單執行緒的,如果同時發出多個請求,它們會排隊執行。%(´● ω ●\`)% ### 簡單判斷原則 * 計算量輕、元件少 → 每個元件自己的 Worker,簡單乾淨 * 計算量重、多處使用 → 全域共享 Worker,節省資源 * 需要同時跑多個任務 → 建立 Worker Pool(這個比較進階,之後有機會再寫) ## 使用限制 Comlink 雖然好用,但還是有些需要注意的地方。 ### 只能傳遞可序列化的資料 Worker 之間的通訊底層還是依賴 `postMessage`,所以傳遞的資料必須是可以被 [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) 複製的類型。 Function、DOM 元素、class 實例(method 會消失)這類都沒辦法傳遞。 如果需要傳遞大型 ArrayBuffer,可以使用 Comlink 提供的 `transfer` 工具: ```ts import { transfer, wrap } from 'comlink' const api = wrap(worker) const buffer = new ArrayBuffer(1024 * 1024) // transfer 會轉移所有權,主執行緒就不再能使用這個 buffer await api.processBuffer(transfer(buffer, [buffer])) ``` ### Worker 內無法存取 DOM 打工仔不能碰 DOM,這是天條,只能做純粹的資料計算。 ### TypeScript 設定 Vite 專案通常不需要額外設定,但如果遇到 Worker 內的 TypeScript 型別問題,可以在 `tsconfig.json` 加上: ```json { "compilerOptions": { "lib": ["ESNext", "DOM", "WebWorker"] } } ``` ## 總結 🐟 * Web Worker 讓耗時計算不阻塞主執行緒,頁面保持流暢 * 原生 `postMessage` 需要手刻訊息分發機制,相當麻煩 * Comlink 的 `expose` 與 `wrap` 把這些苦活全包了,直接像呼叫 async function 一樣使用 * 搭配 Vue Composable 使用,可以讓 Worker 生命週期跟著元件走 * Worker 只能傳遞可序列化的資料,不能操作 DOM 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 %(\*´∀\`)~♥% --- --- url: /aquarium/cyber-scales-desktop.md description: 科幻風格的 Web Desktop 應用程式 --- --- --- url: /blog-program/effect-ts-in-real-projects.md description: Effect 接進 Vue、NestJS、oRPC 是什麼感覺?順便實測效能,最後做一支 todo API ( •̀ ω •́ )✧ --- ![effect-ts-in-real-projects](/effect-ts-in-real-projects.webp){.cover} # Effect 接進真實專案是什麼感覺?Vue、NestJS、oRPC 通通試一遍 ( •̀ ω •́ )✧ 大家好,我是鱈魚。%( ´ ▽ \` )ノ% 這篇是上下兩篇的**下篇**。 上篇[來認識 Effect](/blog-program/getting-to-know-effect-ts)講完了核心概念,用一句話複習就是,Effect 把「會怎麼失敗」跟「需要什麼」寫進 `Effect` 的型別裡,而且後兩欄由編譯器自己算。 沒看過上篇的話建議先去看,這篇會直接用 `yield*`、`Layer` 這些詞,也會一直提到 `Effect` 那三欄。 先複習一下那三欄各自代表什麼,免得後面看得霧煞煞。 | 欄位 | 意思 | |---|---| | `A` | 成功會拿到什麼 | | `E` | 可能會怎麼失敗 | | `R` | 需要哪些服務才跑得起來 | 概念講完,接下來是真正會踩到的問題。 * 接進 Vue 元件是什麼感覺?值不值得? * `@effect/platform` 跟 NestJS 差在哪?效能呢? * 已經在用 oRPC 的話,兩者會不會打架? * 從零開始寫一支 API,到底要多少行? 一個一個來。%( •̀ ω •́ )✧% ## Vue 要怎麼接 聊完後端邏輯,換個場景,Effect 寫的服務要怎麼接進 Vue 元件? 先講 npm 上的現況,因為這件事最近剛翻盤。%(´,,•ω•,,)% Effect v4 把響應式綁定收進核心,`@effect/atom-vue` 成了**官方套件**,跟 React、Solid 並列三個官方支援的前端框架。 不過有個現實問題,`@effect/atom-vue` **只支援 v4**,而 v4 目前還在 RC。 現在 `npm install effect` 裝到的是 v3,v3 時代的 Vue 綁定是 Effect 核心成員 tim-smart 個人維護的 [`@effect-atom/atom-vue`](https://github.com/tim-smart/effect-atom)。 路人:「所以到底能不能用?%(´・ω・\`)%」 鱈魚:「能,但你會是很前面的那批白老鼠。%(´\_ゝ\`)%」 考量到版本還沒穩定,接下來還是示範怎麼自己接,反正就三十行,順便也看得懂官方套件在做什麼。 第一步準備一個 app 層級的 runtime,把 Layer 組合起來,變成可重複使用的執行環境。 ```ts // runtime.ts import { Layer, ManagedRuntime } from 'effect' import { Database, EmailService } from './register-user' const AppLayer = Layer.mergeAll( Database.Default, EmailService.Default, ) export const appRuntime = ManagedRuntime.make(AppLayer) ``` 再包一層 composable,把 Effect 的執行結果接回 Vue 的 `ref`。 ```ts // useRegisterUser.ts import { Effect, Fiber } from 'effect' import { onUnmounted, ref } from 'vue' import { registerUser, type User } from './register-user' import { appRuntime } from './runtime' export function useRegisterUser() { const user = ref(null) const error = ref(null) const loading = ref(false) let fiber: Fiber.RuntimeFiber | undefined function submit(target: User) { loading.value = true error.value = null fiber = appRuntime.runFork( registerUser(target).pipe( Effect.tap((result) => Effect.sync(() => (user.value = result))), Effect.tapError((cause) => Effect.sync(() => (error.value = cause))), Effect.ensuring(Effect.sync(() => (loading.value = false))), ), ) } onUnmounted(() => { if (fiber) { Effect.runFork(Fiber.interrupt(fiber)) } }) return { user, error, loading, submit } } ``` 還記得前面說 Effect 寫好了也不會跑嗎?`runFork` 就是把描述交出去執行的那一刻。 它不等結果,馬上回傳一個 `Fiber`,元件卸載時呼叫 `Fiber.interrupt` 就能中斷,跟原生用 `AbortController` 取消 `fetch` 是同一件事,只是換了個名字。 路人:「一定要用 `ManagedRuntime` 嗎?直接 `Effect.runPromise` 不行嗎?%(´・ω・\`)%」 鱈魚:「單次呼叫用 `Effect.runPromise` 完全沒問題,只是每次都要重新 provide 一次 Layer,跟全 app 共用一個 runtime 比起來浪費資源,服務也沒辦法重複使用。%( •̀ ω •́ )%」 元件測試或 Storybook 想塞一顆假的 `EmailService`,就別直接 import 那個單例 runtime,改用 `provide`/`inject` 傳下去。 ```ts // runtime-provider.ts import type { ManagedRuntime } from 'effect' import type { Database, EmailService } from './register-user' import { inject, type InjectionKey, provide } from 'vue' type AppRuntime = ManagedRuntime.ManagedRuntime const runtimeKey: InjectionKey = Symbol('effect-runtime') export function provideRuntime(runtime: AppRuntime) { provide(runtimeKey, runtime) } export function useRuntime() { const runtime = inject(runtimeKey) if (!runtime) { throw new Error('找不到 runtime,記得先 provideRuntime') } return runtime } ``` 測試時在最外層塞一顆帶測試 Layer 的 runtime,底下所有 composable 拿到的就是測試版本,元件本身完全不用改,跟後端替換 Layer 是同一套心智模型。%(´,,•ω•,,)% ### 那前端到底該不該用? 教完怎麼接,還是得講一下該不該接。 先講我的看法,**多數 Vue 專案不值得**。%(´\_ゝ\`)% 上面那個 composable 就是證據,三十行的膠水,做的事情 `useMutation` 大概三行就交代完了。 原因有這幾個。 **一、`R`(依賴服務)在前端沒什麼用武之地** `Effect` 的第三欄,記錄這段程式需要哪些服務才跑得起來。它最大的價值是測試時可以換掉那些服務,但前端測元件通常不從服務那層換。 主流做法是拿 [MSW](https://mswjs.io/) 在**網路那層**攔截,元件、composable、`fetch` 全部照原樣跑,攔的是 HTTP 請求本身。 這種測法更接近使用者的真實路徑,也不用為了好測而改生產程式碼的架構。Layer 替換的優勢,在這裡幾乎用不上。%( ˘•ω•˘ )% **二、`E`(可能錯誤)到了畫面上會被壓扁** 第二欄,記錄這段程式可能失敗的所有原因。 後端區分 `UserNotFound` 跟 `NetworkError` 很有意義,因為要回不同的 status code。 前端呢?多數情況就三種反應,顯示錯誤訊息、重試、當作沒發生。 八種錯誤型別到了 UI 層常常被壓成同一個 `catch`,那份精細度就浪費了。 **三、該有的輪子早就有人造好了** Effect 的 `retry`、`timeout`、Fiber 中斷聽起來很威,但這些正是 [TanStack Query](https://tanstack.com/query)、[Pinia Colada](https://pinia-colada.esm.dev/) 這類套件的本業。 而且它們還多做了 Effect 不管的事,快取、失效重取、視窗聚焦重新整理、devtools。 拿 Effect 從頭刻這些,其實是降級。 **四、bundle size 是真的成本** 後端多幾百 KB 沒人在意,前端每一 KB 都要使用者用流量跟載入時間去付。 官方的 [Myths 頁面](https://www.effect.website/docs/v3/additional-resources/myths)有一條就叫「The bundle size is HUGE!」,可見這個質疑常被提出。官方的回應是這樣。 > Effect 的最低成本大約是 25k 的 gzipped 程式碼,那個 chunk 包含 Effect Runtime,而且已經涵蓋一般 app 會用到的幾乎所有函數。 > > 從那之後 Effect 對 tree-shaking 很友善,你只會打包到自己用到的部分。 這個說法有它的道理,關鍵字是 **tree-shaking**。25KB 是「起跳價」,不是整包的大小。 不過那個數字沒有附上量測方法,而且它比較的基準是「你也寫了同樣功能的程式碼」。官方另外提到採用 Effect 後自己的程式碼會變短,總成本被攤平,這點就得看專案實際情況了。%( ˘•ω•˘ )% 務實的建議是別直接相信任何一方的數字,接上 bundle 分析工具實際量一次。 後端完全不用煩惱這件事,這一條是前端獨有的成本。 **五、官方綁定還沒進穩定版** 前面提過,`@effect/atom-vue` 只支援還在 RC 的 v4,文件頁也幾乎是空的。 自己接的話,那個 composable 沒有人幫你維護,Vue 改版、Effect 改版都是你自己的事。 路人:「所以你剛剛教那一大段是白教的?%(´・ω・\`)%」 鱈魚:「還是有值得用的時候,只是我想來想去只想到一種。%( ˘•ω•˘ )%」 **那就是前端真的有很複雜的非同步流程。** 多步驟上傳佇列要重試跟退避、即時協作要處理競態、離線同步要對帳,這種場景拿 `AbortController` 配一堆 flag 硬拼會很痛苦,Fiber 的中斷模型確實舒服很多。 不過老實說,這種情況比大家想像的少。 大部分畫面就是「拿資料、顯示、送出表單」,這種需求用不到 Effect 三成的能力,卻要付出全部的學習成本。%( ˘•ω•˘ )% ## `@effect/platform` 不只是 HTTP Vue 那段是把服務接到畫面上,換個方向,`registerUser` 要變成一支 HTTP API 呢? Effect 生態圈負責這塊的是 `@effect/platform`。 不過先澄清一個常見誤解,很多人把它當成「Effect 版的 Express」,它真正的定位是**跨平台的系統能力抽象層**。 HTTP 只是其中一個模組,同一包裡面還有這些。 | 模組 | 管什麼 | |---|---| | `HttpApi`、`HttpServer`、`HttpClient` | REST API、發請求 | | `Socket` | WebSocket 與 TCP socket | | `FileSystem`、`Path` | 檔案讀寫、路徑處理 | | `Command` | 開子行程、串接 stdin/stdout | | `Worker` | Web Worker 與 `worker_threads` | | `Terminal` | CLI 的終端機輸入輸出 | | `KeyValueStore` | 鍵值儲存,可換成記憶體、檔案或其他後端 | 這些模組只定義**介面**,真正的實作放在各平台的套件裡。 ```bash npm install effect @effect/platform @effect/platform-node ``` 以 `Worker` 為例,同一份丟工作給背景執行緒的程式碼,在 Node 由 `@effect/platform-node` 接到 `worker_threads`,在瀏覽器由 `@effect/platform-browser` 接到 Web Worker,換的是最外層那顆 Layer。 看出來了嗎?這就是 `R`(依賴服務)那一欄的老把戲,只是這次被抽換的是**執行環境本身**。%( •̀ ω •́ )✧% ::: tip 不是每個模組每個平台都有實作 `FileSystem` 跟 `Command` 這種東西,瀏覽器根本沒有對應的能力,自然就沒有 browser 版。 `@effect/platform-browser` 提供的是 `BrowserKeyValueStore`(背後是 localStorage)、`BrowserSocket`、`BrowserWorker` 這些瀏覽器真的做得到的事。 所以「跨平台」指的是**介面一致**,不是每個平台都通吃。 ::: 回到 HTTP,核心是 `HttpApi`。 第一步先宣告這支 API 長什麼樣,`api.ts` ```ts import { HttpApi, HttpApiEndpoint, HttpApiGroup } from '@effect/platform' import { Schema } from 'effect' export const UserSchema = Schema.Struct({ id: Schema.String, name: Schema.String, }) export class EmailSendFailed extends Schema.TaggedError()('EmailSendFailed', { email: Schema.String, }) {} export const UsersApi = HttpApi.make('UsersApi').add( HttpApiGroup.make('users').add( HttpApiEndpoint.post('register', '/users') .setPayload(UserSchema) .addSuccess(UserSchema) .addError(EmailSendFailed, { status: 502 }), ), ) ``` 這個檔案沒有任何實作,純粹描述「收什麼、回什麼、可能出什麼錯」。 錯誤型別從 `Data.TaggedError` 換成 `Schema.TaggedError`,因為它要轉成 JSON 送給前端,得有 schema 才知道怎麼轉。 第二步接上實作,`api-live.ts` ```ts import { HttpApiBuilder } from '@effect/platform' import { Layer } from 'effect' import { UsersApi } from './api' import { registerUser } from './register-user' const UsersLive = HttpApiBuilder.group(UsersApi, 'users', (handlers) => handlers.handle('register', ({ payload }) => registerUser(payload))) export const ApiLive = HttpApiBuilder.api(UsersApi).pipe( Layer.provide(UsersLive), ) ``` 請看 handler 那行,`registerUser(payload)` 直接回傳,**`registerUser` 一個字都沒改**。 也請注意這裡**沒有** provide 任何服務,`ApiLive` 到現在還缺著 `Database` 跟 `EmailService`,那份清單原封不動掛在型別上。 第三步才把真的服務填進去,順便跑起來,`server.ts` ```ts import { createServer } from 'node:http' import { HttpApiBuilder, HttpApiSwagger } from '@effect/platform' import { NodeHttpServer, NodeRuntime } from '@effect/platform-node' import { Layer } from 'effect' import { ApiLive } from './api-live' import { Database, EmailService } from './register-user' const ServerLive = HttpApiBuilder.serve().pipe( Layer.provide(HttpApiSwagger.layer()), Layer.provide(ApiLive), Layer.provide(Database.Default), Layer.provide(EmailService.Default), Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), ) Layer.launch(ServerLive).pipe(NodeRuntime.runMain) ``` `npx tsx server.ts` 跑下去,`POST /users` 就活了,打開 `/docs` 還有一份 Swagger 在等你。 那份文件不用另外寫,從 `api.ts` 的 `Schema` 長出來,同一份定義還能再產一支型別安全的前端 client。%( •̀ ω •́ )✧% 宣告、實作、組裝剛好三個檔案,一路上沒有裝飾器,也沒有「框架偷偷幫你做了什麼」的環節。 「什麼時候把依賴填進去」一路推到最外面那層,等一下講測試會再回來咬一口。%(´,,•ω•,,)% ### 換 NestJS 寫一次 同一支 API 用 NestJS 寫,大概長這樣。 ```ts // users.controller.ts @Controller('users') export class UsersController { constructor( private readonly database: Database, private readonly emailService: EmailService, ) {} @Post() async register(@Body() dto: RegisterUserDto) { await this.database.save(dto) await this.emailService.sendWelcomeEmail(dto.email) return dto } } ``` ```ts // users.module.ts @Module({ controllers: [UsersController], providers: [Database, EmailService], }) export class UsersModule {} ``` 平心而論,NestJS 這段寫得很舒服,建構子把依賴列得清清楚楚,比前面那個直接 import 單例的版本好太多。%(´,,•ω•,,)% 路人:「那不就一樣?依賴也是攤在明面上啊。%(・∀・)%」 鱈魚:「差在**保護範圍**跟**什麼時候發現**。%( ˘•ω•˘ )%」 建構子注入只在 class 裡成立,`registerUser` 這種普通函數一旦搬出 controller,依賴又躲回 import 裡了。Effect 沒有這個限制,那份清單跟著 `Effect` 型別走,是函數是 class 都一樣。 至於什麼時候發現,`UsersModule` 的 `providers` 少填一個,TypeScript 完全不會有意見,因為那只是一個陣列,得等啟動時 Nest 組裝容器才會噴 `can't resolve dependencies`。 Effect 版本少 provide 一顆 Layer,紅的是編譯器。%(°ㅂ°)% ### 測試的時候差更多 回到前面那個問題,測這支 API 要怎麼換掉 `EmailService`? NestJS 有一套測試專用的模組 API,得把模組重新宣告一次,再指定要蓋掉誰。 ```ts const moduleRef = await Test.createTestingModule({ controllers: [UsersController], providers: [Database, EmailService], }) .overrideProvider(EmailService) .useValue({ sendWelcomeEmail: vi.fn() }) .compile() const controller = moduleRef.get(UsersController) ``` Effect 這邊沒有「測試專用 API」這種東西。 還記得剛剛把依賴推到最外層嗎?正式環境在 `server.ts` 填真的,測試就填假的,如此而已。 ```ts Layer.provide(EmailService.Default) // [!code --] Layer.provide(EmailServiceTest) // [!code ++] ``` `api.ts`、`api-live.ts`、`registerUser` 三個檔案完全不用動,因為它們從頭到尾就沒決定過要用哪個實作。%(ゝ∀・)b% 整理成表格比較好認。 | 比較項目 | NestJS | Effect Platform | |---------|--------|------------------| | 依賴注入 | 建構子加裝飾器,執行期組裝容器 | `Context`/`Layer`,清單寫在型別的 `R` 那一欄 | | 漏了依賴 | 啟動時噴 `can't resolve dependencies` | 編譯器直接紅 | | 保護範圍 | 只有 class 有 | 函數、class、endpoint 都一樣 | | 資料驗證 | class-validator 寫 DTO | `Schema`,順便長出文件跟 client | | 中介層 | `@UseGuards`、Interceptor,另一套機制 | `HttpApiMiddleware`,跟一般服務同一套 Layer | | 測試替換 | `Test.createTestingModule().overrideProvider()` | `Effect.provide`,跟測普通函數同一招 | ### 那效能呢? 路人:「講這麼多,跑起來到底快不快?%(・∀・)%」 鱈魚:「先講結論,這大概不是你該煩惱的事,不過數字還是值得看。%(´\_ゝ\`)%」 資料來源是 [SaltyAom 的 HTTP 框架 benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark),目前唯一同時收錄 Effect 跟 NestJS 的公開測試。 **看數字之前,先講三個一定要知道的前提。**%( ˘•ω•˘ )% * 表中的 Effect 是 **v4 beta 的 `effect/unstable/http`**,跟你現在 `npm install @effect/platform` 裝到的 v3 是兩套程式碼。v3 那條線沒有任何公開效能數據 * Effect 那組**關掉了 tracing 跟 request logging**,兩者都是框架預設開啟的。照預設寫的服務不會有這個表現 * NestJS 跑**預設的 Express adapter**,官方文件說換成 Fastify adapter 可以快將近一倍,那個組合沒人測過 所以底下的數字只能當**方向參考**,不能當結論。 底下只列 Node 的數字,Bun 那組跳過,畢竟多數人的正式環境還是跑 Node。 #### 一、吞吐量 | 框架 | 平均 req/s | |---|---:| | Fastify | 97,934 | | Hono | 91,090 | | **Effect** | **58,684** | | Express | 42,226 | | NestJS | 31,271 | Effect 大約是 NestJS 的 1.9 倍、Express 的 1.4 倍,但只有 Fastify 的六成。 這張表能證明的只有一件事,**Effect 的 HTTP 層沒有大家想像中那麼肥**。%(´,,•ω•,,)% #### 二、延遲 先講壞消息,**P99 這類百分位數字沒有**。 那份 benchmark 用 bombardier 壓測,而百分位要加 `-l` 參數才會輸出,他們沒加。想要 p99 就得自己 clone 下來重跑。%(´\_ゝ\`)% 不過延遲資料本身是有的,只是不在 README 那張表上。 repo 裡有 commit 原始的壓測輸出(`results/node/*.txt`),bombardier 預設就會印**平均、標準差、最大值**這三項,下面的數字都是從那些檔案挖出來的。 標準差跟最大值其實更好玩,因為它們反映的正是**尾端延遲穩不穩**,跟 P99 想回答的是同一個問題。 以 `GET /` 這條路由、500 連線跑十秒來看。 | 框架 | 平均延遲 | 標準差 | 最大延遲 | |---|---:|---:|---:| | Fastify | 3.33 ms | 5.93 ms | 777 ms | | **Effect** | **5.49 ms** | **15.04 ms** | **1.44 s** | | Express | 7.68 ms | 23.55 ms | 1.90 s | | NestJS | 10.32 ms | 42.07 ms | 3.20 s | 看最後兩欄,排序跟平均延遲一致,但差距被放大了。 NestJS 平均 10ms 看起來還好,標準差卻是 42ms、最壞情況 3.2 秒。這代表**有一小撮倒楣的請求等了非常久**,而那些人剛好就是 P99 會抓出來的族群。%(°ㅂ°)% Effect 的尾端比 Express 跟 NestJS 收斂,但離 Fastify 還有一段距離。 所以只看平均延遲會少看很多東西,那幾個等到天荒地老的使用者,全都藏在後面兩欄裡。%( ˘•ω•˘ )% #### 三、冷啟動與基準記憶體 | 框架 | 啟動時間 | 啟動後 RSS | Bundle | |---|---:|---:|---:| | Hono | 45.2 ms | 95.7 MB | 61.2 KB | | Express | 50.2 ms | 86.6 MB | 603.6 KB | | Fastify | 67.2 ms | 98.4 MB | 554.7 KB | | **Effect** | **80.9 ms** | **90.7 MB** | 357.6 KB | | NestJS | 110.3 ms | 119.0 MB | 1.3 MB | 啟動時間量的是「行程 spawn 到第一個請求成功回應」,Effect 比 NestJS 快約 27%,但輸給 Express 跟 Hono。 基準記憶體大家其實差不多,NestJS 的 119MB 稍微突出,bundle 也是唯一破 1MB 的。 要注意的是**這不等於 serverless 冷啟動**。Lambda、Workers 那種環境還有 INIT、快照還原一整套流程,含 Effect 的冷啟動實測我完全找不到。 如果你要部署到 Cloudflare Workers,倒是有個數字要留意,`effect` 本身壓縮後約 293KB、`@effect/platform` 約 1.49MB(未 tree-shaking 的上限值),而 Workers 免費方案的限制是 1MB。%( ˘•ω•˘ )% #### 四、高負載後的資源釋放 這欄我覺得最有意思。同一份表格記錄了壓測前後的 RSS 變化。 | 框架 | 壓測前 | 壓測後 | 成長 | |---|---:|---:|---:| | **Effect** | 90.7 MB | 114.6 MB | **+23.9 MB(+26%)** | | Fastify | 98.4 MB | 162.3 MB | +63.9 MB(+65%) | | NestJS | 119.0 MB | 224.8 MB | +105.8 MB(+89%) | | Hono | 95.7 MB | 217.1 MB | +121.4 MB(+127%) | | Express | 86.6 MB | 223.8 MB | +137.2 MB(+158%) | Effect 的記憶體成長是 Express 的六分之一、NestJS 的四分之一。 而且它的吞吐量還比這兩個高,照理說配置率應該更高才對,結果反而長最少。%(⊙ロ⊙)% 路人:「是 Effect 的 Scope 在做資源回收嗎?%(・∀・)%」 鱈魚:「我一開始也這樣猜,但查完文件發現不能這樣講。%( ˘•ω•˘ )%」 `Scope` 保證的是檔案、連線這類**明確取得的資源**會被確實釋放,但這份 benchmark 的路由根本沒開這些東西,機制等於沒出場。 所以合理的講法是,**Effect 在這次評測的設定下記憶體成長明顯較低,但別急著歸功給哪個機制**。%(´,,•ω•,,)% 順帶一提,Effect 從來沒進過 [TechEmpower](https://www.techempower.com/benchmarks/) 那套主流框架 benchmark,NestJS 有。想找更權威的對照,目前找不到。 ### 但這真的是你的瓶頸嗎 講完數字,還是要把話說回來。 Pydantic 團隊寫過一篇[實測文章](https://pydantic.dev/articles/web-framework-performance),結論是「**框架本身很少會是瓶頸**」。他們把整個請求拆開看,資料庫查詢跟序列化吃掉絕大多數時間,框架路由只佔中間那幾條細縫。 那篇講的是 Python,但道理跨語言通用。 Effect 官方的 [Myths 頁面](https://www.effect.website/docs/v3/additional-resources/myths)也很坦白。 > 前端有跑到 120fps 還重度使用 Effect 的應用,所以 Effect 大概不會是你的效能問題。 一支 API 只要碰了資料庫,那幾毫秒的 IO 就足以淹沒框架層的差距。 真的在意每秒幾萬個請求,該問的是「要不要用 Bun」跟「資料庫查詢有沒有寫爛」,而不是「該用哪個框架」。%(ゝ∀・)b% ## 那 oRPC 呢? 看過[前一篇 oRPC 的文章](/blog-ocean-world/orpc-vs-ts-rest)的朋友可能會想,這兩個是不是打架? 答案是不會,而且**合約優先的人有一條特別順的路**。%(ノ>ω<)ノ% ### 把 oRPC 掛在 Effect 的 HTTP server 上 關鍵在於契約優先本來就把**契約**跟**HTTP 傳輸**拆開了。 契約是一份獨立的東西,放在私有 npm 或 monorepo 裡,前後端各自拿去對照。 ```ts // @project-code/shared export const todoContract = { create: oc.input(/* ... */).output(/* ... */), } ``` 後端用 `implement(todoContract)` 實作,前端用 `ContractRouterClient` 推型別。至於這些東西**用什麼跑起來**,是另一件事。 所以可以讓 oRPC 出契約跟路由,HTTP server 交給 Effect。%( •̀ ω •́ )✧% 橋接方式意外地簡單,因為兩邊剛好在同一個介面上會合。 * oRPC 的 fetch adapter 是 `(Request) => Promise` * `@effect/platform` 有個 `HttpApp.fromWebHandler`,收的正好就是這種函數 ```ts const orpcApp = HttpApp.fromWebHandler(async (request) => { const result = await rpcHandler.handle(request, { prefix: '/rpc', context: {} }) return result.matched ? result.response : new Response('Not Found', { status: 404 }) }) const app = HttpRouter.empty.pipe( HttpRouter.get('/health', HttpServerResponse.text('ok')), HttpRouter.mountApp('/rpc', orpcApp, { includePrefix: true }), ) ``` 契約優先全程保留,`oc` 定契約、`implement` 實作、前端照樣 `ContractRouterClient`,連 event iterator 串流都能正常穿透。 **最棒的是這條路全部用穩定版就能跑。** ```bash npm install @orpc/server @orpc/contract effect @effect/platform @effect/platform-node ``` `@orpc/server` 對 `effect` 零依賴,所以裝起來完全沒有 peer 衝突。%(´,,•ω•,,)% ::: warning `includePrefix` 一定要設 `true` 預設的 `false` 會改寫請求 URL 來剝除前綴,但 `toWeb` 有個 fast-path 會直接回傳原始 request。 在 Node 上剛好沒事,在 Bun、Deno、Workers 上那個改寫會被無聲忽略,oRPC 會收到沒剝除前綴的路徑。 ::: 不過動手之前先想清楚**你到底想換到什麼**。 | | 內容 | |---|---| | 拿得到 | 單一 port、Effect 的 logger/CORS/OTel 包住 RPC、Layer 生命週期與優雅關機 | | 拿不到 | procedure 內部仍是普通 async function,沒有型別化的錯誤,也沒有服務清單 | 想在 handler 裡跑 Effect,還是得 `ManagedRuntime.runPromise`,而那一步會把型別化錯誤打回 rejected promise。 所以這條路只買到**傳輸層**的 Effect。%( ˘•ω•˘ )% 務實的建議是這樣。 * oRPC 是你唯一的 HTTP 出口 → 用 `@orpc/server/node` 配 `ManagedRuntime` 更單純,找到的唯一一個真實 oRPC + Effect 專案就是這樣做 * 已經有其他 Effect 路由、想統一成同一個 server → 橋接才划算 順帶一提,`@effect/platform` 不只是 HTTP,`FileSystem`、`Command`、`Worker` 那些模組跟 HTTP 無關,走這條路的話也一起拿到了。%(´,,•ω•,,)% ### 補充:oRPC v2 的官方 Effect 整合 上面那條路的 procedure 還是普通 async function。想讓型別化錯誤跟服務清單進到業務邏輯裡,oRPC v2 有**官方的 Effect 整合**。 ```ts os.create.handler(async ({ input }) => { // [!code --] await database.save(input) // [!code --] }) // [!code --] os.create.handler(handlerGen(function* ({ input }) { // [!code ++] const database = yield* Database // [!code ++] yield* database.save(input) // [!code ++] })) // [!code ++] ``` `handlerGen` 會把執行跟錯誤處理包好,不用自己呼叫 `Effect.runPromise`。服務則走 oRPC 的 context,塞一個 `effect/context` 進去。 好消息是**契約完全不用動**。 `handlerGen` 的型別裡沒有 schema 的位置,它對契約的要求只有「符合 Standard Schema」,Zod 本來就符合。所以整份 Zod 契約一行都不用改寫成 Effect Schema。%( •̀ ω •́ )✧% ::: danger 這條路現在全線都是 beta 以 **2026 年 8 月 21 日**的狀態為準。 | 套件 | 穩定版 | 這條路要用的版本 | |---|---|---| | `effect` | 3.22.1 | **4.0.0-beta**(v4 已進 RC) | | `@orpc/server`、`@orpc/contract` | 1.15.0 | **2.0.0-beta.29** | | `@orpc/experimental-effect` | 無穩定版 | **2.0.0-beta.29** | Effect 跟 oRPC 都得押在 beta 上,套件名還掛著 `experimental`。side project 玩玩沒問題,正式產品建議等兩邊都出正式版。 還有三個坑:npm 的 `latest` 標籤指到誤發布的舊版,一定要加 `@beta`;三個 oRPC 套件版號必須完全一致,否則 npm 會裝出巢狀的第二份 `@orpc/server`,讓 prototype 擴充打到錯的 class,執行期噴 `os.xxx.effect is not a function`;最後,它的 peer 寫死 Effect v4 beta,跟 `@effect/platform` 裝不進同一棵樹,**等於放棄前面那個橋接**。 ::: ### 那 `HttpApi` 什麼時候才輪得到 其實你早就看過 `HttpApi` 的合約優先了,就是前面那兩個檔案,`api.ts` 只有宣告,`api-live.ts` 才是實作。 概念跟 oRPC 一模一樣,差別在合約的**形狀**跟**流通範圍**。 | | oRPC 合約 | `HttpApi` | |---|---|---| | 合約長相 | 函式簽章(input、output、errors) | HTTP 端點(method、path、schema) | | 驗證庫 | 任何 Standard Schema,Zod 直接可用 | 只吃 Effect `Schema` | | 前端要不要碰 Effect | 完全不用 | 產 client 就會拉進 Effect | | 前端查詢整合 | TanStack Query、Pinia Colada、SWR | 沒有 | | 部署目標 | 二十幾種,含 Hono、Next、SvelteKit、Cloudflare | Node、Bun、Deno、瀏覽器 | 判斷標準不是「哪個比較好」,而是看**合約要給誰看**。 合約要跨團隊、前端不想碰 Effect,就留在 oRPC。整個後端都是 Effect、前端也吃得下,`HttpApi` 才有登場的理由。 要注意的是,**已經用 Zod 寫好的合約沒辦法搬去 `HttpApi`**。 `HttpApiEndpoint` 的 `setPayload` 型別上只收 `Schema.Schema.Any`,而 Effect Schema 的識別靠一個 `unique symbol`,Zod 結構上無法冒充。轉換也只有 Effect Schema → Standard Schema 這個方向,反過來沒有。 所以那不是「改個 import 就好」,是整份重寫。%( ˘•ω•˘ )% 補充一下,Effect 圈內還有個 `@effect/rpc`,走 RPC 形狀而非 REST 形狀,定位比較接近 oRPC,同一份 `Schema` 可以共用,不過它還在 0.x,暫時不用急著看。 ## 動手做一個最小的 todo server 講了一整篇,最後把東西湊起來,做一支真的跑得動的 todo API。 不用登入、資料用 [Drizzle](https://orm.drizzle.team/) 存進 PostgreSQL,而且照 feature 拆好資料夾。 ```text src/ ├── todos/ │ ├── todo.table.ts # Drizzle 的資料表定義 │ ├── todo.api.ts # 這個 feature 的 endpoint 宣告 │ ├── todo.store.ts # 服務與 Layer │ └── todo.handlers.ts # handler 實作 ├── api.ts # 把各 feature 的宣告併起來 └── server.ts # 組裝所有 Layer 並啟動 ``` 熟悉 NestJS 的話,`todo.api.ts` 大致等於 controller 的路由表、`todo.store.ts` 等於 provider、`api.ts` 等於 `AppModule`。%(´,,•ω•,,)% ::: tip 為什麼檔名用點號分隔 `實體.型別.ts` 這種寫法源自 Angular Style Guide,NestJS 沿用至今。 有趣的是 Angular 自己在 v20 已經不這樣產生檔案了,理由是他們認為 `Service` 這類後綴沒有傳達實質資訊,`UserDataClient` 比 `UserService` 有用得多,細節可以看[那份 RFC 討論](https://github.com/angular/angular/discussions/58412)。不過這個寫法在後端圈還是很通行。 選它有兩個實際理由。 * **模糊搜尋好用**,在編輯器打 `todo tab` 就能直達資料表定義,這也是 Angular 當年列的理由之一 * **glob 抓得精準**,Drizzle 的 `schema: './src/**/*.table.ts'` 不會誤中 `lookup-table.ts` 這種檔案,換成連字號的 `*-table.ts` 就會 不喜歡點號的話,換成 `todo-table.ts` 或 feature 資料夾內用裸名 `table.ts` 都行,只要整個專案一致就好。 ::: ### 一、資料表長什麼樣 `todos/todo.table.ts` 就是一般的 Drizzle 寫法,跟 Effect 沒有關係。 ```ts import { boolean, pgTable, serial, text } from 'drizzle-orm/pg-core' export const todoTable = pgTable('todos', { id: serial('id').primaryKey(), title: text('title').notNull(), done: boolean('done').notNull().default(false), }) ``` 建表就照 Drizzle 的常規流程,`npx drizzle-kit push` 或跑 migration 都行。 ### 二、宣告這個 feature 的 API `todos/todo.api.ts` 只描述形狀,沒有任何實作。 ```ts import { HttpApiEndpoint, HttpApiGroup } from '@effect/platform' import { Schema } from 'effect' export const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String, done: Schema.Boolean, }) export type Todo = typeof Todo.Type export class DatabaseError extends Schema.TaggedError()('DatabaseError', { message: Schema.String, }) {} export const TodoGroup = HttpApiGroup.make('todos') .add( HttpApiEndpoint.get('list', '/todos') .addSuccess(Schema.Array(Todo)) .addError(DatabaseError, { status: 500 }), ) .add( HttpApiEndpoint.post('create', '/todos') .setPayload(Schema.Struct({ title: Schema.String })) .addSuccess(Todo) .addError(DatabaseError, { status: 500 }), ) ``` 錯誤型別用 `Schema.TaggedError` 而不是 `Data.TaggedError`,理由跟前面一樣,它要轉成 JSON 送給前端。 而且**每個 endpoint 都要用 `.addError` 掛上去**,沒掛的話 handler 回傳這個錯誤會編譯失敗,因為 endpoint 宣告的錯誤通道裡根本沒有它。%( ˘•ω•˘ )% ### 三、服務與說明書 `todos/todo.store.ts` 分成兩半,名牌跟說明書。 ```ts import { drizzle } from 'drizzle-orm/node-postgres' import { Context, Effect, Layer } from 'effect' import { DatabaseError, type Todo } from './todo.api' import { todoTable } from './todo.table' // 名牌,只講有哪些能力 export class TodoStore extends Context.Tag('TodoStore'), DatabaseError>; readonly create: (title: string) => Effect.Effect; }>() {} // 說明書,連線字串由外面決定 export const TodoStorePostgres = (connectionUri: string) => Layer.sync(TodoStore, () => { const db = drizzle(connectionUri) return { list: Effect.tryPromise({ try: () => db.select().from(todoTable), catch: (cause) => new DatabaseError({ message: String(cause) }), }), create: (title: string) => Effect.tryPromise({ try: () => db.insert(todoTable).values({ title, done: false }).returning(), catch: (cause) => new DatabaseError({ message: String(cause) }), }).pipe(Effect.map((rowList) => rowList[0]!)), } }) ``` 這裡用 `Context.Tag` 而不是 `Effect.Service`,因為連線字串要由外面給。 `TodoStorePostgres` 是一台生產說明書的機器,餵不同的連線字串會吐出不同的 Layer,等一下測試就靠這件事。%( •̀ ω •́ )✧% ### 四、把宣告併起來 `api.ts` 就是 `AppModule` 的角色。 ```ts import { HttpApi } from '@effect/platform' import { TodoGroup } from './todos/todo.api' export const Api = HttpApi.make('Api').add(TodoGroup) ``` 以後多一個 feature 就多一行 `.add(UserGroup)`。 ### 五、實作 handler `todos/todo.handlers.ts` 才碰到真正的邏輯。 ```ts import { HttpApiBuilder } from '@effect/platform' import { Effect } from 'effect' import { Api } from '../api' import { TodoStore } from './todo.store' export const TodoLive = HttpApiBuilder.group(Api, 'todos', (handlers) => handlers .handle('list', () => Effect.gen(function* () { const store = yield* TodoStore return yield* store.list })) .handle('create', ({ payload }) => Effect.gen(function* () { const store = yield* TodoStore return yield* store.create(payload.title) }))) ``` 注意第一個參數收的是**整份 `Api`**,不是單一個 group,所以這個檔案要回頭 import 根目錄的 `api.ts`。 也就是說 feature 資料夾沒辦法像 NestJS 的 module 那樣完全自給自足。好在宣告跟實作是分開的檔案,依賴方向是 `todo.api` → `api` → `todo.handlers`,不會繞成循環。%( ˘•ω•˘ )% ### 六、組裝並啟動 `server.ts` 是唯一決定「用哪個實作」的地方。 ```ts import { createServer } from 'node:http' import { HttpApiBuilder, HttpApiSwagger } from '@effect/platform' import { NodeHttpServer, NodeRuntime } from '@effect/platform-node' import { Layer } from 'effect' import { Api } from './api' import { TodoLive } from './todos/todo.handlers' import { TodoStorePostgres } from './todos/todo.store' const ServerLive = HttpApiBuilder.serve().pipe( Layer.provide(HttpApiSwagger.layer()), Layer.provide(HttpApiBuilder.api(Api).pipe(Layer.provide(TodoLive))), Layer.provide(TodoStorePostgres(process.env.DATABASE_URL!)), Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), ) Layer.launch(ServerLive).pipe(NodeRuntime.runMain) ``` 跑起來。 ```bash DATABASE_URL=postgres://... npx tsx src/server.ts ``` ```bash curl -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":"買鱈魚"}' # {"id":1,"title":"買鱈魚","done":false} curl localhost:3000/todos # [{"id":1,"title":"買鱈魚","done":false}] ``` 打開 `localhost:3000/docs` 還有一份 Swagger。%( •̀ ω •́ )✧% 加起來一百行左右,但你已經拿到這些東西。 * 請求跟回應都經過 `Schema` 驗證,型別錯了直接擋在門口 * Swagger 文件自動生成,沒有另外寫 * 資料庫錯誤掛在 `E`(可能錯誤)那一欄,呼叫端看型別就知道會噴 `DatabaseError` * 換資料庫只要改 `server.ts` 那一行,其他檔案動都不用動 路人:「用 Express 寫可能更短欸。%(・∀・)%」 鱈魚:「對,但那一百行裡面有一半是型別跟文件在幫你做事。%( ˘•ω•˘ )%」 ## 測試接上真的 PostgreSQL `TodoStore` 現在寫進 PostgreSQL,測試就不能靠假物件混過去了。 拿假的 client 來 mock 意義不大,因為 SQL 有沒有寫對它驗不出來。比較實在的做法是用 [Testcontainers](https://node.testcontainers.org/) 開一個真的資料庫。 先把容器包成服務,`test/pg-container.ts` ```ts import { PostgreSqlContainer } from '@testcontainers/postgresql' import { Effect } from 'effect' export class PgContainer extends Effect.Service()('PgContainer', { scoped: Effect.acquireRelease( Effect.promise(() => new PostgreSqlContainer('postgres:17-alpine').start()), (container) => Effect.promise(() => container.stop()), ), }) {} ``` `Effect.acquireRelease` 收兩個東西,怎麼開跟怎麼關。只要宣告一次,之後不管測試成功、失敗還是被中斷,容器都保證會關掉。%( •̀ ω •́ )✧% 接著把連線字串餵給**正式環境那份** `TodoStorePostgres`。 ```ts const TodoStoreTest = Layer.unwrapEffect( Effect.gen(function* () { const container = yield* PgContainer return TodoStorePostgres(container.getConnectionUri()) }), ).pipe(Layer.provide(PgContainer.Default)) ``` 前面那台「生產說明書的機器」在這裡派上用場了。測到的是真的 SQL,不是假的替身。 測試本體,`test/todo.test.ts` ```ts import { expect, layer } from '@effect/vitest' import { Effect } from 'effect' layer(TodoStoreTest, { timeout: '60 seconds' })('TodoStore', (it) => { it.effect('新增之後應該查得到', () => Effect.gen(function* () { const store = yield* TodoStore const created = yield* store.create('買鱈魚') const todoList = yield* store.list expect(created.title).toBe('買鱈魚') expect(todoList).toHaveLength(1) })) }) ``` 用 `layer(...)` 包起來,容器**整個區塊只開一次**,區塊結束才關,裡面每個 `it.effect` 共用同一個資料庫。 如果改成在每條測試各自 `Effect.provide(TodoStoreTest)`,那就會每跑一條就開關一次容器,慢到懷疑人生。%(´\_ゝ\`)% ::: tip 這段的版本 `@effect/vitest@0.30`(peer 是 `effect ^3.22`)搭 `@testcontainers/postgresql@12`,後者要求 Node 22.22 以上。 另外 Effect v4 把 `it.scoped` 拿掉了,`it.effect` 自帶 `Scope`,升級時要注意。 ::: ## 同一支 API 換 NestJS 寫 有了對照才知道差在哪,同樣的功能用 NestJS 寫大概是這樣。 ```text src/ ├── todos/ │ ├── dto/create-todo.dto.ts │ ├── todo.controller.ts │ ├── todo.service.ts │ └── todo.module.ts ├── app.module.ts └── main.ts ``` 資料夾切法幾乎一樣,差別在 NestJS 多了一個 `todo.module.ts` 把整包收起來。 先是資料驗證,NestJS 這邊靠 class-validator。 ```ts // todos/dto/create-todo.dto.ts import { IsString } from 'class-validator' export class CreateTodoDto { @IsString() title: string } ``` 接著是服務。 ```ts // todos/todo.service.ts @Injectable() export class TodoService { constructor(@Inject(DATABASE) private readonly db: Database) {} list() { return this.db.select().from(todoTable) } create(title: string) { return this.db.insert(todoTable).values({ title, done: false }).returning() } } ``` 再來是 controller 跟 module。 ```ts // todos/todo.controller.ts @Controller('todos') export class TodoController { constructor(private readonly todoService: TodoService) {} @Get() list() { return this.todoService.list() } @Post() create(@Body() dto: CreateTodoDto) { return this.todoService.create(dto.title) } } ``` ```ts // todos/todo.module.ts @Module({ controllers: [TodoController], providers: [TodoService], }) export class TodoModule {} ``` 老實說 NestJS 這版讀起來相當舒服,裝飾器把路由跟依賴標得清清楚楚,行數也差不多。%(´,,•ω•,,)% ### NestJS 這邊怎麼測 同樣接真的 PostgreSQL。 ```ts describe('TodoService', () => { let container: StartedPostgreSqlContainer let app: INestApplication beforeAll(async () => { container = await new PostgreSqlContainer('postgres:17-alpine').start() const db = createDatabase(container.getConnectionUri()) const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(DATABASE) .useValue(db) .compile() app = moduleRef.createNestApplication() await app.init() }, 180_000) afterAll(async () => { await app?.close() await container?.stop() }) it('新增之後應該查得到', async () => { // ⋯⋯ }) }) ``` 功能上完全等價,差別在**收尾這件事得自己顧**。 * `beforeAll` 跟 `afterAll` 要自己配對 * 關閉順序要自己排對,先關 app 再關容器 * timeout 要自己記得加,不然抓映像檔那次會逾時 * `app?.` 那個問號是因為 `beforeAll` 可能中途失敗,變數還是 `undefined` 我翻了幾個 GitHub 上的真實專案,**忘記寫 `container.stop()` 的比例高得驚人**,測試跑完容器就留在那邊。%(°ㅂ°)% Effect 那邊不用擔心這件事,因為「怎麼開」跟「怎麼關」在 `Effect.acquireRelease` 是綁在一起宣告的,`layer(...)` 負責在正確的時機關掉,順序由 `Scope` 保證。 ### 整理一下差別 | | Effect | NestJS | |---|---|---| | 組合單位 | Layer | `@Module` | | API 文件 | `HttpApiSwagger.layer()` 一行 | 要另外裝 `@nestjs/swagger`,並在 DTO 補 `@ApiProperty` | | 回應驗證 | `addSuccess` 會驗 | 不驗,回傳什麼就送什麼 | | 漏了依賴 | 編譯器直接紅 | 啟動時才噴 `can't resolve dependencies` | | 測試替換 | `Layer.provide`,跟正式環境同一招 | `Test.createTestingModule().overrideProvider()` | | 測試資源收尾 | `acquireRelease` 綁在一起宣告 | 自己寫 `afterAll`,順序自己排 | | 型別安全的前端 client | 從同一份定義產出 | 沒有,要自己寫或另接工具 | 最值得注意的是**回應驗證**那列。 NestJS 的 `ValidationPipe` 只擋進來的請求,回傳的東西沒人檢查。`TodoService` 哪天多回傳一個欄位、或少了 `done`,前端才會發現。 Effect 版本的 `addSuccess(Todo)` 是雙向的,回傳值對不上 schema 當場就爆。%( ˘•ω•˘ )% 至於資料夾,兩邊都拆得開,差別在**邊界的形狀**。 NestJS 的邊界是 module,一個 class 把 controller 跟 provider 都包住。Effect 的邊界是 Layer,而路由宣告跟路由實作被拆到兩層去了。 代價是 feature 資料夾沒那麼獨立,換到的是那份宣告可以獨立拿去產 client 跟文件。%( ˘•ω•˘ )% 這其實就是整篇文章的縮影,Effect 前期一定寫得比較多,換到的是後面改動時編譯器會擋住你。 專案越小越不划算,越大越值得。%(´,,•ω•,,)% ## 那 Effect 是不是萬靈丹 一路看下來好像 Effect 完勝,該說的壞話還是要說一下。%(「・ω・)「% * **學習曲線真的陡**,`Effect.gen`、`pipe`、`Layer`、`Context` 一次要吃下好幾個新概念,團隊要有人願意帶 * **小工具不值得**,十行的 CLI script 用 Effect 包起來,樣板程式碼比原本邏輯還多 * **生態圈小很多**,遇到問題能查到的資料,跟 Node.js 原生寫法差一大截 * **跟現有程式碼混用有摩擦**,專案裡一半 Promise 一半 Effect,來回轉換的樣板碼會讓人很煩躁 ### AI 全時代,Effect 真的該優先於 NestJS 嗎? 路人:「上篇你自己都說,AI 幫你砍掉了查資料的成本,你才回鍋 Effect 的。既然門檻不是問題了,遇到金流、訂單、Worker、Realtime 這種複雜後端,Effect 應該要電爆 NestJS 才對吧?%(・∀・)%」 鱈魚:「前半句對,後半句是另一件事,兩句接在一起講就跳太快了。%( ˘•ω•˘ )%」 上篇[來認識 Effect](/blog-program/getting-to-know-effect-ts)「回到兩年前」那節講的是**用得起**,AI 靠官方塞進 npm 套件的 `AGENTS.md`、`@effect/language-service` 這些專門養出來的工具,把當年那五道查資料的門檻拆掉了。 這篇要查的是另一件事,**用得起**不等於**該優先選它**,尤其複雜後端系統這種場景,得分開查證。%( •̀ ω •́ )✧% 先看 Effect 官方自己怎麼講。[effect.website 首頁](https://effect.website)現在的標語是這樣。 > Build production-ready systems your team can ship, customers can depend on, and AI agents can work with. 往下滑還有一整區塊叫「Write Effect code with AI」,理由跟上篇提過的那套工具邏輯一致,型別結構固定讓 AI 不用猜、錯誤訊息夠精細讓 AI 能精準自我修正。 這段行銷詞跟一開始那個說法幾乎一字不差,說白了,那是 Effect 官方現在的行銷主軸,不是鱈魚查出來的新發現。%(´\_ゝ\`)% 那有沒有真的拿 Effect 扛複雜系統的案例?查到幾家。 | 公司 | 用在哪 | 來源 | |---|---|---| | Vercel | Domains 團隊,由 Dillon Mulroy 主導導入 | Effect Days 2025 演講、Cause & Effect podcast | | MasterClass | 即時語音 AI orchestration,撐 Gordon Ramsay、Mark Cuban 這些名人課程的對話 | effect.website 案例研究 | | Warp(薪資合規平台,不是終端機那個 Warp) | 金流與薪資系統,CTO Adam Rankin 親自談的 | Cause & Effect podcast | | OpenCode(sst 那支開源終端機 coding agent) | 大型 TypeScript codebase 遷移,架構其實是 Effect 混 Zod | effect.website 案例、Kit Langton 訪談 | 案例確實對得上金流、Realtime、第三方 API 串接這幾個場景,不算亂講。%( ˘•ω•˘ )% 路人:「這樣不就證明了?%(・∀・)%」 鱈魚:「證明 Effect 官方找得到案例,離『比 NestJS 更適合』還有一段距離。這四家全是 Effect 自己收集、自己訪談的故事,沒有一家附上『我們原本用 NestJS,換過來之後 XX 變好』這種對照數字,也找不到任何獨立第三方比較。%(´・ω・\`)%」 還有件事上篇已經提醒過,AI 寫 Effect 容易生出過期語法,三代 API 混在訓練資料裡,得靠官方 skills、`@effect/language-service` 這些工具才壓得住。 換句話說,讓 AI 幫忙扛 Effect 這件事本身就是一份持續要投資的工具鏈,不是裝完就沒事了。把這筆成本算進「該不該 Effect-first」的天平,結論不會像那句行銷標語那麼一面倒。%( ˘•ω•˘ )% 風險也還在。`HttpApi`、`RPC`、`Workflow` 這幾個模組現在全收在 unstable 目錄下,官方目標是 2026 Q3、Q4 出正式版,這之前 API 隨時可能改,跟上篇提過的 v4 RC 是同一件事。 真要走這條路,務實的做法是把這些還不穩定的平台介面集中包一層,日後版本升級或 API 變動,改動範圍縮在那一層裡,不會炸到整個專案。%(´,,•ω•,,)% 務實一點的判斷方式,看**那三欄有沒有東西可填**。 錯誤只有一種、要用的服務只有一兩個、流程直來直去,原生 TypeScript 配上好好設計的參數傳遞就很夠用。 錯誤分支很多、服務一堆、還要重試跟併發,那三欄填滿的價值就出來了,很值得那份學習成本。%( ˘•ω•˘ )% ## 總結 🐟 * 前端先別急著跟進。元件測試多半用 MSW 在網路那層攔,替換服務那套優勢用不上,官方 Vue 綁定也還卡在還沒發正式版的 v4 * `@effect/platform` 不只是 HTTP,WebSocket、檔案系統、子行程、Worker 都在裡面,換平台等於換一顆 Layer * 跟 NestJS 最大的差別在**什麼時候發現漏了依賴**,NestJS 要等啟動時容器組裝才噴錯,Effect 是編譯器直接紅 * 效能方面 Effect 的 HTTP 層沒想像中肥,但那份 benchmark 有一堆前提要看清楚,而且框架通常不是你的瓶頸 * 已經在用 oRPC 的話兩者不衝突,合約留給 oRPC、Effect 進 handler,Zod 契約一行都不用改 寫到這裡大概可以回答那個老問題了。 Effect 沒打算取代 TypeScript,也未必適合每個專案。但如果你也常常在翻「這函數到底會噴什麼錯、到底需要什麼」,它把答案寫進型別的做法,現在真的值得重新評估一次。%(ゝ∀・)b% 沒看過上篇的話,[來認識 Effect](/blog-program/getting-to-know-effect-ts)那篇有完整的概念說明,還有我兩年前棄坑又回鍋的心路歷程。%(´,,•ω•,,)% 有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 --- --- url: /aquarium/enderbox.md description: 我把 Minecraft 搬到網頁上了!不過這次是操作終界使者,一起來挖方塊、瞬間移動吧! --- --- --- url: /aquarium/hexazen.md description: 組合多種場景並產生不同的自然音效 --- --- --- url: /column-hexazen/01-origin.md description: 同一個白噪音聽到快背起來了,來試試自己做一個 3D 白噪音混音器吧。◝(≧∀≦)◟ --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP01:前言 平常上班時耳機常常都是放白噪音、雨聲等等自然聲音。 同一個聲音聽太多次,連下一個雷聲甚麼時候出現我都快背起來了。%(›´ω\`‹ )% 雖然有很多不錯的線上混音器網站,但是總感覺差了一點甚麼。 這次想嘗試用 Babylon.js 做一個 3D 白噪音混音器,聲音使用隨機播放的形式呈現。 ## 說明 做一個有趣的白噪音網站,同時練習更複雜的 3D 應用 ### 概念 每個聲音都是一個積木,積木可以任意組合,積木種類與規模會產生不同自然音效。 例如: * 草:無 * 樹木:風吹過樹葉聲音 * 雲:下雨聲音 * 房子:咖啡廳聲音 * 河:流水聲 不同規模會產生生態系,例如樹木夠多會有蟲鳴、鳥叫等等。 地圖資訊會儲存在 URL 中,分享連結即可讓其他人聽到你的設計的自然音效。 ### 素材 ![kit](https://kenney.nl/media/pages/assets/hexagon-kit/7b13873307-1706007672/sample.png) 3D 積木則基於 kenney 大大設計的「[Hexagon Kit](https://kenney.nl/assets/hexagon-kit)」 感謝 kenney 大大提供如此優秀的素材!%(\*´∀\`)~♥% ## 開發 現在讓我們開始吧。%੭ ˙ᗜ˙ )੭% 第一步我打算先在此部落格做原型測試。 多虧 VitePress 強大的客製化能力,現在來建立完全客製化的頁面。 ### 建立頁面 新增檔案。 `content\aquarium\hexazen\index.md` ```markdown --- title: HexaZen description: 組合多種場景並產生不同的自然音效 image: https://codlin.me/hexazen-cover.webp layout: false --- ``` `layout` 設為 `false` 代表不使用預設的佈局,這樣就可以自由設計頁面了!%♪( ◜ω◝و(و% ::: tip 詳細說明可見文件:[VitePress:Layout](https://vitepress.dev/reference/default-theme-layout) ::: 現在導航到 `http://localhost:3000/aquarium/hexazen/` 應該會是一片空白。 接著在頁面中以 iframe 方式引入主元件,利用 [whyframe](https://whyframe.dev/) 隔離環境。 `content\aquarium\hexazen\index.md` ```markdown --- title: HexaZen description: 組合多種場景並產生不同的自然音效 image: https://codlin.me/hexazen-cover.webp layout: false --- ``` 使用 iframe 隔離可以避免 VitePress 本身的樣式和狀態干擾,讓 HexaZen 擁有完全獨立的運行環境。 ### 場景初始化 由於 Babylon.js 場景最基本構成是 `engine`、`scene`、`camera`、`light`。 所以我將相關邏輯直接獨立為 `use-babylon-scene` composable,簡化元件內容。 `content\aquarium\hexazen\composables\use-babylon-scene.ts` ```typescript export function useBabylonScene(param?: UseBabylonSceneParam) { const canvasRef = ref() const engine = shallowRef() const scene = shallowRef() const camera = shallowRef() onMounted(async () => { if (!canvasRef.value) return engine.value = await createEngine({ canvas: canvasRef.value, }) scene.value = createScene({ canvas: canvasRef.value, engine: engine.value, }) camera.value = createCamera({ canvas: canvasRef.value, engine: engine.value, scene: scene.value, }) engine.value.runRenderLoop(() => { scene.value?.render() }) await init({ canvas, engine, scene, camera }) }) onBeforeUnmount(() => { engine.value?.dispose() scene.value?.dispose() }) return { canvasRef, engine, scene, camera } } ``` 支援 WebGPU 優先、WebGL 降級的引擎建立策略,並提供預設的場景、攝影機、光源設定,同時允許 `init` callback 自定義初始化邏輯。 現在在 3D 場景元件中引用。 `content\aquarium\hexazen\main-scene.vue` ```vue ``` 接著產生地板。這裡使用 PBR 材質搭配 `MirrorTexture` 產生鏡面倒影效果。 ```typescript function createGround({ scene }: { scene: Scene }) { const ground = MeshBuilder.CreateGround( 'ground', { width: 1000, height: 1000 }, scene, ) const pbr = new PBRMaterial('groundPBR', scene) pbr.albedoColor = new Color3(0.9, 0.9, 0.9) pbr.metallic = 0.2 pbr.roughness = 0.2 const mirrorTexture = new MirrorTexture('groundMirror', 512, scene, true) mirrorTexture.mirrorPlane = new Plane(0, -1, 0, 0) mirrorTexture.level = 0.5 mirrorTexture.renderList = [] pbr.reflectionTexture = mirrorTexture ground.material = pbr ground.receiveShadows = true return ground } ``` 現在場景中應該會有一個帶有鏡面反射的白色地板了。 ### 資料驅動的積木定義 接著把模型放到靜態目錄中,[下載模型](https://kenney.nl/assets/hexagon-kit)並解壓縮。 `content\public\assets\kenny-hexagon-pack` HexaZen 採用資料驅動的積木定義方式,所有積木的模型路徑、位置、旋轉、縮放等資訊都集中在 `data.ts` 中。 這裡的資料來自 [CodStack](/aquarium/codstack/) 工具的輸出。 `content\aquarium\hexazen\domains\block\builder\data.ts` ```typescript interface BlockDefinition { /** 一個 block 可能會有多個特性,例如港口同時有 water、building */ traitList: TraitTypeUnion[]; content: { version: number; rootFolderName: string; partList: PartData[]; }; } export const blockDefinitions = { /** grass */ g1: { traitList: ['grass'] as TraitTypeUnion[], content: { version: 1, rootFolderName: 'assets', partList: [ { path: 'kenny-hexagon-pack/GLB format/grass.glb', position: [0, 0, 0], rotationQuaternion: [0, 1, 0, 0], scaling: [1, 1, 1], metadata: { name: '', mass: 0, restitution: 0.5, friction: 0 }, }, ], }, }, /** tree */ t1: { traitList: ['tree'] as TraitTypeUnion[], content: { version: 1, rootFolderName: 'assets', partList: [ { path: 'kenny-hexagon-pack/GLB format/grass-forest.glb', position: [0, 0, 0], rotationQuaternion: [0, 1, 0, 0], scaling: [1, 1, 1], metadata: { name: '', mass: 0, restitution: 0.5, friction: 0 }, }, ], }, }, // ... 共 30+ 種積木 } satisfies Record ``` 每個積木定義了 `traitList`(特性清單),這是聲景系統的判斷依據。例如港口(b6)同時擁有 `building` 和 `water` 兩個特性。 ### 建立積木 統一的 `createBlock` function 負責載入模型、設定材質、掛載到場景上。 `content\aquarium\hexazen\domains\block\builder\index.ts` ```typescript export async function createBlock({ type, scene, shadowGenerator, hex, hexLayout, weather, }: CreateBlockParams): Promise { const blockDefinition = blockDefinitions[type] const resultList = await Promise.all( blockDefinition.content.partList.map( async ({ path, position, rotationQuaternion, scaling, metadata }) => { const fullPath = `${blockDefinition.content.rootFolderName}/${path}` const model = await ImportMeshAsync(fullPath, scene) // 強制三線性過濾,避免旋轉時紋理閃爍 model.meshes.forEach((mesh) => { if (mesh.material instanceof PBRMaterial) { mesh.material.metallic = 0 mesh.material.roughness = 0.4 } }) return { model, position, rotationQuaternion, scaling } }, ), ) const rootNode = new TransformNode('block-root', scene) // 組裝所有部件到 rootNode resultList.forEach(({ model, position, rotationQuaternion, scaling }) => { const [rootMesh] = model.meshes if (!rootMesh) return rootMesh.receiveShadows = true rootMesh.position = new Vector3(...position) rootMesh.rotationQuaternion = new Quaternion(...rotationQuaternion) rootMesh.scaling = new Vector3(...scaling) rootMesh.parent = rootNode }) // 放置到六角格世界座標 rootNode.position.copyFrom(hexLayout.hexToWorld(hex)) // 進入動畫:從下方彈出 rootNode.position.y -= 1 await animate(rootNode.position, { y: 0, duration: 1000, ease: 'outElastic(1,0.52)', }).then() return { type, rootNode, hex, dispose } } ``` ### 專案結構 目前的檔案結構如下: ``` hexazen/ ├── hexazen.vue # 應用根元件 ├── main-scene.vue # 3D 場景 ├── index.md # VitePress 入口 ├── components/ # UI 元件 ├── composables/ # 共用邏輯 │ ├── use-babylon-scene.ts # 引擎初始化 │ ├── use-simple-i18n.ts # 輕量 i18n │ ├── use-thumbnail-generator.ts │ └── use-font-loader.ts ├── constants/ │ └── index.ts # 版本號 ├── types/ │ └── index.ts # Trait、Weather 型別 └── domains/ ├── block/ # 積木系統 │ ├── type.ts │ ├── block-picker.vue │ ├── builder/ # 建立積木 │ └── trait-region/ # BFS 連通區域 ├── hex-grid/ # 六角格數學 ├── soundscape/ # 聲景系統 │ ├── type.ts │ ├── soundscape-mixer.vue │ ├── player/ # 播放引擎 │ └── resolver/ # 規則解析 └── share/codec/ # URL 編碼 ``` ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen) * 建立 whyframe 隔離的 VitePress 客製化頁面 * 建立 WebGPU/WebGL 雙策略的 Babylon.js 場景 * PBR 材質地板搭配鏡面反射 * 資料驅動的積木定義系統 * 統一的積木建立函式與彈性動畫 下一章讓我們來看六角格座標系統與聲景規則的設計吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/02-hex-grid-and-soundscape.md description: 如何讓六角積木組合出千變萬化的自然音效?讓我們從座標系統開始聊起。✧⁑。٩(ˊᗜˋ*)و✧⁕。 --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP02:六角格與聲景系統 上一章讓我們可以建立積木,但只有積木沒辦法拚在一起 這次要來實作六角格座標系統,讓積木可以排列在六角網格上。 同時基於積木特性建立聲景(Soundscape)系統,讓不同的積木組合自動產生對應的自然音效!%◝( •ω• )◟% ## 六角格座標系統 六角格看似簡單,但座標計算其實有不少眉角。 ### 三種座標系統 六角格常見的座標系統有 Offset、Axial、Cube 三種,各有優缺點。 #### Offset 座標 最直覺的方式:用 `(col, row)` 對應 2D 陣列索引。 看起來很簡單,但問題馬上出現,奇數行和偶數行的鄰居偏移不同: ```typescript // Offset 座標找鄰居:需要判斷奇偶行 😵 function getNeighbors(col: number, row: number) { const isEvenRow = row % 2 === 0 if (isEvenRow) { return [ [col, row - 1], [col + 1, row - 1], // 上方兩格 [col - 1, row], [col + 1, row], // 左右 [col, row + 1], [col + 1, row + 1], // 下方兩格 ] } else { return [ [col - 1, row - 1], [col, row - 1], // 上方兩格 [col - 1, row], [col + 1, row], // 左右 [col - 1, row + 1], [col, row + 1], // 下方兩格 ] } } ``` 距離計算也得分奇偶處理,旋轉更是噩夢。%(›´ω\`‹ )% #### Axial 座標 把六角格攤在兩條斜軸 `(q, r)` 上: 鄰居偏移變得一致了,不用再判斷奇偶: ```typescript // Axial 座標的六方向:固定偏移量,不分奇偶 const axialDirections = [ [+1, 0], [+1, -1], [0, -1], [-1, 0], [-1, +1], [0, +1], ] ``` 但距離計算仍然需要推導第三個隱含的座標: ```typescript // Axial 距離:需要推算出 s function axialDistance(a: [number, number], b: [number, number]) { const dq = a[0] - b[0] const dr = a[1] - b[1] const ds = -dq - dr // 隱含的第三軸 return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds)) } ``` #### Cube 座標 三軸 `(q, r, s)` 並且 **q + r + s = 0 恆成立**,這個約束讓所有運算都變得對稱且優雅。 對比三者的操作複雜度: | 操作 | Offset | Axial | Cube | | -------- | ---------- | ---------- | ----------------------------- | | 找鄰居 | 判斷奇偶行 | 查表 | 向量加法 | | 算距離 | 分段公式 | 推導第三軸 | `max(\|dq\|, \|dr\|, \|ds\|)` | | 旋轉 | 非常複雜 | 需要推導 | 排列 + 取反 | | 對稱性 | 無 | 部分 | 三軸完全對稱 | | 儲存空間 | 2 個值 | 2 個值 | 2 個值(s 可推算) | Cube 座標的距離就是向量在三軸上的最大分量: ```typescript // Cube 距離:一行搞定 function cubeDistance(a: Hex, b: Hex) { return Math.max( Math.abs(a.q - b.q), Math.abs(a.r - b.r), Math.abs(a.s - b.s), ) } ``` 旋轉 60° 也只需要排列三個值再取反: ```typescript // Cube 旋轉:排列 + 取反,優雅到不行 function rotateRight(hex: Hex): Hex { return new Hex(-hex.r, -hex.s, -hex.q) } function rotateLeft(hex: Hex): Hex { return new Hex(-hex.s, -hex.q, -hex.r) } ``` 雖然儲存三個值看似冗餘,但 `s = -q - r` 隨時可以推算,實際只需要兩個值。 這種數學上的對稱性對後面的 BFS 連通偵測和座標轉換大有幫助。%( •̀ ω •́ )✧% ### Hex 類別 讓我們建立核心的 `Hex` 類別。 `content\aquarium\hexazen\domains\hex-grid\index.ts` ```typescript export class Hex { public q: number public r: number public s: number constructor(q: number, r: number, s: number) { // 用 epsilon 容忍浮點誤差 const sum = q + r + s if (Math.abs(sum) > 1e-6) { throw new Error('Hex invariant violated: q + r + s must be 0') } this.q = q this.r = r this.s = s } key() { return `${this.q},${this.r},${this.s}` } static fromAxial(q: number, r: number): Hex { return new Hex(q, r, -q - r) } add(b: Hex): Hex { return new Hex(this.q + b.q, this.r + b.r, this.s + b.s) } subtract(b: Hex): Hex { return new Hex(this.q - b.q, this.r - b.r, this.s - b.s) } } ``` 建構子中直接驗證 `q + r + s === 0`,如果違反就直接拋錯。 `fromAxial` 則是便利方法,只需要給 `q` 和 `r`,`s` 會自動推算。 ### 找鄰居 六角格每格有 6 個共用邊的鄰居,定義方向向量後,找鄰居就是一次向量加法。 ```typescript // 六方向(cube) static readonly directions: ReadonlyArray = Object.freeze([ new Hex(1, 0, -1), new Hex(1, -1, 0), new Hex(0, -1, 1), new Hex(-1, 0, 1), new Hex(-1, 1, 0), new Hex(0, 1, -1), ]) /** 取得共用邊的鄰居 */ neighbor(direction: number): Hex { return this.add(Hex.direction(direction)) } ``` 另外還有 6 個對角鄰居(共用頂點、距離為 2),之後判斷蛙鳴等音效時會用到。 ### 世界座標轉換 有了 Hex 座標後,需要 `HexLayout` 把 Hex 座標轉成 Babylon.js 的世界座標。 ```typescript export class HexLayout { constructor( public orientation: Orientation, public size: number, public origin: Vector3 = Vector3.Zero(), ) {} /** Hex -> 世界座標(X,Z) */ hexToWorld(hex: Hex, yOverride?: number): Vector3 { const M = this.orientation const x = (M.f0 * hex.q + M.f1 * hex.r) * this.size + this.origin.x const z = (M.f2 * hex.q + M.f3 * hex.r) * this.size + this.origin.z const y = yOverride ?? this.origin.y return new Vector3(x, y, z) } /** 世界座標 -> 最近格 Hex(已 round) */ worldToHexRounded(world: Vector3): Hex { return this.worldToHexFractional(world).round() } } ``` 這裡使用 pointy-topped(上下頂點朝上)的六角形方向,透過矩陣變換在 Hex 座標和世界座標之間轉換。 `worldToHexRounded` 把滑鼠點擊的世界座標轉回最近的 Hex 格,放置積木時非常有用。 ::: tip 六角格的數學參考了 Red Blob Games 的 [Hexagonal Grids](https://www.redblobgames.com/grids/hexagons/) 文章 文章中有許多即時互動的範例,強力推薦!%ദ്ദി ˉ͈̀꒳ˉ͈́ )% ::: ## 積木與特性 有了六角格,接著需要定義各種積木與它們的「特性(Trait)」。 ### 特性系統 ```typescript enum TraitTypeEnum { GRASS, TREE, CAMPFIRE, BUILDING, ALPINE, RIVER, WATER, SAND, } ``` 每個積木可以擁有一至多個特性,例如: * 草地(g1~g6):`grass` * 樹木(t1~t3):`tree` * 港口(b6):`building` + `water` * 水車(r5):`river` + `building` * 山中小屋(b7):`building` + `alpine` 這種多特性設計讓積木可以同時參與不同音效的判斷,例如港口既能觸發市集音效,也能貢獻海浪音效。%◝(≧∀≦)◟% ### 區域規模 光是計算單個積木不夠,還需要知道「有多少同類積木連在一起」,也就是「區域規模」。 這就需要 BFS(廣度優先搜尋)來找出連通區域。 `content\aquarium\hexazen\domains\block\trait-region\index.ts` ```typescript export function calcTraitRegionList( blocks: Map | BlockSnapshot[], ): TraitRegion[] { const regionList: TraitRegion[] = [] const traitToKeys = new Map>() // 先將每個 block 的 trait 分類 for (const [key, block] of blockMap) { for (const trait of blockDefinitions[block.type].traitList) { if (!traitToKeys.has(trait)) { traitToKeys.set(trait, new Set()) } traitToKeys.get(trait)!.add(key) } } // 對每個 trait 獨立做 BFS 連通分量分析 for (const [trait, keySet] of traitToKeys) { const unvisited = new Set(keySet) while (unvisited.size > 0) { const startKey = unvisited.values().next().value as string const startBlock = blockMap.get(startKey)! unvisited.delete(startKey) const hexMap = new Map([[startKey, startBlock.hex]]) const queue: Hex[] = [startBlock.hex] let head = 0 while (head < queue.length) { const current = queue[head]! head++ for (let direction = 0; direction < 6; direction++) { const neighbor = current.neighbor(direction) const neighborKey = neighbor.key() if (!unvisited.has(neighborKey)) continue const neighborBlock = blockMap.get(neighborKey)! unvisited.delete(neighborKey) hexMap.set(neighborKey, neighborBlock.hex) queue.push(neighborBlock.hex) } } regionList.push({ trait, hexMap, size: hexMap.size }) } } return regionList } ``` 幾個值得注意的細節: * 使用 `head` 游標取代 `shift()`,避免破壞陣列記憶體連續性 * 以 `Map`(O(1))取代 `Array`(O(n))來查找鄰格 * 一個 block 若有多個 trait,會分別參與不同 trait 的連通分析 ## 聲景系統 終於來到最有趣的部分了!%੭ ˙ᗜ˙ )੭% ### 規則驅動的音效生成 聲景系統的核心是一組「規則(Rule)」,每個規則包含: * `predicate`:判斷是否觸發(依據積木組合與天氣) * `transform`:產生對應的音效 ```typescript interface SoundscapeRule { type: SoundscapeType; predicate: (data: { traitRegionList: TraitRegion[]; blockMap: Map; weather?: Weather; }) => boolean; transform: (soundscapeList: Soundscape[]) => Soundscape[]; } ``` ### 生態系規則 這些規則設計出了一套小型生態系統,積木數量越多,音效越豐富: | 條件 | 音效 | 說明 | | ---------------- | -------- | ---------------- | | 樹木 ≥ 3 | 風吹樹梢 | 林間基本的沙沙聲 | | 草地 ≥ 4 | 蟲鳴 | 蟋蟀與蟈蟈的合唱 | | 樹木 ≥ 6 | 鳥叫 | 黑鶇、交喙的歌聲 | | 樹木 ≥ 10 | 獸鳴 | 鼠兔的叫聲 | | 河流 ≥ 2 | 流水聲 | 潺潺水流 | | 水域 ≥ 15 | 海浪 | 壯闊的海洋聲 | | 水域 ≥ 15 + 有岸 | 潮汐 | 海浪拍打岸邊 | | 高山 ≥ 10 | 高山風 | 凜冽的山風 | | 建築 ≥ 5 | 市集 | 熙攘的人聲 | | 有營火 | 營火 | 柴火劈啪聲 | 例如「風吹樹梢」的規則: ```typescript { type: 'rustle', predicate: ({ traitRegionList, weather }) => { if (weather === 'rain') { return false } return traitRegionList.some( (region) => region.trait === 'tree' && region.size >= 3, ) }, transform: concat([{ id: getId(), title: 'The Whisper of the Treetops', type: 'rustle', mode: { value: 'loop' }, soundList: [{ src: 'hexazen/sounds/rustle-tree.mp3', volume: 0.3, }], }]), }, ``` ### 天氣影響 下雨時大部分自然聲音會受抑制,換成各種雨聲: * 草地的雨聲(基礎) * 森林的雨聲(樹木 ≥ 2) * 屋頂的雨聲(建築 ≥ 5) * 雷聲(隨機間隔 10~30 秒) 這讓同一個積木配置在晴天和雨天會有完全不同的聽覺體驗。%(\*´∀\`)~♥% ### 特殊規則 蛙鳴的觸發條件比較特別:需要一個水域格,且它的 6 個鄰格中至少有 5 個是非水域積木。 ```typescript predicate({ traitRegionList, blockMap }) { const waterRegionList = traitRegionList.filter( (region) => region.trait === 'water', ) for (const waterRegion of waterRegionList) { for (const [, waterHex] of waterRegion.hexMap) { let nonWaterCount = 0 for (let direction = 0; direction < 6; direction++) { const neighborHex = waterHex.neighbor(direction) const neighborBlock = blockMap.get(neighborHex.key()) if (!neighborBlock) continue const isNotWater = !blockDefinitions[neighborBlock.type] .traitList.includes('water') if (isNotWater) { nonWaterCount++ if (nonWaterCount >= 5) return true } } } } return false }, ``` 也就是說,青蛙只會出現在「幾乎被陸地包圍的小池塘」旁邊!%ヾ(◍'౪\`◍)ノ゙% ### 解析流程 最後,`resolveSoundscape` 將所有規則串接起來: ```typescript export function resolveSoundscape(data: { traitRegionList: TraitRegion[]; blockMap: Map; weather?: Weather; }) { const validRuleList = soundscapeRuleList.filter((rule) => rule.predicate(data), ) const soundscapeList = pipe( validRuleList, reduce((acc, rule) => { return rule.transform(acc) }, [] as Soundscape[]), ) return soundscapeList } ``` 流程很直覺:過濾出所有滿足條件的規則,然後依序呼叫 `transform` 將音效累加起來。 每次積木變動或天氣改變時,系統會重新計算連通區域、解析音效,自動更新播放中的聲景。 ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen) * 建立基於 Cube 座標的六角格系統 * 積木的多特性設計讓音效判斷更有彈性 * BFS 連通區域偵測讓「規模」成為音效觸發的關鍵因子 * 規則驅動的聲景系統,用簡單的 predicate + transform 組合出豐富的聽覺體驗 * 天氣系統為同一場景帶來截然不同的聲音氣氛 下一章讓我們來聊聊音效播放引擎與分享編碼的實作吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/03-soundscape-player.md description: 怎麼讓白噪音不再重複?雙軌交叉淡入淡出加上隨機播放,讓耳朵不再疲勞。♪( ◜ω◝و(و --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP03:音效播放引擎 上一章我們設計了聲景規則系統,知道什麼時候該播放什麼音效,但是「播放音效」不是只有播放就好,有些小細節需要注意。%ԅ(´∀\` ԅ)% 例如 loop 音效循環到起點時,會有明顯的「接縫感」,這章就來解決這個問題!%◝( •ω• )◟ % ## 型別定義 先來看看 Soundscape 的資料結構。 `content\aquarium\hexazen\domains\soundscape\type.ts` ```typescript export type SoundscapeType = | 'rustle' | 'insect' | 'bird' | 'frog' | 'beast' | 'river' | 'building' | 'ocean' | 'alpine' | 'rain' | 'campfire' interface Sound { src: string; /** 0 ~ 1 * @default 0.5 */ volume?: number; } export interface Soundscape { id: number; title: string; type: SoundscapeType; mode: | { value: 'loop'; } | { value: 'interval'; /** @default [5, 10] 秒 */ range?: [number, number]; }; soundList: Sound[]; } ``` 目前 `mode` 分為兩種播放模式: * **loop**:適合持續性音效(風聲、流水、雨聲) * **interval**:適合間歇性音效(鳥叫、蛙鳴、雷聲),會在隨機間隔後播放 ## GainNode 串接架構 音效系統的核心是 `GainNode`,靠多個 Node 串接,達成方便、彈性的音效控制。 音量控制鏈如下: ``` trackGain → baseGain → globalGain → muteGain → destination ``` 每一層各司其職: | GainNode | 控制者 | 用途 | | ------------ | -------- | ------------------------------------ | | `trackGain` | 音效定義 | 單軌基礎音量(如鳥叫 0.5、雷聲 0.1) | | `baseGain` | 混音器 | 使用者在混音器 UI 中調整的乘數 | | `globalGain` | 主控音量 | 左側滑桿的整體音量 | | `muteGain` | 靜音按鈕 | 0 或 1,控制靜音 | ```typescript constructor(soundscape: Soundscape) { this.audioContext = getAudioContext() // 建立 GainNode 鏈 this.baseGainNode = this.audioContext.createGain() this.globalGainNode = this.audioContext.createGain() this.muteGainNode = this.audioContext.createGain() this.baseGainNode.connect(this.globalGainNode) this.globalGainNode.connect(this.muteGainNode) this.muteGainNode.connect(this.audioContext.destination) } ``` ::: tip 所有 `SoundscapePlayer` 共用同一個 `AudioContext`,因為瀏覽器對 `AudioContext` 的數量有限制。 ::: ## Loop 模式:雙軌交叉淡入淡出 Loop 模式的核心技巧是**雙軌交疊**(crossfade)。 建立兩個 `Audio` 元素播放同一段音檔,在 A 軌快結束前提早啟動 B 軌,兩軌重疊的期間自然過渡,就聽不到接縫了。 ```typescript private playLoop() { const soundData = this.soundscape.soundList[0] if (!soundData) return const baseVolume = soundData.volume ?? DEFAULT_BASE_VOLUME // 建立雙音軌 const audioA = new Audio(soundData.src) audioA.crossOrigin = 'anonymous' audioA.loop = true const audioB = new Audio(soundData.src) audioB.crossOrigin = 'anonymous' audioB.loop = true const trackA = this.createTrack(audioA, baseVolume) const trackB = this.createTrack(audioB, baseVolume) let useTrackA = true const scheduleNext = (track: AudioTrack) => { if (this.isDestroying) return track.audio.currentTime = 0 track.audio.play().catch((e) => console.warn(`[${this.soundscape.type}] 播放被阻擋:`, e), ) const setupOverlap = () => { // 確保交疊時間不會大於音檔本身長度的一半 const overlap = Math.min( this.OVERLAP_SECONDS, track.audio.duration * 0.4, ) const triggerTimeMs = (track.audio.duration - overlap) * 1000 const timer = setTimeout(() => { this.timeoutIds.delete(timer) useTrackA = !useTrackA const nextTrack = useTrackA ? trackA : trackB scheduleNext(nextTrack) }, triggerTimeMs) this.timeoutIds.add(timer) } if (track.audio.readyState >= 1) { setupOverlap() } else { track.audio.onloadedmetadata = () => { setupOverlap() track.audio.onloadedmetadata = null } } } scheduleNext(trackA) } ``` `OVERLAP_SECONDS` 預設為 4 秒,但不會超過音檔長度的 40%,避免短音檔出問題。%( •̀ ω •́ )✧% ## Interval 模式:隨機間隔播放 Interval 模式適合像鳥叫、蛙鳴這類間歇性聲音。 每次從 `soundList` 中隨機挑一個音效播放,播完後等待隨機秒數再播下一個。 ```typescript private playInterval() { if (this.isDestroying) return const [randomSound] = sample(this.soundscape.soundList, 1) if (!randomSound) return const baseVolume = randomSound.volume ?? DEFAULT_BASE_VOLUME const audio = new Audio(randomSound.src) audio.crossOrigin = 'anonymous' const track = this.createTrack(audio, baseVolume) audio.onended = () => { this.removeTrack(track) if (this.isDestroying) return const { mode } = this.soundscape const [min, max] = mode.value === 'interval' && mode.range ? [Math.min(...mode.range), Math.max(...mode.range)] : [5, 10] const waitSec = Math.random() * (max - min) + min const timer = setTimeout(() => { this.timeoutIds.delete(timer) this.playInterval() }, waitSec * 1000) this.timeoutIds.add(timer) } audio.play().catch((e) => { console.warn(`[${this.soundscape.type}] 播放被阻擋:`, e) }) } ``` 預設等待時間為 5~10 秒的隨機區間,雷聲則特別設定為 10~30 秒,讓間隔更有真實感。 ## 優雅地銷毀 當積木被移除或天氣變化導致音效不再需要時,不能突然靜音,會顯得相當突兀。 這裡用 `linearRampToValueAtTime` 做 2 秒的漸出: ```typescript public async destroy(fadeOutDurationMs = 2000): Promise { if (this.isDestroying) return this.isDestroying = true // 1. 清除所有等待中的計時器 this.timeoutIds.forEach((timer) => clearTimeout(timer)) this.timeoutIds.clear() // 2. 對所有正在播放的音軌執行漸出 const currentTime = this.audioContext.currentTime const fadeEndTime = currentTime + fadeOutDurationMs / 1000 for (const track of this.activeTracks) { track.trackGain.gain.setValueAtTime( track.trackGain.gain.value, currentTime, ) track.trackGain.gain.linearRampToValueAtTime(0, fadeEndTime) } await new Promise((resolve) => { const timer = setTimeout(resolve, fadeOutDurationMs) this.timeoutIds.add(timer) }) // 3. 徹底清理回收資源 for (const track of this.activeTracks) { track.audio.pause() track.audio.removeAttribute('src') track.audio.onloadedmetadata = null track.audio.onended = null track.trackGain.disconnect() track.source.disconnect() } this.activeTracks.clear() this.baseGainNode.disconnect() this.globalGainNode.disconnect() this.muteGainNode.disconnect() } ``` 三步驟:停止排程 → 漸出音量 → 徹底清理資源。乾淨利落。%( •̀ ω •́ )✧% ## Composable:管理播放器生命週期 最後用 `useSoundscapePlayer` composable 把所有播放器串起來。 `content\aquarium\hexazen\domains\soundscape\player\use-soundscape-player.ts` ```typescript export function useSoundscapePlayer( blockMap: ShallowReactive>, options: { muted?: Ref; volume?: Ref; weather?: Ref; } = {}, ) { const traitRegionList = computed(() => calcTraitRegionList(blockMap)) const soundscapeList = computed(() => resolveSoundscape({ traitRegionList: traitRegionList.value, blockMap, weather: weather.value, }), ) const activePlayerMap = shallowReactive(new Map()) watch(soundscapeList, (newList) => { const newIdSet = new Set(newList.map(prop('id'))) // 不在新列表中的音效 → 靜音(保留音量設定) for (const [id, player] of activePlayerMap) { if (!newIdSet.has(id)) { player.muted() } } // 新列表中的音效 → 建立或解除靜音 for (const scape of newList) { if (!activePlayerMap.has(scape.id)) { const player = new SoundscapePlayer(scape) player.setGlobalVolume(volume.value) player.play() if (muted.value) { player.muted() } activePlayerMap.set(scape.id, player) } else { const player = activePlayerMap.get(scape.id)! if (!muted.value) { player.unmuted() } } } }) return { traitRegionList, soundscapeList, activePlayerMap } } ``` 幾個值得注意的設計: * 不在新列表中的音效是**靜音**而非銷毀,保留使用者調整過的音量設定 * `watch` 自動響應 `blockMap` 變化,積木一放下,音效就跟著更新 * 全域音量與靜音狀態也透過 `watch` 即時同步 ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen/domains/soundscape) * 雙軌交叉淡入淡出消除 loop 接縫感 * Interval 模式隨機播放帶來自然變化 * GainNode 四層串接實現細緻的音量控制 * 漸出動畫 + 資源清理確保優雅銷毀 * Composable 自動管理播放器生命週期 下一章讓我們來看看 3D 場景中的互動機制吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/04-3d-scene-interaction.md description: 讓積木可以放置、旋轉、移除,還有滑順的動畫與視覺效果。◝(≧∀≦)◟ --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP04:3D 場景互動 有了六角格和音效系統,現在要讓使用者能在 3D 場景中實際操作積木。 點擊空格放置、點擊積木旋轉、切換鏟子模式移除。%੭ ˙ᗜ˙ )੭% ## 地板:PBR 材質與鏡面反射 首先讓地板看起來更有質感。使用 PBR 材質加上 `MirrorTexture` 產生鏡面倒影。 ```typescript function createGround({ scene }: { scene: Scene }) { const ground = MeshBuilder.CreateGround( 'ground', { width: 1000, height: 1000 }, scene, ) const pbr = new PBRMaterial('groundPBR', scene) pbr.albedoColor = new Color3(0.9, 0.9, 0.9) pbr.metallic = 0.2 pbr.roughness = 0.2 const mirrorTexture = new MirrorTexture('groundMirror', 512, scene, true) mirrorTexture.mirrorPlane = new Plane(0, -1, 0, 0) mirrorTexture.level = 0.5 mirrorTexture.renderList = [] pbr.reflectionTexture = mirrorTexture ground.material = pbr ground.receiveShadows = true // 自動將新增的 mesh 加入倒影渲染清單 scene.onNewMeshAddedObservable.add((mesh) => { if (mesh !== ground) { mirrorTexture.renderList?.push(mesh) } }) return ground } ``` `mirrorTexture.level = 0.5` 讓倒影若隱若現,不會太搶眼。 搭配 `onNewMeshAddedObservable`,後續新增的積木也會自動出現在倒影中。 ## 候選格、選中格、Hover 狀態 六角格有三種視覺狀態: | 狀態 | 透明度 | 顏色 | 可點擊 | | ------------------ | ------ | ------ | ------ | | 候選格 (Candidate) | 0.5 | 灰色 | 是 | | 選中格 (Selected) | 0.6 | 暗金色 | 否 | | Hover | 0.7 | 灰色 | 是 | ```typescript const COLOR_SELECTED = new Color3(0.4, 0.3, 0.1) const COLOR_CANDIDATE = new Color3(0.3, 0.3, 0.3) const ALPHA_SELECTED = 0.6 const ALPHA_CANDIDATE = 0.5 const ALPHA_HOVER = 0.7 const ALPHA_HIDDEN = 0.0 const FADE_SPEED = 14 ``` ### 平滑過渡 狀態切換使用**指數平滑**做漸變動畫: ```typescript scene.onBeforeRenderObservable.add(() => { const dt = engine.getDeltaTime() / 1000 const t = 1 - Math.exp(-FADE_SPEED * dt) for (const [key, material] of tileMaterialMap) { const targetAlpha = targetTileAlphaMap.get(key) ?? ALPHA_HIDDEN const targetColor = targetTileColorMap.get(key) // 透明度漸變 material.alpha = material.alpha + (targetAlpha - material.alpha) * t // 顏色漸變 if (targetColor) { material.emissiveColor.r += (targetColor.r - material.emissiveColor.r) * t material.emissiveColor.g += (targetColor.g - material.emissiveColor.g) * t material.emissiveColor.b += (targetColor.b - material.emissiveColor.b) * t material.diffuseColor.copyFrom(material.emissiveColor) } } }) ``` `1 - Math.exp(-FADE_SPEED * dt)` 是一種與幀率無關的指數平滑公式。不管 60fps 還是 144fps,動畫速度都一致。%( •̀ ω •́ )✧% ## 候選格同步 每次放置或移除積木後,需要重新計算哪些格子是候選格。 ```typescript function syncAllCandidateTile() { // 移除沒有相鄰 placed block 的孤立候補格 candidateTileMap.forEach((hex, key) => { const hasAdjacentBlock = Array.from({ length: 6 }, (_, d) => hex.neighbor(d),).some((neighbor) => props.placedBlockMap.has(neighbor.key())) const hasSelfBlock = props.placedBlockMap.has(key) if (hasAdjacentBlock || hasSelfBlock) return removeCandidate(hex) }) // 補上 placedBlock 鄰格中缺少的候補 props.placedBlockMap.forEach((block) => { for (let d = 0; d < 6; d++) { addCandidate(block.hex.neighbor(d)) } }) // 若地圖為空,確保原點候補存在 if (props.placedBlockMap.size === 0) { candidateTileMap.forEach((hex) => removeCandidate(hex)) addCandidate(new Hex(0, 0, 0)) } } ``` 另外 `addCandidate` 會檢查 `hex.len() > MAX_RADIUS`,限制最大放置半徑為 2 格。 ## 積木放置與動畫 放置積木時使用彈性彈跳動畫,讓積木從下方彈出: `content\aquarium\hexazen\domains\block\builder\index.ts` ```typescript // 進入動畫 rootNode.position.y -= Z_OFFSET await animate(rootNode.position, { y: 0, duration: 1000, ease: 'outElastic(1,0.52)', }).then() ``` 移除時則反向沉入地面: ```typescript async function dispose() { await animate(rootNode.position, { y: -Z_OFFSET, duration: 600, ease: 'inBack', }).then() rootNode.dispose() smoothParticleSystem?.dispose() scope.stop() } ``` `outElastic` 讓積木「咚」一聲彈出來,`inBack` 則讓積木稍微往上抬再沉下去,提供回饋感。%(・∀・)9% ## 旋轉互動 點擊已放置的積木可以旋轉 60°,搭配彈跳效果: ```typescript // 旋轉 animatingBlockSet.add(pickedKey) const duration = 800 animate(block.rootNode.rotation, { y: block.rootNode.rotation.y + Math.PI / 3, duration, ease: 'inOutCirc', }) animate(block.rootNode.position, { y: [0, 0.2, 0], duration, ease: 'inOutBack(3)', onComplete() { animatingBlockSet.delete(pickedKey) }, }) ``` 旋轉的同時積木會稍微抬起再放下,`animatingBlockSet` 則防止動畫期間重複觸發。 ## 滑鼠偵測與 Ray-casting Babylon.js 的 `scene.pick` 方法透過射線投射來判斷滑鼠點到了哪個 mesh。 我們需要兩組偵測器:一組用於已放置的積木(旋轉/移除),一組用於候選格(放置)。 ```typescript // 偵測已放置的積木 scene.onPointerObservable.add(async (info) => { const pick = scene.pick(scene.pointerX, scene.pointerY, (mesh) => { const key = hexMeshMetadata(mesh)?.hex.key() return key ? props.placedBlockMap.has(key) : false }) // ... 處理旋轉或移除 }) // 偵測候選格 scene.onPointerObservable.add((info) => { const pick = scene.pick(scene.pointerX, scene.pointerY, (mesh) => { const key = hexMeshMetadata(mesh)?.hex.key() if (!key || props.placedBlockMap.has(key)) return false return candidateTileMap.has(key) || selectedTileSet.has(key) }) // ... 處理 hover 和選取 }) ``` 透過 `pick` 的 filter callback 可以精確控制哪些 mesh 會被偵測到。 ## 後製效果管線 讓畫面更有質感的最後一步: ```typescript const pipeline = new DefaultRenderingPipeline('hexazenPipeline', true, scene, [ camera, ]) pipeline.fxaaEnabled = true pipeline.samples = 4 // 景深 pipeline.depthOfFieldEnabled = true pipeline.depthOfFieldBlurLevel = DepthOfFieldEffectBlurLevel.High pipeline.depthOfField.focalLength = 135 pipeline.depthOfField.fStop = DEFAULT_F_STOP // 色調 pipeline.imageProcessingEnabled = true pipeline.imageProcessing.contrast = 1.1 pipeline.imageProcessing.exposure = 1.1 // 暗角(vignette) pipeline.imageProcessing.vignetteEnabled = true pipeline.imageProcessing.vignetteWeight = DEFAULT_VIGNETTE_WEIGHT ``` 景深的對焦距離會自動跟隨攝影機: ```typescript scene.onBeforeRenderObservable.add(() => { if (pipeline.depthOfFieldEnabled && camera instanceof ArcRotateCamera) { pipeline.depthOfField.focusDistance = camera.radius * 1000 } }) ``` ### 暗角顏色隨主題變化 更有趣的是,暗角的顏色會根據場景中最多的特性類型動態變化: ```typescript const traitVignetteColorMap: Record = { grass: new Color3(0, 0.2, 0), tree: new Color3(0, 0.2, 0), building: new Color3(0.4, 0.2, 0), alpine: new Color3(0, 0, 0), river: new Color3(0, 0.4, 0.4), water: new Color3(0, 0.4, 0.5), // ... } ``` 放滿樹木時暗角泛綠,放滿水域時暗角泛藍,整體視覺氣氛會跟著改變。%◝( •ω• )◟% ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen/main-scene.vue) * PBR 材質 + 鏡面反射打造質感地板 * 三態候選格搭配指數平滑漸變動畫 * 彈性進出動畫讓積木操作充滿回饋感 * Ray-casting 精確偵測滑鼠互動 * 後製效果管線 + 動態暗角色彩提升視覺氣氛 下一章讓我們來實作天氣系統與粒子特效吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/05-weather-and-particles.md description: 下雨了!用 GPU 粒子系統撒下 5 萬顆雨滴,再搭配射線投射讓水花不會浮空。ˋ( ° ▽、° ) --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP05:天氣與粒子系統 一片晴天固然美好,但如果能下場雨就更有感覺了。%ˋ( ° ▽、° )% 這章來實作天氣系統,GPU 粒子雨滴、射線投射水花、以及動態的光照與霧氣變化。%੭ ˙ᗜ˙ )੭% ## 天氣狀態機 天氣有三種模式,透過 `useCycleList` 循環切換: ```typescript const { state: weatherMode, next: nextWeatherMode } = useCycleList< Weather | undefined | 'random-rain' >([ undefined, // 晴天 'rain', // 雨天 'random-rain', // 隨機(每 5 分鐘擲骰子) ]) useIntervalFn( () => { if (weatherMode.value === 'random-rain') { currentWeather.value = Math.random() < 0.5 ? 'rain' : undefined } }, 1000 * 60 * 5, ) ``` 隨機降雨模式每 5 分鐘擲一次骰子,50% 機率下雨。 ## GPU 粒子系統:雨滴 雨滴使用 `GPUParticleSystem`,容量 50,000 顆粒子,比 CPU 版本高效得多。 ```typescript function createRainSystem(scene: Scene) { if (!GPUParticleSystem.IsSupported) { console.warn('此裝置不支援 GPU 粒子系統') return } const particleSystem = new GPUParticleSystem( 'rain_system', { capacity: 50000 }, scene, ) // 動態繪製雨滴紋理:一條半透明的漸層直線 const dropTexture = new DynamicTexture( 'drop_tex', { width: 1, height: 20 }, scene, false, ) const ctx = dropTexture.getContext() const gradient = ctx.createLinearGradient(0, 0, 0, 32) gradient.addColorStop(0, 'rgba(255, 255, 255, 0)') gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.6)') gradient.addColorStop(1, 'rgba(255, 255, 255, 0)') ctx.fillStyle = gradient ctx.fillRect(0, 0, 4, 32) dropTexture.update() particleSystem.particleTexture = dropTexture ``` 紋理不是用圖片檔,而是用 `DynamicTexture` 動態繪製一條上下漸隱的白線,看起來就像細細的雨絲。 ### 發射器設定 ```typescript const emitter = new BoxParticleEmitter() emitter.direction1 = new Vector3(0, -1, 0) emitter.direction2 = new Vector3(0, -1, 0) emitter.minEmitBox = new Vector3(-5, 4, -5) emitter.maxEmitBox = new Vector3(5, 4, 5) particleSystem.particleEmitterType = emitter particleSystem.emitter = Vector3.Zero() particleSystem.billboardMode = ParticleSystem.BILLBOARDMODE_STRETCHED particleSystem.color1 = new Color4(0.8, 0.8, 0.9, 0.2) particleSystem.color2 = new Color4(0.6, 0.7, 0.8, 0.3) particleSystem.minSize = 0.01 particleSystem.maxSize = 0.01 particleSystem.minScaleY = 5.0 particleSystem.maxScaleY = 5.0 particleSystem.emitRate = 2000 particleSystem.gravity = new Vector3(0, -1, 0) ``` 重點: * `BILLBOARDMODE_STRETCHED` 讓粒子沿移動方向拉伸,呈現雨絲效果 * `minScaleY = 5.0` 把每顆粒子拉成細長的雨滴形狀 * 從 10×10 的方形區域往下垂直降落 ## CPU 粒子系統:水花 雨滴打到地面或積木上會濺起水花。這裡用 CPU 粒子系統,因為需要 ray-cast 來決定水花出生位置。 ```typescript function createSplashSystem(scene: Scene) { const splashSystem = new ParticleSystem( 'splash_system', 10000, scene, ) // 圓形水花紋理 const splashTexture = new DynamicTexture( 'splash_tex', { width: 32, height: 32 }, scene, false, ) const ctx = splashTexture.getContext() const gradient = ctx.createRadialGradient(16, 16, 0, 16, 16, 16) gradient.addColorStop(0, 'rgba(200, 220, 240, 0.8)') gradient.addColorStop(1, 'rgba(200, 220, 240, 0)') ctx.fillStyle = gradient ctx.fillRect(0, 0, 32, 32) splashTexture.update() splashSystem.particleTexture = splashTexture ``` ### 射線投射定位 這是水花系統最精巧的部分,用射線投射來找到地面或積木的表面位置。 ```typescript splashSystem.startPositionFunction = ( _worldMatrix, positionToUpdate, ) => { const randomX = Math.random() * 10 - 5 const randomZ = Math.random() * 10 - 5 // 從高空垂直向下發射射線 const rayStart = new Vector3(randomX, 5, randomZ) const rayDir = new Vector3(0, -1, 0) const ray = new Ray(rayStart, rayDir, 30) // 偵測射線打到了場景中的哪個表面 const hit = scene.pickWithRay(ray, (mesh) => { return mesh.isVisible && mesh.isPickable }) if (hit && hit.hit && hit.pickedPoint) { positionToUpdate.copyFrom(hit.pickedPoint) positionToUpdate.y += 0.01 // 稍微抬高避免被模型吃掉 } else { // 沒打到東西就藏到畫面外 positionToUpdate.copyFromFloats(randomX, -100, randomZ) } } return splashSystem } ``` `startPositionFunction` 覆寫了粒子的出生位置。每顆水花粒子出生前,先從空中往下射一條光線,打到什麼表面就在那裡濺起水花。 這樣水花就會精確地出現在積木頂部、地面上,而不會浮在空中。%ヽ(●\`∀´●)ノ% ## 天氣轉場 切換天氣時,光照和霧氣也要配合變化,使用 3 秒動畫做平滑過渡。 ```typescript watch( () => ({ isRain: props.weather === 'rain', scene: scene.value, }), ({ isRain, scene: sceneValue }) => { if (!sceneValue) return // 粒子系統啟停(水花延遲 3 秒) if (isRain) { rainParticleSystem.value?.start() promiseTimeout(3000).then(() => { splashParticleSystem.value?.start() }) } else { rainParticleSystem.value?.stop() promiseTimeout(3000).then(() => { splashParticleSystem.value?.stop() }) } // 光照漸變 const shadowLight = shadowGenerator.value?.getLight() if (shadowLight) { animate(shadowLight, { intensity: isRain ? 0.01 : 0.8, duration: 3000, }) } // 霧氣漸變 animate(sceneValue, { fogStart: isRain ? 1 : 10, fogEnd: isRain ? 30 : 100, duration: 3000, }) }, ) ``` 下雨時的變化: | 屬性 | 晴天 | 雨天 | | -------- | ---- | ---- | | 光照強度 | 0.8 | 0.01 | | 霧氣起始 | 10 | 1 | | 霧氣結束 | 100 | 30 | 光照幾乎關閉、霧氣逼近,整個場景瞬間變得昏暗朦朧。 水花延遲 3 秒才開始,因為雨滴需要時間「落下來」。%( •̀ ω •́ )✧% ## 營火煙霧:天氣連動 營火積木有煙霧粒子效果,但下雨時火被澆熄了,煙霧也要停止: ```typescript const scope = effectScope() scope.run(() => { watch(weather, (value) => { if (value === 'rain') { smoothParticleSystem?.stop() } else { smoothParticleSystem?.start() } }) }) ``` 使用 Vue 的 `effectScope` 確保清理時不會留下殘餘的 watcher,此概念源自[此文章](/blog-vue/vue-watch-may-cause-a-memory-leak)。 ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen) * GPU 粒子系統高效渲染 5 萬顆雨滴 * `DynamicTexture` 動態繪製紋理,不需額外圖片 * 射線投射讓水花精確定位在表面上 * 3 秒轉場動畫確保天氣切換流暢自然 * 營火煙霧與天氣連動,注重細節 下一章讓我們來看積木選擇器與縮圖生成機制吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/06-block-picker-and-thumbnail.md description: 30 多種積木怎麼讓使用者挑選?用無頭 Babylon.js 場景產生即時縮圖吧!ヾ(◍'౪`◍)ノ゙ --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP06:積木選擇器與縮圖生成 HexaZen 有 30 多種積木,光靠文字名稱選擇太無趣了,這裡需要每個積木的 3D 預覽縮圖。 但 30 多張圖片手動截圖太累了,而且每次微調積木就要重做。%...(›´ω\`‹ )% 來做個自動縮圖產生器吧!%( ´ ▽ \` )ノ% ## 多執行緒縮圖生成器 核心概念是建立多個隱藏的 canvas,每個 canvas 都有獨立的 Babylon.js 引擎,像產線一樣平行生成縮圖。 ### 決定並行數 首先根據裝置性能決定要啟動幾個「產線」: `content\aquarium\hexazen\composables\use-thumbnail-generator.ts` ```typescript function getOptimalConcurrency(): number { if (typeof window === 'undefined' || !window.navigator) { return 2 } const cores = navigator.hardwareConcurrency || 4 const memory = (navigator as any).deviceMemory || 4 let optimal = 2 if (cores >= 8 && memory >= 8) { optimal = 6 } else if (cores >= 4 && memory >= 4) { optimal = 4 } else { optimal = 2 } return Math.min(optimal, 6) } ``` 8 核 + 8GB 記憶體以上開 6 個,4 核開 4 個,其他開 2 個。上限 6 個避免吃太多資源。 ### 建立隱藏的產線 每個產線都是一個完整的 Babylon.js 場景: ```typescript function createGenerator() { const queue = new PQueue({ concurrency: 1 }) const canvas = document.createElement('canvas') canvas.style.visibility = 'hidden' canvas.width = 128 canvas.height = 128 document.body.appendChild(canvas) const engine = new Engine(canvas, true) const scene = new Scene(engine) scene.clearColor = new Color4(0, 0, 0, 0) // 透明背景 const camera = new ArcRotateCamera( 'camera', Math.PI / 4, Math.PI / 4, 5, Vector3.Zero(), scene, ) camera.useFramingBehavior = true if (camera.framingBehavior) { camera.framingBehavior.mode = FramingBehavior.FitFrustumSidesMode camera.framingBehavior.radiusScale = 1 camera.framingBehavior.framingTime = 0 } const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene) // ... } ``` 128×128 的小 canvas,透明背景,`FramingBehavior` 會自動將模型置中並調整鏡頭距離。 ### 縮圖生成流程 ```typescript function generateThumbnail( blockType: BlockType, options?: { signal?: AbortSignal }, ) { return queue.add( async () => { const blockData = blockDefinitions[blockType] // 載入所有部件 const tasks = blockData.content.partList.map(async (part) => { const modelPath = `${blockData.content.rootFolderName}/${part.path}` const model = await LoadAssetContainerAsync(modelPath, scene) const root = model.meshes[0] if (root) { root.position.copyFrom(Vector3.FromArray(part.position)) root.rotationQuaternion?.copyFrom( Quaternion.FromArray(part.rotationQuaternion), ) root.scaling.copyFrom(Vector3.FromArray(part.scaling)) } return model }) const containerList = await Promise.all(tasks) // 清除上一個模型,載入新模型 clearLastContainer() lastContainerList = containerList containerList.forEach((c) => c.addAllToScene()) // 自動調整鏡頭框住所有 mesh const meshes = containerList .flatMap((c) => c.meshes) .filter((m) => m.getTotalVertices() > 0) camera.framingBehavior?.zoomOnMeshesHierarchy(meshes, true) // 渲染一幀並截圖 engine.runRenderLoop(() => scene.render()) await scene.whenReadyAsync(true) camera.beta = Math.PI / 4 camera.alpha = Math.PI / 4 const base64 = await Tools.CreateScreenshotAsync(engine, camera, { width: 128, height: 128, }) engine.stopRenderLoop() const res = await fetch(base64) return await res.blob() }, { signal }, ) } ``` 流程為:載入模型 → 自動框住 → 渲染 → 截圖 → 轉 Blob。 這裡使用 [`p-queue`](https://www.npmjs.com/package/p-queue) 套件處理任務排隊,每個產線一次只處理一個任務(`concurrency: 1`),但多個產線可以同時工作。 ### Round-Robin 分配 任務以輪詢方式分配給產線: ```typescript function generateThumbnail(blockType: BlockType) { const generator = generatorList[index] index = (index + 1) % concurrency return generator.generateThumbnail(blockType) } ``` ## IndexedDB 快取 生成縮圖很耗時,不能每次開頁面都重新產生。用 IndexedDB 快取起來: `content\aquarium\hexazen\domains\block\block-picker.vue` ```typescript const blockThumbnailList = computedAsync(async () => { const tasks = blockTypeList.map(async (blockType) => { const key = `hexazen-${version}-block-thumbnail-${blockType}` // 先查快取 const cache = await get(key) if (cache) { return { type: blockType, thumbnail: URL.createObjectURL(cache), } } // 快取沒有,即時生成 const imgBlob = await generateThumbnail(blockType) await set(key, imgBlob) return { type: blockType, thumbnail: URL.createObjectURL(imgBlob), } }) return Promise.all(tasks) }, []) ``` 快取的 key 包含 `version`,版本更新時自動失效重新生成。 離開頁面時也要記得清理 `ObjectURL`: ```typescript onBeforeUnmount(() => { blockThumbnailList.value.forEach((item) => { URL.revokeObjectURL(item.thumbnail) }) }) ``` ## 積木選擇器 UI 有了縮圖,UI 就簡單了。使用輪播元件搭配倒角邊框風格: ```vue ``` `drag-free` 讓使用者可以自由拖拽瀏覽,手機上也很順暢。%◝(≧∀≦)◟% ## `createSharedComposable` 單例化 縮圖生成器在整個應用中只需要一個實例,使用 VueUse 的 `createSharedComposable`: ```typescript export const useThumbnailGenerator = createSharedComposable( _useThumbnailGenerator, ) ``` 不管幾個元件呼叫 `useThumbnailGenerator()`,都會拿到同一個實例,共享同一批產線。 ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen) * 多執行緒無頭 Babylon.js 場景自動產生 3D 縮圖 * 根據裝置性能動態調整並行數 * IndexedDB 快取避免重複生成 * `createSharedComposable` 確保全域單例 * 輪播 UI 支援拖拽瀏覽 下一章讓我們來看分享編碼與 URL 壓縮的實作吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/07-share-codec.md description: 每個積木只佔 25 bits,用 Bit-packing 把整個場景塞進一條短短的 URL 裡。(/≧▽≦)/ --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP07:分享編碼與 URL 壓縮 我希望分享連結即可讓他人聽到你設計的自然音效,但我不想登入也不想有後端,這代表場景資料要塞進 URL 裡了。%◝( •ω• )◟% JSON 太長了,Base64 編碼 JSON 也不夠精簡,所以這裡採用位元打包(Bit-packing)格式。%੭ ˙ᗜ˙ )੭% ## 編碼格式設計 ### 每個積木需要什麼資訊? | 欄位 | 說明 | 範圍 | 位元數 | | -------- | ------------------------- | --------------- | ------- | | type | 積木種類(如 "g1"、"b7") | 2 個 ASCII 字元 | 16 bits | | q | 六角格 q 座標 | \[-3, 4] | 3 bits | | r | 六角格 r 座標 | \[-3, 4] | 3 bits | | rotation | 旋轉步數 | \[0, 7] | 3 bits | 每個積木只需要 **25 bits**! ### 完整格式 ``` [version: 4 bits][block₁: 25 bits][block₂: 25 bits]... ``` 最前面 4 bits 是版本號,方便未來擴展。目前是 v2。 ## BitWriter 與 BitReader 以 MSB-first 方式讀寫任意寬度的位元。 `content\aquarium\hexazen\domains\share\codec\index.ts` ```typescript class BitWriter { private buffer: number[] = [] private currentByte = 0 private bitPosition = 0 /** 寫入 width 個位元(MSB-first) */ write(value: number, width: number): void { for (let i = width - 1; i >= 0; i--) { this.currentByte = (this.currentByte << 1) | ((value >> i) & 1) this.bitPosition++ if (this.bitPosition === 8) { this.buffer.push(this.currentByte) this.currentByte = 0 this.bitPosition = 0 } } } /** 結束寫入,將剩餘位元補 0 後輸出 */ finish(): Uint8Array { if (this.bitPosition > 0) { this.buffer.push(this.currentByte << (8 - this.bitPosition)) } return new Uint8Array(this.buffer) } } ``` `BitReader` 則是反向操作,從 `Uint8Array` 中逐位元讀取。 ## 編碼:場景 → URL ```typescript export function encodeBlocks(blocks: Iterable): string { const blockArray = [...blocks] if (blockArray.length === 0) return '' const writer = new BitWriter() // 版本號 writer.write(FORMAT_VERSION, BITS_VERSION) for (const block of blockArray) { // type: 2 個 ASCII 字元,各 8 bits writer.write(block.type.charCodeAt(0), BITS_CHAR) writer.write(block.type.charCodeAt(1), BITS_CHAR) // 座標加偏移量,將 [-3, 3] 映射到 [0, 6] const encodedQ = block.hex.q + COORDINATE_OFFSET const encodedR = block.hex.r + COORDINATE_OFFSET writer.write(encodedQ, BITS_COORDINATE) writer.write(encodedR, BITS_COORDINATE) // 旋轉 writer.write(block.rotation, BITS_ROTATION) } return uint8ArrayToBase64url(writer.finish()) } ``` 座標使用固定偏移量,就可以避免資料出現負數,例如:`q + 3` 把 \[-3, 3] 轉成 \[0, 6],剛好可以用 3 bits 表示。 最後透過 Base64url 編碼確保 URL 安全: ```typescript function uint8ArrayToBase64url(bytes: Uint8Array): string { let binary = '' for (const byte of bytes) { binary += String.fromCharCode(byte) } return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } ``` 標準 Base64 的 `+`、`/`、`=` 在 URL 中有特殊含義,替換成 `-`、`_` 並去掉 padding。 ## 解碼:URL → 場景 ```typescript function decodeBinaryBlocks(encoded: string): SharedBlock[] { const bytes = base64urlToUint8Array(encoded) const reader = new BitReader(bytes) const version = reader.read(BITS_VERSION) if (version !== FORMAT_VERSION) { throw new TypeError(`不支援的版本號: ${version}`) } const totalBits = bytes.length * 8 const dataBits = totalBits - BITS_VERSION const blockCount = Math.floor(dataBits / BITS_PER_BLOCK) const blocks: SharedBlock[] = [] for (let i = 0; i < blockCount; i++) { const char1 = String.fromCharCode(reader.read(BITS_CHAR)) const char2 = String.fromCharCode(reader.read(BITS_CHAR)) const blockType = `${char1}${char2}` as BlockType if (!validBlockTypes.has(blockType)) { throw new TypeError(`無效的 BlockType: "${blockType}"`) } const q = reader.read(BITS_COORDINATE) - COORDINATE_OFFSET const r = reader.read(BITS_COORDINATE) - COORDINATE_OFFSET const rotation = reader.read(BITS_ROTATION) blocks.push({ type: blockType, hex: Hex.fromAxial(q, r), rotation, }) } return blocks } ``` 根據 `totalBits` 和 `BITS_PER_BLOCK` 可以推算出有幾個積木,不需要額外儲存數量。 ## 分享與還原流程 ### 分享:編碼 → 複製連結 ```typescript async function handleShare() { const sharedBlocks = Array.from(placedBlockMap.values()).map((block) => { const rotationStep = Math.round(block.rootNode.rotation.y / (Math.PI / 3)) const rotation = ((rotationStep % 6) + 6) % 6 return { type: block.type, hex: block.hex, rotation, } }) const encoded = encodeBlocks(sharedBlocks) const url = new URL(window.parent.location.href) url.searchParams.set('view', encoded) await navigator.clipboard.writeText(url.toString()) toast.add({ title: t('linkCopied'), description: t('shareLinkDescription'), }) } ``` 旋轉角度從弧度轉回步數(六角形嘛,轉 6 次就是一圈了,所以步數為 0~5),確保 `((step % 6) + 6) % 6` 處理負數旋轉。 ### 還原:載入分享連結 ```typescript async function restoreSharedView() { if (!sharedViewEncodedData) return isRestoringView.value = true try { const sharedBlocks = decodeBlocks(sharedViewEncodedData) const tasks = sharedBlocks.map(({ type, hex, rotation }) => { return mainSceneRef.value!.spawnBlock(type, hex).then((block) => { block.rootNode.rotation.y = rotation * (Math.PI / 3) }) }) await Promise.all(tasks) } catch (error) { toast.add({ title: t('restoreFailed'), description: t('linkInvalid'), color: 'error', }) } finally { // 至少顯示 3 秒 loading 畫面 await promiseTimeout(Math.max(3000, new Date().getTime() - startedAt)) isRestoringView.value = false } } ``` `promiseTimeout` 確保 loading 畫面至少顯示 3 秒,避免一閃而過造成使用者困惑。 還原後的檢視模式會自動開啟攝影機自動旋轉,讓使用者可以悠閒地欣賞音景。%(´,,•ω•,,)% ## 效率比較 假設放了 10 個積木: | 格式 | 大小 | | ----------------------- | ---------- | | JSON | ~500 bytes | | Bit-packing + Base64url | ~43 bytes | 壓縮率超過 90%!URL 短而美觀。%(/≧▽≦)/% ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen/domains/share) * Bit-packing 將每個積木壓縮至 25 bits * Base64url 確保 URL 安全 * `BitWriter` / `BitReader` 實現任意位元寬度的讀寫 * 版本號機制方便未來擴展格式 * 分享連結包含完整場景資訊,不需後端 下一章讓我們來看 UI 設計與國際化的實作吧。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/08-ui-and-i18n.md description: 倒角邊框、聲景混音器、還有不用 vue-i18n 的輕量國際化方案。੭ ˙ᗜ˙ )੭ --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP08:UI 設計與國際化 3D 場景和音效引擎都搞定了,最後要讓整個應用的 UI 既好看又好用。 HexaZen 跑在 VitePress 的 whyframe iframe 裡,所以 `vue-i18n` 無法正常運作。 沒關係,那就自己做一個。%ᕕ( ゚ ∀。)ᕗ% ## 倒角邊框元件 HexaZen 的 UI 風格不用圓角,而是用「倒角(chamfer)」,像寶石一樣的多邊形切割。 `content\aquarium\hexazen\components\chamfer-border-card.vue` ```vue ``` 外層一個帶色的 chamfer 框,內層一個白色的 chamfer 框,用 padding 差異製造邊框效果。 ### 倒角按鈕 按鈕也用 `clip-path` 做出八邊形倒角: ```typescript const style = computed(() => { const size = height.value / 2 const size2 = (size / 3) * 2 return { paddingInline: `${size * 1.1}px`, clipPath: `polygon( ${size2}px 0, calc(100% - ${size2}px) 0, 100% ${size}px, 100% calc(100% - ${size}px), calc(100% - ${size2}px) 100%, ${size2}px 100%, 0 calc(100% - ${size}px), 0 ${size}px )`, } }) ``` 用 `useElementSize` 動態取得按鈕高度,讓倒角比例自動適配不同大小的按鈕。%( •̀ ω •́ )✧% ## 聲景混音器 每個音效都可以獨立調整音量,透過 Modal 呈現: ```vue ``` 每個音效類型都有專屬 icon: ```typescript const soundscapeIconMap: Record = { rustle: 'i-mingcute:leaf-fill', insect: 'i-mingcute:bug-fill', bird: 'i-material-symbols:raven', frog: 'i-fa7-solid:frog', beast: 'i-mingcute:cat-fill', river: 'i-material-symbols:water', building: 'i-solar:buildings-2-bold', ocean: 'i-lucide-lab:waves-birds', alpine: 'i-mynaui:mountain-snow-solid', rain: 'i-material-symbols:rainy', campfire: 'i-mingcute:campfire-fill', } ``` 音量範圍 0~2,也就是最大可以放大到原始音量的 2 倍。 只顯示目前正在播放(未被靜音)的音效,避免混亂。%ԅ(´∀\` ԅ)% ## 公告欄 Modal 首次開啟會顯示歡迎頁面,包含 Intro 和 Quick Start 兩個分頁。 Quick Start 使用輪播展示操作步驟,搭配影片和圖片: ```typescript const quickStartItems = computed(() => { const descriptionList = t('start') const imgList = t('startImg') return descriptionList.map((text, index) => ({ img: imgList[index] || '', description: text, })) }) ``` 有趣的是文字內容和圖片路徑都放在 i18n 翻譯檔中,可以根據語言顯示不同的教學內容。 ## 輕量 i18n:不用 vue-i18n 的方案 因為 whyframe 環境的限制,只好自己做了輕量版 i18n composable。 `content\aquarium\hexazen\composables\use-simple-i18n.ts` ```typescript export function useSimpleI18n< Messages extends Record, DefaultLang extends 'zh-hant' | 'en', Data = Messages[DefaultLang], >( messages: Messages & StrictCheck, options?: { defaultLocale?: DefaultLang; }, ) { const currentLocale = ref( options?.defaultLocale ?? Object.keys(messages)[0], ) as Ref const languages = usePreferredLanguages() watch( languages, ([lang]) => { currentLocale.value = ( lang?.includes('zh') ? 'zh-hant' : 'en' ) as DefaultLang }, { immediate: true }, ) function t( key: Key, params?: Record, ): Data[Key] extends string[] ? string[] : string { const value = getMessage(key) as any if (Array.isArray(value)) return value if (typeof value === 'string' && params) { let text = value Object.entries(params).forEach(([k, v]) => { text = text.replace(new RegExp(`{${k}}`, 'g'), String(v)) }) return text } return value } return { locale: currentLocale, setLocale, t } } ``` ### 型別安全的嚴格檢查 最精華的部分是 TypeScript 型別層面的檢查: ```typescript type StrictCheck = { [L in keyof Messages]: { [K in KeyUniverse]: CommonType; }; } ``` `StrictCheck` 確保**所有語言都必須有完全相同的 key 集合**。 如果中文多了一個 key 但英文沒有,TypeScript 編譯時就會報錯。不用等到運行時才發現遺漏翻譯。%╰(*´︶\`*)╯% ### 特色 * 自動偵測瀏覽器語言(`usePreferredLanguages`) * 支援字串插值(`{name}` 參數替換) * 支援字串陣列(用於教學步驟等清單) * 編譯時期型別檢查,缺漏翻譯直接報錯 * 不到 100 行程式碼,零外部依賴 ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen) * 倒角邊框風格統一整個應用的視覺語言 * 聲景混音器提供精細的個別音量控制 * 公告欄兼具歡迎頁和操作教學 * 自製輕量 i18n,編譯時期保證翻譯完整性 * 自動偵測瀏覽器語言,零設定 下一章是最後一篇,讓我們來聊聊 WebGPU 與效能優化。%♪( ◜ω◝و(و% ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /column-hexazen/09-webgpu-and-performance.md description: 最終章!WebGPU 優先策略、智慧鏡頭限制、還有各種讓體驗更流暢的小細節。✧⁑。٩(ˊᗜˋ*)و✧⁕。 --- ![hexazen-cover](/hexazen-cover.webp){.cover} # HexaZen - EP09:WebGPU 與效能優化 最後一章,來聊聊那些讓 HexaZen 跑得更順暢的技術細節。 從渲染引擎的選擇到鏡頭限制,這些看似微小的優化加在一起,就是「流暢」和「卡頓」的差別。%੭ ˙ᗜ˙ )੭% ## WebGPU 優先,WebGL 降級 Babylon.js 支援 WebGPU,效能比 WebGL 好不少。優先使用 WebGPU,不支援的裝置自動降級。 `content\aquarium\hexazen\composables\use-babylon-scene.ts` ```typescript async createEngine({ canvas }) { const webGPUSupported = await WebGPUEngine.IsSupportedAsync if (webGPUSupported) { const engine = new WebGPUEngine(canvas, { antialias: true, stencil: true, }) await engine.initAsync() return engine } return new Engine(canvas, true, { antialias: true, alpha: false, stencil: true, }) }, ``` `WebGPUEngine.IsSupportedAsync` 是非同步檢測,因為瀏覽器需要時間確認 GPU 支援狀態。 ## 場景初始化 `useBabylonScene` composable 封裝了完整的場景生命週期: ```typescript export function useBabylonScene(param?: UseBabylonSceneParam) { const canvasRef = ref() const engine = shallowRef() const scene = shallowRef() const camera = shallowRef() onMounted(async () => { if (!canvasRef.value) return engine.value = await createEngine({ canvas: canvasRef.value, }) // 適配高 DPI 螢幕 engine.value.setHardwareScalingLevel(1 / (window?.devicePixelRatio ?? 1)) scene.value = createScene({ canvas: canvasRef.value, engine: engine.value, }) camera.value = createCamera({ canvas: canvasRef.value, engine: engine.value, scene: scene.value, }) window.addEventListener('resize', handleResize) engine.value.runRenderLoop(() => { scene.value?.render() }) await init({ canvas, engine, scene, camera }) setupSmartCameraLimits(engine, camera, scene) }) onBeforeUnmount(() => { engine.value?.dispose() scene.value?.dispose() window.removeEventListener('resize', handleResize) }) return { canvasRef, engine, scene, camera } } ``` `setHardwareScalingLevel` 根據 `devicePixelRatio` 調整渲染解析度,Retina 螢幕上會更清晰。 ### 預設光照與霧氣 ```typescript createScene({ engine }) { const scene = new Scene(engine) scene.createDefaultLight() const defaultLight = scene.lights.at(-1) if (defaultLight instanceof HemisphericLight) { defaultLight.diffuse = new Color3(1.0, 0.98, 0.95) defaultLight.direction = new Vector3(0.5, 1, 0) defaultLight.groundColor = new Color3(0.64, 0.56, 0.78) } scene.clearColor = new Color4(0.97, 0.97, 0.96, 1) scene.fogMode = Scene.FOGMODE_LINEAR scene.fogColor = new Color3(0.55, 0.6, 0.65) scene.fogStart = 10 scene.fogEnd = 100 return scene }, ``` 暖色光源搭配偏紫的地面反射光,讓積木看起來有柔和的手繪感。 ## 智慧鏡頭限制 鏡頭需要根據裝置螢幕比例自動調整: ```typescript function setupSmartCameraLimits( engine: BabylonEngine, camera: ArcRotateCamera, scene: Scene, ) { const width = engine.getRenderWidth() const height = engine.getRenderHeight() // 直式螢幕使用垂直 FOV,橫式使用水平 FOV if (width < height) { camera.fovMode = Camera.FOVMODE_VERTICAL_FIXED } else { camera.fovMode = Camera.FOVMODE_HORIZONTAL_FIXED } const finalRadius = 4 const safeDistance = finalRadius / Math.sin(camera.fov / 2) camera.lowerRadiusLimit = safeDistance * 0.2 camera.upperRadiusLimit = safeDistance * 1 camera.minZ = finalRadius * 0.1 } ``` 手機直式螢幕和電腦橫式螢幕的 FOV 模式不同,這樣積木在任何裝置上都能完整顯示在畫面中。 ### 鏡頭約束 ```typescript createCamera({ scene, canvas }) { const camera = new ArcRotateCamera( 'camera', 0, Math.PI / 3 * 2, 5, new Vector3(0, 0, 0), scene, ) camera.attachControl(canvas, true) // 禁止平移,只能旋轉和縮放 camera.panningSensibility = 0 camera.wheelDeltaPercentage = 0.01 // 限制仰角,永遠從上方俯瞰 camera.lowerBetaLimit = Math.PI / 3 camera.upperBetaLimit = Math.PI / 3 return camera }, ``` 禁止平移(`panningSensibility = 0`)和固定仰角(`beta` 鎖定在 60°),讓使用者只能水平旋轉和縮放,保持最佳觀賞角度。 ## 模型材質優化 載入 GLB 模型時,強制使用三線性過濾避免紋理閃爍: ```typescript model.meshes.forEach((mesh) => { if (mesh.material instanceof PBRMaterial) { mesh.material.metallic = 0 mesh.material.roughness = 0.4 const texture = mesh.material.albedoTexture if (texture instanceof Texture) { texture.updateSamplingMode(Texture.TRILINEAR_SAMPLINGMODE) } } }) ``` `TRILINEAR_SAMPLINGMODE` 讓紋理邊緣被柔化,旋轉時不會有像素閃爍。 一開始看到的時候真的是快被閃瞎眼。%(◞‸◟ )% ## 陰影生成 ```typescript function createShadowGenerator(scene: Scene) { const light = new DirectionalLight('dir01', new Vector3(-3, -5, -2), scene) light.intensity = 0.8 const shadowGenerator = new ShadowGenerator(2048, light) shadowGenerator.bias = 0.000001 shadowGenerator.normalBias = 0.0001 shadowGenerator.forceBackFacesOnly = true return shadowGenerator } ``` 2048×2048 的陰影貼圖確保陰影邊緣銳利。`forceBackFacesOnly = true` 防止自身陰影產生的瑕疵(shadow acne)。 ## 整體架構回顧 經過 9 篇文章,HexaZen 的架構如下: ``` hexazen/ ├── hexazen.vue # 應用根元件(狀態管理、UI 佈局) ├── main-scene.vue # 3D 場景(互動、粒子、後製) ├── composables/ │ ├── use-babylon-scene # 引擎初始化與生命週期 │ ├── use-thumbnail-gen # 多執行緒縮圖生成 │ ├── use-simple-i18n # 輕量國際化 │ └── use-font-loader # 字體動態載入 └── domains/ ├── block/ # 積木定義、建立、縮圖 ├── hex-grid/ # 六角格數學 ├── soundscape/ # 聲景規則、播放引擎、混音器 └── share/codec/ # URL 編碼/解碼 ``` ## 總結 🐟 以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen) * WebGPU 優先、WebGL 自動降級 * 高 DPI 螢幕適配與智慧鏡頭限制 * 三線性過濾消除紋理閃爍 * 2048×2048 高品質陰影 感謝看到這裡的你!希望這系列文章能給你一些靈感。%ヾ(◍'౪\`◍)ノ゙% 如果你也想體驗看看,歡迎到 [HexaZen](https://codlin.me/aquarium/hexazen/) 玩玩看,打造屬於你自己的音景吧! ::: tip 因為篇幅問題,某些部分簡單帶過。 若想看到更詳細的解釋,請不吝留言或寫信給我喔!%(\*´∀\`)~♥% ::: --- --- url: /blog-vue/js-util-vs-vue-composable.md description: Vue composable,比一般的 JS function 多了些約定,讓我們來看看吧。(´・ω・`) --- ![js-util-vs-vue-composable](/js-util-vs-vue-composable.webp){.cover} # JS Utility Function 和 Vue Composable,傻傻分不清楚 熟悉 Vue 3 的朋友們應該都用過 Composition API 寫過 composable 吧? 之前聽過有人問:「為什麼我要寫個 `useXxx` 的 composable,而不是直接寫個普通的 util function?」 其實 utility function 和 composable 本質上的確都是 function,但 composable 有一些重要的約定。 今天就來聊聊這兩者的差異,這也是目前 AI 最常犯的錯誤。%ლ(╹ε╹ლ)% ## Utility Function 就是純 JS function 先來看最單純的情況。utility function 就是一般的 JavaScript function,負責做資料轉換、格式化、驗證等等純粹的運算。 例如: ```ts export function formatDate(date: Date) { return new Intl.DateTimeFormat('zh-TW').format(date) } export function formatCurrency(amount: number) { return new Intl.NumberFormat('zh-TW', { style: 'currency', currency: 'TWD', }).format(amount) } export function validateEmail(email: string) { return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email) } ``` 這類 function 有幾個特點: * **沒有狀態**:輸入相同,輸出一定相同 * **不依賴 Vue**:拿去 Node.js、React 裡面用也完全沒問題 * **隨時隨地都能呼叫**:在 `setup`、`onMounted`、`watch`、甚至 `setTimeout` 裡面呼叫都可以 ## Composable 不只是 function composable 雖然也是 function,但它會利用 Composition API(`ref`、`onMounted` 等)來封裝有狀態的邏輯。 例如以下是一個偵測視窗大小的 composable: ```ts import { onMounted, onUnmounted, ref } from 'vue' export function useWindowSize() { const width = ref(window.innerWidth) const height = ref(window.innerHeight) function update() { width.value = window.innerWidth height.value = window.innerHeight } onMounted(() => window.addEventListener('resize', update)) onUnmounted(() => window.removeEventListener('resize', update)) return { width, height } } ``` 這時候你可能會想問:「Vue 的響應式系統本來就可以獨立運作,就算不是 Vue 也可以用,所以分界線在哪?」 關鍵點是 composable 內部可能使用 `onMounted`、`onUnmounted` 等 lifecycle hook 或 `watch`、`computed` 等 effect,這些 API 必須在**元件實例的同步流程**中才能正確註冊並自動清除。 ## Composable 必須在同步流程中呼叫 Vue 在執行 ` ``` 因為 ` ``` `watch` callback、`setTimeout`、`Promise.then`、`await` 之後的程式碼,都不在元件實例的同步呼叫堆疊中,lifecycle hook 或 effect 無法正確註冊,自動清除機制就會壞掉。 意思就是在非同步流程中使用 composable 可能會導致 memory leak!%(́⊙◞౪◟⊙‵)% ## Utility Function 可以隨便放 相比之下,utility function 沒有這些限制,因為它們根本不碰 Vue 的響應式系統和生命週期。 ```vue ``` 畢竟就是普通的 function,沒有副作用,也不依賴 Vue 的元件實例。 ## 不要把 Utility 包成 Composable 有時候會看到有人把明明是 utility 的東西,硬是包成 composable 的格式: ```ts // ✗ 不需要這樣做 export function useFormatters() { const formatDate = (date: Date) => { return new Intl.DateTimeFormat('zh-TW').format(date) } const formatCurrency = (amount: number) => { return new Intl.NumberFormat('zh-TW', { style: 'currency', currency: 'TWD', }).format(amount) } return { formatDate, formatCurrency } } ``` 這樣做完全沒有好處,反而帶來壞處: * 多了一層不必要的包裝 * `use` 前綴讓人誤以為裡面有響應式狀態 * 使用時多了解構的步驟 直接 export function 就好了,簡單明瞭。%◝( •ω• )◟% ## Composable 搭配 Utility 一起用 不過 composable 和 utility function 並非互斥。composable 裡面當然可以使用 utility function: ```ts export function validateEmail(email: string) { return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email) } ``` ```ts import { computed, ref } from 'vue' import { validateEmail } from '../utils/validators' export function useEmailInput(initialValue = '') { const email = ref(initialValue) const isValid = computed(() => validateEmail(email.value)) const error = computed(() => email.value && !isValid.value ? '無效的 Email 格式' : null ) return { email, isValid, error } } ``` `validateEmail` 是純粹的 util,`useEmailInput` 才是 composable。 各司其職,結構清晰。%( •̀ ω •́ )✧% ## 所以真的會怎麼樣嗎? 讓我們用實際的例子來感受可能會有甚麼問題,我們使用 VueUse 的 `useIntervalFn` 舉例。 這裡使用簡化過的 `useIntervalFn`,內容如下: ```ts import { onScopeDispose, onUnmounted, ref } from 'vue' /** 簡化版 useIntervalFn,用於示範 composable 的自動清除機制 */ export function useIntervalFnSimple( callback: () => void, interval: number, ) { let timerId: ReturnType | null = null const isActive = ref(false) function stop() { if (timerId) { clearInterval(timerId) timerId = null } isActive.value = false } function start() { stop() timerId = setInterval(callback, interval) isActive.value = true } start() onScopeDispose(stop) return { isActive, start, stop } } ``` 重點在最後那行 `onScopeDispose(stop)`。它會在作用域銷毀時自動呼叫 `stop()`,interval 就會跟著被清除。 前提是 `onScopeDispose` 要在正確的時機被呼叫,也就是在元件實例的同步流程中。 接下來讓我們用實際的元件來對比看看。兩邊都是每秒 +1 的計數器,差別只在 composable 的呼叫位置。 `content\blog-vue\js-util-vs-vue-composable\interval-top-level.vue` ```vue ``` `content\blog-vue\js-util-vs-vue-composable\interval-in-timeout.vue` ```vue ``` 點「銷毀元件」後觀察計數器的變化: 左邊在同步流程中呼叫,銷毀後計數停止。右邊在 `setTimeout`(非同步)中呼叫,銷毀後計數還在跑。 同樣的 composable,只是呼叫時機不同,結果天差地別。%( ˘•ω•˘ )% 這就是為什麼 composable 必須在元件的同步流程中呼叫,否則作用域無法正確與元件實例綁定,導致 memory leak。 不過其實 Vue 在 dev 模式下會貼心地給你一個警告: ```bash [Vue warn]: onUnmounted is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement. ``` 所以如果你在 console 看到這段訊息,就表示有 composable 在非同步流程中被呼叫了,趕快檢查一下吧。%( •̀ ω •́ )✧% ## 用 Effect Scope 補救 雖然說最好的做法是把 composable 放在同步流程中呼叫,但如果真的遇到沒有辦法的情境,可以用 Vue 提供的 `effectScope` 來補救: ```vue ``` `effectScope()` 在同步流程中建立時,會自動成為元件 scope 的子 scope。元件銷毀時,子 scope 也會跟著被停止,裡面收集的 `onScopeDispose`、`watch`、`computed` 等 effect 都會一起清除。 實際效果如下,同樣在 `setTimeout` 中呼叫,但用 `effectScope` 包住後,銷毀元件就能正確停止 interval: 不過要注意的是,`scope.run()` 只能收集基於 `onScopeDispose` 的清除邏輯。如果 composable 內部是用 `onUnmounted` 而不是 `onScopeDispose`,`scope.stop()` 管不到喔。%乁( ◔ ௰◔)「% 所以寫 composable 時,建議用 `onScopeDispose` 取代 `onUnmounted`,這樣使用者不管是在同步流程還是 `effectScope` 中呼叫,都能正確清除,可靠性更高。%( •̀ ω •́ )✧% ## 總結 🐟 * utility function 是純粹的 JavaScript function,不依賴 Vue,隨時隨地都能呼叫 * composable 利用 Composition API 封裝有狀態的邏輯,必須在**元件實例的同步流程**中呼叫 * ` ``` 接著將 `base-image` 註冊為全域元件。 `.vitepress\theme\index.ts` ```ts import type { Theme } from 'vitepress' // https://vitepress.dev/guide/custom-theme import BaseImg from '../../web/components/base-img.vue' export default { // ... enhanceApp({ app, router, siteData }) { app.component('BaseImg', BaseImg) }, } satisfies Theme ``` ## 實測 現在讓我們執行 `vitepress preview`,來試試看效果如何吧。 ![rwd-result](/add-responsive-images-to-vite-press/rwd-result.jpg) 可以注意到原本的 `img` 標籤已替換為 `picture` 標籤(`base-image` 元件),並且產生了 `srcset` 屬性。 大功告成!%੭ ˙ᗜ˙ )੭% ## 總結 🐟 * 使用 [sharp](https://www.npmjs.com/package/sharp) 產生多種尺寸圖片 * 擴展 markdown-it,將 image 標籤替換為自定義 Vue 元件(`base-image`) * 藉由 `base-image` 元件產生約定好的圖片尺寸之 srcset 屬性 以上我們完成自動產生、替換響應式圖片的功能了!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% 其實還有很多細節可以處理,例如:如果圖片小於目標尺寸,就不需要產生新圖片等等。 我就不剝奪其他人實驗的機會了,留給大大們自己嘗試惹。 要改得太多惹,我決定改天!ᕕ( ゚ ∀。)ᕗ --- --- url: /blog-vue/vue-3-5-update-on-watcher-cleanup-with-the-forgotten-on-cleanup.md description: >- Vue 3.5 更新新增了 onWatcherCleanup,用於清理 watcher 中的副作用(Side Effect),可是原本的 watch 已經有 onCleanup,所以到底差在哪呢?(╯▽╰ ) --- ![vue-3-5-update-on-watcher-cleanup-with-the-forgotten-on-cleanup](/vue-3-5-update-on-watcher-cleanup-with-the-forgotten-on-cleanup.webp){.cover} # Vue 3.5 更新,onWatcherCleanup 與 onCleanup [Vue 3.5 更新了](https://blog.vuejs.org/posts/vue-3-5)!除了其他改進與實用功能外,有一個很有趣的新 API,[叫做 `onWatcherCleanup`](https://blog.vuejs.org/posts/vue-3-5#onwatchercleanup)。 不過原本的 watch 已經有 `onCleanup` 了,所以到底差在哪呢? [文件 Side Effect Cleanup 一節](https://vuejs.org/guide/essentials/watchers.html#side-effect-cleanup)中提到的用法為: ```ts import { onWatcherCleanup, watch } from 'vue' watch(id, (newId) => { const controller = new AbortController() fetch(`/api/${newId}`, { signal: controller.signal }).then(() => { // callback logic }) onWatcherCleanup(() => { // abort stale request controller.abort() }) }) ``` 不過 watch 本身就有 onCleanup,所以應該可以這樣寫。 ```ts watch(id, (newId, oldId, onCleanup) => { const controller = new AbortController() fetch(`/api/${newId}`, { signal: controller.signal }).then(() => { // callback logic }) onCleanup(() => { // abort stale request controller.abort() }) }) ``` [文件 onWatcherCleanup 章節](https://vuejs.org/api/reactivity-core#onwatchercleanup)也沒有其他說明。 說不定實際效果不一樣?讓我們來實測看看吧。%(๑•̀ㅂ•́)و✧% 這裡我們使用一個[免費的 API](https://alexwohlbruck.github.io/cat-facts/docs/) 來測試,順便取得貓貓冷知識 XD。 ::: warning 如果 API 壞了,大家可以換成[其他 API 測試](https://free-apis.github.io/#/browse)。 ::: ## 實際測試 ```vue ``` [範例程式碼](https://play.vuejs.org/#eNqdVM1u1DAQfpWRhbRpSRNVhcuyWyhtD+UACKg41BV1E2fXrWNHtrMtWuUZekACbpw4IiEegLdZ0cdg7OxPCqhIXa1ie2a+b2Y+TzIlO1WVTGpO+mRgMyMqB5a7utqmSpSVNg6mYHgRwwVz2TgGrd76DTe7kjNVV9BAYXQJPeToUUVVppV1IHIYelzUy3TeW1uYc+bY3HF0jFaqAm0k8hgixS8OcNUyD4uaZ1iD4TZMqQJoOfDpjJaSG2RCDOycYpm7S2sUeAEK7pn9DuBk7Fxl+2maMbdRsMwl2IE+rxk2n+ky9Sb7WOTDe9NQRXMSt8ApWDFSTPY7aZPWBI0PwWQ+LnFjrqLIcBvKxTU5s1pFazf9EyZrvmrI/7wkSbBjO2F91LqaBRRrxkYibow2N7FeEC15ElzzgA7a75YyRlEXu0BKPYp6R6soOIY+9OJOtwvCTv/MC+5l9mkgTWE9bZOl63/Nx3/S/jFMd87etlvUKnNCKxhxt4e6Yu6QWORLhTPzvnI6MUzlujw8PNiLfAq8ykHajj8OPh4cLyvJHMcTwOC0dg5Zn2RSZOdDSpb0lGzPrj7Ofn66/vEB/7PvV7++fL3+9nmQtpBAhgQVTDYKbRCK112XMQgQKlw9MswnbYrG+7AJTZP4QwhMHL90aAkkqX8pB2mnNBITZ1GaQozCuOFLHPqlBIe6EqjWi8rrYSnpL66AEialvngWbM7UfD7piBnz7Pwf9jN76W2UvMSx5mbCKVn6HDMoRuvef/0cq+04S53XEqNvcb7iOA+1r7ENe1qrHMvuxIVqD8KnSKjRG7t/6biyi6Z8oWESQjwl+BXavaX1VblbyYOAw7tHFd9NuPGcKOBW8jDZJM1vjAKyGQ==) 就一個簡單的按鈕,點擊後會取得貓貓小知識,並顯示在畫面上。 ![測試程式](/vue-3-5-update-on-watcher-cleanup-with-the-forgotten-on-cleanup/1.webp) 每次 watch 觸發時,會呼叫上一次的 onCleanup 或 onWatcherCleanup,這樣就可以清理上一次的副作用。 所以讓我們快速點擊按鈕兩下,觀察一下 DevTools 之 Network 的結果。 ![Network](/vue-3-5-update-on-watcher-cleanup-with-the-forgotten-on-cleanup/2.webp) 可以注意到上次的 fetch 順利取消了! 現在讓我們把 onWatcherCleanup 註解掉,改用 onCleanup,再來觀察一下。 見證奇蹟的時刻到了! 結果一模一樣!%╮(╯▽╰)╭% ...嗯,以上例子的確看不出差別,但是不代表 onWatcherCleanup 沒有用途,讓我們再來看看其他情境吧。 ## onWatcherCleanup 使用情境 ::: tip 感謝 Line Vue.js Taiwan 社群裡的 dog 大大[提供的資訊](https://github.com/vuejs/core/pull/9927),忽然茅舍頓開。%ᕕ( ゚ ∀。)ᕗ% ::: 目前來看 onWatcherCleanup 比較類似 getCurrentScope、onScopeDispose 這類底層 API,平常不會特別使用,一般業務邏輯用 onCleanup 就好。(想統一用 onWatcherCleanup 也是可以 XD) 不過如果你希望「在 watch 中重新執行時,會自動清理上一次動作」的實用 API,這時候 onWatcherCleanup 就派上用場了。 概念上有點類似 [VueUse 的 useEventListener](https://vueuse.org/core/useEventListener/#useeventlistener),此 API 可以在元件中註冊事件,並在元件被銷毀時自動移除。 ```ts import { useEventListener } from '@vueuse/core' // 像這樣註冊事件,不用手動 removeEventListener useEventListener(document, 'visibilitychange', (evt) => { console.log(evt) }) ``` 只是 useEventListener 的目標是「整個元件的 effect scope」,而 onWatcherCleanup 的目標是「單一 watcher scope」。 讓我們延伸剛剛的例子,設計一個在 watch 使用時會自動取消前一次請求,並在元件解除後也會自動取消請求的 API。 新增一個 useCatFact。 ```js import { onBeforeUnmount, onWatcherCleanup, shallowRef } from 'vue' export function useCatFact() { const abortController = shallowRef() /** 元件解除前,自動取消請求 */ onBeforeUnmount(() => { abortController.value?.abort() }) /** 取得貓貓小知識 */ function getFact(id) { return new Promise((resolve, reject) => { const controller = new AbortController() fetch( `https://cat-fact.herokuapp.com/facts?id=${id}`, { signal: controller.signal } ) .then((res) => res.json()) .then((value) => resolve(value)) .catch((error) => { // 忽略終止錯誤 if (error.name === 'AbortError') return reject(error) }) /** * 在 watcher 範圍內會自動取消前一次請求 * * 第二個參數 true 表示忽略不在 watcher 範圍內警告 */ onWatcherCleanup(() => { controller.abort() }, true) abortController.value = controller }) } return { getFact, } } ``` 接著在元件中使用。 ```vue ``` 現在連續點擊按鈕,可以看到前次請求都會取消,而且元件解除時也會自動取消請求。 [範例程式碼](https://play.vuejs.org/#eNp9VW1rG0cQ/ivDUdDJVe4Iab+4ktPEccH90Ia0ph96pbncray1T7vH7p7sIAQhbuiLbUwp7gsYSlqnCalN2kKDSd/+jE9SPvUvZHbvdLpTRIRf2NmZ2WeeeWbUt67EsdNLiLVoNWUgaKxAEpXESx6j3ZgLBX0QpN2ALV8FHRhAW/Au1DCiVvJIJFn21Tt+oAoPx50anQ1Ze8tjHgs4kwpoCC2d1K4FPKzVJ+bQV35+8fEnaJ3Y+7BOJrlbpafsuslpgNk0bIAvb7MAbEa2VsM6tJag7zEwaZ2eHyUEo/0tn6pJvtwTswzMc1l8O2GBopxpr2sYa9ezPDQssgTidqy4I3wW8u7a2uo1A2XgsaabcYjs4UGRbhz5iuAJoHkrUQqzvh1ENNhseVaR3rOW0oNv03+/G//xDf6kvx2MfnwwPv2+6WYhJhkmiCGIfCkxVNHYs6B3oc0FnrDApNsACpSZYjGf9gfo99H4OlyEwcDRB+PoKLKNTCJYTOnqPjfdElCrYSmJvLfpOnaNM9SFqd6zAt6NaUTE+7FmR3rWYsaLvvOjiG+9a2xKJKQxsQcdEmzOsW/IbW3zrOuCSCJ6xLOKO+ULpCa7XvngPURbuuzyMInQ+xWXN4jkUaIxZm5XExYi7JKfQbtqtEvZ+odyZVsRJidFaaDac2D8PQulvvyK0qdwLzlvmDhUArJYUT+yWMwKZx9pxRKxHBGfJXEDZMcguqHHjLOrBNtK1liXJ2w6TmbgBCoBf8m2SVUItTwSCAw9ALLR8W+h4zJnSvAI8aN0p29pzeYJAdyFBUjv7Zz/9XT88OfnPxynX+7///fe+PPH6e4hinP49Ivxr7vD3+/CgpsFzOC08WUzcOYSZh/OJueyY8yoeOM1eAnAvCkoXizPpamV4pAXDwpcWoIBTjRcR8KoJLaN2uJRjzTwcoMEqopwQhH+nbKjw69UoWdop1FtotfN9Axws6NULBddN/DVhbZuODaXbyY+blYcGleb5GUatl7r03Bws1GO7YOk68yPFks4nMyEQiocc8ayj6M6hJnqTEX430yqXZ/nZYif+Gk2ckvVF5FjUTYRgotZmvTHdSH975/R4YPRn3eHpz8933syfnxcdaFtyOId5ndxSbZaUDNUrmhjrZ53yLR8GpV1Jn+4cocrucK7FkjpHtVy9Cj7UsLOjZ58lh7tp/ceDo92yqJFGZ+f3Rme3M/UW4mvHEYnJ+fP9tI7u+nBzvDwzCwBGN9/NDp+lhV+frY/98Hx6S/p11+Vc+Vy1Z/ZUZ+dklyFk75XpsNw0DBAZpiYO1v6W6kwFURmJOqdVGTIB6XAkE9TrkrjqNfXpz0i9ErEzXXJedO5aA1eAOJa7Q0=) ::: tip 這個時候可能有人會有疑問:「如果把 `onCleanup` 傳到 `useCatFact` 裡,不也可以嗎?」 的確可以,就看設計需求與風格了,像是剛剛提到的 `useEventListener` 就是自動處理的方式。%( •̀ ω •́ )✧% ::: 可以看到程式碼變得相當乾淨,不需要手動 onCleanup,也不用擔心忘記取消請求,非常方便。%∠( ᐛ 」∠)\_% 除了發送 HTTP 請求,這在設計動畫也十分實用,可以有效防止同一物件上的動畫重複執行。 舉一個動畫的例子,這裡使用 [anime.js](https://animejs.com/)。 設計一個名為 useBoxAnimation 的動畫工具,可以對指定元素執行各類預先定義好的動畫。 ```ts import anime from 'animejs' export function useBoxAnimation() { /** 動畫目標元素 */ const boxRef = ref() /** 元件解除前,刪除動畫 */ onBeforeUnmount(() => { anime.remove(boxRef.value) }) function translate(x, y) { anime({ targets: boxRef.value, translateX: x, translateY: y, easing: 'spring(1, 80, 10, 0)' }) onWatcherCleanup(() => { anime.remove(boxRef.value) }, true) } function rotate(deg) { anime({ targets: boxRef.value, rotate: deg }) onWatcherCleanup(() => { anime.remove(boxRef.value) }, true) } return { boxRef, translate, rotate, } } ``` 以上只是我目前的心得與理解,如果有更多的使用情境或是更深入的解釋,歡迎大家一起討論!%(´▽\`ʃ♡ƪ)% ## 總結 🐟 * 認識 `onCleanup` 與 `onWatcherCleanup` * `onWatcherCleanup` 適合用在封裝後的功能,一般業務邏輯用 `onCleanup` 就好 * 官方文件的 `onWatcherCleanup` 例子舉的不太好,看不出與 `onCleanup` 的應用差異,害我一開始看得一頭霧水 XD 最後再次感謝 dog 大大提供的資訊,也歡迎大家加入 [Vue.js 社群](https://web-tech-tw.github.io/openchat/#/join/vue-tw) 一起討論 Vue 相關議題!(/≧▽≦)/ --- --- url: /blog-vue/vue-reactive-cheat-sheet.md description: 現在官方推薦使用 ref 就行,所以 reactive 還有甚麼用處呢?讓我們筆記一下。(。・∀・)ノ --- ![vue-reactive-cheat-sheet](/vue-reactive-cheat-sheet.webp){.cover} # Vue Reactive 使用筆記 使用 Vue 定義資料時,到底要用 `ref` 還是 `reactive` 是一個很常見的問題 不過[官方文件推薦使用 `ref`](https://vuejs.org/guide/essentials/reactivity-fundamentals#limitations-of-reactive:~:text=Due%20to%20these%20limitations%2C%20we%20recommend%20using) 就行,所以其實也不是甚麼大問題了。%( •̀ ω •́ )✧% 一般情況下的確 `ref` 更好,坑也相對少一點,且更容易拆分邏輯,詳細說明可以看看[尤大訪談](https://www.youtube.com/watch?v=e8Wlv4AGJjk\&ab_channel=%E6%88%90%E5%BC%8F%E8%AA%9E%E8%A8%80%2FMikeCheng)。 之前看到有人分享:「統一使用 `ref` 的優點是可以明確區分資料,需要用 `.value` 存取的資料一定為響應式資料,反之則否」,我覺得這個準則確實有幫助。 所以 `reactive` 真的沒用用途了嗎?這篇文章來記錄一下 `reactive` 的用法。 不定期更新,會持續補充用法。%( ´ ▽ \` )ノ% ## 解包響應式資料 以 VueUse 的 [useElementBounding](https://vueuse.org/core/useElementBounding/) 為例。 此 API 可以取得多個 element 的 bounding 資料,只需要寬高資料,就可以只取出寬高: ```ts const { width, height } = useElementBounding(el) ``` 但是有時候會需要全部都要,可能會這樣寫: ```ts const bounding = useElementBounding(el) ``` 這時候因為內部資料還是 ref,所以取值需要 .value(例如 `bounding.width.value`),這個時候就可以先用 reactive 包起來: ```ts const bounding = reactive(useElementBounding(el)) ``` 包起來後,就可以 `bounding.width`,不用 value 了 以上用法供大家參考,若有錯誤還請大家多多指教。 ## 建立簡易 store Vue 官方[文件](https://vuejs.org/guide/scaling-up/state-management#simple-state-management-with-reactivity-api)提到 `reactive` 可以用來建立簡易的 store。 ```ts import { reactive } from 'vue' export const store = reactive({ count: 0, increment() { this.count++ } }) ``` ```vue ``` 不過除非很原因很明確,否則還是建議使用 Pinia 這類成熟的狀態管理套件,否則未來維護很麻煩。 ::: tip 我曾經在[酷酷元件的落雪元件](https://chillcomponent.codlin.me/components/bg-snow/)中[實作過此用法](https://gitlab.com/side_project/chill-component/-/blob/main/src/components/bg-snow/bg-snow-store.ts)。 這麼做是因為此為內聚性高的獨立元件,若要求使用者在使用此元件前,必須先安裝 Pinia,這樣就太過於繁瑣了。 但若是一般網頁專案,在不確定專案會不會持續增長的情況下,還是建議使用 Pinia。 ::: ## 總結 🐟 * 官方推薦使用 `ref` * `reactive` 可以用在解包響應式資料、建立簡易 store * 有其他用法也歡迎補充 %(´▽\`ʃ♡ƪ)% --- --- url: /blog-vue/vue-template-compilation-magic.md description: 常聽別人說 Vue template 有編譯魔法,來仔細看看到底差在哪,順便看看酷酷的 Vapor 模式。ლ(´ڡ`ლ) --- ![vue-template-compilation-magic](/vue-template-compilation-magic.webp){.cover} # Vue template 編譯魔法 和同事討論到 Vue template、`h()` 的使用情境剛好最近使用 Nuxt UI 也常使用 `h()` 來渲染內容。 在高度動態的場景下,使用 `h()` 會更加方便,先前也有[文章](/blog-vue/vue-h-function-makes-quasar-dialog-easier-to-reuse.html)提到相關應用。 ::: tip 甚麼是 `h()` `h()` 是 Vue 的渲染函式,詳細說明可以[看看官方文件](https://vuejs.org/guide/extras/render-function.html) ::: 但是 `h()` 不會有 template 的編譯最佳化,所以沒有特別需求的情境下,還是建議使用 template。 [官方文件](https://vuejs.org/guide/essentials/template-syntax.html)的說法如下: > If you are familiar with Virtual DOM concepts and prefer the raw power of JavaScript, you can also directly write render functions instead of templates, with optional JSX support. However, do note that they do not enjoy the same level of compile-time optimizations as templates. 聽說歸聽說,~~忍者龜忍者~~,從來沒有仔細研究過到底差在哪,趁這次機會來看看吧。%( ´ ▽ \` )ノ% ## 速度真的有差? 口說無憑,直接寫個範例比較看看吧。%( ´ ▽ \` )ノ% 由於 JSX 與 `h()` 概念相近,所以這裡只比較 template 與 `h()` 的差異。 分別建立 template 與 `h()` 的範例: ::: code-group ```vue \[list-template.vue] ``` ```ts \[list-h.ts] import { h } from 'vue' export default { props: ['items'], setup(props: { items: any[] }) { return () => h('div', { class: 'root-container' }, props.items.map((item) => h('div', { 'key': item.id, 'class': 'card-wrapper', 'data-test': 'static-attr' }, [ h('div', { class: 'card-header', style: 'background: #eee; padding: 10px; border-bottom: 1px solid #ddd;' }, [ h('h4', { class: 'title', style: 'margin: 0; color: #333;' }, '靜態標題 - Level 1'), h('span', { class: 'badge', style: 'background: red; color: white; padding: 2px 5px;' }, 'Static Badge'), ]), h('div', { class: 'level-2-wrapper', style: 'display: flex; gap: 10px; padding: 10px;' }, [ h('div', { class: 'static-col-left', style: 'width: 50px; background: #ccc;' }, 'Left'), h('div', { class: 'level-3-wrapper', style: 'flex: 1; border: 1px dashed blue;' }, [ h('ul', { class: 'static-list', style: 'list-style: none; margin: 0; padding: 0;' }, [ h('li', { style: 'padding: 5px;' }, 'Static List Item A'), h('li', { style: 'padding: 5px;' }, 'Static List Item B'), h('li', { class: 'level-4-target', style: 'padding: 5px; background: #eef;' }, [ h('span', { class: 'label', style: 'font-weight: bold; margin-right: 10px;' }, 'Value:'), h('span', { class: 'dynamic-val', style: 'color: blue; font-size: 1.2em;' }, item.text), ]), ]), ]), ]), h('div', { class: 'card-footer', style: 'padding: 5px; text-align: center; color: #999;' }, 'Copyright © 2024 Static Inc.'), ]), )) }, } ``` ::: 接著引用以上元件並計時更新耗時,為了讓差距明顯一點,讓內容重複 2000 次。 `perf-comparison.vue` ```vue ``` 現在大家可以按下開始按鈕並切換模式,看看耗時有甚麼差異。 在我的電腦上,template 大約在 5 ms,`h()` 大約在 20 ms。 不同裝置結果會不一樣,不過很明顯可以看出 template 的速度會比 `h()` 快得多。 不過通常不會產生大量元素,所以平時完全看不出差別,只是當你的網頁量級增加,積少成多,性能負擔自然會增加。 所以實務上來說除非有明確的需求或元件 API 限制,否則比較推薦使用 template。%(・∀・)9% ## 編譯結果 template 為甚麼會比較快呢?文件的說法是因為 Vue 的編譯器會自動最佳化(Compile-time Optimization),快取靜態節點、加速 Patch Flags 加速比較過程等等。 實際上不管是 template 還是 `h()`,最終 Vue 都會編譯成 JS 程式碼,用以產生 Virtual DOM (VDOM)。 讓我們來看看編譯後的產物有甚麼差別吧。%੭ ˙ᗜ˙ )੭% 這裡使用 [Vue SFC Playground](https://play.vuejs.org/) 的 PROD 模式進行編譯,來比較 template、`h()` 與 JSX 的編譯內容。 分別建立 3 種版本的簡單範例: ::: code-group ```vue [template] ``` ```ts [h()] import { defineComponent, h, ref } from 'vue' export default defineComponent({ setup() { const msg = ref('Hello Vue!') return () => h('div', null, [ h('h1', null, msg.value), h('input', { value: msg.value, onInput: (e: Event) => { msg.value = (e.target as HTMLInputElement).value }, }), h('h2', null, 'Hi'), ]) }, }) ``` ```tsx [JSX] import { defineComponent, ref } from 'vue' export default defineComponent({ setup() { const msg = ref('Hello Vue!') return () => (

{msg.value}

(msg.value = (e.target as HTMLInputElement).value)} />

Hi

) }, }) ``` ::: 編譯結果: ::: code-group ```js [template] /* Analyzed bindings: { "ref": "setup-const", "msg": "setup-ref" } */ import { createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, toDisplayString as _toDisplayString, vModelText as _vModelText, withDirectives as _withDirectives, ref } from 'vue' const __sfc__ = { __name: 'App', setup(__props) { const msg = ref('Hello Vue!') return (_ctx, _cache) => { return (_openBlock(), _createElementBlock(_Fragment, null, [ _createElementVNode('h1', null, _toDisplayString(msg.value), 1 /* TEXT */), _withDirectives(_createElementVNode('input', { 'onUpdate:modelValue': _cache[0] || (_cache[0] = ($event) => ((msg).value = $event)) }, null, 512 /* NEED_PATCH */), [ [_vModelText, msg.value] ]), _cache[1] || (_cache[1] = _createElementVNode('h2', null, 'Hi', -1 /* CACHED */)) ], 64 /* STABLE_FRAGMENT */)) } } } __sfc__.__file = 'src/App.vue' export default __sfc__ ``` ```js [h()] /* Analyzed bindings: {} */ import { defineComponent, h, ref } from 'vue' const __sfc__ = defineComponent({ setup() { const msg = ref('Hello Vue!') return () => h('div', null, [ h('h1', null, msg.value), h('input', { value: msg.value, onInput: (e) => { msg.value = (e.target).value }, }), h('h2', null, 'Hi'), ]) }, }) __sfc__.__file = 'src/App.vue' export default __sfc__ ``` ```js [JSX] /* Analyzed bindings: {} */ import { createTextVNode as _createTextVNode, createVNode as _createVNode, defineComponent, ref } from 'vue' const __sfc__ = defineComponent({ setup() { const msg = ref('Hello Vue!') return () => _createVNode('div', null, [_createVNode('h1', null, [msg.value]), _createVNode('input', { value: msg.value, onInput: (e) => msg.value = e.target.value }, null), _createVNode('h2', null, [_createTextVNode('Hi')])]) } }) __sfc__.__file = 'src/App.vue' export default __sfc__ ``` ::: 從結果中可以發現 `h()` 與 JSX 結果基本上相似,template 則是加了不少東西,讓我們來看看是甚麼吧。%ヾ(◍'౪\`◍)ノ゙% ## Vue Runtime Helpers template 編譯結果中的 `openBlock`、`createElementBlock`、`withDirectives`、`toDisplayString`、`_cache` 等等都是 [Vue Runtime Helpers](https://github.com/vuejs/core/blob/main/packages/compiler-core/src/runtimeHelpers.ts) 的一部分,用於處理 Virtual DOM 的操作。 其中 Cache 與 Patch Flags 是相當重要的一環,可以來看看 Vue 的 [Rendering Mechanism](https://vuejs.org/guide/extras/rendering-mechanism) 文件,裡面說明了 Vue 如何改進渲染效能。 不過 `h()` 也可以寫出同 Cache 的效果,只要將希望快取的內容提升到變數中即可,例如剛剛的範例就可以改寫成這樣: ```ts import { defineComponent, h, ref } from 'vue' export default defineComponent({ setup() { const msg = ref('Hello Vue!') const onInput = (e: Event) => { // [!code ++] msg.value = (e.target as HTMLInputElement).value // [!code ++] } // [!code ++] const staticNode = h('h2', null, 'Hi') // [!code ++] return () => h('div', null, [ h('h1', null, msg.value), h('input', { value: msg.value, onInput: (e) => { // [!code --] msg.value = (e.target).value // [!code --] }, // [!code --] onInput, // [!code ++] }), h('h2', null, 'Hi'), // [!code --] staticNode, // [!code ++] ]) }, }) ``` 只是這樣寫起來會比較麻煩,也不支援 Patch Flags。%◝( •ω• )◟% ## 同場加映:Vapor Mode 啟發於 SolidJS,Vue v3.6 引入了 Vapor 模式(連名字都有致敬 XD),完全捨棄 VDOM,改為直接操作 DOM。 可以相容原有的寫法,只要在 ` ``` 在 [Vue SFC Playground](https://play.vuejs.org/#eNp9Uc1KAzEQfpWYyyrYrbbioWwLKoXqQUXFU0DW7XSbmk1CMlkLy767k5RWD9JTMt/P8M1Mx2+szdsAfMILXzlpkXnAYFlbWuNmQsuGXmQdc7BiPVs507CMDJnQQldGe2SNr9k08qfZApQy7D3ASXYmdDHctaQ2VCA0VpUIVDFWrC9nXZesfV8MqUqo1DYgaweNWYKaCk684MkeLaPZQpJ2REAxPLTj5xw9JVnJOt94o2mULuoFr0xjpQL3ZFFSUsEnLDGRKyno90PC0AU43+PVGqqvf/CN30ZM8GcHHlwLgh84LF0NuKPnr4+wpf+BpFGCIvUR8gW8USFm3Mlug15S7D+6lPY+nULq+s3Ptwja74eKQaOyT3rB6Tx3R0b/jTvOr5JP6J62+NGCiz1pgeP8Or8YfAKW+Zj3P/ZVs9o=) 中將版本改為 v3.6,可以看到編譯結果已經沒有 VDOM 的身影了。 ```js /* Analyzed bindings: { "ref": "setup-const", "msg": "setup-ref" } */ import { applyTextModel as _applyTextModel, renderEffect as _renderEffect, setText as _setText, template as _template, toDisplayString as _toDisplayString, txt as _txt, ref } from 'vue' const __sfc__ = { __name: 'App', __vapor: true, setup(__props, { expose: __expose }) { __expose() const msg = ref('Hello Vue!') const __returned__ = { msg, ref } Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true }) return __returned__ } } const t0 = _template('

') const t1 = _template('') const t2 = _template('

Hi') function render(_ctx, $props, $emit, $attrs, $slots) { const n0 = t0() const n1 = t1() const n2 = t2() const x0 = _txt(n0) _applyTextModel(n1, () => (_ctx.msg), (_value) => (_ctx.msg = _value)) _renderEffect(() => _setText(x0, _toDisplayString(_ctx.msg))) return [n0, n1, n2] } __sfc__.render = render __sfc__.__file = 'src/App.vue' export default __sfc__ ``` 其中 `_template` 用來建立真實 DOM,仔細比對可以發現: * `n1` 為 `input` 元素,`_applyTextModel` 處理 `v-model` 指令,同步 `msg` 資料 * `x0` 為 `h1` 元素,`_renderEffect` 追蹤 `msg` 響應,使用 `_setText` 更新 text 資料 可以注意到 Vapor 模式的更新關注點是 DOM,透過響應式系統精準更新,而原本 VDOM 則是整個元件,每次更新都需要比較整個元件的 VDOM。 相較之下 Vapor 省掉許多不必要的計算,配合 [`alien-signals`](https://github.com/stackblitz/alien-signals) 更是如虎添翼,性能大幅提升。 不過目前 Vapor 模式還在實驗階段,期待 v3.6 的正式推出!%╰(*´︶\`*)╯% ## 總結 🐟 * template 比 `h()` 快 * 高度動態情境使用 `h()` 更加方便 * Vue Compiler 會自動最佳化 template,更高效的操作 VDOM * Vapor 模式則無 VDOM,可以節省許多不必要的計算,藉此提高性能 --- --- url: /blog-vue/hang-tight-for-a-sec-before-you-start-watch.md description: watch 不是不能用,而是在使用 watch 之前,先想想有沒有其他方案,真的沒有才用 watch。 --- ![dont-depend-on-watch](/dont-depend-on-watch.webp){.cover} # Vue watch 前先小等一下 先說結論: ::: danger `watch` 不是不能用,而是在使用 `watch` 之前,先想想有沒有其他方案,真的沒有才用 `watch`。 ::: 千萬不要為了一時方便讓元件裡滿滿的 `watch`,因為容易產生難以追蹤的副作用,會讓資料流更加複雜。 口說無憑,讓我們舉個栗子 (。・∀・)ノ🌰。 ## 舉個栗子 (。・∀・)ノ🌰 考慮一個簡單的元件,輸入資料型別可以為 `string` 或 `number`,回傳則固定為 `number`。 ```vue ``` 你可能會想問:「阿不是說能不用就不要用,怎麼開場就有 `watch`?」 這是因為除了 `watch` 以外,沒有其他方法可以在元件內得知 `props.modelValue` 發生變更,所以此部分使用 `watch` 沒問題。 以上程式已經將 `props.modelValue` 數值同步至 `numberValue` 中,現在只差將 `numberValue` 數值 emit 出去部分,直覺上可能會這樣寫: ```vue ``` 在簡單的例子中,這樣寫不會有太大問題,但是若 `numberValue` 資料較為複雜(例如深層物件、矩陣等等)或者耦合其他邏輯時,這樣寫可能會導致 watch 無限呼叫或其他難以追蹤的副作用。 一般情況下,我們應該可以直接知道 `numberValue` 的變化來源。 ::: warning ⚠ 如果沒辦法得知,應該重新仔細思考設計 ::: 如果是 input 的話,我們可以從 `@change` 事件得知數值變化,所以此例子可以不使用 watch,結果可能會這樣: ```vue ``` 也就是在明確的資料流中呼叫 emit。 ::: tip 或者使用 input 的 `@blur` 事件,可以在確保使用者輸入完畢,離開 input 後再觸發,減少不必要的連續觸發。 ::: ## 總結 🐟 * 設計元件時,資料流越單純越好,以免寫得時候很爽,除錯的時候火葬場 * watch 是一個很方便的 API,但不要濫用,以免過多副作用難以追蹤 但是如果發現元件內有很多「只能用 `watch` 才能處理的邏輯」,那就表示你可能耦合了太多邏輯和副作用在單一元件內。 請好好思考拆分元件、重構或者重新探討元件設計。( ‧ω‧)ノ╰(‧ω‧ ) --- --- url: /blog-vue/vue-trigger-ref-problem.md description: 前陣子使用 triggerRef 時發現某些情境下無法觸發響應,一起來看看發生甚麼事吧 (´・ω・`) --- ![vue-trigger-ref-problem](/vue-trigger-ref-problem.webp){.cover} # Vue 之 triggerRef 問題 `triggerRef` 主要用於觸發 `shallowRef` 響應,但是在某些情境下會無法觸發響應。%( ´•̥̥̥ ω •̥̥̥\` )% ::: tip 甚麼是 `shallowRef` 想了解甚麼是 `shallowRef` 的朋友,可以參考我之前寫的[文章](./vue-shallow-ref-and-ref)。 ::: ## 問題 [問題重現](https://play.vuejs.org/#eNqNVLuO00AU/ZUrN3Gk4PCqghPx2C2ggNWCaOIUxr52vDsej2bGSSTLiAoKSqSVoACJYmskGj4IVin5BO6MEydkl7BN4rmPc4/vnOPKeSCENyvRGTi+imQmNCjUpQAW8nQYOFoFzijgWS4KqaECiUkP1DRkrJgfm2ctszRFSc9QQyKLHDoE12lbHhW5WMW9vjmYaZ17AQ94VHClIQ51CMMtTF8RJk/Hk5E7nnRNYcCTkkc6KziEcXxADW4XqoCDbfZmISvRE6Waup0ONcAWKddUUKy+CuXm7f/gbMLEcOx53iYwsZh+v1karYgOGnPBQo10AvBflVrTrPsRy6JTWmVLnTZ6cfbt19fPy+/vLs4++v2mcm+Xodr2/fxxvjx/u/zw/hKAhZjeGtGt+n36t2clQj6qqmbTdU2cTWBVbO9ndiMvYmQ0ztQEDvQp7fe33sfpkRTowpIs9U5UwUkvdnGBExFAxlA+E2a1JJdBs1KTs1f6xMa0LLG3jkdTjE6viJ+ohYkFzpFEhXKGgdPmdChT1E368PlTXNBzmyT6JaPqPcljVAUrDcem7GHJY6K9VWfZPrayJf29UIcLjVytX8oQNZW1rQ8ckrHZ3b9efUP3jnfX9pFgaItrC1zXcPNQR9Mda1Gaa5RJGCEcyUKoZqy9w5dGnANYe4iMZqXfeE3Y4iHMMz09wCQsmVZujEnG0eL49nfkdntQ1SvrWQIuGWU4avq9zZweuNYMNmk5mDkFQ48Vqdv5/eXTG3gN4+YjYIEmlzAGxmo1DbSGQxRbmsjyHOOMFNjGDKt9piPNm2E74gdS/+5ccgK0Vvhb6/UfhXHI0A==) 從以上例子可以看到,當 `shallowRef` 傳遞給子元件後,執行 `triggerRef` 並沒有觸發子元件內的響應。 官方有一個 [issue](https://github.com/vuejs/core/issues/7614) 討論此問題。 不過目前看來還沒辦法解決,所以遇到此問題時,只能回歸直接賦值或者參考 issue 內其他解法。 ## 總結 🐟 * `shallowRef` 傳遞給子元件後,執行 `triggerRef` 不會觸發子元件內的響應 * 直接賦值或者參考 issue 內其他解法 --- --- url: /blog-vue/use-suspense-to-load-components-asynchronously.md description: 切換頁面卡卡有很多種原因,這裡舉的例子只針對元件太大的情境。除了想辦法拆分外,還有一個方法就是利用 Vue 的 Async Component。 --- ![use-suspense-to-load-components-asynchronously](/use-suspense-to-load-components-asynchronously.webp){.cover} # Vue 元件太肥,切換頁面卡卡怎麼辦?讓 Suspense 登場吧! 醜話說在前頭,切換頁面卡卡有很多種原因,這裡舉的例子只針對元件太大的情境喔。( •̀ ω •́ )✧ 甚麼樣的情況會讓元件太大呢?有可能是 HTML、CSS 太多,但最常見的情況就是包了太多 JS 在一起了。╮(╯▽╰)╭(import 一大堆套件等等) 除了想辦法拆分外,還有一個方法就是利用 Vue 的 Async Component。 ## Async Component 是什麼? 甚麼是 [Async Component](https://vuejs.org/guide/components/async)?簡單來說就是在真的要用的時候才從 server 下載元件,如果熟悉 Vue Router 的朋友們對以下寫法一定不陌生: ```ts const router = createRouter({ // ... routes: [ { path: '/users/:id', component: () => import('./views/UserDetails.vue') }, ], }) ``` 這個在 Vue Router 中被稱為 [Lazy Loading Routes](https://router.vuejs.org/guide/advanced/lazy-loading.html)。 此概念與 Async Component 概念相同,編譯、打包時會將每個頁面拆分,只有在真的需要某頁面元件時,才從 server 下載,才不會讓進入網頁的使用者等待下載一堆還不會用到的頁面。 讓我們來一個簡單的範例: `App.vue` ```vue ``` 其中 FatComp 是一個和鱈魚一樣超肥的元件,會顯示「很大很大的元件」。 鱈魚:「說好的尊重呢?(゚Д゚\*)ノ」 如果我們直接 import 元件,就必須等待元件下載完成,`App.vue` 的畫面才會出現。 ![同步載入](/use-suspense-to-load-components-asynchronously/sync.gif){width="240px" format="gif"} 可以注意到 Vue Suspense 的文字會等到 FatComp 載入完成後才會顯示。 如果我們使用 Suspense 改寫: ```vue ``` ![非同步載入](/use-suspense-to-load-components-asynchronously/async.gif){width="240px" format="gif"} 可以看到 Vue Suspense 字樣先出現,肥肥的元件則會等到下載完成才出現。 恭喜頁面不卡了!✧⁑。٩(ˊᗜˋ\*)و✧⁕。 Suspense 元件詳細說明可以來[官方文件](https://cn.vuejs.org/guide/built-ins/suspense)看看。 以上[範例程式在此](https://stackblitz.com/edit/vue-suspense-cmte55?file=src%2FApp.vue\&terminal=dev),我們下次再見囉!(´▽\`ʃ♡ƪ) ## 總結 🐟 * 肥大元件請用 Async Component * 配合 Suspense 可以讓頁面有更好的使用者體驗 --- --- url: /blog-vue/vue-generic-components.md description: Vue 3.3 終於新增了泛型元件(Generic Component),可以在 TypeScript 環境中得到更準確的型別提示了!ˋ( ° ▽、° ) --- ![vue-generic-components.jpg](/vue-generic-components.webp){.cover} # Vue 泛型元件,讓 TypeScript 更精確 大家好,我是鱈魚。%(^∀^●)ノシ% Vue 3.3 終於新增了泛型元件(Generic Component),可以在 TypeScript 環境中得到更準確的型別提示了!%ˋ( ° ▽、° )% 這個泛型元件與 TypeScript 的 Generic 是同一個概念,不知道 Generic 的朋友可以先來來[複習一下](https://www.typescriptlang.org/docs/handbook/2/generics.html)。 甚麼?你說你沒有使用 TypeScript?抱歉浪費您 5 秒鐘,可以上一頁了。%(>人<;)% 或者在離開前路過[文件](https://vuejs.org/guide/typescript/overview#generic-components)湊個熱鬧。%( •̀ ω •́ )y% 有使用 TypeScript 的朋友就讓我們一起繼續看下去吧。%ლ(╹◡╹ლ)% ## 來個 select 假設我需要一個綁定物件的 select,最常見的問題就是 `modelValue` 只能 `any`,如下: `SelectAny.vue` ```vue ... ``` ### v-model 這個糖 忘記元件怎麼實現 v-model 的朋友們別擔心,讓我們複習一下。 ::: tip Vue 3.4 開始可以使用 `defineModel`,不過與此文主題無關,所以我們先使用經典寫法。 ::: v-model 只是一個語法糖,實際上的程式碼如下([文件](https://vuejs.org/guide/components/v-model.html#under-the-hood))。 ```vue ``` `modelValue` 就是用來表示預設綁定值的變數,讓我們回到剛剛的 `any`。 所以現在就算我們的 `modelValue` 與 `options` 的型別不同,或者 `multiple` 為 `true`,而 `modelValue` 卻沒有給矩陣時,都不會有任何錯誤提示。 ```vue ``` 畢竟是 any 嘛。%(˘・\_・˘)% ## 加上泛型 如果加上元件泛型就不會有以上問題了。 `SelectGeneric.vue` ```vue // ... ``` 除了 `modelValue`、`options` 有泛型 `ModelValue` 外,我們還加上 `multiple` 判斷,如果為 true,則 `modelValue` 為 `ModelValue`\[],否則為 `ModelValue`。 這樣子就可以達成以下效果: * `modelValue` 與 `options` 型別不同時警告, * `multiple` 為 true 時,如果 `modelValue` 沒有提供矩陣時發出警告。 ```vue ``` TypeScript 成功重返光榮。%o((>ω< ))o% 文章到此結束了,感謝您的閱讀,以上程式碼可以[在此](https://play.vuejs.org/#eNrtWM1u20YQfpUxL6INRWrQnlRJRpoYRYLmB3Xai+gDLS1lptSS4C5lGwIPvcVAgBQ9FUEPPaQ9tCiM9lSg6dvEbn3KK3R2lrsiKdlJHOWQID4Y5OzMzjezs9+MOHOuJUlrmjGn43TFMA0TCYLJLIHI5+Oe50jhOX2Ph5MkTiXMIGUB5BCk8QQaaNb4tLT2lWCpXWy15WHChFKwKtssYkN5jR9aHStRGEqbafnnjLM0HNa0C6mx8Pgw5kJCnMgQHzoEY7ADPRh4HBAW9yesA42zPx6e/f6k0YQEjVFw9SP8g7xZUfr3t5/qSpB7fIf8BBkfKh8wZvILf5dFrvapXa7DTG2VYvZSXqBpqW3RFrcwMH1+eMOX2QTxYS67M/DH6Idnk12VvL67jupaU2C0ko0wOWUD5Upr1fSKtCzXPUfTLykOdvruYEcpd9u6EvDc8UWySRL5kuEbQHfvat+eWbeNbySdH+z0yiQesQgrpwbfc6BTHBEuFk9z4ZVIJRRXTG5xqU0Aak7h7NHxf78+hbtk9uLZw9M/vz/98QjubxcrL54dzYEl/dNHR8//eQzXZRrByePj53//fPL0ydm3f3XbyfnYzSGtDPTtLJJhErG3hHcx15PC4eXxJ/3ZbKEG81zj0BGmC7EWpVWvDHORFxCXi/ZNkm2KFD2EAeoEfiQYMRfg3wK+xSI670QuCGEFRbIMWrlULoFqeWLfqBzQW7vEAgvVUWGeV6wQG+Ylav8VgvZXEPPyEH0bYSUpThNbJbJsEI5bD0TMsZ9SO/CcYTxJwoilutbQdUc3CrXmR1G8f4tkMs0Y9SKy2WPDb5bIH4gDJfOceylDyp5iids16acYg17e2r7DDvDZLmKisgi1L1j8kok4yhRGrfZZxkcIu6RHaG9Sgw75+L7YOpCMCxOUAqo0dUf1HGzP1y8IfQ7349YnZIdtErNYmQheYyppgoy/9iNMFuz7crjXBJX4DM+uPq+gHZcsDfwhg3tpnGhMVEq0QUf1aNQD9IYFsdkBIVMMmER2ykCdgRoLjIiKB3XtTIAK69Drl43bGxtw+sPxyXe/6LqGjTa5LgoVrXfjOGI+1yODbtqJgtiD/VDu3WCBj7rCHbEg5EyBF131H5t8U4dRAmOxEIxb23fvtDSWMDg0K3RWxn8H8Mhp5xHK8/X5iMEmoUQMenELX0SXvDWyZIT135knr9GBwVRnUQEbNEpLOypfxYhTnaZu8lCSjovb+UXuyEMYgLumhOtmslIvlE0NDS8CwkdwFGDip4K5tVjJXDvVG6qUtkzUhSM7uRUbbpoHDEifMw1xNbXqbEeB65HKrUSlHdpMEBiqUpdMmuDSGREQlWt3SWJtgWsb2iPHU8dTYYm+fpCX50LLfz17FdySH5UINaLHAZWYzQf0ej1o6Ow16slRXpYkY22tsoVC9rJBchROTSuja2Y6N73g7Ubu1XJFt/Q075mKlIeYbsv/lBFF6gYBytbWzIudA9BU1z2aBnFq+wGEZmCnzkC72cWSNf5QmJVvmL1fOaajcNDWMhNbW2MtuqgOut44Fn7WlGhP2Wnq04Rk6Y/ex9oGRbdtoTRtawWkecZHwrCK53gK19tizQW3VJKbMIeGv8pQzb6+lGPLliunWgN3tVxbBv2e8G6VAapcsYnkiGdqOZnuwXtKzLWwqmwMvliSeIJ3PqHrvT4Q+cVE/q7QOH3vakmB7M0OiGLnjEmfxmZequz1pybDVSQqvjbpr0Aky538f3lI90M=)取得。 有其他想法請不吝告訴我,鱈魚感謝您!%(o゜▽゜)o☆% ## 總結 🐟 * Vue 3.3 新增泛型元件 * 泛型元件可以讓 TypeScript 推導更精確 --- --- url: /blog-vue/vue-shallow-ref-and-ref.md description: 熟悉 Vue 的朋友們一定知道 `ref` 是啥,一起來看看 `shallowRef` 和 `ref` 有甚麼差別吧!ԅ( ˘ω˘ԅ) --- ![vue-shallow-ref-and-ref](/vue-shallow-ref-and-ref.webp){.cover} # Vue 的 shallowRef 是啥?和 ref 有甚麼差別? 熟悉 Vue 的朋友們一定知道 `ref` 是啥。ԅ( ˘ω˘ԅ) 不知道大家有沒有注意到 Vue 還有一個名字有點像的 API,叫做 `shallowRef`。 `shallowRef` 和 `ref` 有甚麼差別呢? 顧名思義,簡單來說 `shallowRef` 只對淺層(第一層)進行響應式處理。 也就是說以下程式碼。 ```vue ``` 當我們點擊「更新資料」按鈕時,`{{ shallowData.cod.length }}` 部分不會被更新,文字會保持 shallowData: 0。 除非 `updateShallowData` 內容改成: ```ts async function updateShallowData() { const cod = [...shallowData.value.cod, 'fish'] shallowData.value = { cod } } ``` 這樣就會因為第一層,也就是 `shallowData.value` 的值被改變,才會觸發響應,讓畫面更新。 或者使用 `triggerRef` 觸發響應也可以。 ```ts async function updateShallowData() { shallowData.value.cod.push('fish') triggerRef(shallowData) } ``` 看到這裡你可能會想「怎麼搞得更麻煩了?ლ(´口\`ლ)」 別急別急,讓我們來看看為甚麼需要 `shallowRef` 這個玩意兒。 ## shallowRef 的使用場景 先來個工程師的好習慣,第一步先看看[文件寫甚麼](https://vuejs.org/api/reactivity-advanced.html)。 其實文件說得很清楚,主要目的是為了「性能考量」與「外部狀態管理整合」。 文件也給了具體例子: * [Guide - Reduce Reactivity Overhead for Large Immutable Structures](https://vuejs.org/guide/best-practices/performance#reduce-reactivity-overhead-for-large-immutable-structures) * [Guide - Integration with External State Systems](https://vuejs.org/guide/extras/reactivity-in-depth#integration-with-external-state-systems) ### 大型物件 對整個龐大物件進行響應式處理,會導致性能下降。 只看文字說明不夠具體,讓我們用程式碼證明看看吧。 考慮以下程式碼。 ```vue ``` 以上程式的邏輯為: 1. `createData` 建立一個長度 10000 的大型物件。 2. 分別使用 `ref` 和 `shallowRef` 對 `createData` 的結果進行包裝。 3. `performance.now()` 紀錄更新資料所需時間。 在我的電腦上 `ref` 大約需要 7ms,`shallowRef` 大約需要 0.1ms。 可以注意到 `shallowData` 的更新時間比 `data` 快了近 70 倍!%( •̀ ω •́ )✧% 路人:「雖然差 70 倍,但實際上也才花了 7ms,平常也用不到這麼大的物件,這樣是不是不用 `shallowRef` 也無所謂啊?%(\*´・д・)?%」 鱈魚:「你說沒錯 %(\*´,,•ω•,,)%,平常很難遇到大型物件,不過還要是注意一些情境。」 ### 配合第三方套件 當你使用 pixi、babylon.js 這類套件時,套件本身會建立各種複雜物件,例如:Engine、Scene、Mesh 等等。 雖然一般情況下,完全不需要 `ref`,直接使用即可。 ```ts // ❌ 不需要 ref 包裝 const engine = ref(new Engine(canvas, true)) // ✅ const engine = new Engine(canvas, true) ``` 但是有時候還是需要響應怎麼辦?這個時候就可以用 `shallowRef` 了!%੭ ˙ᗜ˙ )੭% 直接使用 `ref` 包裝,會讓畫面會卡到爆炸喔。 除了卡到炸以外,`ref` 的 `Proxy` 還有可能破壞套件的內部邏輯,導致套件無法正常運作。 例如以下 issue: * [Bug: When Application is proxied by Vue 3(ref), an error occurs when rendering graphics](https://github.com/pixijs/pixijs/issues/10477) 可以看到裡面提到的解法也是用 `shallowRef` 解決。%◝( •ω• )◟% ::: tip 例如酷酷元件中的 [useBabylonScene](https://gitlab.com/side_project/chill-component/-/blob/main/src/composables/use-babylon-scene.ts?ref_type=heads#L67),用於快速建立一個 3D 場景。其中的 babylon.js 相關物件就是使用 `shallowRef` 包裝。 ::: 再進階一點還可以考慮使用 `customRef`,不過這個就留到未來有機會再聊啦。%( ´ ▽ \` )ノ% ## 總結 🐟 目前為止的經驗,性能問題通常是 DOM 太多,幾乎不會是 `ref` 的問題,過早使用 `shallowRef` 反而容易搞出其他 Bug 喔。%( ˙꒳​˙)% * `shallowRef` 只對第一層進行響應式處理,適合複雜物件。 * 配合第三方套件的複雜物件時,使用 `shallowRef` 可以避免破壞物件內部邏輯或拖垮性能。 --- --- url: /blog-vue/nuxt-ui-v4-revisited.md description: 快一年前寫過 Nuxt UI v4 初探,當時列的坑補了幾個?這一年又多了哪些好東西?ლ(´∀`ლ) --- ![nuxt-ui-v4-revisited](/nuxt-ui-v4-revisited.webp){.cover} # 一年後再看 Nuxt UI 大家好,我是鱈魚。(。・∀・)ノ゙ 去年九月寫過一篇 [初探 Nuxt UI v4](https://codlin.me/blog-vue/a-first-look-at-nuxt-ui-v4.html),文末列了一串「目前還沒有的東西」。 不到一年,Nuxt UI 已經從 v4.0 一路衝到 **v4.11**,元件數從 113 個長到 133 個 %੭ ˙ᗜ˙ )੭% \~~感謝 Vercel 乾爹~~ XD 來先驗收以前列的坑,再挑幾個這一年來有沒有甚麼有趣的酷功能!%੭ ˙ᗜ˙ )੭% ## 先看版本節奏 從去年底到現在的釋出時間表,大概一個月一個 minor: | 版本 | 時間 | 招牌功能 | | --- | --- | --- | | v4.1.0 | 2025/10 | Empty、五個元件支援虛擬化、componentDetection | | v4.2.0 | 2025/11 | InputDate/InputTime、`data-slot`、Tailwind prefix | | v4.3.0 | 2025/12 | Editor 全家桶、ScrollArea | | v4.4.0 | 2026/01 | Calendar 週次、CommandPalette 調校 | | v4.5.0 | 2026/02 | Theme 元件、四款新中性色、Toast 去重 | | v4.6.0 | 2026/03 | Sidebar、AI Chat 元件三兄弟 | | v4.7.0 | 2026/04 | Listbox、Link 自動 i18n | | v4.8.0 | 2026/05 | Theme 元件可設 prop 預設值、ContentSearch 非同步搜尋 | | v4.9.0 | 2026/06 | Calendar 月/年模式、useTour、theme.unstyled | | v4.10.0 | 2026/07 | InputRating、icon 打包進 build、unmountOnHide | | v4.11.0 | 2026/08 | Splitter、ProgressGroup、Vue 端 CSS 掃描剔除 | 節奏穩定到有點可怕,官方團隊真的很拚 %(\*´∀\`)~♥% ## 舊坑驗收時間 先給大家看結論表,細節在後面。 | 當初的抱怨 | 現況 | | --- | --- | | Table 沒有虛擬滾動 | ✅ v4.1 補上,而且一次給了五個元件 | | ui prop 不知道對到哪個 DOM | ✅ v4.2 `data-slot`,後面又加了 Theme 元件 | | 沒有 Time Picker | ✅ v4.2 `UInputDate`、`UInputTime` | | 沒有所見即所得編輯器 | ✅ v4.3 直接送一整套 | | `useFormField` 文件找不到 | 🔶 Form 文件裡有提到,但獨立頁面反而消失了 | | 元件無法從 `@nuxt/ui` 引入 | ❌ 還是只能走目錄 | | 沒有 directives、utils | ❌ 還是沒有 | | Toast 不能單獨設定位置 | ❌ 官方已標記 not planned | | 不能用 UnoCSS | ❌ issue 早就 not planned 了 | 九項裡面補了四項半,以一年來說算相當有誠意 %(・∀・)9% ### ✅ 所見即所得編輯器 這是我最意外的一項。當初的結論是「要自己引 Tiptap 或 TinyMCE」,結果 v4.3.0 官方直接端出六個元件: * `Editor`:本體,支援 JSON、HTML、Markdown 三種內容格式 * `EditorToolbar`:工具列,可自訂功能群組 * `EditorSuggestionMenu`:打 `/` 叫出的指令選單 * `EditorMentionMenu`:打 `@` 提及某人 * `EditorEmojiMenu`:打 `:` 插入 emoji * `EditorDragHandle`:拖曳把手,可重新排序區塊 底層就是 [Tiptap](https://tiptap.dev/),MIT 授權,沒有授權疑慮惹!%੭ ˙ᗜ˙ )੭% 最基本的用法只有一行: ```vue ``` `v-model` 綁下去、`content-type` 選格式,就這樣 %(๑•̀ㅂ•́)و✧% 官方還附了 [Editor 模板](https://editor-template.nuxt.dev/),裡面有用 PartyKit 做的即時協作,還有接 AI SDK 的自動補字。 想加自訂功能的話,Tiptap 的 extension 系統整套開放,`editor` 實例也拿得到,天花板很高。 ::: tip Tiptap 相關套件掛在 peerDependencies,記得自己裝。 ::: ### ✅ ui prop 與 DOM 結構 當初的痛點是 `ui` 參數一堆 key,不知道對應到哪層 DOM。v4.2 加了 `data-slot` 已經緩解一半,這一年又多了三個新解法。 **第一招,Theme 元件(v4.5.0)。** 不用一個一個元件改,包起來就好: ```vue ``` 它不渲染任何 DOM,底層走 Vue 的 `provide` / `inject`,所以不管巢狀多深都吃得到。可以層層嵌套,內層蓋外層,元件上直接寫的 `ui` 優先權最高。 **第二招,Theme 元件也能設 prop 預設值(v4.8.0)。** ```vue ``` 某個區塊要整批換成大尺寸?包一層就好,再也不用複製貼上 `size="lg"` 三十次 %(´,,•ω•,,)♡% **第三招,slot class 可以寫成 function(v4.9.0)。** 以前傳字串是「合併」到預設 class 上,遇到要覆蓋的情境就得跟 `tailwind-merge` 角力。現在傳 function 就變成「取代」: ```vue ``` `:ui`、`app.config.ui`、`` 三個地方都通用。 **加碼,`theme.unstyled`(v4.9.0)。** 如果想連預設樣式都不要,直接在 build 階段剝光: ```ts export default defineNuxtConfig({ modules: ['@nuxt/ui'], ui: { theme: { unstyled: true, }, }, }) ``` 只留結構與 a11y 邏輯,樣式全部自己來,順便減少 HTML 與 bundle 肥肉。 路人:「你看起來也很需要 unstyled。%( ・ิω・ิ)%」 鱈魚:「說好的尊重呢?!%Σ(ˊДˋ;)%」 ::: warning 連結構性 class 也會一起剝掉(定位、transition、flex/grid 都算)。像 `Modal`、`Drawer`、`Calendar` 這種版面吃重的元件,得自己重新排版。 ::: ### 🔶 自定義 Form 欄位 當初的狀況是「`useFormField` 存在,但文件網頁沒有」。 現在反過來了,`docs/content/docs/3.composables/use-form-field.md` 這個檔案直接從 repo 消失,改成在 [Form 文件](https://ui.nuxt.com/docs/components/form)的自訂驗證段落用一句 tip 帶過: > You can use the `useFormField` composable to implement this inside your own components. 功能還在、`src/runtime/composables/useFormField.ts` 也還在,就是沒有獨立說明頁。想知道回傳哪些東西,還是得翻原始碼 %(´・ω・\`)% ### ❌ 元件顯式引入 還是老樣子。看一下 `@nuxt/ui` 的 `exports` 欄位就知道原因: ```json { ".": { "types": "./dist/module.d.mts", "import": "./dist/module.mjs" }, "./components/*": "./dist/runtime/components/*", "./composables/*": { "types": "./dist/runtime/composables/*.d.ts", "import": "./dist/runtime/composables/*.js" } } ``` 根路徑導出的是 Nuxt module 本體,所以 `import { UModal } from '@nuxt/ui'` 永遠拿不到元件。乖乖走目錄: ```ts import UModal from '@nuxt/ui/components/Modal.vue' ``` 好消息是 `./composables` 與 `./utils` 這兩個路徑開出來了,寫共用邏輯時方便一點。 ### ❌ Directives、Utils 翻了一下 `src/runtime`,資料夾只有 `components`、`composables`、`locale`、`plugins`、`types`、`utils`、`vue`,沒有 `directives`。 `utils` 裡面是 `ai.ts`、`form.ts`、`link.ts`、`tv.ts` 這類內部工具,跟 Quasar 那種通用工具庫是兩回事。 這塊還是繼續靠 VueUse 補,實務上影響不大。 ### ❌ Toast 單獨設定位置 [官方 issue](https://github.com/nuxt/ui/issues/808) 在 2024 年底就標記 not planned 了,現在文件還是同一句話: > Change the `toaster.position` prop on the App component to change the position of the toasts. 不過 v4.5.0 加了我覺得更實用的 **Toast 去重**。同樣 `id` 的 toast 再叫一次,現有那張會跳一下脈動動畫,畫面上永遠只有一張: ```ts const toast = useToast() // 連按十次也只會有一張,只是會抖十下 XD toast.add({ id: 'save-success', title: '儲存成功' }) ``` 手殘連點的使用者終於不會噴出一整排通知了 %(≧∀≦)% ### ❌ UnoCSS [issue #196](https://github.com/nuxt/ui/issues/196) 2023 年就 not planned 結案,六十幾個讚也救不回來。 現在 `tailwindcss` 掛在 dependencies 裡(v4.3.3),`tailwind-variants`、`tailwind-merge` 也是核心依賴,這條路基本上封死了。想用 UnoCSS 就只能看 [Una UI](https://github.com/una-ui/una-ui),不過生態系差距擺在那,我還是繼續用 Nuxt UI %( ˘・з・)% ## 酷酷的新東西 除了上面補坑的部分,還有一票純新增的功能。 ### Splitter(v4.11.0) 熱騰騰,八月才出的可拖曳分割版面: ```vue ``` 支援 min/max 尺寸、可收合、可巢狀、可直向。SSR 記得給 `id` 讓水合時對得上,另外 `auto-save-id` 會把版面存進 `localStorage`,使用者調好的比例下次還在 %(ノ>ω<)ノ% 寫 IDE 風格的工具或後台時超好用。 ### Sidebar(v4.6.0) 以前要自己拼 Dashboard 元件才做得出來的側邊欄,現在有專門元件了。桌機固定、手機自動變成 Modal、Slideover 或 Drawer: ```vue ``` 三種外觀(`sidebar`、`floating`、`inset`)配三種收合模式(`offcanvas`、`icon`、`none`),排列組合夠用了。 ::: warning v4.6.0 改用 Nuxt 的 `moduleDependencies` API,最低要求變成 **Nuxt 4.1.0**,升級前先確認一下。 ::: ### useTour(v4.9.0) 新手導覽終於不用自己刻,用法很簡單,整趟導覽共用同一個 `Popover`,換步驟時把錨點重新指過去就好: ```vue ``` `target` 收字串選擇器、function 或 `null`(`null` 就是置中不指任何東西)。它只管步驟狀態與錨點解析,內容跟按鈕全部交給你,彈性剛剛好。 會自動把目標捲進畫面,切換時 popover 也會平滑移動 %(ゝ∀・)b% ### 虛擬滾動與 Listbox v4.1.0 補虛擬滾動時,官方一口氣給了五個元件,`CommandPalette`、`InputMenu`、`SelectMenu`、`Table`、`Tree` 通通追加了 `virtualize` 參數: ```vue ``` `ScrollArea`(v4.3.0)則是有內建虛擬滾動的捲動容器,底層一樣是 [TanStack Virtual](https://tanstack.com/virtual/latest),任何自己刻的長清單都能套。 `Listbox`(v4.7.0)是常駐顯示的可選清單,內建搜尋與虛擬化。跟 `SelectMenu` 的差別在於它不會彈出浮層: ```vue ``` 雙欄式的挑選介面(左邊清單、右邊詳情)就靠它了。 ### unmountOnHide(v4.10.0) 這招很實用。`Modal` 與 `Slideover` 關閉時預設會卸載內容,表單填一半關掉就沒了。設成 `false` 就會留著: ```vue ``` 表單狀態、捲動位置、初始化很貴的子元件,都能撐過開開關關 %(´▽\`ʃ♡ƪ)% ### Icon 打包進 build(v4.10.0) 以前 icon 是執行時去 Iconify API 撈,離線就 GG。現在會直接嵌進 build 產物,SSR 當下就渲染得出來,離線也能用。 Nuxt 端靠 `@nuxt/icon` 的 client bundle 自動處理。純 Vue + Vite 專案則是 `@nuxt/ui/vite` 多了 `icon.clientBundle` 選項: ```ts import ui from '@nuxt/ui/vite' export default defineConfig({ plugins: [ ui({ icon: { clientBundle: { // 手動列 icons: ['lucide:heart', 'simple-icons:github'], // 或整包掃 scan: true, }, }, }), ], }) ``` 記得把用到的 collection 裝起來(`@iconify-json/{collection_name}`),不然沒東西可以打包。 ### CSS 掃描剔除終於下放到 Vue(v4.11.0) 這功能其實 v4.1.0 就有了,叫做 `experimental.componentDetection`。打開之後會掃過你的原始碼,只替真正用到的元件(連同它們的相依元件)產出 CSS: ```ts export default defineNuxtConfig({ modules: ['@nuxt/ui'], ui: { experimental: { componentDetection: true, }, }, }) ``` 一百多個元件的樣式全塞進去實在有點浪費,掃過一輪只留用到的,CSS 直接瘦一大圈。 v4.2.0 又補了兩個洞,lazy component 掃得到了,Nuxt Layers 也會一起掃。 不過這一年來它都只有 Nuxt module 吃得到,純 Vue + Vite 專案只能眼巴巴看著。%(╥ω╥\`)% 直到 v4.11.0,`@nuxt/ui/vite` 終於也支援了: ```ts import ui from '@nuxt/ui/vite' export default defineConfig({ plugins: [ ui({ experimental: { componentDetection: true, }, }), ], }) ``` 用 `` 動態渲染的元件掃不到,改傳陣列手動列出來即可。 Nuxt 與純 Vue 兩邊的功能落差又補平一項 %(´,,•ω•,,)♡% ### 純 Vue 的待遇一路補上來 把這一年的更新排在一起看,會發現純 Vue 專案的待遇正在追上 Nuxt。 * v4.3.0:`unplugin` 加了 `router` 與 `scanPackages` 選項,也支援 prose 元件 * v4.6.0:`Icon` 開放 Vue 端的全域設定,官方還架了獨立的 Vue REPL playground * v4.9.0:`vite` 外掛加了 `root` 選項,可以自訂 `.nuxt-ui` 目錄位置 * v4.10.0:icon 打包進 Vite build * v4.11.0:`componentDetection` 下放 文件也多了一整頁[純 Vue 的 SSR 指南](https://ui.nuxt.com/docs/getting-started/integrations/ssr)。Nuxt 那邊開箱即用,純 Vue 得自己用 `@unhead/vue/server` 把顏色變數注進 ``,不然 SSR 會少一截樣式: ```ts import { createHead, renderSSRHead } from '@unhead/vue/server' const head = createHead() const payload = await renderSSRHead(head) app.head.push(payload.headTags) ``` Laravel Inertia 與 AdonisJS 的完整範例文件裡也都有,這兩個生態系要接 Nuxt UI 變得容易很多。 ::: tip 小插曲 當初在 Vite + Vue 專案照文件設定完,發現 theme 相關的 prop 型別提示缺一塊。 研究後發現是 `tsconfig` 少了 `#build/ui/*` 這條 path,補上就好了: ```json { "compilerOptions": { "paths": { "#build/ui/*": ["./node_modules/.nuxt-ui/ui/*"] } } } ``` [PR](https://github.com/nuxt/ui/pull/5706) 在 v4.4.0 釋出。 雖然只是改了一點點文件,不過能夠協助貢獻感覺很不錯 %(≧∀≦)% ::: ### Tailwind class 前綴(v4.2.0) 這個功能對舊專案來說很關鍵。 Nuxt UI 會產出一大堆 Tailwind utility class,跟你自己的樣式或其他 UI 函式庫撞名的機率不低。v4.2.0 開放了 Tailwind CSS 的 `prefix` 選項: ```ts export default defineNuxtConfig({ modules: ['@nuxt/ui'], css: ['~/assets/css/main.css'], ui: { theme: { prefix: 'tw', }, }, }) ``` CSS 那邊也要對應改一下: ```css @import "tailwindcss" prefix(tw); @import "@nuxt/ui"; ``` 之後所有 utility 都變成 `tw:flex`、`tw:p-4` 這種樣子,跟既有樣式井水不犯河水。 要把 Nuxt UI 塞進既有專案漸進式導入時,這招很實用。%(´,,•ω•,,)♡% ### 更加 AI 友善:Agent Skills 去年那篇寫了 MCP Server 與 LLMs.txt,這一年官方又多開了一條路,**Agent Skills**。 跟 MCP 提供即時工具呼叫不同,Skills 是把結構化的知識檔直接塞進 AI 的上下文,整段對話都參考得到。官方的 usage skill 涵蓋安裝、主題、全部元件、composables、表單驗證、版面組合與官方模板。 安裝靠 [`skills`](https://skills.sh) CLI,支援三十幾種 agent: ```bash # 裝進目前專案 npx skills add nuxt/ui # 指定 agent npx skills add nuxt/ui --agent claude-code # 全域安裝,所有專案共用 npx skills add nuxt/ui --global ``` 裝完在對話框打 `/nuxt-ui` 就能叫出來。 Claude Code 也可以直接用自己的指令裝: ```bash claude skill add https://github.com/nuxt/ui/tree/v4/skills/nuxt-ui ``` Skill 檔案本身就放在 [repo 的 `skills/nuxt-ui/`](https://github.com/nuxt/ui/tree/v4/skills/nuxt-ui),任何吃自訂上下文的工具都能引用。 順帶一提,ProsePrompt 元件在 v4.10.0 多了 `claude` 這個 action,文件網頁上的範例 prompt 可以一鍵丟進 Claude %(・∀・)9% ### 動畫節奏統一(v4.11.0) 所有動畫與位移轉場(含離場)統一走 `--ease-out`。想調整整套函式庫的手感,覆寫一個 token 就好: ```css @theme { --ease-out: cubic-bezier(0.16, 1, 0.3, 1); } ``` `prefers-reduced-motion` 的覆蓋範圍也擴大了,浮層改成原地淡入,`Tabs` 指示器之類的位移轉場會停掉。無障礙有顧到,讚讚 %(ゝ∀・)b% ### 其他小東西 * **Empty**(v4.1.0):空狀態元件,`icon`、`title`、`description`、`actions`、`loading` 一次到位,不用每個專案自己刻一份 * **InputRating**(v4.10.0):星等評分,支援半星、hover 預覽、自訂 icon * **ProgressGroup**(v4.11.0):多段式進度條配圖例,硬碟容量分析那種畫面 * **Calendar `type` prop**(v4.9.0):一個參數在日/月/年選擇器之間切換,標題也變成可點的按鈕,不用狂按上一頁下一頁 * **AI Chat 三兄弟**(v4.6.0):`ChatReasoning`、`ChatTool`、`ChatShimmer`,串 AI SDK 的訊息 parts 就能做出有思考過程與工具呼叫的聊天介面 * **四款新中性色**(v4.5.0):`taupe`、`mauve`、`mist`、`olive`,來自 Tailwind CSS v4.2 * **Link 自動 i18n**(v4.7.0):裝了 `@nuxtjs/i18n` 之後,所有吃 `to` 參數的元件自動走 `$localePath` * **統一 focus 樣式**(v4.9.0):所有元件共用同一套 `focus-visible` 光暈,顏色跟著元件的 `color` 走 * **Form 程式化送出也走 HTML5 驗證**(v4.5.0):呼叫 `form.submit()` 時會先跑原生驗證,送出按鈕放在 Modal footer 那種情境特別有用 * **Table row pinning**(v4.6.0):可以把指定列釘在上下兩端,v4.7.0 又補上虛擬化模式下的 sticky header/footer * **`useToast` 的 `max`**(v4.1.0):全域設定同時最多顯示幾張通知 * **`Popover` 的 `enableTouch`**(v4.10.0):觸控裝置上也能觸發,久違的 [#2346](https://github.com/nuxt/ui/issues/2346) 終於結案 * **Prose 標題錨點與複製按鈕可設定**(v4.10.0):拿 Nuxt UI 寫文件站的話會用到 * **`Checkbox`/`Switch` 的 `trueValue`/`falseValue`**(v4.6.0):接後端那種 `'Y'` / `'N'` 欄位不用再自己轉 * **icon 可以用 `false` 隱藏**(v4.9.0):不用再想辦法塞空字串 * **新增 composable 文件**:`useScrollShadow`(捲動陰影)、`extractShortcuts`(從選單項目自動抽出快捷鍵)、`defineLocale` 與 `extendLocale` ## 升級要注意的地方 跨這麼多版本,難免有 breaking change。 \~~不過不是問題,當然是鞭打 AI 叫他給我升到好。~~ %◝( •ω• )◟% | 版本 | 異動 | | --- | --- | | v4.1.0 | `CommandPalette` 的 `trailing-icon` 改給 input 用,子項目的 icon 改用 `children-icon` | | v4.1.0 | `Table` 的 `select` 事件參數順序調整 | | v4.2.0 | `InputMenu`、`InputNumber`、`InputTags`、`Select`、`SelectMenu` 的 exposed ref 統一回傳 HTML 元素,不再給元件實例 | | v4.6.0 | 改用 Nuxt 的 `moduleDependencies` API,最低需求變成 **Nuxt 4.1.0** | | v4.8.0 | `InputMenu` 的 `autocomplete` 改名 `mode`,值為 `'combobox'`(預設)或 `'autocomplete'` | v4.8.0 那個改名非改不可。原本的 `autocomplete` 跟 HTML 原生屬性撞名,瀏覽器自動填入(`email`、`one-time-code` 那些)會失效。改名之後原生屬性就能正常穿透到內層 input 了: ```diff - + ``` 官方在 v4.1.0 的 release note 還特地道歉了一下,說一百多個元件偶爾就是得修正一致性。我是覺得這種頻率完全可以接受啦 %(´∀\`)% ## 總結 🐟 * 一年內從 v4.0 到 v4.11,元件 113 個變 133 個,一個月一個 minor,節奏穩定得誇張 * 當初列的九個坑補掉四個半,**Editor 全家桶**影響最直接,商業專案的授權疑慮直接解決;剩下三個(顯式引入、directives、UnoCSS)是架構取向問題,短期內不用期待 * **Theme 元件**加上 slot class function、`theme.unstyled`、`theme.prefix`,客製化路線從「猜 DOM」變成「有工具可用」 * 純 Vue 專案的待遇一路補上來,SSR 文件、icon 打包、`componentDetection` 陸續下放,Nuxt 與 Vue 的落差越來越小 * AI 整合從 MCP、LLMs.txt 延伸到 **Agent Skills**,一行 `npx skills add nuxt/ui` 就到位 一年前的結論是「潛力無窮」,一年後看起來,的確不斷成長。%( •̀ ω •́ )✧% 如果去年因為缺編輯器或缺虛擬滾動而放棄的大大們,現在可以回鍋看看了 %ლ(´∀\`ლ)% 以上如有錯誤,還請多多指教。 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 --- --- url: /blog-program/optical-illusion-museum.md description: 拉動滑桿揭開錯覺真相,Web 上的互動式視錯覺展 --- ![optical-illusion-museum](/optical-illusion-museum.webp){.cover} # 互動式視錯覺展 大家好,我是鱈魚。%( ´ ▽ \` )ノ% 最近展示視錯覺圖片給小鱈魚看,結果反應超乎預期。 小鱈魚:「媽!老爸又在變奇怪的魔術騙人了。%ლ(╹口╹ლ)%」 鱈魚:「怎麼是這種反應。%(。-\`ω´-)%」 展示這些視錯覺圖片時,發現這些範例很適合以網頁為載體,做成可互動的展示。 不然證明錯覺只能用手指遮擋,很不方便,絕對不是因為手指太肥 %( ˘・з・)% 這裡我選了幾個有趣的視錯覺效果,每一個都有滑桿,方便從「錯覺」過度到「真相」。 一起來見證大腦被騙的瞬間吧!%( ´ ▽ \` )ノ% ## 亮度錯覺 人類的大腦在判斷「這玩意兒有多亮」時,不靠絕對亮度,而是和周圍的東西做比較。 ### 棋盤陰影錯覺 看看下面這張棋盤,A 格和 B 格其實是同一個顏色。%( ´ ▽ \` )ノ% 這個錯覺是 MIT 教授 [Edward Adelson](https://persci.mit.edu/gallery/checkershadow) 在 1995 年發表。 大腦為了幫你「看懂」這個場景,會自動對陰影區域做亮度補償。 它覺得「這裡有陰影,所以實際上應該比看到的更亮」,結果就擅自上調了 B 格的感知亮度。 導致明明是同一個灰色,卻不管是躺著看、橫著看還是站著看,怎麼看都覺得 B 比 A 亮。%ლ(╹ε╹ლ)% ### 同時對比錯覺 兩個完全相同的灰色方塊,一個放在深色背景上,一個放在淺色背景上,你卻會感覺左邊那個比較亮。 同時對比錯覺早在 19 世紀就有人研究,法國化學家 Michel Eugène Chevreul 在研究織品染色時就發現了這個現象。 大腦不會判斷「真正的亮度」,只在乎「相對於旁邊的東西,這個算亮還是暗」。 ### Hermann Grid 幽靈灰點 盯著下面的格子,在白線的十字交叉點看到若隱若現的灰色小圓點? 但只要你直接盯著某一個交叉點看,灰點就消失了。 這個現象最早由德國生理學家 Ludimar Hermann 在 1870 年發現,距今已經超過 150 年了。 成因是視網膜上的「側抑制」機制。 當較多亮色圍繞某個區域時(就像四條白線圍繞交叉點),周邊的神經細胞會互相「壓制」,讓你覺得那個地方沒那麼亮。 結果就是明明是白色的交叉點,你卻看到了根本不存在的灰色圓點。 ## 顏色錯覺 大腦不但會搞錯亮度,連顏色都可以無中生有。%(´⊙ω⊙\`)% ### 霓虹色擴散 看看下面這張圖,中間的圓形區域是不是隱約有一層淡藍色的薄紗?但其實圓形區域的背景和外面一樣,都是純白色,唯一的差別只有線條的顏色。 這個現象稱為「霓虹色擴散」,Dario Varin 在 1971 年首先描述,Harrie van Tuijl 在 1975 年進一步研究並命名。 當黑色線條有一段換成彩色時,大腦會自動在彩色線段之間「補上」一層透明的顏色。 ### Munker 錯覺 左邊的球看起來偏紫藍,右邊的球看起來偏黃綠?但其實兩顆球都是完全相同的青色。 這個錯覺由 Hans Münker 在 1970 年的博士論文中首次描述。 當密集的彩色條紋覆蓋在目標色上方時,大腦會把條紋的顏色「混」進去。 導致紫色條紋讓青色看起來偏藍紫,黃色條紋讓青色看起來偏黃綠。 ### 互補色殘影 盯著下面的彩色圖案中心十字 15 秒,然後把滑桿拉過一半。 你會在空白畫面上看到紅色的圓、洋紅色的三角形、藍色的方塊。 殘影現象早在 19 世紀初就有紀錄,歌德在《色彩論》中就有詳細描述。 長時間盯著某個顏色,負責感應該色的視錐細胞會疲勞。 當你移開視線看白色時,其他未疲勞的視錐細胞會「搶戲」,讓你看到互補色,像極了那些摸魚但很會刷存在感的同事。%(›´ω\`‹ )% 青色的互補色是紅色,綠色的互補色是洋紅,黃色的互補色是藍色。 ## 幾何錯覺 大腦在判斷「大小」和「長度」上也差不多隨便。%(・ω・)% ### Ponzo 透視錯覺 上方的黃色橫線看起來是不是比下方的長?其實兩條線完全一樣長。 這個錯覺由義大利心理學家 [Mario Ponzo](https://en.wikipedia.org/wiki/Ponzo_illusion) 在 1911 年提出。 鐵軌向遠方匯聚營造出透視感,大腦會根據透視線索推斷「遠處的物體看起來這麼大,實際上一定更大」,於是擅自認定上方的線更長。 ### Ebbinghaus 大小錯覺 左邊的橘色圓看起來是不是比右邊的小?被大東西包圍就覺得自己小,被小東西包圍就覺得自己大。 這個錯覺以德國心理學家 [Hermann Ebbinghaus](https://zh.wikipedia.org/zh-tw/%E8%89%BE%E8%B3%93%E6%B5%A9%E6%96%AF%E9%8C%AF%E8%A6%BA) 命名。 大腦在判斷物體大小時,也會拿它和周圍的東西做比較。 大圓包圍 → 中心圓看起來小。 小圓包圍 → 中心圓看起來大。 路人:「難怪站在你旁邊會顯瘦 %◝( •ω• )◟%」 鱈魚:「你今天是來找碴的嗎?%(゚皿゚)%」 ### Müller-Lyer 箭頭錯覺 上面那條線看起來是不是比下面那條長?(不包含箭頭部分) 這個錯覺由德國社會學家 [Franz Carl Müller-Lyer](https://zh.wikipedia.org/zh-tw/%E7%B1%B3%E5%8B%92-%E8%8E%B1%E5%B0%94%E9%94%99%E8%A7%89) 在 1889 年提出,是最早有系統研究的幾何錯覺之一。 向外張開的箭頭讓視線往外延伸,大腦就覺得「這條線很長」。 向內收縮的箭頭把視線往回拉,大腦就覺得「這條線比較短」。 有個有趣的研究發現,生活在「盒子型建築」環境裡的人(就是現代都市人)比較容易被這個錯覺騙到。 可能是因為我們太習慣用直角來判斷距離了。 ### Jastrow 弧形錯覺 下面的弧形看起來是不是比上面的大?同樣的形狀,只是擺放位置不同,感覺就完全不一樣。 這個錯覺由美國心理學家 [Joseph Jastrow](https://zh.wikipedia.org/zh-tw/%E5%8A%A0%E6%96%AF%E7%89%B9%E7%BD%97%E5%9B%BE%E5%BD%A2) 在 1892 年發現。 上面弧形的短邊(內弧)緊鄰下面弧形的長邊(外弧),大腦在兩者交界處做比較時,就會覺得下面的比較大。 鱈魚:「同事質疑我的披薩片特別大塊時,只要說是 Jastrow 錯覺,大家馬上就理解了!%( •̀ ω •́ )✧%」 路人:「我覺得他們只是覺得你沒救了 %(´・ω・\`)%」 ### 咖啡牆錯覺 下面的水平線看起來是不是歪歪斜斜的?但它們全部是水平的平行線。 1979 年,英國布里斯托的研究者 [Richard Gregory](https://en.wikipedia.org/wiki/Caf%C3%A9_wall_illusion) 在一家咖啡館的牆壁磁磚上發現了這個現象,所以才叫 Café Wall。 黑白磚塊的交錯排列配上灰色的間隔線,讓大腦誤判了線條的角度。 把磚塊拿掉,線就乖乖變回水平了。%(・ิω・ิ)% ### Zöllner 平行線錯覺 長線看起來是平行的嗎?看起來東倒西歪的,但其實每一條都是完美的水平線。 這個錯覺由德國天體物理學家 [Johann Karl Friedrich Zöllner](https://zh.wikipedia.org/zh-tw/%E7%AD%96%E5%B0%94%E7%BA%B3%E9%94%99%E8%A7%89) 在 1860 年發現,是最早被記錄的幾何錯覺之一。 和咖啡牆類似,不過它是用交叉的短線製造干擾。 ## 所以大腦為甚麼老是被騙? 會有這麼多錯覺是因為大腦不夠優秀嗎?其實這些錯覺反而是它強大的證明。%(・∀・)9% 鱈魚:「就像常常寫出 BUG,反而是強大的證明!%(「・ω・)「%」 同事:「????%('◉◞⊖◟◉\` )%」 視覺系統每秒要處理海量資訊,資料量之大,就像你那些只看的到 icon 的 Chrome 分頁一樣。%ᕕ( ゚ ∀。)ᕗ % 如果每個像素都要精確分析,大腦早就當機了。 所以大腦發展出了一套「捷徑」系統,用各種經驗法則來快速解讀眼前的畫面。 這些捷徑在 99% 的日常情境下都非常好用,只有這些特殊情境才會變成誤判。 同事:「就跟你總是漏掉的測試案例一樣嗎?%( ・ิω・ิ)%」 鱈魚:「別提這種傷心事了。・゚・(つд\`゚)・゚・」 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 以上有任何錯誤或不清楚的地方,還請各位大大多多指教。%( •̀ ω •́ )✧% --- --- url: /blog-program/improve-speed-using-memoization.md description: 最近遇到從大矩陣資料找出指定資料的需求,是時候使用 memoization 來提升效能了。੭ ˙ᗜ˙ )੭ --- ![improve-speed-using-memoization](/improve-speed-using-memoization.webp){.cover} # 你是個成熟的 function 了,該自己記得結果了 ```js const data = [ { name: 'A', color: 'red', condition: () => 'A' }, { name: 'B', color: 'blue', condition: () => 'B' }, { name: 'C', color: 'green', condition: () => 'C' }, { name: 'D', color: 'yellow', condition: () => 'D' }, { name: 'E', color: 'black', condition: () => 'E' }, // ... 200 多個 ] ``` 想要取得符合條件的資料,最直覺的方式就是用 `filter`,例如: ```ts function getData(color: string) { return data.filter((item) => item.color === color) } ``` 由於 `data` 不會變動,output 只與 input 相關。 此 function 又會大量使用,可以預期有非常多重複的運算。 這時候就可以使用 memoization 這個技巧,透過記憶化來提升計算效能。 ## 什麼是 memoization Memoization 是一種簡單的技巧,透過記憶化來提升計算效能。 當 function 被呼叫時,會先檢查是否有相同的輸入值,如果有就直接回傳記憶的結果,而不是重新計算。 就是一個使用空間換時間的概念。%◝( •ω• )◟% 使用 Vue 的朋友們,如果有用過 `computed`,其實都用過 memoization 了。 有興趣的朋友們可以看看我先前的文章「[善用 computed](https://codlin.me/blog-vue/make-good-use-of-computed)」,裡面有詳細探討。 ## Lodash memoize 這次我直接使用 Lodash 提供的 [`memoize`](https://www.lodashjs.com/docs/lodash.memoize) 來實現 memoization。 口說無憑,讓我們實際跑跑看 benchmark,看看實際上差多少。 使用 [BenchJS](https://benchjs.com/) 這個酷炫的網站,可以直接在 Web 跑 benchmark,非常方便。 首先在 Code 分頁中,準備資料並撰寫實作。 首先在 `setup.ts` 裡面建立測試資料,export 後就可以在各實作中共用。 `setup.ts` ```ts function generateTestData(size: number) { return Array.from({ length: size }, (_, i) => ({ key: `${i}`, value: i })) } export const length = 200 export const data = generateTestData(length) ``` 接著在 implementations 中建立兩個檔案,檔名與實作相同。 `implementations/memoize.ts` ```ts import { memoize } from 'lodash' const getData = memoize((key: string) => data.find((datum) => datum.key === key), ) export function run() { getData(`${length - 1}`) } ``` `implementations/no-memoize.ts` ```ts const getData = (key: string) => data.find((datum) => datum.key === key) export function run() { getData(`${length - 1}`) } ``` 可以看到資料與實作都一樣,只差在 `memoize.ts` 的 `getData` 有 memoize。 現在讓我們到 Compare 分頁,按下 Run All。 BenchJS 會開始跑 `run` 的內容,並且計算時間。 ![compare-result](/improve-speed-using-memoization/compare-result.png) 可以看到 `memoize` 比 `no-memoize` 快非常多。 不難理解,因為原本的操作之時間複雜度是 O(n),而 memoize 後只需要 O(1)。 [範例網址](https://benchjs.com/playground#/N4IgzgLghhCmIC5QDdYCcwEsD2A7RAjADQiYC2ADgDaxmy7QQ65iIDaomAJoiAEoAjAGa40AYwBeADQBaABRkCAksgDuABgCcADwC0ADgCKIEkMw1cUOrzplsmCbAB0EViTF44DXuQrY0EAAEwIG29o6BAL6BQmjYZIEA5FTYXFBgABaJADq4uR4sQQDmsBAAIjBQgQC8obThsAAUjQDWsACeCIGQaJi4RQCUNQB8uYGBadBOZrhczZMArmRD1cMTMEtObe011bXbA0S5A7m5sNp+ATELuGJMeIFoN41DwGOBJeWVjQAGACTACxFCAZQK6QIESI-E64SIgSJETg8BDgMSwABW2AEACYFmg+FB9AAxAASLRk6QA8pgKCYQGYLFZ4CjcNhdGEHM5XHSCl4ILwCpAPqUKtAaoFWh0uj0+oMRuspjM5o1Fst5aqth1dvsOjCzhd-EEhDc7sxHs9Xu9PqKoL8AUCQWCIVCYXDIgBdEhQU2oAAqUGUyPApQWFBcbmDEFDAGFUsz6Sb7rhhbh0DBYL7YJAbY0sI4urglgJ0Jbk48Q2hkwBBNBoKDtaZxMiNEIOjLSzlRIgSgD63cwKzWLfe422XX+wEwUKOZfGyCgVAWsC6mHekQGrv1lyCgqCbfF2PU6i3hsCu4VVVqJVTdbgmez3zbAzpYBDFDKvoAyrwuLAxFQoDQWAPhSAQF2Cd5f3-QDgPPfdakPdQAG5IL-ACgLPPAhUmKAujeWdxkCMduggXp+hQgi5wXJcCyLdAKPGSI2HdBjAlySJTlTA0rmASIkLpICoC4OhYwYeh+RRABiQJDAWTAxBaQJP2gK4AHE5N-TjJOkolzGA5SnjuPFYFycFXyjMNXC6T830w39AggbBAgoICKBgi9uzEQS4ECDJYCoCh0GuW4kzAbtSjEJxTMCXwaDoBgYGYMAAHoAConGAVwiHRMBIi6JRKDi8TEoeBks0whK+llQJi1uDIyEAloiTyFrtMCAB1XomH6QIACF6DEerGrATiCCcJTbNzN9wwGBBOIAUVQNB2hBar2mwBZAnObdAlUcwqBq4CoHncwAxoQJ0gukCsXAoR-C271QVi2hiqTKKWp+T6IHaQKwC8mkIFyZLkrPbyjtmLbuKCOBsMqE8rnPHDxRrOsG1ieIW0CNsugII91C7Xt+0HGKBgo7bT3PMAlnFRpALQWiyGLNBmOJumnCArgFjRZooG7ARWcCABqGru3UUnck+n5OOxcaCuoF6EtCiVziseWZrmlq5aKxXmF0MBArETAzDEOzgKKbByr8oDuygCH1s21NYC4BynPJq4qmNEKzUsOhncSJ5cESd6Ja+n6s3+ihAa4nbzwDmnifw8YgKjSsL3Zp2uaaKmyFF8XYRDqX8BIKhMAEOteizdhQB9+MUjSTJ4XdBEQCWrA8EISIgA) ## 為甚麼不要都 memoization 就好? 因為實作 memoization 也需要額外的計算與記憶體。 在資料很少、計算負荷極低的情況下,可能沒什麼差別,甚至可能比較慢。 在這次的例子中,把 `length` 改成 5,再跑一次 benchmark。 ```txt memoize.ts 141,218,403 (-0.7%) no-memoize.ts 142,192,549 Best ``` 會看到結果很接近,甚至 `no-memoize` 還比 `memoize` 快一點。 所以實際上要看情況使用,不是所有情況都適合使用 memoization。 真的遇到性能瓶頸,需要進行改善時,再來評估改進方案。 記得要跑 benchmark,有多少證據,說多少話喔。%( ´ ▽ \` )ノ% ## 總結 🐟 * memoization 可以提升效能,透過記憶化來避免重複計算。 * 不是所有情況都適合使用 memoization,要看情況使用。 * 記得要跑 benchmark,才能確定是否真的有提升效能。 --- --- url: /blog-vue/do-not-always-fetch-api-in-on-mounted.md description: Vue 你還在 onMounted 中取 API 嗎?其實不一定要這樣做,來看看不同做法吧。( ͡~ ͜ʖ ͡°) --- ![do-not-always-fetch-api-in-on-mounted](/do-not-always-fetch-api-in-on-mounted.webp){.cover} # 其實 Vue 不一定要在 onMounted 取 API 大家好,我是鱈魚。(。・∀・)ノ゙ 最近看到大家討論取得 API 時機,有許多人都提到「一定要」或者「習慣」,在 `onMounted` 這個 hook 內呼叫 API 取得資料。 其實這也沒不好,但是也沒什麼好處就是了。(。・ω・。) 讓我們來思考一下下,有關於 Vue 取得 API 資料這檔事。(~~不會轉生,也沒有史萊姆~~) ## 「一定要」在 onMounted 有人說「一定要」是因為不這麼做會壞掉,這個可以從 2 個部分來看。 首先是 `onMounted` 這個 hook 是甚麼?讓我們看看官網的定義: > Registers a callback to be called after the component has been mounted. 意思就是「註冊一個會在元件安裝完成後被呼叫的 callback」。 甚麼意思呢?也就是他和有沒有資料一點關係都沒有。`onMounted` 沒辦法幫你保證有資料、template 不會壞,從此天下太平,再也不用加班,。(´。_。`) 所以就牽扯到了第二部分:為甚麼會壞掉? 馬上取會壞,`onMounted` 取就不會壞,這表示你的資料可能和 DOM 或元件發生耦合,這其實不是好現象,不但違反了資料驅動的概念還埋了隱形的~~翅膀~~…地雷。 具體原因可能如下: * API 參數耦合了某個 DOM 的資料,導致「沒有在 `onMounted` 取值」等於「對 undefined 動手動腳」,當然就壞掉惹。(〃` 3′〃) * API 參數或操作耦合了某個子元件,而 Vue 的 `onMounted` 會保證子元件都 `onMounted` 完後,自己的 `onMounted` 才會觸發,導致 `onMounted` 取才沒事。 所以總結來說,所謂的「壞掉」很有可能只是資料流沒考慮 undefined 的情境。 ::: tip 可能啦,我巫力不足,只能通靈到這裡惹。 ╮(╯▽╰)╭ ::: 如果有使用 TypeScript 就很少會出現這類問題了,因為 TypeScript 會先不開心、滿江紅給你看。ᕦ(ò\_óˇ)ᕤ ## 「習慣」在 onMounted 習慣上可能會這樣寫: ```vue ``` 考慮讀取狀態則可能再加個 loading: ```vue ``` 剛剛說了,其實不用在 `onMounted` 才取資料,早一點取也行,所以可以變成這樣: ```vue ... ``` 路人:「這樣真的有比較好嗎?」 鱈魚:「當然沒有。(≧∇≦)ノ」 路人:(抄起球棒) 鱈魚:「冷靜冷靜,還沒說完啊。(っ °Д °;)っ」 ## 來點 Composition 讓我們回想一下 Vue 3 的 Composition API 精神,一但要取得資料變多後,仔細觀察可以發現「資料、狀態(isLoading)、取資料的 function」會重複出現。 讓我們抽離、重組一下,新增一個檔案。 `useAsyncData.ts` ```ts import { ref } from 'vue' interface UseAsyncDataParam { executor: () => Promise; onError?: (error: unknown) => void; } export function useAsyncData(param: UseAsyncDataParam) { const data = ref() const isLoading = ref(true) param.executor().then((result) => { data.value = result }).catch((error) => { param.onError?.(error) }).finally(() => { isLoading.value = false }) return { data, isLoading, execute: param.executor, } } ``` ::: tip 寫過 Nuxt 的朋友們一定會有熟悉的感覺。(. ❛ ᴗ ❛.) ::: 現在讓我們用這個 `useAsyncData` 改寫一下剛剛的例子看看。 ```vue ``` 就算有多個狀態要取得也變得更好組織程式碼了,是不是看起來乾淨許多呢? 恭喜我們重複造了 Nuxt 的 `useAsyncData` 與 VueUse 的 `useAsyncState` 的輪子了! ✧⁑。٩(ˊᗜˋ\*)و✧⁕。 沒錯,雖然目前這個 `useAsyncData` 的功能很簡陋,有很多需要改進的部分,但是這就是上述兩個 API 的基礎精神。 不過 Nuxt 還包含了 SSR 行為,所以更加複雜,推薦大家可以看看 VueUse `useAsyncState` 的[文件](https://vueuse.org/core/useAsyncState/#useasyncstate)或[原始碼](https://github.com/vueuse/vueuse/blob/main/packages/core/useAsyncState/index.ts),一定可以獲益良多。 文章到此結束了,感謝您的閱讀,以上範例程式碼可以[在此取得](https://play.vuejs.org/#eNrNVb1u1EAQfpWVm/NJhy8oVJdLohBSgPhJIDTIzeKbS5z4dq3d9eXCyQiJgpIuEg1vAA0NFLwNoHR5BWbW6z378gMVoohjz8w3O/N9M3vzYCvPo2kBwSAY6kSluWEaTJFvxCKd5FIZtp1xrdNkm2tgYyUnrBP1GzYCd9YW0RL/a/4ygzagZa4xw351JB6GHwYmecYN4Bdjw+axfWdq5ybrsN9ABb3A6ESKcXoQHWkpsKc5AeMgQWSagXqSm1QKHQcDZj3k41kmTx5Ym1EF9Gp7cgjJ8RX2Iz0jWxzsKtCgphAH3me4OgBTuXeePYYZvnvnRI6KDKNvcD4FLbOCaqzC7hZihGU34my19y3XqTjY1zszA0LXTVGhFFna+DhAnom061pflLsa3bG4WJTI4pK8y7PBMi4O1hGO2RZzMmdSPJKFMDDqMQVjVjr12wMyZ3jkcySux+jpo6I+z9MODQIqqA0ryLlOiYYUtxF2MUflSvVDyUfYfuUPqWvyxsKlDruROQQRhiNueJetb1QtU8poyrMCEEguxJTdKOEmOQzDRRydIjOIQCmpws7eXoeyY+Q4Fcj/aTPW1+ITj3mmwcb/YcIPb284ood9fLe2UTpl01vpGNn1mS3JJNj5l3e/zj6cf37z8/3Zj6+foigKL75/vPj2Fp/d169e2Ax9TNHMBViNw1P/qP7cvmxGgk+AlSj5ArW8TihJZDTqDzMrXorqqjFPoNJuHitCU6IB00ZhsWtkKulBfw42LkRC01dLj/w5qMJ5UoIJOGG7OAapBqd2iMslsyk4pm0wo/HbTycgC+M1cB5KZQFhw1SX1knkqLMwkzLuo+yx1ZWVldrgPLQDyNGWPhXJPZyTigM/wJeH27Xbosejd7nikyG9+XphBklhJKpRtVH3boNcLVLs0PxtYowdxAErxLGQJ8IipjId3cx1s4EqcZhTJYPrivOiVFtGC+IWsELXJN24hK4YxuxZUd2o30hUqchMWzs6ya9PFeD1qLfTUtCGVSfUNEUuZIFc3lYHu3ZjmxPQms5Wob1Lebylahbnrd2781uh7O166bfwn16wPqw5II2oprlxH9vbjgjAOaTkrcsPv0oksgnFPbxi0P0FYPFOO3sdWAavvnmr37O/uk89tf/plVr+BtH4PtM=)。 有其他想法請不吝告訴我,鱈魚感謝您!(o゜▽゜)o☆ ## 總結 🐟 * 不一定要在 `onMounted` 取 API,這樣做可能會讓程式碼更複雜 * 使用 Composition API 可以更好組織程式碼 * 重複造輪子是進步的關鍵,但是也不要隨意在正式專案造輪子,以免埋下地雷 --- --- url: /blog-vue/a-first-look-at-nuxt-ui-v4.md description: Nuxt UI 釋出 v4 了,免費開放許多進階元件,馬上來研究看看 ლ(´∀`ლ) --- ![a-first-look-at-nuxt-ui-v4](/a-first-look-at-nuxt-ui-v4.webp){.cover} # 初探 Nuxt UI v4 Nuxt UI v4 熱騰騰釋出囉!%੭ ˙ᗜ˙ )੭% 期待很久了,終於等到這一天,馬上來研究看看 %ლ(´∀\`ლ)% ## 研究目標 雖然 Quasar 目前滿足大部分情境,但是 Quasar 更新放緩,作者心力分散至 Quasar CLI、SSR 開發,許多 UI 相關 issue 排不進排程,隨著需求與限制增加,可能會遇到瓶頸 而我目前僅用到 UI,至於為甚麼只用到 UI,以下是我的考量: * Electron、Capacitor 整合: Quasar 雖然可以整合 Electron、Capacitor,但是截至目前的開發經驗,依照需求直接使用 Electron、Capacitor 會更好 因為 Quasar CLI 二次封裝後,不但更難除錯(很難知道是 CLI 有問題、還是框架有問題),且封裝後許多設定參數脫離原框架的文件,很難找到參考資料 * SSR: 我比較信任更成熟的 Nuxt,畢竟 SSR、SSG 是一個很複雜的問題,Nuxt 團隊有更多資源與人力可以解決問題 Nuxt UI 除了有官方背景外,還深度整合 Nuxt,潛力無窮,順便解決以下問題: * Quasar utils class 衝突問題(因為統一使用 Tailwind CSS 了) * Design Token 分散問題(雖然這個已經靠 UnoCSS 解決) * SSR 整合問題(Quasar + Nuxt 有時候會遇到一些水合小毛病) * Router 整合問題(Quasar + Nuxt 時,Router 跳轉按鈕不會使用 NuxtLink 元件) ## 背景資料 基於 [Reka UI](https://reka-ui.com/)、[Tailwind CSS](https://tailwindcss.com/) 與 [Tailwind Variants](https://www.tailwind-variants.org/) 開發,內部還整合了 VueUse、多個 `unplugin` 套件與資源,滿滿自家人的概念 %(◐‿◑)% 還有以下特點: * MIT 授權 * a11y 完善 * VueUse 深度整合 * Nuxt SSR 整合 * Vue i18n 整合 * [元件超多](https://ui.nuxt.com/docs/components) ## 重點筆記 ### 元件特點 以下筆記主要相對於 Quasar UI 出發。 ### ui prop [Nuxt UI:Customize components](https://ui.nuxt.com/docs/getting-started/theme/components#ui-prop) ```vue ``` 可以注入 class 至元件指定層級 DOM 中,這個功能與 `PrimeVue` 的 [`passthrough`](https://primevue.org/passthrough/) 類似 雖然實務開發上不是甚麼大問題,不過我自己有一個小疑問:基於 [BEM 規範](https://nedhsu.github.io/posts/CSS-Day25/)的 UI 套件若要魔改樣式,因為命名結構明確,可以很容易指向目標,但這類基於 Tailwind CSS 的 UI 套件若 `ui` 參數沒有開到,要指向特定的 DOM 是不是就需要相對複雜的 CSS 選擇器? ::: tip v4.2.0 新增 `data-slot` 屬性 v4.2.0 開始,對應 ui prop 的 DOM 會加入 `data-slot` 屬性,CSS 選擇器可以更容易指向目標 DOM 了 %(/≧▽≦)/% ::: #### Form [Nuxt UI:Form](https://ui.nuxt.com/docs/components/form) 欄位驗證可以直接用 Zod(支援 [Standard Schema](https://github.com/standard-schema/standard-schema) 的驗證器都可以) 配合 [ts-rest](https://codlin.me/blog-ocean-world/unleash-the-magic-of-type-script-with-ts-rest.html) 更簡單惹!%(ゝ ∀・)b% #### Icon 內部使用 [unplugin-icons](https://github.com/unplugin/unplugin-icons),[超多 icon](https://icones.js.org/collection/all) 可以用%!੭ ˙ᗜ˙ )੭% 會自動對 icon 進行按需引入(On-demand),不會載一大包字型檔案 ![icon](/a-first-look-at-nuxt-ui-v4/icon.webp) SSR、SSG 友善,不會有 [FOUC](https://inbound.technology/fouc-flash-of-unstyled-content-%E7%B6%B2%E9%A0%81%E6%A8%A3%E5%BC%8F%E7%9F%AD%E6%9A%AB%E5%A4%B1%E6%95%88%E6%98%AF%E4%BB%80%E9%BA%BC%EF%BC%9F%E5%8E%9F%E5%9B%A0%E7%82%BA%E4%BD%95%EF%BC%9F/) 問題 #### Dashboard [Nuxt UI:Dashboard](https://ui.nuxt.com/docs/components/dashboard-group) 一系列 Dashboard 元件,可以輕鬆拼出[專業後台](https://dashboard-vue-template.nuxt.dev/) ### Page [Nuxt UI:Page](https://ui.nuxt.com/docs/components/page) 一系列 Page 元件,幾乎內建 [Landing 頁面](https://landing-template.nuxt.dev/)會用到的基礎元件了 綜合 Dashboard 元件,Nuxt UI 用來開發前後台都很適合 ### Image [Nuxt UI:ColorModeImage](https://ui.nuxt.com/docs/components/color-mode-image) 沒有額外封裝 img 元件,不過有提供一個 **`ColorModeImage`**,自動依照明暗模式切換圖片 若有安裝 `@nuxt/image` 會在內部自動依賴 `NuxtImg` 元件 #### Table [Nuxt UI:Table](https://ui.nuxt.com/docs/components/table) 基於 [TanStack Table](https://tanstack.com/table/latest) 開發功能強大 cell 內容[同時支援 slot 與 h function](https://ui.nuxt.com/docs/components/table#with-slots) 更彈性 [`h` function](https://vuejs.org/guide/extras/render-function.html) 可以用來建立 vDOM,與 JSX 概念類似,適合用來渲染動態內容。 先前有一篇文章有提到其應用:[Vue h function 讓 Quasar Dialog 更容易重複使用](https://codlin.me/blog-vue/vue-h-function-makes-quasar-dialog-easier-to-reuse.html) ::: 如果內容只是簡單的調整樣式、排版,或簡單元件(例如:`Badge`、`Tag`) 等等,可以直接使用 `h` function: ```vue ``` 內容更內聚,不需要額外建立 slot 元件 但如果是較複雜的內容,還是使用 slot 更好,除了支援度更好外,template 編譯性能也比較好: ```vue ``` #### Modal Quasar 是 Dialog,在 Nuxt UI 中叫做 Modal [Nuxt UI:Modal](https://ui.nuxt.com/docs/components/modal) 開啟方式不同於 Quasar,可以不用傳遞額外變數控制開啟(有需要的話還是可以綁定變數) 在 default slot 放入按鈕,即可以開啟,內容則寫在 content slot 中 ```vue ``` ### Design System 承襲 Tailwind CSS v4 設計,設定變成基於 CSS 變數 [Nuxt UI:Design System](https://ui.nuxt.com/docs/getting-started/theme/design-system) ### Figma Kit 所有的顏色、樣式幾乎都已用 token 定義,與 CSS 名稱一致,可以直接複製貼上,元件也有定義相關變體,可以直接切換 這個資源很讚,這樣 UI 或 PM 要出設計稿或草稿時,都可以直接使用、不用重畫,更不容易出現認知落差。(・∀・)9 [Nuxt UI:Figma Kit](https://ui.nuxt.com/figma) ### Template 可以從模板借鑒~~抄襲~~內容過來用 (≖‿ゝ ≖)✧ [Nuxt UI:Templates](https://ui.nuxt.com/templates) ### AI 整合 #### MCP Server 可以與 AI 工具整合,甚至 [ChatGPT 也可以用](https://ui.nuxt.com/docs/getting-started/ai/mcp#chatgpt) [Nuxt UI:MCP Server](https://ui.nuxt.com/docs/getting-started/ai/mcp) 安裝完成後使用指令(輸入 /)會出現以下提示: ![mcp](/a-first-look-at-nuxt-ui-v4/mcp.webp) * **`find_component_for_usecase`**: Find the best component for your specific use 給它功能情境,會幫你從 Nuxt UI 元件庫中挑最適合的元件(或元件組合),並說明為什麼、需要搭配哪些 props/slots/events、還會附上簡短使用片段。 * **`implement_component_with_props`**: Generate complete component implementation with proper 當你已經決定「要用哪個 Nuxt UI 元件」與「要怎麼配置 props/事件/slots」,這個 prompt 會直接產出**完整可用的 SFC 實作**(含 ` ``` 若 Form 不能直接使用驗證器(Zod schema、ArkType type 等等),也不是大問題,來個轉換器就行。 以我常用的 Quasar 為例,可以參考此[實作](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/apps/admin-web/src/common/zod-validator.ts)。 ::: tip 感謝優秀的同事們 此轉換器是公司的夥伴們開發,我幫忙寫測試與文件,感謝他們的努力!%(´,,•ω•,,)% ::: ## 後端 ### 合約 + 測試 = 更可靠的 API 後端實作合約基本上就和概念篇相同,實作上最重要的部分是可以拿合約寫 e2e 測試,這樣只要測試有過,API 基本上不會有錯惹。%ԅ(´∀\` ԅ)% 所以要怎麼拿合約寫 e2e 測試呢? 概念如下: 1. 將資料輸入合約,取得 API 請求資料(例如 url、query string、body data) 2. 利用第 1 步取得的資料,使用測試工具(例如 Supertest)發送請求 接下來以 [`collection-data` 合約](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/packages/shared/src/collection-data/contract.ts)為例,一步一步來吧。 建立一個將 ts-rest 合約轉換成 API 請求資料的工具: `apps\api-server\src\common\utils\utils-ts-rest.ts` ```ts type ExtractQuery = T extends { query: infer U } ? U : never type ExtractBody = T extends { body: infer U } ? U : never interface UseContractReturn { url: string; method: Lowercase; query: Query; body: Body; } /** 協助取出合約 API 指定 API 內容 * * 主要用於 e2e 測試 */ export function useContract< Route extends AppRouteQuery, Data extends ClientInferRequest, >( route: Route, data?: Data ): UseContractReturn, ExtractBody> export function useContract< Route extends AppRouteMutation, Data extends ClientInferRequest, >( route: Route, data?: ClientInferRequest ): UseContractReturn, undefined> export function useContract< Route extends AppRouteQuery | AppRouteMutation, >( route: Route, data?: ClientInferRequest, ): any { const { method, path, } = route const url = path.replace( /:(\w+)/g, (match, key) => data?.params?.[key], ) if (data && 'body' in data) { return { url, method: method.toLowerCase() as Lowercase, query: data?.query, body: data, } } return { url, method: method.toLowerCase() as Lowercase, query: data?.query, } } ``` 測試工具使用 Supertest,建立一個將合約資料透過 Supertest 發送請求的工具: `apps/api-server/src/resource/collection-data/collection-data.e2e.ts` ```ts import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' import type { IncomingHttpHeaders } from 'node:http' import { collectionDataContract, } from '@ts-rest-practice/shared' import request from 'supertest' import { useContract } from '../../common/utils/utils-ts-rest' // 這裡不可能出現其他 Contract,所以可以簡短命名 type Contract = typeof collectionDataContract type ContractRequest = ClientInferRequest interface ApiOptions { headers?: IncomingHttpHeaders; } /** e2e 測試之合約轉換層 * * 將合約轉換成 API 呼叫,這樣就可以直接拿合約內容進行 e2e 測試 * * 也就是說只要合約有變動,這裡也要跟著變動,這樣就可以確保合約的正確性 * * 同時只要 e2e 測試通過,即表示合約正確,可以交付 * * 另一個附加好處是如果其他資源的 e2e 測試有依賴此資源,也可以直接拿去重複使用 * * @param server 測試環境中的 HTTP server 主體 * @param globalOptions 額外的選項。同 method 內的 options,method 內的 options 會覆蓋這裡的 options */ export function createCollectionDataApi( server: any, globalOptions?: ApiOptions, ) { return { async create< Code extends keyof Contract['create']['responses'] = 200, >( data: ContractRequest['create']['body'], code = 200 as Code, options?: ApiOptions, ) { const { url, method } = useContract(collectionDataContract.create, { body: data, }) const { body, statusCode, headers } = await request(server)[method](url) .set({ ...globalOptions?.headers, ...options?.headers, }) .send(data) if (code !== statusCode) { throw new Error(`${statusCode} : ${JSON.stringify(body, null, 2)}`) } return { headers, body: body as ClientInferResponseBody< typeof collectionDataContract.create, Code >, } }, // 其他方法同理 } } ``` 最後就針對此資源建立 e2e 測試(以下為參考,實作可以替換成自己喜歡的方式): ```ts type CollectionDataContract = ClientInferRequest describe('collectionData e2e', () => { /** 使用記憶體版本 MongoDB 運行測試 */ let mongodb: MongoMemoryReplSet let mongoConnection: Connection let app: INestApplication let server: ReturnType let collectionDataApi: ReturnType beforeAll(async () => { // ...啟動 Server 與 DB collectionDataApi = createCollectionDataApi(server) }, 60000) /** 測試結束,關閉 DB */ afterAll(async () => { // ... }) async function clearAll() { // ... } /** 每次測試開始前與結束時,都清空 collection 資料,以免互相影響 */ beforeEach(async () => await clearAll()) afterEach(async () => await clearAll()) function createCollectionData( params?: Partial, ) { const expectData: CollectionDataContract['create']['body'] = { name: 'test', description: '安安', ...params, } return collectionDataApi.create(expectData) } describe('建立 collection-data', () => { it('全部參數', async () => { const expectData: Required = { name: 'name', description: 'description', remark: 'remark', } const { body: result } = await collectionDataApi.create(expectData) expect(result).toMatchObject(pick( expectData, ['name', 'description', 'remark'], )) }) }) describe('取得 collection-data', () => { it('取得指定筆數資料', async () => { await Promise.allSettled([ createCollectionData(), createCollectionData(), ]) const { body: result } = await collectionDataApi.find({ limit: 1 }) expect(result.total).toBe(2) expect(result.data).toHaveLength(1) }) }) describe('取得指定 collection-data', () => { it('取得指定資料', async () => { const { body: data } = await createCollectionData() const { body: newData } = await collectionDataApi.findOne(data.id) expect(newData).toEqual(data) }) }) describe('更新指定 collection-data', () => { it('修改 name 為 cod', async () => { const { body: data } = await createCollectionData() const { body: newData } = await collectionDataApi.update(data.id, { name: 'cod', }) expect(newData.name).toBe('cod') }) }) describe('刪除指定 collection-data', () => { it('刪除資料後,find 無法取得', async () => { const { body: data } = await createCollectionData() await collectionDataApi.remove(data.id) const { body: result } = await collectionDataApi.find() expect(result.data).toHaveLength(0) }) }) }) ``` 為了防止篇幅過長,這裡只展示部分測試內容,完整測試程式碼可以[在此查看](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/apps/api-server/src/resource/collection-data/collection-data.e2e.spec.ts)。 理論上測試內容只要覆蓋合約內的所有 API 與業務邏輯之測試案例,就可以確保 API 正常交付惹。 至於測試案例怎麼列?這個就是一門藝術了。%(◜௰◝)% ## 總結 🐟 感謝大家看到這裡,以上為使用 ts-rest 實際開發的點點滴滴,內容與觀念會隨著時間推移而變化,所以本文會不定期更新。 特別感謝公司優秀的同事們,導入 ts-rest、專案結構、使用設計與實作,很多靈感都來自同事們的提問與建議,非常感謝大家。%( ‧ω‧)ノ╰(‧ω‧ )% 也感謝前端社群的 Danny 大大推薦 ts-rest 這個好東西,有任何問題還請大家不吝指教。%(\*´∀\`)~♥% 本文完整程式碼可以[在此取得](https://github.com/Codfisher/blog-ts-rest-practice)。 --- --- url: /blog-ocean-world/unleash-the-magic-of-type-script-with-ts-rest.md description: 'ts-rest 可以實現從伺服器到客戶端的全型別安全,可以有效降低前後端溝通血流成河的機率。(´,,•ω•,,)' --- ![unleash-the-magic-of-type-script-with-ts-rest](/unleash-the-magic-of-type-script-with-ts-rest.webp){.cover} # 前後端不再打架,讓 ts-rest 發揮 TypeScript 的魔法吧!(概念篇) 本文基於 TypeScript,如果沒有採用的小夥伴可以狠心離開惹。~(>\_<。) 或者一起湊個熱鬧。(´▽\`ʃ♡ƪ) 此文為概念篇,顧名思義就是來認識認識何謂 ts-rest。 ## 甚麼是 ts-rest? ts-rest 是基於 TypeScript 的 RESTful API 工具,主要目的在於實現從介面定義開始,涵蓋至伺服器、客戶端的全型別安全,而且無需額外、繁瑣的標註或生成過程。 風格類似 RPC,可以讓 API 呼叫更加直覺,配合 TSDoc 有更完整、詳盡的說明,可以大幅降低使用 API 的心智負擔。 ### 優點 * 前後端溝通基於 TypeScript 合約,有完整的型別提示,可以避免很多錯誤 * 資料欄位與端點更明確,可以讓使用者更容易理解 API 功能 * 降低發布、變更導致 API 錯誤的風險 ### 缺點 * 學習成本較高 * 需要遵守一定的規範 * 前後端需要有良好的溝通模式或管道 ## 從前有個後端和前端 以下範例以 Vue、NestJS 為例,相關套件版本如下: * @nestjs/core:`10.4.1` * @ts-rest/core:`3.51.0` * @ts-rest/nest:`3.51.0` * zod:`3.23.8` 他們想要合力串接一個資料,叫做 CollectionData。 ### 通用資料 前後端手牽手,先討論想要的格式。(*≧︶≦))( ̄▽ ̄* )ゞ 第一步定義傳輸層的 schema,方式與 zod 相同。 [定義 Zod Object](https://zod.dev/?id=objects) ```ts import { z } from 'zod' export const collectionDataSchema = z.object({ id: z.string(), /** 名稱 */ name: z.string(), description: z.string(), remark: z.string(), }) export interface CollectionData extends z.infer< typeof collectionDataSchema > { } // 用 type 也可以,取決於貴團隊的規範 // export type CollectionData = z.infer ``` 接著根據剛剛制定的 schema 和 API 功能定義合約: [ts-rest:Define Contract](https://ts-rest.com/docs/core/) ```ts import { AppRoute, initContract } from '@ts-rest/core' import { z } from 'zod' import { collectionDataSchema } from './schema' // 建立 collection-data export const createCollectionDataDtoSchema = collectionDataSchema.omit({ id: true, timestamp: true, }).partial({ remark: true, description: true, }) /** * 使用 satisfies 是為了 AppRoute 的欄位提示又可以保留具體內容定義。 * 當然 as const 也行,只是輸入的過程不會有 AppRoute 欄位提示。 * const create: AppRoute 會遺失詳細的內容定義,所以最後採用 satisfies。 */ const create = { method: 'POST', path: '/v1/collection-data', body: createCollectionDataDtoSchema, responses: { 201: collectionDataSchema, }, summary: '建立 collection-data', } satisfies AppRoute export const collectionDataContract = initContract().router({ create, }, { pathPrefix: '/api', commonResponses: { 500: z.object({ message: z.string(), }), }, }) ``` 路人:「怎麼只有一個 create?ಠ\_ಠ」 鱈魚:「我懶。(ツ)」 路人:(抽刀) 鱈魚:「刀下留魚啊 (#°Д°),是因為內容太多怕大家會累啦,用心良苦欸。」 ## 後端實作(NestJS) 依照官網步驟實作。 [ts-rest:Nest](https://ts-rest.com/docs/nest/) 以下是一個簡單的範例: ```ts // ... @Controller() export class CollectionDataController { constructor( private readonly loggerService: LoggerService, private readonly collectionDataService: CollectionDataService, ) { } @TsRestHandler(collectionDataContract.create, { validateResponses: true }) async create() { return tsRestHandler(collectionDataContract.create, async ({ body: dto, }) => { const [error, data] = await to(this.collectionDataService.create(dto)) if (error) { this.loggerService.error(`建立 CollectionData 錯誤 :`) this.loggerService.error(error) return { status: 500, body: { message: '建立 CollectionData 錯誤', }, } } return { status: 201, body: data, } }) } } ``` ::: tip 那個奇怪的 await to 是因為我用了[這個](./blog-program/await-to-js-keeps-your-promises-slick),不是寫錯喔。(。・∀・)ノ゙ ::: 開啟 validateResponses 的話,ts-rest 會嚴格驗證 API 響應資料格式是否正確,錯誤的話會直接噴 500,這就表示後端最好在 e2e 測試時,就該把可能情境測出來,只要測試有過,資料格式就不可能錯。 前後端就不用再為了資料有坑吵架了。( ‧ω‧)ノ╰(‧ω‧ ) ## 前端實作(Vue) 前端用法有兩種,可以依照需求挑適合的用: * [Fetch Client](https://ts-rest.com/docs/core/fetch):用法和 axios、fetch 概念相同。 * [Vue Query](https://ts-rest.com/docs/vue-query):其實就是 TanStack Query 可以漸進式導入,兩個同時用也沒問題。 個人推薦 Vue Query,雖然有點門檻,可是功能很強很好用。(๑•̀ㅂ•́)و✧ 如果都不喜歡,也可以[自定義](https://ts-rest.com/docs/core/custom)。 ### 驗證 前端如果呼叫 API 前想先驗證一次參數正確性。例如:驗證 collectionData API 的 body 資料是否正確。 只要取得 contract 中對應路徑的 zod schema 即可。 ```ts const createCollectionDataDto = collectionDataContract.create.body createCollectionDataDto.parse({ // ... }) ``` 剩下就是 zod 的工作了。(o゚v゚)ノ [Zod:parse](https://zod.dev/?id=parse) ### 型別 型別則是使用 ts-rest 提供的 [Inferring Types 工具](https://ts-rest.com/docs/core/infer-types)。 例如回應型別可以使用 `ServerInferResponseBody` 推導: ```ts type Data = ServerInferResponseBody< typeof collectionDataContract.create, 201 > ``` ![ServerInferResponseBody](/unleash-the-magic-of-type-script-with-ts-rest/ServerInferResponseBody.png) ts-rest 還有很多內容,可以[來官網逛逛](https://ts-rest.com/docs/intro)。♪(^∇^\*) ## 所以前後端要怎麼共享合約? 鱈魚:「真是個好問題,當然是複製貼...ᕕ( ゚ ∀。)ᕗ 」 路人:(拿起球棒) 鱈魚:「開玩笑的啦。∠( ᐛ 」∠)\_」 目前我自己已知不錯的方式有: * 私有 npm * monorepo 個人比較推薦使用 monorepo,我們公司也是如此,至於怎麼用 Google 有很多教學,這裡就不贅述囉。ԅ( ˘ω˘ԅ) ## 總結 🐟 * ts-rest 可以讓前後端串接更加嚴謹、自動化 * 介紹後端、前端實作 * 合約可以藉由私有 npm、monorepo 共享 以上恭喜你完全發揮 TypeScript 的魔法惹!ˋ( ° ▽、° ) 從此前後端再也不用為資料不一致問題打架了。✧⁑。٩(ˊᗜˋ\*)و✧⁕。 甚麼?你說還有很多事情可以吵?╭(°A ,°\`)╮ 那就超過本文的探討範圍惹,要打去練舞室打。ᕕ( ゚ ∀。)ᕗ --- --- url: /blog-vue/make-good-use-of-computed.md description: 常聽說不要把複雜的表達式寫在 template 裡,應先用 computed 裝起來,來試試有甚麼差別吧。ლ(´∀`ლ) --- ![make-good-use-of-computed](/make-good-use-of-computed.webp){.cover} # 善用 computed 常聽說不要把複雜的表達式寫在 template 裡,應優先用 computed 裝起來再放到 template 中。 聽說歸聽說,~~苓膏龜苓膏~~,來試著探討看看吧。%( ´ ▽ \` )ノ% ::: tip Vue 官方 Style Guide 之「Priority B Rules: Strongly Recommended」中有「[Simple expressions in templates](https://vuejs.org/style-guide/rules-strongly-recommended.html#simple-expressions-in-templates)」 不過規則還是要依照團隊內採用規範為主。%ԅ( ˘ω˘ԅ)% ::: ## 聽說性能有差? 設計一個繁重的計算方便比較差別,來看看 computed 與 template 結果。 `utils.ts` ```ts export function getFibonacci(n: number): number { if (n <= 1) return n return getFibonacci(n - 1) + getFibonacci(n - 2) } ``` 就是一個經典的費氏數列計算。 接著設計兩個元件,一個使用 computed,一個直接在 template 裡計算。 `components/ComputedCalc.vue` ```vue ``` `components/TemplateCalc.vue` ```vue ``` 主要邏輯為: * 透過 `useIntervalFn` 每 3 秒更新一次 `key`,觸發 template 更新 * `console.time` 計算 template 更新所需的時間 * `ComputedCalc.vue` 使用 `computed` 儲存 `getFibonacci` 結果 * `TemplateCalc.vue` 則直接在 `template` 裡呼叫 `getFibonacci`。 接著我們在 `App.vue` 中來測試這兩個元件。 `App.vue` ```vue ``` 切換 `checkbox` 會在 devtool 的 console 看到 computed 與 template 的時間差異。 範例程式碼[在此](https://stackblitz.com/edit/make-good-use-of-computed-rnmb827u?file=src%2FApp.vue) console 的內容如下 ```text computed: 16.491943359375 ms computed: 0.996826171875 ms template: 12.89794921875 ms template: 12.85302734375 ms computed: 13.27294921875 ms computed: 0.20703125 ms computed: 0.18408203125 ms template: 15.92578125 ms template: 12.6689453125 ms template: 12.741943359375 ms ``` 可以注意到計算 `getFibonacci(30)` 的時間,兩者基本上差不多,最大的差別在於 `computed` 只有第一次計算時會花較多時間。 這就是官文文件提到的 [Computed Caching](https://vuejs.org/guide/essentials/computed#computed-caching-vs-methods) 效果,只有在 reactive dependency 改變時才會重新計算。 所以不管 template 如何更新,都不會觸發額外的計算。 從這個結果來看,善用 `computed` 最大的優點是可以減少不必要的計算,提升效能。 ## 性能之外呢? 路人:「一般才不會有這麼繁重的計算,所以是不是沒差?%(。-\`ω´-)%」 鱈魚:「的確,你說的沒錯。%(´,,•ω•,,)%」 雖然說元件中若有一堆不必要的計算,可能會有積少成多,導致性能不佳的部分。 但根據個人一直以來的開發經驗,主要的性能瓶頸都不是這類問題,通常都是 DOM 太多導致畫面卡頓。 「性能比較好」,這個理由個人覺得不夠充分,不過 `computed` 除了性能之外,還有其他優點,例如: ### 方便除錯 配合 [debugger](https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging),方便追蹤響應過程。 ```ts const plusOne = computed(() => count.value + 1, { onTrack(e) { // triggered when count.value is tracked as a dependency debugger }, onTrigger(e) { // triggered when count.value is mutated debugger } }) ``` ### 方便測試 `computed` 的內容放到 `defineExpose` 中,就可以在單元測試中測試。 ```vue ``` 這一點與 Style Guide 中的「[Simple computed properties](https://vuejs.org/style-guide/rules-strongly-recommended.html#simple-computed-properties)」的理念有點類似。 除了更容易測試外,命名準確的 `computed` 也能提升程式碼的可讀性。 ## 真的不好嗎?(\*´・д・) 所以把表達式放在 `template` 中,真的很不好嗎? 我自己是覺得下面這種情況,還是很可以接受的啦。%ԅ(´∀\` ԅ)% ```vue ``` 這類例子要是用 `computed` 裝起來,命名也不太好想。 但下面這種就有點過分了。%(。-\`ω´-)% ```vue ``` 建議用 `computed` 裝起來吧。%(\*´∀\`)~♥% ```vue ``` ## 同場加映 路人:「如果真的有繁重的計算需求,有甚麼方法嗎?」 鱈魚:「可以請打工仔支援。%ᕕ( ゚ ∀。)ᕗ %」 路人:「...?%( ・ิω・ิ)%」 Web Worker 可以在背景執行的獨立執行緒 ,可以避免主執行緒被阻塞。 我的酷酷元件的[落雪效果](https://chillcomponent.codlin.me/components/bg-snow/)也是用 Web Worker 計算雪花動態與碰撞偵測。 即使雪花超過 10 萬個,FPS 很低,也不會讓網頁卡頓。%( •̀ ω •́ )✧% ::: tip 所以我說那個支援度呢? 截至 2025/01/17 為止,[支援度](https://caniuse.com/webworkers)達到了 98.19%,基本上可以快樂使用!✧⁑。٩(ˊᗜˋ\*)و✧⁕。 ::: VueUse 有一個 [`useWebWorkerFn`](https://vueuse.org/core/useWebWorkerFn/#usewebworkerfn) 可以簡化 Web Worker 的使用。 來試試看吧。%੭ ˙ᗜ˙ )੭% `components/WorkerCalc.vue` ```vue ``` 每 3 秒計算一次 n=40 的費氏數列,並且顯示目前時間。 接著在 `App.vue` 中加入 `WorkerCalc`。 `App.vue` ```vue ``` 現在 console 會多出 worker 的計算時間。 ```text worker: 959.0419921875 ms worker: 996.47998046875 ms worker: 980.422119140625 ms ``` 可以注意到即使花了將近 1 秒計算,網頁依舊很流暢。%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% ## 總結 🐟 * `computed` 有著 `Computed Caching` 的效果,只有在 reactive dependency 改變時才會重新計算,可以減少不必要的計算。 * `computed` 除了性能之外,還有方便除錯、測試、提升可讀性等優點。 * 繁重的計算可以考慮交給 Web Worker,避免網頁卡到歪。 --- --- url: /blog-program/await-to-js-keeps-your-promises-slick.md description: 可以在特定情境下簡化 Promise 寫法 --- ![await-to-js](/await-to-js.webp){.cover} # 回不去惹,用 await-to-js 讓你的 Promise 操作簡潔又俐落 前陣子意外注意到一個有趣的套件,名為 [await-to-js](https://www.npmjs.com/package/await-to-js)。 主要用途是多一種 Promise 與 await 的寫法,可以在特定情境下讓程式碼更簡潔。 類似的實作其實 [Radash](https://radash-docs.vercel.app/docs/async/tryit) 也有,不過 await-to-js 用法比較單純。 ::: tip 2024/08/26 更新 [ECMAScript 新提案: Safe Assignment Operator Proposal](https://dev.to/dharamgfx/bye-bye-try-catch-blocks-meet-javascripts-safe-assignment-operator-proposal-1j7?ref=dailydev) 等於直接把 await-to-js 加到語言規格中,期待 await-to-js 套件退休那一天。(๑•̀ㅂ•́)و✧ ::: ## 具體差在哪? 假設原本的 Promise 是這樣: ```ts let isLoading = true task().then(() => { console.log('done') }).catch(() => { console.error('error') }).finally(() => { isLoading = false }) ``` 用 await 會這樣寫。 ```ts let isLoading = true try { await task() console.log('done') } catch (e) { console.error('error') } isLoading = false ``` 使用 await-to-js 改寫後變成: ```ts import to from 'await-to-js' let isLoading = true const [error] = await to(task()) isLoading = false if (error) { console.error('error') return } console.log('done') ``` 除了看起來比較簡潔,不用 try catch 外,配合 early return 很有效。 在前端可能還好,在後端各種 Promise 操作的場合的很方便,從此以後寫法再也回不去了。XD 以下是官方的範例。 ```ts import to from 'await-to-js' async function asyncTaskWithCb(cb) { let err, user, savedTask, notification; [err, user] = await to(UserModel.findById(1)) if (!user) return cb('No user found'); [err, savedTask] = await to(TaskModel({ userId: user.id, name: 'Demo Task' })) if (err) return cb('Error occurred while saving task') if (user.notificationsEnabled) { [err] = await to(NotificationService.sendNotification(user.id, 'Task Created')) if (err) return cb('Error while sending notification') } if (savedTask.assignedUser.id !== user.id) { [err, notification] = await to(NotificationService.sendNotification(savedTask.assignedUser.id, 'Task was created for you')) if (err) return cb('Error while sending notification') } cb(null, savedTask) } async function asyncFunctionWithThrow() { const [err, user] = await to(UserModel.findById(1)) if (!user) throw new Error('User not found') } ``` 我自己的話後端比較常用,例如 NestJS 的 Controller 中: ```ts @Controller() export class ArticleController { // .. @UseGuards(AuthGuard('jwt')) @Post('articles') async getInfo( @User() jwtPayload: JwtPayload, @Body() body: CreateArticleDto, ) { const [userError, result] = await to( this.userService.check(jwtPayload) ) if (userError) { throw new HttpException( `使用者身分異常`, HttpStatus.FORBIDDEN, ) } const [checkError, result] = await to( this.articleService.checkQuota(jwtPayload) ) if (checkError) { throw new HttpException( `建立文章失敗,請稍後再試`, HttpStatus.INTERNAL_SERVER_ERROR, ) } if (result.reason === 'quota-has-been-used-up') { throw new HttpException( `文章額度已滿,請聯絡客服`, HttpStatus.BAD_REQUEST, ) } const [createError, article] = await to( this.articleService.create(body) ) if (createError) { throw new HttpException( `建立文章失敗,請稍後再試`, HttpStatus.INTERNAL_SERVER_ERROR, ) } return article } } ``` 可以提前處理 error,更方便使用 early return。◝(≧∀≦)◟ await-to-js 的實現很簡單,他的原始碼只有 22 行。 ```ts /** * @param { Promise } promise * @param {object=} errorExt - Additional Information you can pass to the err object * @return { Promise } */ export function to( promise: Promise, errorExt?: object ): Promise<[U, undefined] | [null, T]> { return promise .then<[null, T]>((data: T) => [null, data]) .catch<[U, undefined]>((err: U) => { if (errorExt) { const parsedError = Object.assign({}, err, errorExt) return [parsedError, undefined] } return [err, undefined] }) } export default to ``` 如果不想 npm install 的話,也可以直接貼到自己的專案使用。ヾ(◍'౪\`◍)ノ゙ ## Promise 都改用 await to 嗎? 當然不是這樣啦,await to 只是多一個選擇。ლ(╹ε╹ლ) 實際上還是有些地方更適合使用 then 或 try catch。 例如: 有時候根本就不會有錯誤,就維持 await。 ```ts const result = await task() ``` 有錯誤也沒關係,不需要特別處理,用 catch 紀錄一下就行。 ```ts await task().catch(console.error) ``` 單一錯誤不重要,統一處理即可,就直接 try catch。 ```ts try { await task1() await task2() await task3() await task4() } catch (error) { console.error(error) } ``` 請依照實際情況挑選適合的寫法喔。(。・∀・)ノ゙ ## 總結 🐟 * await-to-js 可以在特定情境下簡化 Promise 寫法 * 配合 early return 很有效 * 請不要只用 await-to-js,需要用 then 或 try catch 還是用原本的寫法 --- --- url: /blog-program/remeda-pipe.md description: pipe 代表函數式程式設計中的概念,利用多個功能結合在一起,資料依序通過每個功能進行處理 --- ![remeda-pipe](/remeda-pipe.webp){.cover} # 好 pipe 不用嗎?藉由 remeda 讓程式碼更簡潔彈性吧! ## 甚麼是 pipe 顧名思是就是水管,但是不會像瑪利歐由人來鑽,而是讓資料通過。 pipe 是一種函數式程式設計中的概念,用來表示一條資料處理的管道。 可以將多個 function 組合在一起,資料會依序經過每個 function 進行處理。 這種方法使程式碼更加清晰、模組化,並且易於維護。 個人覺得和以往指令式的寫法最大差別在於關注點不同。 怎麼說呢?讓我們看看以下兩種等效的程式。 例 1: ```ts const data = [1, 2, 3] const filteredData = data.filter((value) => value > 1) const result = filteredData.join('-') ``` 例 2: ```ts const result = pipe( [1, 2, 3], filter((value) => value > 1), join('-'), ) ``` ::: warning 我知道你很想問例 1 明明可以一行解決。範例而已啦,讓我們慢慢來。(´・ω・\`) ::: 可以注意到: * 例 1 的專注點在「結果」 * 例 2 的寫法專注點在「過程」 了解差異後學習轉換上就會相對容易了。(應該吧 ლ(╹ε╹ლ)) ## ramda、remeda? 這兩個都是基於 FP 概念設計的優秀套件,其實選一個喜歡的都行。 不過我主要用 remeda,因為 remeda 可以完美配合 TypeScript,而且同時支援 data-first 與 data-last 的寫法,兩個範例如下: * data-first: ```ts const data = { name: 'cod', age: '18', } const result = pick(data, ['name']) ``` * data-last: ```ts const data = { name: 'cod', age: '18', } const pickName = pick(['name']) const result = pickName(data) ``` 個人覺得使用上更為直覺。 此外 remeda 還有其他特性,詳細說明就不再此贅述,可以參考以下文件。 [Remeda](https://remedajs.com/) ## 所以 pipe 有甚麼好處? 個人覺得最大的好處是不用一直想變數名稱 XD,而且 function 方便抽換,整體來說增加了程式的彈性。 ### 減少臨時變數 你有一個物件陣列代表員工資料: ```ts const employees = [ { name: '鱈魚', age: 30, active: true, department: 'HR' }, { name: '阿貓', age: 25, active: false, department: 'IT' }, { name: '阿狗', age: 28, active: true, department: 'HR' }, { name: '章魚', age: 35, active: true, department: 'IT' }, { name: '鯊魚', age: 26, active: true, department: 'Sales' }, ] type Employee = (typeof employees)[0] ``` 要處理以下流程: 1. 挑出 active 員工 2. 根據部門分組 3. 每個部門挑出年紀最小的員工 4. 最後依年紀排序顯示這些員工 一般可能會這樣寫: ```ts // 1. 篩選 active const activeEmployees = employees.filter((e) => e.active) // 2. 分組(用物件表示) const grouped = activeEmployees.reduce((acc, e) => { (acc[e.department] ||= []).push(e) return acc }, {} as Record) // 3. 每組取年紀最小的員工 const youngestPerDept = Object.values(grouped).map((group) => group.reduce((min, item) => (item.age < min.age ? item : min)) ) // 4. 排序 const result = youngestPerDept.sort((a, b) => a.age - b.age) ``` 使用 remeda 就可以這樣寫: ```ts const result = pipe( employees, filter((e) => e.active), groupBy((e) => e.department), mapValues((group) => group.reduce( (min, item) => (item.age < min.age ? item : min) )), values(), sortBy((e) => e.age) ) ``` 可以看到不但不需要臨時變數,要追加邏輯也很方便。 ### 更容易使用 Early Return 使用 Early Return 可以讓程式碼更簡潔,避免過多的巢狀結構。 但是抽離太多 function 反而會讓程式碼變得冗長、難以閱讀。 remeda 的 `pipe` 可以讓我們在一個區塊內使用 Early Return,這樣就可以避免過多的巢狀結構。 例如:提取 `MaybeElement` 型別內部的 `HTMLElement`,若不存在則取得目前元件的 `el`,若都不存在則使用 `document.documentElement`。 一般可能會這樣寫: ```ts function getRootElement(target: MaybeElement) { let rootElement: HTMLElement | undefined /** target 為 MaybeElement */ let value = toValue(target) if (value && '$el' in value) { value = value.$el } else { const instance = getCurrentInstance() value = value ?? instance?.vnode?.el ?? instance?.proxy?.$el ?? document.documentElement } if (!value) { rootElement = undefined } else if (value instanceof HTMLElement) { rootElement = value } else { throw new TypeError( '[useImagesReady] 取得目標元素異常,請確認 target 是否為 HTMLElement 或 Vue 元件', ) } return rootElement } const rootElement = getRootElement(target) ``` 如果想要改為 Early Return,可能要把部分邏輯拆成 function: ```ts function resolveEl(_value: MaybeElement) { const value = toValue(_value) if (value && '$el' in value) { return value.$el } const instance = getCurrentInstance() return value ?? instance?.vnode?.el ?? instance?.proxy?.$el ?? document.documentElement } function resolveRootElement(value: unknown) { if (!value) { return undefined } if (value instanceof HTMLElement) { return value } throw new Error( '[useImagesReady] 取得目標元素異常,請確認 target 是否為 HTMLElement 或 Vue 元件', ) } function getRootElement(target: MaybeElement) { const value = resolveEl(target) return resolveRootElement(value) } const rootElement = getRootElement(target) ``` 但是這種只用一次的 function 反而讓程式碼變得冗長。 使用 remeda 的 `pipe`,則可以這樣寫: ```ts const rootElement = pipe( toValue(target), /** 提取 el 部分 */ (value) => { if (value && '$el' in value) { return value.$el } const instance = getCurrentInstance() return value ?? instance?.vnode?.el ?? instance?.proxy?.$el ?? document.documentElement }, /** 收束型別 */ (value) => { if (!value) { return undefined } if (value instanceof HTMLElement) { return value } throw new Error( '[useImagesReady] 取得目標元素異常,請確認 target 是否為 HTMLElement 或 Vue 元件', ) }, ) ``` 這樣就可以避免過多的巢狀結構,不需定義多餘的 function,讓程式碼更清晰。 ### 內建 Lazy Evaluation remeda 的 `pipe` 會自動使用 lazy evaluation,在某些情境下有奇效,詳情請見[這篇文章](/blog-program/remeda-lazy-evaluator) ### 讓邏輯結構清晰 使用像是 babylon.js 這類有大量物件的套件時,常常會有這種改寫物件資料的部分。 ```ts function createHole(data: ElData) { const depth = Math.max(data.width, data.height) const texture = new Texture( '/minecraft/textures/block/dirt.png', scene.value, true, false, Texture.NEAREST_NEAREST ) /** 方塊基準尺寸 */ const baseSize = 80 texture.uScale = data.width / baseSize texture.vScale = data.height / baseSize const material = new StandardMaterial('hole', scene.value) material.emissiveColor = new Color3(0.1, 0.1, 0.1) material.diffuseTexture = texture const hole = MeshBuilder.CreateBox(data.id, { width: 1, height: 1, depth, sideOrientation: Mesh.BACKSIDE, }, scene.value) // 使用縮放對應寬高,這樣就可以自由調整尺寸,而不用變更 mesh hole.scaling.x = data.width hole.scaling.y = data.height hole.renderingGroupId = 1 hole.material = material hole.position.x = data.x + data.width / 2 - windowSize.width / 2 hole.position.y = -data.y - data.height / 2 + windowSize.height / 2 hole.position.z = depth / 2 hole.isVisible = !data.visible hole.metadata = { ...data, position: hole.position, } return hole } ``` 其實仔細一看就會知道每個區塊的功能,段落也相當明確,但是如果用 pipe 寫會更清晰。 讓我們改寫一下。 ```ts function createHole(data: ElData) { const depth = Math.max(data.width, data.height) const texture = pipe( new Texture( '/minecraft/textures/block/dirt.png', scene.value, true, false, Texture.NEAREST_NEAREST ), tap((texture) => { /** 方塊基準尺寸 */ const baseSize = 80 texture.uScale = data.width / baseSize texture.vScale = data.height / baseSize }) ) const material = pipe( new StandardMaterial('hole', scene.value), tap((material) => { material.emissiveColor = new Color3(0.1, 0.1, 0.1) material.diffuseTexture = texture }), ) const hole = pipe( MeshBuilder.CreateBox(data.id, { width: 1, height: 1, depth, sideOrientation: Mesh.BACKSIDE, }, scene.value), tap((hole) => { // 使用縮放對應寬高,這樣就可以自由調整尺寸,而不用變更 mesh hole.scaling.x = data.width hole.scaling.y = data.height hole.renderingGroupId = 1 hole.material = material hole.position.x = data.x + data.width / 2 - windowSize.width / 2 hole.position.y = -data.y - data.height / 2 + windowSize.height / 2 hole.position.z = depth / 2 hole.isVisible = !data.visible hole.metadata = { ...data, position: hole.position, } }), ) return hole } ``` ::: tip 那個 tap 是甚麼? [`tap`](https://remedajs.com/docs/#tap) 是 remeda 提供的 function,可以讓我們在 pipe 中執行副作用,並且回傳原本的值。 ::: 這樣每個區塊做的事情更加清晰。 甚至 Vue 的 `watch`、`useEventListener` 等等額外副作用也可以放在同一個區塊內。 以下是 Matter.js 的例子: ```ts const mouse = pipe( Mouse.create(document.body), tap((item) => { // 移除 wheel listener 否則葉面無法使用滾輪捲動 // @ts-expect-error 存在 mousewheel item.element.removeEventListener('wheel', item.mousewheel) // 同步滾動量,以免滾動後拉不到 ball useEventListener('scroll', () => { Mouse.setOffset(item, { x: window.scrollX, y: -window.scrollY, }) }) watch(pixelRatio, (value) => { item.pixelRatio = value }, { immediate: true }) }), ) ``` 可以清楚看到相關副作用都集中在同一個區塊內。 路人:「看起來好像差不多?(´・ω・\`)」 鱈魚:「看起來的確差不多,現在讓我們配合編輯器的功能。ლ(´∀\`ლ)」 VSCode 可以摺疊指定區塊,這樣就可以乾淨的分開每個段落。 快捷鍵如下: * `Ctrl + K` -> `Ctrl + J`:展開全部區塊 * `Ctrl + K` -> `Ctrl + 0`:摺疊全部區塊 * `Ctrl + K` -> `Ctrl + 1`:摺疊第 1 層區塊 * `Ctrl + K` -> `Ctrl + 2`:摺疊第 2 層區塊 ... 以此類推 所以我們就可以將剛剛的程式摺疊第 2 層區塊,會變成這樣: ```ts function createHole(data: ElData) { const depth = Math.max(data.width, data.height) const texture = pipe( // ... ) const material = pipe( // ... ) const hole = pipe( // ... ) return hole } ``` Matter.js 的例子則可以摺疊第 1 層,變為: ```ts const mouse = pipe( // ... ) ``` 這樣不管是在閱讀或是維護上都會更加方便。( •̀ ω •́ )✧ ### 處理資料 接著看看資料處理的例子。 假設我們有多個 IoT 設備回傳資料,網頁需要彙整並顯示內容,資料為: ```ts interface Datum { deviceId: string; type: string; temperature: number; humidity: number; otherSensorData: Array<{ type: string; value: number; }>; } const data: Datum[] = [ { deviceId: 'device_1', type: 'A', temperature: 24.5, humidity: 50.0, otherSensorData: [ { type: 'A', value: 10 }, { type: 'A', value: 20 } ] }, { deviceId: 'device_2', type: 'B', temperature: 22.3, humidity: 45.5, otherSensorData: [ { type: 'B', value: 15 }, { type: 'C', value: 25 } ] } ] ``` 以下讓我們來實際撰寫程式。 #### 列出所有設備 ID 並用頓號分隔 熟悉 JS 的人一定可以很快寫出以下程式: ```ts const result01 = data .map(({ deviceId }) => deviceId) .join('、') ``` 用 pipe 寫則會像這樣: ```ts const result02 = pipe( data, map(prop('deviceId')), join('、') ) ``` 看起來好像沒比較好捏?但是用 remeda 可以更簡單抽離與複用: ```ts const getDeviceIdListString = piped( map(prop('deviceId')), join('、') ) ``` 也方便加入新的處理邏輯: ```ts import { trim } from 'ramda' const getDeviceIdListString = piped( /** 將每個 deviceId 去除頭尾空白 */ map( piped(prop('deviceId'), trim), ), join('、'), ) ``` 邏輯越複雜效果會越明顯,來看看其他例子。 #### 將設備依照 type 分類 ```ts const groupByType = pipe( data, groupBy(prop('type')), values, ) ``` #### 取得平均溫度與平均濕度 ```ts const meanData = { temperature: pipe(data, meanBy(prop('temperature'))), humidity: pipe(data, meanBy(prop('humidity'))), } ``` #### 取得 otherSensorData type 種類清單 ```ts const typeList = pipe( data, /** 將所有 otherSensorData 攤平、組成新矩陣 */ flatMap(prop('otherSensorData')), /** 依照 type 數值去除重複項目 */ uniqBy(prop('type')), /** 取出 type 數值產生新矩陣 */ map(prop('type')) ) ``` #### 取得所有溫溼度不在舒適範圍內的設備 ```ts function isComfortableTemperature(value: number) { return value >= 22 && value <= 28 } function isComfortableHumidity(value: number) { return value >= 40 && value <= 60 } const isComfortable = piped( allPass([ piped(prop('temperature'), isComfortableTemperature), piped(prop('humidity'), isComfortableHumidity), ]) ) const uncomfortableList = pipe(data, reject(isComfortable)) /** * 因為只有一個參數,所以也可以用 data-first 的方式寫 * const result = reject(data, isComfortable); */ ``` 從以上例子來看,其實就算沒有註解,從 function 的名稱我們看得出來此程式在做甚麼。( •̀ ω •́ )✧ 這樣可讀性是不是提升了許多呢?如果沒有,就再多看幾次(? 如果錯誤或更好的做法,歡迎大家多多指教。(´▽`) ## 總結 🐟 * pipe 可將多個功能串聯,資料依序通過每個功能進行處理 * remeda 可以完美配合 TypeScript,同時支援 data-first 與 data-last 的寫法 * 使用 pipe 可以讓程式碼結構更加清晰、模組化,並且易於維護 --- --- url: /blog-ocean-world/flexible-data-reordering.md description: 如何實現靈活的資料排序 --- ![flexible-data-reordering](/flexible-data-reordering.webp){.cover} # 如何實現靈活的資料排序 最近遇到任意排序資料的需求,研究了一輪後找到了一個有趣的解決方案。 方案是一個名為 Fractional indexing 的演算法,能夠實現靈活的資料排序。 其特點為能夠在現有清單中任意位置插入新資料且不改變其他資料順序的情況下進行排序。 換個比喻,就像是可以在不變更其他人號碼牌的情況下,讓你手上的號碼牌變成任意位置,可以隨意插隊的感覺。%◝( •ω• )◟% 讓我們一步步探討看看。 ## 如何排序? 如何自由調整資料排序呢?%ლ(╹ε╹ლ)% 有人可能會想說:「阿不就調整矩陣順序就好了?%( ˘・з・)%」 不過那是在同一個矩陣中才有辦法,如果是從 DB 取出來呢? DB 內的資料通常都只能依靠某個欄位進行排序取值,不能像矩陣換個位置就行。 舉個類比情境,就像是 `object` 的 key-value 資料那樣,例如: ```ts const data = { 1: { name: 'cod', age: 10, }, 2: { name: 'cat', age: 1, }, 3: { name: 'dog', age: 4, }, } ``` 需要將此 `data` 內容轉換成矩陣,若依據 `age` 排升序則為: ```ts const list = [ { name: 'cat', age: 1, }, { name: 'dog', age: 4, }, { name: 'cod', age: 10, }, ] ``` `object` 不會保證資料順序,所以直接轉換成 `array` 不能保證順序正確,一定要先 `sort()` 才行。 剛剛依 `age` 欄位排序,現在需求改成可以隨意調整資料順序,該怎麼做呢? 大家可能應該都想到方法了,新增一個 `order` 欄位不就好了嗎? ```ts const data = { 1: { name: 'cod', age: 10, order: 1, }, 2: { name: 'cat', age: 1, order: 2, }, 3: { name: 'dog', age: 4, order: 3, }, } ``` 若要移動位置,只需要調整 `order` 欄位的值即可。 例如:要將 `dog` 移到 `cat` 前面,只需要將 `dog` 的 `order` 改成 `1.5` 即可。 這就是 Fractional indexing 的核心概念!%( •̀ ω •́ )✧% 不過此演算法的數值範圍使用 0 到 1 之間的浮點數。 ::: tip 至於為甚麼是 0 到 1 之間,這個倒是沒有特別找到具體的原因,若有大大知道為甚麼還請不吝告訴我。%(\*´∀\`)~♥% ::: 此演算法來自鼎鼎大名的 Figma,其 CTO 分享的[這篇文章](https://madebyevan.com/algos/crdt-fractional-indexing/)。 文章內有很生動的互動效果,能夠讓人更直覺地理解 Fractional indexing 運作方式。 ## 浮點數精度 不過這裡還有個小問題,就是浮點數的精度有限,若交換太多次可能會導致因精度不足,讓排序異常。 以 JS 的數字(IEEE 754 的 64 位元雙精度浮點數)為例,小數點後大約可以正確表示到 15~17 位數字,若超過這個範圍,會出現數值遺失問題。 有沒有可以存好存滿的方法呢? 那就用字串吧!同時使用 Base62 編碼,讓字串短一點。%(・∀・)9% ::: tip JS 有時候也會使用字串表示數值,避免潛在的精度損失問題。 [Decimal.js](https://github.com/MikeMcl/decimal.js?tab=readme-ov-file#use:~:text=it%20is%20recommended%20to%20pass%20strings%20rather%20than%20numbers%20to%20avoid%20a%20potential%20loss%20of%20precision.) ::: 甚麼是 Base62 編碼?這是一種將資料轉換成只包含 62 個可列印字元的編碼方式 62 個字元包括: * 數字:0 到 9(共 10 個) * 大寫英文字母:A 到 Z(共 26 個) * 小寫英文字母:a 到 z(共 26 個) 總共:10 + 26 + 26 = 62 個字元 舉個例子:%( ´ ▽ \` )ノ🌰% 假設要將十進位的數字 125 轉成 Base62 編碼 1. 125 ÷ 62 = 2 餘 1 → 第一個字元是 1 2. 2 ÷ 62 = 0 餘 2 → 第二個字元是 2 最終得到 125 的 Base62 編碼為 21 所以 [Fractional Indexing](https://github.com/rocicorp/fractional-indexing?tab=readme-ov-file#generatekeybetween) 範例程式碼才會都是奇怪的英文數字組合: ```ts import { generateKeyBetween } from 'fractional-indexing' const first = generateKeyBetween(null, null) // "a0" // Insert after 1st const second = generateKeyBetween(first, null) // "a1" // Insert after 2nd const third = generateKeyBetween(second, null) // "a2" // Insert before 1st const zeroth = generateKeyBetween(null, first) // "Zz" // Insert in between 2nd and 3rd (midpoint) const secondAndHalf = generateKeyBetween(second, third) // "a1V" ``` ## 所以我說那個程式碼呢? 讓我們用工程師的語言來對話吧!來人啊,上程式碼!%(≖‿ゝ≖)✧% 這裡實作使用 [`fractional-indexing-jittered`](https://www.npmjs.com/package/fractional-indexing-jittered) 這個套件。 讓我們新增一個 `createDataManager` function 建立一個管理資料排序的物件。 首先新增一些預期可能用到的基礎方法。 `content\blog-ocean-world\flexible-data-reordering\index.ts` ```ts export function createDataManager() { const dataMap: Map = new Map() return { add(data: Data) { return '' }, moveBefore( id: string, targetId: string, ) { }, moveAfter( id: string, targetId: string, ) { }, delete(id: string) { }, getAll() { return [] }, } } ``` 接著讓我們新增測試檔案與測試案例。 `content\blog-ocean-world\flexible-data-reordering\index.test.ts` ```ts import { describe, expect, it } from 'vitest' import { createDataManager } from './' interface Data { name: string; value: number; } describe('createDataManager', () => { it('新增資料並取得所有資料', () => { const manager = createDataManager() manager.add({ name: 'a', value: 1 }) manager.add({ name: 'b', value: 2 }) const allData = manager.getAll() expect(allData).toHaveLength(2) expect(allData[0]?.name).toBe('a') expect(allData[1]?.name).toBe('b') }) it('根據 id 刪除資料', () => { const manager = createDataManager() const aId = manager.add({ name: 'a', value: 1 }) manager.add({ name: 'b', value: 2 }) manager.delete(aId) const allData = manager.getAll() expect(allData).toHaveLength(1) expect(allData[0]?.name).toBe('b') }) it('可以移動資料到指定資料之後', () => { const manager = createDataManager() const aId = manager.add({ name: 'a', value: 1 }) const bId = manager.add({ name: 'b', value: 2 }) const cId = manager.add({ name: 'c', value: 3 }) manager.moveAfter(aId, cId) const allData = manager.getAll() expect( allData.map((d) => d.name), ).toEqual(['b', 'c', 'a']) }) it('可以移動資料到指定資料之前', () => { const manager = createDataManager() const aId = manager.add({ name: 'a', value: 1 }) const bId = manager.add({ name: 'b', value: 2 }) const cId = manager.add({ name: 'c', value: 3 }) manager.moveBefore(bId, aId) const allData = manager.getAll() expect( allData.map((d) => d.name), ).toEqual(['b', 'a', 'c']) }) }) ``` 執行測試,結果全部失敗,不意外,因為還沒有實作任何邏輯,如果全過我會比較驚訝。%(´・ω・\`)% 現在讓我們完成實作吧!%ヾ(◍'౪\`◍)ノ゙% `content\blog-ocean-world\flexible-data-reordering\index.ts` ```ts import { generateKeyBetween } from 'fractional-indexing-jittered' export function createDataManager() { const dataMap: Map = new Map() function getDataEntriesList() { return Array .from(dataMap) .sort((a, b) => a[1].order < b[1].order ? -1 : 1) } return { add(data: Data) { const id = crypto.randomUUID() const lastData = getDataEntriesList().at(-1) const order = generateKeyBetween( lastData?.[1].order ?? null, null, ) dataMap.set(id, { data, order }) return id }, moveBefore( id: string, targetId: string, ) { const currentData = dataMap.get(id) if (!currentData) return const targetData = dataMap.get(targetId) if (!targetData) return const dataList = getDataEntriesList() const targetIndex = dataList.findIndex((item) => item[0] === targetId) const prevTarget = dataList[targetIndex - 1] const newOrder = generateKeyBetween( prevTarget?.[1].order ?? null, targetData.order, ) currentData.order = newOrder dataMap.set(id, currentData) }, moveAfter( id: string, targetId: string, ) { const currentData = dataMap.get(id) if (!currentData) return const targetData = dataMap.get(targetId) if (!targetData) return const dataList = getDataEntriesList() const targetIndex = dataList.findIndex((item) => item[0] === targetId) const nextTarget = dataList[targetIndex + 1] const newOrder = generateKeyBetween( targetData.order, nextTarget?.[1].order ?? null, ) currentData.order = newOrder dataMap.set(id, currentData) }, delete(id: string) { dataMap.delete(id) }, getAll() { return getDataEntriesList() .map(([_, { data }]) => data) }, } } ``` 沒意外的話,現在測試應該全部通過了。 在 `getDataEntriesList` 中 log 一下內容,觀察看看 `order` 欄位的值。 ```txt { data: { name: 'b', value: 2 }, order: 'a1' } { data: { name: 'b', value: 2 }, order: 'a1' } { data: { name: 'b', value: 2 }, order: 'a1' } { data: { name: 'c', value: 3 }, order: 'a2' } { data: { name: 'a', value: 1 }, order: 'a3' } { data: { name: 'b', value: 2 }, order: 'Zz' } { data: { name: 'a', value: 1 }, order: 'a0' } { data: { name: 'c', value: 3 }, order: 'a2' } ``` 可以發現內容與官方範例程式碼一致,相當有趣。%(´,,•ω•,,)% ::: tip 這裡的測試案例只有簡單驗證,沒有考慮到其他案例與邊界情況,大家有興趣可以自己擴充測試。%( ‧ω‧)ノ╰(‧ω‧ )% ::: 完整程式碼可以在[這裡找到](https://github.com/Codfisher/cod-aquarium/blob/main/content/blog-ocean-world/flexible-data-reordering)。 ## 拖來拖去玩玩看 光看程式碼還是有點抽象,不如直接動手拖動看看。%( ´ ▽ \` )ノ% 拖動任一項目放開後,只有被移動的那筆資料的 order 會變化,其他資料完全不受影響。 這正是 Fractional indexing 的精髓,換成 DB 情境,就是一次移動只需要更新一筆資料。%(≖‿ゝ≖)✧% 多拖幾次會發現字串會變長,例如 `a1V`、`a1Fzz`,這就是字串精度所帶來的效果。 ```vue ``` ## 總結 🐟 * Fractional indexing 演算法能夠實現靈活的資料排序,讓資料可以任意插隊。 * 浮點數有精度問題,使用 Base62 編碼的字串表示數值,可以避免精度損失。 --- --- url: /blog-vue/vue-watch-may-cause-a-memory-leak.md description: 某些情境會讓 watch 不會自動解除,一起來看看吧!(ˊДˋ;) --- ![vue-watch-may-cause-a-memory-leak](/vue-watch-may-cause-a-memory-leak.webp){.cover} # 小心遺漏的 watch 讓網頁爆炸 最近注意到 `watch` 有一個有趣的現象,一個可能會挖坑的現象。 在某些特殊情境下,`watch` 的位置可能不會在元件最外層。 類似這種感覺: ```vue ``` 先別吐槽這樣寫超廢,只是個簡單的示意啦。%(ʘ ͜ʖ ʘ)% ::: tip 其實如果有這類需求,推薦使用 VueUse 提供的 [watchPausable](https://vueuse.org/shared/watchPausable/)、[watchTriggerable](https://vueuse.org/shared/watchTriggerable/#watchtriggerable) 這類進階版 watch,看起來更漂亮、更好維護。 ::: 我們知道正常情況下,元件 `onUnmounted` 後,`watch` 就會自動解除。 但是剛剛的那個例子,`onUnmounted` 後 `watch` 不會自動解除喲!%ლ(╹◡╹ლ)% 所以如果頁面中有很多這類 `watch`,很有可能讓網頁爆掉唷!%o(≧∀≦)o% 路人:「是在開心個毛線喔。%(゚Д゚\*)ノ%」 這是因為如果在內層呼叫 `watch`,會讓 `watch` 與元件處在不同 effectScope,導致此結果。 ## 該怎麼辦?%(っ °Д °;)っ% 解法也很簡單,勤奮一點手動解除。%◝( •ω• )◟% ```vue ``` 路人:「啊如果有很多 watch,這樣一個一個解除很累欸。%(ˊДˋ;)%」 鱈魚:「…%('◉◞⊖◟◉`)%,先不探究這種 code 怎麼過得了 code review ,假設真的有很多`watch\` 要解除,可以用剛剛提到的關鍵字『effectScope』」 [effectScope](https://vuejs.org/api/reactivity-advanced.html#effectscope) 可以用來捕捉範圍內的 reactive effects,所以可以把所有的 watch 收集起來統一解除。 例如像這樣: ```vue ``` 世界再度回復和平!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% 以上提到的範例可以在[此連結查看](https://play.vuejs.org/#eNrNVctu2zAQ/JWFLlYKRXbQ9uLaAdoihxToA01yqnJQ5JWtVCIJPpwUhv69S1Jy5ES2EbdBexN3h7sz3CG1Ct4LES8NBuNgojJZCA0KtRGnCSsqwaWGFUjMoYZc8goGBB2sUx95JZp4PLQLW2nwbiN/kXGBGyAX6UFesbtUZ4sNbBNr0QnLOFMaloUqbkqEqeUW5mmp8Chhk6FXQNxpobESZaqRVgCTxcnparXeV9eTIUVcpmDCUMXjis+wnCZBg0kC0L8EUiBbYPbzht8ngd/gVC+Pi3wDPHxIesm7EK3UXsxk2KEeRIFWJDov5vGt4ozmtLJliBbVKUqUX4Uu6FCSYAwuY3NpWfK7Ty6mpcGojTspPfFbRerG9PFNokK5JCrrnE7lHLVPn118wXv6Xifp0IwlviP5HRUvjeXoYR8MmxHtDs6xPXdGKNj8Up3da2SqFWWJWmTt8HRYBu0RbpP+QPd1/MbtS1hNp9jac4/RI3CTiYCzK1ZxwzTOIsA8x0z7uW7ehNaRNCPKTbvIkCzpk7NUp41XT46cjan1ZVEhNzoMj2B66vm71qFFRxAu09LgQw7A1uIlxiWfh4Mf/vJdu9pjGETg8VSdJFPnOoK3o5Hv1tHSbfeoYAcF1wO709ZJWG5YZs8ZjKBmJMvvto1j15SkfU71IpYpm/GK8q/gZDSy+xup51RTErTbvC1mYZHFO667rvCsWLoPcNJdaOhjj2/MxivzPwzcR5mpblA28WY0/UZwxWNpWDe4zx+9DvES+m3ijfLULgdw8tKezcpv28Or9cbhPm7JK82Fd9wfGhsa5v/Q/+4I912Czu/zgGuwxflPX7MSNRjfitb99mnytPHZr1z7t3zZx25N8a845CVH35zH9uHXvwG30kPy)。 ::: danger 可以觀察到即使元件消失後,devtool 的 console 依舊持續跑出 log!%(゚Д゚\*)ノ% ::: 不過說的真,請不要這樣用 `watch`。…%(›´ω\`‹ )% ## 總結 🐟 * watch 不會自動解除的原因是在不同 effectScope * 使用 effectScope 可以統一解除 `watch` --- --- url: /blog-vue/quasar-menu-hover-on-open.md description: 原本想說應該很簡單,仔細探討後發現事情沒有像憨魚想的那麼簡單。(◉◞౪◟◉ ) --- ![quasar-menu-hover-on-open](/quasar-menu-hover-on-open.webp){.cover} # 幫 Quasar Menu 加上 hover 開啟功能 最近遇到 Menu 想要使用 hover 開啟的需求,不過 Quasar 的 Menu 預設沒有這個功能,只好自行處理了。%◝( •ω• )◟% 原本想說應該很簡單,不是只要偵測 mouseenter 與 mouseleave 就好了嗎? 沒想到多層級 menu 就出事了,因為從第 1 層移動到第 2 層 menu 的瞬間,就會導致第 1 層選單隱藏。 結果,趴!都沒惹,事情果然沒有像憨魚想的那麼簡單。%(◉◞౪◟◉ )% 多說無益,直接來看看成果。 (按下 Run Project 後需要稍等一會兒) ::: tip 不僅用於 Quasar Menu 雖然這篇針對 Quasar Menu 開發,但此邏輯沒有綁定 Quasar Menu 特有功能,所以微調後也可用於其他 UI 套件的 Menu,~~應該啦 %(ゝ∀・)b%~~。 ::: ::: warning WCAG hover 開啟 Menu 功能可能會違反 WCAG 某些準則,這點要多注意。 ::: 鱈魚:「(省略一千字)...,建議不要做 hover 開啟 Menu 功能 %(⌐■\_■)✧%」 甲方:「我要 %(◉◞౪◟◉ )%」 鱈魚:「好,我做給你 %...(›´ω\`‹ )%」 ## 需求 所有的開發第一步都是釐清需求。 甚麼?你說沒有需求怎麼辦?那就開始通靈吧!%(。-\`ω´-)% ### 基本需求 * 當滑鼠 hover 在按鈕(或觸發器)上時,Menu 自動開啟。 * 滑鼠從按鈕移出時: * 移至 Menu 上,Menu 保持開啟 * 否則 Menu 關閉 ### 多層級單選單 假設開啟了 3 層,則: * 滑鼠離開第 3 層回到第 2 層時,第 3 層消失 * 直接離開第 3 層至頁面,所有選單消失 * 直接回到第一層,第 2、3 層關閉 ### 使用需求 計畫開發一個可以無痛替換 Quasar Menu 元件,假設叫做 `menu-hover`。 先來複習一下 [Quasar Menu 用法](https://quasar.dev/vue-components/menu)。 用法很簡單,只要在預期產生 Menu 的元素內放入 `q-menu` 即可。 ```vue ``` `menu-hover` 元件需要像這樣簡單替換即可使用: ```vue ``` ## 架構 主要有兩個工作,概念如下圖所示: 1. 注入一個紀錄 Menu 層級的變數 2. root menu 注入收集 child 資料之 function ![menu-hover 架構圖](/quasar-menu-hover-on-open/menu-hover-架構圖.png) 由於要傳遞未知層級的資料,預計使用 [Vue 的 Provide/Inject](https://vuejs.org/guide/components/provide-inject) 處理。 ## 開發 ### props 與 hover 邏輯 現在來實際開發吧,首先從最基本的 props 開始。 因為要可以無痛替換原本的 `q-menu`,所以需要繼承 `q-menu` 的 props。 ::: tip 為了單純一點,這次排除 `target` 和 `modelValue`,有需要再想辦法加進來。 ::: `src/components/menu-hover/menu-hover.vue` ```vue ./type./type ``` 接下來是觸發器與 menu 自己本身的 hover 邏輯。 `src/components/menu-hover/menu-hover.vue` ```vue ``` 這裡使用 `VueUse` 提供的 `useParentElement` 與 `useElementHover` 簡單完成。 menu 的部分則是因為 `useElementHover` 放入元件沒有作用,所以改用普通的 `mouseenter` 與 `mouseleave` 處理。 ::: tip menuRef 與 `useElementHover` 目前做過以下嘗試。 這樣寫 TS 不開心: ```ts useElementHover(menuRef, { delayLeave: 100, }) ``` 這樣寫沒反應: ```ts useElementHover(() => menuRef.value?.$el, { delayLeave: 100, }) ``` 若有大大知道怎麼做,還請不吝告訴我!(\*´∀\`)~♥ ::: ### 注入 level 並蒐集 child menu 資料 先來定義形別資料。 `src/components/menu-hover/type.ts` ```ts import type { InjectionKey, Ref } from 'vue' export interface MenuData { id: string; level: number; } export const menuLevelInjectionKey = Symbol('menu-level') as InjectionKey export const menuInjectionKey = Symbol('menu-hover') as InjectionKey<{ bindSubmenu: (data: MenuData) => void; unbindSubmenu: (id: string) => void; submenuList: Ref; }> ``` 並在元件引用。 `src/components/menu-hover/menu-hover.vue` ```vue // ... ``` 基本上就是當 child menu 顯示時,將自己的資料傳遞給 root menu,隱藏、消失時移除資料。 並透過 `menu-level` 紀錄目前是第幾層,這樣 root 與 child 都可以得知目前整體 Menu tree 狀態了。 有狀態後,要判斷 hover 邏輯就簡單多了。 ### 開啟選單 最後就是實際開啟 Menu 的部分了,使用 `menuVisible` 來控制 Menu 的顯示與隱藏。 有 3 種方式會觸發 Menu 顯示: 1. 觸發器 hover 2. Menu hover 3. 自己的子選單開啟 ```vue ``` 以上我們完成 hover 開啟的邏輯了。 若有哪個部分解解釋得不夠清楚或有大神知道更簡潔的方法,還請不吝告訴我。%੭ ˙ᗜ˙ )੭% 完整原始碼[在這裡](https://github.com/Codfisher/blog-quasar-menu-open-on-hover) 想嘗試看看的朋友可以[點這裡](https://stackblitz.com/~/github.com/Codfisher/blog-quasar-menu-open-on-hover?file=src/components/menu-hover/menu-hover.vue) ## 總結 🐟 * 微調後可以用於其他 UI 套件的 Menu * 使用 Vue 的 Provide/Inject 來傳遞未知層級的資料 --- --- url: /aquarium/meme-cache.md description: 常常找不到記憶中的梗圖嗎?我來幫你找找 ԅ(´∀` ԅ) 可用描述或圖中文字搜尋近千張迷因梗圖。 --- --- --- url: /blog-program/remeda-lazy-evaluator.md description: remeda 實作中有 lazy evaluator 有甚麼好處?來 Bench 一下看看◝( •ω• )◟ --- ![remeda-lazy-evaluator](/remeda-lazy-evaluator.webp){.cover} # 懶有懶的好處,聊聊 Lazy Evaluation 故事發生於某天平凡的午後,鱈魚我和平常一樣快樂地用著 [`Remeda`](https://remedajs.com/),使用 [`take`](https://github.com/remeda/remeda/blob/main/packages/remeda/src/take.ts) 時忽然很好奇原始碼如何實作。 鱈魚:「給我照腳本念,不要偷嘴我!%ლ(´口\`ლ)%」 讓我們看看 `Remeda` 的 `take` 原始碼: ```ts export function take(...args: ReadonlyArray): unknown { return purry(takeImplementation, args, lazyImplementation) } function takeImplementation(array: T, n: number): Array { return n < 0 ? [] : array.slice(0, n) } function lazyImplementation(n: number): LazyEvaluator { if (n <= 0) { return lazyEmptyEvaluator } let remaining = n return (value) => { remaining -= 1 return { done: remaining <= 0, hasNext: true, next: value } } } ``` `purry` 是 `Remeda` 特殊函數,不在此文章討論範圍,有興趣的朋友可以來[這裡看看](https://remedajs.com/docs/#purry)。 可以看到的確有 `Array.slice` 沒錯,不過還有 `lazyImplementation` 部分,這個其實就是實作「惰性求值」(Lazy Evaluation)邏輯。 至於甚麼是「惰性求值」?簡單來說,就是在需要時才計算,而不是一開始就計算所有東西。 細節網路上超多文章,這裡就不贅述了。%(ゝ∀・)y% ## 來 Bench 一下 讓我們來 Bench 一下,感受看看差異吧。 邏輯為:將一個巨大的矩陣 map、filter 後,只取前 5 筆資料。 資料定義如下: ```ts export const mapFn = (x: number): number => x * 2 export const filterFn = (x: number): boolean => x % 3 === 0 export const TAKE_COUNT = 5 const DATA_SIZE = 1_000_000 export const data = Array.from({ length: DATA_SIZE }, (_, i) => i + 1) ``` 總共測試三種版本程式: `Remeda` 的 `lazy` 版本: `lazy.ts` ```ts import { filter, map, pipe, take } from 'remeda' pipe( data, map(mapFn), filter(filterFn), take(TAKE_COUNT) ) ``` 原生 Array 版本: `native.ts` ```ts data .map(mapFn) .filter(filterFn) .slice(0, TAKE_COUNT) ``` 將 `Remeda` function 拆開來用,不要使用 `lazy`: `no-lazy.ts` ```ts import { filter, map, take } from 'remeda' const mapped = map(data, mapFn) const filtered = filter(mapped, filterFn) take(filtered, TAKE_COUNT) ``` [實際跑個 Bench](https://benchjs.com/playground#/N4IgzgLghhCmIC5QDdYCcwEsD2A7RAjADQiYC2ADgDaxmy7QQ65iIDaomAJoiAMIB1AQC8ACgBUAEl2EB9PtgBmAZgAeAdgByAWUniAHCBKLMNXFDq8qUYQE8AdBFYkAxnjgNe5CtjQQABMD+ZFAURP4mVHBo4dAA1rDhFJgUsP4AvhFo2GT+AORotLBcUHkAOrgVsKo+fv5uLAFoAK64-gC8-gAUAJQdAHyBFf7+hRDNaG3JqV3DI-4l0ERzIyEUXWsAYrg9y23zkdFdh+jbuyv+8bBd4gCCANIAovIA8gCqmuI9cz0A3BXpX4gdJETg8BAgABaAE04i4AIpvAAMBDeAE5FAANe64ABWzVuLkwAHcACxGECRegWeAQ3DYAC01jsjmcIAaHggXkovgCQTW4ROMUuUASGSyOXyhToJXKlVw1VqAQakFGrQ63T67UGwDmKoCa1SXA1ay6iyg4S2O1+81tIz1eFVQuKGqFG1CRsFpmiZxtczGEzaV2O3vQxXCdyerw+X3+uEBwNBpHB-AAjgALTQANmJSOUAHF86FocgCEjFGQ+AARYkUqnmSx0mCYVCsikc+hciFVGq8+qOprqzq9AZDfYByYLGBQC72U1W777Eb2N1Cs6zsBUTAua5IiMPZ58d6fP4AoHpAC6JCgLiYqHEUAARgBJFN8DPZ3MFosUEtlivVrWJBgLA4wUAoXC0iAip9vqwShNsGpdKoCD+LgzRkI+6A9Kh6GYego6qP4ABU-gAExxjBdRwWubTDihaEYVhaA4f4j7YNgNBQHRgxEQApP4ygdO0nRIpRvbUQO-iRoex7iBqACscYVHBVa3HcsgAMrPpCjwagQshIkZhlGX6CoScqUnmhqtxoGgUAOIo2RkF0QRmAA5hA6aoWpGnabpGThF0sjhJgWqDJg-gANT+AQp74MBoHNBQVbiJpvCQS41iFP47lUNgj5QFQY4jJl2VpHBVqochuFMdho54cxcalbAWVQDlNGhmg2zVQxjX1dqbEcVxuDNQsrXlf2jTSQe0afIpY1le1FVWdOtX4WgbAXmNAIVD2SqBAmJCFFAXB0AoDCdrwADE-jws025xP4mnQHU+YPZBe24Ndt2bKYaQvS0t4TLAFQMv4IFgayqGaUlFD9pBlzYP4FCFBQy1Tks9QnXA-jprAVCpGgEStLezBgOEoEuPYYP+N4NB0AwzaOgA9MR9jAE4RC4mA6Soc+lAM52zNtFSYBTdAmC4FL7lsfQLjpiEaBxNsX0-f4AhoJgTC4LLABC8uK+1cRgF9BD2M9cPdJDyWsjhX2PKgaC2F5Mv+LY2DNP4VEBMSpjFVh-hQMgUCmE+NBB+LUC5flhXFYovjeze6Z04LRRM0weA0-KAAGecQLYqRgC4WsUBAFQsyz2OwDAaTccaPuXLAqrmvtsGrdANl2Q5K7Oa5-geV5qFlkZgXdCFdPhZP4kHXBYAYUh7VoOtzFbVPS-2IUXDNDuXRdBabHr9FbHhEi8V5znX1kRbAvUOnjDk901QWHfdsIF9t9CxnzAMmAqREiYFwCM0juWwM3PGYZwj13dp7NCsAXQQGRo3aOihSaZzaA2F0BRWh5GzhUC+Bci4lxSOXcys8pItDopqUcupxxJUnOaTexQd7XHnmQU+8V0j4LzhUCkW5Hz2S1s3dgoBMG8GlMUKAwILwghAE7LAeBCDpCAA) 可以看到結果差距非常驚人。 | Name | Ops/sec | |------------|-------------------------------------------------| | lazy.ts | 1,113,819 Best | | no-lazy.ts | 63 (-100.0%) | | native.ts | 61 (-100.0%) | * `lazy` 版本表現最佳,達到每秒 111 萬次操作,遠超過其他兩個版本。 * `no-lazy` 與 `native` 版本表現相近很合理,因為 `Remeda` 的 `map`、`filter` 都是使用原生的 `Array.map`、`Array.filter`。 `lazy` 版本會那麼快,是因為它只需要計算到滿足 `take` 的部分,並不會計算整個資料集,所以時間才差那麼多。 ## 所以用 Remeda 性能都會好棒棒嗎? 倒也不能這麼說,讓我們去掉 `take` 的部分,再跑一次看看。 | Name | Ops/sec | |------------|----------------------------------------------| | lazy.ts | 19(-70.4%) | | no-lazy.ts | 64 Best | | native.ts | 63(-1.8%) | 可以看到 `lazy` 的效能大幅下降,甚至比不上 `no-lazy` 版本。 這是因為 `lazy` 的 `take` 只在需要時才計算,當沒有 `take` 時,整個資料集都需要計算,這樣就失去了惰性求值的優勢,反而會因為 `lazy` 額外的計算邏輯,導致效能下降。 或者把資料數量改成 50 筆,再跑一次。 | Name | Ops/sec | |------------|------------------------------------------------------| | lazy.ts | 1,017,423 (-36.0%) | | no-lazy.ts | 1,539,932 (-3.2%) | | native.ts | 1,590,550 Best | 可以看到 `lazy` 版本下降惹 %Σ(ˊДˋ;)% 這是因為瀏覽器為了 JS 性能操碎了心,這點資料才不算甚麼,`lazy` 的計算邏輯反而拖累了性能。 ## 原生不能懶懶的嗎? 可以使用 generator 來實作惰性求值邏輯: `generator.ts` ```ts function* mapIterator( iterable: Iterable, mapFn: (item: T) => U ): IterableIterator { for (const item of iterable) { yield mapFn(item) } } function* filterIterator( iterable: Iterable, filterFn: (item: T) => boolean ): IterableIterator { for (const item of iterable) { if (filterFn(item)) { yield item } } } function* takeIterator( iterable: Iterable, count: number ): IterableIterator { let i = 0 for (const item of iterable) { if (i++ >= count) break yield item } } export function run() { const iterator = takeIterator( filterIterator( mapIterator(data, mapFn), filterFn ), TAKE_COUNT ) const result = Array.from(iterator) } ``` Bench 結果: | Name | Ops/sec | |--------------|----------------------------------------------------| | lazy.ts | 636,538 (-0.2%) | | no-lazy.ts | 40 (-100.0%) | | native.ts | 40 (-100.0%) | | generator.ts | 637,597 Best | 也有 [proposal-iterator-helpers 提案](https://github.com/tc39/proposal-iterator-helpers/),截至 2025/05/26 還在 Stage 4,雖然已經有瀏覽器實作,不過要注意[支援度](https://caniuse.com/?search=Iterator)。 `iIterator.ts` ```ts function* toIterator(array: T[]) { for (const val of array) { yield val } } export function run() { toIterator(data) .map(mapFn) .filter(filterFn) .take(TAKE_COUNT) .toArray() } ``` Bench 結果: | Name | Ops/sec | |--------------|------------------------------------------------------| | lazy.ts | 586,274 Best | | no-lazy.ts | 30 (-100.0%) | | native.ts | 34 (-100.0%) | | generator.ts | 501,987 (-14.4%) | | iterator.ts | 545,057 (-7.0%) | [Bench 程式碼](https://benchjs.com/playground#/N4IgzgLghhCmIC5QDdYCcwEsD2A7RAjADQiYC2ADgDaxmy7QQ65iIDaomAJoiAMIB1AQC8ACgBUAEl2EB9PtgBmAZgAeAdgByAWUniAHCBKLMNXFDq8qUYQE8AdBFYkAxnjgNe5CtjQQABMD+ZFAURP4mVHBo4dAA1rDhFJgUsP4AvhFo2GT+AORotLBcUHkAOrgVsKo+fv5uLAFoAK64-gC8-gAUAJQdAHyBFf7+hRDNaG3JqV3DI-4l0ERzIyEUXWsAYrg9y23zkdFdh+jbuyv+8bBd4gCCANIAovIA8gCqmuI9cz0A3BXpX4gdJETg8BAgABaAE04i4AIpvAAMBDeAE5FAANe64ABWzVuLkwAHcACxGECRegWeAQ3DYAC01jsjmcIAaHggXkovgCQTW4ROMUuUASGSyOXyhToJXKlVw1VqAQakFGrQ63T67UGwDmKoCa1SXA1ay6iyg4S2O1+81tIz1eFVQuKGqFG1CRsFpmiZxtczGEzaV2O3vQxXCdyerw+X3+uEBwNBpHB-AAjgALTQANmJSOUAHF86FocgCEjFGQ+AARYkUqnmSx0mCYVCsikc+hciFVGq8+qOprqzq9AZDfYByYLGBQC72U1W777Eb2N1Cs6zsBUTAua5IiMPZ58d6fP4AoEgsG8ZqPZq4hmQ7RcSS2KvqLOY7QsdTKABGuLrpjUo2IAAOb0OgMC+G2rjuJ2vCKK0LhMHgABUwShAAktEkFoAAPOI4RvP0XSYNhP40Ag-hYRB5GwPh-SWqE2yUSRcBkJRXyjm8PSUdRaBQLRfE4bhRFjiMii+N0+r+KRtD+EoMlkTQfS6ku-i2JgsBUMaVqsbQp77OkAIVBUCG4EhzBoUKQkQL49F6fxtG8UpdHiAxEShmgzHdLJ7H+Jx2r+D+2DYDQUA7M5NE0DZdluWJESSV00m+fJiiKVFsAqRcmBpSGUSnLgelkD0WVqSMGlacavlxraRmGcZ8pmRZqEigkMV4W5DkCRRVEufR4RuK0ECUbgzRkD+6A8b1GXtfR8U0AEmAakiNUJWgUkDuluQKbJjnKfFIw5T5ADUx3+P0nSDQwfQ-oUoqreVmnaVtq11XVPZKv2jRqm0w5ajqDrfbtOEalc7WzGp1nYbZaAQ3a6EUOD5qMRQZx7PDa7yra5xqZGh7HuIPyrdJhRgM0UQarcaD8Q4ijZGQXUwwZgImfgF7JrwVZoOIYAAEIAEqyFAUDiPzzSYD+oiPBAmyPBhtwAWYNJeO10HsrBngQk1yG4GhtmzZ1UDU1AtgcWwAC6pXiYl0nIFAVCpf4Rs01b8wVc9dtUHMb2sx9fYk0OmqjqpIz69DvhmtOi62nOoTuqjOyzqunnrmpjiitcePRies62VTNO9GerPAubJBQBZqDiAJGEpjCcKIii6JYji+KEiS5IkGAsDjBQChcLSICKv7m1WhqXSqCNY0TWgU2jeN6Cjqo-hoQATHGQ91NJmNjxP-hz9PU3BaFsDhYv-gAKT+MoHTtJ0K1+5vm1Z0eMYagArHGFTSVWtx3LIADKGFISPA1AQWQSIIHgIgX6BUvZH7fXNJTY2tN6ZdCCGYECEB0yUR-n-QBwCMjhC6LIcImB-oyX8GdAgBkKRdx7lWcQ-9eD9xcNYQo-gQJUGwD+e28UWFsLSNJK0LFd770mqOMRaBVr8KNoIzamMRGT3njPUcR8wq4GkbAVhsivqqmfgTd+mjtHsOkuaJR08LarQag-PkCYSB3S4HQBQDA4IQgAMT+HhOLFwcR-D-2gHUfM4t+6szcR4zYgE-EQBaEhCYsAKgMn8HQ5oFBWSUX-t3FJ-Z+6XGwP4CghQKA6ORvUO6cB-Dpi0qkda2tmBgHCN3Fw9gEkyUoDQOgDBmyOgAPQoXsMAJwRBcRgHSLxNpRROk6w8jQMAX1oCYFwAskCQV6AuHTCENAcRtihI8QINApEln+F5qs9ZRs4hgFZgQewfjMkUG6Mk1JTgeKs0eKgNAtgsGHNsNgZo-gN4BGJKYB2E0nZ21MN1NIUBZlQA4VwnhDsJLrRPms1p1AJmMGYM0+UAADXFEBbCpDAC4fZFAIAVG6d00pJ9ynhWNP8y4sBVTmhsbogIiDOj5xNiuVB6D6CYOwf4MsEDCHdBITJchZD15wOVJtMmuRhzO3MegC25Dnb2EKFwZoO4uhdAtEFVVlCgrhCRAZXF2LWYr2uRhcZHSMWOm6NUCwaLWTPPlNatFtqum4AZGAVIRITAuGyWkEC2BGUVLDOEWl6kfl71gC6WyfzpVOwiIhKZDYXQFFaHkLFFQzX4sJcSlIZLYGfQDr9IOgUQ6jEyZOc06rihauuHK41zNc24oqBSLct0jaaVYAgDgIB028GlMUKAJcQQgDeVgPAhB0hAA) 以上就是這次的簡單測試。%੭ ˙ᗜ˙ )੭% 鱈魚:「以後遇到懶得做的 feature,就說我正在 Lazy Evaluation 就對惹。%(ゝ∀・)b%」 PM:(抽出生魚片刀 ( ˘•ω•˘ )) ## 總結 🐟 * `Remeda` 的 `lazy` 版本在處理大量資料時,能夠避免不必要的計算,提升效能。 * 不過當資料量小時,`lazy` 反而會拖慢效能。 * 性能會因情境、runtime、瀏覽器最佳化與程式寫法而有所不同,最好應該實際跑過 benchmark 再做選擇。 --- --- url: /blog-vue/vue-h-function-with-type-hints.md description: 先前提到 Quasar 的 Dialog Plugin 很好用,再讓我補充一個用法。ლ(╹◡╹ლ) --- ![vue-h-function-with-type-hints](/vue-h-function-with-type-hints.webp){.cover} # 所以我說那個型別呢?來一個有形別提示的 Vue h function 吧! [先前提到](./blog-vue/vue-h-function-makes-quasar-dialog-easier-to-reuse) Quasar 的 Dialog Plugin 很好用,再讓我補充一個用法。 有時候會希望可以點擊圖片時將圖片最大化呈現,這個需求如果要簡單實現,可以使用 QDialog 實作。 最直接的做法應該是點擊指定圖片時,將圖片的 src 存起來,再將 dialog 的顯示狀態改為 true,讓 Dialog 開啟。 其實你可以直接使用 $q.dialog 配合 h function 簡單完成。 ```vue ``` 這樣就可以簡單快速的利用 Dialog 開啟圖片了,完全不會有額外的 template 與變數!♪( ◜ω◝و(و ::: tip 不過如果要複雜的互動,例如:縮放平移等等,當然還是要包個專用元件處理。 ::: 當然不只有 QImg,想用其他元件也行,概念都一樣。◝( •ω• )◟ 不過這裡稍微有個小小的困擾,就是 h function 不會提示元件的參數,給錯或是參數改了都不知道。…(›´ω\`‹ ) 讓我們建立一個有元件參數提示的 h function 吧!(/≧▽≦)/ ## 有形別提示的 h function 第一步是建立協助抽取 Vue 元件型別的實用 function。 ::: tip 推薦官方工具 推薦使用官方的 [vue-component-type-helpers](https://www.npmjs.com/package/vue-component-type-helpers) ::: `types/index.ts` ```ts import { Slot } from 'vue' /** 提取 Vue Component 之內部 props * * 會將 style、class、event 全部取出來 */ export type ExtractComponentProps = Comp extends new () => { $props: infer P; } ? P : never /** 提取 Vue Component slots */ export type ExtractComponentSlots = Comp extends new (...args: any) => { $slots?: infer S; } ? (S extends Slot ? Parameters[0] : {} ) : {} ``` 接著新增 typedH function。 `common/utils.ts` ```ts import { Component, h } from 'vue' import { ExtractComponentProps, ExtractComponentSlots } from '../types' /** 元件繼承參數 * * [文件](https://cn.vuejs.org/guide/components/attrs.html#fallthrough-attributes) */ export interface InheritAttr { class?: string; style?: Record; /** 為了讓範例精簡,這裡只列出 click,可以自行追加基礎事件 */ onClick?: (event: MouseEvent) => void; } /** Veu h function 有型別推導的版本 * * [何謂 h function](https://cn.vuejs.org/guide/extras/render-function.html) * * @param component Vue SFC 元件或 html tag 名稱 * @param props SFC 內所有參數,包含 class、style、event 等等 * @param slots SFC 插槽 */ export function typedH( component: string, props?: string, ): ReturnType export function typedH( component: string, props?: InheritAttr, slots?: ExtractComponentSlots, ): ReturnType export function typedH( component: Comp, props?: ExtractComponentProps & InheritAttr, slots?: ExtractComponentSlots, ): ReturnType export function typedH( component: any, props?: any, slots?: any, ) { if (!slots) { return h(component, props) } return h(component, props, slots) } ``` 用法與原本的 h function 一模一樣,現在我們有型別提示了!✧⁑。٩(ˊᗜˋ\*)و✧⁕。\*。 ![型別提示](/vue-h-function-with-type-hints/Untitled.png) 就這樣,其實也不是甚麼神奇的東西。(´,,•ω•,,) [範例程式](https://stackblitz.com/~/github.com/Codfisher/blog-vue-typed-h)在此,大家下次見。( ´ ▽ \` )ノ ## 總結 🐟 * h function 快速將現有元件變成 Dialog 元件 * 透過 typedH function 可以有型別提示 --- --- url: /blog-ocean-world/use-nodejs-to-automatically-generate-the-compressed-file.md description: 有些案子需要打包、壓縮後交付,雖然壓縮按一下很簡單,但是一直重複就不簡單了。讓我們使用腳本自動產生壓縮檔,不用再手動壓縮了。♪(^∇^*) --- ![use-nodejs-to-automatically-generate-the-compressed-file](/use-nodejs-to-automatically-generate-the-compressed-file.webp){.cover} # 打包後順便壓縮一下吧,使用 Node.js 自動產生壓縮檔 有些案子需要打包、壓縮後交付,雖然壓縮也只是簡單按一下,但是一直重複就不簡單了。(˘・\_・˘) 讓我們使用腳本自動產生壓縮檔,解放手動壓縮輪迴吧。♪(^∇^\*) ## 檔案結構 第一步當然是先介紹一下檔案結構。 ```text . ├─ dist // 打包後的檔案 ├─ scripts │ └─ arch-dist.ts // 產生壓縮檔的腳本 ├─ package.json └─ ... ``` ## 安裝套件 搜尋了一下發現有個套件叫做 [archiver](https://www.npmjs.com/package/archiver),可以幫我們打包、壓縮檔案。 安裝 archiver: ```bash npm i -D archiver @types/archiver ``` 來寫程式吧!( •̀ ω •́ )✧ `scripts\arch-dist.ts` ```ts import fs from 'node:fs' import archiver from 'archiver' import { name } from '../package.json' const fileName = `${name}.zip` const output = fs.createWriteStream(`./${fileName}`) const archive = archiver('zip', { zlib: { level: 9 } }) output.on('close', () => { console.log(`zip 壓縮完成 : `, fileName) }) archive.on('error', (err) => { throw err }) /** 將資料傳輸至 output */ archive.pipe(output) /** 指定要加入的目錄,第二參數為 false,表示不要將自己加入 */ archive.directory('./dist', false) archive.finalize() ``` 程式碼就這麼簡單,非常的樸實無華。(´・ω・\`) 鱈魚:「現在我們來執行腳本吧!」 路人:「Node.js 又不能直接跑 TypeScript。(⊙\_⊙)?」 鱈魚:「沒錯,所以讓我們來編譯吧。(≧∇≦)ノ」 路人:(直接離場) 先別走啊!開玩笑的啦,我才不會這樣自找麻煩。(´,,•ω•,,) 可以利用 vite-node 直接執行 TypeScript 腳本。 vite-node 就是 Vitest 與 Nuxt 3 Dev SSR 底層使用的工具,可以讓我們無痛執行 TypeScript。 甚麼?你說 ts-node?那個實在太難用,我們還是用 vite-node 吧。(´・ω・\`) 現在讓我們新增腳本。 `package.json` ```json { "scripts": { "build": "vite build && npm run build:arch", "build:arch": "npx vite-node scripts/arch-dist.ts" } } ``` ::: tip 使用 npx 就不需要先安裝 vite-node 了,不知道的朋友可以看看[這裡](https://hoyis-note.coderbridge.io/2021/07/20/npm-npx-%E5%B7%AE%E5%88%A5/) ::: 這樣就可以在 build 後,自動執行壓縮檔案的腳本了。 ## 總結 🐟 * archiver 套件可以幫我們打包、壓縮檔案 * 使用 vite-node 可以無痛執行 TypeScript 腳本 終於不用一直手動壓縮惹。(。・ω・。) --- --- url: /article-overview.md description: 來找吧!我把所有的文章都放在這了! --- --- --- url: /blog-ocean-world/meme-blur-detection.md description: 如何用 Node.js 判斷梗圖是否模糊?從 Laplacian Variance 到分塊 P75,一路踩坑的演算法探索之旅 --- # 梗圖太糊怎麼辦?自動模糊偵測的踩坑之旅 %(・∀・)9% 大家好,我是鱈魚 🐟 最近分享快取梗圖強化搜尋功能時,發現有些圖片糊到不行,嚴重影響使用體驗。 問題是圖庫裡有近 900 張圖,總不能一張一張人工檢查吧?%(◞‸◟ )% ## 總結 🐟 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去 %(\*´∀\`)~♥% 有錯誤或不周全之處,還請多多指教 %( ´ ▽ \` )ノ% --- --- url: /blog-program/what-is-property-based-testing.md description: 測試案例都是自己想的,那沒想到的 bug 怎麼辦?讓機器幫你找吧 (ง •̀_•́)ง --- ![what-is-property-based-testing](/what-is-property-based-testing.webp){.cover} # 測試案例都是自己想的,那沒想到的呢?(◜௰◝) 大家好,我是鱈魚。%( ´ ▽ \` )ノ% [上一篇](/blog-program/bdd-with-vitest-cucumber)聊流程測試,結尾提到使用者會怎麼操作系統,你永遠想不到,那又是另一個故事。 路人當時吐槽「是有多少故事」,今天就來講其中一個。%(σ゚∀゚)σ% 回想一下那筆退款比營業額還高的 bug。 單元測試、整合測試、突變測試全部綠燈,最後是有人想到「客服可能分三次退」,才靠流程測試抓到。 問題是想得到的才寫得出來。 bug 偏偏躲在沒想到的地方。%( ´•̥̥̥ ω •̥̥̥\` )% 有沒有辦法不列情境,只寫規則,剩下交給機器自己去找反例? 有喔,這就是 Property-Based Testing。%( •̀ ω •́ )✧% 這篇會先介紹它,再拿實驗數據一條一條質疑它,最後聊它跟突變測試怎麼搭配。 ## 什麼是 Property-Based Testing 平常寫的測試叫做範例測試(Example-Based Testing),自己挑一組輸入、自己算好答案,再比對函式的輸出跟答案一不一樣。 ```ts it('100 元拆成 3 期', () => { expect(splitPayment(100, 3)).toEqual([33.34, 33.33, 33.33]) }) ``` Property-Based Testing 反過來,不寫答案,只寫「不管輸入是什麼都要成立」的規則,工具會自己產生上百組輸入,想辦法打破這條規則。 ```ts import { fc, test } from '@fast-check/vitest' // 金額用「分」產生再除以 100,保證最多兩位小數 const totalArbitrary = fc.integer({ min: 0, max: 10_000_000 }).map((cents) => cents / 100) const countArbitrary = fc.integer({ min: 1, max: 36 }) test.prop([totalArbitrary, countArbitrary])('拆完加回去,不多一分也不少一分', (total, count) => { const centsList = splitPayment(total, count).map((value) => Math.round(value * 100)) expect(centsList.reduce((sum, cents) => sum + cents, 0)).toBe(Math.round(total * 100)) }) ``` property 在這裡指數學上的「性質」,例如加法的交換律,所以我習慣叫它**性質測試**,免得跟物件的屬性搞混。 | | 範例測試 | 性質測試 | |---|---|---| | 輸入 | 自己挑 | 工具產生,一輪上百組 | | 期望 | 具體答案 | 一條規則 | | 失敗時 | 告訴你哪組輸入錯了 | 縮小過的反例 | 這招來自 Haskell 的 QuickCheck,Koen Claessen 與 John Hughes 在 ICFP 2000 發表,十年後拿下這個會議的[最具影響力論文獎](https://sigplan.org/Awards/ICFP/)。 後來各語言都有自己的版本,Python 有 Hypothesis,Java 有 jqwik,JavaScript 與 TypeScript 則是 [fast-check](https://fast-check.dev/)。 ## 把上次的退款 bug 丟給它 先安裝 fast-check,搭配 Vitest 的話再加上官方的整合套件。 ```bash npm install -D fast-check @fast-check/vitest ``` ::: tip 還在用 Vitest 3? `@fast-check/vitest` 0.4 之後要求 Vitest 4.1 以上,Vitest 3 請改裝 `@fast-check/vitest@0.3`。 ::: 退款邏輯沿用上一篇的 `checkRefund` 與 `submitRefund`,只把金流呼叫拿掉。 測試只寫一條規則,不管客服怎麼退,累計退款都不能超過訂單金額。 ```ts import { fc, test } from '@fast-check/vitest' import { expect } from 'vitest' import { createOrder, getRefundedAmount, submitRefund } from './refund' test.prop([ fc.integer({ min: 1, max: 100_000 }), // 訂單金額 fc.array(fc.integer({ min: 1, max: 100_000 })), // 客服依序申請的退款金額 ])('不管客服怎麼退,累計退款都不會超過訂單金額', (total, amountList) => { const order = createOrder({ total }) for (const amount of amountList) { submitRefund(order, amount) } expect(getRefundedAmount(order)).toBeLessThanOrEqual(order.total) }) ``` `fc.integer`、`fc.array` 這類產生器叫做 arbitrary,負責描述輸入的範圍。這裡的意思是一筆 1 到 10 萬元的訂單,加上一串 1 到 10 萬元的退款申請,筆數不限。 跑下去,紅了。 ```text Error: Property failed after 2 tests { seed: 614164776, path: "1:1:1:4:0:2:0:1:0:2:3:0:0:2:1:0:0:3:0:0:1:0:2:2:2:2:2:2:2:2:2:2:2:2:2:2:2:2:3", endOnFailure: true } Counterexample: [23558,[1,23558]] Shrunk 38 time(s) Caused by: AssertionError: expected 23559 to be less than or equal to 23558 ``` 才試到第 2 組輸入就抓到了。%Σ(ˊДˋ;)% `Counterexample` 翻成人話,一筆 23558 元的訂單,先退 1 元,再退 23558 元,總共退了 23559 元。 每一筆單獨檢查都沒超過訂單金額,加起來就超過了,跟上一篇分三次退 400 元是同一個 bug。 差別在於這次沒有人想到「分次退款」,我只寫了一條規則,情境由機器自己找出來。%( •̀ ω •́ )✧% ### 縮小反例 `Shrunk 38 time(s)` 代表它把反例縮小了 38 次。 fast-check 第一次遇到失敗時,輸入通常亂七八糟。換個 seed(亂數種子)跑,第一次失敗的輸入長這樣。 ```text [99987, [99993, 69478, 74257]] ``` 訂單 99987 元,三筆退款申請裡,第一筆超過訂單金額沒有通過,後兩筆加起來才超過,要看懂還得先心算一輪。%(°c_,°\`)% 找到失敗之後,fast-check 會一直嘗試把數字改小、把陣列改短,只要還是失敗就保留,最後縮成這樣。 ```text [74257, [1, 74257]] ``` 這個過程叫做 shrinking。只看縮完的結果,一眼就知道問題出在「先退一點,再退全額」。 為了確認不是運氣好,我用 1000 個不同的 seed 各跑一輪,每輪照預設試 100 組輸入。 * 1000 輪全部抓到 * 中位數試到第 3 組就失敗,最慢的一輪試到第 21 組 * 995 輪縮成「先退 1 元,再退全額」,另外 5 輪縮成「先退全額,再退 1 元」 不過縮小一次只動一個參數,所以停在 `[74257, [1, 74257]]`。最理想的反例是「1 元的訂單退兩次 1 元」,得同時縮小訂單金額與退款金額,它就到不了。%(´・ω・\`)% ## 規則比情境好列 「訂單 1000 元,分三次退 400 元,第三次要拒絕」,情境是分三次退,答案是第三次拒絕,換成規則一句就寫完。 一條規則背後可能有上百種情境。規則的數量少很多,也比較不會隨著需求變動。%(ゝ∀・)b% 路人:「說得那麼好聽,還不是丟亂數進去?%(´・ω・\`)%」 鱈魚:「好問題,接下來就一條一條質疑它。%(ง •̀\_•́)ง%」 ## 質疑一:這不就是亂數測試嗎? 跟自己寫個迴圈跑 `Math.random()` 比,差在三件事,而且都量得出來。 ### 產生器會偏心 `fc.integer()` 預設的範圍是 -2147483648 到 2147483647,大約 43 億個整數。 我照實際跑測試的設定,用 1000 個 seed 各跑 100 次,收集 10 萬個 `fc.integer()` 產生的值來數。 | 產生的值 | fast-check 實際出現率 | 均勻亂數 | |---|---|---| | 剛好是 0 | 0.38% | 43 億分之 1 | | 絕對值不超過 10 | 7.85% | 約 2 億分之 1 | | 絕對值不超過 1000 | 23.08% | 約 215 萬分之 1 | | 離最大值或最小值 10 以內 | 4.05% | 約 2 億分之 1 | fast-check 刻意偏向 0、小數字與範圍兩端,bug 最常躲在這幾區。%(≖‿ゝ≖)✧% ### 會自己縮小反例 自己寫的亂數迴圈只會丟給你一坨數字,剩下自己想辦法。 ### 失敗可以重播 把錯誤訊息裡的 `seed` 與 `path` 填回測試的參數,就能直接重現同一個反例。 ```ts test.prop([ fc.integer({ min: 1, max: 100_000 }), fc.array(fc.integer({ min: 1, max: 100_000 })), ], { seed: 614164776, path: '1:1:1:4:0:2:0:1:0:2:3:0:0:2:1:0:0:3:0:0:1:0:2:2:2:2:2:2:2:2:2:2:2:2:2:2:2:2:3', endOnFailure: true, })('重播上次失敗的輸入', (total, amountList) => { // ...同上 }) ``` 實際跑一次,第 1 組輸入就失敗,縮小 0 次,反例一樣是 `[23558,[1,23558]]`。 修好之後,可以把反例放進 `examples`,之後每一輪都會先跑它,再跑隨機輸入。 ```ts test.prop([ fc.integer({ min: 1, max: 100_000 }), fc.array(fc.integer({ min: 1, max: 100_000 })), ], { examples: [[23558, [1, 23558]]], })('不管客服怎麼退,累計退款都不會超過訂單金額', (total, amountList) => { // ...同上 }) ``` 這樣修好的 bug 就不會在哪天悄悄回來。%(ゝ∀・)b% ## 質疑二:輸入每次都不一樣,會不會今天綠明天紅? 會。%(´・ω・\`)% 偏心歸偏心,產生器並沒有保證一定會試到哪個值。 我寫了幾個只在特定條件出錯的假 bug,一樣用 1000 個 seed 各跑一輪,統計每一輪抓到的機率。 | bug 在什麼時候出現 | 一輪抓到的機率 | |---|---| | `x` 剛好是 0 | 31.3% | | `x` 剛好是最大值 2147483647 | 17.8% | | `x` 剛好是 123456 | 0% | | 兩個整數剛好相等 | 10.9% | | 陣列裡有重複的值 | 33.4% | | `x` 小於等於 0 | 100% | 大家都說性質測試會自動測邊界值,可是只在 0 出錯的 bug,每三輪 CI 只有一輪抓得到。%(°ㅂ°)% 反過來,只要 bug 涵蓋一整段範圍,例如「小於等於 0 就出錯」,幾乎第一輪就會現形。 範圍訂得窄一點,情況會好很多。 | 寫法 | 抓到「剛好是 0」的機率 | |---|---| | `fc.integer()` | 31.3% | | `fc.nat()`,只產生非負整數 | 54.0% | | `fc.integer({ min: 0, max: 1000 })` | 91.5% | | `fc.integer()` 跑 1000 次 | 94.3% | 範圍越貼近真實的資料,越容易抽中邊界值,所以寫產生器不要偷懶,訂單金額就寫訂單金額的範圍。 ### 預設值的盲區 | bug 在什麼時候出現 | 一輪抓到的機率 | |---|---| | 陣列超過 10 個元素 | 0% | | 字串含逗號,預設 `fc.string()` | 97.9% | | 字串含 emoji,預設 `fc.string()` | 0% | | 字串含 emoji,`unit: 'grapheme'` | 100% | | 字串含中文,`unit: 'grapheme'` | 2.7% | | 字串含中文,`unit: 'binary'` | 100% | fast-check 預設產生的陣列與字串最長 10 個,預設的 `fc.string()` 只有可列印的 ASCII 字元。 `maxLength` 最容易讓人誤會。寫了 `fc.array(fc.integer(), { maxLength: 50 })`,產生的陣列照樣最長 10 個,因為長度還受 `size` 控制,要加上 `size: 'max'` 才會用到 50。不設 `maxLength` 的話,改成 `size: 'medium'` 最長可到 100。 所以分頁這種「超過 20 筆才出錯」的 bug,用預設值跑到天荒地老也抓不到。 字串也一樣,要測 emoji 得指定 `unit`,偏偏 `'grapheme'` 很少產生中文,`'binary'` 則幾乎不產生逗號、反斜線這類符號。要穩就用 `fc.constantFrom` 列出想測的字元,再跟一般的字串產生器混在一起。%(◞‸◟ )% **產生器產不出來的輸入,性質測試永遠測不到**。它省下你想情境的力氣,輸入長什麼樣子還是得自己想。 ### 那到底算不算 flaky? 一般說的 flaky test,指程式沒問題、測試卻亂紅,例如時間差、網路不穩。 性質測試的紅燈不一樣,每一次紅都附上一組真實的反例,重播就能重現,代表程式真的有 bug,只是這一輪才碰到。 CI 可以固定 seed 求穩定,代價是永遠只測同樣那 100 組輸入。我比較喜歡讓 seed 隨機,多跑一次就多探索一點。 ## 質疑三:規則要怎麼想?比寫範例還難吧? 老實說,比較難。%(´ー\`)% Harrison Goldstein 等人在 ICSE 2024 發表的[〈Property-Based Testing in Practice〉](https://harrisongoldste.in/papers/icse24-pbt-in-practice.pdf),訪談了 Jane Street 的 31 位工程師。30 場訪談裡有 16 場提到寫規格拖慢了進度,有位受訪者講得很直接,最常見的失敗是根本不知道要測什麼性質。 好在前人整理過套路。Scott Wlaschin 在 2014 年寫的[〈Choosing properties for property-based testing〉](https://fsharpforfunandprofit.com/posts/property-based-testing-2/)列了七種,我挑六種,全部套在實驗會用到的函式上。 | 套路 | 意思 | 範例 | |---|---|---| | 來回轉換 | 轉換一次再反過來轉換一次,要回到原點 | 標籤編碼再解碼,拿回同一份清單 | | 不變量 | 轉換前後有東西不會變 | 金額拆完加回去,不多一分也不少一分 | | 冪等 | 執行兩次跟執行一次一樣 | 合併過的時段再合併一次,結果不變 | | 殊途同歸 | 換個順序執行,結果一樣 | 時段輸入順序顛倒,合併結果一樣 | | 對照組 | 拿笨但一定對的方法比對 | 一分鐘一分鐘數,合併前後涵蓋的時間一樣 | | 難算易驗 | 答案難算,驗證卻很簡單 | 合併結果照順序排好,段與段之間有空檔 | 來回轉換最好寫,核心只有一行。 ```ts test.prop([fc.array(fc.string({ minLength: 1 }))])('編碼再解碼,要拿回一模一樣的清單', (tagList) => { expect(decodeTagList(encodeTagList(tagList))).toEqual(tagList) }) ``` 對照組則是把「怎麼算」換成「怎麼驗」。合併時段的演算法要排序、要判斷包含與相接,驗證卻只要把每一分鐘數出來。 ```ts const timeRangeArbitrary = fc .tuple(fc.integer({ min: 0, max: 1440 }), fc.integer({ min: 1, max: 180 })) .map(([start, duration]): TimeRange => [start, start + duration]) function toMinuteSet(rangeList: TimeRange[]) { const minuteSet = new Set() for (const [start, end] of rangeList) { for (let minute = start; minute < end; minute++) { minuteSet.add(minute) } } return minuteSet } test.prop([fc.array(timeRangeArbitrary)])('合併前後涵蓋的分鐘一模一樣', (rangeList) => { expect(toMinuteSet(mergeTimeRanges(rangeList))).toEqual(toMinuteSet(rangeList)) }) ``` 笨方法慢歸慢,笨到不可能寫錯,拿來當標準答案剛剛好。%(ゝ∀・)b% Goldstein 的訪談也發現,業界最常用這種跟另一份實作比對的性質,30 場訪談裡有 17 場提到,例如拿舊版系統當對照組,驗證重寫的新版。 ### 別把實作抄一遍 新手很容易把實作再寫一次,拿來當期望值。 ```ts // 別這樣寫 test.prop([totalArbitrary, countArbitrary])('跟實作算的一樣', (total, count) => { const totalCents = Math.round(total * 100) const baseCents = Math.floor(totalCents / count) const remainderCents = totalCents % count const expectedList = Array.from({ length: count }, (_, index) => (index < remainderCents ? baseCents + 1 : baseCents) / 100) expect(splitPayment(total, count)).toEqual(expectedList) }) ``` 實作怎麼錯,測試就跟著怎麼錯,這條測試永遠都會過。%(´\_ゝ\`)% ## 質疑四:AI 一秒寫一百條範例,還需要性質測試嗎? 路人:「現在叫 AI 寫測試,邊界值它比我還會想,範例一口氣生一百條,性質測試還有必要嗎?%(´・ω・\`)%」 鱈魚:「我也不知道,所以來做個實驗。%( •̀ ω •́ )✧%」 ### 實驗設計 準備四支常見的商業邏輯函式,每支都附 JSDoc 規格與正確實作。 | 函式 | 規格重點 | |---|---| | `splitPayment` | 金額拆成多筆,加總不差一分,任兩筆相差不超過 0.01,大的排前面 | | `truncateText` | 以 Unicode code point 計算字數,emoji 也算一個字,超過就截斷並接上「…」 | | `mergeTimeRanges` | 合併重疊或相接的時段,不可修改傳入的陣列 | | `encodeTagList`、`decodeTagList` | 標籤清單與逗號分隔字串互轉,必須完整還原 | 接著請另一個 AI 扮演「之後來改程式的人」,替每支函式寫 4 個常見的錯誤版本,共 16 個。每個錯誤版本都通過 JSDoc 裡的範例,例如: * 轉換成「分」時把 `Math.round` 寫成 `Math.floor`,`19.99 * 100` 會變成 `1998.9999999999998`,平白少一分 * 用 `.length` 與 `.slice()` 算字數,會把 emoji 切成一半 * 合併時段時忘了取 `Math.max`,包在前一段裡面的時段會把範圍縮小 * 跳脫字元只跳脫逗號,忘了先跳脫反斜線本身 這 16 個版本在任何測試寫出來之前就定案,寫測試的 AI 看不到。 受試者是 6 個各自獨立的 Claude Sonnet 5,3 個只准寫範例測試(E 組),3 個只准寫性質測試(P 組),除了這條限制以外,指示一字不差。它們看得到規格與正確實作,測試必須在正確實作上全綠。 最後比三件事: * **擋下幾個錯誤版本**:把錯誤版本換進去跑,至少一條測試失敗就算擋下。性質測試每次輸入不同,所以每個版本跑 20 次取平均 * **突變分數**:Stryker 在正確實作上的分數 * **成本**:測試數量與執行時間 開跑前我先把預測寫下來,其中一條是「P 組擋下的錯誤版本比較多」,信心六成。 ### 結果:平手 | | E1 | E2 | E3 | P1 | P2 | P3 | |---|---|---|---|---|---|---| | 測試數 | 52 | 57 | 51 | 15 | 19 | 16 | | 擋下的錯誤版本(共 16 個) | 14 | 15 | 15 | 15.95 | 11.2 | 15.95 | | 突變分數 | 100% | 98.7% | 100% | 98.7% | 87.3% | 98.7% | 15.95 代表 16 個版本裡有 15 個每次都擋下,剩下那個 20 次擋下 19 次。 E 組平均擋下 14.67 個,P 組 14.37 個。照事先訂的門檻,差距不超過 1 個版本就算量不出差別,我賭的「P 組擋下比較多」不成立。%(´;ω;\`)% AI 寫的範例測試比我想像的強很多。emoji 切一半、時段互相包含、跳脫字元這些經典邊界,三位 E 組受試者全部都有測。 ### 漏掉的地方幾乎不重疊 | 錯誤版本 | 誰漏掉 | |---|---| | `splitPayment`:`Math.round` 寫成 `Math.floor` | E1、E2 全漏;P1、P3 二十次各漏一次 | | `splitPayment`:餘數平均散開,破壞「大的排前面」 | E1、E3 | | 標籤:四個跟跳脫有關的錯誤 | P2 全漏 | | 時段:相接判斷用 `<` 取代 `<=` | P2 二十次只抓到四次 | **範例測試漏掉的錯,全都要剛好挑中某些值才會出現。** `Math.floor` 那個版本,大約 6.56% 的兩位小數金額會出錯,0.29、0.57、1.13、19.99 都會,而且看不出規律。範例測試多半挑 100、0.01、1999.99 這種漂亮的數字,一個都沒踩中。 E1 特地寫了一條測試防浮點數誤差,註解寫著 `29.99 * 100 !== 2999`,可是在 JavaScript 裡 `29.99 * 100` 剛好等於 2999,這條測試完全擋不下 `Math.floor` 那個版本。%(╯°Д°)╯︵ ┻━┻% E3 抓到了,也是誤打誤撞。它刻意用 `0.3 / 3` 那條防浮點數誤差,偏偏擋不住,最後靠另一條拿 19.99 測「餘數給第一筆」的案例擋下來。 性質測試一輪試 100 個隨機金額,照機率算幾乎一定會踩到。 **性質測試的漏網之魚,幾乎全來自 P2,問題都出在產生器。** P2 的測試看起來最用心。它在註解說,字串用 `unit: 'binary'` 涵蓋完整 Unicode,逗號、反斜線這類需要跳脫的字元也會驗到;時段起點則用 `fc.integer()` 開到正負 21 億,驗證大數值與負數。 結果這兩個決定都弄巧成拙。 `unit: 'binary'` 從一百多萬個 code point 裡抽字元。我實際產生 10 萬個字串,找不到任何反斜線,含逗號的也只有 1 個;預設只產生 ASCII 的 `fc.string()`,反而各有約 5%。P2 的標籤清單產生器,一輪 100 次裡出現反斜線的機率只有 0.3%,四個跟跳脫有關的錯誤版本當然全漏。 時段也一樣。兩段剛好首尾相接的機率趨近於零。我把 P2 那條「段與段之間要有空檔」的性質單獨拿出來,只換範圍,各跑 1000 輪: | 起點範圍 | 抓到相接錯誤的機率 | |---|---| | P2 的寫法,正負 21 億 | 6.7% | | 一天的分鐘數,0 到 1440 | 76.5% | | 0 到 30 | 100% | P1 與 P3 剛好相反,兩個都刻意提高逗號與反斜線的出現機率,時段範圍也只有幾十到一百多分鐘,所以都接近滿分。 **範圍開越大,特定的值越稀有**,看起來涵蓋更多,實際上測到的更少。%(╬☉д⊙)% ### 各配一份就幾乎全抓 既然漏的地方幾乎不重疊,把 E1 配 P1、E2 配 P2、E3 配 P3,統計兩份測試加起來擋下幾個。 | 組合 | 擋下的錯誤版本(共 16 個) | |---|---| | E1 加 P1 | 15.95 | | E2 加 P2 | 16 | | E3 加 P3 | 16 | 最弱的 P2 配上 E2,一樣拿到滿分。範例測試負責經典邊界,性質測試負責那些要剛好挑中的值。 ### 成本 | | E 組 | P 組 | |---|---|---| | 測試程式碼(非空白行) | 239 到 352 | 157 到 245 | | 跑一次 `vitest run`(10 次中位數) | 0.67 到 0.69 秒 | 1.5 到 2.9 秒 | | 其中純測試時間 | 19 到 23 毫秒 | 0.53 到 1.7 秒 | | 跑一輪 Stryker | 約 9 秒 | 54 到 75 秒 | 性質測試的時間多半花在 `splitPayment`,P 組把筆數開到 500 或 1000,每條性質都要把金額拆成上百筆、重複 100 次。 另外,三位 P 組受試者寫出的 `splitPayment` 性質幾乎一模一樣,就是把 JSDoc 的五條規格逐條翻成五條性質。規格寫成條列時,性質測試幾乎可以照抄。%(´,,•ω•,,)% ### 讀結論前先打折 這個實驗有幾個地方要先講清楚。%(´・ω・\`)% * **樣本很小。** 4 支函式、16 個錯誤版本、每組 3 位受試者,差一兩個版本就會改變結論。 * **錯誤版本由 AI 產生。** 類型偏向 AI 想得到的錯,真實專案的 bug 可能更刁鑽。 * **受試者看得到正確實作。** 寫範例測試時可以直接執行實作拿期望值,E1 與 E2 都這樣做過。 * **受試者都是 Claude Sonnet 5。** 換模型或換人寫,結果可能不同。 * **P3 自己驗證過。** 它寫了四個故意改壞的版本檢查自己的測試,類型跟實驗的錯誤版本有部分重疊,這在指示允許的範圍內,但可能讓 P3 的成績偏高。 完整的設計、預測、每個錯誤版本的程式碼差異與原始數據,都在[實驗報告](https://github.com/Codfisher/cod-aquarium/blob/main/docs/property-based-testing-experiment.md)。 ## 突變測試與性質測試差在哪 看過[突變測試](/blog-program/what-is-mutation-testing-and-stryker)那篇的朋友,可能會覺得兩個很像,都是讓工具自動產生一大堆東西來測程式。 兩者改動的對象剛好相反。%(ゝ∀・)b% | | 突變測試 | 性質測試 | |---|---|---| | 自動產生什麼 | 改壞的程式碼,也就是突變體 | 輸入資料 | | 回答的問題 | 測試夠不夠嚴 | 程式在沒想到的輸入下對不對 | | 產出 | 突變分數、存活的突變體 | 縮小過的反例 | | 身分 | 替測試打分數的工具 | 一種寫測試的方法 | | 人要想什麼 | 幾乎不用想 | 規則與輸入範圍 | | 貴在哪 | 突變體數量乘上整套測試的時間 | 每條性質跑上百次 | 兩者不在同一層,所以可以一起用。 ### 突變分數 100%,照樣漏掉 bug 回到實驗。E1 的範例測試在 Stryker 拿到 100%,79 個突變體全數擊殺,可是 16 個錯誤版本它漏了 2 個。 打開 Stryker 替 `splitPayment` 產生的 12 個突變體,沒有一個會把 `Math.round` 換成 `Math.floor`。它只會用固定的招式改程式,例如 `*` 換成 `/`、`<` 換成 `<=`、整段清空,招式表裡沒有的錯,分數再高也沒驗到。 E1 另外漏掉「把餘數平均散開」這個版本。這種錯等於換掉了整套演算法,跟[上一篇](/blog-program/bdd-with-vitest-cucumber)說的「少寫的那一行」一樣,都不在突變測試產生得出來的範圍裡。 性質測試只檢查結果有沒有符合規則,改一個符號或整支重寫都一樣。 ### 存活的突變體,剛好指出產生器的盲點 反過來看 P2,突變分數 87.3%,六位受試者裡最低,10 個存活的突變體分布如下: * 9 個在 `tag-list` 的跳脫邏輯 * 1 個在 `mergeTimeRanges` 的相接判斷,把 `<=` 改成 `<`,跟其中一個錯誤版本一模一樣 這正好是 P2 的兩個盲點。它如果寫完測試順手跑一次 Stryker,就會看到跳脫邏輯那幾行全都沒驗到,回頭檢查產生器,發現 `unit: 'binary'` 幾乎不會產生反斜線。 **突變測試幫你檢查性質寫得夠不夠嚴、產生器夠不夠廣;性質測試幫你拿沒想到的輸入檢驗規則。** 兩個剛好各補對方一塊。%( •̀ ω •́ )✧% ## 兩者怎麼配合 ### 先避開兩個坑 我第一次拿 Stryker 跑性質測試,分數是 0%。%((((;゚Д゚)))% 測試檔只有兩條 `test.prop`,一條檢查加總,一條檢查相差不超過 0.01。Stryker 產生 12 個突變體,連「整個函式清空」這種必死的突變體都活了下來。 追下去發現是兩個獨立的坑。 **坑一:seed 寫在測試名稱裡。** `@fast-check/vitest` 會把這一輪用的 seed 接在測試名稱後面,沒指定 seed 的話,每次都是新的亂數。 ```text ✓ 加總剛好等於總額 (with seed=442508060) ``` Stryker 會先完整跑一次測試,記下每條測試的名稱與它涵蓋的突變體。之後測每個突變體時,它把記下的名稱組成 Vitest 的 `testNamePattern`,只跑相關的測試。 問題是每跑一次,seed 就重抽一次,名稱跟著變,舊名稱一條測試都對不上。Vitest 一條測試都沒跑,沒有失敗,突變體就算存活。報告裡的 `Tests ran` 還會列出那兩條測試,看起來就像真的跑過。 **坑二:Vitest 5 換了名稱的串接符號。** Vitest 5 比對 `testNamePattern` 時,會把 `describe` 與測試名稱用 `>` 串起來,Stryker 10.0.0 還在用空白串接,只要測試包在 `describe` 裡就對不上。這個問題已經有人回報([stryker-js #6210](https://github.com/stryker-mutator/stryker-js/issues/6210)),修正的 PR 還沒合併。 這個坑跟性質測試無關,純範例測試一樣中招。 我在 Vitest 4.1.11 與 5.0.1 各跑一輪,同一支 `splitPayment`、同樣 12 個突變體。 | 寫法 | Vitest 4.1.11 | Vitest 5.0.1 | |---|---|---| | `test.prop`,seed 隨機 | 0% | 0% | | `test.prop`,固定 seed | 100% | 100% | | 原生 `it` 包 `fc.assert` | 100% | 100% | | 名稱固定,但包在 `describe` 裡 | 100% | 0% | 要避開坑一,就替 Stryker 另外準備一份 Vitest 設定,在 setup 檔固定 seed,平常跑測試時 seed 照樣隨機。 ```ts // vitest.stryker.setup.ts import { fc } from '@fast-check/vitest' fc.configureGlobal({ seed: 20260924 }) ``` 接著讓 Stryker 專用的 Vitest 設定載入這支 setup 檔。 ```ts // vitest.stryker.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['test/**/*.test.ts'], setupFiles: ['./vitest.stryker.setup.ts'], }, }) ``` 最後在 Stryker 的設定裡指定這份 Vitest 設定。 ```json // stryker.config.json { "testRunner": "vitest", "vitest": { "configFile": "vitest.stryker.config.ts" } } ``` 或者乾脆不用 `test.prop`,改用原生的 `it` 包 `fc.assert`,名稱就固定了。 ```ts import fc from 'fast-check' import { expect, it } from 'vitest' it('拆完加回去,不多一分也不少一分', () => { fc.assert(fc.property(totalArbitrary, countArbitrary, (total, count) => { // ...同前 })) }) ``` 坑二目前只能等 Stryker 修正,或是先留在 Vitest 4。實驗裡的突變分數,就是在 Vitest 4.1.11 加上固定 seed 跑出來的。 兩個坑最麻煩的地方在於踩到了也不會報錯。只要專案裡還有其他測試殺得死那些突變體,分數看起來就很合理,很難察覺有一批測試根本沒跑。 看到分數異常低,或是整輪突變測試跑得異常快,先懷疑工具,再懷疑自己。 ### 配合的流程 1. **先寫性質。** 規格裡的條列,大多可以直接翻成性質 2. **產生器貼近真實資料,再刻意提高特殊值的出現機率。** 例如時段用一天的分鐘數,標籤刻意多放逗號與反斜線 3. **用固定 seed 跑 Stryker。** 存活的突變體會告訴你哪條規則太鬆、哪個產生器太窄 4. **關鍵案例寫成範例測試。** 規格裡的範例、踩過的反例、經典邊界,用範例測試釘住,讀的人也最好懂 至於成本,性質測試會讓 Stryker 變慢,因為每個突變體都要把性質重跑上百次。實驗裡慢了六到八倍。跟突變測試那篇的建議一樣,只有核心邏輯值得兩種一起上。 ## 別人的研究怎麼說 我的實驗規模很小,所以回頭找了幾篇論文對照。%(´・ω・\`)% **工業界的戰績。** QuickCheck 作者 John Hughes 在 2016 年的[〈Experiences with QuickCheck〉](http://publications.lib.chalmers.se/records/fulltext/232550/local_232550.pdf)整理了幾個案例。替 Volvo 測汽車軟體標準 AUTOSAR 時,他們讀了約 3000 頁規格,寫成 2 萬行的 QuickCheck 模型,測了 6 家供應商共 100 萬行 C 程式碼,找出 200 多個問題,其中超過 100 個是標準本身的模糊或矛盾。 另一個案例是 Klarna 的資料庫,每兩個月就壞一次,工程師花了六週都找不到原因。QuickCheck 測了大約 10 分鐘就抓到,最後發現只要一筆資料加上五、六次 API 呼叫就能重現。 **業界怎麼用。** 前面提過的 Goldstein 等人訪談 Jane Street,有位受訪者說,抽象很清楚、實作卻很複雜時,性質測試最有用。 **一條性質測試抵幾條範例?** Savitha Ravi 與 Michael Coblenz 在 OOPSLA 2025 發表的[〈An Empirical Evaluation of Property-Based Testing in Python〉](https://cseweb.ucsd.edu/~mcoblenz/assets/pdf/OOPSLA_2025_PBT.pdf),分析了 426 個使用 Hypothesis 的專案,並對其中 40 個跑突變測試,結論是平均每條性質測試抓到的突變,約是一般單元測試的 50 倍。 這個數字要打折看。一條性質測試本來就會跑上百組輸入,逐條比較先天佔便宜;作者自己也說這是相關性研究,關聯偏弱。而且這些專案裡 98.4% 的測試仍是單元測試。 同一篇還發現,55% 的突變只要一組輸入就抓到,76% 在前 20 組之內。這跟我的實驗對得上,預設 100 次大多夠用,會漏掉通常是因為產生器很少產出那種輸入。 **讓 AI 寫性質測試。** Muhammad Maaz、Nicholas Carlini 等人在 2025 年的[〈Agentic Property-Based Testing〉](https://arxiv.org/abs/2510.09907)(作者多半來自 Anthropic)讓 Claude Code 自己替 100 個熱門 Python 套件寫性質測試,一共產出 984 份 bug 報告。人工抽查之後,56% 確實是 bug,32% 值得回報給維護者,每抓到一個有效 bug 的成本不到 10 美元。其中一個是 NumPy 的 Wald 分布會回傳負數,修正已經合併。 2026 年的 [PBT-Bench](https://arxiv.org/abs/2605.15229) 則專門出難題,植入的 bug 刻意設計成預設產生器幾乎碰不到。AI 得先讀文件找出規則,再寫出能把輸入集中到觸發範圍的產生器,表現最好的模型也只抓到 83.4%。P2 正好栽在這一點上。 | 我的發現 | 研究 | 對照 | |---|---|---| | 性質測試能自己找出沒想到的情境 | Klarna 的資料庫 bug | 一致 | | 規則寫得出來才用得上 | Jane Street 訪談 | 一致 | | 性質測試沒有擋下比較多 | Ravi 與 Coblenz 的 50 倍 | 比較單位不同 | Ravi 與 Coblenz 逐條比,一條性質抵很多條範例;我拿整套測試比,E 組用 50 幾條範例,擋下的數量跟 P 組十幾條性質差不多。 ## 總結 🐟 AI 一秒寫一百條範例,還需要性質測試嗎?需要,但別期待它單打獨鬥。%(´・ω・\`)% * **性質測試只要你寫規則,情境交給機器找。** 上一篇靠人想到「分三次退」才抓到的 bug,一條規則加上 fast-check,1000 輪全部抓到,還自動縮成「先退 1 元,再退全額」。 * **它只是抽樣,邊界值也不一定抽得到。** 只在 0 出錯的 bug,每三輪只抓到一輪;紅燈都是真 bug,綠燈只代表這一輪的輸入沒出事。 * **產生器決定它測得到什麼。** 範圍要貼近真實資料,特殊值的出現機率要刻意提高。 * **跟 AI 寫的範例測試互補。** 兩組平均擋下的數量打平,漏掉的地方卻幾乎沒有重疊;範例測試會漏掉要剛好挑中的值,性質測試會漏掉產生器的盲區,各配一份幾乎全抓。 * **突變測試替性質測試健檢。** 突變分數 100% 的範例測試照樣漏掉浮點數錯誤,性質測試補得上;P2 存活的突變體剛好指出它的盲點。搭配時記得固定 seed,Vitest 5 則要等 Stryker 修正。 有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。%( ´ ▽ \` )ノ% --- --- url: /blog-program/what-is-mutation-testing-and-stryker.md description: 測試覆蓋率蓋好蓋滿,卻還是有蟲蟲,有沒有一個更誠實的分數? --- ![what-is-mutation-testing-and-stryker](/what-is-mutation-testing-and-stryker.webp){.cover} # 測試覆蓋了卻還是有蟲蟲?來點突變吧! 大家好,我是鱈魚。%( ´ ▽ \` )ノ% 有沒有遇過 AI 洋洋灑灑寫了一卡車的測試,跑起來全部亮綠燈,覆蓋率甚至高達 100%,心滿意足地收工回家。 結果馬上就被 bug 炸飛了。%( ´•̥̥̥ ω •̥̥̥\` )% 覆蓋率只代表你的測試**有經過那幾行程式**,不代表那幾行**有被好好驗證**。 有沒有一種指標,能真正告訴我們「測試到底擋不擋得住 bug」? 有喔,這就是「突變測試」(Mutation Testing)。%(ノ>ω<)ノ% ## 什麼是突變測試 突變測試的主要目的是**測試你的測試**,聽起來很饒舌,其實概念很簡單。 其概念很壞壞,也很聰明。%(´,,•ω•,,)% 簡單來說就是故意把你的程式碼改壞,製造出一堆有 bug 的版本,這些被改壞的版本就叫做「突變體」(Mutant)。 改壞的方式五花八門,例如: * 把 `>=` 改成 `>` * 把 `+` 改成 `-` * 把 `&&` 改成 `||` * 直接把某行程式刪掉 接著拿你原本的測試,跑每一個突變體,有兩種結果: * **突變體被殺死(Killed)**:測試失敗了,代表測試成功抓到這個被改壞的地方,這是好事。 * **突變體存活(Survived)**:測試竟然還是全綠,代表程式被改壞了測試卻沒發現,這就是漏洞。 最後它會把「被殺死的比例」算成一個「突變分數」(Mutation Score): ```text 突變分數 = 被殺死的突變體 / 總突變體數量 ``` 覆蓋率回答的是「這行有沒有被跑過」,突變分數回答的是「這行如果寫錯了,你的測試攔不攔得住」。 後者才能看出測試是否全面。%(ゝ∀・)b% ## 來個案例吧 光講概念太無聊了,大家可能都跑光了。 甚麼?已經走了?別走啊,程式端上來了啊。%ლ(´口\`ლ)% 假設有個計算運費的函數,規則稍微複雜一點: * 一般會員:滿 1000 免運,否則收 60 元 * VIP 門檻較低:滿 500 免運,否則收 30 元 `shipping.ts` ```ts interface Order { amount: number; isVip: boolean; } export function getShippingFee(order: Order): number { const threshold = order.isVip ? 500 : 1000 const base = order.isVip ? 30 : 60 return order.amount >= threshold ? 0 : base } ``` 接著來點測試,一般會員與 VIP 各測「免運」跟「要付運費」兩種,這裡用 [Vitest](https://vitest.dev/) 示範: `shipping.test.ts` ```ts import { describe, expect, it } from 'vitest' import { getShippingFee } from './shipping' describe('getShippingFee', () => { it('一般會員金額足夠免運', () => { expect(getShippingFee({ amount: 1500, isVip: false })).toBe(0) }) it('一般會員金額不足要收運費', () => { expect(getShippingFee({ amount: 800, isVip: false })).toBe(60) }) it('VIP 門檻較低,同樣金額就免運', () => { expect(getShippingFee({ amount: 800, isVip: true })).toBe(0) }) it('VIP 金額不足,收較低運費', () => { expect(getShippingFee({ amount: 300, isVip: true })).toBe(30) }) }) ``` 跑一下測試,全綠。覆蓋率 100%,完美,收工。%ᕕ( ᐛ )ᕗ% 一般會員、VIP、免運、要運費,四種組合全測過了,可以安心下班了吧?%ლ(╹ε╹ლ)% ## 讓 Stryker 上場踢館 再多綠燈也沒用,來點突變測試吧。 TypeScript 最有名的突變測試工具是 [Stryker](https://stryker-mutator.io/)。 名字取自漫威裡專門獵殺變種人的反派 Stryker,用來獵殺程式裡的突變體,這命名真的很會。%(´,,•ω•,,)% 因為我們用 Vitest,所以搭配 Vitest runner,先安裝套件: ```bash npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner ``` 接著加上設定檔: `stryker.config.json` ```json { "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", "testRunner": "vitest", "mutate": ["src/**/*.ts", "!src/**/*.test.ts"] } ``` 然後執行: ```bash npx stryker run ``` Stryker 會自動把程式改壞成一堆突變體,逐一丟給測試跑。%( •̀ ω •́ )✧% ```text #1. [Survived] EqualityOperator src/shipping.ts:9:10 - return order.amount >= threshold ? 0 : base + return order.amount > threshold ? 0 : base ``` 結果出爐,竟然溜掉一隻突變體。%ლ(・´ェ\`・ლ)% Stryker 把 `>=` 改成 `>`,測試全都沒有發現。 這個改動只影響一種情況,就是金額「剛好等於門檻」的時候。原本剛好 1000 元的一般會員可以免運,改成 `>` 之後就要多付 60 元,這在電商可是會直接引爆客訴的 S 級重大 bug。%(;´༎ຶД༎ຶ\`)% ## 難道不是你太菜嗎? 路人:「本來就該測邊界啊,你確定不是你漏掉案例?%(´・ω・\`)%」 鱈魚:「是...是沒錯啦,只是程式碼一多、邏輯一複雜,很難確保每一個邊界都注意到嘛!%(;´༎ຶД༎ຶ\`)%」 路人:「剛剛的程式碼又不複雜...%ლ(╹ε╹ლ)%」 鱈魚:「不管啦,繼續。%ヽ(́◕◞౪◟◕‵)ノ%」 突變測試真正的價值,不是「挖出神秘的 bug」,而是底下這兩點: 1. **比覆蓋率更誠實的指標**:覆蓋率只回答「這行有沒有被跑過」;突變分數回答的是「這行如果寫錯了,測試攔不攔得住」。 2. **真正的價值是規模**:當專案裡有上千個判斷式或你接手一份看不懂的 legacy 測試時,根本沒辦法用肉眼一條一條確認。突變測試可以系統性地幫你把「漏掉的那幾條」標出來,讓你不必再賭自己的細心和運氣。 現在我們補上兩條門檻線: ```ts it('一般會員剛好 1000 免運', () => { expect(getShippingFee({ amount: 1000, isVip: false })).toBe(0) // [!code ++] }) it('VIP 剛好 500 免運', () => { expect(getShippingFee({ amount: 500, isVip: true })).toBe(0) // [!code ++] }) ``` 再跑一次 Stryker,這隻突變體立刻被擊殺,突變分數也跟著補回來惹。 甚麼?你說殺突變體好麻煩?當然是叫 AI 殺啊!%ԅ(´∀\` ԅ)% ## 還有一種洞,斷言太鬆 剛剛那個洞是「**案例漏了**」,補一條測試就好。 但還有另一種更陰險的洞,案例明明有寫,卻等於沒寫。%( ˘•ω•˘ )% 回到開頭那個情境,AI 幫你生了一卡車測試。它很可能寫出這種東西: ```ts it('一般會員金額不足要收運費', () => { expect(getShippingFee({ amount: 800, isVip: false })).toBeGreaterThan(0) }) ``` 看起來很正常對吧?案例有了、跑得過、那行程式碼也確實被執行到,覆蓋率照樣 100%。%(・∀・)% 問題是 `toBeGreaterThan(0)` 根本沒在驗運費算得對不對,它只驗了「有收錢」。收 60 會過,收 30 會過,收 9999 也會過。%(°ㅂ°)% 這種斷言有正式名稱,叫做**寬鬆斷言**(overly general assertion),`toBeTruthy()`、`toBeDefined()`、`toBeInstanceOf(Object)` 都是常客,本身就被歸類為測試異味。 因為變數幾乎都有定義、物件幾乎都是物件,這種斷言等於什麼都沒驗。 而 Stryker 對這種洞特別敏感,因為它根本不需要你多寫什麼,直接動手改就對了。 ```text #2. [Survived] ConditionalExpression src/shipping.ts:8:16 - const base = order.isVip ? 30 : 60 + const base = true ? 30 : 60 ``` 它把運費強制改成 VIP 價 30 元,一般會員的運費從 60 變 30。 原本 `toBe(60)` 會當場翻臉,但 `toBeGreaterThan(0)` 看到 30 覺得很滿意,於是突變體大搖大擺地活了下來。%ლ(・´ェ\`・ლ)% 這件事之所以重要,倒不是因為人看不出來。 `toBeGreaterThan(0)` 跟 `toBe(60)` 擺在一起,任誰都知道哪個比較鬆。 真正的問題是,**這種事根本不該由人來做**。%( ˘•ω•˘ )% 逐條檢查兩百行 diff 裡每個斷言有沒有驗到東西,是純粹的機械勞動,做完也不會變聰明,注意力倒是先燒光了。 而人的注意力很貴,應該留給機器做不到的事,例如這條商業規則本身對不對、有沒有漏掉什麼情境、這個設計三個月後會不會後悔。%( •̀ ω •́ )% 所以這跟前面講的「規模」是同一件事,**能自動化的就別交給人**。 斷言一放寬,突變體立刻復活,Stryker 不會累也不會分心,你就可以把腦袋留給真正需要思考的部分。%( •̀ ω •́ )✧% 路人:「所以斷言愈嚴愈好囉?%(「・ω・)「%」 鱈魚:「也不是,往另一邊歪一樣有事。%( ˘•ω•˘ )%」 把整個物件連 `createdAt` 時間戳一起 `toEqual` 下去,測試會因為完全無關的欄位變動一直壞掉,這在 xUnit Test Patterns 裡叫做 [Fragile Test](http://xunitpatterns.com/Fragile%20Test.html)。 判斷標準不是「愈嚴愈好」,而是**斷言有沒有正對著這條測試要驗的行為**。太寬驗不到東西,太窄綁到不相干的欄位,兩種都是沒對準。%( •̀ ω •́ )% ## 常見的 mutator 對照表 剛剛 Stryker 對 `getShippingFee` 動的那一刀(`>=` 改成 `>`),只是它眾多招式裡的其中一種。 它到底還會怎麼壞壞?這裡整理一張常見的 mutator 對照表,讓大家心裡有個概念: | Mutator | 做什麼 | 範例(原始 → 變異) | |---------|--------|---------------------| | ArithmeticOperator | 算術運算子對調 | `a + b` → `a - b`、`a * b` → `a / b` | | EqualityOperator | 比較運算子對調 | `a < b` → `a <= b`、`a === b` → `a !== b` | | LogicalOperator | 邏輯運算子對調 | `a && b` → `a \|\| b` | | BooleanLiteral | 布林值翻轉 | `true` → `false`、`!a` → `a` | | ConditionalExpression | 條件式強制真假 | `if (a > b)` → `if (true)` 或 `if (false)` | | StringLiteral | 字串內容抽換 | `'hi'` → `''` | | UnaryOperator | 一元運算子翻轉 | `-a` → `+a` | | UpdateOperator | 遞增遞減對調 | `a++` → `a--` | | BlockStatement | 清空整個程式區塊 | `{ ... }` → `{}` | | ArrayDeclaration | 清空陣列內容 | `[1, 2, 3]` → `[]` | | OptionalChaining | 拿掉可選鏈 | `foo?.bar` → `foo.bar` | 完整清單可以參考 [Stryker 官方文件](https://stryker-mutator.io/docs/mutation-testing-elements/supported-mutators/)。%( •̀ ω •́ )✧% ## 突變一定要殺掉嗎? 前面一直說「存活的突變體代表漏洞」,但這句話其實有例外。 有些突變體根本**殺不死**,或者**殺了也沒意義**。%( ˘•ω•˘ )% ### 殺不死:等價突變體 先看一段把庫存正規化的函數,負數一律當作 0: `normalize.ts` ```ts export function normalizeStock(count: number): number { // 庫存不會是負數,負數一律當作 0 if (count < 0) { return 0 } return count } ``` 我們把正數、0、負數三種情況都測好測滿: ```ts expect(normalizeStock(5)).toBe(5) expect(normalizeStock(0)).toBe(0) expect(normalizeStock(-3)).toBe(0) ``` 看起來滴水不漏,但 Stryker 就是有辦法生出一隻殺不死的突變體: ```text [Survived] EqualityOperator src/normalize.ts:3:7 - if (count < 0) { + if (count <= 0) { ``` 它把 `count < 0` 改成 `count <= 0`,唯一有差別的地方,是 `count` 剛好等於 0 的時候。 可是你仔細想想,`count` 是 0 時,原本走 `else` 回傳 `count`(也就是 0),改成 `<=` 之後走 `if` 回傳 `0`,兩邊算出來都是 0,結果一模一樣。%(・∀・)% 所以不管丟什麼進去,這兩種寫法的行為**完全相同**。 這種「改了程式碼卻沒改行為」的突變體,就叫做「等價突變體」(Equivalent Mutant)。 它本質上不是 bug,任何測試都不可能讓它失敗,你再怎麼補測試都殺不死它。 而且要判斷一隻突變體算不算等價突變體,工具也幫不了你。 以前判斷這個很麻煩,不過現在你可以叫 AI 看。%(´,,•ω•,,)% ### 殺得死,但不值得殺 另一種情況,是那些「殺得死,但殺了也沒意義」的突變體,最典型的就是 log 訊息: ```ts export function checkout(order: Order): void { logger.info(`結帳開始,訂單編號 ${order.id}`) // Stryker 會把字串改成 '' // ...實際結帳邏輯 } ``` Stryker 會把那串 log 改成空字串,然後它就這麼活了下來,因為根本沒有測試在乎 log 印了什麼。 但你真的要為了殺它,特地寫一個「檢查 log 有沒有印出這串字」的測試嗎? 這種測試又脆弱又沒營養,~~就像上司強迫你寫精準到分的工作日誌~~ 這種突變體放它一馬就好。%(ゝ∀・)b% 如果這些雜訊看了礙眼,Stryker 也能用 `// Stryker disable next-line all` 註解,或在設定檔排除特定 mutator,直接把它們消音。 所以看到存活突變體,先別急著補測試,先問自己兩個問題,這隻殺得死嗎?值得殺嗎?~~或是丟給 AI 殺。~~%( •̀ ω •́ )% 這也是為什麼突變分數不用硬追求 100%,和人生一樣,夠了就好。%(「・ω・)「% ## 有用歸有用,但是別用過頭 突變測試雖然強,但它有個明顯的缺點,就是慢到不行。 因為它要把程式改壞成幾十、幾百個突變體,每一個都得重跑一次測試,時間會指數級上升。 拿本文的範例實際量了一下(Node 22 + Vitest 3 + Stryker 9,11 條測試程序並行): | 規模 | 突變體數量 | 一般測試 | 突變測試 | |------|-----------|---------|---------| | 單一函數 | 5 | 約 1.8 秒 | 約 6~7 秒(首次冷啟動要 26 秒)| | 五個函數 | 58 | 約 4.8 秒 | 約 28 秒 | 光是五個小函數,時間就從 5 秒膨脹到 28 秒,足足六倍,而且純突變執行階段會隨突變體數量幾乎線性成長。 真實專案動輒上萬個突變體,跑一輪從幾分鐘到幾小時都有可能。%(°ㅂ°)% 所以實務上通常不會拿它跟一般單元測試一樣,每次 commit 都跑全部。 比較常見的做法,是只針對核心邏輯、金流計算這類特別重要的模組使用,或是放在 CI 排程裡定期跑一次就好。 ## 總結 🐟 * 覆蓋率只回答「這行被跑過沒」,不回答「這行寫錯了測試攔不攔得住」。 * **突變測試在測試你的測試**,故意把程式改壞,看測試抓不抓得到,最後給你一個比覆蓋率誠實的「突變分數」。 * 它抓得到兩種洞,**案例漏了**,還有**案例有寫但斷言太鬆**。後者不是看不出來,而是**這種機械檢查不該由人做**,人的注意力該留給「規則本身對不對」這種問題。 * JavaScript / TypeScript 可以用 **Stryker**,搭配 Vitest runner 幾乎無痛上手。 * 突變測試很吃效能,而且存活突變體不一定都要殺(**等價突變體**殺不死、log 訊息不值得殺),鎖定核心邏輯、放進 CI 排程,分數別強求 100%。 所以有突變測試後,系統就 100 分,蟲蟲絕跡,世界和平了嗎? 當然沒有,系統還是會有流程漏洞、規格外的 edge case 等等問題,那又是另一個故事啦。%乁( ◔ ௰◔)「% 。 有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 --- --- url: /blog-program/bdd-with-vitest-cucumber.md description: 單元、整合、突變測試全過,怎麼還是出包了?( ´•̥̥̥ ω •̥̥̥` ) --- ![bdd-with-vitest-cucumber](/bdd-with-vitest-cucumber.webp){.cover} # 測試都加了還是出包?是不是少了流程測試? 大家好,我是鱈魚。%( ´ ▽ \` )ノ% 上次聊完[突變測試](/blog-program/what-is-mutation-testing-and-stryker),結尾說系統還是會有流程漏洞,那又是另一個故事。 今天就是那個故事。%(ノ>ω<)ノ% 某個平凡的早晨,你拿著早餐在電腦前準備開工。 結果 PM 回報:「這個月退款金額怎麼比營業額還高?」 馬上嚇得沒心情吃早餐了。%(;´༎ຶД༎ຶ\`)% 明明測試都寫好寫滿了,怎麼會這樣勒?%( ´•̥̥̥ ω •̥̥̥\` )% ## 先看看犯案現場 退款這件事,資料結構長這樣,`order.ts` ```ts export interface Refund { amount: number; createdAt: string; } export interface Order { id: string; total: number; status: 'paid' | 'cancelled'; /** 這筆訂單所有的退款紀錄 */ refundList: Refund[]; } export function getRefundedAmount(order: Order): number { return order.refundList.reduce((sum, item) => sum + item.amount, 0) } ``` 接著是退款檢查,`refund.ts` ```ts export type RefundResult = | { accepted: true; amount: number } | { accepted: false; reason: 'ORDER_NOT_REFUNDABLE' | 'INVALID_AMOUNT' } export function checkRefund(order: Order, amount: number): RefundResult { if (order.status !== 'paid') { return { accepted: false, reason: 'ORDER_NOT_REFUNDABLE' } } if (amount <= 0 || amount > order.total) { return { accepted: false, reason: 'INVALID_AMOUNT' } } return { accepted: true, amount } } ``` 最後是實際送出退款,`refund-service.ts` ```ts export function submitRefund(order: Order, amount: number): RefundResult { const result = checkRefund(order, amount) if (!result.accepted) { return result } paymentGateway.refund(order.id, amount) order.refundList.push({ amount, createdAt: getNow() }) return result } ``` 不到 30 行,邏輯清楚,命名整齊,看起來完全沒有嫌疑犯的樣子。%(・∀・)% 各位大大可以先停下來想三秒,猜猜看犯人是誰。%(´,,•ω•,,)% ## 第一關:單元測試 用 [Vitest](https://vitest.dev/) 把每個函數測好測滿,邊界一個都不放過。 `getRefundedAmount` 很單純,加總跟空陣列兩條就打發了,重點在 `checkRefund`。 ```ts // refund.test.ts describe('checkRefund', () => { const order = createOrder({ total: 1000 }) it('金額在範圍內就通過', () => { expect(checkRefund(order, 400)).toEqual({ accepted: true, amount: 400 }) }) it('剛好等於訂單金額也通過', () => { expect(checkRefund(order, 1000)).toEqual({ accepted: true, amount: 1000 }) }) it('超過訂單金額就擋下來', () => { expect(checkRefund(order, 1001)).toEqual({ accepted: false, reason: 'INVALID_AMOUNT', }) }) it('金額是 0 也擋下來', () => { expect(checkRefund(order, 0)).toEqual({ accepted: false, reason: 'INVALID_AMOUNT', }) }) it('已取消的訂單不能退款', () => { const cancelledOrder = createOrder({ total: 1000, status: 'cancelled' }) expect(checkRefund(cancelledOrder, 400)).toEqual({ accepted: false, reason: 'ORDER_NOT_REFUNDABLE', }) }) }) ``` 上邊界、下邊界、狀態、剛好等於,全部顧到了。跑起來全綠,覆蓋率 100%。%ᕕ( ᐛ )ᕗ% ## 第二關:整合測試 單元測試只驗單一函數,接著驗模組串起來對不對,金流和資料寫入一起看。 ```ts // refund-service.test.ts describe('submitRefund', () => { it('退款成功時,會呼叫金流並寫入退款紀錄', () => { const order = createOrder({ total: 1000 }) const spy = vi.spyOn(paymentGateway, 'refund') const result = submitRefund(order, 400) expect(result.accepted).toBe(true) expect(spy).toHaveBeenCalledWith(order.id, 400) expect(order.refundList).toHaveLength(1) expect(getRefundedAmount(order)).toBe(400) }) // 另一條測「檢查沒過時不呼叫金流、不留紀錄」,這裡先省略 }) ``` 模組確實串接正確,該呼叫的呼叫、該擋的擋、該記錄的記錄,也是全綠。%( •̀ ω •́ )✧% ## 第三關:突變測試 看過[上一篇](/blog-program/what-is-mutation-testing-and-stryker)的朋友一定會說,測試全綠不代表什麼,叫 [Stryker](https://stryker-mutator.io/) 出來踢館。 ```bash npx stryker run ``` Stryker 開始把程式改壞。`!==` 換成 `===`、`<=` 換成 `<`、`>` 換成 `>=`、`||` 換成 `&&`,能想到的壞事全做了一遍。 ```text Ran 9 tests, 9 passed All found mutants have been killed 🎉 Mutation score: 100.00% ``` 全部擊殺,突變分數 100 分。%(ノ>ω<)ノ% 覆蓋率 100%、突變分數 100%、單元測試綠、整合測試綠。 那筆 1200 元的退款到底怎麼變出來的勒?%ლ(・´ェ\`・ლ)% ## 第四關:流程測試 前三關都在問「**這段程式碼寫對了嗎**」,現在換個問法,「**顧客實際走一遍會發生什麼事**」。 客服處理瑕疵品時,習慣分次退款,退一件算一件。 ```ts // refund-flow.test.ts describe('退款流程', () => { it('累計退款金額不應該超過訂單金額', () => { const order = createOrder({ total: 1000 }) submitRefund(order, 400) submitRefund(order, 400) submitRefund(order, 400) expect(getRefundedAmount(order)).toBeLessThanOrEqual(order.total) }) }) ``` 紅了。%(;´༎ຶД༎ຶ\`)% ```text AssertionError: expected 1200 to be less than or equal to 1000 ``` 犯人抓到了。 `checkRefund` 從頭到尾只檢查「**這一次**的退款金額」有沒有超過訂單金額,完全沒看過之前退了多少。 每一次退 400 都合法,三次加起來就退了 1200 出去。%(°ㅂ°)% ## 為什麼前三關全都擋不住 路人:「所以突變測試和你一樣也是個廢物囉?%(「・ω・)「%」 鱈魚:「不能這麼說,它的作用不在此...等等你是不是說了啥?%( ˘•ω•˘ )%」 突變測試的做法是把**已經存在的程式碼**改壞,看測試會不會失敗。 而這個 bug 不在任何一行程式碼裡,而在**不存在的那一行**。 ```ts // 這行從來沒被寫出來,所以 Stryker 沒有東西可以改壞 if (amount > order.total - getRefundedAmount(order)) { /* ... */ } ``` 換句話說,突變測試只會問「你寫的這行如果寫錯了,測試攔不攔得住」,永遠不會問「你是不是少寫了一行」。 這類錯誤有個正式名稱,叫做**遺漏型錯誤**(Omission Fault)。 Just 等人在 FSE 2014 的論文《[Are Mutants a Valid Substitute for Real Faults in Software Testing?](https://homes.cs.washington.edu/~rjust/publ/mutants_real_faults_fse_2014.pdf)》拿真實專案的 bug 對照突變體,只有 **73%** 對得上。剩下那 27% 裡,很大一塊就是這種「程式碼多了或少了」。 再往上看一層,三關失守的原因是同一個。 * **單元測試**問的是「這個函數照規格做了嗎」 * **整合測試**問的是「模組串起來還是照規格做嗎」 * **突變測試**問的是「規格裡的每個判斷,測試都驗到了嗎」 三個問題都很好,但共同前提只有一個,**假設規格沒問題**。 生動一點的比喻就是: * 單元測試測試「積木是否符合模具的形狀」 * 整合測試確定「多個積木是否能正常組合」 * 突變測試是「故意拔掉其中一個積木,看看會不會解體」 結果沒有人告訴你,組出來的椅子造型積木真的要拿來坐,一坐就解體,摔了個狗吃屎。%( ;´༎ຶД༎ຶ )% 三方各自都很合理,湊在一起才出事。 * PM 想的退款是「顧客不要了,整筆退掉」,所以規格只寫「退款金額不得超過訂單金額」 * 工程師為了保留彈性,把金額做成參數,讓未來可以退部分 * 客服遇到三件商品壞一件,很自然就分次退,退一件算一件 三個人都在做對的事,但從頭到尾**沒人想到「分次退款」該怎麼算**。%ლ(´口\`ლ)% ## 那就多寫流程測試? 既然只有第四關擋得住,答案好像很明顯。 問題是流程測試難寫,而且難的地方跟其他三關完全不同。%( ˘•ω•˘ )% 路人:「難在哪?剛剛那條不是三行就寫完了嗎?%(・∀・)%」 鱈魚:「難在兩件事,怎麼知道**要寫哪幾條**,還有寫完之後那條規則**怎麼不蒸發**。%(´・ω・\`)%」 ### 難處一:要寫甚麼? 前三關其實都有機械式的對應關係。 * **單元測試**看函數,有幾個參數幾條分支,就配幾條 * **整合測試**看串接路徑,一條路徑配一條 * **突變測試**根本不用你想,工具自己算 流程測試沒有這種對應,你沒辦法說「一個 service 配三條流程測試」,因為它的單位不是程式碼,是**商業規則**。%( ˘•ω•˘ )% 而商業規則不會自己從程式碼裡長出來,得有人挖。 路人:「釐清商業規則不是 PM 的事嗎?%(´・ω・\`)%」 鱈魚:「是沒錯,但**工程師實作時會遇到的細節比 PM 多很多,這很正常**。%( ˘•ω•˘ )%」 需求會議通常這樣結束,主持人問一句「大家還有問題嗎」,沒人說話,散會。%(´\_ゝ\`)% 問題是沒想到的事情不會舉手,而規格的洞往往不是在會議室裡發現,是在**寫程式的時候**。 因為只有工程師有辦法逐行逼近實作細節,PM 想的是「顧客要退款」,你想的是「`amount` 這個參數要不要限制範圍」。%(°ㅂ°)% 別怕煩到 PM,該問就多問吧,記得把問題有條理地整理好再問。%( •̀ ω •́ )✧% 甚麼?你說 PM 回不知道要你自己決定...%('◉◞⊖◟◉\` )% 至於怎麼讓這種對話變得有結構,BDD 圈有現成的做法,例如 Matt Wynne 的 [Example Mapping](https://cucumber.io/docs/bdd/example-mapping/),把規則和沒答案的疑問寫成不同顏色的卡片,桌上還有紅卡就代表這個 story 還不能開工。有興趣可以去看看。%(´,,•ω•,,)% ### 難處二:規則寫完就蒸發 假設運氣好,那句話真的被問出來了,接下來呢? `expect(getRefundedAmount(order)).toBeLessThanOrEqual(order.total)` 這行背後藏著一條商業規則。 > 一筆訂單的累計退款金額,不得超過訂單金額。 問題是討論歸討論,最後留下來的只有這行 `expect`。那張紅卡散會就丟了,需求文件沒補、會議記錄也沒人回頭看。%( ˘•ω•˘ )% 而且就算工程師真的把規則寫成註解,PM 和客服也看不懂 `toBeLessThanOrEqual`,沒辦法幫你檢查「你理解的規則跟我想的一樣嗎」。 要補的是這三件事。 * 規則要用**人話**寫,PM 和客服才有辦法幫你檢查 * 規則要**獨立存在**,不能散會就蒸發 * 規則要**跟著測試一起跑**,不然遲早跟實作脫節 路人:「那開個 Notion 頁面寫下來不就好了?%(・∀・)%」 鱈魚:「你自己相信那份文件三個月後還會是對的嗎?%(´\_ゝ\`)%」 把常見做法擺在一起看就很清楚。 | 做法 | 人話 | 獨立存在 | 跟著測試跑 | |------|:----:|:--------:|:----------:| | Notion 或 Markdown 文件 | ✅ | ✅ | ❌ | | 程式碼註解 | ✅ | ❌ | ❌ | | 寫得很用心的 `it` 描述 | ❌ | ❌ | ✅ | | Gherkin | ✅ | ✅ | ✅ | 前兩欄好處理,第三欄比較麻煩。 而第三件事之所以難,在於**那份人話不能只是放在旁邊的說明,它必須是測試的執行來源**。 放在旁邊的文件一定會過期,因為沒有任何機制強迫它更新。只有當「改壞那段文字,測試就會跟著壞掉」的時候,它才會有人維護。%( ˘•ω•˘ )% 但這又帶出下一個問題,自然語言要能執行,格式就得受限。 * 太自由,機器讀不懂 * 太嚴格,人就懶得看了,那還不如直接寫程式碼 Gherkin 是個折衷點,著重在三個關鍵字,剩下的部分可以自由發揮。 * **Given**:前提是什麼 * **When**:發生了什麼事 * **Then**:應該得到什麼結果 是不是很眼熟?這就是每條測試都在做的 Arrange-Act-Assert。%( •̀ ω •́ )✧% 所以 Gherkin 並沒有發明新東西,它只是把測試本來就有的形狀,翻譯成 PM 也讀得懂的樣子。 ## 把規則寫成 Gherkin 把剛剛那條規則寫下來,`features/refund.feature` ```gherkin Feature: 訂單退款 身為 客服人員 我想要 對訂單分次退款 以便 處理單件瑕疵的狀況 Rule: 累計退款金額不得超過訂單金額 Scenario: 分次退款直到超出訂單金額 Given 顧客有一筆 1000 元的已付款訂單 And 這筆訂單已經退款過 800 元 When 客服再申請退款 400 元 Then 系統應該拒絕這筆退款 And 訂單的累計退款金額應該還是 800 元 ``` `Feature` 底下那三行是慣例的使用者故事,格式是「身為誰、想要什麼、為了什麼」,不影響執行,純粹讓讀的人知道這功能為何存在。 請特別看 `And 這筆訂單已經退款過 800 元` 這句。 在單元測試的思維裡,不太會存在這種「**已經累積了什麼狀態**」的案例。 單元測試基本上是「給我一組乾淨的輸入」,而真實的小蟲蟲們往往就藏在不乾淨的既有狀態裡。%( ˘•ω•˘ )% Gherkin 的 `Given` 天生就在描述這件事,逼你去想「動作發生之前,系統已經經歷過什麼」。 這份檔案裡沒有一行程式碼,PM 看得懂、QA 看得懂、客服看得懂,所有人都看得懂!(應該吧 ( ◔ ௰◔))。 不過有件事得先講清楚,`.feature` 檔本身只是純文字,丟給 Node 不會有任何反應。 它得再補一層**步驟定義**,把每一句話對應到一段程式碼,測試框架才有辦法照著順序跑。 所以精確說法是,這份文件**驅動測試執行**,而不是文件自己會跑。 後面我們會來實際跑跑看。%( •̀ ω •́ )✧% ### Gherkin 不等於 user story 路人:「等等,所以 Gherkin 不就是 user story 換個寫法?%(´・ω・\`)%」 鱈魚:「剛好相反,它是 user story **沒講完的那一半**。%( •̀ ω •́ )%」 Ron Jeffries 提過 user story 的[三個 C](https://agilealliance.org/glossary/three-cs/)。 * **Card**:卡片上那句「身為誰、想要什麼」,故意寫得很短 * **Conversation**:拿著卡片去跟人討論 * **Confirmation**:討論完之後,講定怎樣才算做完 卡片刻意模糊,用途是**提醒你去找人聊**,不是拿來當規格。Gherkin 待在第三個 C,是那場對話**談完之後**的產物。%(´,,•ω•,,)% | | User Story | Gherkin Scenario | |---|---|---| | 回答 | 誰要什麼、為什麼 | 什麼情況下會發生什麼 | | 精確度 | 刻意模糊 | 刻意具體 | | 能不能驅動測試 | 不能 | 接上步驟定義就能 | | 數量 | 一張卡 | 一張卡通常對應好幾個 Scenario | | 壽命 | 做完就歸檔 | 要一直活著 | 最後一列最容易忽略,user story 拿來排程,做完就歸檔;feature 檔得比那張卡活得久。 所以 feature 檔要按**行為領域**分,不能按 story 或 sprint 分,不然衝刺一結束就滿地孤兒檔案。%(´\_ゝ\`)% ## 這套做法有名字 用人話寫規則、開工前先講好、寫成可執行的驗收條件,這些都不是誰臨時想出來,背後有兩套發展了二十幾年的方法論,ATDD 和 BDD。 這兩個詞常一起出現,很多人分不清楚,還常看到「BDD 由 ATDD 演化而來」這種說法。它們其實是**同期長出來的兄弟**,各自從不同痛點出發,最後在同一個地方會合。%(´,,•ω•,,)% ### ATDD ATDD 全名是 Acceptance Test Driven Development,驗收測試驅動開發。 Kent Beck 在 2002 年的《Test-Driven Development: By Example》就提過,只是當時覺得不太實際。同年 Ward Cunningham 做出 [FIT](http://fit.c2.com/),讓客戶自己寫驗收測試,這條路才真的跑起來。 主張是**開工前先把「什麼叫做做完」講清楚,寫成具體的驗收條件**。 實務上最有名的做法叫「三個朋友」(Three Amigos),開工前由三種角色一起坐下來討論。 * **業務代表**(PM)說明想要什麼 * **開發**說明技術上做得到什麼 * **測試**負責問各種「那如果⋯⋯呢」 回頭看看那 1200 元。 > 「如果客服分三次退,每次都退 400 呢?」 路人:「這種案例我自己也想得到啊,開會幹嘛?%(´・ω・\`)%」 鱈魚:「對,你想得到,這跟有沒有跑 ATDD 一點關係都沒有。%ヽ(́◕◞౪◟◕‵)ノ%」 路人:「...%(´・ω・\`)%」 說實話 **ATDD 不會讓你變聰明**,會問出那句話的還是人,流程沒辦法自動思考。 細心的工程師自己泡咖啡時就想到了,而開了會卻全場沒人提很常見。 不過 PM 想流程、業務想實際操作,多個腦袋湊在一起,比一個人埋頭寫程式更容易冒出那句話。 這是機率,不是保證。%乁( ◔ ௰◔)「% 當然可以請 AI 一起幫忙想,不過最終只有人有辦法跟客戶確認。 ### BDD BDD 全名是 Behavior Driven Development,行為驅動開發,由 Dan North 提出,而它的親爸爸其實是 **TDD**。 事情是這樣。North 教 TDD 時一直被問同樣幾個問題,這該不該叫 test?要測到多細?先測哪個? 他後來想通了,這些根本不是技術問題,問題出在 `test` 這個字。 於是 2003 年他寫了 [JBehave](https://jbehave.org/) 取代 JUnit,把整套詞彙從「測試」換成「行為」,2006 年才發表〈[Introducing BDD](https://dannorth.net/introducing-bdd/)〉這篇經典。 後來受 DDD 的通用語言(Ubiquitous Language)啟發,他和 Chris Matts 把驗收條件的格式定成 Given-When-Then,這才長成今天的樣子。 ::: tip Dan North 和 Chris Matts 是誰? [Dan North](https://dannorth.net/about/)(現名 Daniel Terhorst-North)是英國軟體顧問,寫程式、帶團隊三十幾年,BDD 由他起頭,也提過 Deliberate Discovery 這類概念,現在自己開顧問公司。 [Chris Matts](https://papachrismatts.uk/) 是業務分析師,長年在投資銀行做交易與風險管理系統。他提出 Feature Injection,先找出價值,再回推該做哪些功能,剛好補上 BDD 缺的需求分析那半邊。 ::: 所以兩條路的起點不同,ATDD 想解決「**怎麼讓客戶參與驗收**」,BDD 想解決「**test 這個字讓工程師搞不清楚在幹嘛**」。 一個往前推到需求端,一個往外推到業務端,最後在 Given-When-Then 會合。%(ゝ∀・)b% 實務上的差別在於,ATDD 沒有規定驗收條件要寫成什麼樣子,純文字、表格、便利貼都行。而 BDD 主張用**統一的語言描述系統行為**,也就是前面那套 Given-When-Then。 補充一下,連續出現同一種步驟時,第二句之後可以用 `And` 接下去,讀起來比較順。想表達反向條件則用 `But`,兩個都是 [Gherkin 官方規格](https://cucumber.io/docs/gherkin/reference/)裡的關鍵字。 兩者可以一起用,用 ATDD 的精神開會,用 BDD 的格式記錄。%( •̀ ω •́ )✧% 總結一下,概念就是: * **想到**靠人,流程只能提高機率 * **不忘記**靠工具,這件事真的保證得了 不過學術定義是一回事,具體要怎麼做我覺得比較重要,接下來看看可以怎麼實作。 ## 導入 vitest-cucumber 回到剛剛那份 `.feature` 檔,該幫它接上步驟定義,讓它真的跑起來了。 JavaScript 圈最有名的 BDD 工具是 [Cucumber](https://cucumber.io/),不過官方的 `@cucumber/cucumber` 有自己一整套 runner 和設定,跟 Vitest 是兩個世界。 既然專案已經在用 Vitest,那就用 [vitest-cucumber](https://vitest-cucumber.miceli.click/),它讓 `.feature` 檔直接驅動 Vitest 測試,不用多養一套測試框架。%( •̀ ω •́ )✧% 安裝只要一行,也不用改設定檔。 ```bash npm install -D @amiceli/vitest-cucumber ``` 接著把 feature 檔實作出來,`refund.spec.ts` ```ts import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber' import { expect } from 'vitest' import { createOrder, getRefundedAmount, type Order } from '../order' import { type RefundResult, submitRefund } from '../refund-service' const feature = await loadFeature('features/refund.feature') describeFeature(feature, ({ Rule }) => { Rule('累計退款金額不得超過訂單金額', ({ RuleScenario }) => { RuleScenario('分次退款直到超出訂單金額', ({ Given, And, When, Then }) => { let order: Order let result: RefundResult Given('顧客有一筆 1000 元的已付款訂單', () => { order = createOrder({ total: 1000 }) }) And('這筆訂單已經退款過 800 元', () => { submitRefund(order, 800) expect(getRefundedAmount(order)).toBe(800) }) When('客服再申請退款 400 元', () => { result = submitRefund(order, 400) }) Then('系統應該拒絕這筆退款', () => { expect(result.accepted).toBe(false) }) And('訂單的累計退款金額應該還是 800 元', () => { expect(getRefundedAmount(order)).toBe(800) }) }) }) }) ``` 跑起來一樣紅,抓到一樣的 bug。 修法也不難,把那個一直沒接上的函數接回去就好。 ```ts export function checkRefund(order: Order, amount: number): RefundResult { if (order.status !== 'paid') { return { accepted: false, reason: 'ORDER_NOT_REFUNDABLE' } } const refundableAmount = order.total - getRefundedAmount(order) // [!code ++] if (amount <= 0 || amount > order.total) { // [!code --] return { accepted: false, reason: 'INVALID_AMOUNT' } // [!code --] } // [!code --] if (amount <= 0 || amount > refundableAmount) { // [!code ++] return { accepted: false, reason: 'INVALID_AMOUNT' } // [!code ++] } // [!code ++] return { accepted: true, amount } } ``` 有趣的是,這行補上去之後,Stryker 才終於**有東西可以突變**。%(´,,•ω•,,)% ## 前端也能用 前端用瀏覽器跑 e2e 測試,同樣能套用這套概念。 Gherkin 描述的是**行為**,商業規則跟你用什麼技術實作完全無關。 Playwright 這邊的套件叫 [playwright-bdd](https://vitalets.github.io/playwright-bdd/),先把 `.feature` 編譯成測試檔,再交給 Playwright 原生的 runner 跑。 ```bash npm install -D playwright-bdd ``` 設定檔把 `defineBddConfig` 的回傳值交給 `testDir` 就好,`playwright.config.ts` ```ts import { defineConfig } from '@playwright/test' import { defineBddConfig } from 'playwright-bdd' const testDir = defineBddConfig({ features: 'features/**/*.feature', steps: 'features/steps/**/*.ts', }) export default defineConfig({ testDir, use: { baseURL: 'http://localhost:5173' }, }) ``` 接著是 UI 版的 Scenario,`features/refund-admin.feature` ```gherkin Feature: 客服後台退款 身為 客服人員 我想要 在後台對訂單分次退款 以便 處理單件瑕疵的狀況 Rule: 累計退款金額不得超過訂單金額 Scenario: 超額退款要在畫面上被擋下來 Given 客服打開訂單 A001 的退款頁面 And 這筆訂單金額是 1000 元 And 這筆訂單已經退款過 800 元 When 客服輸入退款金額 400 元 And 客服送出退款 Then 畫面應該提示可退金額只剩 200 元 And 退款紀錄應該還是只有一筆 ``` `Rule` 跟前面那份一模一樣,只有 `Scenario` 從「申請退款」變成「填欄位、按按鈕」。%( •̀ ω •́ )✧% 步驟實作在 `features/steps/refund-admin.ts` ```ts import { expect } from '@playwright/test' import { createBdd } from 'playwright-bdd' const { Given, When, Then } = createBdd() Given('客服打開訂單 {word} 的退款頁面', async ({ page }, orderId: string) => { await page.goto(`/admin/orders/${orderId}/refund`) }) Given('這筆訂單金額是 {int} 元', async ({ page }, total: number) => { await expect(page.getByTestId('order-total')).toHaveText(`${total}`) }) Given('這筆訂單已經退款過 {int} 元', async ({ page }, refundedAmount: number) => { await expect(page.getByTestId('refunded-amount')).toHaveText(`${refundedAmount}`) }) When('客服輸入退款金額 {int} 元', async ({ page }, amount: number) => { await page.getByLabel('退款金額').fill(`${amount}`) }) When('客服送出退款', async ({ page }) => { await page.getByRole('button', { name: '送出退款' }).click() }) Then('畫面應該提示可退金額只剩 {int} 元', async ({ page }, remaining: number) => { await expect(page.getByRole('alert')).toHaveText(`可退金額只剩 ${remaining} 元`) }) Then('退款紀錄應該還是只有一筆', async ({ page }) => { await expect(page.getByTestId('refund-row')).toHaveCount(1) }) ``` 先產生測試檔再跑。 ```bash npx bddgen && npx playwright test ``` 有個地方要注意,playwright-bdd 走官方 Cucumber.js 路線,**步驟全域註冊、用 Cucumber Expression 比對文字**,跟 vitest-cucumber 的巢狀 callback 不一樣。 所以 `And` 開頭的步驟要用 `Given` 定義,因為 `And` 會沿用前一句的關鍵字。`{word}`、`{int}` 這些參數寫法也是 Cucumber Expression 的標準語法。 ## Gherkin 到底好在哪 好不好其實也取決於怎麼用、團隊怎麼合作等等,不過 Gherkin 確實有幾項好處。 vitest-cucumber 會拿 `.spec.ts` 對照 `.feature`,少實作一個 Scenario、步驟文字對不上、把 `When` 寫成 `Then`,通通直接報錯。 **規格與測試可以綁在一起**,不會有那種文件寫 A、程式做 B 的經典慘案。%( •̀ ω •́ )✧% 再來看看兩種寫法的差異。 | 比較項目 | Vitest 流程測試 | vitest-cucumber | |---------|----------------|-----------------| | 誰看得懂 | 只有工程師 | PM 和 QA 都看得懂 | | 規則放哪 | 藏在 `it` 字串裡,或只在腦袋裡 | 獨立的 `.feature` 檔 | | 規則會不會過期 | 會,改了實作忘了改描述 | 不會,對不上直接報錯 | | 補一個邊界案例 | 要動程式碼 | 在 Examples 加一行 | | 額外成本 | 無 | 多一層步驟要維護 | 未來回溯需求與邏輯時,不管是新接手的工程師還是 AI Agent,直接看 `.feature` 檔會比看測試程式碼更快更清楚。 ### 業務邏輯可讀性較高 講可讀性太抽象,兩種寫法擺在一起就知道了。%(´,,•ω•,,)% 下面兩份測試測的是同一份退款邏輯,內容完全等價。 先看 Vitest 版。 ```ts describe('退款流程', () => { let order: Order beforeEach(() => { order = createOrder({ total: 1000 }) vi.spyOn(paymentGateway, 'refund').mockResolvedValue({ status: 'ok' }) }) it('部分退款後,可退餘額要跟著減少', () => { submitRefund(order, 300) expect(getRefundedAmount(order)).toBe(300) expect(order.refundList).toHaveLength(1) }) it('超額退款要被擋下來', () => { submitRefund(order, 800) const result = submitRefund(order, 400) expect(result.accepted).toBe(false) expect(getRefundedAmount(order)).toBe(800) }) it('金流失敗時不應該留下退款紀錄', () => { vi.spyOn(paymentGateway, 'refund').mockImplementation(() => { throw new Error('gateway down') }) expect(() => submitRefund(order, 300)).toThrow() expect(order.refundList).toHaveLength(0) }) it('退滿訂單金額後,訂單要標記為已全額退款', () => { submitRefund(order, 1000) expect(order.status).toBe('refunded') }) }) ``` 再看 vitest-cucumber 版,規格與實作分成兩份檔案,先是 `refund.feature` ```gherkin Feature: 訂單退款 身為 客服人員 我想要 對訂單分次退款 以便 處理單件瑕疵的狀況 Scenario: 部分退款後,可退餘額要跟著減少 Given 顧客有一筆 1000 元的已付款訂單 When 客服申請退款 300 元 Then 訂單的累計退款金額應該是 300 元 And 訂單應該有 1 筆退款紀錄 Scenario: 超額退款要被擋下來 Given 顧客有一筆 1000 元的已付款訂單 And 這筆訂單已經退款過 800 元 When 客服再申請退款 400 元 Then 系統應該拒絕這筆退款 And 訂單的累計退款金額應該還是 800 元 Scenario: 金流失敗時不應該留下退款紀錄 Given 顧客有一筆 1000 元的已付款訂單 And 金流服務目前無法連線 When 客服申請退款 300 元 Then 系統應該回報退款失敗 And 訂單不應該有任何退款紀錄 Scenario: 退滿訂單金額後,訂單要標記為已全額退款 Given 顧客有一筆 1000 元的已付款訂單 When 客服申請退款 1000 元 Then 訂單狀態應該是已全額退款 ``` 接著是 `refund.spec.ts` ```ts const feature = await loadFeature('features/refund.feature') describeFeature(feature, ({ Scenario }) => { Scenario('部分退款後,可退餘額要跟著減少', ({ Given, When, Then, And }) => { let order: Order Given('顧客有一筆 1000 元的已付款訂單', () => { order = createOrder({ total: 1000 }) }) When('客服申請退款 300 元', () => { submitRefund(order, 300) }) Then('訂單的累計退款金額應該是 300 元', () => { expect(getRefundedAmount(order)).toBe(300) }) And('訂單應該有 1 筆退款紀錄', () => { expect(order.refundList).toHaveLength(1) }) }) Scenario('超額退款要被擋下來', ({ Given, And, When, Then }) => { let order: Order let result: RefundResult Given('顧客有一筆 1000 元的已付款訂單', () => { order = createOrder({ total: 1000 }) }) And('這筆訂單已經退款過 800 元', () => { submitRefund(order, 800) }) When('客服再申請退款 400 元', () => { result = submitRefund(order, 400) }) Then('系統應該拒絕這筆退款', () => { expect(result.accepted).toBe(false) }) And('訂單的累計退款金額應該還是 800 元', () => { expect(getRefundedAmount(order)).toBe(800) }) }) Scenario('金流失敗時不應該留下退款紀錄', ({ Given, And, When, Then }) => { let order: Order Given('顧客有一筆 1000 元的已付款訂單', () => { order = createOrder({ total: 1000 }) }) And('金流服務目前無法連線', () => { vi.spyOn(paymentGateway, 'refund').mockImplementation(() => { throw new Error('gateway down') }) }) When('客服申請退款 300 元', () => { expect(() => submitRefund(order, 300)).toThrow() }) Then('系統應該回報退款失敗', () => { expect(order.status).toBe('paid') }) And('訂單不應該有任何退款紀錄', () => { expect(order.refundList).toHaveLength(0) }) }) Scenario('退滿訂單金額後,訂單要標記為已全額退款', ({ Given, When, Then }) => { let order: Order Given('顧客有一筆 1000 元的已付款訂單', () => { order = createOrder({ total: 1000 }) }) When('客服申請退款 1000 元', () => { submitRefund(order, 1000) }) Then('訂單狀態應該是已全額退款', () => { expect(order.status).toBe('refunded') }) }) }) ``` 不難看出 Gherkin 版本比較囉唆,但好處是**把檢查範圍縮小**。 **一句話配一條斷言。** Vitest 版的一個 `it` 塞了兩三條斷言,標題「超額退款要被擋下來」同時涵蓋它們,沒有一條跟標題一對一,你得自己在腦中拆哪句對應哪一半。 Gherkin 版的每個步驟只負責一句話,那句中文就正對著底下唯一那行 `expect`,對不上就會直接跳出來。 **每一行的身分都標好了。** `Given` 是前置條件、`When` 是動作、`Then` 才負責驗證。Vitest 版的佈景和重點混在同一區塊,你得先讀懂程式碼才分得出來,除非你讀程式碼比讀文字還快,那當我沒說。%(́⊙◞౪◟⊙‵)% ### PM 自己就能加測試案例 現在人人都用 AI 寫 Code 了,假設 PM 某天說:「我想確認幾種分次退款的組合對不對。」 以前這句話代表你要開編輯器,現在他自己就能在表格裡加一行。 ```gherkin Rule: 累計退款金額不得超過訂單金額 Scenario Outline: 各種分次退款的組合 Given 顧客有一筆 1000 元的已付款訂單 And 這筆訂單已經退款過 <已退> 元 When 客服再申請退款 <本次> 元 Then 退款結果應該是 <結果> Examples: | 已退 | 本次 | 結果 | | 0 | 1000 | 通過 | | 800 | 200 | 通過 | | 800 | 400 | 拒絕 | | 1000 | 1 | 拒絕 | ``` [`Scenario Outline`](https://cucumber.io/docs/gherkin/reference/#scenario-outline) 是**範本**,`<已退>`、`<本次>`、`<結果>` 這種角括號寫法是佔位符,名稱直接對應底下 `Examples` 表格的欄位。 表格有幾列就跑幾次,每次把那列的值填進去,等於一口氣寫了四條 Scenario。%( •̀ ω •́ )✧% 實作端把 `Scenario` 換成 `RuleScenarioOutline`,callback 第二個參數就是這次跑到的那一列。 ```ts RuleScenarioOutline('各種分次退款的組合', ({ Given, And, When, Then }, variables) => { // ... And('這筆訂單已經退款過 <已退> 元', () => { const refundedAmount = Number(variables['已退']) if (refundedAmount > 0) { submitRefund(order, refundedAmount) } }) Then('退款結果應該是 <結果>', () => { expect(result.accepted).toBe(variables['結果'] === '通過') }) }) ``` [`variables`](https://vitest-cucumber.miceli.click/features/scenario-outline/) 是個物件,key 就是 `Examples` 的欄位名,值一律是**字串**,所以 `已退` 要自己 `Number()` 轉成數字,`結果` 則是拿 `'通過'` 這串中文去比對。 那張表格同時是規格、測試案例和驗收清單,三個身分一次滿足。%ԅ(´∀\` ԅ)% ## 如果只有工程師要看呢? 路人:「如果專案只有我一個人,PM 不看、沒有 QA,那不就完全不用導入?%(´・ω・\`)%」 鱈魚:「還是有別的好處啦。%(「・ω・)「%」 大致上是這樣沒錯,[Cucumber 官方](https://cucumber.io/blog/bdd/bdd-vs-tdd/)講得很直接,TDD 一個人就能做,BDD 的前提是一群人先對話。 不過還是有兩個理由讓我沒完全放棄它。 **第一:三個月後的你就是那個熟悉的陌生人。** 聽起來很像甚麼言情小說標題,但是這種情況真的不稀奇。%(◉◞౪◟◉ )% 回去看半年前寫的測試,你會完全想不起來當初為什麼那樣判斷。 `.feature` 檔記下的是**當初定了甚麼規則**,而 `it` 的字串裝不下這些細節。 **第二:「不准出現程式碼名詞」這條約束的價值。** 被迫用領域語言描述行為,你會一直想「使用者到底想幹嘛」,而不是「這函數該回傳什麼」。 視角換了,看到的東西也會不一樣。%◝( •ω• )◟% 話說回來,只為了這兩點,成本效益還是有點勉強。 維護這些案例與文件的成本真的很高,看了就累。%*(┐「﹃゚。)*% 但最近多出來一位新同事,讓我重新評估了這件事。%(´,,•ω•,,)% ## 好同事:AI agent 沒錯,就是 AI。 以前 BDD 要湊齊三種角色才划算,現在其中一位可能是 agent,而 Gherkin 剛好人和 AI **兩邊都讀得懂**。 ### 一、驗收條件被刪會很顯眼 有沒有遇過請 AI 修個 bug,它跑完測試發現紅了,很貼心地把期望值改成實際輸出,回報「已修復,測試全綠」。%ლ(・´ェ\`・ლ)% 驗收條件獨立成檔就沒那麼好動手,少實作一條 `Scenario` 就報錯,真要拿掉得連那句中文一起刪,而 diff 上少一段商業規則,比少一行 `expect` 顯眼太多。%( ˘•ω•˘ )% 不過也就到這裡了,步驟裡的 `expect` 被註解掉,或是 `toBe(false)` 換成 `toBeDefined()`,`.feature` 檔的內容管不到,也無法限制。%(;´༎ຶД༎ຶ\`)% 這招叫**寬鬆斷言**(overly general assertion,[上一篇](/blog-program/what-is-mutation-testing-and-stryker)有詳細說明),不過突變測試抓的到,所以不是大問題。%(ノ>ω<)ノ% 剩下的確認與檢查工作就是你的任務了。%(ゝ∀・)b% 甚麼?你希望檢查和確認也不用做?記得出事的時候不要只會說都是 AI 寫錯。%( ・ิω・ิ)% ### 二、feature 檔是最好的需求輸入 Gherkin 的結構是「狀態 → 動作 → 結果」,剛好是 LLM 最擅長處理的形式。 丟一份 feature 檔給 agent 說「把這個實作出來」,比丟一段散文需求精確太多,前提、動作、期望值全都結構化了,沒有模糊空間。%(ゝ∀・)b% ### 三、agent 的領域知識索引 agent 每次讀專案都要重新理解商業規則,讀幾千行測試碼很貴,還容易漏。 `.feature` 檔把散落各處的商業規則集中成十幾個檔案,agent 讀完就掌握了整個領域,token 省得不是一點半點。 ### 四、錨點不會跟著工作一起漂 跑久的 agent 最常見的毛病是飄移,做著做著就忘了原本要幹嘛,最後交出一份很認真但方向錯掉的東西。%(´\_ゝ\`)% 意圖只寫在測試程式碼裡,**錨點和工作就是同一個檔案**,agent 改著改著連錨點一起改掉。 `.feature` 檔放在外面,改實作時它動都不動,重新對焦也只花一百多個 token。 「系統應該拒絕這筆退款」這句話又沒什麼模糊空間,agent 想自我說服「這樣應該也算完成了」都很難。 甚麼?你的 AI 自我說服成功?請蛋雕那個 AI。%⎝(・ω´・⎝)% `Scenario` 也天生適合當任務邊界,步驟固定、完成條件明確,而飄移多半就是停止條件太模糊。 ### 可是我覺得 Gherkin 很難寫 QQ 的確,Gherkin 要寫得好其實不容易,人自己寫都會歪,何況 AI。%( ˘•ω•˘ )% 常見的歪法有這幾種。 * 一個 `Scenario` 塞好幾種行為 * 用操作步驟取代狀態,寫「點設定、點權限、點下拉選單」,不寫「使用者具有編輯者權限」 * `Then` 寫成看不到的結果,例如「登入成功」 * 資料用 `foo`、`bar`,不用實際金額和名字 * 一個步驟用「並」、「然後」黏兩個動作 好在 Automation Panda 大神已經把[整套規則](https://automationpanda.com/2026/04/27/bdd-gherkin-guidelines-for-ai-coding-and-testing/)寫成一份 [gherkin-guidelines.md](https://github.com/AutomationPanda/gherkin-guidelines-for-ai),直接丟進專案當 context 檔就好,Claude Code、Cursor、Copilot 都吃得下。%( •̀ ω •́ )✧% ::: tip Automation Panda 是誰? 本名 Andrew Knight,美國測試自動化工程師,長期在 BDD 與 Gherkin 圈子寫作演講,部落格 [Automation Panda](https://automationpanda.com/) 累積了大量 Python 測試與 BDD 的實務文章。 ::: ## 但是別急著全部改寫 講了這麼多好話,也要說說壞話,不然像業配。%(「・ω・)「% 看過不少團隊導入 Cucumber 之後放棄,通常都踩到這四個坑。 **一、拿去測工具函數** `formatDate`、`deepClone` 這種東西,直接 Vitest 三行寫完,套 Gherkin 只是自找麻煩。BDD 描述的是**使用者行為**,不是函數簽章。 **二、步驟寫成程式碼的翻譯** 看過這種 feature 檔嗎? ```gherkin Given 初始化 RefundService When 呼叫 submitRefund 方法並傳入 order 與 amount Then 回傳值的 accepted 屬性應該等於 false ``` 這完全是本末倒置。PM 看到只會更困惑,等於付了 Gherkin 的成本,好處一點也沒拿到。%ლ(´口\`ლ)% 判斷標準很簡單,**feature 檔裡不應該出現任何程式碼名詞**。 **三、`Then` 寫得含糊** ```gherkin Then 退款流程應該正常運作 ``` 看起來有驗東西,其實什麼都沒驗,因為「正常運作」沒人能判斷真假。有沒有覺得眼熟?這就是 `toBeDefined()` 的 Gherkin 版本,**規格層的寬鬆斷言**。%( ˘•ω•˘ )% 寫程式時知道要用 `toBe(false)`,寫規格時同一條標準照樣成立,`Then` 要寫成**看得出來的結果**。 ```gherkin Then 系統應該拒絕這筆退款 // [!code ++] And 訂單的累計退款金額應該還是 800 元 // [!code ++] ``` 而且這種洞比程式碼裡的更貴,因為規格是四關的最上層,它含糊了,底下每一關都跟著含糊。%(°ㅂ°)% **四、寫完就沒有下文** feature 檔寫完之後沒人看、沒人討論、也沒拿去餵 agent,那就只是把 `it` 改成 `Scenario`,ATDD 的精神一點也沒沾到。 判斷方式很簡單,問問自己這份檔案**寫給誰看**。答案要是「沒有誰」,那就別寫了。%( ˘•ω•˘ )% 所以務實的做法是分層對待。 | 層級 | 工具 | 負責回答 | |------|------|---------| | 單元測試 | Vitest | 這個函數寫對了嗎 | | 整合測試 | Vitest | 模組串起來還是對的嗎 | | 突變測試 | Stryker | 我寫的測試夠不夠嚴格 | | 流程測試 | vitest-cucumber | 這條商業規則真的成立嗎 | | 前端 E2E 測試 | playwright-bdd | 使用者知道規則守住了嗎 | 前三層守的是「**程式碼有沒有寫錯**」,後兩層守的是「**我們有沒有想錯**」。 各層各司其職,數量從上往下遞減,但越往下,一條測試的價值越高。%(ゝ∀・)b% ## 總結 🐟 * 單元、整合、突變測試都**假設規格沒問題**,規格少講一條,三關全滅,只有流程測試擋得住。 * 流程測試難在**要寫哪幾條**跟**規則會不會蒸發**。前者只能靠人挖,開會只是提高機率,而**工程師往往比 PM 更早撞到規格的洞**,撞到就回去問。 * 後者交給 **Gherkin**,用人話寫、獨立成檔、跟著測試一起跑,那份人話**得是測試的執行來源**。同一條 `Rule`,vitest-cucumber 驗 domain、playwright-bdd 驗畫面。 * 好處集中在協作,多了 agent 如虎添翼,`.feature` 檔同時是需求輸入、領域索引與不會漂的錨點。想寫得好,可以參考 [gherkin-guidelines.md](https://github.com/AutomationPanda/gherkin-guidelines-for-ai)。 那寫了流程測試之後,是不是就再也不會出包了呢? 當然不是,還有第三方 API 亂噴、時區、閏年、使用者用你想都想不到的方式操作系統⋯⋯不過那又是另一個故事了。%乁( ◔ ௰◔)「% 路人:「是有多少故事!%╭(°A ,°\`)╮%」 有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。 --- --- url: /blog-ocean-world/check-images-ready.md description: 確保圖片都乖乖載入完成,一個都不能少!順便玩玩看 Vitest 的 Browser Mode(・∀・)9 --- ![check-images-ready](/check-images-ready.webp){.cover} # 甚麼?圖片還沒好喔!Σ(ˊДˋ;) 最近開發網頁列印功能時,發現使用者列印當下,圖片可能還沒出現,導致頁面不完整。%(◞‸◟ )% 怎麼辦哩?來做一個圖片載入完成的檢查吧!%(・∀・)9% ## 釐清需求 先來釐清需求,這個功能的目的是: 1. 確保指定 DOM 下所有圖片都載入完成 2. 如果圖片不可見則忽略 3. 需考慮 CSS background-image ## 實作 熟悉 Web 開發的人,應該都知道圖片載入完成可以透過 `load` 事件來處理。 知道這點後,其實就很簡單了,讓我們一步一步來。 這裡以 Vue 為例,建立一個名為 `useImagesReady` 的 Composition API。 ### 取得目標的 HTML 元素 我們的參數支援直接使用 Vue 元件,所以要先處理一下,讓我們可以取得目標的 HTML 元素。 `composables\use-images-ready.ts` ```ts interface UseImagesReadyParams { /** 不指定則為目前元件 */ target?: MaybeElement; /** 強制延遲(ms),保險起見 * @default 0 */ forceDelay?: number; /** 是否包含 background-image * @default true */ includeBackgroundImages?: boolean; } /** 偵測目標下所有圖片是否載入完成 * * 目前不考慮動態新增的圖片 */ export function useImagesReady(params: UseImagesReadyParams = {}) { const { target, forceDelay = 0, includeBackgroundImages = true, } = params const totalImages = ref(0) const isReady = ref(false) /** 取得目前元件實例 */ const instance = getCurrentInstance() onMounted(() => { const rootElement = pipe( toValue(target), (value) => { if (value && '$el' in value) { return value.$el } return value ?? instance?.vnode?.el ?? instance?.proxy?.$el ?? document.documentElement }, (value) => { if (!value) { return undefined } if (value instanceof HTMLElement) { return value } throw new Error( '[useImagesReady] 取得目標元素異常,請確認 target 是否為 HTMLElement 或 Vue 元件', ) }, ) if (!rootElement) { isReady.value = true console.warn('[useImagesReady] 目標元素不存在,無法偵測圖片載入狀態') } }) return { isReady, totalImages, } } ``` ::: tip 那個 `pipe` 是甚麼咚咚? 不知道的朋友們可以來這裡[看看](/blog-program/remeda-pipe) ::: ### 取得所有圖片 接著是取得圖片部分,先新增一個判斷 image 是否可見的 function。 若其父元素隱藏,則圖片一定也看不到,所以要一路找上去,把列祖列宗都找出來 %ლ(´∀\`ლ)%。 ```ts function isElementVisible(el: HTMLElement | null) { /** 一路往父層檢查 */ while (el) { const style = getComputedStyle(el) if (style.display === 'none' || style.visibility === 'hidden') { return false } el = el.parentElement } return true } ``` ::: tip 怎麼不用 `offsetParent`?%ლ(╹ε╹ლ)% 研究中有找到 `offsetParent` 這個屬性,當父元素 `display: none` 時會是 `null`,就可以不用一路往上找。 只是父元素為 `position: fixed` 也會是 `null`,這樣會誤判,所以不行。%ლ(╹ε╹ლ)% [HTMLElement: offsetParent property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent) ::: 接著取得目標元素下的所有圖片。 ```ts const imageElements = Array.from(rootElement.querySelectorAll('img')) .filter((img) => !!img.src && isElementVisible(img)) ``` 接著取得背景圖片並轉換成 `HTMLImageElement`,最後合併到 `imageElements`。 ```ts const backgroundImageElements = Array.from( includeBackgroundImages ? rootElement.querySelectorAll('*') : [], ).reduce((list, el) => { if (!(el instanceof HTMLElement) || !isElementVisible(el)) { return list } const bgValue = getComputedStyle(el).getPropertyValue('background-image') if (bgValue && bgValue !== 'none') { // 只處理 url 的背景圖片 const url = bgValue.match(/url\(["']?([^"']+)["']?\)/) if (!url || !url[1]) { return list } const img = new Image() img.src = url[1] list.push(img) } return list }, [] as HTMLImageElement[]) const imageElements = Array.from(rootElement.querySelectorAll('img')) .filter((img) => !!img.src && isElementVisible(img)) .concat(backgroundImageElements) ``` ### 圖片載入事件 圖片到齊了,剩下就是處理載入事件了,這裡我們使用 VueUse 的 [`useEventListener`](https://vueuse.org/core/useEventListener/)。 `composables\use-images-ready.ts` ```ts export function useImagesReady(params: UseImagesReadyParams = {}) { // ... onMounted(() => { // ... totalImages.value = imageElements.length if (totalImages.value === 0) { isReady.value = true return } let loadedCount = 0 async function checkCompletion() { if (loadedCount === totalImages.value) { await promiseTimeout(forceDelay) isReady.value = true } } function handleEvent() { loadedCount++ checkCompletion() } imageElements.forEach((img) => { if (img.complete) { loadedCount++ return } useEventListener(img, 'load', handleEvent, { once: true }) useEventListener(img, 'error', handleEvent, { once: true }) }) checkCompletion() }) return { isReady, totalImages, } } ``` 這樣就完成了,當所有圖片載入完成(或錯誤)後,`isReady` 會變成 `true`。 雖然還有一些情境與邊界問題還未考慮,不過這樣已經很夠用惹。%ԅ(´∀\` ԅ)% 完整程式可以在[這裡取得](https://github.com/Codfisher/cod-aquarium/blob/main/composables/use-images-ready.ts) ## 來個測試 先前都用 Playwright 測試,這次來試試看 Vitest 的 [Browser Mode](https://vitest.dev/guide/browser/)。%(。・∀・)ノ゙% Browser Mode 會直接在瀏覽器中執行測試,不是模擬環境,可以更接近實際使用情境。 與 Playwright 最大的差別在於不用開啟頁面,可以直接測試 Vue 元件。 Vitest 會在內部自動處理,開發者用起來就如同平常寫單元測試一樣單純。 ::: tip Playwright 其實也可以測試元件,不過此功能正在實驗中。 而因單元測試很常使用 Vitest,如果測試都能在 Vitest 完成,整合性更高、更方便。 [Playwright test-components](https://playwright.dev/docs/test-components) ::: [安裝與設定](https://vitest.dev/guide/browser/#installation)基本上依照官網步驟即可。 這裡我使用 Vitest 的 VSCode [擴充套件](https://marketplace.visualstudio.com/items?itemName=vitest.explorer)來執行測試。 ### 建立輔助 function 第一步來做一個產生基本內容的 Vue 元件吧,這裡我們直接使用 `h` function 建立,就不用獨立 `.vue` 檔案了。 `composables\use-images-ready.spec.ts` ```ts type Images = Array<{ src: string; id?: string; style?: string }> function createTestComponent( images: Images, slots: VNode[] = [], ) { return defineComponent({ setup() { const { isReady, totalImages, } = useImagesReady() return () => h('div', null, [ h('h1', '測試元件'), ...images.map((img) => h('img', img)), ...slots, h('div', { id: 'status' }, isReady.value ? 'Ready' : 'Loading'), h('div', { id: 'totalImages' }, `total: ${totalImages.value}`), ]) }, }) } ``` 測試用的圖片我使用 [Picsum](https://picsum.photos/) 這個隨機圖片服務。 建立一個 `getImageSrc` function 取得圖片 URL。 ```ts const sizeList = range(1, 10).map( (i) => `${i * 20}`, ) as [string, string, ...string[]] function getImageSrc(params?: { width: number; height: number }) { if (!params) { const sizes = sample(sizeList, 2) return `https://picsum.photos/${sizes.join('/')}` } const { width, height } = params return `https://picsum.photos/${width}/${height}` } ``` ### 建立測試 現在來建立第一個測試。 ```ts import type { VNode } from 'vue' import { page } from '@vitest/browser/context' import { map, pipe, range, sample } from 'remeda' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-vue' import { defineComponent, h, nextTick } from 'vue' import { useImagesReady } from './use-images-ready' // ... /** 負責斷言 Ready */ function expectReady(screen: ReturnType) { return expect.element(screen.getByText('Ready'), { /** * 不斷檢查元素是否存在,直到超過 timeout 或找到元素 * * https://vitest.dev/guide/browser/assertion-api.html#:~:text=expect.poll%20and%20expect.element%20APIs */ interval: 100, }).toBeInTheDocument() } describe('useImagesReady', () => { it('如果沒有圖片,isReady 應該立即設為 true', async () => { const screen = render(createTestComponent([])) await expect.element(screen.getByText('測試元件')).toBeInTheDocument() await expectReady(screen) }) }) ``` 執行測試後,沒有意外的話會開啟瀏覽器並顯示測試結果,如下圖。 ![browser-ui](/check-images-ready/browser-ui.png) 讓我們新增更多測試。 ```ts // ... describe('useImagesReady', () => { it('如果沒有圖片,isReady 應該立即設為 true', async () => { // ... }) it('1 張圖片載入後,文字從 Ready 變 Loading', async () => { const data = [ { src: getImageSrc() }, ] const screen = render(createTestComponent(data)) await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) it('2 張圖片載入後,文字從 Ready 變 Loading', async () => { const data: Images = [ { src: getImageSrc() }, { src: getImageSrc() }, ] const screen = render(createTestComponent(data)) await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) it('hidden 的圖片會被忽略', async () => { const data: Images = [ { src: getImageSrc() }, { src: getImageSrc(), style: 'display: none;' }, { src: getImageSrc(), style: 'visibility: hidden;' }, ] const screen = render(createTestComponent(data)) await expect.element(screen.getByText(`total: 1`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) it('父層元素 hidden 的圖片也應被被忽略', async () => { const data: Images = [ { src: getImageSrc() }, ] const screen = render(createTestComponent(data, [ h('div', { style: 'display: none;' }, [ h('img', { src: getImageSrc() }), ]), h('div', { style: 'visibility: hidden;' }, [ h('img', { src: getImageSrc() }), ]), ])) await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) it('更父層元素 hidden 的圖片也應被被忽略', async () => { const data: Images = [ { src: getImageSrc() }, ] const screen = render(createTestComponent(data, [ h('div', { style: 'display: none;' }, [ h('div', [ h('div', [ h('img', { src: getImageSrc() }), ]), ]), ]), ])) await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) it('須包含 background-image 圖片', async () => { const data: Images = [ { src: getImageSrc() }, ] const screen = render(createTestComponent(data, [ h('div', { style: [ 'width: 100px', 'height: 100px', `background-image: url(${getImageSrc()})`, ].join(';'), }), ])) await expect.element(screen.getByText(`total: 2`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) it('父層元素隱藏,要忽略 background-image 圖片', async () => { const data: Images = [ { src: getImageSrc() }, ] const screen = render(createTestComponent(data, [ h('div', { style: 'display: none;' }, [ h('div', { style: [ 'width: 100px', 'height: 100px', `background-image: url(${getImageSrc()})`, ].join(';'), }), ]), h('div', { style: 'visibility: hidden;' }, [ h('div', { style: [ 'width: 100px', 'height: 100px', `background-image: url(${getImageSrc()})`, ].join(';'), }), ]), ])) await expect.element(screen.getByText(`total: 1`)).toBeInTheDocument() await expect.element(screen.getByText('Loading')).toBeInTheDocument() await expectReady(screen) }) }) ``` ![all test](/check-images-ready/all-test.png) 測試順利過關!%( ‧ω‧)ノ╰(‧ω‧ )% 完整測試程式可以在[這裡取得](https://github.com/Codfisher/cod-aquarium/blob/main/composables/use-images-ready.spec.ts) Vitest Browser Mode 體驗真滴很不錯,不過還在實驗中,不清楚會不會有奇怪的問題。 期待正式釋出的那一天。%(ゝ∀・)b% ## 總結 🐟 * 測試圖片載入狀態的功能,確保列印時圖片都載入完成 * 使用 Vue 的 Composition API 實作 `useImagesReady` * 使用 `useEventListener` 偵測圖片載入事件 * 使用 Vitest 的 Browser Mode 進行測試 --- --- url: /blog-vue/solve-fetch-waterfall-with-data-loader.md description: 元件層層嵌套,API 一個等一個?來看看 Vue Data Loader 怎麼解決 fetch 瀑布流問題。(ノ>ω<)ノ --- ![solve-fetch-waterfall-with-data-loader](/solve-fetch-waterfall-with-data-loader.webp){.cover} # 用 Vue Router 的 Data Loader 解決 fetch 瀑布流問題 大家好,我是鱈魚。(。・∀・)ノ゙ 會寫這篇其實是很久以前看到 Vue Router 新增 [Data Loader](https://router.vuejs.org/data-loaders/)。 由於專案幾乎都標配了 TanStack Query,慧根不足的我,實在是想不到有甚麼具體情境需要 Data Loader。%(´・ω・\`)% 直到最近某次與 AI 討論時,AI 提到 fetch 瀑布流(Waterfall)問題,才讓我重新想到 Data Loader 並來實際實驗看看。 所謂的 fetch 瀑布流(Waterfall),就是指嵌套元件中的 API 請求需要一個個排隊,導致畫面跟著一塊塊載入的現象。 這樣說太抽象了,來看看具體範例。%◝( •ω• )◟% ## 什麼是 fetch 瀑布流? 以下是一個簡單的三層巢狀元件,需要的 ID 資料已存在網址上,UI 卻只能一個個載入。 你說這不就是設計不良?方便展示的範例啦,難保不會遇到這種情況嘛。%(́⊙◞౪◟⊙‵)% ```vue ``` ```vue ``` ```vue ``` API 沒有順序耦合,可以同時發起,但只要子元件被 `v-if`(或 `Suspense`)擋住,**父元件還沒拿到資料,子元件根本無從建立**,瀑布流就這樣產生了: ``` User ████████░░░░░░░░░░░░░░░░ 1s Post ░░░░░░░░████████░░░░░░░░ 1s Comment ░░░░░░░░░░░░░░░░████████ 1s 總耗時:3s ``` 三個各 1 秒的 API,因為排隊累加,總共花了 3 秒。%( ˘•ω•˘ )% ## 常見的解法 最直覺的解法,就是全在最外層父元件用 `Promise.all` 一次打完: ```ts const [user, postList, commentList] = await Promise.all([ fetchUser(1), fetchPostList(1), fetchCommentList(1), ]) ``` 這雖然解決了瀑布流,卻會引發新問題: * **資料與元件脫鉤**:原本各元件自行抓資料,現在全堆在父元件。 * **props 滿天飛**:子元件不能自己要資料,只能仰賴父元件層層餵進來。 有沒有辦法既讓元件獨立抓自己的資料,又同時做到平行載入呢? ## Vue Router 的 Data Loader [Data Loader](https://router.vuejs.org/data-loaders/) 是 `unplugin-vue-router` 提供的功能,核心精神是**讓「資料請求」與元件同檔共存,卻不受元件生命週期束縛,由路由在換頁前提早平行觸發**。 如此一來,Loader 一樣寫在子元件檔案中,不需要拉到父元件,資料歸屬一目了然,元件獨立性完整保留。 ::: tip Data Loader 詳細說明 由於這篇文章並非介紹 Data Loader,關於 Data Loader 具體用法與詳細說明,還請大家參考[官方文件](https://router.vuejs.org/data-loaders/)。 ::: 以下是宣告並呼叫 Loader 的簡易範例。 ```vue ``` 這種做法不但維持「元件自管資料」的乾淨架構,多個 Loader 還會在跳轉瞬間**一起幫你平行抓好資料**: ``` Post ████████░░░░░░░░░░░░░░░░ 1s Comment ████████░░░░░░░░░░░░░░░░ 1s Related ████████░░░░░░░░░░░░░░░░ 1s 總耗時:1s ``` 同樣三支 API,從 3 秒縮短到 1 秒。%=͟͟͞͞( •̀д•́)% ::: tip defineBasicLoader 為什麼要放在非 setup 的 ` ``` 就結論而言,這似乎是 reka-ui 的 `Slot` 元件實作上的 bug,導致 `ref` 永久遺失。 搜尋了一下,似乎還真的有人遇到類似的問題,還有相關討論: * [\[Bug\]: ref not working properly in custom-wrapped Tooltip with asChild prop](https://github.com/unovue/reka-ui/issues/1580) * [\[Bug\]: DropdownMenuContent as-child > ref element is not updated when closed](https://github.com/unovue/reka-ui/issues/694) * [Another implementation of asChild](https://github.com/unovue/reka-ui/discussions/1324) 如果各位大大知道更多資訊,還請不吝分享。%( ´∀\`)~♥% ## 如何除靈 所以要怎麼除靈呢?只能改變 template 結構嗎?的確也可以,只是有一個更簡單的解決方案。%( •̀ ω •́ )✧% 那就是出場次數堪比日本製壓縮機,聊天紀錄永遠輪迴在「下次約」,我們那個極度少見的朋友:**`v-once`**!%ヽ(•̀∀•̀ヽ )% 路人:「你這樣是歧視 v-once。%ლ(╹ε╹ლ)%」 鱈魚:「不然你問各位讀者,有用過的舉手,我數數看 %(´・ω・\`)%」 `v-once` 的作用是告訴 Vue:「這個節點只渲染一次,之後的更新都跳過」。 前面提到問題的根源在於 `Slot` 元件每次 render 都會對 slot 內容執行 `cloneVNode`,而 clone 出來的新 VNode 遺失了 `ref`,導致 Vue unset 舊 ref 後無法 set 回來。 加上 `v-once` 後,Vue 會在首次渲染時快取這個 VNode。後續 `Slot` 呼叫 `slots.default()` 取得的是**快取的 VNode**,而非重新建立的新 VNode。因此即使 `cloneVNode` 再次執行,clone 的來源始終是同一個帶有 ref 的 VNode,`ref` 不會被 unset,自然也就不會遺失。 用法很簡單,就是加上一個 `v-once` 指令。 ```vue ``` 以下是使用 `v-once` 前後的對比,可以看到使用 `v-once` 後,即可開啟選單且不管怎麼調整寬度,方塊都不會扁掉。%(\*´∀\`)~♥% ::: warning 所以說那個問題呢? 如同剛剛提到的現象,此問題**僅在 dev 模式下發生**,prod 模式不受影響,所以大家應該看不出差別。%(›´ω\`‹ )% 如果大家知道到底發生了甚麼事,還請不吝分享。%( ´∀\`)~♥% ::: ## 什麼時候適合用 v-once `v-once` 在以下情境非常實用: * **第三方函式庫接管的 DOM 元素**:canvas(WebGL)、地圖容器、影片播放器等 * **不需要 Vue 響應式更新的靜態內容**:一次性渲染的大量列表項目 * **放在會頻繁更新的父元件 slot 裡的穩定元素**:就像本文的案例 反過來說,如果元素本身的 attribute 或內容需要隨資料變化,就不適合用 `v-once`,因為加上去之後此 DOM 的內容就再也不會更新了。 如同那些被作者棄坑的作品,再也不會更新了。%(╥ω╥\`)% ## 總結 🐟 * 使用 `as-child` 模式的元件(如 Nuxt UI 的 `UContextMenu`),底層的 reka-ui `Slot` 元件會在每次 render 時對 slot 內容執行 `cloneVNode`,導致 **`ref` 永久遺失** * 這是 reka-ui 的[已知問題](https://github.com/unovue/reka-ui/issues/1580),`cloneVNode` 沒有正確保留原始 VNode 的 `ref` * `v-once` 讓 Vue 快取 VNode,使 `Slot` 每次拿到的都是同一個帶有 `ref` 的 VNode,避免 ref 被 unset * `v-once` 適合用在由第三方函式庫(Babylon.js、Three.js 等)接管、不需要 Vue 響應式更新的 DOM 元素上 * 注意 `v-once` 會讓該元素上所有的動態綁定失效,只適合用在不需要 Vue 更新的元素 --- --- url: /blog-program/for-each-and-for-loop.md description: 常聽到別人說特定情境下 forEach 比 for 迴圈還慢,剛好有個好機會,讓我來試試看吧。ԅ(´∀` ԅ) --- ![for-each-and-for-loop](/for-each-and-for-loop.webp){.cover} # 聽說 forEach 比 for 迴圈慢? 常聽到別人說:某些情況下 `forEach` 比 `for` 迴圈還慢。 babylon.js 的 Particle System 文件中,[更新大量粒子](https://doc.babylonjs.com/features/featuresDeepDive/particles/particle_system/customizingParticles/)的迴圈預設也都是使用 `for` 迴圈。 最近酷酷的元件做了一個[下雪背景](https://chillcomponent.codlin.me/components/bg-snow/),粒子數量與計算量相對得多。 剛好藉這個機會來實測看看不同迴圈的實際效果。%ԅ(´∀\` ԅ)% ::: tip 以下實驗基於 Google Chrome v131.0.6778.110 超不嚴謹,僅針對此元件情境,請不要當作正式結論。( ゚∀。) ::: ## 實測 基於原本的[程式碼](https://gitlab.com/side_project/chill-component/-/blob/main/src/components/bg-snow/bg-snow-worker.ts?ref_type=heads#L97),加上 `performance.now` 計算執行時間,且每運行 100 次再輸出一次,避免 devtool 炸掉。 ```ts let count = 0 particleSystem.updateFunction = function (particles) { const startTime = performance.now() // 計算 staticMap 與粒子是否有碰撞 for (const id in staticMap) { // ... } const endTime = performance.now() if (count % 100 === 0) { console.log({ particles: particles.length, time: endTime - startTime, }) } count++ } ``` ### for ```text {particles: 1159, time: 0.5} {particles: 1185, time: 1} {particles: 1170, time: 0.5999999046325684} {particles: 1195, time: 1} {particles: 1200, time: 1.2000000476837158} {particles: 1194, time: 0.7000000476837158} {particles: 1201, time: 0.6999999284744263} {particles: 1182, time: 1.1999999284744263} ``` 可以看到原本粒子數量最多大約在 1200 左右,執行時間在 0.5 ~ 1.2 ms 之間。 ### forEach 現在來看看 `forEach` 的執行時間。 ::: tip staticMap 的 `for` 部分維持不變,因為這次只比較矩陣 `forEach` 與 `for` 的差異。 ::: ```ts particleSystem.updateFunction = function (particles) { const startTime = performance.now() // 計算 staticMap 與粒子是否有碰撞 for (const id in staticMap) { const bounding = staticMap[id] if (!bounding) continue /** 依照文件作法更新粒子 * * https://doc.babylonjs.com/features/featuresDeepDive/particles/particle_system/customizingParticles/ */ particles.forEach((particle, index) => { // ... }) } // ... } ``` 來看看結果 ```text {particles: 1163, time: 1.100000023841858} {particles: 1168, time: 1.2000000476837158} {particles: 1184, time: 1.2000000476837158} {particles: 1180, time: 1} {particles: 1171, time: 1} {particles: 1161, time: 1.2000000476837158} {particles: 1174, time: 1.1999999284744263} {particles: 1166, time: 1.2999999523162842} ``` 可以注意到耗時多了一點點,執行時間在 1 ~ 1.3 ms 之間。 ### for of 再來試試看 `for of`。 ```ts particleSystem.updateFunction = function (particles) { const startTime = performance.now() // 計算 staticMap 與粒子是否有碰撞 for (const id in staticMap) { const bounding = staticMap[id] if (!bounding) continue /** 依照文件作法更新粒子 * * https://doc.babylonjs.com/features/featuresDeepDive/particles/particle_system/customizingParticles/ */ let index = -1 for (const particle of particles) { index++ // ... } } // ... } ``` 結果: ```text {particles: 1189, time: 1.2000000476837158} {particles: 1186, time: 1.5} {particles: 1185, time: 1.5} {particles: 1182, time: 1.100000023841858} {particles: 1190, time: 0.7999999523162842} {particles: 1188, time: 1.100000023841858} {particles: 1187, time: 1.399999976158142} {particles: 1185, time: 2.1999999284744263} {particles: 1181, time: 1.1999999284744263} {particles: 1186, time: 1} ``` 和 `for` 差不多,但是偶而會跑出 2 ms 的時間。 ## 暴風雪! 差異不大好像不夠精彩,讓我們把粒子數量加好加滿,變成暴風雪看看!ლ(´∀\`ლ) ![snowstorm](/for-each-and-for-loop/snowstorm.png) 超多雪!ᕕ( ゚ ∀。)ᕗ `for` ```text {particles: 97970, time: 360.10000002384186} {particles: 98122, time: 340} {particles: 98118, time: 342.7000000476837} {particles: 98035, time: 355.2000000476837} {particles: 98069, time: 348.60000002384186} ``` `forEach` ```text {particles: 98275, time: 304.09999990463257} {particles: 98491, time: 273.5} {particles: 98092, time: 356} {particles: 97463, time: 472.7000000476837} {particles: 98080, time: 342} ``` `for of` ```text {particles: 99365, time: 122.20000004768372} {particles: 99052, time: 181.59999990463257} {particles: 98330, time: 289.3000000715256} {particles: 97579, time: 390.7999999523163} {particles: 98233, time: 329.3000000715256} ``` 粒子數量大約在 98k 左右,可以看到結果很有趣: * `for` 與 `forEach` 時間差不多,但是 `for` 比較穩定 * `for of` 最快,但偶而會變慢。 以上就是這次的超不專業的不嚴謹實驗。੭ ˙ᗜ˙ )੭ 究竟誰比較快,會因為情境、runtime、瀏覽器最佳化與程式寫法而有所不同,最好應該實際跑過 benchmark 再做選擇。 ## 總結 🐟 * 元素數量不多時,`forEach` 與 `for` 差異不大。 * 性能會因情境、runtime、瀏覽器最佳化與程式寫法而有所不同,最好應該實際跑過 benchmark 再做選擇。 --- --- url: /blog-ocean-world/markdown-it-custom-syntax.md description: 顏文字換行會原地變為一坨亂碼,該怎麼辦呢?(PД`q。) --- ![markdown-it-custom-syntax](/markdown-it-custom-syntax.webp){.cover} # 臉被切到了怎麼辦!來自定義 markdown 語法吧 段落文字太長時,在句尾的顏文字很容易被換行,像這樣 (#° Д°) 一秒便變成了一坨不知所云的亂碼。%( ´•̥̥̥ ω •̥̥̥\` )% 原本解法是像這樣 `(>'-'<)`,把顏文字用 HTML 包起來。 有效,但是實在不夠~~懶人~~優雅。%( ・ิω・ิ)% 既然此部落格基於 markdown 寫作,那麼就來調整一下 markdown-it 吧!%◝(≧∀≦)◟% ## 自定義語法 markdown-it 設計之初就考慮到了擴展性,自定義語法小菜一碟。 查了一圈 markdown 還沒被使用的符號,看起來 `%` 還不錯,所以預計使用 % 符號建立一個決不換行的區塊。 也就是 `%(>'-'<)%` 自動變為 `(>'-'<)` 首先建立一個 plugin。 `.vitepress\plugin\markdown-it-nowrap.ts` ```ts import type MarkdownIt from 'markdown-it' const RULE_NAME = 'nowrap_span' const MARKER = '%' /** 緊跟在數字後面的 % 是百分比(4.2%、100%),不是標記 */ function isPercentSign(source: string, markerIndex: number) { const previousChar = source[markerIndex - 1] return previousChar !== undefined && previousChar >= '0' && previousChar <= '9' } /** 使用 MARKER 包圍文字,即可建立不換行的元素 * https://vitepress.dev/guide/markdown#advanced-configuration * * 內文的百分比會跟標記衝突,例如「掉 4.2%。[連結](url) 差 50%」, * 兩個百分比之間的文字(含連結)會整段被當成顏文字吞掉, * 所以數字後面的 % 一律視為百分比,且顏文字不跨行。 * * @param md */ export function markdownItNowrap(md: MarkdownIt) { /** 解析 % 區塊 * * 顏文字僅用於 inline 內容,所以註冊在 inline ruler 之前 */ md.inline.ruler.before('emphasis', RULE_NAME, (state, silent) => { const start = state.pos const markerLength = MARKER.length // 如果不是 %,直接跳過 if (state.src.slice(start, start + markerLength) !== MARKER) { return false } if (isPercentSign(state.src, start)) { return false } let end = state.src.indexOf(MARKER, start + markerLength) while (end !== -1 && isPercentSign(state.src, end)) { end = state.src.indexOf(MARKER, end + markerLength) } if (end === -1) { return false } // 顏文字不會跨行,跨行代表這個 % 沒有配對 if (state.src.slice(start, end).includes('\n')) { return false } if (!silent) { const token = state.push(RULE_NAME, '', 0) token.content = state.src.slice(start + markerLength, end).trim() } state.pos = end + markerLength return true }) /** 渲染自定義區塊內容 */ md.renderer.rules[RULE_NAME] = (tokens, idx) => { if (!tokens[idx]?.content) { return '' } return `${tokens[idx].content}` } } ``` 接著依照[文件指示](https://vitepress.dev/guide/markdown#advanced-configuration),在 vitepress 使用 plugin。 `.vitepress\config.mts` ```ts // ... // https://vitepress.dev/reference/site-config export default ({ mode }: { mode: string }) => { // ... return withMermaid({ title: '鱈魚的魚缸', // ... markdown: { lineNumbers: true, config(md) { md.use((md) => markdownItBaseImg(md, mode)) md.use(markdownItNowrap) // [!code ++] }, }, // ... }) } ``` 完工! 現在被 % 包圍的文字都會自動變成帶有 text-nowrap class 的 `span` 了! ![nowrap-span](/markdown-it-custom-syntax/nowrap-span.png) 對原理有興趣的讀者們,歡迎來[看看這篇文章](https://ithelp.ithome.com.tw/articles/10354983)。%◝( •ω• )◟% ## 總結 🐟 * markdown-it 可自由擴展自定義語法 * 透過自定義語法讓顏文字不會被換行 --- --- url: /blog-vue/vue-directive-and-effect-scope.md description: Directive 內沒有自動解除響應式資料與副作用的魔法呦 (・∀・) --- ![vue-directive-and-effect-scope](/vue-directive-and-effect-scope.webp){.cover} # 自動解除的魔法呢?Vue Directive 和 Effect Scope Vue 除了元件與 Composition API 外,還有很方便的自定義 [Directive](https://cn.vuejs.org/guide/reusability/custom-directives)。 前陣子的新作 - [彈珠檯](https://chillcomponent.codlin.me/components/util-pinball/),就用了自定義指令,可以將 DOM 變成彈珠檯的中的機關,像這樣: ```vue ``` 其中有個需要取得 DOM 尺寸與位置的邏輯,這裡取簡化後的核心程式碼: ```ts export const vPinballMech: Directive = { mounted(el) { if (!(el instanceof HTMLElement)) { console.warn('v-pinball-mech 只能用於 HTMLElement') return } const bounding = reactive(useElementBounding(el)) const id = store.add({ options, ...bounding, }) watch(bounding, (value) => { store.update(id, value) }, { deep: true }) }, } ``` 當元素綁定後,在 `store` 新增此元素,配合 `useElementBounding` 取得元素尺寸並用 `watch` 同步尺寸變化。 不過當元件移除時,指令中 `watch` 與 `useElementBounding` 並不會自動解除呦。%( ゚ ∀。)% ## 真的會這樣嗎? 要證明這件事,其實也相當簡單,來個 [`useIntervalFn`](https://vueuse.org/shared/useIntervalFn/): 正常情況下 `useIntervalFn` 會在 Effect Scope 停止時自動解除。 ```ts export const vPinballMech: Directive = { mounted(el, binding) { useIntervalFn(() => { console.log('cod') }, 1000) }, } ``` 可以注意到即使元件被移除,`cod` 仍然會不斷出現。 為甚麼會這個樣子呢?這個我們可以反過來想,Vue 元件移除時如何清理響應式資料與副作用呢? 相關概念其實曾經在[這篇文章](/blog-vue/vue-watch-may-cause-a-memory-leak)提過。 主要是 Vue 會在元件建立自動產生元件範圍的 [Effect Scope](https://cn.vuejs.org/api/reactivity-advanced#effectscope) 並在移除時自動停止 Effect Scope。 而 Directive 並沒有自己的 Effect Scope,所以裡面的響應式資料不會被自動清理。 所以怎麼知道 Directive 沒有 Effect Scope ?很簡單,使用 `getCurrentScope` 即可: ```ts export const vPinballMech: Directive = { mounted(el) { if (!(el instanceof HTMLElement)) { console.warn('v-pinball-mech 只能用於 HTMLElement') return } const currentScope = getCurrentScope() console.log('currentScope: ', currentScope) // ... }, } ``` 會注意到 console 結果是 `currentScope: undefined`。 你說為甚麼 Directive 內可以取得來源元件,卻沒有 Effect Scope?%(´・ω・\`)% 具體原因我也不清楚,就看有沒有大大知道為甚麼了。 ## 所以該怎麼辦呢? 解決方法其實很簡單,就是自己建一個 Effect Scope,然後自己的 Scope 自己關。%ლ(´∀\`ლ)% 不過不知道為甚麼在 `mounted` 之外的地方呼叫 `scope.stop()` 都沒有效(無效的程式碼在[這裡](https://gitlab.com/side_project/chill-component/-/blob/develop/src/components/util-pinball/v-pinball-mech.ts?ref_type=heads)) 所以改成在 `mounted` 中持續判斷 DOM 是否依然存在,不存在則解除。 ```ts export const vPinballMech: Directive = { mounted(el) { if (!(el instanceof HTMLElement)) { console.warn('v-pinball-mech 只能用於 HTMLElement') return } const scope = effectScope() scope.run(() => { const bounding = reactive(useElementBounding(el)) const id = store.add({ options, ...bounding, }) watch(bounding, (value) => { store.update(id, value) }, { deep: true }) useIntervalFn(() => { // 檢查元素是否還在 DOM 中,否則停止 effect scope if (!el.isConnected) { scope.stop() } }, 100) }) }, } ``` 現在在元件被移除後,響應式資料與副作用都會自動解除了。%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% ## 總結 🐟 * Vue Directive 自身沒有 Effect Scope,所以裡面的響應式資料與副作用不會被自動清理。 * 可自行建立 Effect Scope,並在適當時機停止。 --- --- url: /blog-program/make-mistakes-invisible-with-sentry.md description: 系統上線後常常遇到前端錯誤不好追蹤問題,使用者很難說得清楚發生甚麼事,Sentry 可以協助記錄錯誤,並且提供更多資訊,讓開發者更容易追蹤問題。 --- ![make-mistakes-invisible-with-sentry](/make-mistakes-invisible-with-sentry.webp){.cover} # 藉由 Sentry 讓錯誤無所遁形吧! 系統上線後常常遇到前端錯誤不好追蹤、難以重現。 使用者也很難說得清楚花黑噴。( ´•̥̥̥ ω •̥̥̥\` ) 鱈魚:「只好默默地忽略。ԅ( ˘ω˘ԅ)」 路人:「不要亂教啊!Σ(ˊДˋ;)」 這個時候 Sentry 就可以登場了! ## Sentry 是什麼? Sentry 是一個開源的錯誤追蹤工具,可以幫助開發者追蹤錯誤,並且提供更多資訊,讓開發者更容易追蹤問題。 不只有 JavaScript,還支援 Python、Java、PHP、Ruby 等等多個語言,甚至支援 Unity、Unreal Engine 等等。 由於目前看來 Sentry 市占率最高還可以自託管,所以優先來試試 Sentry。 ## 設定 Sentry 以下我們以 Vue 為例,依照[文件指示](https://docs.sentry.io/platforms/javascript/guides/vue/#install)安裝、設定。 主要修改 main.js,加入 Sentry 初始化設定。 ```ts import * as Sentry from '@sentry/vue' import { createApp } from 'vue' import { createRouter } from 'vue-router' const app = createApp({ // ... }) const router = createRouter({ // ... }) Sentry.init({ app, dsn: 'https://xxxxxxxxxxxx@o4507587449978880.ingest.us.sentry.io/4507824142942208', integrations: [ Sentry.browserTracingIntegration({ router }), Sentry.replayIntegration(), ], // Set tracesSampleRate to 1.0 to capture 100% // of transactions for tracing. // We recommend adjusting this value in production tracesSampleRate: 1.0, // Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/], // Capture Replay for 10% of all sessions, // plus for 100% of sessions with an error replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, }) app.use(router) app.mount('#app') ``` 接著設定自動上傳 sourcemaps,就可以在每次執行 npm run build,都會自動上傳 sourcemaps。 執行設定引導精靈: ```bash npx @sentry/wizard@latest -i sourcemaps ``` 並根據提示設定,就大功告成了! ## 紀錄、查看錯誤 讓我們實際觸發錯誤,建立一個會 throw error 的按鈕。 ```vue ``` Sentry 捕捉到錯誤後,會列在 Issues 中。 ![issues](/make-mistakes-invisible-with-sentry/issues.png) ## 錯誤報告 點進去後可以看到詳細錯誤內容。 ![error-reports](/make-mistakes-invisible-with-sentry/error-reports.png) ### 裝置資訊 可以看看究竟甚麼裝置跑出小蟲蟲。( •̀ ω •́ )✧ ![device-info](/make-mistakes-invisible-with-sentry/device-info.png) ### 錯誤程式碼 有上傳 sourcemaps 的話,可以精準地知道發生錯誤的程式碼。 ![error-code](/make-mistakes-invisible-with-sentry/error-code.png) ### 麵包屑 詳細記錄操作流程,還可以記錄每個步驟耗時多久、API 花費時間等等。 ![breadcrumbs](/make-mistakes-invisible-with-sentry/breadcrumbs.png) ### 畫面錄影 最厲害的部分來了,不只有文字紀錄,甚至還有畫面錄影喔!ˋ( ° ▽、° ) ![video-recording](/make-mistakes-invisible-with-sentry/video-recording.gif){format="gif"} ## 其他細節 ### 手動抓取錯誤 除了自動抓取錯誤外,也可以手動記錄錯誤或訊息。 [Usage | Sentry for Vue](https://docs.sentry.io/platforms/javascript/guides/vue/usage/) ### 更詳細的記錄 [Enriching Events | Sentry for Vue](https://docs.sentry.io/platforms/javascript/guides/vue/enriching-events/) 可以針對事件提供額外的 context,更方便 debug。 以下舉幾個例子: * [附件](https://docs.sentry.io/platforms/javascript/guides/vue/enriching-events/attachments/):可以儲存螢幕截圖或特定檔案。 * [等級](https://docs.sentry.io/platforms/javascript/guides/vue/enriching-events/level/):紀錄特定等級的事件,方便分級追蹤。 * [tag](https://docs.sentry.io/platforms/javascript/guides/vue/enriching-events/tags/):方便分類紀錄。 * [使用者](https://docs.sentry.io/platforms/javascript/guides/vue/enriching-events/identify-user/):可以記錄錯誤來自哪個使用者。 ## 總結 🐟 * Sentry 可以幫助開發者追蹤錯誤,讓開發者更容易追蹤問題 * Sentry 可以自託管,也有提供免費方案 * 除了錯誤訊息,還有裝置細節、操作錄影等等非常詳盡的資訊 Sentry 還有其他很多功能,不過目前這樣對我來說很足夠了,歡迎大家更深入的嘗試。 整體用起來真的很不錯,很強很方便。(´▽\`ʃ♡ƪ) 不過如果你的網頁有 SEO 需求,要注意 Sentry 會影響 SEO 跑分,因為需要額外載入 JS 資源,導致 TTFB 變差。 所以我只有在 App 或後台網頁才會使用 Sentry。 以上內容,希望對大家有幫助。 有問題還請多多指教。(o゚v゚)ノ --- --- url: /blog-vue/vue-h-function-makes-quasar-dialog-easier-to-reuse.md description: Quasar Dialog 的 Invoking custom component 很好用,但是有些困擾的地方,一起來看看有甚麼辦法吧。 --- ![vue-h-function-makes-quasar-dialog-easier-to-reuse](/vue-h-function-makes-quasar-dialog-easier-to-reuse.webp){.cover} # 藉由 Vue h function 讓 Quasar Dialog 更簡單複用 大家好,我是鱈魚。(^∀^●)ノシ Quasar 是一個基於 Vue 的全端框架,具體內容可以看看[官方文件介紹](https://quasar.dev/introduction-to-quasar#what-is-quasar)。 不過目前我主要還是把 Quasar 當 UI 套件用。(╯▽╰ ) ## Quasar 的 Invoking custom component Quasar 的 Dialog 有一個很有趣的功能,在文件稱為「[Invoking custom component](https://quasar.dev/quasar-plugins/dialog#invoking-custom-component)」,也就是可以以命令式(imperative)呼叫任何自定義的 Dialog 元件。 這在建立複雜的後台系統很方便,除了元件可以作為 Dialog 重複使用,更可以保持 template 乾淨,不會充滿大量與目前元件邏輯無關的 html 內容,讓程式碼更簡潔、內聚一點。 但是有個小困擾,文件表示必須先將元件包裹於 q-dialog 並調用 useDialogPluginComponent, 也就是說我有一個 CreateForm 元件要變成 Dialog,要建立一個新元件: `CreateFormDialog.vue` ```vue ``` 接著使用 $q.dialog 呼叫: ```ts $q.dialog({ component: CreateFormDialog, }) ``` 成功把 CreateForm 變成 Dialog 開啟了!ˋ( ° ▽、° ) 不過這樣會導致每有一個元件想以 Dialog 形式開啟,就會多一個 xxxDialog.vue 的變體元件,實在是有點麻煩且多餘。 但是我們知道,其實 Vue 內部的 VDOM 都是透過 h function 建立,所以其實我們可以利用 h function 簡化這個過程。 ::: tip h function 就是所謂的渲染函數,詳細說明可以[看看官方文件](https://cn.vuejs.org/guide/extras/render-function.html) ::: h function 的寫法很簡單,其參數依順序如下: 1. type:除了 html tag 外,也可以是 Vue 的元件。 2. props:DOM 的參數,也可以是元件的 prop。 3. children:此 DOM 的子元素,也可以是元件的 slot ```ts import { h } from 'vue' const vnode = h( 'div', // type { id: 'foo', class: 'bar' }, // props [ /* children */ ] ) ``` 換句話說,剛剛的程式碼: ```ts $q.dialog({ component: CreateFormDialog, }) ``` 可以透過 h function 改寫成: ```ts $q.dialog({ component: h( QDialog, {}, { default: () => h(CreateForm) } ) }) ``` 鱈魚:「這樣就不用一直建立 Dialog 變體元件了!(≧∇≦)ノ」 路人:「這樣程式碼看起來很可怕欸。(゚Д゚\*)ノ」 鱈魚:「也是捏,讓我們重構、封裝一下。(。・∀・)ノ゙」 ## 重構、封裝 首先第一步,讓我們建立協助提取元件型別的實用 function。 ::: tip 推薦官方工具 推薦使用官方的 [vue-component-type-helpers](https://www.npmjs.com/package/vue-component-type-helpers) ::: `src/types/utils.type.ts` ```ts /** 提取 Vue Component 之內部 props * * 會將 style、class、event 全部取出來 */ export type ExtractComponentProps = TComponent extends new () => { $props: infer P; } ? P : never /** 提取 Vue Component slots */ export type ExtractComponentSlots = TComponent extends new () => { $slots: infer P; } ? P : never ``` 接著建立基於 Quasar 的 utils 工具。 主要有 2 個 function: * wrapWithDialog:用於將指定元件使用 QDialog 包起來。 * openUsingDialog:將 wrapWithDialog 包裹後的元件,使用 Dialog 開啟。 `src/common/utils-quasar.ts` ````ts import { Dialog, QDialog, QDialogProps } from 'quasar' import { Component, h } from 'vue' import { ExtractComponentProps, ExtractComponentSlots } from '../types' /** 將 Vue SFC 元件包装為 QDialog,可以更簡單配合 $q.dialog 使用 * * @param component Vue SFC 元件 * @param props SFC 內所有參數,包含 class、style、event 等等 * @param dialogProps QDialog 原本參數 * * @example * ```typescript * const component = wrapWithDialog(EditForm, { * data, * onSubmit() { * dialog.hide(); * }, * }); * * const dialog = $q.dialog({ component }); * ``` * * @example * ```typescript * $q.dialog({ * component: wrapWithDialog( * BrandLog, * { * data, * class: 'w-full' * }, * { * fullHeight: true, * } * ), * }); * ``` */ export function wrapWithDialog( component: Comp, props?: ExtractComponentProps, dialogProps?: QDialogProps, slots?: ExtractComponentSlots ) { return h(QDialog, dialogProps, { default: () => h(component, props ?? {}, slots ?? {}), }) } /** 使用 Quasar Dialog 開啟元件 * * @param component Vue SFC 元件 * @param props SFC 內所有參數,包含 class、style、event 等等 * @param slots SFC 插槽 * @param dialogProps QDialog 原本參數 * @returns * * @example * ```typescript * const dialog = openUsingDialog(EditForm, { * data, * onSubmit() { * dialog.hide(); * }, * }); * ``` */ export function openUsingDialog( component: Comp, props?: ExtractComponentProps, dialogProps?: QDialogProps, slots?: ExtractComponentSlots ) { return Dialog.create({ component: wrapWithDialog(component, props, dialogProps, slots), }) } ```` 現在我們可以把剛剛的程式碼: ```ts $q.dialog({ component: h( QDialog, {}, { default: () => h(CreateForm) } ) }) ``` 改寫成這樣: ```ts openUsingDialog(CreateForm) ``` 是不是整潔很多啊!♪( ◜ω◝و(و 而且因為有加上型別推導,所以使用時會有很完整的型別提示。 ![型別提示](/vue-h-function-makes-quasar-dialog-easier-to-reuse/type-hints.png) 現在可以配合元件事件撰寫邏輯了。( •̀ ω •́ )✧ Vue 會自動將元件 emit 的事件名稱加上 on 前綴,所以: * update:modelValue 會變成 onUpdate:modelValue * cancel 會變成 onCancel ```ts function openCreateForm() { const dialog = openUsingDialog(CreateForm, { 'onUpdate:modelValue': function (data) { alert(`送出資料: ${data.name}`) dialog.hide() }, onCancel() { dialog.hide() } }) } ``` 以上用法讓大家參考參考,如果有問題還不吝指教,鱈魚感謝大家!( ´ ▽ \` )ノ 更完整的範例請見[範例程式碼](https://stackblitz.com/~/github.com/Codfisher/quasar-imperative-dialog?file=src/App.vue)。 ## 總結 🐟 * 透過 h function 簡化 Quasar Dialog 的 Invoking custom component 用法 * 將 h function 封裝,更容易使用 --- --- url: /blog-program/do-you-want-to-do-a-side-project.md description: 分享一下鱈魚的 Side Project 靈感與發想。(๑•̀ㅂ•́)و✧ --- ![do-you-want-to-do-a-side-project](/do-you-want-to-do-a-side-project.webp){.cover} # 要不要來點 Side Project 啊?ԅ(´∀\` ԅ) 到底要不要做 Side Project 是一個老生常談的問題。%( •̀ ω •́ )✧% 其實廣義來說,任何工作以外的事情都可以算是 Side Project,例如學習一門新技術、寫部落格、練習運動等等。 本文的 Side Project 定義為「與專業相關,但非上班時間做的任何專案」,並聚焦於 Web(Vue)與 JavaScript 領域。%(。・∀・)ノ゙% 其他領域我就不知道了,需要請教其他大大。%(´,,•ω•,,)% ## 💭 為甚麼要做 Side Project? 來說說我覺得 Side Project 的好處!%(๑•̀ㅂ•́)و✧% 不過這些好處因人而異,每個人標準不同,參考參考即可。 ### 創造機會 先不說程式就像學外語一樣,多寫多思考,可以增進程式閱讀速度,也可以縮短將思緒轉換為程式碼的時間。 工作開發專案時,如果想要導入新技術或工具,通常需要考慮供應商綁定、開發流程、團隊接受度等等問題。 所以會保守看待,不會輕易導入,自己練習就不用擔心這些問題啦。 可以放飛自我,在練習的過程中累積經驗。%ヾ(⌐■\_■)ノ♪% ### 做好準備 > 機會是留給準備好的人 這句話雖然很老梗,但依然正確。 當機會不講武德的出現時,除了自己沒自信接,別人也可能不敢交給你,Side Project 就是一個很不錯的練習機會。 也可以趁機練習「軟實力」部分: 1. 找資料:如何快速搜尋並有效率的篩選相關資訊 2. 分析問題:快速定位問題並提出解決方案 3. 歸納、統整文件:將解決方案或使用說明整理成文件,讓他人能夠快速理解 內容很少很簡單無所謂,重點是練習的過程。%(๑•̀ㅂ•́)و✧% ### 單純喜歡 \~~以上是寫報告用的正經理由,看看就好,我自己的主要原因也是這個%( ゚ ∀。)%。~~ 沒有甚麼動機能夠比「有興趣」來得更有動力了。%(´,,•ω•,,)% 就像是小時候躲在被子裡偷完 PSP,或者是再怎麼累都能夠凌晨爬起來偷玩電腦一樣。 不用別人講自己會非常勤奮,但是別人要求的進度或項目總是會能拖就拖,不看到死線絕不開工。%ᕕ( ゚ ∀。)ᕗ% ## 💡 靈感哪裡來? 不用擔心別人做過,重點是自己的練習與實踐,過程中一定會有各種收穫,甚至新靈感。 以下從門檻高至低介紹,希望大家能夠找到自己喜歡的部分。%(。・∀・)ノ゙% ### 解決日常問題 記錄下你或周圍朋友、家人常遇到的問題,思考可以怎麼應用技術解決這些問題。 不過使用的技術可能會相對複雜,例如伺服器、資料庫、雲端託管等等,需要更多的背景知識,不過這也是一個很好的學習機會。 反正做壞了也沒人會怪你,放手做做看吧。%ლ(´∀\`ლ)% \~~有人酸你,那是他 G8,不是你的問題~~。ᕕ( ゚ ∀。)ᕗ ::: tip 前端可以練習使用 Firestore 儲存資料,就可以再沒有後端的情況下儲存資料,完成一個完整的應用程式喔。%( •̀ ω •́ )✧% ::: ### 從模仿開始 模仿是一個很好的開始,可以從中學習到很多東西,但是門檻高低不一,需要斟酌斟酌。%(◜௰◝)% #### 做一個網頁版小畫家 這個有點難度,不過可以先從一個簡單的功能開始,例如圖片編輯、濾鏡等等。 最後目標挑戰一下打造一個網頁版 Photoshop。 可以玩到很多很有趣的圖學知識和一些網頁性能的技巧。 [Photopea](https://www.photopea.com/) 就是一個很好的例子,來玩玩看吧!%ˋ( ° ▽、° )% #### 模仿某個 UI 套件 例如 [Quasar](https://quasar.dev/)、[Vuetify](https://vuetifyjs.com/en/) 等等,也可以挖挖看原始碼學習,不過在沒有說明的情況下,很不容易看懂就是了。%( ・ิω・ิ)% #### 實作 utility libraries 某個的 API 例如 [VueUse](https://vueuse.org/) 這類套件,裡面很有趣且實用的 API,找一個自己覺得有趣的 API 實作看看。 實作的過程不只可以學到一些少見的[瀏覽器 APIs 知識](https://developer.mozilla.org/en-US/docs/Web/API),還可以臨摹開源大神們的寫作習慣與 API 設計風格,進而增進自己的程式能力。 以下舉幾個很有趣的 API: * [`useElementByPoint`](https://vueuse.org/core/useElementByPoint/):取得滑鼠所在位置的元素 * [`useMagicKeys`](https://vueuse.org/core/useMagicKeys/):註冊各類快捷鍵、組合鍵 * [`onLongPress`](https://vueuse.org/core/onLongPress/):長按事件 * [`onClickOutside`](https://vueuse.org/core/onClickOutside/):點擊元素外部事件 * [`useRefHistory`](https://vueuse.org/core/useRefHistory/):記錄 ref 的歷史數值 * [`makeDestructurable`](https://vueuse.org/shared/makeDestructurable/):讓物件變為可以矩陣解構或物件解構 * [`useVModel`](https://vueuse.org/core/useVModel/):簡化元件定義 v-model 過程。(不過現在有 [defineModel](https://vuejs.org/api/sfc-script-setup.html#definemodel),這個 API 可以功臣身退了) 查看原始碼前先想想看這些 API 如何實現?又考慮了哪些邊界情境呢?%( •̀ ω •́ )✧% ::: tip 我的[酷酷的元件](https://chillcomponent.codlin.me/components/)內就大量使用了 VueUse API 歡迎來玩玩看。%(/≧▽≦)/% ::: ### 從興趣出發 這個動機最強,但是也是門檻不一,因為簡單可以很簡單,要複雜也能很複雜。 以下是一些我曾經看過的酷酷題目。%(⌐■\_■)y% #### 寶可夢圖鑑 可以查閱各種寶可夢資訊的網站,進階一點可以挑戰模糊搜尋、圖片辨識等等功能。 #### 網頁調音器 分享者本身的興趣是音樂,融合自身專長打造了一個網頁調音器,方便快速校正吉他。 超酷超強!(≖‿ゝ≖)✧ #### 生成式藝術 英文為 Creative Code,利用程式碼產生各種有趣的圖形或者藝術品。 [Creative Coding TW](https://creativecoding.in/) 有很多有趣的資訊與作品,大家可以參考參考。 #### 梗圖編輯器 方便快速製作梗圖的網站,一言不合就梗圖。XD 例如:[Meme 梗圖倉庫](https://memes.tw/)、[Meme Generator](https://imgflip.com/memegenerator) 等等。 #### 網頁自動化 這裡的例子為 [Automa](https://www.automa.site/),他是一個瀏覽器外掛,使用節點拖拉的方式,可以很輕鬆、直覺的實現自動化網頁操作。 #### 桌面自動化 網頁可以自動化,桌面當然也可以自動化,做個像是按鍵精靈的東西,可以幫你自動化一些重複性工作,~~刷個遊戲甚麼的~~。%∠( ᐛ 」∠)\_% 目前找到較為成熟的套件為 [Nut.js](https://nutjs.dev/),歡迎大家多多補充!%(´▽\`ʃ♡ƪ)% ## 🧰 練習技術或套件 最後就是門檻最低、相對容易開始的部分,我會依序介紹 JS 生態圈中的套件並附上連結。 大家一起來看看吧。( ´ ▽ \` )ノ ### 動畫 動畫是網頁設計不可或缺的一部分,以下是一些常見的動畫套件。 #### 動畫控制 * [GSAP](https://gsap.com)、[Anime.js](https://animejs.com) 可以控制各種 DOM 或屬性動畫,例如 CSS、SVG 等等。 GSAP 功能最多也最強大,不過進階功能要收費,Anime.js 功能雖然沒那麼多,但是用到目前為止覺得很夠用。 #### 精緻動畫 * [Lottie](https://lottiefiles.com)、[Rive](https://rive.app) 有獨立的動畫製作工具,方便設計、管理極為複雜的動畫,並在網頁提供尺寸更小(相較影片)、自定義程度更高且效果一致的動畫。 #### 動畫工具 * [Theatre.js](https://www.theatrejs.com) 方便直接在網頁上調整、設計特殊元素動畫,例如 three.js 內的 3D 物件,定位上比較接近工程師。 ### 多媒體 #### 聲音 * [Tone.js](https://tonejs.github.io) 可以製作各種音樂、音效,並融合於各種網頁互動。 #### 繪圖 * [p5.js](https://p5js.org)、[PixiJS](https://pixijs.com) 可以製作各種 HTML、CSS 難以實現的複雜繪圖。 p5.js 比較像是在畫布上作畫,Pixi 則是將將畫布視為一個場景,加入各類物體進行繪製。 很推薦大家看看這本電子書:《[Nature of Code](https://natureofcode.com/introduction/)》 書中基於 p5.js 開發,介紹了很多有趣的知識與開發技巧,並用程式碼實現自然界的各種現象。 例如物理模擬、生物遺傳等等,超級有趣!%◝(≧∀≦)◟% #### 3D * [Three.js](https://threejs.org)、[Babylon.js](https://www.babylonjs.com) 開發製作各種酷炫的 3D 互動、遊戲等等。 ::: tip 先前曾經用 Babylon.js 製作[精靈球工作臺](https://side_project.gitlab.io/pokeball-workshop/) ::: #### 遊戲 * [Phaser](https://phaser.io)、[PlayCanvas](https://playcanvas.com)、[GDevelop](https://gdevelop.io) 可以製作各種網頁遊戲,但是門檻可能會高一點。 不過因為好玩,所以通常更有動力。%(´,,•ω•,,)% ::: tip 我先前出的書:《[Arduino自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機(iThome鐵人賽系列書)](https://www.books.com.tw/products/0010942459?sloc=main)》,最後一章的[貓狗大戰遊戲](https://youtu.be/OpayalfQ124?si=XeiACQL9xzPhdxzP\&t=170)就是使用 Phaser 開發的呦。( •̀ ω •́ )✧ ::: ### 其他領域 JS 得利於瀏覽器與 V8 引擎的發揚光大,觸角也延伸至其他領域。 不過這個部分需要其他領域的相關知識,有興趣再加減嘗試看看囉。%ヾ(◍'౪\`◍)ノ゙% ::: danger 且記「JS 可以實現,不代表適合用 JS」,如果要深入實作或者打造產品,還請選用合適的語言。 例如:機器學習 Python 資源最多;嵌入式系統就看廠商 SDK 語言,通常都是 C/C++。 ::: #### 機器學習 * [TensorFlow.js](https://www.tensorflow.org/js?hl=zh-tw)、[Transformers.js](https://github.com/xenova/transformers.js)、[ml5.js](https://ml5js.org) 在瀏覽器上實現臉部、語音、骨架辨識等等有趣的功能。 #### 嵌入式系統 * [Johnny-Five](https://johnny-five.io)、[DeviceScript](https://microsoft.github.io/devicescript/) 用來控制或開發各種微控制器或單晶片,例如 Arduino、ESP8266、ESP32 等等。 不過 Johnny-Five 需要電腦(Node.js)與硬體持續通訊,DeviceScript 則直接撰寫 TypeScript,編譯後燒入硬體的韌體中。 #### IoT * [MQTT.js](https://github.com/mqttjs)、[matter.js](https://github.com/project-chip/matter.js/)、[Aedes](https://github.com/moscajs/aedes) 可以製作 IoT 應用程式,例如智慧家居、智慧農業等等。 我曾經使用 Aedes 建立一個分散式系統,可以遙控並收集數百台 IoT 裝置資料,非常有趣。ヾ(◍'౪\`◍)ノ゙ #### 外掛 * [瀏覽器外掛](https://developer.chrome.com/docs/extensions?hl=zh-tw)、[VSCode 外掛](https://code.visualstudio.com/api) 製作瀏覽器外掛、VSCode 外掛或各類開發工具的外掛,提升工作~~~或摸魚~~效率。%ᕕ( ゚ ∀。)ᕗ% #### 桌面應用程式 * [Electron](https://www.electronjs.org) 可以使用前端生態系,建立 UI 豐富的跨平台桌面應用程式。 缺點是記憶體消耗較高、程式體積也大。 %(́◉◞౪◟◉‵)% ::: tip 知名的 [VSCode、Slack、Notion 等等](https://www.electronjs.org/apps)的桌面版程式,就是用 Electron 開發。 ::: #### 手機應用程式 * [Capacitor](https://capacitorjs.com)、[NativeScript](https://nativescript.org) 可以用網頁技術開發手機應用程式,因為有一層轉換框架,所以一定會受限於框架本身。 如果框架沒有提供對應功能,就必須自己實作原生模組,這部分就需要原生開發知識。 但是若只是一些簡單的資料展示,沒有性能需求的應用程式,可以考慮使用。 Capacitor 基於 WebView,其實就是 APP 內嵌網頁。優點是可以使用前端現有生態圈,缺點就是性能會比原生差得多。(我自己實測 Android 14 很順,iOS 17.4 會有點卡) NativeScript 則是編譯為原生程式碼,性能會比 Capacitor 好,但是限制也比 Capacitor 來得多。 #### Headless CMS * [Directus](https://directus.io)、[Strapi](https://strapi.io) 可以快速建立 CMS、產生資料 API,而且內建一個管理後台。 讓使用者可以在沒有專職後端的情況下,建立一個資料單純的簡易系統。 如果前端想要跨足後端領域,個人認為這是一個很不錯的起頭。 可以從中學習到 API 設計、資料規劃、權限規劃等等後端常見的入門知識。 Directus 後台基於 Vue 開發,Strapi 後台則是基於 React 開發,兩者都有很高的客製化能力。 ## 🛠 實用資源 分享給大家一些我常看的電子報或網站。%(◍′ω\`◍)% ### 電子報 英文的話有 [JavaScript Weekly](https://javascriptweekly.com/)、[Frontend Focus](https://frontendfoc.us/)、[Node Weekly](https://nodeweekly.com/),可以收到各種 JS 或前端生態圈的最新消息與技術發展。 中文的話則是 [ExplainThis](https://www.explainthis.io/zh-hant)、[Star Rocket 科技創業週報](https://blog.starrocket.io/star-rocket-newsletter/)、[VK 科技閱讀時間](https://vickyho.com/),可以看到各類科技圈的大小事。 ### 參考資料 有空我會逛逛 [Codrops](https://tympanus.net/codrops/)、[Awwwards](https://www.awwwards.com/collections/)、[Codepen Spark](https://codepen.io/spark)。 以上網站集結了全世界的優秀作品和技術探討,很適合作為靈感或臨摹的參考。 ### 部屬平台 常用的部署資源則有 `Cloudflare`、`Firebase`、`Zeabur`。 ::: tip Zeabur 是台灣的雲端服務商,來推一下自家人。%(/≧▽≦)/% ::: ## 總結 🐟 * 建立靈感庫,以免忘記點子 * 不用擔心別人做過,重點是自己的練習與實踐,過程一定會有各種收穫,甚至新靈感 * 先求有再求好,否則永遠無法開始 * 別怕做不出來,慢慢研究,例如:[派對動物](https://www.youtube.com/watch?v=PPWyUhT6gRk\&ab_channel=%E9%B1%88%E9%AD%9A)就是我 2020 年想到的點子,直到 2023 才有能力做出來。%(´,,•ω•,,)% * 有餘力的話可以把過程寫成文章,除了當學習筆記,也可以作為作品集 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。%(´▽\`ʃ♡ƪ)% 一起來做 Side Project 吧!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% --- --- url: /blog-ocean-world/detached-dom-elements.md description: 使用虛擬列表時,記憶體不斷上升,甚至爆炸,該怎麼辦呢? --- ![detached-dom-elements](/detached-dom-elements.webp){.cover} # 說好的虛擬呢?記憶體爆炸啦! 某個專案回報網頁會越來越卡,甚至崩潰。 查了一下是因為記憶體用量像鱈魚的體重一樣越來越高,都不知道是鯨魚還是鱈魚了。 鱈魚:「旁白不要給我亂念劇本 %ლ(´ 口\`ლ)%」 嘗試重現了一下,發現在虛擬滾動列表中,反覆滾動會讓記憶體不斷上升,而且還沒有辦法釋放。 讓我們來看看是怎麼回事。 ## 虛擬滾動 若頁面一次載入太多元素,可能會導致頁面卡頓,甚至崩潰。 這時候我們可以透過虛擬滾動來解決這個問題。 虛擬滾動只會顯示可見區域的元素,同時也會預先載入(Overscan)可見區域外的一些元素,避免快速捲動時出現大量空白。 下方是一個虛擬滾動的互動說明,可以清楚看到可視區域、渲染區域和未渲染區域。 UI 套件可能會有內建虛擬滾動元件,如果沒有且需求簡單,則可以使用 VueUse 的 `useVirtualList`。 使用上非常簡單: ```vue ``` 結果如下: 可以看到 50000 列資料輕輕鬆鬆啦!%( •̀ ω •́ )✧% ## 問題重現 不難注意到虛擬列表會不斷建立、銷毀 DOM 元素,如果我們反覆滾動,記憶體就會不斷上升。 讓我們快速滾動列表並打開 DevTools 使用 Performance Monitor(效能監視器)查看記憶體與 DOM 的變化。 ![dom-is-released-normally](/detached-dom-elements/dom-is-released-normally.webp) 可以注意到即使 DOM 元素一度衝到接近 40000 個,也會在特定時間點釋放並不會無限上升。 所以到底是怎麼回事呢?%(́⊙◞౪◟⊙‵)% 反覆交叉嘗試後,發現問題出在 `input` 元素,沒錯,就是那個樸實無華的輸入框。%(◉◞౪◟◉ )% 只要在剛剛的虛擬列表中加入 `input` 元素,記憶體就會不斷上升,不會自動釋放,直到記憶體爆炸。 ```vue {14-17,36} ``` 一樣不斷滾動列表,並查看 Performance Monitor。 ![dom-is-not-released](/detached-dom-elements/dom-is-not-released.webp) 可以看到 DOM 數量不斷上升,完全沒有釋放的跡象。%(;´༎ຶД༎ຶ\`)% 鱈魚:「真相出爐了,兇手就是 `input`!%ヽ(●\`∀´●)ノ%」 同事:「可是一樣的範例,我的電腦沒有這個問題欸%(´・ω・\`)%」 鱈魚:...%ヽ(́⊙◞౪◟⊙‵)ノ% 使用不同瀏覽器交叉測試後,發現內鬼是瀏覽器外掛(例如:密碼管理工具),關閉外掛後就沒問題了。%( ˘•ω•˘ )% 所以其實此問題可能一直都在,只是因為大量的 `input` 在虛擬滾動中反覆建立、銷毀,放大了問題才會注意到,所以該怎麼辦呢? 鱈魚:「這個簡單,關閉外掛或只能用乾淨瀏覽器,不要拉倒 %(・∀・)9%」 同事:「PM 在你後面,他非常火 %╭(°A ,°\`)╮%」 我們沒辦法控制客戶的瀏覽器環境,只能調整 `input` 出現的時機了。 可以改成開啟編輯模式時才顯示 `input` 或使用像 [Popup Edit](https://quasar.dev/vue-components/popup-edit) 這種點擊後顯示浮動 `input` 的元件。 最後改成 Popup Edit 方案,不但問題解決惹,性能也提升了一點,用起來更流暢惹。%ԅ( ˘ω˘ԅ)% ## 總結 🐟 * 虛擬列表可以確保大量資料顯示時,不會造成頁面卡頓。 * 某些瀏覽器外掛會導致問題,調整 `input` 出現的時機,避免大量建立、銷毀 `input` 元素。 --- --- url: /blog-vue/problem-with-accidentally-modifying-pinia-data.md description: 有時候使用 Pinia 會遇到資料意外變更問題,這其實不是鳳梨的錯,讓我們看看怎麼回事吧。( ´ ▽ ` )ノ --- ![拿著鳳梨的鱈魚](/problem-with-accidentally-modifying-pinia-data.webp){.cover} # 誰偷了我的鳳梨!聊聊意外修改 Pinia 資料問題 相信大家用 Vue 3 後應該都改用 Pinia 了吧?沒用過的人趕快試試看吧。(´,,•ω•,,) Pinia 最簡單的用法就像這樣: `counter.ts` ```ts import { defineStore } from 'pinia' import { ref } from 'vue' export const useStore = defineStore('counter', () => { const n = ref(0) return { n } }) ``` 接著在要使用的地方呼叫 useStore: `App.vue` ```vue ``` 其實像這樣直接操作 `store.n` 的方式,方便歸方便,~~苓膏龜苓膏~~。 但不是個好做法,這種方式容易讓資料流混亂,想像一下你有多個元件都使用 `store.n`,然後想改就改。…( ・ิω・ิ) 比較推薦的方式通常為由 store 提供一個修改資料的 function,例如: `counter.ts` ```ts import { defineStore } from 'pinia' import { ref } from 'vue' interface User { name: string; price: number; } export const useStore = defineStore('counter', () => { const user = ref({ name: 'cod', price: 100, }) function updateUser(data: Partial) { user.value = { ...user.value, ...data, } } return { user, updateUser, } }) ``` 呼叫的部份改為: `App.vue` ```vue ``` 這樣如果未來要新增邏輯、權限甚至重構,都容易得多。 但是問題來了,有時候流程中會有「確認」的按鈕,也就是要按下確認後,才修改 store 的資料。 假設有一個負責修改 User 資料的元件: `UserCard.vue` ```vue ``` `App.vue` ```vue ``` 目前畫面如下圖。 ![Untitled](/problem-with-accidentally-modifying-pinia-data/Untitled.png){width="200px" height="145px"} 這時候你會發現出事啦!╭(°A ,°\`)╮ 按下遞增的時候,不只元件內的 user 數值發生變化,連 store 的數值也一起變啦!Σ(ˊДˋ;) 熟悉 JS 的朋友們一定都知道發生甚麼事,這是因為直接指派物件是 Call by Reference,所以: ```ts const user = ref(store.user) ``` 這個部分的程式會讓 user 依舊指向 store 的 user,結果就意外改到鳳梨裡面的資料了。(›´ω\`‹ ) 這時候聰明的讀者們一定也想到解法,在 ref 的時候拷貝一次不就行了? `UserCard.vue` ```vue ``` 這時候會發現世界恢復和平了,資料一切正常!◝( •ω• )◟ 鱈魚:「但是阿。(´● ω ●\`)」 路人:「怎麼那麼多但是?…(›´ω\`‹ )」 實際上協作開發的時候難保大家都會注意到這件事情,所以保險起見,可以在 Pinia 提供資料時先拷貝一次。 `counter.ts` ```ts import { defineStore } from 'pinia' import { computed, ref } from 'vue' interface User { name: string; price: number; } function clone(data: Data): Data { return JSON.parse(JSON.stringify(data)) } export const useStore = defineStore('counter', () => { const user = ref({ name: 'cod', price: 100, }) function updateUser(data: Partial) { user.value = { ...user.value, ...data, } } return { user: computed(() => clone(user.value)), updateUser, } }) ``` 這樣即使元件中使用 ```ts const user = ref(store.user) ``` 也不會意外修改 Pinia 中的資料了!✧⁑。٩(ˊᗜˋ\*)و✧⁕。 當然還有其他方式: * [readonly](https://vuejs.org/api/reactivity-core.html#readonly) 不過在 TypeScript 中會讓型別變得複雜,使用上會稍微有點麻煩 * [immutable.js](https://immutable-js.com/) 功能強大,但有額外的學習、依賴成本 就看大家喜歡哪一種了。♪( ◜ω◝و(و 以上程式可以來這裡取得:[範例程式](https://play.pinia.vuejs.org/#eNqtVc1OGzEQfhVrL9kI8IJAVEoDoqUcygFQaW97cXYnYNj1WrY3BEU59AH6Chw49hH6PlXbt+j4J94FKpRDL4k9nvk83+eZ2UXyTko6ayEZJWNdKC4N0WBaSSomrg7yxOg8OcwFr2WjDPmiQR0zVZKpamoyoNnKYCEGb3MRPRek1XBpGgVkGb2LphUGFDV6YF2LRmi8zjkdRP90iDjjzCeDV+PGQC0rZgB3hIxLPnMLEkKl4gWMyGLh9xSBFHVGsly6iMyHuPW1ItlqHelYyzjrXZNsJp7IVs0kvdGNQIEWNigPB6gL3unzyBPJBWfWkieZW1PQ9dZENXc2mRt03ly5HqFUWQkz0zSV3mKS+7BrY6QeZVlRCvQvoeIzRQWYTMg6exFztE/36U5W8UmGF2VclDB/eg1GbJVQr4O+cj3apjt79I1Dne0G0NqiWlCUcomyGI3PNuVXz0QpmlryCtS5NByf9Yk4rKqau1NnM6qFmGJxDcXtP+w3eu6zvlCA8s2gR8swdQXGH59cnsEc1/Gwbsq2Qu9XDj+BbqrW5ujd3reixLR7fi7bj+6Nubj6rE/mBoRekbKJOjWcv5P5+BXqXbq7dLenYtcJKGFsmRKmXDzrGldN2C7RScF0k1i5WwNldLP959rPwk4Z1r4tbp+IYDX2hzYK+WBvkVXHiLaegEILJpWLaSsKS4AUVSNg/IEZdpiW+Dsidj30fx5R4YhQgpxenp9RyZSG1C39FXx67+KGtpEdNMxd7r7f41w46PNNB0GSwSZJh+Tg0F8UQxS6I3PXsodp0NcTw8hyEJ4vMNvZ3naG5dB3euTWSswMLEigdsHwmVnlcYerh3MjZMaq1mYZbIRQ6meLOwgXOquFCns3cBzpqFIPcxQfLvUkndZphzocBpwuUU8ElUQuWDn9ibve0HYl86xQ/s+Ufv46aTeA7XGvprgoFNQgDNJ2enSU/aje2PDFEiN0O6l5dA/A3ev1JPOB630xxpPWGFfjTGtUym/zhBwVFS9u0RIzdRL6J/7z9eHn40NAyHzM2oCeSA/t1+OP39+/vUALhi5XrP5WKcyk94V78W3rvm7d6smXDLfa3Fd2SUOuTtEaJxMXI7It52RPzqOIwTdZ/gUMSr+N) ## 總結 🐟 * 小心 call by reference 導致意外修改 Pinia 資料 * Pinia 提供資料時先拷貝一次,避免意外修改 --- --- url: /blog-ocean-world/js-highlight-api.md description: 在 JavaScript 實作網頁文字搜尋關鍵字的 highlight 效果 --- ![js-highlight-api](/js-highlight-api.webp){.cover} # 越暗的地方你越亮,透過 JS 標記關鍵字吧 製作關鍵字篩選資料功能時常常想,如果能像瀏覽器內建的關鍵字搜尋功能那樣,標記符合的關鍵字,使用體驗應該會更好。 研究了一輪,發現還真的可以做到,讓我們一步一步來實作看看吧! 不知道甚麼是文字標記效果?那就先體驗看看成果。%( ´ ▽ \` )ノ% 可以注意到 table 內的文字會自動標記符合的關鍵字!%(≖‿ゝ≖)✧% ## 關鍵字過濾 首先讓我們做一個基本的 table 元件,讓使用者可以輸入關鍵字來篩選資料。 可以看到當我們輸入關鍵字時,table 會自動篩選出符合的資料。 如果能夠再標記符合的關鍵字,可以大幅提升使用體驗。%( •̀ ω •́ )✧% 所以應該怎麼做呢?將關鍵字使用 `` 標籤包起來嗎? 這的確是最經典的做法,但現在有更容易的方法了!%( ‧ω‧)ノ╰(‧ω‧ )% ## Highlight API 現在可以使用 [Highlight API](https://developer.mozilla.org/en-US/docs/Web/API/Highlight) 實現!%─=≡Σ((( つ•̀ω•́)つ% 不過目前(2025/08/26)支援度為「Baseline 2025 Newly available」,如果有舊瀏覽器需求,就只能乖乖回去用 `` 標籤了。%( ˘・з・)% 所以該怎麼使用呢?概念為: 1. 取得要標記的文字目標 DOM 2. 使用 `Range` 設定標記範圍 3. 使用這個 `Range` 建立一個 `Highlight` 物件。 4. 將這個 `Highlight` 物件透過 CSS.highlights.set 註冊到瀏覽器,並指定名稱為 `'my-custom-highlight'`。 5. 設定 `::highlight(my-custom-highlight)` 樣式內容 以下是範例程式: `content\blog-ocean-world\js-highlight-api\text-highlight.vue` ```vue ``` 實際效果如下: 可以注意到文字不需要被 `` 包起來,Highlight API 會自動幫我們標記符合的關鍵字。 這樣就不用調整 HTML 結構了,是不是很方便啊!%∠( ᐛ 」∠)\_% ## 來個封裝 知道怎麼用了,現在讓我們把這個功能封裝成 `useTextHighlight`,讓使用更簡單、方便。 ### 需求與規格 第一步當然是來個需求分析: 1. 產生唯一名稱避免互相影響,並注入對應的 CSS: ```css ::highlight(highlight-xxxxx) { background-color: yellow; color: black; } ``` 2. 關鍵字或 DOM 有變動時: 1. 走訪容器裡的所有文字節點 2. 找出每個文字節點中「關鍵字」的所有出現位置。 3. 對每個命中的片段建立 `Range`,加到 `Highlight` 裡。 4. 最後使用 `CSS.highlights.set` 將 `Highlight` 註冊到瀏覽器 3. 功能(元件)移除時自動移除標記與樣式 ### 定義參數 ```ts interface UseTextHighlightOptions { /** 搜尋目標,會標記以下文字節點 */ target?: MaybeElementRef; /** 標記底色 */ background?: MaybeRefOrGetter; /** 標記文字顏色 */ color?: MaybeRefOrGetter; /** 額外的觸發條件,變動時會更新標記 * * 可確保外部依賴資料更新後也會更新標記 */ triggerOn?: MaybeRefOrGetter; /** 強制延遲指定時間後再執行 * * 若 target DOM 更新自動偵測無效,可以使用此參數 */ delay?: number; } export function useTextHighlight( keyword: MaybeRefOrGetter, options: UseTextHighlightOptions = {}, ) {} ``` ### 目標元素與樣式 接著取得目標元素並產生唯一名稱與樣式: ```ts const highlightName = `highlight-${Math.random().toString(36).slice(2)}` const targetEl = computed(() => { const target = toValue(options.target) if (!target) { return document.documentElement } if (target instanceof Element) { return target } if (target.$el instanceof HTMLElement) { return target.$el } throw new Error('Invalid target') }) const style = computed(() => { const background = toValue(options.background) ?? '#ffeb3b' const color = toValue(options.color) ?? 'inherit' return `::highlight(${highlightName}) { background: ${background}; color: ${color}; }` }) useStyleTag(style) ``` [`useStyleTag`](https://vueuse.org/core/useStyleTag/) 是 VueUse 的功能,可以動態產生 ` ``` 再也不用擔心黏不住了!%✧⁑。٩(ˊᗜˋ\*)و✧⁕。% ## 總結 🐟 完整程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/web/directives/v-sticky.ts) * CSS sticky 好用,但是有限制 * 使用 JavaScript 實現萬用 sticky 效果,突破 CSS sticky 限制 目前尚未考慮 x 軸的 sticky,有興趣的朋友可以自己改改看喔。%( ´ ▽ \` )ノ% --- --- url: /column-chill-components/01-origin.md description: 小廢物們背後的技術亮點 --- ![chill-components](/chill-components.webp){.cover} # 酷酷元件背後的星點 - EP01:前言 當初開發酷酷元件的初衷很單純:「一半是為了好玩,一半則是為了技術的練習與實驗」。 雖說這些元件多半只是「小廢物」,除了偶爾能在生成式藝術的背景裡露個臉,工作上難得派上用場。 然而背後所蘊藏的技術、設計思維與實作概念,卻意外的有用,甚至成為探索技術邊界的鑰匙。 在這個專欄中,我將分享這些元件背後的技術亮點,並介紹它們如何在實際應用中發揮作用。%(・∀・)9% 也歡迎大家留言、私訊或[寫信](mailto:hi@codlin.me)給我,分享你對這些元件的看法或使用經驗喔。%(\*´∀\`)~♥% 技術名稱同[酷酷元件的技術星圖](https://chillcomponent.codlin.me/tech-starmap.html),歡迎大家來探索。 --- --- url: /blog-vue/about-vue-component-splitting.md description: 關於 Vue 元件拆分那檔事 --- ![img](/img.webp){.cover} # 關於 Vue 元件拆分那檔事 以下情況不考慮到處複用 * 此業務邏輯元件只出現第一次:除非 UI 有此元件設計系統 * 只有外觀長得像,但是 domain 完全不相關 ## 總結 🐟 --- --- url: /blog-vue/about-vue-data-flow.md description: Vue 資料流實作筆記,還請大家多多指教 (*´∀`)~♥ --- ![about-vue-data-flow](/about-vue-data-flow.webp){.cover} # 關於 Vue 資料流那檔事 實際開發時,常常會遇到怎麼規劃資料流的問題。 ::: tip 強烈推薦 TypeScript 有 TypeScript 協助檢查,直接少一大半問題 %(´,,•ω•,,)% ::: 此文筆記了一些曾經遇過的情境,不是規範指南,希望大家可以多多討論,一起進步。%(\*´∀\`)~♥% 可能某天會發現寫法不好、考量不夠周到,所以本文會持續不定期更新。%(´,,•ω•,,)% ::: tip 軟體開發沒有銀彈 [軟體開發沒有銀彈](https://zh.wikipedia.org/zh-tw/%E6%B2%A1%E6%9C%89%E9%93%B6%E5%BC%B9),沒有絕對的對錯,只有適不適合,實務上要根據專案規模、需求持續調整。 ::: 接下來讓我們根據情境或 Vue API (Vue 3)來討論資料流的概念與實作吧。%੭ ˙ᗜ˙ )੭% ## 依 API Vue 中基本傳遞資料的方式有 Prop(emit)、Provide、Store 其實官方文件已經有很詳細的[說明](https://vuejs.org/v2/guide/components.html#Passing-Data-with-Props),這裡只是根據實務經驗整理一下。 我自己列了一個簡易的流程圖,簡化思考。 ```mermaid --- title: Vue 的 Prop、Provide、Store 使用時機 --- flowchart TD A((開始)) --> B{是否跨多個元件?} B --否--> R1 B --是--> C{皆為兄弟元件嗎?} C --是--> R2 C --否--> D{中途有多個元件 只為轉發而宣告 Prop 嗎?} D --否--> R1 D --是--> E{傳遞方向可預期嗎? 例:僅有後代元件使用} E --是--> R3 E --否--> F{狀態的用途範疇明確嗎? 例如:User 登入資訊} F --是--> R4 F --否--> 請重新探討架構 R1[Prop] R2[使用父元件管理 Prop] R3[Provide] R4[Store] ``` ### 經典的 Prop Prop 的目的在於元件不用管外部狀態如何,只關心內部資料如何使用。 透過固定的資料定義,讓元件可以任意拔插。 當然實作上很常遇到「隨著各種追加需求,參數瘋狂增加,為了向下相容,導致參數越來越歪」的問題。 這個未來有機會再另開文章,筆記一下元件設計問題。...(›´ω\`‹ ) ::: tip Vue 3.5 之後可以直接解構 Prop 以前解構 Prop 會失去響應性,只能乖乖寫 `props.xxx` 或是用 `toRefs`。 3.5 起 [Reactive Props Destructure](https://github.com/vuejs/rfcs/discussions/502) 轉正並預設啟用,直接解構就好,順便還能寫預設值。 ```vue ``` 不過要注意解構出來的變數本質是 getter,丟進 `watch` 或 composable 時記得包成 getter,例如 `watch(() => count, ...)`。%(´,,•ω•,,)% ::: ### 甚麼時候使用 store? 這裡的 store 指的是 Vuex、Pinia 這類的狀態管理套件,其資料為全域共享。 除了全域共享外,個人覺得 store 與 Prop 最大的不同在於:「在元件內部依賴指定資料」 意思是此元件不能獨立運作,必須依賴 store 才能正常運作。 好不好需要看情況討論,我的心得是: * 基礎元件(或稱 [Atom 元件](https://medium.com/@kevinkurniawan97/atomic-design-with-vue-fa1b50a1251e))不要依賴應用領域狀態 Button、Input 這類基礎元件內部不要依賴 User 資訊、購物車這種業務資料或外部 API,最好使用 Prop 傳遞 * 傳遞深度未知、需全域共享且領域清晰的資料才用 store User 登入資訊、購物車資訊等等 如果需共享的資料來自於外部 API,也可以考慮使用 [Tanstack Query](https://tanstack.com/query/v5/docs/framework/vue/overview#motivation)(舊稱 Vue Query)或 [Pinia Colada](https://pinia-colada.esm.dev/),不只省去 store,還有很多實用功能。%ˋ( ° ▽、° )% Pinia Colada 是 Vue 生態自己長出來的方案,比 Tanstack Query 輕,也與後面會提到的 Data Loader 官方整合。API 相當接近,官方甚至有[遷移指南](https://pinia-colada.esm.dev/cookbook/migration-tvq.html)可以參考。 #### Vue 3 不需要 store? 有看過有人說「Vue 3 的響應式系統配合 ES Module,就可以共享資料了,根本不需要使用 store」 這個可以分成兩個部分來看: * 專案不一定需要 store * 我不想要用 Pinia、Vuex 作為 store management 方案 不是每個專案規模都需要 store,如果網頁只是一個很簡單的一頁式活動頁面,還真的不需要。 簡單專案中使用 store,反而可能會讓專案變得更複雜。 但是「使用 Pinia、Vuex 以外的 store management 方案」,這個就需要思考了。 的確可以「Vue 3 響應式系統配合 ES Module」實現資料共享,我在[酷酷元件的落雪元件](https://chillcomponent.codlin.me/components/bg-snow/)中就是這樣實作。 這麼做是因為此為內聚性高的獨立元件,若要求使用者在使用此元件前,必須先安裝 Pinia,這樣就太過於繁瑣了。 但若是一般網頁專案,在不確定專案會不會持續增長的情況下,還是建議使用 Pinia。 因為隨著專案成長,你可能會開始需要解決以下問題: * 如何除錯 * [HMR 問題](https://pinia.vuejs.org/cookbook/hot-module-replacement.html)(保留、更新資料等等) * [如何測試](https://pinia.vuejs.org/cookbook/testing.html) * 如何加入 plugin * 追蹤資料變更 甚至 SSR 相關問題等等。 以上問題解決後會發現:恭喜!重工了一個 Pinia!%ヽ(́◕◞౪◟◕‵)ノ% 但是沒辦法使用 Pinia 現有生態系(各種外掛、工具),後續接手的人可能會很痛苦。 Pinia 已設計得相當簡易了,除非你的情境真的很不適合 Pinia,不然還是乖乖用吧。%(´● ω ●\`)% ### Provide? Provide 的用法在[官方文件](https://vuejs.org/guide/components/provide-inject)解釋得很清楚。 主要目的是解決層層傳遞的問題,但是也有與 store 類似的問題,就是依賴耦合問題。 ::: tip 從[實作的角度](https://github.com/vuejs/pinia/blob/2071db285569a3119cf62c5be16a63fb7b681b1d/packages/pinia/src/createPinia.ts#L29)來看,store 就是依靠 Provide 從 App 層注入,實現所有元件資料共享功能。 ::: 我自己只有在「依賴方向明確,深度未知的強耦合的元件」才會使用 Provide。 例如: #### Quasar 的 QForm Quasar 的 [`QForm`](https://quasar.dev/vue-components/form#child-communication) 與 [`useFormChild`](https://quasar.dev/vue-composables/use-form-child),其內部實作就是使用 Provide。 只要 `QForm` 內部含有 `QField` 或使用 `useFormChild` 之元件,無論元件多深層,提交時若有錯誤,都會自動阻擋,相當方便。 #### 酷酷元件的 card-futuristic 此[元件](https://chillcomponent.codlin.me/components/card-futuristic/)由多個子元件組成,父子元件為強耦合關係。 作為容器的父元件使用 Provide 提供綁定邏輯給子元件,[子元件綁定](https://gitlab.com/side_project/chill-component/-/blob/main/src/components/card-futuristic/use-card-part.ts)完成後由父元件統一調度動畫。 藉此實現酷炫、複雜的動畫效果。 #### 遞迴元件 深度未知最特殊的情境是遞迴元件,例如 Tree、多層選單、留言串。 這種元件的深度由資料決定,你根本不知道會長幾層。使用者多加一層分類,Prop 鏈就得跟著長一節。 ```vue ``` 節點自己的資料(`node`)用 Prop 沒問題,它本來就只屬於這一層。 但是「目前選到哪個節點」、「點擊後要通知誰」這種**整棵樹共用**的東西,用 Prop 傳就是災難。%( ˘ω˘ )% 狀態本人住在根節點。 ```vue ``` `tree-node` 則是收下來,用一份、再往下傳一份。 ```vue ``` 這樣寫可以動,但是事件得一層一層冒上去,第 8 層的點擊真的會跑 8 次 `emit` 才抵達根節點。 不過層層 `emit` 不算甚麼大問題,比較大的問題其實是重繪。 假設再追加拖曳排序,多了一個 `draggingId`,真正在意的只有被拖的節點與游標底下那個。 但是它從根節點一路往下傳,於是拖曳過程每移動一下,整棵樹就重繪一次。%( ゚д゚)% 改用 Provide 就沒這些問題了,先定義一把有型別的鑰匙。 ```ts // tree-key.ts import type { InjectionKey, Ref } from 'vue' export const treeKey = Symbol('tree') as InjectionKey<{ selectedId: Readonly>; select: (id: string) => void; }> ``` 根節點提供一次。 ```vue ``` 節點自己拿。 ```vue ``` * 點擊直接呼叫 `select`,不用冒泡,也就沒有轉發鏈這回事 * 每個節點各自訂閱用得到的東西,`draggingId` 只會更新真的有讀它的節點 * 型別由 `InjectionKey` 一路帶到底,跟 Prop 版本一樣完整 #### Provide 注意事項 Provide 用起來很快樂,也要注意一些細節,不然很容易翻車。%⎝(・ω´・⎝)% ##### 善用 `InjectionKey` 而不是字串 字串鍵沒有型別,打錯也不會有人告訴你。 ```ts import type { InjectionKey, Ref } from 'vue' export const cartKey = Symbol('cart') as InjectionKey<{ itemList: Readonly>; addItem: (item: CartItem) => void; }> ``` `provide` 與 `inject` 用同一把鑰匙,型別自動對上,IDE 也會幫忙提示。 ##### 資料提供 `readonly`、變更走 function 直接把 ref 丟出去,等於任何後代都能改,出事時完全不知道誰動的手。 ```ts import { readonly, ref } from 'vue' const itemList = ref([]) provide(cartKey, { itemList: readonly(itemList), // [!code ++] addItem, }) ``` 想改資料就呼叫提供者給的 function(本例子為 `addItem`),變更邏輯集中在一處,要加驗證、加 log 都方便。 如此 Provide 才算是「有作用域的依賴注入」,不然它就只是一個藏起來的全域變數而已。%(´,,•ω•,,)% #### 可是隱式依賴很可怕 也有人認為 Provide 會有「隱式依賴」問題。 元件檔案打開來看不出它需要什麼,必須知道自己被誰包著才能跑,抽出來重用就直接爆炸。%╭(°A ,°\`)╮% 這派的主張是寧可忍受 Prop 一路傳,也要讓依賴顯式可見,至少改壞的時候 TypeScript 會叫。 我覺得兩邊都有道理,實務上的折衷是把 inject 包成 composable,並且想清楚「缺了它會怎樣」。 如果元件沒有它就完全沒有意義,則應該附上明確的錯誤訊息。 ```ts function useCart() { const cart = inject(cartKey) if (!cart) { throw new Error('useCart 必須在 cart-provider 內部使用') } return cart } ``` 但是更理想的情況是準備回退方案,讓元件少了 provider 也能自己撐著。 `inject` 本來就支援預設值,第三個參數表示「預設值是個 factory」,這樣每個元件才會拿到自己的一份,不會共用同一個物件。 ```ts function useTree() { return inject(treeKey, () => createStandaloneTree(), true) } ``` 前面提到的 `useFormChild` 就是這種設計,欄位放在 `QForm` 外面照樣能用,只是不參與整體驗證而已。 ### 命令式資料流:defineExpose 與 useTemplateRef 前面聊的都是宣告式,資料變了畫面跟著變,但是有些情境天生就是命令式。 例如「按下按鈕讓表單重置」、「跳到影片第 30 秒」、「開啟時自動 focus 輸入框」。 這時候可以由子元件 `defineExpose`,父元件用 `useTemplateRef` 拿到實例後直接呼叫。 ```vue ``` ```vue ``` ::: tip `useTemplateRef` 是 Vue 3.5 新增的 API,比以前「宣告同名 ref」的寫法更明確,型別也更完善。 ::: 判斷情境大概如下: * **動作型可以**,`focus()`、`reset()`、`play()`、`scrollTo()` 這種一次性動作 * **狀態查詢型不行**,如果你在父元件寫 `formRef.value.isValid`,那就該改用 Prop 或 emit 因為狀態查詢代表資料流反了,而且 template ref 不保證何時有值,掛載前後拿到的東西不一樣,很容易踩雷。%( ›´ω\`‹ )% ## 依情境 接下來根據情境來討論資料流的概念與實作。 ### 兄弟元件溝通 如果資料只需在兩三個元件間共享且傳遞深度很淺,就不建議使用 store,而是由父元件提供資料。 若元件很深層則可在父元件使用 Provide 提供資料。 例如:拆分成多個 tab 的表單,由父元件提供資料給各分頁即可。 ```vue ``` ### 表單狀態該由誰持有? 上面那個範例只傳了 `data`,但是實務上表單還有一堆狀態,例如驗證錯誤、哪些欄位被動過、有沒有未儲存的變更。 我自己的分法是這樣。 * **驗證狀態**由容器持有,因為送出時要一次看全部 * **dirty / touched** 由欄位自己判斷,容器只負責彙總 ::: tip dirty 與 touched 是甚麼? 表單套件常見的兩個詞,dirty 指值被改過(跟初始值不同),touched 指使用者碰過(聚焦後又離開)。 dirty 看值,touched 看互動,所以離開頁面要不要跳「尚未儲存」看 dirty,錯誤訊息要不要現在就顯示則看 touched,避免一進畫面就滿江紅。%( ˘ω˘ )% ::: 如果每個分頁自己記自己的錯誤,送出時容器就得反過來去撈每一頁的狀態,通常會變成這樣。 ```ts const step1Ref = useTemplateRef('step1') const step2Ref = useTemplateRef('step2') function submit() { if (!step1Ref.value?.validate()) return if (!step2Ref.value?.validate()) return // ... } ``` 這就是前面說過的「狀態查詢型」命令式操作,資料本來該由下往上送,現在變成上面伸手去撈。 而且分頁一旦改成 `v-for` 動態渲染,容器還得自己維護一份 ref 清單,愈長愈歪。%( ›´ω\`‹ )% 反過來由容器提供註冊機制,欄位掛載時自己登記,送出時容器一次收割,就乾淨多了。 這也正是前面提到 Quasar `QForm` 與 `useFormChild` 的作法,不想自己造輪子的話,vee-validate、FormKit 這類套件也是把這件事包好了。%( ´ ▽ \` )b% ### 元件內更新或觸發事件不要優先使用 watch 在使用 watch 修改或觸發事件前,先想想有沒有其他方案,真的沒有才用 watch。 詳細說明請參考先前文章:[Vue watch 前先小等一下](https://codlin.me/blog-vue/hang-tight-for-a-sec-before-you-start-watch) ### v-model 與資料流 大家應該都是知道 `v-model` 其實是個語法糖。 元件結構單純時,使用 `v-model` 很容易且簡單,且元件內部實作使用 `defineModel` 爽到不行。%(´,,•ω•,,)% 但是資料開始複雜時,通常不會希望部分資料變更就立即向外部傳遞。 因為若互動元件有複雜的操作邏輯(例:拖動排序、表單驗證),多餘的響應與傳遞,除了性能損耗外,可能會導致操作邏輯出現不自然的跳動或其他副作用。 這個時候就可以考慮把 `v-model` 拆成回 `:model-value` 與 `@update:modelValue` 分別處理。 自行處理資料流,選擇更恰當的時機觸發響應並傳遞資料。%( •̀ ω •́ )✧% ::: tip 不適合使用 `defineModel` 的場合,我自己會選擇使用 `shallowRef` 配合 `triggerRef` 實作。 除了減少不必要的副作用,因為 `triggerRef` 實際由自己掌控,也更容易追蹤資料變更。%ԅ(´∀\` ԅ)% ::: ### Composable 共享資料? 有人說可以使用 Composable 共享資料,只要把資料放在 Composable 的 function 外面就可以,像這樣: ```ts const data = ref(0) // [!code ++] export function useSharedData() { const data = ref(0) // [!code --] return { data } } ``` 實際上這個與使用 ES Module 共享資料的方式相同,[官方文件](https://vuejs.org/guide/scaling-up/state-management#simple-state-management-with-reactivity-api)也有提到這種用法。 但是這樣做一樣會遇到先前提到的問題(如何除錯、追蹤資料變更等等)。 ::: tip 這樣寫有另一個風險,不過只有 SSR 會遇到,有另外寫了一篇詳細筆記,有興趣的朋友可以參考[AI 可能會寫的 Vue Composable 地雷,你踩過了嗎?](https://codlin.me/blog-vue/vue-composable-outer-variable-trap) ::: 所以我自己的約定是,共享資料統一交給 store,Composable API 專注於組合、邏輯複用。 開發時 store 一定是全域共享,Composable API 則可放心使用,不用怕污染。 避免到處都有全域共享資料,後續接手的人可能會一頭霧水。 如果 Composable 需要共享資料,則在內部使用 store 即可。 ::: tip Pinia 也可以寫成 [setup 風格](https://pinia.vuejs.org/cookbook/composables.html#Setup-Stores)(或稱 Composable API 風格),所以若 Composable 需要共享資料,可以直接使用 setup store 即可。 ::: #### 善用 [`MaybeRefOrGetter`](https://vuejs.org/api/utility-types#maybereforgetter) 傳遞 Composable API 參數時,若同時支援傳入一般數值、`ref` 或 `getter`,可以增加彈性。 不過大家可能會說:「這樣要判斷 3 種情境,很麻煩欸!%(°口°〃)%」 這時候可以使用 Vue 提供的 [`toValue`](https://vuejs.org/api/reactivity-utilities#tovalue),輕鬆解決。 ::: tip v3.3 版本前的朋友,可以考慮使用 VueUse 提供的 [`toValue`](https://vueuse.org/shared/toValue/#tovalue)。 ::: 所以有甚麼好處呢?例如 VueUse 的 [`useIntervalFn`](https://vueuse.org/shared/useIntervalFn/)。 用法與 `setInterval` 相同: ```ts useIntervalFn(() => { // 定時任務 }, 1000) ``` 但是如果你把 `1000` 改成 ref 或 getter,就可以動態改變間隔時間。 ```ts const interval = ref(1000) useIntervalFn(() => { // 定時任務 }, interval) ``` 這樣就會在 `interval` 變更時,自動調整間隔時間。 官方文件也有這方面的說明,可以參考[這裡](https://vuejs.org/guide/reusability/composables#accepting-reactive-state)。 ### 切換頁面傳值 切換 router 有時候需要傳遞資料,一般來說優先使用以下方法: * query string 或 path params * Vue Router 之 [`Passing Props`](https://router.vuejs.org/guide/essentials/passing-props.html) 不過有時候可能會需要傳遞複雜一點的物件資料。 例如:從「商品列表」轉跳至「商品細節」,除了 path params 加上商品 ID 外,如果同時傳遞列表頁中的商品資料,細節頁面就不用特別在讀取一次商品資料。 這個時候就可以使用 `history.state` 來傳遞資料。 商品列表頁跳轉邏輯可能如下: ```ts async function toProductDetail(product: Product) { router.push({ name: 'product-detail', state: { /** * state 不能傳遞複雜類型,structuredClone 確保轉為一般物件 * * [Release Note](https://github.com/vuejs/router/releases/tag/v4.1.0) */ product: structuredClone(product), } }) } ``` 商品細節頁取得資料邏輯可能如下: ```ts const productId = useRouteParams('id') function isProduct(data: any): data is Product { // 驗證資料是否為 Product } const { isLoading, state: product, } = useAsyncState(async () => { const data = history.state?.product if (isProduct(data)) { return data } // 沒有 product 資料,從 API 取得 return getProduct(productId.value) }, undefined) ``` ::: tip [history.state 相關功能正在 RFC 中](https://github.com/vuejs/rfcs/discussions/400),未來可能新增更多 API 支援,大家可以關注關注。 ::: ### 傳遞成本也是資料流的一部分 最後補一個容易被忽略的角度,資料流的選擇會直接影響效能。 `ref` 與 `reactive` 都是深層響應,丟一個上萬筆的陣列進去,Vue 會很敬業地幫你把每一層都變成 Proxy。 如果這份資料只會整包替換,不會單獨改某一筆,那用 `shallowRef` 就好。 ```ts const itemList = shallowRef([]) // 整包換掉,一樣會觸發更新 itemList.value = await fetchItemList() ``` 地圖實例、圖表實例、Three.js 物件這種第三方實例,則用 `markRaw` 明確告訴 Vue 不要碰。 ::: tip `shallowRef` 與 `ref` 的差別,可以參考先前文章 [Vue 的 shallowRef 是啥?和 ref 有甚麼差別?](https://codlin.me/blog-vue/vue-shallow-ref-and-ref) ::: 順帶一提,Vue 3.6 把 `@vue/reactivity` 改為基於 [alien-signals](https://github.com/stackblitz/alien-signals),開銷更低、在龐大元件樹下也更耐操。%ヾ(◍'౪\`◍)ノ゙% ### 進階資料載入 進階取得資料的方式除了剛剛提到的 [Tanstack Query](https://tanstack.com/query/v5/docs/framework/vue/overview#motivation),Vue Router 團隊還開發了 [Data Loaders](https://router.vuejs.org/data-loaders/)。 Data Loader 乍看之下 API 與 Tanstack Query 相當接近,最大的差別在於 Data Loader 與 Vue Router 深度整合,將資料載入整合至導航週期。 可以在路由守衛中取得資料,執行更複雜的導航邏輯。 詳細說明可以參考作者的[說明影片](https://www.youtube.com/watch?v=VkSGxuAJwv8\&ab_channel=%E5%89%8D%E7%AB%AF%E5%9C%88),有相當深入淺出的介紹。 ::: warning 這段已經過期囉 原本這裡寫著「截至 2025/01/15,Data Loader 還在 RFC 階段」,現在情況不一樣了。 `unplugin-vue-router` 已於 2026/02/24 [併入 vuejs/router](https://github.com/posva/unplugin-vue-router),倉庫封存,typed routes 與 Data Loader 都進了 Vue Router 核心。 想看實際用法的話,可以參考先前文章[用 Vue Router 的 Data Loader 解決 fetch 瀑布流問題](https://codlin.me/blog-vue/solve-fetch-waterfall-with-data-loader)。%( ˘ω˘ )% ::: ## 總結 🐟 * 元件優先關心 Prop 來的資料,基礎元件不要依賴業務領域狀態 * 依賴方向明確、深度未知、強耦合才用 Provide,搭配 InjectionKey 與 readonly,缺了 provider 要明確報錯或給回退方案 * 遞迴元件的共用狀態交給 Provide,省下層層 emit 與整棵樹重繪 * 命令式資料流只拿來做動作,會想查狀態就代表資料流反了 * 表單的欄位值與驗證狀態由容器持有,欄位不要自己留副本 * 善用 MaybeRefOrGetter,增加 Composable API 彈性 * 跳轉頁面傳遞資料還有 history.state 這個選擇 以上是我自己的心得,不過一樣老話一句,如果你很清楚你在做甚麼,也沒什麼一定不可以就是了。%(・∀・(・∀・(・∀・\*)% 大家如果有不同的想法,還請不吝告訴我。%(´▽\`ʃ♡ƪ)% 也推薦大家閱讀以下文章: * [Effective State Management](https://michaelnthiessen.com/effective-state-management) * [Vue 官方文件,Cross-Request State Pollution](https://vuejs.org/guide/scaling-up/ssr#cross-request-state-pollution) * [Pinia Colada,Why?](https://pinia-colada.esm.dev/why.html) * [Vue 官方文件,Provide / Inject](https://vuejs.org/guide/components/provide-inject) --- --- url: /public/minespace/sounds/CREDITS.md --- # 音效來源 這個資料夾裡的音檔各有出處與授權,加新檔案時請一併補在這裡。 沒有記載的檔案是先前留下的,來源待補。 ## wheat-wind.mp3 * **原名**:WIND, Wind through Wheatfield * **作者**:Uganda * **來源**: * **授權**:CC0 1.0(公眾領域貢獻,不需標示、可商用) * **原始描述**:風穿過麥田,帶著細碎的爆裂聲,背景有幾聲鳥。 * **處理**:原檔 1 分 44 秒。取第 10 到 70 秒, 再把第 6 到 10 秒交叉淡入到結尾,做成 60 秒的無縫循環 (`acrossfade`,兩端收在同一個取樣點上,接回去聽不出接縫)。 接著補 19 dB 增益並過限幅器(`alimiter` 的 level 要關掉, 它預設會把輸出自動正規化回 0 dB,壓多少都沒用), 最後轉成單聲道 32 kHz / 56 kbps。 * **音量**:平均 −24.0 dB、峰值 −0.9 dB * **加音檔一定要量音量** 這裡踩過一次:先前選的另一份錄音(szelestamas / 620099, 質地其實更準、而且完全沒有鳥)平均只有 −49.8 dB, 比這個資料夾其他音檔輕了將近 30 dB。規格全部對齊、檔案也正常, 但乘上音源的 volume 與距離衰減之後等於沒有聲音。 這個資料夾的音檔平均落在 −19 到 −25 dB 之間,新加的要落進這個範圍。 量法:`ffmpeg -i 檔名.mp3 -af volumedetect -f null -` * **用在**:麥浪田的麥稈摩擦(`wheat-rustle-*`) ## jigoku-lava.mp3 * **原名**:Lava Loop 3 * **作者**:Fission9 * **來源**: * **授權**:CC0 1.0(公眾領域貢獻,不需標示、可商用) * **處理**:原檔為 48 kHz / 24 bit / 立體聲 WAV, 轉成單聲道 32 kHz / 56 kbps MP3 以對齊本資料夾其他音檔的規格。 長度維持原本的 39.6 秒未裁切——作者標明它完美循環, 裁掉任何一段都會在接點留下一下可以聽出來的頓挫。 * **用在**:地獄谷裂縫裡的熔岩(`jigoku-churn-*`) --- --- url: /column-shader-notes/01-shader-intro.md description: 從 JavaScript 開發者的角度出發,一起來看看 Shader 的核心概念吧!(「・ω・)「✧ --- ![shader-notes](/shader-notes.webp){.cover} # 鱈魚的 Shader 筆記 - EP01:甚麼是 Shader 大家好,我是鱈魚。%(。・∀・)ノ゙% 其實大部分的視覺效果或特效用 SVG Filter、Canvas2D API,甚至用 HTML + CSS 也能實現 曾經我也很熱衷使用[純 CSS 實現各種視覺效果](https://codepen.io/collection/GoqZzQ),所以為甚麼還要學 Shader 呢? 原因很單純,就是早晚會撞到效能天花板。%( ˘・з・)% 沒辦法強迫大家用超強機器,效能要好只能靠 GPU,而 GPU 只認識 Shader,所以還是只好乖乖來研究 Shader 惹。%ԅ( ˘ω˘ԅ)% 剛好最近很常做[基於 Shader 的效果](https://chillcomponent.codlin.me/components/bg-starry-sea/),想說還是來寫個筆記,以免自己金魚腦,常常忘記。 如果你已經熟悉 JavaScript,那你離 Shader 只差幾個關鍵觀念的距離。 讓我們從 JS 開發者的視角,一步步搞懂 Shader 到底在幹嘛。%(•̀ω•́)✧% 路人:「JS 不熟,只會框架怎麼辦?%(◜௰◝)%」 鱈魚:「那就來幫我點廣告,一起熱鬧熱鬧也好 %(„ಡωಡ„)%」 ::: tip 關於 Shader 語言 瀏覽器有兩套圖形 API,各自有不同的 Shader 語言: * **WebGL** → **GLSL**(OpenGL Shading Language) * **WebGPU** → **WGSL**(WebGPU Shading Language) 兩者核心觀念(渲染管線、Vertex/Fragment Shader、Uniform 等)幾乎一樣,只是語法不同。 本文以 **WebGL + GLSL** 為主,未來有機會再聊聊 WGSL。 ::: ## GPU 與 CPU 的思維差異 Shader 與一般的 JS 程式有個關鍵差異要注意。%( •̀ ω •́ )✧% 一般 JS 程式走 CPU 的思路:「一步一步,一個一個來」,但 GPU 是「一口氣處理所有像素」。 鱈魚:「和鱈魚和油魚一樣,差一個字差很多 %(・∀・)9%」 路人:「隨便拉,看起來都很肥 %(。-\`ω´-)%」 鱈魚:「我說那個尊重呢?%(╥ω╥\`)%」 甚麼意思哩?可以看看以下示意圖。 CPU 要一格一格慢慢塗,GPU 則是一口氣全部塗完。%( •̀ ω •́ )✧% ## Shader 是什麼? 瀏覽器畫畫面時,背後有一條「渲染管線」。 Shader 就是管線中**可以用程式自訂**的步驟。 WebGL2 只有兩種 Shader: | 類型 | 用途 | 執行頻率 | | --------------- | ------------------ | -------------- | | Vertex Shader | 決定每個頂點的位置 | 每個頂點跑一次 | | Fragment Shader | 決定每個像素的顏色 | 每個像素跑一次 | WebGPU / OpenGL 4.x+ / Vulkan / DirectX 還有其他 Shader 類型: | 類型 | 用途 | | ------------------- | ----------------------------------------------------------- | | Geometry Shader | 可以新增或刪除頂點,例如把一個點變成一個三角形 | | Tessellation Shader | 把粗糙的網格細分成更多三角形,用於地形、曲面等 | | Compute Shader | 通用 GPU 運算,不一定和圖形渲染有關(物理模擬、粒子系統等) | | Mesh Shader | 較新的概念,取代 Vertex + Geometry 的組合,效能更好 | 簡單來說,Vertex Shader 圈出哪些像素可以畫,接著讓 Fragment Shader 決定像素要畫甚麼顏色。%(*´ω`)人(´ω`*)% ### Vertex Shader:決定形狀與位置 所以 Vertex Shader 怎麼圈出哪些像素可以畫呢?答案是給他一堆點(頂點)。 路人:「可以撐起地球那種嗎?」 鱈魚:「那是支點 %(́⊙◞౪◟⊙‵)%」 在 JavaScript 準備好頂點座標後,GPU 會對每個頂點各跑一次 Vertex Shader。 ```glsl attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); } ``` `a_position` 是從 JavaScript 傳進來的頂點座標(Attribute),每個頂點跑一次。 `gl_Position` 是 GLSL 的內建變數,用來告訴 GPU「這個頂點放在哪」。`vec4` 的四個值分別是 x、y、z、w。 簡單說,z 控制深度(誰擋住誰),w 和透視投影有關(近大遠小)。2D 用不到這些,所以 z 固定 `0.0`、w 固定 `1.0` 就好。詳細原理會在 [EP02](./260407.02-vertex-shader) 說明。 準備好頂點資料後,JavaScript 會呼叫 `gl.drawArrays(mode, first, count)` 告訴 GPU「開始畫」。 第一個參數 `mode` 決定 GPU 怎麼連接這些頂點。 常見的繪圖模式有: **點** `gl.POINTS`:每個頂點畫一個點,大小由 `gl_PointSize` 決定。 **線** `gl.LINES`:每 2 個頂點連一條線。 **三角形** `gl.TRIANGLES`:每 3 個頂點組一個獨立三角形,最通用。需要 6 個頂點才能拼出四邊形。 本文的互動編輯器用的是 `gl.TRIANGLES`,用兩個三角形拼出一個覆蓋整個畫面的四邊形: 有了這個全螢幕四邊形,Fragment Shader 就能在畫面上的每個像素執行。 現在看不太懂沒關係,[EP02](./260407.02-vertex-shader) 我們會詳細介紹 Vertex Shader,以及如何用它來繪製各種圖形。%ヾ(◍'౪\`◍)ノ゙% ### Fragment Shader:決定像素顏色 Vertex Shader 決定了頂點圍出的區域後,GPU 會自動把那個區域內的每個像素都交給 Fragment Shader 處理。 不同的繪圖模式,Fragment Shader 執行的範圍也不同: * **`POINTS`**:每個頂點周圍 `gl_PointSize × gl_PointSize` 的正方形區域 * **`LINES`**:每條線段涵蓋的像素 * **`TRIANGLES`**:三角形內部的所有像素 本文用兩個三角形拼成全螢幕四邊形,所以 Fragment Shader 會在畫面上的**每一個像素**都跑一次。 每個像素執行時,可以透過 `gl_FragCoord.xy` 知道「我是畫面上哪一個像素」,然後透過 `gl_FragColor` 決定最終顏色。 ```glsl precision mediump float; void main() { gl_FragColor = vec4(0.2, 0.6, 1.0, 1.0); } ``` `precision mediump float` 是 WebGL 的 Fragment Shader 必須宣告的浮點精度。有三種可以選: | 精度 | 用途 | | --------- | -------------------------- | | `lowp` | 省電,但容易出現色階斷層 | | `mediump` | 夠用於大部分 2D 效果 | | `highp` | 精準計算距離、座標時必須用 | ## GLSL 語法速成 WebGL Shader 使用的語言叫 **GLSL**(OpenGL Shading Language),語法長得跟 C 很像。 ### 型別對照 | JS / TS | GLSL | 說明 | |---------|------|------| | `number` | `float` | 浮點數(GLSL 沒有整數混用,`1` 要寫成 `1.0`) | | `number` | `int` | 整數 | | `boolean` | `bool` | 布林值 | | — | `vec2` | 二維向量 `vec2(x, y)` | | — | `vec3` | 三維向量 `vec3(r, g, b)` 或 `vec3(x, y, z)` | | — | `vec4` | 四維向量 `vec4(r, g, b, a)` | ### 向量操作 向量是 GLSL 最強大的特色之一,一行就能做到 JS 需要好幾行的事。 ```glsl vec3 color = vec3(1.0, 0.5, 0.3); // Swizzling:隨意取出分量重組 vec2 rg = color.rg; // vec2(1.0, 0.5) vec3 bgr = color.bgr; // vec3(0.3, 0.5, 1.0) // 向量運算:整組一起算 vec3 a = vec3(1.0, 2.0, 3.0); vec3 b = vec3(4.0, 5.0, 6.0); vec3 c = a + b; // vec3(5.0, 7.0, 9.0) vec3 d = a * 2.0; // vec3(2.0, 4.0, 6.0) ``` 同樣的邏輯用 JS 來寫的話: ```js const color = [1.0, 0.5, 0.3] // 取出分量要自己 index const rg = [color[0], color[1]] const bgr = [color[2], color[1], color[0]] // 向量運算要用迴圈或 map const a = [1.0, 2.0, 3.0] const b = [4.0, 5.0, 6.0] const c = a.map((value, i) => value + b[i]) const d = a.map((value) => value * 2.0) ``` ### 常用內建函式 | GLSL 函式 | 對應概念 | 說明 | |-----------|---------|------| | `mix(a, b, t)` | 線性插值 | `a * (1-t) + b * t`,`t` 在 0~1 之間 | | `step(edge, x)` | 階梯函式 | `x < edge ? 0.0 : 1.0` | | `smoothstep(a, b, x)` | 平滑階梯 | 在 a~b 之間平滑過渡 | | `length(v)` | 向量長度 | `sqrt(v.x² + v.y²)` | | `clamp(x, min, max)` | 限制範圍 | 等同 `Math.min(Math.max(x, min), max)` | | `sin(x)` / `cos(x)` | 三角函式 | 和 JS 的 `Math.sin` 一樣 | | `abs(x)` | 絕對值 | 和 JS 的 `Math.abs` 一樣 | ## Fragment Shader:你的第一個像素函式 理論夠多了,讓我們來動手看看吧!%(・∀・)9% Fragment Shader 核心概念相當簡單: 每個 pixel 都會問自己兩個問題: 1. **我在哪裡?** → `gl_FragCoord.xy`(像素座標) 2. **我要什麼顏色?** → `gl_FragColor = vec4(r, g, b, a)` 就醬!ヾ(◍'౪\`◍)ノ゙ 整個 Fragment Shader 說穿了就是一個函式:「給定座標,回傳顏色」。 來試試你的第一個 Shader,下方的編輯器可以直接修改程式碼,改完會即時更新畫面。 試著把 `vec4(0.2, 0.6, 1.0, 1.0)` 裡的數字改成其他值看看。 小提醒: * GLSL 的顏色值範圍是 `0.0` ~ `1.0`,不是 CSS 的 `0` ~ `255`。所以 `vec4(1.0, 0.0, 0.0, 1.0)` 就是純紅色。 * `vec4` 的第四個值是 alpha(透明度),但你可能會發現改成 `0.0` 畫面完全沒有變化。 因為 WebGL 預設把 alpha 預乘進顏色(`premultipliedAlpha`),加上沒有開啟透明混合(Blending),alpha 在這裡不會有任何效果。 * `gl_FragColor` 只能用 `vec4`,不能直接塞 `vec3` 進去,不像 JS 會自動轉型。 寫 `gl_FragColor = vec3(0.2, 0.6, 1.0)` 會直接編譯錯誤。 ### 座標正規化與 UV 直接用 `gl_FragCoord` 也可以,不過座標會是實際的像素值(例如 800、600),用起來有幾個痛點: * 畫布大小一變,效果就跑版(800px 寫好的圓心座標,換成手機 375px 就偏了) * 很多數學函式(`sin`、`smoothstep`)在 0~1 的範圍內最好用 * 別人的 Shader 範例幾乎都用正規化座標,不轉換就看不懂 所以通常我們會把座標「正規化」到 `0.0 ~ 1.0` 的範圍,這個正規化後的座標習慣叫做 **UV**。 ```glsl vec2 uv = gl_FragCoord.xy / u_resolution; ``` 這樣不管畫布多大,`uv.x` 和 `uv.y` 都會在 0 到 1 之間,同一段程式碼到處都能用。 來看看用 UV 座標做出漸層效果有多簡單。 把座標值當成顏色值,就自動產生了漸層。%(ゝ∀・)b% ## Uniform:JS 與 Shader 的溝通橋樑 到目前為止,Shader 都只是自己跟自己玩。 實際上需要從 JS 傳資料進去,例如「現在過了幾秒」或「滑鼠在哪裡」。 這時候就要用 **Uniform**。 `uniform` 這個字在 GLSL 裡是個修飾詞,代表「這個值從外部(JS)傳進來,而且**每個 pixel 都拿到同樣的值**」。 本文的互動編輯器已經幫你注入了三個常用的 uniform: | Uniform | 型別 | 說明 | |---------|------|------| | `u_time` | `float` | 從啟動開始經過的秒數 | | `u_resolution` | `vec2` | 畫布寬高(像素) | | `u_mouse` | `vec2` | 滑鼠在畫布上的位置 | 來試試用 `u_time` 做動畫效果。 ## 常見圖形 學會了基礎概念,來看看幾個經典的 Shader 圖形怎麼做。 下方的編輯器提供了幾個預設範例,可以切換來看,也可以直接修改試玩。%◝( •ω• )◟% ### 畫圓的原理 記得 Shader 繪製都要從像素的視角出發。%(「・ω・)「% 所以畫圓的重點在於判斷目前的 pixel 距離圓心有多遠。 來逐行拆解上面「圓形」預設範例的程式碼。 #### 第一步:UV 正規化 ```glsl vec2 uv = gl_FragCoord.xy / u_resolution; ``` 把像素座標除以畫布尺寸,得到 `0.0 ~ 1.0` 的 UV 座標,前面介紹過了。 #### 第二步:把原點移到畫面中心 ```glsl vec2 center = uv - 0.5; ``` UV 的原點在左下角 `(0, 0)`,減掉 `0.5` 後原點就移到畫面正中間了。 這時候座標範圍會變成 `-0.5 ~ 0.5`,中心點是 `(0, 0)`。 #### 第三步:修正長寬比 不過有個小陷阱,UV 的 x 和 y 都是 `0.0 ~ 1.0`,但畫布通常不是正方形。 假設畫布是 600×300,那 x 方向的 `0.3` 實際上代表 180px,y 方向的 `0.3` 只有 90px。用 `length()` 算出來的「圓」看起來會變成橢圓。 解法很簡單,把 x 乘上長寬比,讓 x 和 y 在像素層面等比: ```glsl center.x *= u_resolution.x / u_resolution.y; ``` 以 600×300 的畫布為例,長寬比是 `2.0`,所以 `center.x` 會拉伸為兩倍,讓 x 方向和 y 方向的距離單位一致。 這樣 `length()` 算出來的距離就會維持等比,圓就是圓了。%(•̀ω•́)✧% #### 第四步:計算距離 ```glsl float dist = length(center); ``` `length()` 就是畢氏定理(`sqrt(x² + y²)`),算出目前像素到中心點 `(0, 0)` 的距離。 #### 第五步:用 step 判斷圓的邊界 ```glsl float circle = step(dist, 0.3); ``` `step(edge, x)` 的規則很簡單:`x >= edge` 回傳 `1.0`,否則 `0.0`。 所以 `step(dist, 0.3)` 就是在問「`0.3` 有沒有 `>=` dist」: * 圓內(dist < 0.3)→ `0.3 >= dist` 成立 → `1.0` * 圓外(dist >= 0.3)→ `0.3 >= dist` 不成立 → `0.0` 也就是圓內得到 `1.0`,圓外得到 `0.0`。%( •̀ ω •́ )✧% #### 第六步:用 mix 混色 ```glsl vec3 color = mix( vec3(0.1, 0.1, 0.2), // 背景色 vec3(0.2, 0.8, 0.6), // 圓的顏色 circle ); ``` `mix(a, b, t)` 是線性插值,公式是 `a * (1 - t) + b * t`。 * `circle = 1.0`(圓內)→ 取第二個顏色,藍綠色 * `circle = 0.0`(圓外)→ 取第一個顏色,深色背景 #### 第七步:輸出 ```glsl gl_FragColor = vec4(color, 1.0); ``` 把 `vec3` 的顏色加上 alpha `1.0`,組成 `vec4` 輸出。 你可能會想問:「為甚麼第五到七步不直接用 `if-else` 就好?比 `step` + `mix` 更直覺不是嗎?」%(´・ω・\`)% ```glsl if (dist < 0.3) { gl_FragColor = vec4(0.2, 0.8, 0.6, 1.0); } else { gl_FragColor = vec4(0.1, 0.1, 0.2, 1.0); } ``` 的確更直覺沒錯,不過使用 `step` + `mix` 避開 `if` 是為了避免 **branch divergence** 的問題。 GPU 會把像素分成一小組一小組,同一組裡的像素**強制同步(lockstep),每個時鐘週期都執行同一條指令**。 ::: tip NVIDIA 叫 **warp**,32 個一組;AMD 叫 **wavefront**,64 個 ::: 如果遇到 `if-else`,warp 裡有些像素走 `if`、有些走 `else`,但大家被綁在一起沒辦法各走各的,GPU 只好**兩條路都跑**。 以畫圓為例,假設一個 warp 裡有 20 個像素在圓內、12 個在圓外: 1. 先讓 32 個像素全部執行 `if` 分支(塗藍綠色),但圓外那 12 個會被遮罩住,算完後丟棄 2. 再讓 32 個像素全部執行 `else` 分支(塗背景色),換圓內那 20 個被遮罩住 等於每個像素都跑了兩條路的指令,花了兩倍時間,`step` 是純數學運算,不需要分支,GPU 一口氣就算完了。 所以 Shader 裡盡量避免使用 `if-else`,能用純數學運算就用數學運算。%(⌐■\_■)✧% 理解了每一行之後,來看看完整的畫圓流程圖: 最終效果就是半徑內被塗成藍綠色,半徑外被塗成深色背景,整體看起來就是一個圓。%(ゝ∀・)b% ### 挑戰時間 試試看能不能改出這些效果: 1. 讓圓形跟著時間移動(提示:圓心加上 `sin(u_time)` 位移) 2. 畫出同心圓(提示:對 distance 做 `sin` 或 `fract`) 3. 把硬邊圓改成柔和邊緣(提示:用 `smoothstep` 取代 `step`) ## 總結 🐟 * Shader 是在 GPU 上執行的小程式,分為 **Vertex Shader**(處理位置)和 **Fragment Shader**(處理顏色) * GPU 的思維是「每個 pixel 同時獨立計算」,不是 CPU 的「一步一步來」 * 透過 **Uniform** 可以從 JS 傳入時間、滑鼠位置等動態資料 ### 推薦學習資源 * [The Book of Shaders](https://thebookofshaders.com/):最經典的 Shader 互動教學 * [Shadertoy](https://www.shadertoy.com/):Shader 界的 CodePen,大量範例可以拆來學 以上就是從 JS 開發者角度的 Shader 入門,有錯誤還請多多指教。 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。%(\*´∀\`)~♥% --- --- url: /column-shader-notes/02-vertex-shader.md description: 從 Attribute 到 Varying,搞懂 Vertex Shader 怎麼控制每個頂點的位置、大小與顏色!(「・ω・)「✧ --- ![shader-notes](/shader-notes.webp){.cover} # 鱈魚的 Shader 筆記 - EP02:認識 Vertex Shader 大家好,我是鱈魚。%(。・∀・)ノ゙% [上一篇](./01-shader-intro)我們認識了 Fragment Shader,學會讓每個像素自己決定顏色。 不過當時的頂點都是固定的全螢幕四邊形,Vertex Shader 只負責把座標原封不動丟出去,毫無存在感。%(´ー\`)% 現在讓我們來認識 Vertex Shader,學會怎麼控制每個頂點的位置、大小和顏色。 先來看看最終成品: 畫面上每個點的位置、大小、顏色全都不一樣,這些全靠 Vertex Shader 搞定。 要做到這個效果,我們需要搞懂四個核心概念: 1. **Attribute**:讓每個頂點擁有專屬資料 2. **繪圖模式**:決定頂點要連成什麼形狀 3. **gl\_PointSize**:控制點的大小 4. **Varying**:讓資料從 Vertex Shader 傳到 Fragment Shader 讓我們一關一關來。%(•̀ω•́)✧% ## 第一關:Attribute ### 為什麼需要 Attribute? EP01 介紹過 Uniform,它從 JS 傳資料到 Shader,特色是**每個頂點拿到的值都一樣**。 但如果要讓每個頂點出現在不同位置呢?用 Uniform 做不到,因為所有頂點都會拿到同一組座標,全部擠在一起。 這時候就需要 **Attribute**。 | 特性 | Uniform | Attribute | |------|---------|-----------| | 資料來源 | JS 傳入 | JS 傳入 | | 每個頂點的值 | 全部一樣 | 各自不同 | | 宣告方式 | `uniform vec2 u_xxx` | `attribute vec2 a_xxx` | | 常見用途 | 時間、滑鼠位置、畫布尺寸 | 頂點座標、顏色、大小 | 簡單說,Uniform 是廣播,Attribute 是點名發資料。%(・∀・)9% ### JS 端怎麼傳 Attribute? Attribute 資料存在 JS 這邊,但 Shader 跑在 GPU 上。兩邊記憶體不共通,所以不能直接丟變數過去。 想像你要寄包裹給 GPU,流程大概是這樣: 1. **準備箱子**(建立 Buffer) 2. **把箱子放上輸送帶**(綁定到 ARRAY\_BUFFER) 3. **往箱子裡塞資料**(bufferData) 4. **在箱子上貼標籤**,告訴 GPU「裡面是什麼、怎麼讀」(vertexAttribPointer) 一步一步來。%( •̀ ω •́ )✧% #### Step 1、2、3:建立 Buffer 並塞資料 ```js // 準備箱子 const buffer = gl.createBuffer() // 放上輸送帶 gl.bindBuffer(gl.ARRAY_BUFFER, buffer) // 塞入 3 個頂點的座標(每個頂點 2 個 float:x, y) const positionList = new Float32Array([ -1.0, // 頂點 1 左下(x) -1.0, // 頂點 1 左下(y) 1.0, // 頂點 2 右下(x) -1.0, // 頂點 2 右下(y) -1.0, // 頂點 3 左上(x) 1.0, // 頂點 3 左上(y) ]) gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW) ``` `Float32Array` 裡的數字是攤平的一維陣列,每 2 個一組就是一個頂點的 `(x, y)`。 試著新增或修改下方的頂點資料,觀察右邊攤平後的陣列怎麼對應: `gl.STATIC_DRAW` 用於提示 GPU 這筆資料不會常常更新,方便做快取最佳化。如果會頻繁更新就用 `gl.DYNAMIC_DRAW`,不過用錯也不會壞,只是效能差一點。 #### Step 4:貼標籤,讓 GPU 知道怎麼讀 資料塞進去了,但 GPU 還不知道這堆數字代表什麼。這一步就是告訴它:「這個 Buffer 對應 Shader 裡的 `a_position`,每次讀 2 個 float 當一組」。 ```js // 找到 Shader 裡 a_position 的位置 const location = gl.getAttribLocation(program, 'a_position') // 啟用這個 attribute gl.enableVertexAttribArray(location) // 告訴 GPU 怎麼讀:每個頂點讀 2 個 float gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0) ``` `vertexAttribPointer` 的第二個參數 `2` 代表「每個頂點讀 2 個 float」,對應 Shader 裡的 `vec2`。如果是 `vec3` 就填 `3`,`float` 就填 `1`。 整理一下兩邊的對應關係: | JS 端 | Shader 端 | 說明 | |-------|-----------|------| | `Float32Array([-1, -1, 1, -1, ...])` | `attribute vec2 a_position` | 資料內容 ↔ 變數宣告 | | `getAttribLocation(program, 'a_position')` | `a_position` | 透過名字找到彼此 | | `vertexAttribPointer(loc, 2, ...)` | `vec2` | 每個頂點讀幾個 float | 簡單說,JS 負責「準備資料、說明格式」,Shader 負責「接收並使用」。兩邊靠 **attribute 的名字**(如 `a_position`)連結在一起。%(ゝ∀・)b% 整個流程一圖總結: ### gl\_Position 的 z 和 w EP01 提過 `gl_Position = vec4(x, y, z, w)`,當時 z 和 w 都直接帶常數,現在來搞懂它們各自在幹嘛。 #### z 控制深度 z 決定「誰在前面、誰被擋住」。GPU 用 z 值做深度測試(depth test),z 越小越靠近鏡頭。2D 場景不需要遮擋,所以固定 `0.0`。 #### w 控制透視縮放 `vec4(x, y, z, w)` 其實是數學上的**齊次座標**(homogeneous coordinates),多出來的 w 讓平移、旋轉、縮放、投影都能用同一套矩陣乘法搞定。 GPU 會在最後一步把 x、y、z 全部**除以 w**,這步叫做透視除法(perspective division)。 ```glsl // GPU 內部做的事 螢幕座標 = (x/w, y/w, z/w) ``` 做 3D 透視投影時,投影矩陣會把「離鏡頭的距離」塞進 w。離越遠 → w 越大 → 除完後座標越小 → 物體看起來越小,自然產生近大遠小的效果。 2D 不需要透視,所以 w 固定 `1.0`(除以 1 嘛,就是除了個寂寞 %(´・ω・\`)%)。 總結來說 z 負責排序,w 負責縮放。齊次座標和投影矩陣的推導是一整塊大主題,之後有機會再開一篇來聊 %(´∀\`)% ### 動手試試:Attribute 下方的範例使用 `gl.POINTS` 模式繪製頂點。 可以試試看: * 新增或修改頂點資料 * Vertex Shader:微調位置與尺寸。 * Fragment Shader:修改顏色。 ## 第二關:繪圖模式 ### 頂點怎麼連成形狀? 有了頂點座標,接下來 GPU 要決定「這些點怎麼連」。 這由 `gl.drawArrays(mode, first, count)` 的第一個參數 `mode` 決定。EP01 提過 `POINTS`、`LINES`、`TRIANGLES`,這裡完整介紹所有模式。 **點** `gl.POINTS`:每個頂點畫一個點。 **線** | 模式 | 規則 | 說明 | |------|------|------| | `LINES` | 每 2 個一組 | 1-2 一條、3-4 一條,奇數個會忽略最後一個 | | `LINE_STRIP` | 前後相連 | 1-2、2-3、3-4... 像折線圖 | | `LINE_LOOP` | 前後相連 + 首尾相接 | 和 LINE\_STRIP 一樣,最後再從尾連回頭 | **三角形** | 模式 | 規則 | 說明 | |------|------|------| | `TRIANGLES` | 每 3 個一組 | 1-2-3 一個、4-5-6 一個,不共用頂點 | | `TRIANGLE_STRIP` | 前後共用 | 1-2-3、2-3-4、3-4-5... 每個新頂點和前兩個組成三角形 | | `TRIANGLE_FAN` | 扇形展開 | 1-2-3、1-3-4、1-4-5... 第一個頂點是共用的中心 | ### 動手試試:繪圖模式 下方用同一組五邊形頂點,切換不同繪圖模式看看實際效果。 切換模式前可以先仔細想想,猜猜看會跑出什麼圖案。%੭ ˙ᗜ˙ )੭% ::: tip 點不夠會怎麼樣? 頂點數不能整除時,多餘的頂點會被忽略,目前只有 5 個頂點,所以: * `LINES` 模式只能配成 2 條線(4 個頂點), * `TRIANGLES` 模式只能組成 1 個三角形(3 個頂點)。 ::: 是不是很有趣啊!%ԅ(´∀\` ԅ)% ## 第三關:gl\_PointSize ### 控制點的大小 `gl.POINTS` 模式下,每個點預設只有 1 像素,幾乎看不到。 在 Vertex Shader 裡可以透過內建變數 `gl_PointSize` 設定點的大小(單位是像素): ```glsl void main() { gl_Position = vec4(a_position, 0.0, 1.0); gl_PointSize = 20.0; // 每個點 20px } ``` 不過這樣每個點都一樣大,有點無聊。 如果想讓每個點大小不同,只要再加一個 Attribute 就好: ```glsl attribute vec2 a_position; attribute float a_size; // 每個頂點專屬的大小 void main() { gl_Position = vec4(a_position, 0.0, 1.0); gl_PointSize = a_size; // 用 Attribute 的值 } ``` JS 端再多開一個 Buffer 塞大小資料: ```js const sizeList = new Float32Array([20.0, 35.0, 15.0, 45.0, 28.0]) // 建立 Buffer、綁定、塞資料、設定 vertexAttribPointer... // 流程和 a_position 一樣,只是 size 參數改成 1(每個頂點讀 1 個 float) ``` 概念相同,意思是如果以後需要追加顏色、透明度,只要 Buffer 開好開滿,想傳幾組就傳幾組!%ᕕ( ゚ ∀。)ᕗ% 不過 Shader 裡能同時使用的 attribute 數量有上限,WebGL 保證至少 16 個,一般夠用了 %(ゝ∀・)b% ### gl\_PointCoord:點內部的座標 `gl.POINTS` 模式有個限定的 Fragment Shader 內建變數:`gl_PointCoord`。 `gl.POINTS` 模式下,每個點在螢幕上佔一個正方形區域(大小由 `gl_PointSize` 決定)。`gl_PointCoord` 就是這個正方形內部的相對座標,範圍 `0.0 ~ 1.0`,左上角 `(0, 0)`,右下角 `(1, 1)`。 注意 `gl_PointCoord` 和 JS 傳入的頂點座標(`a_position`)完全無關。 `a_position` 決定點畫在畫布哪裡,`gl_PointCoord` 則是點內部每個像素的座標。 利用 `gl_PointCoord` 可以在方形的點裡畫出圓形: ```glsl void main() { vec2 center = gl_PointCoord - 0.5; float dist = length(center); // 丟棄圓外的像素 if (dist > 0.5) discard; gl_FragColor = vec4(0.2, 0.8, 0.6, 1.0); } ``` `gl_PointCoord - 0.5` 把座標原點移到正方形中心,`length()` 算出離中心的距離。 超過 `0.5` 就在圓外,用 `discard` 丟棄。 `discard` 是 GLSL 的關鍵字,作用是「這個 fragment 不畫了,蛋雕!%੭ ˙ᗜ˙ )੭%」。 ::: tip Fragment 和 Pixel 不一樣喔 嚴格來說,Fragment Shader 處理的是 **fragment**,不是 pixel。 fragment 是「可能成為 pixel 的候選人」,還要通過深度測試、alpha 測試等關卡才能變成最終螢幕上的 pixel。 就像面試過了不代表拿到 offer,中間還有好幾關 %(´・ω・\`)% 不過入門階段暫時把 fragment 想成像素也沒問題,之後接觸深度測試、stencil 測試時自然會更清楚兩者的差異。 ::: ### 動手試試:gl\_PointSize 下方範例用了 `a_position` 和 `a_size` 兩個 Attribute,搭配 `gl_PointCoord` 畫出柔和邊緣的圓點。 重點在 Fragment Shader 這行: ```glsl float alpha = 1.0 - smoothstep(0.35, 0.5, dist); ``` `smoothstep(edge0, edge1, x)` 會在 `edge0` 到 `edge1` 之間產生平滑的 0→1 過渡,所以: * `0.35`(edge0):dist 小於此值時,`smoothstep` 回傳 `0`,alpha 為 `1.0`,完全不透明 * `0.5`(edge1):dist 大於此值時,`smoothstep` 回傳 `1`,alpha 為 `0.0`,完全透明 * 兩者之間就是柔和漸變的區間 間距越大,邊緣越朦朧;兩個值越接近,邊緣越銳利。 * 把 `0.35` 改成 `0.1`,漸層範圍變大,圓點像水母一樣軟趴趴 * 改成 `0.49`,幾乎沒有漸層,邊緣硬得像餅乾 %(´∀\`)% 不過光在 Shader 裡設定 alpha 還不夠,WebGL 預設不處理透明度,需要在 JS 端手動開啟 **Alpha Blending**: ```js // 這段寫在 JS 的繪製流程中,通常放在 gl.drawArrays() 之前 gl.enable(gl.BLEND) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) ``` `blendFunc` 設定混合公式,`SRC_ALPHA` 和 `ONE_MINUS_SRC_ALPHA` 就是最常見的「正常透明度」效果。 如果沒開,Shader 算出的 alpha 會完全沒有效果,寫了個寂寞 %(´;ω;\`)% ## 第四關:Varying ### 從 Vertex 到 Fragment 的橋樑 到目前為止,Fragment Shader 的顏色都直接寫死在程式碼裡,如何讓每個頂點有不同顏色呢? 顏色是 Fragment Shader 的工作,但「每個頂點不同」的資料只有 Attribute 能做到,而 Attribute 只能在 Vertex Shader 裡使用。 怎麼辦?用 **Varying** 當作中間人。%(•̀ω•́)✧% ```glsl // ── Vertex Shader ── attribute vec2 a_position; attribute vec3 a_color; varying vec3 v_color; // 宣告 varying void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_color = a_color; // 把顏色交給 varying } ``` ```glsl // ── Fragment Shader ── precision mediump float; varying vec3 v_color; // 同名的 varying void main() { gl_FragColor = vec4(v_color, 1.0); } ``` 兩邊都宣告同名同型別的 `varying`,就可以 Vertex Shader 寫入,Fragment Shader 讀取。 簡單來說就是 JS 傳給 Vertex Shader,Vertex Shader 再傳給 Fragment Shader,接力賽的概念 %( ‧ω‧)ノ╰(‧ω‧ )% ### GPU 自動插值 Varying 最神奇的地方在於 GPU 會自動做**插值**。 假設三角形的三個頂點分別設定紅、綠、藍三種顏色,三角形內部的每個 fragment 會根據自己和三個頂點的距離,自動混合出過渡色。 不需要寫任何混色的程式碼,GPU 的 rasterizer 會自動算出對應比例,這是 fixed function(固定管線功能),不能用 Shader 改寫。 這就是為什麼 OpenGL 入門教學常常以「彩色三角形」當作 Hello World。%(ゝ∀・)b% ::: tip 其實不是單純的線性插值 預設的插值模式是**透視校正插值**(perspective-correct interpolation),會把頂點的 w 分量納入計算,確保 3D 透視場景下的插值結果看起來正確。 我們目前 w 都是 `1.0`,所以效果和線性插值完全一樣,未來有機會再來詳細比較 %(´∀\`)% ::: ### 動手試試:Varying 經典的彩色三角形,三個頂點分別是紅、綠、藍。 試試以下改法: * 切到 Vertex Shader,把 `v_color = a_color` 改成 `v_color = vec3(1.0, 1.0, 0.0)`,三個頂點都傳同一色,整個三角形變成純黃色,插值無事可做 * 改成 `v_color = vec3(a_position, 0.0)`,把座標當顏色用,左下偏黑、右下偏紅、上方偏綠,座標值直接反映在色彩上 %(°ω°)ノ% * 切到 Fragment Shader,把 `gl_FragColor = vec4(v_color, 1.0)` 改成 `gl_FragColor = vec4(1.0 - v_color, 1.0)`,顏色反轉,原本的紅變成青、綠變成洋紅 ## 組合技:回到成品 學完四關,再回頭看看開頭的成品。切到 Vertex Shader 分頁,逐行解讀: ```glsl attribute vec2 a_position; // Attribute:每個頂點的座標 attribute float a_size; // Attribute:每個頂點的大小 attribute vec3 a_color; // Attribute:每個頂點的顏色 varying vec3 v_color; // Varying:傳給 Fragment Shader void main() { gl_Position = vec4(a_position, 0.0, 1.0); gl_PointSize = a_size; // 控制點的大小 v_color = a_color; // 把顏色交出去 } ``` Fragment Shader 負責用 `gl_PointCoord` 把方形的點畫成圓形,再從 `v_color` 取得顏色: ```glsl precision mediump float; varying vec3 v_color; void main() { vec2 center = gl_PointCoord - 0.5; float dist = length(center); if (dist > 0.5) discard; float alpha = 1.0 - smoothstep(0.3, 0.5, dist); gl_FragColor = vec4(v_color, alpha); } ``` ### 挑戰時間 試試看能不能改出這些效果: 1. 讓點的位置隨 `u_time` 移動(提示:`gl_Position.x += sin(u_time)` 之類的) 2. 讓點的大小隨時間脈動(提示:`gl_PointSize = a_size + sin(u_time) * 10.0`) 3. 讓顏色隨時間變化(提示:在 Fragment Shader 裡對 `v_color` 加上 `sin(u_time)` 的偏移) ## 總結 🐟 * **Attribute**讓每個頂點擁有專屬資料,和 Uniform 的「全部一樣」互補 * **繪圖模式**決定 GPU 怎麼連接頂點:POINTS、LINES、TRIANGLES 等,90% 的時候用 TRIANGLES 就好 * **gl\_PointSize**在 POINTS 模式下控制點的大小,搭配 `gl_PointCoord` 可以在點內部畫圓 * **Varying**是 Vertex Shader 傳資料給 Fragment Shader 的橋樑,GPU 會自動做線性插值 下一篇我們會用這些概念組合出真正的粒子系統,讓大量的點動起來,產生煙霧飄散的效果。敬請期待!%(ノ>ω<)ノ% 以上就是 Vertex Shader 核心概念的介紹,有錯誤還請多多指教。 感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。%(\*´∀\`)~♥% --- --- url: /aquarium/codmarbles.md description: 此專案沒有偉大的技術願景,純粹就是我想看東西滾來滾去而誕生的專案。(ゝ∀・)b ---