基地开发日志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 === falseisActive === 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。(为什么会有remarkrehype?因为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>
  )
}

深色模式的动画多了一个长按的效果,可以将鼠标事件拆分成MouseUpMouseDown,然后通过一个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频发,有时一连研究好几个小时都修不好。但,回想起开发时的每一次探索与尝试,我依然觉得充满乐趣。

目录
  • 1. 搭建网站的初衷
  • 2. 基地的基础框架
  • 3. 奇怪的气泡动画
  • 4. CSS Module与Tailwind
  • 5. 深浅模式
  • 6. 动态路由与文本解析
  • 7. SSR闪烁的解决
  • 8. MDX引入,功能升级
  • 9. 主页与动画设计
  • 10. SEO优化
  • 11. 响应式设计
  • 12. 写在最后