博客 V2

终于又抽出来一点时间来翻修自己的博客,现在它已经有一个基本的形状了。

新博客丢掉了 Typecho 换成 Next.js 从头搭建个人主页。毕竟 GCP 每个月出站流量也是 💰,而且我终于可以用上全村最骚的前端技术自由发挥定制博客了。

SSG!

静态生成整个站点,整个主页和全部博客内容都在构建时处理完成,这样就可以直接部署到 Cloudflare Pages 这样的静态站托管平台了还不收钱

可这前端技术实在发展太快,明明也才一年多没仔细关注前端发展,什么按需水合、什么岛屿架构把我的心智击穿又击穿。 特别是脱水(dehydration)和水合(hydration),再加上 Next.js 默认会给塞一些 node 模块的 polyfill,一段代码在两边跑两遍会发生一些不可名状的事情,有时候还会出莫名其妙的 hydration error:

为什么
为什么

我选择放弃治疗,只要能渲染不崩就行(

自己编写 markdown 主题

自己写主题就可以用上一些潮流的 CSS 特性,比如 :has() pseudo class,这个特性 Chrome 五个月前刚刚 ship,用它可以做出来精致的嵌套 quote 效果

Blockquotes can also be nested...

...by using additional greater-than signs right next to each other...

...or with spaces between arrows.

Blockquotes can also be nested...

...by using additional greater-than signs right next to each other...

and return to last block

而不是这样
而不是这样

以及手搓出来的更好看的代码块,支持文件名、行号以及高亮特定关键词。使用了 rehype-pretty-code,需要自己编写高亮和行号样式。搬运之前的博客的时候把代码块样式也重构了一下,比如VSCode 黑魔法探秘之插件加载机制这篇的代码块都加上了对应的高亮。

test
import { defineDocumentType, makeSource } from './lib/contentLayerAdapter'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
// @ts-ignore
import remarkDirective from 'remark-directive'
import remarkMath from 'remark-math'
import rehypeMath from 'rehype-katex'
import rehypeHighlight from 'rehype-highlight'
import rehypeMinify from 'rehype-preset-minify'
 
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `documents/posts/**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: {
      type: 'string',
      required: true,
    },
  computedFields: {
    path: {
      type: 'string',
      resolve: (post) => `/posts/${post.slug || post._raw.flattenedPath}`,
    },
  },
}))
 
export default makeSource({
  contentDirPath: 'documents/posts',
  documentTypes: [Post],
  mdx: {
    remarkPlugins: [remarkParse, remarkMath, remarkGfm, remarkDirective],
    rehypePlugins: [[rehypeMath, { throwOnError: true, strict: true }], rehypeHighlight, rehypeMinify],
  },
})
test
import { defineDocumentType, makeSource } from './lib/contentLayerAdapter'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
// @ts-ignore
import remarkDirective from 'remark-directive'
import remarkMath from 'remark-math'
import rehypeMath from 'rehype-katex'
import rehypeHighlight from 'rehype-highlight'
import rehypeMinify from 'rehype-preset-minify'
 
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `documents/posts/**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: {
      type: 'string',
      required: true,
    },
  computedFields: {
    path: {
      type: 'string',
      resolve: (post) => `/posts/${post.slug || post._raw.flattenedPath}`,
    },
  },
}))
 
export default makeSource({
  contentDirPath: 'documents/posts',
  documentTypes: [Post],
  mdx: {
    remarkPlugins: [remarkParse, remarkMath, remarkGfm, remarkDirective],
    rehypePlugins: [[rehypeMath, { throwOnError: true, strict: true }], rehypeHighlight, rehypeMinify],
  },
})

当然还有公式支持:

假設有  CC  與  DD  兩個範疇,而函子  FF  是  CC  與  DD  之間的映射

  • CC  範疇中的每個 object XX  都與  DD  範疇之中每個  F(X)F(X)  相關聯
  • FF 將  CC  中的每個 morphism f:XYf:X \rightarrow Y  映射到  DD  中的 morphism F(f):F(X)F(Y)F(f):F(X) \rightarrow F(Y)  並且滿足以下兩個條件:
    • 對於  CC  中的每個  XXF(idx)=idF(x)F(id_x)=id_{F(x)}
    • 對於  CC  中的  f:XYf:X \rightarrow Y  和  g:YZg:Y \rightarrow Z  等所有 morphism ,F(gf)=F(g)F(f)F(g \circ f)=F(g) \circ F(f)

暗色模式只会遵循浏览器/系统设置(

加密文章

由于这是个 SSG 的纯静态站,给文章上密码没有办法后端校验,唯一的方法是真·加密。幸好技术选型的时候文章是通过 Contantlayer 处理一遍,生成文章内容的 React 组件(一段 JS 脚本)之后再交给 Next.js 渲染的,文章内容和页面布局组件是解耦的,可以对文章内容单独加密,在布局组件中解密,成功后再渲染。

加密解密都用 AES-CBC,iv 跟文章内容存放在一起,十分地简单。不要问我为什么加解密用的不是同一个 crypto,那是来自 hydration 的爱

import nodeCrypto from 'crypto'
 
async function aesKeyFromString(crypto: SubtleCrypto, key: string, alg: AesCbcParams) {
  const ek = new TextEncoder().encode(key)
  const hash = await crypto.digest('SHA-256', ek)
 
  return await crypto.importKey('raw', hash, alg, true, ['encrypt', 'decrypt'])
}
 
// encryption on server side(build time)
export async function encrypt(text: string, password: string) {
  const iv = nodeCrypto.getRandomValues(new Uint8Array(16))
  const alg = { name: 'AES-CBC', iv } satisfies AesCbcParams
  const key = await aesKeyFromString(nodeCrypto.subtle, password, alg)
 
  const data = new TextEncoder().encode(text)
  const buf = await nodeCrypto.subtle.encrypt(alg, key, data)
 
  return [Buffer.from(iv).toString('base64'), Buffer.from(buf).toString('base64')] as const
}
 
// decryption on client side(runtime)
export async function decrypt(text: string, password: string, ivStr: string) {
  const iv = Buffer.from(ivStr, 'base64')
  const alg = { name: 'AES-CBC', iv: iv } satisfies AesCbcParams
 
  const key = await aesKeyFromString(crypto.subtle, password, alg)
  const cipher = Buffer.from(text, 'base64')
  try {
    const plain = await crypto.subtle.decrypt(alg, key, cipher)
    return new TextDecoder().decode(plain)
  } catch (e) {
    console.error(e)
    return undefined
  }
}
import nodeCrypto from 'crypto'
 
async function aesKeyFromString(crypto: SubtleCrypto, key: string, alg: AesCbcParams) {
  const ek = new TextEncoder().encode(key)
  const hash = await crypto.digest('SHA-256', ek)
 
  return await crypto.importKey('raw', hash, alg, true, ['encrypt', 'decrypt'])
}
 
// encryption on server side(build time)
export async function encrypt(text: string, password: string) {
  const iv = nodeCrypto.getRandomValues(new Uint8Array(16))
  const alg = { name: 'AES-CBC', iv } satisfies AesCbcParams
  const key = await aesKeyFromString(nodeCrypto.subtle, password, alg)
 
  const data = new TextEncoder().encode(text)
  const buf = await nodeCrypto.subtle.encrypt(alg, key, data)
 
  return [Buffer.from(iv).toString('base64'), Buffer.from(buf).toString('base64')] as const
}
 
// decryption on client side(runtime)
export async function decrypt(text: string, password: string, ivStr: string) {
  const iv = Buffer.from(ivStr, 'base64')
  const alg = { name: 'AES-CBC', iv: iv } satisfies AesCbcParams
 
  const key = await aesKeyFromString(crypto.subtle, password, alg)
  const cipher = Buffer.from(text, 'base64')
  try {
    const plain = await crypto.subtle.decrypt(alg, key, cipher)
    return new TextDecoder().decode(plain)
  } catch (e) {
    console.error(e)
    return undefined
  }
}

實作效果見 世界は豊かに、そして美しく,當然密碼只能猜猜咯?

Test Password 密碼是 test

更快的加载速度

你可能会注意到页面加载速度变快了很多 🚀

事 CDN,你换了 CDN!

的确是这样的,我把字体换成了饿了么的 CDN(谢谢他们的 npm CDN 镜像),现在加载 webfont 的 css 文件只需要不到 100ms,按需加载一个字体块也只要 300-400ms。另外 Next 自带了很多优化,比如 split chunk, tree shaking,再也不用操心那团混乱的 webpack 配置。

除去上面的优化,新的主页还加入了 Service Worker 支持,workbox 的 precache 功能可以预先缓存一些公用静态资源,在页面间跳转的时候就不需要再从网络上获取,直接使用本地缓存就会显得非常快。

现在页面打开速度和国内网页差不多,唯一拉垮的地方就是 CF Pages 的国内延迟非常烂,即使页面的主要内容只需要 100ms 不到就可以渲染出来 TTFB 也还是需要 1.2-1.5s 左右。

Next?

博客文章还没有搬运完,由于每篇文章都是手动搬运的剩下的大部分都是互联网垃圾,所以进度会非常慢;文章也需要一个评论系统,或许会接入一个现成的吧当然是自己搓啦。

还有一些细节正在施工中 🚧。

祝元宵快乐 🎇

Semesse avatar
Semesselast year

--UPDATE--

可以发布评论

Semesse avatar
Semesselast year

當然是使用了 CF Workers+KV(

Semesse avatar
Semesselast year

支持 i18n 了,但内容必须在浏览器渲染,会有 Layout Shift 🥲 后面有时间了再折腾一下用 CF Workers 做一个分流

Semesse avatar
Semesselast year

把评论做成接近可视区域才 lazy load 的形状了,fork 了一份 rehype-pretty-code 掺了点魔法去掉了对 crypto 的依赖(不然我们的小聪明 Next.js 会把 polyfill 塞进 first chunk 😅),fork 了一份 next-contentlayer 稍微改了一下让整个文章内容都 SSR 渲染,这样就不需要客户端再水合了

另外弄了一个 remark 插件和一个 rehype 插件处理 markdown 里面的图片添加元数据让 Next.js 可以用一个模糊底图当 placeholder(credit to圖片效能最佳化,使用 Next.js Image、plaiceholder、客製 MDX 元件 - Modern Next.js Blog 系列 #22

今天的优化就到这里,睡觉(

Neruthes avatar
Nerutheslast year

评论系统做得挺有意思的,我最近新增的评论区实践是查idarticleiddiscussionid_{article} \leftarrow id_{discussion}表后从 GitHub API 拉 repo discussions 区内对应该文章的 post 下的评论区,效果参考https://neruthes.xyz/articles-comments/?id=2022-12-12.0。不过因为我不打算为单独文章做 HTML 版本,所以无法将文章内容与评论区放在同一页面内。

Semesse avatar
Semesselast year

给评论加上了 KaTeX 支持,以及 inline critical css, 制作了一个小的 GA 代理

但是样式又烂掉了)

Semesse avatar
Semesselast year

UPDATE:支持了 RSS 和 tag,调整了一下样式

Semesse avatar
Semesselast year

以及自建了 OSS 放图片(

Semesse avatar
Semesse12 months ago

總算找到了一個比較搭的英文字體,把主色調修改到了 REC2020 色域的藍/綠色,在支持廣色域的設備和瀏覽器上會看到更鮮艷的顏色(?

以及略微調整了一下樣式

Semesse avatar
Semesse10 months ago

从 Next.js 迁移到了 Astro,并且框架从 React 换成了 Preact,再也不用忍受优化不掉的 100+KB (gzipped) 的 React+Next 大礼包了 😇 现在文章页面不包含评论区只需要 27.1 KB,而原先需要 274 KB

调整了一下样式以及给每篇文章加上了自动生成的 OG Image,支持了发送邮件通知(

刚迁移完还有好多 Bug

Semesse avatar
Semesse10 months ago

test

Semesse avatar
Semesse10 months ago

折腾了一下 GeoDNS 和分区 CDN,用旧域名搭建了一个境内加速站点

当然备案是不可能有备案的,只能用 CloudFront 的东亚节点这样子

Semesse avatar
Semesse10 months ago

用 thumbhash 复刻了 Next.js 的 image placeholder,现在文章图片也有加载动效啦

Semesse avatar
Semesse10 months ago

把 Next.js 的 link prefetch 也复刻了一下,配合 CloudFront 就可以秒开了

Semesse avatar
Semesse9 months ago

对评论做了一下服务端预渲染,不过因为 astro 的类 RSC 特性再加上用了异步 remark/rehype 插件没有办法同步渲染所以实现得有点 tricky。

并且滚动到评论区触发 hydration 第一次渲染的时候会因为 markdown 是异步渲染的(渲染完再setState),不管返回了什么都会替换掉服务端渲染好的 markdown DOM,这一小段 loading 时间只能展示点别的东西(比如现在是恢复成未渲染的 markdown),这下学艺不精了

Semesse avatar
Semesse9 months ago

最后还是没有忍住,略施小计做了个延迟水合,现在评论水合不会造成 Layout Shift 了。就是代码有点丑 🤡

Loading New Comments...