使用 Hugo + SolidStart 重构博客

今年度的重构博客,比以往来的更早一些

May 7, 2023
7385
#SolidJS#博客#Hugo

本文最近一次更新于 1 年 6 个月前,其中的内容很可能已经有所发展或是发生改变。

https://ae02.alicdn.com/kf/Sd131368bb4274e71922dead036462afb9.jpg

在去年年底时,我重新设计了一版博客的主题,不过此版主题主要是针对 UI 样式方面的调整,底层的架构没有变化:静态内容还是 Hugo 生成,动态内容如:搜索、消遣页面,暗色模式、评论等功能则是由 Svelte 提供支持。这个技术栈使用起来其实挺搭的:Svelte 打包后的结果很小,生成的 JavaScript 文件直接在 Hugo 模板中用一个 script 标签引入就行了。

那么,是什么原因让我仅仅时隔半年的时间就再次重构博客呢?让我先卖一个关子,先说一下 Hugo 的一些问题。

Hugo的缺点

Hugo 其实本身的集成度很高了:模板生成、静态资源打包、自定义的输出格式等功能都有。但有两点我用着不是特别舒服。

开发体验

Hugo 主题的开发体验太差了,差的体验其实都和它的生态相关。

  1. VSCode 的插件本身提供的功能非常有限:文件跳转不行、语法高亮残废、代码补全仅限于一些基础的 snippet。而且因为 Hugo 的 template 是扩展的 HTML 语法,如果写了稍微复杂一点的 Hugo 表达式,在保存自动格式化时有时候会按照 HTML 的语法缩进,导致表达式的出现语法错误,这时候只能在右下角将语法切换为 Plain Text,然后再保存。

  2. Hugo 本身本身虽然具备 JavaScript 和 Sass、SCSS 文件的打包功能,但其实支持都比较有限:

    • JavaScript 的打包是由 ESBuild 提供支持,但是你不能在 Hugo 中添加 ESBuild 的插件;
    • Sass、SCSS 的支持需要通过 npm 来安装 PostCSS、Bable 的支持同样需要通过 npm 安装 Babel.js 依赖。

Hugo 在很努力地支持这些现代化前端构建工具,这些是开发组的努力,但只能说任重而道远啊。

黑盒

Hugo 内部具备了非常多的功能,但是就像一个黑盒一样是集成在它内部的,我三年前从 Hexo 切换过来的时候,正是看中了它这一点,但直到我自己开发起来我才发现这其实并不是什么好事。因为有些功能你总会需要自己来定制实现的,比如博文加密、构建前后的 Hook、针对某些元素自定义渲染等,这种功能可能并不是每个人都会用到,开发组不做也很正常,但是我觉得 Hugo 的开发组对此的态度也有一点奇怪。

很早之前我在寻找 Hugo 博客加密的方案时,曾经想过一个方法就是在 Hugo 渲染的时候调用外部的可执行文件或者脚本(Shell 或者 .go 文件),于是我就搜了一下,发现早在 2015 年就有人提了 Issue,开发者一开始也认为这个想法很赞,不过还是出于安全(?)以及兼容性的考虑放弃了这个做法,并把这个 Issue locked 了。开发组对不同平台的兼容性有顾虑这一点还是有道理的,不过说出于安全的考虑我真的是没搞懂,开发组似乎是担心有人在主题文件里的可执行的脚本加入了恶意代码,执行起来就会导致设备中毒或者删除数据之类的,乍一看好像还挺有道理的,可仔细一想完全不通啊。哪个语言的包管理器都支持从网上下载代码啊,怎么难道他们都不担心下载的是恶意代码吗?

另一个让我不喜开发组的例子是 Markdown 元素的自定义渲染,目前仅支持有限的渲染:标题标签、超链接标签、图片标签、代码块标签(这个还是最近加上的),当询问到为什么不支持更多的标签自定义渲染的时候,开发组说因为担心渲染的性能问题,所以只开放了这几个,可 Hugo 的渲染速度真的很快很快了,我博客的近三百个页面,只需要 120 ms 的时间就可以全部生成,就算加上所有的标签的自定义渲染会牺牲 50% 的性能,我认为大多数的人都是可以接受的,甚至他们根本感受不到区别。

开发组的态度,加上 Hugo 本身像是一个黑盒,所以 Hugo 的生态其实很差,仅仅是包括一些主题而已,任何针对 Hugo 本身功能上的扩展都没有。

Anyway,吐槽归吐槽,用还是得继续用下去。(几年前迁移到 Hugo 时都说了 Hexo 再也不见了,难道还能删文自己打脸不成?

让 Hugo 回归本质

“什么是 Hugo 的本质?”

“内容生成和模板渲染。”

因此本次重构我只使用 Hugo 生成文章的内容,不包含任何样式、脚本等,输出格式我选择了 JSON,包含渲染后文章的主体内容,还包含了文章的元信息以及下一篇、上一篇等额外字段。

这些 JSON 文件,就是后续网站构建的内容核心。

为什么重构?

扯了这么久 Hugo 的缺点,其实本次的重构的缘由并不是我完全无法忍受这些缺点了,只是单纯想吐槽一下,又觉得为了吐槽单独写一篇文章有点没必要,于是就在这里写了。本次重构的主要原因是我想稍微系统地学一下前端目前的主流框架,并且利用现代的 Web 开发技术来提升一下博客的用户体验。

我之前的博客就已经很注重用户体验了:CSS 和 JS 文件都只有一个,且 JS 还使用了 async defer 属性加载,完全不阻塞页面的渲染。不过虽然目前 CSS 和 JS 文件都只有一个,但是在页面切换浏览器重新渲染页面的时候,同样的 CSS 文件和 JS 文件都会再请求一次,对于用户来说,再次请求的 CSS 和 JS 文件其实没什么必要,因为它们俩每个页面都是一样的,不同的只是 HTML。因此本次重构的核心目的就是在切换页面时支持增量同步内容,而不用完整地重新渲染整个页面;同时在浏览器首次访问页面的时候,能够渲染出的是 HTML 内容,而不是要通过 JavaScript 来生成 DOM,虽然现在搜索引擎抓取时都支持了 JavaScript 的运行,但在 JavaScript 加载之前就能渲染出页面内容对用户的体验也会更好。

接下来对框架的选择,我便会以支持增量同步内容 + 预渲染 HTML 的功能作为最重要的考量标准。

框架的选择

SvelteKit

因为我之前的博客就是使用的 Hugo + Svelte,Svelte 给我的开发体验还可以(比 Hugo 是要强不少的),因此本次重构我第一想法就是选择 SvelteKit,不过简单的尝试后我还是放弃了,相比于 Svelte 的模板语法我还是更喜欢 JSX。而之所以在两年半前迁移到 Hugo 时候选择和 Svelte 搭配使用,无非就是因为 Svelte 的性能以及打包后的脚本最小(因为没有 runtime);这对当时以静态内容为主、动态内容为辅的博客来说无疑是更合适的。

而现在我的重构目标则是要把博客变成以动态内容为主,这样的话选择 Svelte 的理由似乎不是那么充分了。

Gatsby

我的表情包网站正是基于的 Gatsby v2 写的,现在都迭代到 v5 了,可惜 Gatsby 的缺点是太重,依赖太多,而且不支持 pnpm。

连 React 的官方文档都从 Gatsby 跑路到 Next.js 了,你还有什么理由用它呢?

Astro

这个框架也是最近才冒出来的,我的个人主页在前段时间就用了这个框架重构,不过它只能当 SSG 来使用,不同页面的切换无法通过异步刷新来解决。因此还是放弃了。

Next.js

Next.js 可以算是前端圈最大名鼎鼎的一个框架了,我简单使用了一会之后发现开发体验真的很顺畅:文档完善、社区繁荣、功能齐全,还有商业公司在背后支撑。而且 Next.js 还可以使用 getStaticProps 和 getStaticPaths 函数来可以生成纯静态的资源,不需要借助 Node.js 的运行时,似乎和我的 Hugo 方案完美契合。

于是我就愉快地使用 Next.js 重构起了我的博客。

Next.js 的局限性

你可能注意到了,如果 Next.js 真的这么美好,那么本文的标题就应该是「使用 Hugo + Next.js 重构博客」了。

getStaticProps

一般来说,如果要使用 Next.js 的 SSG(Static Site Generator)模式,会在 getStaticPaths 函数返回所有静态页面,然后用 getStaticProps 来把静态页面的属性传递给组件。

对应到我的博客上,getStaticPaths 需要返回所有博客的列表,然后 getStaticProps 会去找寻每一篇博客对应的 JSON 文件,读取内容并把它通过 props 传递给组件。我一开始就是按照这样的思路来写的,没遇到什么问题。可就在编译完成后,我发现生成的结果除了包含了渲染后的 HTML,居然包含了传入的 props 内容!这也就意味着我的文章信息在渲染后的 HTML 中存了两份。我一开始以为是我的使用方式有问题,后来我发现React 官方文档也有这个问题。

这个问题其实让我有点膈应,因为它对于越长的文章,影响越大,尤其是我的博客放在 Cloudflare Pages 上运行,本来国内用户与 Cloudflare 之间的带宽就小,这样一来就更影响用户首次打开页面的体验了。

好在这个问题其实是有解决方案的。那就是写一个 Webpack 的插件,来自定义解析 JSONX 格式的文件(注意这里不能使用 JSON 格式了,需要把 JSON 格式改一个后缀,不然会和 Webpack 默认的 JSON 解析器冲突),把文件读取后返回一个定义了 React 组件的字符串,Next.js 会直接以这个包含着组件定义的字符串来作为正常页面渲染。而且这么做有一个好处,就是自定义的组件也可以正常渲染。

我为博客文章内部的图片都写了一个 LazyImg 的组件(图片出现在视窗里才加载)并通过 Hugo 的自定义渲染 Hook 来将 img 标签都替换成 LazyImg 的标签。如果是通过 dangerouslySetInnerHTML 来设置的话,LazyImg 组件的内容是不会被正常渲染的,只会当成纯文本来显示。

这个问题得到了妥善的解决,这也是我在本次重构博客耗费最多时间的地方。

URL 路径不支持中文

是的,Next.js 的 URL 是不支持中文的,不光不支持中文,所有的非 Ascii 字符都不支持。而且几年前有人提到了这个问题,但是官方一直没有修复。

虽然通过 getStaticProps 是可以支持中文的,不过因为重复包含 props 的问题,我不是特别想用这个方式。不过也没有其他更好的解决方式,中文的标签几百个,改起来是在太费事了。

于是,我只好针对文章目录,使用 Webpack 的自定义插件载入页面;针对标签和分类目录,还是通过 getStaticPaths 和 getStaticProps 来生成页面。虽然这样标签和分类目录会包含两份 props,不过好在这两种页面的内容都比较少:只包含文章的元信息的内容,这样影响到也不是很大——我是这样安慰自己的。

问题基本得到了解决,以一种不完美的方式。

pages 内的文件夹不支持软链接

这个问题其实还挺奇怪的,因为经过我的测试,其他支持 file-based 路由的框架遇到文件夹是软链接时,文件夹内的文件也都是可以正常被路由的。这个问题我懒得去研究了,索性直接在构建完成后,把 Hugo 输出的 posts 目录拷贝到了 routes 内。

渲染性能

Next.js 本身打包生成的 JavaScript 文件挺多的,而且挺大的,这其实是很影响渲染性能的。

在我配置稍差一点的设备上运行 Lighthouse,使用 Next.js 重构后的移动页面性能评分是 85 分,其中 Total Blocking Time 是 300 ms,Largest Contentful Paint 是 3.4 s。

老实说,看到这个结果我还挺失望的……我能做到的优化已经做得足够了:图片全都懒加载,React 的组件也都是懒加载,文章内容也都是静态 HTML、没有借助 JavaScript 渲染,我只能把这个锅甩给 Next.js 和 React 本身了。

于是,我不得不忍痛放弃了 Next.js。

终于还是 SolidStart

那么,有没有一个框架写法上是接近于 JSX,但是打包的结果比 Next.js 更小、性能又更强的呢?

其实还真有,而且还不只一个:

  1. 用 preact 来代替 react:preact 本身是一个轻量化的 react 替代品,对于 react v17 版本之前的 API 是完全兼容的,不过可惜 v18 版本的 renderToReadableStream 不兼容了。而 Next.js 13 版本正是基于 react v18 开发的,因此也不能直接用 preact 来代替 react 了。
  2. wmrfresh:这两个都是基于 preact 的框架,性能本身是比 react 强不少,也更为轻量化。不过我觉得 preact 标榜自己是 react 的轻量化替代,这样是走不远的,一开始就是靠着和 react 一样的 API 来吸引用户,现在还和 react 不兼容了,又能怎么吸引用户呢?
  3. SolidStart:基于 Solid.JS 的框架(我的个人主页是使用的 Astro + Solid.JS),还处于起步的阶段,我在深入使用中也发现了一些 bug,但是它的确是我能找到的最合适的 Next.js 替代品了:支持 JSX 语法,性能比 Svelte 更强,打包后的文件也足够小,支持增量刷新页面。

于是,我就决定使用 SolidStart 了。之前提到,Next.js 是通过写一个 Webpack 的插件来自定义 JSONX 文件的解析渲染和渲染,SolidStart 自然也是支持的,写一个类似功能的 Vite 的插件即可。而且 Vite 打包的速度比 Webpack 快太多了。

下面列一下我针对本次博客重构所做得一些优化和调整。

深色模式调整

这次我给深色模式加上了一个点击弹出的菜单项,不再是之前点击一下直接切换了:

  1. 浅色模式:此模式会永远保持浅色
  2. 深色模式:此模式会永远保持深色
  3. 跟随系统:此模式会跟随系统的模式来自动切换网站的模式

为此,需要有两个状态来分别表示用户的选择以及网站的实际模式。为什么需要两个状态呢?因为用户的选择(userSelected)和网站的实际模式(theme)并不是完全对应的:用户选择跟随系统选项的时候,网站的模式是不固定的,可能是深色也可能是浅色。

  • userSelected 可能值为三个:light、dark 和 auto,会存放在 localStorage 中;
  • theme 的可能值为两个:light 和 dark,这个值也是直接和当前网站模式对应的。
const ThemeMenu = ({ show, toggleShow }: ThemeMenuProps) => {
    const [selected, setSelected] = createSignal(isBrowser ? window.lt() : "auto")
    const handleClick = (e: MouseEvent, key: string) => {
        toggleShow(e)
        setSelected(key)
        let systemMode = key;
        if (key === "")
            systemMode = window.mt();
        setGlobal({ theme: systemMode })
        localStorage.setItem("customer-theme", key)
    };


    onMount(() => {
        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
            if (selected() !== 'auto') return
            const newColorScheme = e.matches ? 'dark' : 'light';
            setGlobal({ theme: newColorScheme })
        })
    })

		...
}

梳理一下深色模式生效的流程:

当用户初次进入网站或点击刷新的时候,首先会从 localStorage 中载入用户的选择,如果不是 auto,那么就直接把 html 标签的 class 设置成对应的值;如果是 auto,就用当前系统的模式。这部分的逻辑,是我单独抽出来的放在 head 标签里的,并没有和 JSX 组件放在一起:因为组件是在 DOM 加载完成后才渲染,如果这部分也放在组件逻辑中,那么页面可能会在组件渲染时出现闪烁的情况。

等到 JSX 组件加载完成后,theme 相关的逻辑会首先执行,它会直接使用 html 的 className 来作为初始值。

接着是切换模式按钮的渲染,这时 userSelected 会读取 localStorage 中的值,作为初始值。同时,在模式切换按钮的内部,我加上了一个 onMount 的函数,为系统的模式切换加上了一个 EventListener,当检测到系统模式发生改变且用户选择的是跟随系统时,会根据系统的模式来设置 theme 的值。

最后,当用户点击了切换按钮的时候,userSelected 会直接修改成用户选项所对应的值,同时存入 localStorage;而 theme 会复杂一点,需要判断一下:

  1. 用户选择的是跟随系统:先获取系统的模式,然后设置成 theme;
  2. 用户选择的不是跟随系统:将用户的选择设置成 theme。

userSelected 我使用的是 createSignal 存放,毕竟生命周期只是在按钮组件里;而 theme 我使用了 createStore 存放,因为跨了多层,而且使用的地方是最外层的 HTML 标签,无法使用 ContextProvider 包裹了。

加载状态的反馈

切换成现有架构后我发现了在页面切换时存在的一个问题,当用户点击页面时,会有几百毫秒的停顿时间,这期间是在等待加载渲染新页面所需的资源,而在当前页面也没有任何变化,这会让用户怀疑是不是没有点击或者网络出问题了。对比传统的静态页面,点击 a 标签时,左上角的刷新图标和标签 tab 会转圈给用户反馈表示新页面正在加载。

我首先考虑使用 Skeleton Screens,但是我发现它应用在全页面加载的时候会很怪,只适合在页面的部分区域加载时使用,于是我放弃了这个方案,转而使用了大部分的网站都用的顶部进度条来表示加载状态。顶部加载进度条其实就是两个状态,当页面切换的时候设置进度条开始,然后页面切换完成设置进度条结束;控制进度条结束比较简单,放在 onMount Hook 中即可。

控制进度条的开始则稍微复杂一点:SolidStart 有两个 Hook 可以设置成进度条的开始:分别是 useIsRouting 和 useBeforeLeave,经过实验我发现 useIsRouting 有一个 bug(feature?),当点击的是同页面的锚点时,useIsRouting 也会被触发,而点击同页面的锚点并不会造成 onMount Hook 的重复执行,所以如果使用了 useIsRouting 那么在点击锚点就会导致进度条开始了但一直不结束。

因此,我还是使用了 useBeforeLeave 这个 Hook 来设置成进度条的开始。不过 useBeforeLeave 也有一个 bug(feature?),虽然页面的锚点切换不会导致 useBeforeLeave 触发,但是页面的 SearchParams 的改变会导致 useBeforeLeave 的触发,我的博客在搜索页面会把搜索内容映射到 SearchParams 中,因此需要在 useBeforeLeave 中做一个判断:如果当前页面和目标页面的 pathname 一致,那么就不设置进度条开始:

const ContentLayout = (...) => {
  
    useBeforeLeave(e => {
        if (!(e.to.toString().startsWith(e.from.pathname) && e.from.pathname !== "/")) nProgress.start()
    })
    onMount(() => {
        nProgress.done()
    })
  
  	...
};

这里还需要额外判断一下源页面不是首页,否则首页跳转到任何页面都不会有进度条开始。

代码高亮调整

之前有提到过,我的页面是由 Loader 读取 JSONX 文件并将其内容包含在组件定义中返回,这样做的好处之前已经说了,可以让自定义的组件也可以正常渲染。但是也有坏处:渲染内容必须是合法的 JSX 组件。Hugo 生成的内容自然是标准的 HTML 组件,可却不一定是合法的 JSX 组件。比如<hr>就是一个标准的 HTML 组件,但是它没有闭合,所以不能算是一个 JSX 组件,如果直接丢在组件定义中返回,那么页面渲染就会出错。

当然,代码块中不存在未闭合组件的问题,却有{}的问题,在 JSX 组件中,花括号包含的内容会被认为是 JavaScript 表达式,而代码块中出现的大括号基本上是不可避免的。如果在 Hugo 的自定义 render-codeblock Hook 中对花括号进行转义,那么又会影响到 RSS 的输出。

所以我只能放弃了 Hugo 提供的代码高亮,render-codeblock Hook 我是这么写:

{{- $lang := .Attributes.lang | default .Type -}}
<Pre lang="{{ $lang }}"><code>{{ .Inner }}</code></Pre>

Pre 大写是为了标识它是一个自定义组件。接着在自定义 Loader 里,解析 Hugo 生成的字符串,并通过设置 xmlMode,这样 cheerio 会把所有 HTML 标签转化成自闭合的,然后不断调用 hljs 去渲染,然后把 base64 编码后的值返回,这里如果不使用 base64 的话,花括号仍然会导致 JSX 渲染错误。

import { load } from "cheerio";
import hljs from "highlight.js";
import { encode } from "js-base64";

const renderHighlight = (content: string) => {
    const $ = load(content, { xmlMode: true, decodeEntities: false });
    $('Pre').each((index, element) => {
        const lang = $(element).attr('lang')
        if (!lang || lang.includes("$lang")) return
        const code = $(element).find("code").html() || ""
        const highlightedCode = hljs.highlight(code, { language: lang }).value
        $(element).html(encode(highlightedCode))
    })
    return $.html()
}

export default renderHighlight;

Pre 组件就比较简单了,把传入的 children 用 base64 decode 一下然后塞到 innerHTML 中,确保以文本方式渲染就行了:

import { decode } from "js-base64"

const Pre = ({ children, lang }) => {
    const code = decode(children)
    return (
        <pre class="relative">
            <code class="hljs rounded" data-lang={lang} innerHTML={code} />
        </pre>
    )
}

export default Pre

所有图片懒加载

之前的博客因为担心懒加载的图片到 RSS 输出中就看不到了,所以文章内的图片没有做懒加载。本次本次重构中,我用了一种比较巧妙的方式让文章内的图片懒加载的同时,又可以能让 RSS 输出能正常看到图片。首先是 Hugo 定义的 render-image Hook:

<Img src="{{ $dest | safeURL }}" alt="{{ $text }}" />

这里的 Img 同样是我定义的组件,我用它覆盖掉了原生的 img 组件,这是它的定义:

import { createEffect, createSignal } from "solid-js";

const LazyImg = ({ src, ...rest }) => {
    const [visible, setVisible] = createSignal(false)
    const SVGFallback = '...'
    let self: HTMLImageElement;
    createEffect(() => {
        const observer = new IntersectionObserver(entries => {
            if (entries[0].isIntersecting) {
                setVisible(true)
                observer.unobserve(entries[0].target)
            }
        })
        observer.observe(self as HTMLImageElement)
    })

    return (
        <img ref={self!} {...rest} src={visible() ? src : SVGFallback} />
    )
}

export default LazyImg;

初始情况下,图片展示的是 SVGFallback 的内容(是一个 1x1 像素的图片的 base64 编码),当组件出现在视窗内的时候,将 src 设置成真实的 URL。

同时,因为我在 render-image Hook 中针对 Img 组件的写法和原生 img 组件的写法是一致的,只是名称大小写不同,所以它输出到 RSS 中的就是普通的 img 标签,能够被 RSS 阅读器展示。

中文路由的支持

之前提到,SolidStart 是一个刚起步的框架,因此存在一些小 bug,除了之前提到的 useIsRouting 外,还有不支持中文路由的问题。

我很确定这个是一个 bug 而不是 feature,bug 的原因在于包含了非 Ascii 字符的文件路径与 URL 的路径存在一层 URL 编码的转换,而 SolidStart 没有做这一层转换。知道了原因,修复起来就很简单了,在路由前给路径参数返回的值 Encode 一下,在 build 阶段的时候,根据 URL 路径写入文件前,再将路径 Decode 一次,it works like a charm。

我还就此问题给 SolidStart 提了一个 issue,不过他们还没回复我。

题外话,我试了好几个 file-based 路由的框架,只有 Svelte 是对中文的路由支持的。其他的框架都有这个 URL 编码的问题,不过 Next.js 貌似并不是因为这个问题。

其它的一些小调整

以下是一些没那么重要的调整,放在一起说一下:

推荐相关文章

在本次重构中,我还在文章页面的侧栏新增了一个区域,用于列出与当前文章相关的其他文章。相关度是根据文章标签计算的,如果找不到同标签的其它文章,那么就展示同分类的其它文章。

移动端的目录展示

之前移动端在文章页面是不展示侧边栏的,不过现在我感觉随着我的文章越写越长,为了移动端的阅读体验还是加上一个目录展示更合适一些。现在移动端文章页面会在右下角展示一个目录的按钮,点击就会弹出目录。

搜索页面

搜索页面现在会把用户的输入映射到 URL 中的 SearchParams 上面,反过来 SearchParams 中的输入也会映射到搜索框中。

尾声

本文算是我目前写的最长的一篇文章了,写长文的感觉还挺好的,能够把自己对一个项目完整地思考展现出来,写文的过程自己也能重新梳理自己的思路。

本次重构博客耗费的时间与精力远远超过了我的预估。我一开始预估也就一周内就可以完成,毕竟样式什么的都不用调整,直接复用就行了,没想到居然耗费了我将近一个月的时间,看来项目实际的开发时间起码是预估开发时间 x3,只多不少。

虽然过程稍微长了点,不过这次重构我也学到了不少东西,我挺满意的。

使用 Hugo + SolidStart 重构博客

https://blog.itswincer.com/posts/reconstruct-my-blog-with-hugo-solidstart/

作者

Wincer

更新于

May 10, 2023

许可协议

CC BY-NC-ND 4.0
  1. Jul 22, 2024

    又一次博客优化记录
  2. Sep 15, 2022

    聊聊博客主题的更新
  3. Mar 21, 2021

    博客搜索功能正式上线
  4. Aug 22, 2018

    博客折腾小记
  5. Jul 5, 2018

    博客访问统计报告(2017.6.20-2018.7.4)
  6. Jul 29, 2017

    再见 LiveRe,拥抱 Disqus