我已經不記得這是自己第幾次重寫博客的代碼,但我很確定這是(短時間內)最後一次了。無論如何,是時候給我這在自定義性、易維護性和成本三者之間找不到任何平衡點的完美主義畫上一個暫時的句號了。
重寫的原因#
因為越來越看不慣舊博客的頁面設計,我決定直接推翻重新設計。但在我這麼做之前,我突然意識到我已經有好久沒更新博客了,最近的更新還都是一些寫得讓人摸不著頭腦的小說,而且大部分是直接搬運自我在另一個網站的創作 —— 總之,我已經很久沒有靜下心來認真地寫博客了。我認為時不時用較長篇幅的文字記錄一下自己在某方面的摸索歷程還是很有必要的,這和我做手帳時的日常記錄有很大區別,針對事物本身而非時間的記述能幫助我在理清思路的同時加深記憶。
我不小心丟掉了寫博客的這個習慣,有很大原因是更新靜態博客時繁瑣的操作流程。我前幾次重寫博客代碼,使用的都是純粹的 Next.js 或者 Svelte 框架,算是一種「無伺服器」(Serverless)的應用。由於沒有數據庫,博客所有的文章都以 Markdown 文件的形式,和博客的源代碼儲存在一起。這就導致,在我需要更新博客的時候,我要先在一個 Markdown 文件裡寫好文章內容,還要在文件的開頭編寫必須的 Front Matter 用於表示文章標題、創建日期、標籤等元信息(而且我每次都記不住格式和屬性的命名,要先找到以前的文件,然後複製到新的文件裡)。當文件準備好之後,我要把它放進 Git 倉庫裡對應的目錄下,在本地運行 npm run dev
測試是否會產生問題,無誤後推送到 GitHub 並等待 Vercel 將新的博客版本部署在生產環境中。之後,如果我還需要修改文章內的某些錯誤,或者進行額外的更新,我無法在移動設備上進行這些操作,我需要打開我的電腦,打開 GitHub Desktop 和 VS Code,編輯我的內容,然後測試,再推送,再等待部署。
初次接觸靜態博客的開發者可能會覺得這很有意思,很極客,但我很快就厭倦了,因為有時候我只是想要寫一篇文章傳達一些想法或者是純粹地記錄,可我卻需要打開一個光是看著就覺得自己要開始寫 Bug 的界面,然後再進行一系列十分「黑客」的操作,才能發表我的文章。這簡直太反人類了。
為了保留寫博客的好習慣,同時不委屈自己,我決定重寫一個讓自己覺得更舒服的博客系統。
我的思路#
我需要一個功能齊全的圖形化博客管理後台,但我不想自己造輪子,也不想用市面上花里胡哨的 CMS 來「大炮打蚊子」,我只是想要一個簡單、易上手的,適用於個人博客的內容管理程序。
符合這個描述的,答案當然是 ——Typecho。
不過問題是,Typecho 是一個用 PHP 編寫的,前後端一體的博客程序。對我這個已經享受過用 JavaScript 寫前端是多麼舒爽的人而言,回到 PHP 時代無異於現代人到山裡住岩洞,我還得重新適應 PHP 並重新寫一個博客主題。這實在是令我不能接受。
我既想享受傳統博客傻瓜式操作帶來的便利,又不願意離開現代化前端開發的優雅和高效。那么解決方案就很明顯了 —— 使用一個無頭 CMS,同時重新設計博客的前端。但問題又回來了,市面上大部分的無頭 CMS 都有些臃腫,或者說是相對於我要解決的問題,它們都具備了太多我不需要的功能。不過,Typecho 雖然不能作為無頭 CMS 使用,但它的量級卻剛剛好滿足我的需求。
那麼,我只需要想辦法把 Typecho 變成一個無頭 CMS,一切問題就都迎刃而解了。
開始實踐#
我很容易就找到了一個現有的插件,它能為 Typecho 提供 RESTful 化的 API。這樣一來,Typecho 就能作為純粹的後端為我自己設計的前端提供數據了,而我只需要在 Typecho 的控制台更新博客內容就行了。
接下來,我只需要把重點放在前端的設計上就好了。
選擇工具#
我決定使用我熟悉的 Next.js 編寫前端,因為我決定把前端托管在 Vercel 上,而 Vercel 的 Next.js 的支持顯然更好。
在 CSS-in-JS 這方面,我選擇了最近很火的 Tailwind.css 而不是自己用 SCSS 手寫每一個類。一方面,新版本的 Next.js 默認支持 Tailwind.css,省去了自己配置的時間;另一方面,有了 React 對模塊化開發的支持,每個相同或相似的元素都可以被編寫成組件,在 CSS 層面做到語義化就顯得有些沒必要了,這時候有更方便快捷的方法當然是最好的。
順帶一提,我有好一段時間沒用 Next.js,上一版博客(Isla)使用的是 Svelte。新版本的 Next.js 添加了新的頁面路由方法,即 App Router,與以往的 Page Router 區分開來。照理來說使用 App Router 是更好的,但剛回坑的我顯然還沒有反應過來,所以繼續採用 Page Router 編寫博客。不過,能跑就行。
像是 React Icons 圖標庫這樣的額外工具就沒必要提了。
獲取文章#
使用 Next.js Page Router 提供的 getStaticProps()
函數可以在頁面加載之前獲取來自無頭 CMS 的數據。使用 fetch()
獲取 API 內容,記得使用 await
關鍵詞。
插件提供的 RESTful 風格的 API 可以直接用 json 解析,不要忘記解析時也需要加上 await
關鍵詞。
export async function getStaticProps() {
const res = await fetch('https://blog.guhub.cn/api/posts')
const posts = await res.json()
return { props: { posts } }
}
完成之後將文章列表數據作為 Props
返回給主函數即可。
不過,在這裡我遇到了個後端的問題,代碼這樣正常跑了數十次之後我才發現前端只展示了前五篇文章,原因是插件給 API 提供了分頁功能,每頁默認五篇,需要在 URL Query 中用 ?page=
標明正在查看第幾頁。不過我目前的設計並不需要分頁功能,所以我用 API 提供的另一個方法增加了每頁顯示的文章數量,算是一個比較蠢的解決方案。
const res = await fetch('https://blog.guhub.cn/api/posts?pageSize=9999')
展示文章#
從後端得到的數據中,重要的數據在 data.dataSet 下,裡面包含了文章的標題、創建時間戳、CID、分類、Slug 等。值得一提的名為 digest
的屬性,這個和 Typecho 的設置掛鉤,如果設置了在首頁展示完整的 $this->content()
,digest
就會包含全文內容的 HTML 字符串而不只是摘要。這個插件在文章列表的 API 中沒有專門輸出全文內容的屬性,如果在 digest
只輸出摘要的情況下需要獲取全文,就要用 slug 或者 cid 等唯一的屬性到另一個路徑中獲取更詳細的文章信息。
這顯然有些太麻煩了,於是我決定不更改 Typecho 的設置,把 digest
當作全文內容使用。不過,我仍然有在文章列表輸出真正的摘要的需求,這就意味著我需要在前端截取一段摘要。
我是這樣實現的:
function stripDigest(digest) {
//刪除空行和空格
digest = digest.replace(/\ +/g,"").replace(/[ ]/g,"").replace(/[\r\n]/g,"")
//刪除標題
digest = digest.replace( /<h.*?>(\S|\s)*?<\/h.*?>/g,"")
//在文章內容中尋找 <!--more--> 標籤
// 若存在,則截取 <!--more--> 之前的內容
// 若不存在,則截取前 150 個字符
var moreTag = digest.search(/<!--more-->/)
var sliceEnd = (moreTag>0) ? moreTag+2 : 150
//刪除 html 標籤,只保留文字內容,然後執行截取操作
digest = digest.slice(0,sliceEnd).replace(/<\/?[^>]+(>|$)/g, "") + "......"
return digest
}
摘要應當是一段連續的文字,沒有分行和空格,所以要先刪去這些空白;標題最好也刪去;然後是喜聞樂見的 <!--more-->
標籤,這個是用來手動截取摘要的,如果有 <!--more-->
標籤,就將其作為分界線截取前面的文本作為摘要;如果沒有,就截取前 150 個字符。然後需要刪去 HTML 字符串中的標籤,只保留純文字內容。
如果你有閒心仔細看了上面的代碼,你可能會對這一段代碼感到疑惑:
var moreTag = digest.search(/<!--more-->/)
var sliceEnd = (moreTag>0) ? moreTag+2 : 150
其中,變量 moreTag
用來表示 <!--more-->
所在位置的索引。如果存在,索引就大於 0,照理就應該以索引直接作為之後 slice()
方法,但我在這裡加了 2,原因是 —— 不加這個 2 的話,截取的位置就不對。
很經典的問題,我不知道為什麼要寫這段代碼,但不寫的話程序跑起來就有問題。
雖然加上了之後跑起來也不完全對,但不加的話問題更大。我一直沒搞明白為什麼,然後就擺爛了。現在想想,最佳的處理方式是按照 RESTful API 設計的邏輯走,直接獲取服務端提供的摘要。這個問題留到之後有空再改吧。
頁面設計#
能夠獲取文章數據並展示在前端就已經完成博客的基本功能了,接下來輪到頁面設計。
在之前幾個版本的博客設計中,我都在刻意地追求簡潔(一個已經被用爛了的設計風格)。當時的頁面組成就是白底黑字,加上一些同樣簡單的黑色線條圖標,和一些淡灰的色塊簡單地劃分一下區域。
這樣的設計確實讓我在花里胡哨的網站和 App 中找到了一絲清爽的感覺,但問題在於,這種過於簡單的設計很容易被「刻奇」,更準確地,是我在對這種被許多裝作內行的博主廣泛認可的設計風格進行刻奇。這樣的風格缺乏新意和個性,現在想來,也是我想要把博客前端推翻重寫的主要原因。
我已經忘記了是什麼給了我靈感,但在我糾結數日後,我對新博客的外觀設計有了新的構想。我想要一個簡潔大方,但同時特徵鮮明,色彩明顯,排版富有新意的設計。在融入了一些報刊頭條和拼貼的元素之後,我首先在 Figma 上做好了一個概念圖。
在之後的實裝過程中我又做了一些調整,加上了類似網格手帳本內頁的底紋,逐漸變成了現在的樣子。
RSS 訂閱#
在這個沒什麼人看博客,寫博客的大多數更新也經常擠牙膏的時代,給願意關注自己的讀者一個訂閱的途徑,在自己終於更新的時候提醒一下讀者是有必要的。
起初我覺得這並不難,因為 Typecho 本身提供了 RSS 訂閱源。但問題又來了,我把後端部分(Typecho)放在了 blog.guhub.cn
這個域下,前端則在 www.guhub.cn
,而 Typecho 本身並不是為前後端分離的方案設計的,所以在 Typecho 提供的訂閱源中,所有文章鏈接都指向了 blog.guhub.cn
這個域,而不是我現在使用的 www.guhub.cn
。
我以為我只需要在前端把 RSS 的 XML 抓過來,然後把所有的 blog.guhub.cn
替換為 www.guhub.cn
就可以了。不過,Next.js 的設計者大概怎麼也不會想到有個愛走彎路的傻子想要幹出這種事情,它沒有辦法直接處理 XML 數據,我也沒有找到直接獲取頁面內容的方法。這照理來說是可行的,但我不想在這一步多花時間了,於是......
npm i rss
我安裝了一個 RSS 庫,用我從 API 獲取到文章數據重新生成了一個訂閱源。
export default async function generateRssFeed({ posts }) {
const feedOptions = {
//...
}
const feed = new RSS(feedOptions);
posts.map((item) => {
let post = parseBlogPost(item);
feed.item({
title: post.title,
description: post.content,
url: `${site_url}/blog/${post.slug}`,
date: post.date,
});
});
fs.writeFileSync('./public/feed/index.xml', feed.xml({ indent: true }));
}
這樣 Next.js 就會在需要的時候生成一個 XML 文件作為 RSS 訂閱源。現在,如果你願意的話,你可以用這個鏈接訂閱我的博客。
其他#
細枝末節的實現步驟就不在這裡贅述了。我還有諸如分類、標籤頁面等功能沒有做出來,文章列表的設計也有些過於簡陋,這些會慢慢地在之後補上的。光是做這些功能就夠我忙活一陣子了,我應該還不至於很快就覺得無聊然後把整個博客刪掉。
另外,如果你覺得這個博客的前端看著不舒服,尤其是目前我還沒有把深色模式做出來,你可以到 Typecho 側閱讀文章,那邊我使用的是 Matcha 主題,也是我編寫的,功能較為完善,閱讀體驗應該比現在的博客要好很多。
哦對,差點忘了,我把這個項目叫做 Taco,也就是把 Typecho 中間的音素拆掉一部分之後得到的單詞。
备案和網站加速#
因為 Typecho 側的伺服器使用的是騰訊雲的國內伺服器,所以我終於給 guhub.cn
這個域名備案了。不過最主要的原因還是想要使用 CDN 和對象儲存這樣的服務提高博客的訪問速度。
ICP 備案和公安備案的具體步驟就不細談了。CDN 和對象儲存服務我使用的是又拍雲,對於獨立博客這種低訪問量網站是性價比很高的選擇了,用了半個多月只扣了不到一塊錢的費用。
全國都是綠油油的感覺很舒服。
最近倒腾博客的成果大概就是這些了。
插幾句題外話,如果你細心的話,你會發現目前博客還沒有友情鏈接頁面,這個我會儘快加上的。我打算重新開放友鏈申請,並刪除一部分不常交流的友鏈。對博客往後的發展我也有了初步的構想。這些內容或許我都會單獨寫一篇文章談一談,在這裡就不展開說明了。
好了,感謝你讀到這裡,希望你過得還愉快。