基地开发日志1.0
基地的初步基建终于完成了!现在,这里是关于它的第一份开发日志。
搭建网站的初衷
搭建网站的目的嘛,迷你小站也没有那么多宏大的目的,只是为了建一个自己的小基地,记录自己关于前端的开发体验和研究。不过,这个小基地不仅仅是一个博客,我更喜欢称呼它研究前端的“前哨基地”。(会更新的!)
另外,这个网站是从0开始开发的,没有用任何模板(对,你没听错,2025年还有笨蛋在手搓博客)。现在无论是模板本身,还是它配套的服务都已经非常成熟了,但秉着体验开发和我要创新的想法,我还是决定从头开始开发。
基地的基础框架
网站是基于Next.js框架搭建的。在初期阶段,计划先用原生CSS,JavaScript和React进行开发,后续逐步引入Tailwind和TypeScript。
初始化Next项目后,可以准备动工了。开始我先搭建路由和基础页面。基于Next.js非常出色的路由管理系统,基础路由的设置还是很方便的。页面的话,主要采用了Flex布局,做了一下Article、About的基础页面和一个常驻导航栏。
奇怪的气泡动画
Article页面上的标题栏有一个气泡动画,它是用Motion动画库做的。(多提一嘴,这个动画库原本叫Framer Motion,但在2024年9月被整合并重命名为Motion) 动画的基础设计是创建一个useCallback用来控制新气泡的生成(用useCallback可以保证函数稳定,避免频繁执行),创建一个state数组存储气泡状态,当鼠标悬浮交互时,在useEffect里更新它的状态。
const [bubbles, setBubbles] = useState([])
const [isHoveringbox, setIsHoveringBox] = useState(false) //控制悬浮事件
const resetBubble = useCallback(
//气泡参数信息
)
useEffect(() => {
setBubbles(
//ForEach内调用resetBubble更新气泡状态
)
}, [isHoveringbox])
//...其他代码
return (
//每个气泡完成动画后单独reset
bubbles.map(() => (
<motion.div onAnimationComplete={() => resetBubble(bubble.id)}></motion.div>)
)
)
动画能正常生成了,但还有个问题,如果鼠标迅速地移入移出,气泡会发生闪烁,因此还是使用useRef设置防抖逻辑。
const hoverTimerRef = useRef(null)
const handleHoverChange = useCallback((isHovering) => {
// 清除之前的定时器,防止多次触发
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
}
//设置定时器
hoverTimerRef.current = setTimeout(() => {
setIsHoveringBox(isHovering)
}, 1000)
}, [])
到这里动画逻辑基本完成了。然而,此时出现了非常奇怪的Bug。在动画执行过程中,有极少的气泡会突然变得非常巨大或者消失。根据分析,“幽灵气泡”的产生似乎跟React 渲染时有关。
主要存在于下面的逻辑中:
const handleHoverChange = useCallback((isHoveringbox) => {
hoverTimerRef.current = setTimeout(() => {
setIsHoveringBox(isHoveringbox) //这里控制isHoveringbox
}, 1000)
}
const resetBubble = useCallback(
size: isHoveringbox ? ... : ... //这里依赖isHoveringbox
)
useEffect(() => {
setBubbles(
//...
isActive: isHoveringbox //这里控制气泡是否会reset
)
})
return (
<motion.div
onAnimationComplete={() => {
if (bubble.isActive) { //这里控制动画是否reset
resetBubble(bubble.id)
}
}}>
</motion.div>
)
简单梳理一下时间线:
// T1.鼠标移出
// T2.计时器触发
// T3.1s后isHoveringbox状态变为false, React触发渲染, 开始新一轮 render 阶段
// T4.React进入Commit阶段,执行useEffect通过setBubbles重设所有bubble.isActive, 并resetBubble更新
大部分气泡都能被正确设置参数和reset,但是,如果有气泡刚好在T3到T4这段极短的窗口期内完成动画触发onAnimationComplete
,那么在这个闭包周期内捕获的参数为isHoveringbox === false
和isActive === true
(这个参数的更新滞后了),由此产生了bug。
解决方案的话,最简单的就是更改动画reset的逻辑,通过isHoveringbox
而非isActive
进行判断即可。顺便也简化了一下代码,撤销isActive属性,完全由isHoveringbox
控制。
<motion.div
onAnimationComplete={() => {
if (isHoveringbox) { //该这个控制条件
resetBubble(bubble.id)
}
}}>
</motion.div>
优化后的动画逻辑非常简单:初始挂载动画逻辑,悬浮鼠标激活气泡,设置一次性阶梯式动画延迟并激活reset,鼠标离开关闭reset,气泡完成动画后就会停止。
CSS Module与Tailwind
使用原生CSS开发时,有一个需要解决的问题:由于CSS导入后是全局生效的,所以需要避免CSS类名污染。于是我引入了CSS Module。它能很好的解决这个问题,但随着开发的深入,我发现了一个新的问题:如果我要给一个标签添加一些一次性的常用样式(如background-color; display: flex
),我不仅需要给它写个CSS,加个类,然后还要给它加上CSS Module标签!这种开发方式实在不够简洁,于是我决定正式引入Tailwind,以替代CSS Module。
两种开发风格示例:
//CSS module
import styles from "@/styles/about.module.css";
return (<div className={styles["about-container"]}></div>)
//Tailwind
return (<div className="flex felx-col items-center gap-2"></div>)
实际上,Tailwind和CSS Moudule并不会冲突,但是Tailwind官方文档中不建议这么做,因为风格不统一,并且会影响最终构建效率。
深浅模式
既然已经引入了Tailwind,我决定用它的dark mode来制作深浅模式。为了更好的颜色适配,我采用了谷歌的Material Design 3颜色系统标准,引入深浅颜色模版文件。它们由根元素HTML上的dark
类控制导入,与Tailwind dark mode控制条件统一。
/* light.css文件(同理dark.css) */
.light {
--md-sys-color-background: rgb(246 250 254);
--md-sys-color-on-background: rgb(23 28 31);
/* ... */
}
随后,我将这些颜色整合到 Tailwind 主题中,以便后续直接使用Tailwind控制样式。
/* global.css */
@theme {
--color-background: var(--md-sys-color-background);
--color-on-background: var(--md-sys-color-on-background);
}
这是一种简单且具有拓展性的主题设置方式。如果后续想要更改或增加网站的主题样式,只需替换对应的颜色模版文件就行了。
动态路由与文本解析
接下来,我准备进入文章详情页的制作了。从Article页链接到具体文章时需要配置一个动态路由,可以使用下面Next.js官方提供的API:
//生成所有可用路由
export async function generateStaticParams() {
const articlesDirectory = path.join(process.cwd(), "content", "articles")
const files = await fs.readdir(articlesDirectory) //读取目录下所有文件名
return files
.filter((file) => file.endsWith(".md"))
.map((file) => ({
slug: file.replace(/\.md$/, ""),
}))
}
//定义访问不在可用路由时的行为,为false时跳转到not-found页面
export const dynamicParams = false
配置完动态路由,就该考虑如何解析Markdown了。在这里,我用了一个轻量级的库:marked
。(后面想想可能这时直接用remark
会好的多)。它提供最基础的markdown文件解析,应用方式也非常简单:
import { marked, Renderer } from "marked";
let content = ""
try {
content = await fs.readFile(filePath, "utf8")
} //...
//自定义解析样式
const renderer = new Renderer()
renderer.heading = (token) => {...}
//解析Markdown为HTML
const html = marked.parse(content)
//插入HTML
return (<article dangerouslySetInnerHTML={{ __html: html }}></article>)
SSR闪烁的解决
在测试网页的时候,我发现,如果在深色模式下重新刷新网页,那么屏幕会短暂白屏一下,然后切换为黑色,即屏幕加载时背景会发生“闪烁”。研究了一下,这应该是服务器渲染(SSR)与客户端渲染不一致导致的问题,核心原因是我的初始模式依赖于读取localStorage对象,这是一个仅存在于浏览器(客户端)的对象,服务器端无法读取,只能在客户端切换更新,导致闪烁。
useEffect(() => {
const saved = localStorage.getItem("theme") || "light"; //读取localStorage
setMode(saved);
}, [])
为了解决这个问题,我尝试在网站初始化时加一个高优先级的脚本读取localStorage,以第一时间更新样式。然后,Next发出水合(Hydration)错误的警告,这表示服务器渲染的 HTML 结构与客户端 React 尝试渲染的组件树结构不一致,因为客户端应用了localStorage的判断,生成了不一致的DOM结构。
方案还需要改进。既然服务器端无法读取到loacalStorage,那就不用它了,我决定尝试用cookie来储存用户的主题。
import { cookies } from "next/headers";
export default async function RootLayout({ children }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value || "light"; //获取cookie
return(
<html className={theme}></html>
)
}
切换的文件:
useEffect(() => {
const match = document.cookie.match(/theme=(dark|light)/); //匹配cookie
let theme = match ? match[1] : null;
//如果没有 cookie,则检测系统偏好
if (!theme) {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
theme = prefersDark ? "dark" : "light";
document.cookie = `theme=${theme}; path=/; max-age=${60 * 60 * 24 * 365}`;
}
setMode(theme);
document.documentElement.className = theme;
}, []);
这样就能正确设置主题颜色,不会再出现闪烁了。
MDX引入,功能升级
之前提到使用了marked
来解析markdown,我当时觉得轻量又易用,挺好的。但当我准备为文章增加一些功能(例如代码高亮),才发现这个库真的空空如也啊(悲)。正巧我Next官方文档里看到了MDX的介绍,于是决定引入MDX来进行拓展。
在Next中,MDX文件是可以单独作为路由的,不过这里我只想让它像Markdown一样解析成一般的HTML。然而,支持这样解析的官方库next-mdx-remote
过期了(它只支持Page Route)。翻了下文档,官方说有一个推荐的社区库next-mdx-remote-client
可用。于是,我应用了这个新库,成功替换了marked。(为什么会有remark
和rehype
?因为MDXRemote
能很轻易地集成它们!)
import { MDXRemote } from "next-mdx-remote-client/rsc";
//自定义解析样式
function remarkExtractHeadings(headings) {...}
//配置解析插件
const options = {
mdxOptions: {
remarkPlugins: [
remarkGfm,
remarkSlug,
remarkExtractHeadings(headings),
],
},
parseFrontmatter: true,
};
return (
<article className="article-container">
<MDXRemote source={content} options={options} components={components} />
</article>
)
完成基础解析后,还有三个新功能要实现:TOC(文章目录)、代码高亮和代码复制。文章目录需要先为标题标签加上id(可以用插件或者像我一样手动配),然后创建一个Intersection Observer 实例监视窗口,这样可以让标题高亮跟随文章滑动,最后加一个手动跳转的逻辑即可。注意Observer实例应排除手动跳转。
let headings = []
function remarkExtractHeadings() {
return () => (tree) => {
visit(tree, "heading", (node) => {
if (node.depth === 3) {
const textNode = node.children.find((child) => child.type === "text");
if (textNode) {
const text = textNode.value;
const id = node.data?.id;
if (id) {
headings.push({ text, id }); //收集标题信息
}
}
}
});
};
}
TOC组件Observer实例部分代码:
export default function ArticleTOC({ headings }) {
const [activeId, setActiveId] = useState("");
// 创建一个Intersection Observer实例,检测标题位置
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (clickedRef.current) {
return; // 如果是点击滚动,则跳过观察
}
entries.forEach((entry) => {
// 如果元素正在进入视口
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
// Observer 的配置
{
rootMargin: "-20px 0px -75% 0px",
threshold: 0.1,
}
);
//获取所有元素
const headingElements = document.querySelectorAll(
"article.article-container h3"
);
//观察所有元素
headingElements.forEach((element) => {
observer.observe(element);
});
return () => {
//...组件卸载时清理函数
};
}, []);
//设置手动跳转目录方法
const handleTocItemClick = () => {...}
return (headings.map(() => {/*用activeId控制高亮*/}))
}
代码高亮部分我应用了rehypePrismPlus
插件实现,它的集成过程非常方便。
mdxOptions: {
rehypePlugins: [
[
rehypePrismPlus, //应用这个高亮插件
{
showLineNumbers: false,
ignoreMissing: true,
defaultLanguage: "plaintext",
},
],
],
},
顺带自定义了一些样式:
/*滚动条样式*/
html.light pre[class*="language-"] {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05);
}
html.dark pre[class*="language-"] {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
}
关于实现代码复制功能,考虑先用一个div
包裹pre
,再创建一个绝对定位的button
,绑定它获取这个pre
的文本。我将它们封装成了一个React组件。
export default function CodeBlock({ pre }) {
const [copied, setCopied] = useState(false);
const handleCodeCopy = () => {
//获取代码文本
const get_code_text = (node) => {...}
const CodeElement = pre.props.children;
const codeText = get_code_text(CodeElement);
//复制代码文本到剪切板并标记已复制
navigator.clipboard
.writeText(codeText)
.then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
})
.catch((err) => {
console.error("无法复制代码:", err);
});
}
return (
<div className="relative">
<button onClick={handleCodeCopy}>
{copied ? "已复制" : code_language}
</button>
{pre}
</div>
)
}
主页与动画设计
制作主页时,我加了一个深浅模式不同的迷你交互动画:水波扩散(light)和电波扩散(dark)。方案是建一个透明全屏的<div>
置于页面上方并检测交互事件,然后通过Motion
动画库控制动画。
使用<AnimatePresence>
能让组件被移除后不会被立刻卸载,而是执行exit动画,完成退出动画后再卸载。
浅色模式动画:
export default function ApertureLight() {
const [apertures, setApertures] = useState([])
//处理鼠标点击事件
const handleClick = (e) => {
const aperture_item = {
x: e.clientX, //这里可以获取鼠标坐标
y: e.clientY,
r: 50,
key: crypto.randomUUID(), //使用UUID可以获取独一无二的key标识
radius: border_list[Math.floor(Math.random() * border_list.length)],
}
setApertures((prev) => [...prev, aperture_item])
}
//...
return (
<motion.div className="canvas-box">
<AnimatePresence>
{apertures.map((aperture) => {return ...})}
</AnimatePresence>
<motion.div>
)
}
深色模式的动画多了一个长按的效果,可以将鼠标事件拆分成MouseUp
和MouseDown
,然后通过一个state记录按下的时间。
const [presstime, setPressTime] = useState(0)
const handleMouseUp = () => {
setPressTime(Date.now())
}
const handleMouseDown = (e) => {
const new_time = Date.now() - presstime
}
每个动画都有一个对应的计时器,用于在动画结束后卸载动画。但这里有一个问题,如果在动画为结束前切换页面,那么整个组件都会被卸载,但定时器还在运行,这可能会导致内存泄漏。这里,我用了useRef
来解决这个问题。它记录了所有定时器的信息,并在组件卸载时清除仍在运行的定时器。
const timeRef = useRef([])
useEffect(() => {
return () => {
timeRef.current.forEach((timeId) => {
clearTimeout(timeId)
})
timeRef.current = []
}
}, [])
const handleMouseDown = (e) => {
const timeId = setTimeout(...)
timeRef.current.push(timeId)
}
另外,在控制应用的动画的地方,使用<AnimatePresence>
并为每个组件添加退出动画,可以让动画在切换深浅模式时更自然地过渡。
<AnimatePresence>
{theme ? <ApertureDark key="dark" /> : <ApertureLight key="light" />}
</AnimatePresence>
最后,在完成了主页动画后,我还用Motion
给每个页面加了一些例如淡入淡出的基础动画,让页面加载更流畅一些。
<motion.main
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
><motion.main>
SEO优化
SEO优化有很多要做,语义化标签,比如<article>
、<nav>
等开发时就已经使用了,还需要设置一下元信息<meta>
。Next v15中可以通过导出metadata
实现:
export const metadata = {
title: "星轨前哨基地",
description: ...,
keywords: ...,
icons: { icon: "/favicon.svg" },
//添加openGraph和twitter元数据
openGraph: {...},
twitter: {...},
}
动态路由可以通过generateMetadata
函数导出页面的metadata
信息:
export async function generateMetadata({ params }) {Add commentMore actions
const { slug } = await params
const article = article_map[slug] || {}
return {
title: article.title,
description: article.desc,
authors: [{ name: "Star Trial" }],
openGraph: {...},
twitter: {...},
}
}
后面还给网站做了robots.txt和sitemap.xml,用next/font
优化了字体的加载,Image
优化了图片的加载来提高加载性能。优化图片大小这一步很有意思,主页的Gif经过了Gif -> mp4 -> 高清化 -> Gif -> 压缩 -> webp
的转变,在清晰度比原Gif高的情况下,大小压缩到了原本的15%。
网站的字体还采用了多字体同时应用,中文使用MiSans,英文使用Roboto。通过先应用Roboto并禁用自动回调实现。
//font.jsx
import { Roboto } from "next/font/google";
export const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
adjustFontFallback: false, //禁用自动回调
preload: true,
});
响应式设计
网站的响应式设计取的是常规的两个断点:768和1024,分别适配移动端,平板电脑/小屏幕电脑,PC端。现在流行的响应式设计是移动端优先,不过考虑到我以后要加一些PC端更适用的东西,这里还是以PC端优先。
@media (max-width: 1023px) {
.bubble-box {
max-width: 80%;
}
}
@media (max-width: 767px) {
.bubble-box {
max-width: 90%;
}
}
像导航栏这样需要略微变化内容的小组件,可以考虑简单地通过CSS的display
控制:
@media (max-width: 767px) {
.nav-links {
display: none;
}
.separator {
display: none;
}
.nav-menu {
display: flex;
}
}
移动端需要通过交换显示的小菜单可以用React控制是否显示,这里我还稍微加了点过渡动画。
const [isMenuOpen, setIsMenuOpen] = useState(false)
return (
<AnimatePresence>
{isMenuOpen && (
<motion.div
initial={{ opacity: 0, transform: "translateX(100%)" }}
animate={{ opacity: 1, transform: "translateX(0)" }}
exit={{ opacity: 0, transform: "translateX(100%)" }}
transition={{
duration: 0.3,
ease: "easeOut",
type: "tween",
}}>
<motion.div>)
}
</AnimatePresence>
)
至此本日志所有的开发内容介绍完毕。
写在最后
以前没完整开发过项目时,并不清楚那是什么样的。在经历了一次完整的开发旅程后,才深刻体会到了它究竟是怎样一番景象 。 开发的过程绝非轻松,Bug频发,有时一连研究好几个小时都修不好。但,回想起开发时的每一次探索与尝试,我依然觉得充满乐趣。