這是我的部落格,宙と木。每一次換頁,都是一顆琥珀色的點從這一頁飛到下一頁,落進下一頁自己的那條線。10 月 25 日凌晨,它在誕生的第 572 天換上了這個樣子。

這個站一開始只是我自己從零拼起來的作品集,之後每多一頁就照當時的感覺補一頁。做了一年左右,我開始覺得它冒出一股 AI 味,很多地方看起來像隨便哪個模板都長得出來,所以今年五、六月參考了 innei 部落格的幾個做法,後來又換上 WebGPU 畫的星空背景(那篇在這裡)。文章、碎念、看過的動畫、拍的照片都放在這裡,所以我一直希望它看起來是「我的」,而不是某個主題改一改。

老實說,半年前我對這個 blog 還算滿意。只是我一直是一頁一頁分開改的,從來沒想過整個站放在一起是什麼樣子。四月改「成長軌跡」頁的時候,我把卡片背板拿掉、換成背景浮著的巨大年份,看起來是好看了,可是總覺得缺了點什麼。後來才想通,缺的是沉浸感:每一張卡片都像獨立的投影片,中間沒有任何東西把它們串起來。

我平常是個很常放空、看著天空發呆的人,也一直覺得自己的內在比這個站能裝下的寬很多,只是不知道怎麼表達。直到十月某天我把音樂、書櫃、設備三頁並排看,發現它們長得一模一樣:大標、副標、往下一排卡片。那一刻我覺得這些頁面只是為了展示而放在那裡,每一頁都長這樣的話,讀者要探索什麼?我決定一次翻掉。

「從現在起,我要讓地球動起來。」

《地。關於地球的運動》

這是我很喜歡的一部作品裡的台詞。故事裡的人跨越好幾個世代,冒著生命危險把地動說一路傳下去。我做的事當然小得多,但下定決心要把整個站翻過來的那一刻,腦中浮現的就是這句話。

最後花了四天。10 月 22 日開始想方向,25 日凌晨上線,合併的那個 PR 動了 449 個檔案,加 41,744 行、刪 53,184 行。十四個頁面全部重畫,每一頁換成一個天體或一種太空裡真的會用到的東西。這篇寫的是這四天,和這四天之前的 572 天;轉場和字型的技術細節收在文末的附錄,想自己動手做的人可以直接跳過去。

第一版,失敗了#

一開始我沒有先畫設計稿,想說直接在本機改出來比較看得出效果,原型跟實際搭背景的感覺差很多。第一版保留了寫實材質的土星,換頁時讓行星爆炸碎成星塵,手記的卡片往下長到頁面底部接上地平線,碎念頁做成像素風。

做出來我自己看了很不滿意。土星移動時會過曝,爆炸散開的粒子跟背景原本的星星接不起來,看起來像薩諾斯彈指,又像橡皮擦把東西擦掉。手記的星軌一開始密到我看了頭暈,內容幾乎被蓋掉;版面怎麼調都有種上個時代部落格的味道,很像以前的 Yahoo 奇摩。

改了一個下午,每次修完一個地方,另一個地方就顯得更不搭。問題在於我沒有先定基調,每一頁各自加特效,材質、配色、動態都在各說各話。老實說那版也不算醜,只是不是我要的,所以我沒有刪,封存在 GitHub 的 archive/sky-morph 分支,然後回頭從方向開始想。

「不正確,不代表沒有意義。」

《地。關於地球的運動》

這句話在《地。》裡反覆出現。第一版是錯的,但如果沒有先做錯,我也不會知道自己真正想要的是什麼。

只留點、線和一點琥珀#

我做 app 的時候本來就偏好襯線字那種質感,站名「宙と木」的「宙」也本來就是宇宙(「木」來自我的網名木村盆栽),所以方向其實很快就收斂了:星空本來就可以只用點和線畫,星是點,軌道、星軌、彗尾都是線,用線把頁面串起來,剛好補上我覺得缺少的那份沉浸感。

最難割捨的是首頁的土星。那顆有材質建模的土星,我一直調到今年四月,效能、畫質、整體觀感都磨過好幾輪,要整個拋棄真的捨不得。最後我把它留著,但換成點和線的畫法:一條條白色刷痕畫在不透明的球上,環也是線。換完之後效果好很多,我才發現自己不一定要那麼堅持真實的材質。不過捨不得還是捨不得,舊的那顆我留在舊站裡當紀念。

定下來的規則很少:

項目決定
顏色黑 #0A0A0C 當紙、暖白 #F1ECE3 當墨、琥珀 #E9C17A 只當「一個點」的訊號
材質不用卡片、陰影、毛玻璃,能不用分隔線就不用
中文字思源宋體(Noto Serif TC)
英文字只用在整段英文的大字,用 Instrument Serif,一律不加粗

我對調色沒什麼概念,但很確定一件事:顏色一多,畫面就沒有主體了,所以整站只有那顆點是琥珀色的。這次我也學乖了,先把方向想清楚再寫程式。每一頁我都先做成可以直接在瀏覽器裡點的示意,一版一版改,前後改了 15 版,全部頁面都定案之後,我才開始動網站。

每一頁都是一個天體#

方向定了之後,每一頁我都問同一個問題:這個東西在太空裡會是什麼?每頁的副標都是我自己寫的,我想讓整站讀起來有一點文青味,但不要冗言贅字。

頁面副標
首頁一個寫代碼的人,偶爾造點東西。
手記群星在流轉,給每一瞬的好奇留下一道弧線。
碎念捕獲一道劃破寂靜的雜音。
作品在無聲的深空裡,鑿出一寸寸痕跡。
收藏引力所及之處,皆是照亮過我的碎光。
照片跨越虛空的微弱信號,在此逐行顯影。
設備解構精密,安放偏執。
朋友深空漫長而孤寂,所幸回頭總有星芒。
留言長夜無聲,靜候來自遠方的脈衝。
航行日誌掙脫引力,此身即是長空。
關於坐穩了。
此站點此處無他,唯有黑夜、白墨與微光。

首頁:什麼都不放#

首頁是那顆土星,手記、碎念、作品、收藏、關於五個分區是繞著它轉的五顆衛星。我刻意不在門面放任何資訊,平常只有線和點,滑鼠靠近哪顆衛星,那條軌道才亮起來,浮出那一區的名字和最新一則;閒著的時候,偶爾會有一顆自己亮一下,提醒你它們是可以點的。我希望進來的人是自己去探索的,就像我在這個宇宙裡探索自己一樣。

土星我堅持留 3D,是因為之後想讓首頁變成一個可以在裡面遨遊的立體空間。

碎念:一閃而過的彗星#

碎念對我來說是腦海裡閃過的一些瞬間,像 Threads 那樣隨手一發,所以做成一顆彗星。它沿著一條大 S 弧線飛,最新的那一則是彗核,舊的沿著彗尾左右排開,很舊的只剩尾巴上的一個點,滑過才浮出來。

點進單則碎念時,鏡頭會往彗尾上那一段放大進去,像用望遠鏡拉近,那一段亮起來、其他的退暗,底下的留言就是那段彗尾散出來的碎屑。我想要的是「你在看彗星的某一段」,而不是跳到另一個頁面。

手記:長曝光的星軌#

手記是我最喜歡的一頁。一篇文章就是一道星軌:越新的文章繞在越外圈,寫得越長,弧就拖得越長。背景那些淡淡的弧也是星軌,所以整片夜空看起來像一張長曝光的照片。打開頁面時像快門剛打開,所有的星一起拖出軌跡,之後整片繞著天極慢慢轉。星軌其實是地球自轉留下的痕跡:天上的星星沒有在轉,是我們站著的這顆星球在轉。

最讓我滿意的是點進文章的那一下:你點的那一篇,它的星軌會被拉直、拉近,變成文章頁右邊那道軌道。

這道軌道同時是目錄。章節是軌道上的刻度,刻度之間的距離照章節實際的長度排;讀過的部分是細實線,還沒讀到的是點線,琥珀點就是正在讀的你。手記列表是一圈一圈的星軌,點進一篇,等於鏡頭拉近到其中一道。

設備:還不滿意的圖紙#

設備頁是改到最後還是不太滿意的一頁。第一版是零重力組裝,加上太空船子系統的標籤和即時遙測,做出來很平淡,而且跟宇宙其實沒什麼關係。我心裡一直想把它做成工程概念設計圖,但自己一筆一筆刻要花太多時間,最後是用 Gemini 生白底黑線的爆炸軸測圖當底稿,再由程式疊上編號圈、引線、尺寸線和零件表;滑過編號,底稿上那個零件就染成琥珀色。

第一張圖紙 SHEET 0 是老家那台大概 2012 年買的電腦。它沒有照片,機殼是憑記憶畫的點狀草圖,點會微微晃,規格像開機自檢一樣一行一行打出來。它是我的童年,所以放在最前面。

這一版我還是不滿意,之後應該還會再改。

還有幾頁#

其他幾頁也都各有一個天體:

  • 作品是一條從中心往外長的螺旋航線。起點放的是畢業專題 VoltiCar,一個把碳權遊戲化的專題;選題不太理想,分組也不太順,現在回頭看技術上就是小兒科,可是它是我第一個把當時會的東西全部用上的專案,前後做了一年多,常常做到廢寢忘食。所以它是航線的起點。每件作品是一顆線畫的小星球,大小、地貌、環和衛星都由 GitHub 的數據長出來。
  • 照片照著太空照片傳回地球的流程畫:探測器拍下來轉成 0 和 1,由深空網路的大碟子收下來,一行一行顯影成黑白,最後才上色。
  • 朋友的中心是這個站,朋友是繞著它轉的星,點一顆就跟他對接,兩艘船接上,旁邊展開他最近寫的文章。
  • 留言的每一則都是一道收到的訊號,打字時你打的字會即時變成波形,送出時飛進天線。
  • 航行日誌是一整頁升空的場景,離地的高度是累積的 GitHub 貢獻,這頁在文章最後還會再出現一次。
  • 此站點是一具六分儀對著星空,裡面的玩具一律拿站上真的東西,一步一步拆開寫出當初怎麼想的。我希望有人玩完會覺得「原來也可以這樣做」。

關於:就此落下#

關於頁是自由落體。點從正上方慢慢掉下來,停成「坐穩了。」的那個句號,讀者往下捲,就是跟著它一起往下墜。

靈感是之前在新聞上看到的一支影片:葡萄牙的深夜,一顆藍色火球劃過天空,整片夜空被照亮好幾秒。天文現象一直很震撼我,一顆星落到地球,就跟人生一樣,散發出最絢麗的光芒之後轉瞬即逝。所以這一頁掠過那些稚嫩和迷惘的昨天,停在「就此落下」。但落下之後才是新的開始,再往下是地底,一層一層挖到我做過的事,最深的地方放著我最喜歡的一句話。

「我想,感動大概比壽命的長度還要重要。」

《地。關於地球的運動》

收藏:改了四次的訊號#

收藏頁(在看、書櫃、在聽)是改最多次的一頁,因為我想在這裡展示的是品味:讓人看到我看過很多東西,也看得出我喜歡什麼。第一版三區並排,太重複;第二版改成切換加星塵重組,做出來太刻意;第三版是三團星雲,往下捲鏡頭在三團之間飛,還是沒有宇宙浩大的感覺,圓形的封面也被切掉大半。

三版都不對之後,我不再想「封面要怎麼排」,改成想:在太空裡,這些東西實際上會怎麼被用到?答案是訊號。每一部作品是我看完那一刻從地球發出的一圈訊號,離地球多遠,就代表離現在多久。往下捲,是從地球一路往外退,經過真實位置的月球、韋伯望遠鏡、行星、航海家一號、太陽圈邊界、歐特雲,最後退到大約 9 光年外的鄰近恆星。

往外退的路上會經過航海家一號。1990 年,它在大約 60 億公里外回頭拍了一張地球,地球在照片裡還不到一個像素。卡爾·薩根看著那張照片寫下:

「再看一眼那個小點。那就是這裡,那是我們的家,那就是我們。」

卡爾·薩根,《暗淡藍點》

我想讓收藏頁有這種感覺:往外退得越遠,地球就越小,而那些照亮過我的作品,都是從那個小點發出去的訊號。

一顆點,串起每一頁#

如果整個翻新只能保留一件事,我會選轉場。我一直覺得轉場的絲滑和銜接度,決定了一個站有沒有質感:讀者不一定說得出哪裡好,但卡一下、閃一下、位置跳一下,他們一定感覺得到。

我對「順」的在意不是這次才開始的。七月改文章頁載入時,我就卡在骨架畫面和內文之間的銜接;十月調星空效能時,我的目標是讓進來的人覺得「怎麼可以 3D 還順成這樣」。所以翻新第一天的 demo 我特別難接受:換頁的飛行又乾又單調,進場和退場是同一個方向,隔天改出來的版本也只是用淡入淡出把兩頁硬湊成連貫。

後來我給自己定了一條規則:每次換頁都要從一個點延伸出去。首頁是軌道上的衛星,碎念的彗核,手記的每道星軌也有一顆星。只要每一頁都有自己的那個點,換頁就能變成一顆點從這一頁飛到下一頁。

第一版像硬畫的#

第一版很陽春:點沿著一條固定的路徑飛,飛到之後新的一頁淡入。實際看起來點跟頁面上的線對不起來,琥珀點沒有對齊彗核,轉場畫的線也跟彗尾差了一點。

改了幾輪之後,「進作品頁」那一段剛好做得最順,真的是一氣呵成。

我拿它當標準,其他頁都照它改。把順的原因拆開來看,有三件事:

舊的一頁自己收進那個點

首頁的土星收進被點的那顆衛星,碎念的彗尾縮回彗核,頁面上的東西都往那一點收攏,而且收完才換頁。

鏡頭像電影追焦

這是我最在意的一點。鏡頭要先移到琥珀點上,把它完全置中;點出發後鏡頭跟拍,點一直在畫面正中間;快到的時候鏡頭停下來,此時點還在動,才拍得到它身後的軌跡。就像電影先放大追蹤一個對象,鏡頭停下時才看到它走過的路。

最後一段走頁面自己的線

點最後順著下一頁本來就有的線(星軌、彗尾、航線)落下。點身後的彗尾會隨時間散掉,所以落地時畫面上留下的只有那一頁自己的線。

每一頁接住它的方式#

這三件事定下來之後,每一頁要做的就是回答兩個問題:點最後沿著哪條線落進來,離開時這一頁怎麼收回一個點。這也是我覺得最好玩的地方,同一顆點,在每一頁都有不同的意思。

頁面點怎麼落進來離開時
首頁飛到土星中心,土星從那一點撐開整組收進被點的那顆衛星
手記切進一道星軌,繞著天極走一大段,停在那篇文章的星上點進文章時,那篇的星軌被拉成文章的目錄
碎念順著彗尾落成彗核彗尾縮回彗核
作品落在起點,當筆尖畫出整條航線航線從起點擦掉,收進最新那件的衛星
收藏從深空俯衝回地球鏡頭收回地球,點從台灣帶著一圈波前發射
照片點就是那道訊號,從探測器落進天線整頁收回 0 和 1,點從探測器發射
留言起點振一下,一道琥珀光沿頻道跑進天線光從天線倒著跑回起點
朋友落在中心,軌道從內往外一條條長出來軌道收回中心
航行日誌落在發射台,火箭升空火箭沿機身飛走,點跟著機鼻
關於從正上方慢慢掉進句號帶著往下掉的速度離開
此站點落在望遠鏡的鏡筒前端,視線拉到第一顆星從正在測的那顆星飛走

訪客不一定是從首頁進來的,所以開場也是同一套:第一次進站時是一片夜色,中間畫著一圈,等頁面載完,那一圈畫滿,中間那顆點就從畫面正中間飛向這一頁。

下定決心之後,這四天我沒有想停下來過。現在我最喜歡的是照片、收藏、手記這幾頁,尤其是從手記的星軌接進文章內頁那一段。雖然還有一點小卡頓,快速連續換頁的手感也還沒調到滿意,但它們是目前最接近我心中「絲滑」的樣子。

還沒做好的#

寫到這裡看起來好像都很順,但有幾塊我自己很清楚還沒做好。

手機和 iPhone Duo#

整套設計是在寬螢幕上想的,手機只是「能看」而已。首頁的 3D 土星在手機上縮得很小,衛星的名字要靠滑過才浮出,手機沒有滑過這回事;作品頁的螺旋在手機上轉成直的,作品名擠在一起。今年 9 月出的 iPhone Duo 更麻煩,它展開之後內螢幕 7.6 吋、比例接近方形,剛好落在手機和桌面之間,現在的版面兩邊都不像。

這部分我還沒有答案。很多頁面的畫面本身就是內容,不是把卡片排成一欄就能解決,大概要每一頁重新想一次「在小螢幕上,這個天體應該怎麼被看」。

可讀性#

上線之後,X 上有人留言說可讀性有點差,文字不好讀、也不太知道該往哪裡點。老實說剛設計出來的時候我也這樣想過。導覽列平常是收起來的,滑過才展開;首頁一進來只有土星和幾條線,沒有任何一個按鈕在告訴你「點這裡」。

這是我刻意的選擇。我希望進來的人第一時間感受到的是滿版的震撼,還有在宇宙裡自己摸索的感覺,但這兩件事很難跟「好讀、好找」取得平衡。如果我把字加粗、讓文章內容多一點顏色,像以前的 MDX 區塊那樣花花綠綠,讀起來確實會比較清楚,可是整站的風格就會開始不和諧。我還在一點一點調,但整體要調到哪裡,我還沒找到那條線。

被看見的那天#

上線隔天,我在後台看到一波從 X 來的流量,點進去才發現 innei 特別發了一篇推文介紹這個站,還在我的碎念底下留言。我一直很喜歡他的部落格,日式紙質風做得很美,文章裡各種元素的互動、排版引擎都很細,也很佩服他的開發精神。我本來以為他大概不會看這種小站,所以那一刻真的覺得很不真實,開心又受寵若驚。不過開心完馬上開始想:是不是哪裡還要改、哪裡做得不夠好,害我又有點不想給人看了。

我參考過他的做法,現在他看到了我做出來的東西。這種感覺有點像《地。》裡講的傳承:一個人留下的東西,被下一個人接過去,再帶到更遠的地方。

「如果說我看得比別人更遠,那是因為我站在巨人的肩膀上。」

牛頓,1675 年寫給虎克的信

也有站長是在 innei 的群組看到我的友鏈申請才找過來,說這個站做得很漂亮,順便回報留言頁送不出去,那個 bug 當天就修掉了。我一直覺得這個站是一個藏得有點深的寶藏,細節很多,但不一定有人會看到。這次翻新之後,開始有人從別的地方找過來,在裡面點來點去,還會回來跟我說哪裡不好讀。

第 572 天#

翻新上線那天是這個站的第 572 天。航行日誌頁畫的就是這段時間:往遠方是時間,離地的高度是累積的 GitHub 貢獻,到現在是 5,356 次。

回頭看這條線,它一開始只是一頁 Hero 加作品集。2025 年 8 月才有部落格,10 月有了自己的網域,今年 1 月整站搬到自家的 HomeLab。之後幾乎每個月都在拆掉重來:四月做多語系、第一次大規模重做設計,六月把整站從 JavaScript 搬到 TypeScript、前端遷到 TanStack Start,七月把 Express 後端整包換成 Rust、讓 WebGPU 的太空背景轉正,然後是這次。WebGPU、Rust 後端、自架伺服器、五個語系的翻譯,很多東西我都是因為這個站才第一次碰,也大多是踩了坑才學會,那些過程多半寫成了手記。

舊站我沒有刪,凍結在 archive.koimsurai.com。它雖然不再是我要的樣子,但新站能這麼順、這四天能做得這麼快,很大一部分是靠它這一年多打下的架構。

這 572 天裡最辛苦的其實不是寫程式,是想文章題材的時候。我從來沒想過放棄這個站,但常常會想:我為什麼要做這些?好像一直都只有我自己在做。所以這次有人從別的地方找過來、在裡面點來點去,對我來說比什麼都實在。

這次翻新,比較像是把這 572 天學到的東西,第一次用我自己的美學整理成一個完整的樣子。它還不完美,手機版、可讀性、效能都還有一堆要改,但這條線還會繼續往上爬。

附錄:給想自己做的人#

下面是轉場、字型和舊站的實作細節,跟上面的故事無關,有興趣再看。

收、飛、長#

轉場拆成三段:收、飛、長。換頁先被擋下來,等舊頁收完才放行;飛的時候新頁已經掛上、跟著鏡頭從點要去的那一側滑進來;點落地之後新頁才開始長出自己的東西。

點連結 / 上一頁history blocker 擋下這次換頁leave()收進的那一點、初速、收完的 Promise收完才放行掛上新頁(先藏在鏡頭外)anchor()、arrive() 給錨點和最後一段路對焦、跟拍、煞停,點沿 approach 落地點落地,頁面開始長讀者路由轉場層舊的一頁新的一頁讀者路由轉場層舊的一頁新的一頁

轉場層(TransitionLayer)負責擋換頁、畫點和彗尾、移鏡頭;每一頁只要用一個 hook 報上四件事:錨點在哪、點最後走哪條路、怎麼收、落地時要做什麼。這是關於頁的版本,點從正上方直直掉進「坐穩了。」的句號:

tsx
usePageTransition({
  // 點飛進來,停成標題的句號
  anchor: () => stopPoint(titleStopRef.current),
  // 最後一段:從句號正上方直直往下掉,鏡頭跟著往下,頁面從下面升上來
  arrive: () => {
    const p = stopPoint(titleStopRef.current);
    if (!p) return null;
    const drop: Pt[] = [];
    for (let d = FALL_IN_PX; d >= 0; d -= 6) drop.push({ x: p.x, y: p.y - d });
    return { approach: drop, join: Math.round((FALL_IN_PX - FALL_SHOWN_PX) / 6), pace: FALL_PACE };
  },
  // 收:字退掉,琥珀點帶著往下掉的速度離開
  leave: () => {
    const point = dotRef.current;
    if (!point) return null;
    pageRef.current?.classList.add('is-leaving');
    return { point, velocity: { x: 0, y: 0.8 }, done: after(300) };
  },
});

approach 是點最後要走的那段路,join 標出鏡頭在哪一點煞停,前面那段可以在畫面外。pace 小於 1 是放慢,關於頁是自由落體,我讓它掉得比別頁慢。頁面沒給 leave 的話,轉場層會用一圈圓形遮罩把整頁收進錨點,所以新頁面不用一開始就把轉場寫完。手記進文章時,那道星軌會交給轉場層繼續畫著,等文章頁長出來才淡掉,兩頁之間那條線不會斷。擋換頁用的是 TanStack Router 的 router.history.block,上一頁、下一頁(popstate)也會經過同一個 blocker,所以滑鼠側鍵換頁一樣有轉場。

邊界情況#

飛行本身做順之後,花最多時間的是各種邊界情況。最明顯的是快速連續換頁:點畫到一半的線加上懸停的點,會殘留在下一頁上。

  • 快速連續換頁:轉場中又換頁,舊的那趟直接收尾,這一次不演。頁面的「收」最多等 0.8 秒,一趟轉場超過 8 秒就當作出事、直接收掉。
  • 背景星空每次換頁會跳回原位,看起來像整片天空位移。改成鏡頭的位移累積下來,不歸零。
  • 用滑鼠側鍵換頁會退回預設的飛法。原因是頁面判斷「要往哪收」時看的是被點的那個元素,側鍵換頁時沒有被點的東西。改成照目的地 ctx.to 判斷。
  • 從一篇文章換到另一篇不演轉場。文章頁是原地換:左欄留著,只有標題、內文和目錄換掉。
  • 任何光暈、亮線都要漸退。瞬間暗掉看起來像斷電,全站統一慢慢淡回頁面原本的亮度。
  • 開場:網路慢的話最多等 3.5 秒就飛,不會讓人卡在載入畫面。只換語系、進出後台,或系統設定了「減少動態」的讀者,不會看到轉場。

一頁只載需要的字#

繁中思源宋體的可變字重版本一支 16.8 MB,簡中、日文、韓文各一支,文章的字體切換另外還有黑體、霞鶩文楷、MiSans 幾種,不可能整支丟給讀者。我的做法是按頁裁字:瀏覽器用 MutationObserver 收集畫面上用到哪些字,跟伺服器要 /fonts/<風格>/<字重>/<鍵>.woff2?c=<字集>&f=<語系>,伺服器用 subset-font(底層是 HarfBuzz 的 wasm)裁出只含這些字的 woff2。網址由字集決定,所以可以永久快取。拿上一篇 FurSpark 的內文實測,全文用到 836 個不同的字,從 16.8 MB 的原檔裁出 400 字重的 woff2 是 185 KB。

伺服器端裁字的核心程式
ts
import subsetFont from 'subset-font';
// subset-font 執行期才用 require.resolve 找 HarfBuzz 的 wasm,打包工具追不到,
// 明確 import 一次,harfbuzzjs 整包(含 wasm)才會被帶進輸出
import { Blob as HbBlob, Face as HbFace } from 'harfbuzzjs';

// 裁一次要把整個原檔複製進 wasm,一篇長文約 0.7 秒:一次只裁一個,排隊
let queue: Promise<unknown> = Promise.resolve();
const serial = <T>(job: () => Promise<T>): Promise<T> => {
  const run = queue.then(job, job);
  queue = run.catch(() => undefined);
  return run;
};

async function build(file: string, weight: 400 | 600, cps: number[]) {
  const font = await sourceOf(file); // 原檔只留最近用過的幾支在記憶體
  return subsetFont(font, String.fromCodePoint(...cps), {
    targetFormat: 'woff2',
    variationAxes: { wght: weight }, // 可變字重的原檔,取出要的那個字重
  });
}

有兩個坑值得記一下。第一個是打包:subset-font 在執行期才用 require.resolve 找 HarfBuzz 的 .wasm,Nitro 的追蹤工具看不出這是一個相依,部署後才發現找不到檔案,解法是把它設成外部相依(traceDeps),再明確 import 一次 harfbuzzjs。第二個是缺字:繁中頁面上偶爾會出現簡體字(通常是留言),現在請求可以多帶一個 x=sc,伺服器先查繁中原檔缺哪些字,只把缺的那些從簡中那支補裁出來。

舊站怎麼留#

上線前我把舊版凍結成 git tag v1-final 和一個 Docker 映像,另外開一個容器掛在 archive.koimsurai.com,跟新站共用同一個後端。

共用後端的風險是舊站的表單還能寫資料,所以我在 nginx 那層把它鎖成唯讀:登入直接回空、API 只放讀取、後台導回主站,也不讓搜尋引擎收錄。

舊站的 nginx 設定
nginx
server {
    server_name archive.koimsurai.com;
    add_header X-Robots-Tag "noindex, nofollow" always;

    # 不讓搜尋引擎收舊站,免得跟新站搶同一篇文章
    location = /robots.txt {
        default_type text/plain;
        return 200 "User-agent: *\nDisallow: /\n";
    }
    # 後台導回主站
    location /admin { return 302 https://koimsurai.com/admin; }
    # 登入直接回空
    location ^~ /api/auth/ { return 204; }
    # API 只放讀取
    location /api/ {
        if ($request_method !~ ^(GET|HEAD)$) { return 204; }
        proxy_pass http://127.0.0.1:<後端埠>;
    }
    location / {
        proxy_pass http://127.0.0.1:<舊站容器埠>;
    }
}

效能與上線後#

效能修正的原則是畫面不能變。手記點進文章原本會頓一下,因為整篇內文在同一幀掛上,改成站內換頁進來時分批掛;收藏頁重整時的頓,是背景星空的 worker 正在建 WebGPU device,改成等進場動畫跑完、主執行緒閒下來再建。

50–67ms
手記進文章那一幀
原 183–217ms
0ms
收藏頁重整時的頓
原 67–85ms

還沒解決的是收藏頁最外圈:訊號圈的虛線在捲到最外面時,每一幀要重切四到六萬段,會從 65 fps 掉到 45 fps 左右。設備頁的網路圖和遙測也有一條原則:留結構、拿掉敏感型號,遙測只給溫度、負載、記憶體、功耗,數字取整、快取 30 秒。上線後兩天我又發了二十幾個 PR,補遷移時漏掉的右鍵選單、搜尋面板、燈箱、電子報和後台,每一頁的分享圖也在後端用 Rust 的 tiny-skia 重畫一次,輸出成 APNG。


寫到這裡,這個站還在繼續長。翻新不是終點,只是那條線上的又一個節點。最後,還是那句我最喜歡的話:

Stay hungry, stay foolish.

參考連結