使用Nextjs和Contentlayer搭建我的blog
此前用typcho搭配handsome主题也写过一些博文,但没能把写博客的习惯坚持下来,总感觉作为前端建站套模版感觉有点不好意思。近几周正好手上活不多,趁这个机会搓个博客。
整体框架
毕业设计做的是论坛,本来打算做完再魔改成博客,选的 Nextjs SSR + Koa 后端 + quill 富文本编辑器。后来发现博客和论坛差别挺大——博客是单向输出,静态方案更合适,也不需要后端。干脆放弃在毕设上改,重新来。
1. 项目框架
要求很简单:支持 SSG,纯手搓不套模版。主要技术栈是 React,之前也写过 SSR 的 Nextjs 项目,直接用 Nextjs 最顺手。
文章展示是核心,评论相对次要,发布和评论可以拆开,不一定非要自己维护一整套后端。
文章模块
不打算集成编辑器,vscode + mdx 预览插件已经够用了。技术写作会有不少代码展示,也需要跨平台分享,markdown 是更合适的选择。
为什么选 MDX
MDX 能在 Markdown 里混写 JSX,可以在文章里插自定义图表、代码块,比纯 markdown 灵活不少。找内容管理工具时发现了 ContentLayer,很契合我的场景,结果集成完才发现原项目因为赞助问题停更了。暂时没找到更好的替代,就转到了社区分支 ContentLayer2,后面有更合适的再考虑迁移。
Contentlayer 里配置了什么
接入没想象中复杂,核心两件事。
第一件是定义 frontmatter 字段结构——标题、描述、日期、标签、头图、作者,提前声明好,漏了关键字段编译阶段就会报错,不用等到渲染时才发现少东西。
另外还定义了 slug 和 slugAsParams 两个计算属性,后面文章列表、详情页路由、标签筛选都能直接用编译结果,不用自己反复处理文件路径。
const computedFields = {
slug: {
type: 'string',
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
slugAsParams: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.split('/').slice(1).join('/'),
},
};
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `**/*.mdx`,
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
description: { type: 'string' },
date: { type: 'date', required: true },
tags: { type: 'list', of: { type: 'string' } },
image: { type: 'string', required: false },
authors: {
type: 'list',
of: { type: 'string' },
required: true,
default: ['haiminovo'],
},
},
computedFields,
}));MDX 渲染链路
第二件是让 MDX 展示更适合技术博客。挂了 remark-gfm 支持表格、任务列表,配了 rehype-slug、rehype-autolink-headings 和 rehype-pretty-code 处理标题锚点和代码高亮。标题天然带 id,后面目录导航组件直接复用,算是顺手把两件事一起解决了。
渲染时,Next 页面从 allPosts 取数据,把 page.body.code 交给自定义 Mdx 组件。Mdx 内部用 useMDXComponent 转成 React 组件,同时把 Callout、Card、代码复制按钮这些映射进去。写作时还是写 MDX,展示层完全按博客自己的样式走。
评论模块
静态页面,不想靠后端搞评论系统,需要额外部署服务的先排除。基于 GitHub 的方案看了几个,最后选了 Giscus——主要是它的 GitHub Action 比较方便,而且其他几个看起来很久没更新了。
评论区和主题同步
Giscus 自带亮色暗色主题,博客又支持手动切换,不同步的话页面切黑了评论区还是亮的,很割裂。
做法是主题存 localStorage,切换时除了改 html 的 dark 类名,再派发一个自定义事件,让评论组件跟着更新 theme 属性。暗黑模式按钮不直接操作 Giscus 实例,评论组件只管监听和更新,比较松耦合。前面那篇监听 localStorage 的文章,就是折腾这块时记下来的。
2. 部署方案
大二薅羊毛买了十年轻量应用服务器,放着浪费,挂博客刚好。也有想实操一下 SEO 的意思,所以不挂 Vercel 或 GitHub Pages。
为什么走静态导出
部署到自己的服务器,Next 这边就尽量往纯静态靠。配置了 output: 'export',构建后直接产出静态文件,博客这种以读为主的站点够用了。动态路由在静态部署下刷新会 404,顺手加了 trailingSlash: true。
import { withContentlayer } from 'next-contentlayer2';
const nextConfig = {
output: 'export',
trailingSlash: true,
images: {
loader: 'akamai',
path: '',
unoptimized: true,
},
};
export default withContentlayer(nextConfig);图片也处理了一下。静态导出用不了 Next 自带的图片优化服务,切成 unoptimized 配合静态 loader,先保证能稳定跑起来。后面有精力再研究更细的图片策略。
自动化部署
大四实习的两家公司都有 CI/CD,push 完等自动打包发布,比手动登服务器舒服太多。但自己从零搭才发现,麻烦的不是写脚本,是把构建、上传、覆盖、回滚这些环节串顺。
目前先把”本地写作 → Contentlayer 编译 → Next 静态导出 → 服务器托管”这条主链路跑通,保证站点能稳定更新。CI/CD 后面再补,先把页面和文章体验打磨好。
3. 布局及样式
响应式布局方案
对于博客界面,响应式是肯定要做的,界面整体打算做三栏响应式布局,pc 上展示为三栏,平板上展示为两栏,移动端展示单栏。侧边栏定宽,内容栏自适应,使用媒体查询和 flex 布局实现界面适配。
页面整体布局
最外层 RootLayout,顶部导航栏,中间左右侧边栏加主内容区。右侧挂标签、目录、返回顶部,左侧放个人信息和站点信息。小屏幕下断点隐藏部分侧栏,把空间让给正文。比一开始就只做单栏灵活一些。
首页和文章页的区分
首页偏展示,横幅、头像、近期更新,做个轻量落地页。文章详情页强调阅读体验,目录、评论区、返回顶部这些辅助组件围着正文转。
CSS 方案
毕设前最后一段实习接触到 tailwindCSS,不用写样式名、不用管 BEM、不用纠结优先级覆盖,直接上瘾。没试过的建议试试原子化 CSS。
默认主题
很早之前就想实现一个可以任意调整主题颜色的功能,这次实现的思路是根据传入的基础色使用 js 生成由基础色到纯白以及由基础色到纯黑的 1 到 10 十个级别的共 20 种颜色给到 tailwindCSS,
在需要使用主题色的元素中尽量只使用这 20 种颜色,这样再更换主题色时只需要调整输入的基础色就可以完成整套颜色样式的更新。现在这部分逻辑我直接写在 tailwind.config.mjs 里,
这样启动和构建时就能直接生成主题色。
const hexToRgb = (str) => {
let hexs = null;
const reg = /^\#?[0-9A-Fa-f]{6}$/;
if (!reg.test(str)) {
throw new Error('Invalid color value in tailwind.config.mjs');
}
str = str.replace('#', '');
hexs = str.match(/../g);
return hexs?.map((item) => parseInt(item, 16));
};
const generateColor = (str, mode = 'light') => {
let basicColor = hexToRgb(str);
if (!basicColor) return [];
if (mode === 'dark') basicColor = basicColor.map((item) => item - 100);
const stepArr = basicColor.map((item) => (mode === 'dark' ? (item / 10).toFixed(0) : ((255 - item) / 10).toFixed(0)));
return new Array(10).fill(undefined).map((_, index) => {
const targetColor = basicColor.map((item, colorIndex) => {
return mode === 'dark'
? item - index * +stepArr[colorIndex] < 0
? 0
: item - index * +stepArr[colorIndex]
: item + index * +stepArr[colorIndex] > 255
? 255
: item + index * +stepArr[colorIndex];
});
return `rgb(${targetColor[0]},${targetColor[1]},${targetColor[2]})`;
});
};颜色导入方式
在 tailwind.config.mjs 中将自定义颜色导入:
const basicColor = '#e2e5e8';
const themeColors = {};
generateColor(basicColor).forEach((item, index) => {
themeColors[`custom-color-${index + 1}`] = item;
});
generateColor(basicColor, 'dark').forEach((item, index) => {
themeColors[`custom-color-dark-${index + 1}`] = item;
});
const config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
'font-light': '#5a6a7a',
'font-normal': '#3a4a5a',
'font-strong': '#1a2a3a',
'font-light-dark': 'rgba(255,255,255,0.9)',
'font-normal-dark': 'rgba(255,255,255,0.7)',
'font-strong-dark': 'rgba(255,255,255,0.5)',
...themeColors,
},
},
},
};这种方案对基础色要求比较高,选不好深色模式会一团黑,浅色模式亮得晃眼。后面打算重写一下颜色生成逻辑。
暗黑主题
tailwindCSS 有自带的 darkmode 方案,这里整体上也是沿用了同样的思路,只不过主题切换的控制权放在我自己的按钮组件里。
初始化时先从 localStorage 读取用户上次选择的主题,再给 html 节点增删 dark 类名,后续点击切换时同步更新本地缓存和页面状态。
暗黑模式没再手动配一套复杂色板,更多用纯白加透明度区分层级。朴素是朴素,但和偏浅灰、低饱和的主题搭起来还行,维护也轻松。
4. 文章展示细节
数据流转
写起来才发现,文章系统除了”能展示”还有一堆零碎细节。文章列表页消费 allPosts,标签页在同一份数据上过滤,侧边栏标签统计也来自 Contentlayer 编译结果。新增一篇带标签的文章,列表、筛选、站点信息自动更新,不用维护额外数据文件。
详情页展示结构
动态路由根据 slugAsParams 找文章,生成 title、description、keywords,基础 SEO 先保证。头图、简介、正文、评论区按流程拼起来,后面扩展阅读量、上一篇下一篇也方便。
小功能
标题锚点、代码复制、代码高亮、目录导航,单独拿出来没什么,但少了就觉得差点意思。博客不是把文章渲染出来就完了,体验往往藏在这些边角里。
5. 小结
博客还远没做完,自动化部署、主题系统、图片处理、写作体验,后面可折腾的还很多。
技术路线倒是基本稳了:Next.js 管页面和路由,Contentlayer2 编译 MDX,Giscus 评论,TailwindCSS 样式,再加自己手搓的组件,核心能力差不多齐了。
踩了坑或者补了什么细节,会继续记录。