好久没写博客了,每当我有些想写代码的情绪无处发泄的时候,我就会开始折腾之前写的老项目,而我的博客又是一个稍微有一点复杂的项目(Hugo + SolidStart),项目的复杂度一旦升高,难免会在不同需求之间做权衡,难以做到尽善尽美,所以博客一般是我折腾次数最多的。这次折腾的起因是我发现 SolidStart 在前段时间发布了 1.0 版本,相比之前的 beta 版本有不少改动,借着更新博客的机会,正好水一篇文章:)
SolidStart 的升级
除开 URL 路径终于支持 UTF-8 字符外,本次服务端渲染方式相比之前变化是最大的,花了点时间做了相关的适配。
目前博客改成了使用 Hugo 输出 JSX 格式的内容文件,本质上还是 JS 的 Object,只是在内容开头加上了 export default
,这样 SolidStart 就会根据文件的 [.jsx] 后缀把它当成页面去渲染,而不需要我再指定文件后缀去渲染页面;然后我再通过自定义的 Vite 插件把 JSON 内容都渲染成 JSX 内容。这样的构建流程会相比之前的更直观一点,也方便排查问题。
SolidStart 1.0 版本页面的加载顺序变成和 RemixJS、NextJS 一样了,即页面所有的 JS 文件,无论是动态导入还是静态导入都会同时加载。在之前的版本,有一些动态导入的文件,要等它的依赖项(比如 entry-client.js)全部加载完成之后才会发出请求。
但是现在版本打包的静态资源大小会相比之前更大一些,主要是包含了完整的 vite manifest(这一点我不太喜欢,因为我阅读源码之后发现是可以避免的),而且这个 manifest 映射是直接插入在 HTML 文件的 script 标签里的!虽说 gzip 后也就几 KB 的差距,但是会让 DOMContentLoaded 的耗时加长,等看后续 SolidStart 版本更新看看是否会有改进。
不过我手动测试了好几次 DOMContentLoaded 和 Load 的耗时,测试结果上来看和之前倒没啥差别,终究还是我有点强迫症了……
i18n 的逻辑调整
我博客目前的英文文章是通过 URL 来区分的,而 SolidStart 现在只有在 Route
内部才能使用 useLocation
判断当前渲染的页面是什么,这让我有点犯难:
- 我的博客页面的 Header 部分和 Footer 部分也是需要参与页面的中英文切换,而它们在所有页面都是不变的,因此它们不应该放在
Route
内部,而应该和作为Route
同级的元素; - 我使用的 typesafe-i18n 如果在
Route
内部使用在服务端渲染的时候会报错;
所以我不得不使用了一个有点别扭的办法:通过渲染页面的数量来判断当前的页面是否是英文页面。但是这也有一个前提,就是需要确保所有英文页面是先被渲染的。好在 SolidStart 的页面预渲染顺序可以通过 prerender.routes
来指定,所以我目前是把英文的页面以及自定义 404 的页面在该参数里指定,然后剩余的页面让程序构建时自动爬取即可。
去掉标签详情页面
在上次更新博客时,我就去掉了标签云页面改成了在页面底部随机展示其中一部分标签。而我最近在查看博客统计时,发现标签详情页面的访问数还是少得可怜——近半年来,超过 10 次访问的标签页面都寥寥无几。
因此这次更新我就直接把标签详情页面去掉了,底部的随机标签展示部分也去掉了,文章详情页的标签倒是还留着,不过点进去已经不是标签详情页面了,而是搜索页面根据标签过滤的结果。我很满意搜索功能的实现,但是发现搜索页面的访问量还是很低,干脆给它引流一下。
ServiceWorker 骚操作
Service Worker 最广泛的应用应该是根据不同规则缓存页面的静态资源,以便在不发生 HTTP 请求的情况下返回缓存的资源,来提高网页的性能。
这个骚操作就用到了 Service Worker 最关键的两点功能:拦截和返回。具体来说,Service Worker 会拦截本站除图片外所有的静态资源的 HTTP 请求,然后在 Service Worker 内部根据请求的资源名同时去不同的 CDN 取数据,其中有任意一个 CDN 节点先返回完整的资源即代表本次请求成功。同时把其他正在等待返回的请求 abort。如果不 abort 的话,当同时请求的资源比较多的时候,浏览器可能会限制住后续的请求发送。
Chrome 是限制同一个 tab 每个 host 只能最多有 6 个 tcp 链接。
以下是代码实现:
const ASSETS_PREFIXES = [
`CDN_HOST_1`,
`CDN_HOST_2`,
`CDN_HOST_3`,
``
]
const fetchAsset = (url: string, signal: AbortSignal) => {
return new Promise((resolve, reject) => {
fetch(url, { signal })
.then(async res => res.ok ? resolve(res) : reject())
.catch(() => reject())
})
}
const catchAssets = async (pathname: string) => {
const controller = new AbortController(),
signal = controller.signal;
return Promise.any(ASSETS_PREFIXES.map(prefix => fetchAsset(`${prefix}${pathname}`, signal)))
.then(async res => {
const body = await res.text();
controller.abort();
return { headers: res.headers, body: body, status: res.status }
})
.catch(err => console.log(err))
}
registerRoute(({ request }) => (request.destination === 'script' || request.destination === 'style'),
async ({ event }) => {
const parsedUrl = new URL(event.request.url);
const { body, ...rest } = await catchAssets(parsedUrl.pathname)
return new Response(body, rest)
}
);
这个功能之前在饿了么的 NPM 镜像网站还没有下架的时候挺好用的,因为饿了么用的是阿里云的 CDN,在国内的访问速度非常快,后来饿了么的镜像失效后,其他的一些公益镜像速度就不太行了。也导致了我也一直没写文章介绍这个功能,感觉确实在如今有些鸡肋了。
文章大纲自动滚动
现在电脑端右边侧栏的文章大纲会自动跟随阅读的进度聚焦。如果大纲也可以滚动的话,那么大纲的滚动条会调整至当前页面正在阅读的次级标题正好为顶部的位置。
之前一直觉得这个可能有点麻烦就没做,现在实现了发现确实有点麻烦,关键点在于边界情况:
当网页设置了 scroll-behavior: smooth
属性时,且页面存在 A,B,C,……,J,K 等多个次级标题,导致文章大纲高度超过了容器高度限制,出现了滚动条。目前我已经看到文章末尾,此时大纲元素的顶部不再是 A 标题了;但当我通过大纲滚动条滑动到进度条最上面,并点击了 A 标题的链接,A 标题的链接此时在大纲容器里属于未溢出状态,同时由于 scroll-behavior: smooth
的存在,页面会逐渐从 K -> J -> … -> C -> B 然后到达 A。而页面在向上滚动经过 C 标题的一瞬间,大纲也应该要随之聚焦到 C 标题,并将大纲滚动条调整至 C 标题为顶部的位置,而此时 A 已经向上溢出了,但当页面继续滚动到 A 标题时,大纲滚动条又回到了 A 为顶部的位置,这就会造成一种大纲进度条「反复横跳」的现象。
有个比较简单的方法解决:不要设置这么多次级标题去掉 scroll-behavior: smooth
属性。去掉之后,页面会瞬间从结尾切换到 A 标题的位置。
复杂的解决办法,设置一个信号跟踪自动滚动状态,:
const [isScrolling, setIsScrolling] = createSignal(false)
const [activeId, setActiveId] = createSignal('');
createEffect(() => {
if (hash()) {
setIsScrolling(true) // 页面 hash 出现变化,设置自动滚动
setTimeout(() => setIsScrolling(false), 1000) // 一秒之后解除自动滚动
}
})
const handleIntersect = (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !isScrolling()) { // 当页面处于自动滚动状态的时候,不要激活对应的大纲 id
setActiveId(entry.target.id);
}
});
}
一些微调
另外还做了一些小的调整,没啥技术含量,统一在这里说一下:
搜索页面
现在点击搜索页面搜索的结果跳转也是增量更新了,不再需要请求完整的 HTML 页面了。这一点改动其实就是把搜索接口 API 返回的 URL 从绝对路径改成了相对路径。
字体
标题和正文的字体家族相较之前做了交换,标题改成了使用衬线体而正文改成了使用非衬线体,这样在文章详情的页面会显得没有那么锐利,阅读长文的时候眼睛不会那么累。
构建参数
vite 的构建参数加上了 minifyInternalExports 和 experimentalMinChunkSize,这让打包成的静态资源文件进一步减少。
博客首页加载的必要静态资源(HTML、CSS 和 JS)只有 48 KB!全部请求也只有 12 个!强迫症表示很满意。