Nextjs搭建个人博客
2024-04-06 · 12min我很喜欢Anthony Fu的博客设计,简单的黑白灰配色,同时首页还有随机的梅花生长动画作为背景,左上角的 SVG 签名动画,动画过渡的主题切换,都透露着简约但不简单的感觉。了解到该博客是使用Nuxt创建的后,因为工作我使用 React 比较多,便决定使用Nextjs实现一个同样简约风的博客。
技术选型:
- Nextjs
- Typescript
- tailwindcss
- MDXRemote
项目文件结构:
|-- blog-nextjs
|--public
|-- fonts 字体文件
|-- app
| |-- favicon.ico
| |-- globals.css
| |-- layout.tsx
| |-- loading.svg loadingSVG
| |-- loading.tsx loading组件
| |-- page.tsx 首页
| |-- blogs
| |-- page.tsx 博客列表
| |-- loading.svg 博客加载SVG
| |-- loading.tsx 博客加载动画
| |-- [slug] 博客详情
| |-- layout.tsx
| |-- markdown.css
| |-- page.tsx
|-- components
| |-- blogList
| | |-- index.tsx 博客列表
| |-- header
| | |-- index.tsx 顶部header
| |-- homeLink
| | |-- index.tsx 顶部导航
| |-- ThemeContent
| | |-- index.tsx 页面主题切换Provider
| |-- ThemeSwitch
| |-- index.tsx 页面主题切换开关
|-- data
| |-- post.ts 处理博客数据(博客列表、博客详情)
|-- lib
| |-- utils.ts
|-- posts 写博客的地方
|-- 博客文件夹1
| |-- index.mdx
|-- 博客文件夹2
|-- components.js
|-- index.mdx
因为之前博客分别用过 Vupress、Vitepress 以及Docusaurus构建,比较喜欢这种将 markdown 存于项目中,通过读取文件的方式来渲染 markdown,而 nextjs 服务端组件恰好支持这种需求。
博客首页
import "./globals.css";
import ThemeContent from "@/components/ThemeContent";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className=" flex flex-col items-center bg-[--bg] text-[--text]">
<ThemeContent children={children} />
</body>
</html>
);
}
主题切换
这里查看了 Anthony Fu 博客中主题切换的过渡动画,发现在切换的过程中发现其页面html标签下出现了一个名为view-transition的伪类。

查阅后下,这是View Transitions API的产物,这里先不过多展开探讨,先把主题切换过渡给实现出来。
虽然 Nextjs 自带 darkmode,但是并不能支持主题动态切换,所以我这里使用next-themes的客户端组件来控制博客的主题切换功能。
"use client";
import { ThemeProvider as NextThemeProvider } from "next-themes";
import { ReactNode, useEffect, useRef, useState } from "react";
import Header from "../header/header";
import Arrow from "./arrow.svg";
const ThemeContent: React.FC<{ children: ReactNode }> = ({ children }) => {
const [backVisible, setBackVisible] = useState<boolean>(false);
const topRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
const io = useRef<IntersectionObserver>(
new IntersectionObserver((entries) => {
if (entries[0]) {
setBackVisible(!entries[0].isIntersecting);
} else {
setBackVisible(false);
}
}),
);
useEffect(() => {
if (topRef.current) {
io.current.observe(topRef.current);
}
return () => {
io.current.disconnect();
};
}, []);
const handleBackTop = () => {
mainRef.current &&
mainRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
};
return (
<NextThemeProvider themes={["dark", "light"]}>
<button
className={`fixed bottom-3 right-3 flex h-10 w-10 items-center justify-center rounded-full opacity-30 transition-all hover:bg-[var(--tagBg)] hover:text-[var(--title)] hover:opacity-100 ${backVisible ? "visible" : "hidden"}`}
onClick={handleBackTop}
>
<Arrow />
</button>
<Header />
<main
ref={mainRef}
className=" mt-[24px] h-[calc(100vh-48px)] max-w-[48rem] overflow-auto"
>
<div ref={topRef} className=" h-[1px] w-full"></div>
{children}
</main>
</NextThemeProvider>
);
};
export default ThemeContent;
ThemeContent 中还添加了一个返回顶部的按钮,在内容区顶部添加一个宽度占满,高度为 1px 的透明元素,通过IntersectionObserver API来监听内容区域是否处于顶部,以此来控制返回顶部按钮的显隐。
"use client";
import { useTheme } from "next-themes";
import React, { useEffect, useState } from "react";
import Sun from "./sun.svg";
import Moon from "./moon.svg";
const ThemeSwitch: React.FC = () => {
const [mounted, setMounted] = useState<boolean>(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
const toggleTheme = (
event: React.MouseEvent<HTMLInputElement, MouseEvent>,
) => {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
);
let isDark = theme === "dark";
if (!document.startViewTransition) {
setTheme(isDark ? "light" : "dark");
return;
}
const transition = document.startViewTransition(async () => {
setTheme(isDark ? "light" : "dark");
});
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark ? [...clipPath].reverse() : clipPath,
},
{
duration: 400,
easing: "ease-in",
pseudoElement: isDark
? "::view-transition-old(root)"
: "::view-transition-new(root)",
},
);
});
};
if (!mounted) {
return null;
}
return (
<label className="relative flex justify-center cursor-pointer hover:text-[var(--title)] transition-all">
{theme === "dark" ? <Moon /> : <Sun />}
<input
className=" absolute hidden"
type="checkbox"
onClick={(event) => toggleTheme(event)}
/>
</label>
);
};
export default ThemeSwitch;
ThemeSwitch 可以根据自己喜好放在博客任意位置,我这里就添加到页头最右侧。
过渡动画的实现还需要在 global.css 添加 View Transitions 的伪类样式设置
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
[data-theme="dark"]::view-transition-old(root) {
z-index: 1;
}
[data-theme="dark"]::view-transition-new(root) {
z-index: 999;
}
::view-transition-old(root) {
z-index: 999;
}
::view-transition-new(root) {
z-index: 1;
}
至于首页就是十分纯粹的TSX文件渲染的页面。
博客列表
渲染博客列表之前需要先拿到博客列表数据。 我将所有的mdx文件放在项目中的 posts 文件夹中(src/app/posts)。每一篇博客创建一个文件夹,文件夹中包含一个 index.mdx 文件,index.mdx 就是写博客的地方。 交互组件在当前文件夹中编写的组件代码,最后通过公共的 components 文件统一导出所有组件。
import BlogList from "@/components/blogList/list";
export default function BlogPage() {
return <BlogList />;
}
这里我将 component 单独抽离到 app 路径外,你可以更好的管理你的组件。app 文件下的文件都是页面文件。这样划分,职责功能更加清晰。
export default async function BlogList() {
return <div className="relative -top-[10px] flex flex-col gap-8">...</div>;
}
接下来就需要获取博客列表数据,新建 data 文件夹(src/app/data/post.ts),这里集中处理读取博客内容,获取到博客列表信息,和博客详情。可以理解为数据库操作。
读取全部文章数据:
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { pinyin } from "pinyin-pro";
import { isChinese } from "@/lib/utils";
export interface PostDetail {
meta: {
title: string;
date: string;
spoiler: string;
slug: string;
id: string;
};
content: string;
}
const rootDirectory = path.join(process.cwd(), "src", "posts");
/**
*
* @description 读取全部文章数据
*/
export const getPostBySlug = async (dir: string) => {
const filePath = path.join(rootDirectory, dir, "/index.mdx");
const fileContent = fs.readFileSync(filePath, { encoding: "utf8" });
const { data } = matter(fileContent);
const id = isChinese(dir)
? pinyin(dir, {
toneType: "none",
separator: "-",
})
: dir;
return {
meta: { ...data, slug: dir, id },
content: fileContent,
} as PostDetail;
};
export const getAllPostsMeta = async () => {
const dirs = fs
.readdirSync(rootDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
let datas = await Promise.all(
dirs.map(async (dir) => {
const { meta, content } = await getPostBySlug(dir);
return { meta, content };
}),
);
datas.sort((a, b) => {
return Date.parse(a.meta.date) < Date.parse(b.meta.date) ? 1 : -1;
});
return datas;
};
/**
*
* @description 获取文章详情方法
*/
export async function getPost(slug: string) {
const posts = await getAllPostsMeta();
if (!slug) throw new Error("not found");
const post = posts.find((post) => post.meta.slug === slug);
if (!post) {
throw new Error("not found");
}
return post;
}
import Link from "next/link";
import { getAllPostsMeta, PostDetail } from "@/data/post";
import dayjs from "dayjs";
export default async function BlogList() {
const posts = await getAllPostsMeta();
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const postMap: Map<number, PostDetail[]> = new Map();
posts.forEach(({ content, meta }) => {
const date = dayjs(meta.date);
const month = monthNames[date.month()];
const year = date.year();
const newPost = {
content,
meta: {
...meta,
date: `${month} ${date.get("dates")}`,
},
};
if (postMap.has(year)) {
postMap.get(year)!.push(newPost);
} else {
postMap.set(year, [newPost]);
}
});
return (
<div className="init-aniamtion relative flex flex-col gap-16 pt-6">
{[...postMap.entries()].map(([year, posts]) => (
<div className="flex flex-col gap-4">
<div className="year-title">{year}</div>
<div className="flex flex-col gap-8">
{(posts as PostDetail[]).map((post) => {
return (
<Link
className="block text-[var(--infoText)] transition-all hover:text-[var(--title)]"
key={post.meta.id}
href={"/blogs/" + post.meta.slug + "/"}
>
<article className="flex items-baseline gap-4">
<div className="text-[16px] font-bold ">
{post.meta.title}
</div>
<p className="text-[13px] text-[var(--title)] opacity-50">
{post.meta.date}
</p>
</article>
</Link>
);
})}
</div>
</div>
))}
</div>
);
}
博客详情
首页博客列表实现完了,接下来就是博客详情页面,渲染 mdx 文件内容,首推 nextjs 官方的 MDXRemote 组件,最重要的就是它支持引入自己编写的组件,这样就可以实现博客中的组件交互效果。
博客详情是 nextjs 动态路由最好的应用场景,你可以通过动态路由来渲染不同的博客详情页面。创建动态路由文件(src/app/blogs/slug/layout.tsx)。博客详情作为新页面自然是必须 layout 包裹的,所以在这里引入 layout 组件。
export default function DetailLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<footer className="mt-12">
</footer>
</>
);
}
import { getAllPostsMeta, getPost } from "@/data/post";
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "./markdown.css";
import { getPostWords, readingTime } from "@/lib/utils";
export async function generateStaticParams() {
const metas = await getAllPostsMeta();
return metas.map((post) => {
return { slug: post.meta.slug };
});
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
let postComponents = {};
try {
postComponents = await import(
"../../../posts/" + params.slug + "/components.js"
);
} catch (e: any) {
if (!e || e.code !== "MODULE_NOT_FOUND") {
throw e;
}
}
const words = getPostWords(post.content);
const readTime = readingTime(words);
return (
<article className="init-aniamtion">
<h1
className={[
"text-[40px] font-black leading-[44px] text-[--title]",
].join(" ")}
>
{post.meta.title}
</h1>
<p className="mb-6 mt-2 text-[13px] text-[var(--infoText)] ">
{new Date(post.meta.date).toLocaleDateString("cn", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
<p className="mt-2 text-[13px] text-[var(--infoText)] ">字数:{words}</p>
<p className="mt-2 text-[13px] text-[var(--infoText)]">
预计阅读时间:{readTime}分钟
</p>
<div className="markdown init-aniamtion mt-10">
<MDXRemote
source={post?.content || ""}
components={{
...postComponents,
}}
options={{
parseFrontmatter: true,
mdxOptions: {
// @ts-ignore
remarkPlugins: [remarkMath],
rehypePlugins: [
// @ts-ignore
rehypeKatex,
[
// @ts-ignore
rehypePrettyCode,
{
theme: {
light: "light-plus",
dark: "dark-plus",
},
},
// { theme: "dark-plus" },
],
],
},
}}
/>
</div>
</article>
);
}
文章 markdown 渲染美化 css
.markdown {
line-height: 28px;
--path: none;
--radius-top: 19px;
--radius-bottom: 19px;
--padding-top: 1rem;
--padding-bottom: 1rem;
}
.markdown a {
@apply border-b-[1px] border-[rgba(125,125,125,.3)] text-[--link] transition-all hover:border-[--link];
}
.markdown p {
@apply pb-8;
}
.markdown hr {
@apply pt-8 opacity-60 dark:opacity-10;
}
.markdown h2 {
@apply mt-2 pb-8 text-3xl font-bold;
}
.markdown h3 {
@apply mt-2 pb-8 text-2xl font-bold;
}
.markdown h4 {
@apply mt-2 pb-8 text-xl font-bold;
}
.markdown :not(pre) > code {
border-radius: 10px;
background: var(--inlineCode-bg);
color: var(--inlineCode-text);
padding: 0.15em 0.2em 0.05em;
white-space: normal;
}
.markdown pre {
@apply -mx-4 mb-8 overflow-y-auto p-4 text-sm;
clip-path: var(--path);
margin-left: 0;
margin-right: 0;
border-top-right-radius: var(--radius-top);
border-top-left-radius: var(--radius-top);
border-bottom-right-radius: var(--radius-bottom);
border-bottom-left-radius: var(--radius-bottom);
padding-top: var(--padding-top);
padding-bottom: var(--padding-bottom);
background-color: var(--code-bg);
}
.markdown pre code {
width: auto;
}
.markdown blockquote {
@apply relative -left-2 -ml-4 mb-8 pl-4;
font-style: italic;
border-left: 3px solid hsla(0, 0%, 0%, 0.9);
border-left-color: inherit;
opacity: 0.8;
}
.markdown blockquote p {
margin: 0;
padding: 0;
}
.markdown p img {
margin-bottom: 0;
}
.markdown ul {
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.75rem;
list-style-position: outside;
list-style-image: none;
list-style: disc;
}
.markdown li {
margin-bottom: calc(1.75rem / 2);
}
.markdown img {
@apply mb-8;
max-width: 100%;
}
.markdown pre [data-highlighted-line] {
margin-left: -16px;
padding-left: 12px;
border-left: 4px solid #ffa7c4;
background-color: #022a4b;
display: block;
padding-right: 1em;
padding-left: 1em;
}
code[data-theme*=" "],
code[data-theme*=" "] span {
color: var(--shiki-light);
background-color: var(----code-bg);
}
[data-theme="dark"] {
code[data-theme*=" "],
code[data-theme*=" "] span {
color: var(--shiki-dark);
background-color: var(--code-bg);
}
.markdown pre {
background-color: var(--code-bg);
}
}