• 【React】使用Next.js构建并部署个人博客


    👉 TypeScript学习TypeScript从入门到精通

    👉 蓝桥杯真题解析蓝桥杯Web国赛真题解析

    👉 个人简介:一个又菜又爱玩的前端小白🍬
    👉 你的一键三连是我更新的最大动力❤️!


    前言

    关于博客系统,相信大家早已驾轻就熟,网上有很多以markdown驱动的博客框架,如vuepresshexo等,这类框架的本质是生成静态站点,而个人开发的博客系统大多是使用数据库的全栈项目,这两种方式各有各的好处,这里就不做比较了

    这篇文章我们将自己独立去开发并部署一个以markdown驱动的静态站点博客,所用技术栈如下:

    • React
    • TypeScript
    • Next.js
    • tailwindcss
    • Vercel部署

    注意: 本文只是演示使用Next.js从0到1构建并部署一个个人博客项目,不会对项目构建过程中所用到的技术做详细的讲解,不过不用担心,只要跟着文章一步一步来,小白都能成功部署自己的个人博客!

    项目仓库地址:https://github.com/Chen0807AiLJX/next-blog
    最终效果可见:https://next-blog-eosin-six.vercel.app/

    现在让我们开始吧!

    开始之前请确保自己电脑上配置的有Node.js 12.13.0 或更高版本。

    1、创建Next.js项目

    要创建 Next.js 应用程序,请打开终端,cd进入到要在其中创建应用程序的目录,然后运行以下命令:

    npx create-next-app@latest --typescript ailjx-blog
    
    • 1

    上述代码表示:通过create-next-app创建名为ailjx-blogTypeScript版本的Next.js应用程序

    vscode打开ailjx-blog项目,目录结构如下:

    在这里插入图片描述

    在项目根目录终端运行以下命令启动项目

    npm run dev
    
    • 1

    打开http://localhost:3000/显示如下页面:
    在这里插入图片描述

    2、安装tailwindcss

    在项目根目录终端运行以下命令:

    npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
    
    • 1

    生成tailwindcss配置文件:

    npx tailwindcss init -p 
    
    • 1

    此时项目里会多出两个文件:tailwind.config.jspostcss.config.js

    修改tailwind.config.js文件里的content为:

        content: [
            "./pages/**/*.{js,ts,jsx,tsx}",
            "./components/**/*.{js,ts,jsx,tsx}",
            "./styles/**/*.css",
        ],
    
    • 1
    • 2
    • 3
    • 4
    • 5

    pages文件夹下的_app.tsx文件的第一行添加:

    import "tailwindcss/tailwind.css";
    
    • 1

    之后重新启动项目

    3、添加布局页面

    准备一张自己的头像(建议比例为1:1,这里演示用的头像文件名为author.jpg

    public文件夹下新建images文件夹,将你的头像图片放入其中,并删除public文件夹下的svg文件

    public文件为项目的静态文件,可直接通过地址访问,如访问演示所用头像:http://localhost:3000/images/author.jpg

    项目根目录下新建components文件夹,并添加布局文件layout.tsx

    import Head from "next/head";
    import Image from "next/image";
    import Link from "next/link";
    
    const name = "Ailjx"; // 名称,根据需要修改
    export const siteTitle = "Ailjx Blog"; // 网站标题,根据需要修改
    
    interface Props {
        children: React.ReactNode;
        home?: boolean;
    }
    
    export default function Layout({ children, home }: Props) {
        return (
            <div className='max-w-2xl mx-auto px-4 mt-12 mb-24'>
                <Head>
                    <link rel='icon' href='/favicon.ico' />
                    <meta name='description' content='AiljxBlog——Ailjx的博客' />
                    <meta
                        property='og:image'
                        content={`https://og-image.vercel.app/${encodeURI(
                            siteTitle
                        )}.png?theme=light&md=0&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`}
                    />
                    <meta name='og:title' content={siteTitle} />
                    <meta name='twitter:card' content='summary_large_image' />
                </Head>
                <header className='flex flex-col items-center'>
                    {home ? (
                        <>
                            <Image
                                priority
                                src='/images/author.jpg'
                                className='rounded-full'
                                height={144}
                                width={144}
                                alt={name}
                            />
                            <h1 className='text-5xl font-extrabold tracking-tighter my-4'>
                                {name}
                            </h1>
                        </>
                    ) : (
                        <>
                            <Link href='/'>
                                <a>
                                    <Image
                                        priority
                                        src='/images/author.jpg'
                                        className='rounded-full'
                                        height={108}
                                        width={108}
                                        alt={name}
                                    />
                                </a>
                            </Link>
                            <h2 className='text-2xl my-4'>
                                <Link href='/'>
                                    <a>{name}</a>
                                </Link>
                            </h2>
                        </>
                    )}
                </header>
                <main>{children}</main>
                {!home && (
                    <div className='mt-12'>
                        <Link href='/'>
                            <a>← 返回首页</a>
                        </Link>
                    </div>
                )}
            </div>
        );
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76

    这里使用了几个Next自带的组件:

    • Head:向Html页面的head内添加内容,里面内容自己根据需要修改
    • Image:渲染图像的组件,src地址修改为自己头像的地址
    • Link :页面间跳转组件

    4、新建markdown文章

    项目根目录下新建posts文件夹,添加一个markdown文件,如:

    欢迎来到我的博客.md

    ---
    title: "欢迎来到我的博客"
    date: "2022-08-08"
    ---
    
    ## 欢迎你!
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    注意: 需要在每个markdown文件的顶部通过---添加元数据,元数据需要有title字段表示文章标题,date字段表示日期,如上面欢迎来到我的博客.md的元数据为:

    ---
    title: "欢迎来到我的博客"
    date: "2022-08-08"
    ---
    
    • 1
    • 2
    • 3
    • 4

    这些数据在我们渲染markdown内容时需要用到

    5、解析markdown内容

    需要安装以下插件:

    • remark-prism:代码高亮插件
    • date-fns:处理日期
    • gray-matter:获取元数据
    • next-mdx-remote:用于解析和渲染markdown内容
    • remark-external-links:对markdown内的链接添加reltarget,使其能够在新页面打开

    在项目根目录终端运行以下命令安装上述插件:

     npm i remark-prism date-fns gray-matter next-mdx-remote remark-external-links
    
    • 1
    npm i @types/remark-prism --D
    
    • 1

    在项目根目录新建存放工具函数的utils文件夹,里面新建处理markdown文件的posts.ts

    import fs from "fs";
    import path from "path";
    // gray-matter:获取元数据
    import matter from "gray-matter";
    // date-fns:处理日期
    import { parseISO } from "date-fns";
    import { serialize } from "next-mdx-remote/serialize";
    // remark-prism:markdown代码高亮
    import prism from "remark-prism";
    // externalLinks:使markdown的链接是在新页面打开链接
    import externalLinks from "remark-external-links";
    
    interface MatterMark {
        data: { date: string; title: string };
        content: string;
        [key: string]: unknown;
    }
    
    // posts目录的路径
    const postsDirectory = path.join(process.cwd(), "posts");
    // 获取posts目录下的所有文件名(带后缀)
    const fileNames = fs.readdirSync(postsDirectory);
    
    // 获取所有文章用于展示首页列表的数据
    export function getSortedPostsData() {
        // 获取所有md文件用于展示首页列表的数据,包含id,元数据(标题,时间)
        const allPostsData = fileNames.map((fileName) => {
            // 去除文件名的md后缀,使其作为文章id使用
            const id = fileName.replace(/\.md$/, "");
    
            // 获取md文件路径
            const fullPath = path.join(postsDirectory, fileName);
    
            // 读取md文件内容
            const fileContents = fs.readFileSync(fullPath, "utf8");
    
            // 使用matter提取md文件元数据:{data:{//元数据},content:'内容'}
            const matterResult = matter(fileContents);
    
            return {
                id,
                ...(matterResult.data as MatterMark["data"]),
            };
        });
    
        // 按照日期从进到远排序
        return allPostsData.sort(({ date: a }, { date: b }) =>
            // parseISO:字符串转日期
            parseISO(a) < parseISO(b) ? 1 : -1
        );
    }
    
    // 获取格式化后的所有文章id(文件名)
    export function getAllPostIds() {
        // 这是返回的格式:
        // [
        //   {
        //     params: {
        //       id: '......'
        //     }
        //   },
        //   {
        //     params: {
        //       id: '......'
        //     }
        //   }
        // ]
    
        return fileNames.map((fileName) => {
            return {
                params: {
                    id: fileName.replace(/\.md$/, ""),
                },
            };
        });
    }
    
    // 获取指定文章内容
    export async function getPostData(id: string) {
        // 文章路径
        const fullPath = path.join(postsDirectory, `${id}.md`);
    
        // 读取文章内容
        const fileContents = fs.readFileSync(fullPath, "utf8");
    
        // 使用matter解析markdown元数据和内容
        const matterResult = matter(fileContents);
    
        return {
            content: await serialize(matterResult.content, {
                mdxOptions: { remarkPlugins: [prism, externalLinks] },
            }),
            ...(matterResult.data as MatterMark["data"]),
        };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95

    posts.ts里有三个主要的函数:

    • getSortedPostsData:在首页用于展示文章列表

    • getAllPostIds:获取指定格式的所有文章id(文件名),这个格式是Next所要求的

      因为我们在写文章详情页面时需要使用动态路由,每个文章的id就是一个路由,并且我们使用的Next静态站点生成会在项目打包构建时直接生成所有的html文件,需要把每一个路由对应的页面都构建出来,Next会根据getAllPostIds函数返回的这种格式的数据去构建每一个html页面

    • getPostData:获取文章详情,在文章详情页面会用到

    6、添加首页

    首页会展示文章列表,会用到一个日期渲染组件,我们先创建一下

    components文件夹下新建date.tsx文件:

    import { parseISO, format } from "date-fns";
    
    interface Props {
        dateString: string;
    }
    
    export default function Date({ dateString }: Props) {
        const date = parseISO(dateString);
        return (
            <time dateTime={dateString} className='text-gray-500'>
                {format(date, "yyyy年MM月dd日")}
            </time>
        );
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    修改pages文件夹下的index.tsx文件如下:

    import type { NextPage, GetStaticProps } from "next";
    import Head from "next/head";
    import Layout, { siteTitle } from "../components/layout";
    import Link from "next/link";
    import Date from "../components/date";
    
    import { getSortedPostsData } from "../utils/posts";
    
    interface Props {
        allPostsData: {
            date: string;
            title: string;
            id: string;
        }[];
    }
    
    const Home: NextPage<Props> = ({ allPostsData }) => {
        return (
            <Layout home>
                <div>
                    <Head>
                        <title>{siteTitle}</title>
                    </Head>
    
                    <section className='text-xl leading-normal text-center'>
                        <p>你好,我是 Ailjx</p>
                        <p>一个又菜又爱玩的前端小白,欢迎来到我的博客!</p>
                    </section>
    
                    <section className='text-xl leading-normal pt-4'>
                        <h2 className=' text-2xl my-4 font-bold'>Blog</h2>
                        <ul>
                            {allPostsData.map(({ id, date, title }) => (
                                <li key={id} className='mb-5'>
                                    <Link href={`/posts/${id}`}>
                                        <a>{title}</a>
                                    </Link>
                                    <br />
                                    <small>
                                        <Date dateString={date} />
                                    </small>
                                </li>
                            ))}
                        </ul>
                    </section>
                </div>
            </Layout>
        );
    };
    
    export const getStaticProps: GetStaticProps = async () => {
      	// 获取文章列表
        const allPostsData = getSortedPostsData();
    
        return {
            props: {
                allPostsData,
            },
        };
    };
    
    export default Home;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62

    修改styles文件夹下的globals.css如下:

    a {
        color: #0070f3;
        text-decoration: none;
    }
    
    a:hover {
        text-decoration: underline;
    }
    
    img {
        max-width: 100%;
        display: block;
    }
    ::-webkit-scrollbar {
        width: 5px;
        height: 5px;
        position: absolute;
    }
    ::-webkit-scrollbar-thumb {
        background-color: #0070f3;
    }
    ::-webkit-scrollbar-track {
        background-color: #ddd;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    删除style文件夹下的Home.module.css

    此时运行项目,打开http://localhost:3000/可见:
    在这里插入图片描述

    7、添加文章详情页面

    pages文件夹下创建posts文件夹,在其中创建[id].tsx文件:

    import type { GetStaticProps, GetStaticPaths } from "next";
    import Layout from "../../components/layout";
    import { getAllPostIds, getPostData } from "../../utils/posts";
    import Head from "next/head";
    import Date from "../../components/date";
    import { MDXRemote, MDXRemoteProps } from "next-mdx-remote";
    // 引入代码高亮css
    import "prismjs/themes/prism-okaidia.min.css";
    
    interface Props {
        postData: {
            title: string;
            date: string;
            content: MDXRemoteProps;
        };
    }
    
    export default function Post({ postData }: Props) {
        return (
            <Layout>
                <Head>
                    <title>{postData.title}</title>
                </Head>
                <h1 className='text-3xl font-extrabold my-4 tracking-tighter'>
                    {postData.title}
                </h1>
    
                <Date dateString={postData.date} />
    
                <article className='py-8 prose  prose-h1:mt-8'>
                    <MDXRemote {...postData.content} />
                </article>
            </Layout>
        );
    }
    
    // getStaticProps和getStaticPaths只在服务器端运行,永远不会在客户端运行
    export const getStaticPaths: GetStaticPaths = async () => {
        // 获取所有文章id,即所有路由
        const paths = getAllPostIds();
        return {
            paths,
            fallback: false,
        };
    };
    
    export const getStaticProps: GetStaticProps = async ({ params }) => {
    	// 获取文章内容 
        const postData = await getPostData(params!.id as string);
        return {
            props: {
                postData,
            },
        };
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    之后在首页点击文章列表跳转到文章详情页面:
    在这里插入图片描述
    到此一个简单的博客项目就写好了

    8、Vercel部署

    没有Github账号的先去注册一个账号

    Github上新建一个名为next-blog的仓库(名称自己根据需要修改):

    在这里插入图片描述

    仓库权限公共私有都可,并且不需要使用README 或其他文件对其进行初始化

    在我们的博客项目根目录下运行以下命令推送代码到Github仓库里:

    git remote add origin https://github.com/<username>/next-blog.git
    git branch -M main
    git push -u origin main
    
    • 1
    • 2
    • 3

    请将上述第一行命令origin后面的地址替换成你的仓库地址,一般是将替换为你Gitub的用户名,next-blog替换成你仓库的名称

    之后刷新仓库查看代码:

    在这里插入图片描述

    项目仓库地址:https://github.com/Chen0807AiLJX/next-blog

    细心的大佬应该会发现我们这样提交代码是有问题的,因为我们并没有合并本地代码到本地仓库,所以提交到Github仓库的代码并不是我们最终的效果,而是创建Next.js时的初始效果。

    不过不用担心,我们在后面会对其进行处理。当然,你也可以现在处理,直接将最新的代码同步到仓库,这样你就免了后面我们对其处理的操作

    打开Vercel,没有Vercel账号的点击右上角的注册按钮进行注册,注册时选择通过Github注册,登录时也使用Github登录

    登录Vecel成功后打开 https://vercel.com/import/githttps://vercel.com/new或点击新建项目按钮,之后进入到以下页面:

    在这里插入图片描述
    这个页面中会自动获取你的Github仓库,选择你刚刚推送博客项目的仓库,点击Import按钮,之后直接点击Deploy按钮:

    在这里插入图片描述

    稍等片刻,出现以下页面就部署成功了:

    在这里插入图片描述

    点击上述页面左侧的页面预览框就能跳转到你部署成功的网页了,但这时你会发现部署的页面不是我们最终的页面,而是创建Next.js时的初始页面,这是因为我们在Git提交代码到仓库时没有合并本地代码,我们重新提交一下就行了

    我们可以在VScode里快速提交代码到仓库:

    在这里插入图片描述
    在这里插入图片描述

    点击同步更改后会开始转圈,等待转圈结束就提交成功了,之后什么都不用干,仓库代码更新后Vercel会自动部署!!!

    打开https://vercel.com/dashboard能查看到你已经部署的项目和对应的网页地址:

    在这里插入图片描述

    好啦,到此我们的任务就全部完成了,之后需要添加文章只需要在项目的posts文件内新建markdown文件就行了(不要忘记在markdown顶部添加元数据),更新完文章提交代码到仓库即可

    结语

    这次使用Next.js搭建个人博客只是一个小小的尝试,可以说是只搭建了一个骨架,其实走完整个流程你应该会有很多有趣的想法去完善填充你的博客,因为基础功能我们已经实现,剩下的就是锦上添花的操作了,这完全取决于你

    项目仓库地址:https://github.com/Chen0807AiLJX/next-blog
    最终效果可见:https://next-blog-eosin-six.vercel.app/

    参考资料:

    如果本篇文章对你有所帮助,还请客官一件四连!❤️

  • 相关阅读:
    【milkv】0、duo编译环境搭建
    梦笔记2022-1122
    死锁的原因及解决方法
    阿里云服务器续费流程_一篇文章搞定
    Java(面试题准备(非技术面)(仅供参考))
    Blazor 拖放上传文件转换格式并推送到浏览器下载
    《THE ENERGY MACHINE OF JOSEPH NEWMAN》翻译
    .NET 8 中的 WPF File Dialog 改进
    选择最适合的产品研发和运营管理工具
    python/pygame 挑战魂斗罗 笔记(三)
  • 原文地址:https://blog.csdn.net/m0_51969330/article/details/126251063