如何搭建我的个人网站

Filed under 网页开发 on 2024年11月11日. Last updated on 2024年11月17日.

Table of Contents

Why?

当我摒弃掉所有似是而非的理由,我发现其实我单纯只是想做一个自己的网站。不管是技术、设计、写作,都回归到做事情本身,这个过程本身就很美妙。如果你对这个过程并不感兴趣,社交媒体或许是一个更好的选择。

当年,在刚进入大学时,我是想成为一名建筑设计师。但是阴差阳错成为了软件工程师,却也发现其乐无穷。那时我特别喜欢逛图书馆,在不经意间发现了《CSS禅意花园》这本书,另当时的我大开眼界。也许就是那个时候,设计并实现自己网站的种子就埋下了。

总览

在本文中,我会介绍两种解决方案。每一种方案我都试过,各有优劣,我会在接下来的内容里详细介绍。

方案优点缺点
Github Pages + Jekyll1. 免费
2. 配置简单
3. 默认域名辨识度高
1. 可定制化差
2. 可扩展性差
AWS Amplify + Next.js1. 灵活
2. 可扩展性强
1. Next.js有一定开发成本
2. AWS Amplify有一定运营维护成本

总的来说,如果想要快速发布静态内容,对于其他细节要求不高的情况下,Github + Jekyll确实是一个不错的选择。然而一旦你想要对于网站的细节进行打磨的时候,Github + Jekyll就捉襟见肘了,而AWS Amplify + Next.js显得要游刃有余得多。

Github Pages + Jekyll

使用Github Pages,根据官方文档的指引,使用Jekyll可以在几分钟内就搭建一个静态网站。其中,Github提供了一个免费的子域名(username.github.io, 其中username是你的用户名或者机构名称),而Jekyll也提供了一系列开箱即用的主题供你选择。

可以说,如果你的需求仅仅是快速发布一些内容,那么Github Pages + Jekyll是一个非常不错的选择。但是在我个人的使用中,还是遇到了一些不那么完美的体验。

基于以上一些原因,我在维护了一段时间Github Pages + Jekyll过后,还是放弃了这个解决方案。

AWS Amplify + Next.js

我放弃Jekyll的时候,并没有想要把其他框架生成的静态网站托管在Github Pages上,也完全没有意识到这件事的可行性,所以直接也把Github Pages放弃了,转而选择了AWS Amplify。

当然,相比Github Pages,AWS Amplify不光专业、全面,并且上手难度也非常的低,成本也低到可以忽略不计的程度。

在使用AWS Amplify的过程中,我了解到了Next.js。经过一番调研,最终决定了使用Next.js。

在使用Next.js的过程中,整体来讲还是非常好用的。

AWS Amplify托管Next.js

相比Github Pages托管,AWS Amplify的接入门槛也还是比较低的。在AWS Amplify上托管Next.js,有专门的官方文档可以参考。

将Jekyll博客迁移到Next.js

首先,Next.js有两种Router模式:App Router和Pages Router。其中App Router能够支持到一些新的功能,所以最好直接使用App Router。

在此基础上,就可以迁移博客相关页面了。根据Next.js Routing,可以定义相关的Routing。 其中比较重要的两个功能,Route GroupsDynamic Routes

Route Groups能够帮助我们更好的管理代码结构和URL路径之间的映射,不至于把所有URL路径对应的目录都平铺在app目录下。

Dynamic Routes对于博客来说,就更加重要。因为所有的文章URL,肯定是需要动态生成的。

Dynamic Routes和generateStaticParams

// file app/blog/[slug]/page.tsx

export default async function BlogPage({ params }: { params: Promise<{ slug: string }>}) {
    const slug = (await params).slug
    return (
        <main>My Post: {slug}</main>
    )
}

但是要注意我们直接这样使用Dynamic Routes的话,所有的请求都是on demand的。

假设我们把以下的代码部署到AWS Amplify,会导致相关的Dynamic Routes都是在server端动态处理。假设我们是按照slug参数来获取文章,那么这个文章会在服务端动态加载。

# Pay attention to 'ƒ  (Dynamic)  server-rendered on demand'
npm run build

> example-app@0.1.0 build
> next build

 Next.js 15.0.3

   Creating an optimized production build ...
 Compiled successfully
 Linting and checking validity of types
 Collecting page data
 Generating static pages (6/6)
 Collecting build traces
 Finalizing page optimization

Route (app)                              Size     First Load JS
 /                                    5.57 kB         105 kB
 /_not-found                          896 B           101 kB
 /blog                                139 B           100 kB
 ƒ /blog/[slug]                         139 B           100 kB
+ First Load JS shared by all            99.9 kB
 chunks/4bd1b696-80bcaf75e1b4285e.js  52.5 kB
 chunks/517-d083b552e04dead1.js       45.5 kB
 other shared chunks (total)          1.88 kB


  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand 

可以从build的输出看到,/blog/[slug]这个路径是动态处理的(server-rendered on demand)。

这时,一定要记得使用generateStaticParams函数

// file app/blog/[slug]/page.tsx

export async function generateStaticParams() {
    return [{slug: 'hello'}]
}

export default async function BlogPage({ params }: { params: Promise<{ slug: string }>}) {
    const slug = (await params).slug
    return (
        <main>My Post: {slug}</main>
    )
}

这个函数相当于是在build阶段,给出Dynamic Routes所有的值,根据这些值直接生成静态页面。

# Pay attention to '●  (SSG)     prerendered as static HTML (uses generateStaticParams)' and line '/blog/hello'.
npm run build

> example-app@0.1.0 build
> next build

 Next.js 15.0.3

   Creating an optimized production build ...
 Compiled successfully
 Linting and checking validity of types
 Collecting page data
 Generating static pages (7/7)
 Collecting build traces
 Finalizing page optimization

Route (app)                              Size     First Load JS
 /                                    5.57 kB         105 kB
 /_not-found                          896 B           101 kB
 /blog                                139 B           100 kB
 /blog/[slug]                         139 B           100 kB
 /blog/hello
+ First Load JS shared by all            99.9 kB
 chunks/4bd1b696-80bcaf75e1b4285e.js  52.5 kB
 chunks/517-d083b552e04dead1.js       45.5 kB
 other shared chunks (total)          1.88 kB


  (Static)  prerendered as static content
  (SSG)     prerendered as static HTML (uses generateStaticParams) 

这样,我们就能看到/blog/[slug]这个路径现在变成了预渲染的HTML(prerendered as static HTML),并且也根据generateStaticParams函数返回的参数,生成了/blog/hello这个路径对应的内容。

当然,在实际实现的时候,我们可以拿到slug参数,去获取markdown文件,编译过后将其渲染到页面。

使用remark处理markdown

将markdown文件渲染为HTML页面,我尝试了两种方式。

将markdown文件加载为HTML

这种方式简单直接,实现也很好理解。

  1. 根据slug参数读取markdown文件。
  2. 将文件内容编译为HTML。
  3. 将HTML设置到JSX元素的dangerouslySetInnerHTML属性。
export async function markdownToHtml(content: string) {
    const processedContent = await remark()
        .use(remarkMath)
        .use(html, { sanitize: false })
        .use(remarkGfm)
        .use(remarkGemoji)
        .use(remarkRehype, { allowDangerousHtml: true })
        .use(rehypeRaw)
        .use(rehypeKatex)
        .use(rehypeStringify)
        .process(content)
    return processedContent.value.toString()
}

具体使用哪些插件,按照自己的需求选择就好了。但是这里要注意插件的顺序,需要仔细调整,否则会遇到错误。

将markdown导入为MDX

把markdown导入MDX其实应该比编译为HTML更简单一些。但是由于我使用了frontmatter,需要额外配置插件。

import createMDX from '@next/mdx'

import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
import remarkMath from 'remark-math'
import remarkGfm from 'remark-gfm'
import remarkGemoji from 'remark-gemoji'
import remarkToc from 'remark-toc'

import rehypeKatex from 'rehype-katex'

const withMDX = createMDX({
    options: {
        remarkPlugins: [
            remarkFrontmatter,
            remarkMdxFrontmatter,
            remarkMath,
            remarkGfm,
            remarkGemoji,
            remarkToc,
        ],
        rehypePlugins: [
            rehypeKatex,
        ],
    },
})

在这里,Next.js官方的文档其实并不好用。 如果想要了解这些插件到底都做了什么,我们需要稍微解释一下背后的原理。

MDX是基于JSX的markdown扩展。本质上MDX还是会被编译成JSX组件。所以当我们使用import将MDX文件导入时,具体能导出什么变量,取决于整个MDX的转译过程。

而Next.js本身只是将标准的MDX转译为JSX,所以import的时候默认只有一个export default变量。如果我们想要获取到其中frontmatter的信息,就需要单独配置插件,这也就是为什么我们需要额外配置remarkFrontmatterremarkMdxFrontmatter

然而问题并没有结束,如果你跟我一样也使用了VS Code,那你大概能看到这样一个错误。

vs-code-error

这是因为默认的MDX模块并没有export metadata这个变量。即便我们在转译后的模块中有这个变量,但是定义中还是没有。

所以,我们还需要订制一下MDX模块的定义。

// global.d.ts
export interface Frontmatter {
    title: string;
    createdAt: string;
    updatedAt: string;
    author: string;
    language: string;
    tags: string[];
    summary: string;
}

declare module '*.mdx' {
    import { MDXProps } from 'mdx/types';
    const MDXComponent: (props: MDXProps) => JSX.Element;
    export default MDXComponent;

    // Add named exports based on your MDX configuration
    export const frontmatter: Frontmatter;
}  

为了不跟Next.js的metadata冲突,我把MDX中导出的frontmatter信息直接定义为了frontmatter。当然,你也可以在插件中定义你喜欢的名字。

const withMDX = createMDX({
    options: {
        remarkPlugins: [
            [remarkMdxFrontmatter, "myFrontmatter"],
        ],
    },
})

这样,我就可以在代码中直接导入MDX以及对应的frontmatter了。

import { notFound } from "next/navigation";

export async function MDXPage({ params }: { params: { slug: string } }) {
    try {
        const { default: Content, frontmatter } = await import(`@/posts/${params.slug}.mdx`)
        return (
            <main>
                <h1>{frontmatter.title}</h1>
                <p><small>{`Filed under ${frontmatter.tags}`}</small></p>
                <Content />
            </main>
        )
    } catch {
        notFound()
    }
}

到此为止,我们就基本把Jekyll迁移到Next.js了。

总结

从开始使用AWS Amplify + Next.js搭建个人网站,断断续续也有半年多了。在这半年多的时间里,我大部分闲暇时间都花在了网站的架构和样式上,内容反而更新得特别少。但是在此期间能分享的内容还是挺多的,我会在后续的文章中继续分享。