没事造轮子没事造轮子

没事造轮子

显示博客文章

  • W_Z_C
  • 阅读约 31 分钟
显示博客文章

本节内容是 Nextjs 搭建博客系列文章的核心内容,主要讲解如何在博客中显示 Markdown 文章。Nextjs 官网为此提供了一个叫做 @next/mdx 的插件,遗憾的是该插件默认只支持文件路由,这意味着你必须将 Markdown 文件存放置到 pages 文件夹下,充当普通的页面,文档的文件名会自动转换为访问的 URL 地址,而文档的内容会自动转换为网页显示的内容,大家感兴趣可以查看 官网的教程。但是因为咱们的文档标题可能包含中文,这时候根据路径映射 URL 就比较蛋疼了。

这里还要额外介绍一下 MDX,MDX 是一个在 Markdown 文档上面的扩展标准,它允许你在 Markdown 文档中包含 JSX 组件,所以如果你舍得花费功夫,可以让你的页面非常丰富,这也是我为什么手撸一个博客的原因,因为你可以使用 MDX 这个特性制作很多强大的交互性页面(来吧,展示!😎)。

目前要想使用 MDX 文档,我能找到方式有几种:

目前我使用的是 mdx-bundler,因为它支持 import 功能,可以导入外在的组件,而 next-mdx-remote 不支持。

使用 mdx-bundler 方法不难,但是要想显示 MDX 的内容需要自己手动编写一点代码,核心内容就是读取文档转化为 HTML 文件,当用户访问指定的 URL 的时候,将 HTML 发送到浏览器即可。

这里面存在一个 URL 和 文档的映射问题,如果你使用官方的 @next/mdx 插件进行渲染,文档的路径会自动映射为页面路径,但是对非 ASCII 用户来说这种方式不是很理想,这也是我放弃使用官方插件的一个理由之一。

既然如此我们就需要额外绑定 URL 和 Markdown 的手段,目前面临两个棘手的问题:一个是如何为每个 Markdown 指定一个 URL,其次是如何将这些 URL 告知给 Nextjs,只有完成这两步操作最终才能通过这些 URL 显示对应的文档内容。

对于前者可以在每个 Markdown 文档前面添加一部分额外的元信息。对于后者可以使用 Nextjs 的官方接口 getStaticPaths 来注册,下面详细阐述一下它们的使用方式。

1. 文档元信息

文档的元信息其实就是文档的附加信息,可以简单的划分为两个部分,一个部分是静态的,基本上很少改变,例如标题、分类、作者、发布时间等等,另一类是动态的,例如访问量、评分、评论等等,这类信息会随着时间的流逝而经常改变。

对于前者,我们可以使用 frontmatter 插件来解决这个问题,该插件可以允许你在 Markdown 文档的头部保存一组键值对,这对于存储静态信息来说非常合适。对于后者则需要和数据库联动做持久化操作,在以后的章节中再具体介绍,这里先主讲静态部分。

下面是我博客中某个 Markdown 文档的头部案例:

---
slug: ru-he-zhuan-qian
category: 博客
title: 如何在网络中赚钱?
author: W_Z_C
description: 如何在网络时代赚钱?一共只需要三步。第一步需要有一个想法。第二步,贯彻执行这个想法。第三步,扩大或者换个新的想法执行。
tags: 如何赚钱、想法、策略、网络赚钱、赚钱体会
featureImage: /assert/images/make-money.jpg
createdAt: 2020-09-11
updatedAt: 2020-12-22
---

下面是文章的具体内容……

其中文章头部的 slug 就是未来访问这篇文章的 URL 地址,要想实现这个功能还需要我们将这些地址注册到 Nextjs 中。

2. getStaticPaths 和路由系统

在上篇文章中我们已经提到过 Nextjs 的页面系统,将文件添加到 pages 目录后,会自动生成和文件路径一致的 URL,管理这些 URL 的系统在后端经常被称之为路由系统,本质上路由系统就是对前端访问请求的解析和响应,常见的解析方式就是对 URL 的处理,例如查询参数、URL 路径,除此之外还可以包含 HTTP 头的解析等等。

在 Nextjs 中,会将 index 文件名称自动处理为根目录,例如:

  • pages/index.js 文件会转变为 / URL 地址。
  • pages/blog/index.js 文件会转变为 /blog URL 地址。

除此之外,还有最常见的嵌套结构:

  • pages/blog/demo.js 文件会转变为 /blog/demo URL 地址。
  • pages/dashboard/settings/user.js 文件会转变为 /dashboard/settings/user URL 地址。

上面这些都比较好理解,因为使一对一的关系,一个文件路径对应一个 URL。但是除此之外,Nextjs 还支持动态变量,你可以使用 “[]” 来表示可变的部分:

  • pages/blog/[slug].js 文件会转变为 /blog/:slug URL 地址。
  • pages/[username]/settings.js 文件转变为 /:username/settings URL 地址。
  • pages/post/[...all].js 文件转变为 /post/* URL 地址。

URL 中以 “:” 开头的字符串代表一个变量,这个变量可以在 Nextjs 后端代码中读取到。对于第一个案例,当用户访问 /blog/test 时,会自动触发 pages/blog/[slug].js 页面中的逻辑,你可以在文件代码中读取到用户访问的 URL 最后一个字段的名称,在这个案例里就是 test,如果用户访问 /blog/ru-he-zhuan-qian,那 slug 变量的值就是 ru-he-zhuan-qian

这种带有动态变量的路由本质上就是将一组相似的 URL 逻辑统一到单独的页面进行处理,好处就是同一段代码可以处理一组页面的逻辑。在代码中,你可以通过 URL 中的变量信息区分不同的页面,这种方式在网站开发中非常常见,因为大多数网站的页面都是可以分成几类来处理。理论上,Nextjs 可以使用一个页面处理所有的 URL,只需要写作 pages/[[...slug]].js,这其实才是我博客中真正使用的方式,它可以处理网站上的所有页面信息。

在 Nextjs 中,含有变量的路由,即包含中括号([])文件名的页面被称为动态路由,这种路由在处理的时候需要特别对待,因为 Nextjs 在生成页面的时候,需要知道该文件生成的所有 URL 地址,对于常见的嵌套路由来说,很容易计算,一个文件代表一个 URL,但是对于带中括号的动态路由来说,就需要我们一种额外的方法来告知 Nextjs 该页面一共包含多少 URL 链接。

Nextjs 为此特意提供了一个函数,你可以在动态页面的代码中导出一个叫做 getStaticPaths 的函数,来告知 Nextjs 这个页面包含哪些 URL,例如:

pages/posts/[id].js
export async function getStaticPaths() {
  return {
    paths: [
        { params: { id: '1' } },
        { params: { id: '2' } }
    ],
    fallback: true, false or "blocking" // See the "fallback" section below
  };
}

上面的代码表示该页面包含两个 URL,分别是 /posts/1/posts/2,这些页面会在 Nextjs 编译期间生成。其中函数返回值中的 fallback 表示如何处理 URL 中不存在的路由,例如当用户访问 /posts/3 时会如何处理。

3. 处理 Markdown 文档

网站结构规划中,我已经提到过,为了以后查询和动态数据存储,我将 Markdown 暂存到了 Sqlite 数据库,所以在真正处理 Markdown 之前,需要先要创建 Sqlite。

3.1 创建数据库

这次迭代我不想使用原来的 Sqlite 操作方式,想替换为 Remix 教程中使用的 Prisma ORM 框架,好处在于实现一次既可以在不同的数据库之间无缝切换,毕竟 Prisma 框架支持很多种数据库,坏处就是需要花费额外的时间学习如何使用,不过这不要紧,边学边用即可。

首先,我们需要安装两个库:

npm install --save-dev prisma
npm install @prisma/client

其中:

  • prisma,用于开发过程中和数据库之间的交互。
  • @prisma/client,用于数据的查询。

安装好后,初始化 prisma:

npx prisma init --datasource-provider sqlite

控制台会输出下面的提示信息:

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore. Don't forget to exclude .env to not commit any secret.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

prisma 会在当前目录生成 prisma/schema.prisma 和 .env 两个文件,我们先将 .env 文件中的 DATABASE_URL 变量的值改为预设的目标位置:

DATABASE_URL="file:../dbs/dev.db"

然后修改 schema.prisma 文件,设置存储 Markdown 信息的表结构,下面是修改后的文件内容:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model posts {
  id                 Int     @id @default(autoincrement())
  i18n               String  @default("zh-Hans")
  slug               String
  layout             String  @default("Post")
  category           String  @default("博客")
  categorySlug       String  @default("bo-ke")
  chapter            String? @default("")
  title              String
  description        String?
  scripts            String  @default("")
  headings           String  @default("[]")
  content            String?
  readMins           Float   @default(0)
  words              Int     @default(0)
  author             String  @default("W_Z_C")
  tags               String?
  featureImage       String
  featureImageWidth  Int     @default(0)
  featureImageHeight Int     @default(0)
  featureVideo       String? @default("")
  demoLink           String? @default("")
  sourceLink         String? @default("")
  filename           String
  createdAt          String
  updatedAt          String

  @@unique([slug, i18n], map: "kp_slug")
}

因为博客的文章目测很少,撑死上千的文章,完全没有必要拆分表,所以直接一张表涵盖所有文章的静态数据,每行一篇文章。

有了 schema 之后,可以执行 push 命令,创建数据库:

npx prisma db push

控制台会输出提示信息:

Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": SQLite database "dev.db" at "file:../dbs/dev.db"

The database is already in sync with the Prisma schema.

✔ Generated Prisma Client (3.14.0 | library) to .\node_modules\@prisma\client in 134ms

这时候查看 dbs 文件夹,就会发现多出了一个叫做 dev.db 的文件,该文件就是咱们的 Sqlite 数据库。接下来将该文件和 .env 文件追加到 .gitignore 文件中,防止被提交到 git 中泄密。

/dbs/dev.db
.env

3.2 初始化数据库

有了数据库之后,我们在 src 目录下创建一个 backend 目录,这里面存放一些纯后端的代码,目前主要用于将 Markdown 文件内容添加到数据库中。

首先要实现的功能是查找现有的 Markdown 文件列表:

backend/init.mjs
//获取文件路径
async function getPathAll(root, excludes = {}, attachs = {}, allFiles = []) {

    //获取所有文件
    const files = (await fsP.readdir(root, { withFileTypes: true })).filter(f => !excludes[f.name])

    //存储文件
    const dir = [];
    for (let i = 0; i < files.length; i++) {
        const ext = path.extname(files[i].name)

        if (files[i].isDirectory()) {
            dir.push(path.join(root, files[i].name))
        } else if (files[i].isFile() && (ext === '.md' || ext === '.mdx')) {
            allFiles.push({ ...attachs, dir: root, name: files[i].name })
        }
    }

    //递归
    await Promise.all(dir.map(async f => getPathAll(f, excludes, attachs, allFiles)))

    return allFiles
}

代码逻辑很简单,直接使用递归的方式查询指定目录下的所有后缀名为 .md.mdx 的文件,返回的结果包含文件所在的目录和文件的名称,除此之外还在函数的参数中额外添加了一个 attachs 参数,该参数会被追加到返回值中,该值的目的主要是为了标记返回结果的类型。

知道了文件位置,接下来要做的就是解析文件,这里使用的是前面提到的 mdx-bundler 库,该库可以解析 Markdown 文件,并返回文件的元数据和文件内容,具体可以查看 官方文档。最终这些返回的内容会统一存储到数据库中,便于以后使用。

在实现逻辑之前,需要额外的安装几个库:

npm install moment
npm install reading-time
npm install tiny-pinyin
npm install mdx-bundler esbuild

其中 moment 用于处理时间,reading-time 用于获取文章的阅读时间,tiny-pinyin 用于将中文转化为拼音,转化 URL 的时候会用到,mdx-bundler 用于解析 MDX 文档。

下面是解析 Markdown 文件列表的代码:

Markdown 文件列表解析
init.mjs
import * as fsP from 'fs/promises'
import * as path from 'path'
import readingTime from 'reading-time'
import { bundleMDX } from 'mdx-bundler'
import moment from 'moment'

//获取 MDX 文档数据
const DOCROOT = process.env.DOC_ROOT || path.join(process.cwd(), 'docs')
const COMPONENTROOT = process.env.COMP_ROOT || path.join(process.cwd(), 'components')

async function getMDXList() {
    const files = await getPathAll(DOCROOT)

    let posts = []
    let onces = []

    for (let i = 0; i < files.length; i++) {
        const fp = path.join(files[i].dir, files[i].name)

        //文章表弟列表
        let headings = []
        const { code, frontmatter } = await bundleMDX({
            mdxOptions: opts => {
                //TODO: 添加额外的处理插件
                return opts
            },
            file: fp,
            cwd: COMPONENTROOT
        })


        //是否为草稿
        if (frontmatter.isDraft) {
            continue
        }

        //统计字数
        const text = await fsP.readFile(fp)
        const stats = readingTime(text, { wordsPerMinute: 400 })

        const category = frontmatter.category || '博客'

        let post = {
            sort: getSort(files[i].name),
            category: category,
            categorySlug: toPinyin(category),
            chapter: '',
            featureImage: '',
            featureVideo: '',
            demoLink: '',
            sourceLink: '',
            scripts: '',
            i18n: '',
            ...frontmatter,
            readMins: stats.minutes,
            words: stats.words,
            content: code,
            headings: JSON.stringify(headings),
            filename: fp,
            createdAt: moment(frontmatter.createdAt),
            updatedAt: moment(frontmatter.updatedAt)
        }

        //校验
        const key = `${post.slug}_${post.i18n}`
        if (!onces[key]) {
            onces[key] = true
        } else {
            console.error(key, 'URL 重复!')
        }

        posts.push(post)
    }

    return posts
}

这里面有几个地方需要说明一下,首先是 sort 属性,这个属性主要为了文章排序,因为我的博客在显示教程的时候,会在左边显示教程章节列表,列表中的文章是存在先后顺序的,为了解决这个问题,我特意规定每个 Markdown 文件名的前面都追加一个序号,这样不仅在本地看的方便,而且还可以记录文章的先后顺序。

例如:

04 Yaffs文件系统移植.md
05 CMake 安装指南.md
06 zlib 静态编译教程.md

序号和真正的文件名之间存在一个空格,这样写代码的时候更容易处理。 代码中 getSort 函数就是为了获取文章的排序信息:

init.mjs
//获取文件排序
function getSort(filename) {
    const items = filename.split(' ')
    if(items.length > 0) {
        return ~~items[0];
    }

    return -1;
}

再有 toPinyin 函数,是为了创建分类的 URL,因为每个文章都有一个类别,所以博客上会出现类似下面的链接:

/category/bian-yi

该链接会显示同一个分类下的所有文章,这是博客系统中常见的功能,这类页面一般被称为聚合页面。要想实现这类页面,首先需要知道该分类下有哪些文章,最好的方式是专门创建一个分类表,然后将分类和文章进行关联,这样做能才满足数据库的第三范式。

但是线上博客文章的数量很少,冗余的信息并不会对线上的查询造成性能影响,因此为了简化查询语句,完全可以增加两列,一个列存放分类名称,一列存放分类的路径。

下面是 toPinyin 函数的代码,仅供大家参考:

toPinyin 函数实现
init.mjs
//移除首位字符
function removeFBChar(str, ch) {
    if(typeof str !== 'string' || str.length < 1) {
        return ''
    }

    //排除首字符
    if(str.startsWith(ch)) {
        return removeFBChar(str.slice(1), ch)
    }

    //排除尾字符
    if(str.endsWith(ch)) {
        return removeFBChar(str.slice(0, str.length - 1), ch)
    }

    return str
}

//文字转拼音
function toPinyin(text) {
    const arr = TinyPinyin.parse(text.trim())
    const splits = []
    let tmp = ''

    for(let i=0; i<arr.length; i++) {
        if(arr[i].type === 2) {
            if(tmp.length > 0) {
                splits.push(tmp)
                tmp = ''
            }
            splits.push(arr[i].target)
        } else {
            if(arr[i].source === ' ') {
                tmp += '-'
            } else {
                tmp += arr[i].source
            }
        }
    }

    if(tmp.length > 0) {
        splits.push(tmp)
    }


    //返回的拼音字段
    let py = (splits.join('-') || '').toLowerCase().replace(/[^a-z0-9-]/gi, '').replace(/-+/g, '-')


    //移除首位字符
    return removeFBChar(py, '-');
}

getMDXList 函数中其实还要一些冗余部分,例如 headings 变量貌似完全没有用到,以及额外的插件没有完善,之所以暂时删除这个部分,主要是因为让大家的思维更加聚焦,这些逻辑在本章节的作用并不大,其中 headings 主要是提取文章标题,为了显示文章大纲使用,而那些插件主要是为了处理 Markdown 文档中的一些特殊元素而设置的,可以放到后期专门讲解。

下面随机抽取一项返回值给大家感受一下:

getMDXList 返回值
  {
    sort: 6,
    category: '编译',
    categorySlug: 'bian-yi',
    chapter: '',
    featureImage: '/images/zlib-logo.png',
    featureVideo: '',
    demoLink: '',
    sourceLink: '',
    scripts: '',
    i18n: '',
    slug: 'zlib-jing-tai-bian-yi-jiao-cheng',
    layout: 'Post',
    type: 'Post',
    title: 'zlib 静态编译教程',
    author: 'W_Z_C',
    description: 'zlib 是提供资料压缩的函数库,由 Jean-loup Gailly 与 Mark Adler 所开发,初版0.9版在199551日发表。zlib使用抽象化的 DEFLATE 算法,最初是为 libpng 函 
数库所写的,后来普遍为许多软件所使用。',
    tags: 'zlib、zlib静态库、静态库、开发库、静态编译、编译教程',
    initAvgScore: 5,
    initTotalCount: 7,
    createdAt: Moment<2020-03-04T08:00:00+08:00>,
    updatedAt: Moment<2020-12-22T08:00:00+08:00>,
    readMins: 2.535,
    words: 1014,
    content: 'var Component=(()=>{var s=Object.create;var l=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var g=Object.getOwnPropertyNames;var u=Object.getPrototypeOf,b=Object.prototype.hasOwnProperty;var f=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports),p=(e,r)=>{for(var o in r)l(e,o,{get:r[o],enumerable:!0})},c=(e,r,o,i)=>{if(r&&typeof r=="object"||typeof r=="function")for(let t of g(r))!b.call(e,t)&&t!==o&&l(e,t,{get:()=>r[t],enumerable:!(i=h(r,t))||i.enumerable});return e};var z=(e,r,o)=>(o=e!=null?s(u(e)):{},c(r||!e||!e.__esModule?l(o,"default",{value:e,enumerable:!0}):o,e)),m=e=>c(l({},"__esModule",{value:!0}),e);var a=f((y,d)=>{d.exports=_jsx_runtime});var x={};p(x,{default:()=>M,frontmatter:()=>C});var n=z(a()),C={slug:"zlib-jing-tai-bian-yi-jiao-cheng",layout:"Post",type:"Post",category:"\\u7F16\\u8BD1",title:"zlib \\u9759\\u6001\\u7F16\\u8BD1\\u6559\\u7A0B",author:"W_Z_C",description:"zlib \\u662F\\u63D0\\u4F9B\\u8D44\\u6599\\u538B\\u7F29\\u7684\\u51FD\\u6570\\u5E93\\uFF0C\\u7531 Jean-loup Gailly \\u4E0E Mark Adler \\u6240\\u5F00\\u53D1\\uFF0C\\u521D\\u72480.9\\u7248\\u57281995\\u5E745\\u67081\\u65E5\\u53D1\\u8868\\u3002zlib\\u4F7F\\u7528\\u62BD\\u8C61\\u5316\\u7684 DEFLATE \\u7B97\\u6CD5\\uFF0C\\u6700\\u521D\\u662F\\u4E3A libpng \\u51FD\\u6570\\u5E93\\u6240\\u5199\\u7684\\uFF0C\\u540E\\u6765\\u666E\\u904D\\u4E3A\\u8BB8\\u591A\\u8F6F\\u4EF6\\u6240\\u4F7F\\u7528\\u3002",tags:"zlib\\u3001zlib\\u9759\\u6001\\u5E93\\u3001\\u9759\\u6001\\u5E93\\u3001\\u5F00\\u53D1\\u5E93\\u3001\\u9759\\u6001\\u7F16\\u8BD1\\u3001\\u7F16\\u8BD1\\u6559\\u7A0B",featureImage:"/images/zlib-logo.png",initAvgScore:5,initTotalCount:7,createdAt:new Date(158328e7),updatedAt:new Date(16085952e5)};function k(e={}){let{wrapper:r}=e.components||{};return r?(0,n.jsx)(r,Object.assign({},e,{children:(0,n.jsx)(o,{})})):o();function o(){let i=Object.assign({p:"p",a:"a",img:"img",code:"code",pre:"pre",blockquote:"blockquote"},e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(i.p,{children:"zlib \\u662F\\u63D0\\u4F9B\\u8D44\\u6599\\u538B\\u7F29\\u7684\\u51FD\\u6570\\u5E93\\uFF0C\\u7531 Jean-loup Gailly \\u4E0E Mark Adler \\u6240\\u5F00\\u53D1\\uFF0C\\u521D\\u72480.9\\u7248\\u57281995\\u5E745\\u67081\\u65E5\\u53D1\\u8868\\u3002zlib\\u4F7F\\u7528\\u62BD\\u8C61\\u5316\\u7684 DEFLATE \\u7B97\\u6CD5\\uFF0C\\u6700\\u521D\\u662F\\u4E3A libpng \\u51FD\\u6570\\u5E93\\u6240\\u5199\\u7684\\uFF0C\\u540E\\u6765\\u666E\\u904D\\u4E3A\\u8BB8\\u591A\\u8F6F\\u4EF6\\u6240\\u4F7F\\u7528\\u3002"}),`\n' +
      '`,(0,n.jsxs)(i.p,{children:["Windows \\u7248\\u4E00\\u822C\\u4E0D\\u63D0\\u4F9B\\u9759\\u6001\\u7F16\\u8BD1\\uFF0C\\u672C\\u6559\\u7A0B\\u4E3B\\u8981\\u4ECB\\u7ECD\\u4E86\\u5728 Windows \\u5982\\u4F55\\u9759\\u6001\\u7F16\\u8BD1 zlib\\u3002\\u5728\\u5F00\\u59CB\\u4E4B\\u524D\\uFF0C\\u9996\\u5148\\u786E\\u8BA4\\u4F60\\u7684\\u673A\\u5668\\u5DF2\\u7ECF\\u5B89\\u88C5\\u4E86 CMake \\u548C Visual Studio\\uFF0C\\u5982\\u679C\\u4F60\\u4E0D\\u77E5\\u9053\\u600E\\u6837\\u5B89\\u88C5\\uFF0C\\u53EF\\u4EE5\\u67E5\\u770B ",(0,n.jsx)(i.a,{href:"/cmake-an-zhuang-zhi-nan/",children:"CMake \\u5B89\\u88C5\\u6559\\u7A0B"})," \\u548C ",(0,n.jsx)(i.a,{href:"/visual-studio-ide-an-zhuang-zhi-nan/",children:"Visual Studio \\u5B89\\u88C5\\u6559\\u7A0B"}),"\\u3002"]}),`\n' +
      '`,(0,n.jsxs)(i.p,{children:["\\u9996\\u5148\\u53BB",(0,n.jsx)(i.a,{href:"http://www.zlib.net/",children:"\\u5B98\\u7F51"}),"\\uFF0C\\u4E0B\\u8F7D zlib \\u7684\\u6E90\\u7801\\u5305\\u3002"]}),`\n' +
      '`,(0,n.jsx)(i.p,{children:(0,n.jsx)(i.img,{src:"/images/zlib0.jpg",alt:"\\u5B98\\u7F51\\u4E0B\\u8F7D\\u9875\\u9762"})}),`\n' +
      '`,(0,n.jsxs)(i.p,{children:["\\u6700\\u65B0\\u7248\\u672C\\u7684 zlib \\u4E3A 1.2.11\\u3002\\u4E0B\\u8F7D\\u5B8C\\u6210\\u540E\\uFF0C\\u89E3\\u538B\\uFF0C\\u627E\\u5230 CMakeLists.txt \\u6587\\u4EF6\\uFF0C\\u7528\\u5DF2\\u7ECF\\u5B89\\u88C5\\u597D\\u7684 CMake \\u8F6F\\u4EF6\\u6253\\u5F00\\uFF0C\\u6216\\u8005\\u76F4\\u63A5\\u5C06\\u8BE5\\u6587\\u4EF6\\u62D6\\u62FD\\u5230 CMake \\u7684 UI \\u754C\\u9762\\u4E0A\\u3002\\u5728 ",(0,n.jsx)(i.code,{children:"Where to build the binaries"})," \\u76EE\\u5F55\\u540E\\u9762\\u589E\\u52A0 ",(0,n.jsx)(i.code,{children:"_build"})," \\u76EE\\u5F55\\u3002\\u8FD9\\u6837\\u53EF\\u4EE5\\u786E\\u4FDD\\u751F\\u6210\\u7684\\u6587\\u4EF6\\u5728 ",(0,n.jsx)(i.code,{children:"_build"})," \\u76EE\\u5F55\\u4E2D\\uFF0C\\u800C\\u4E0D\\u4F1A\\u6C61\\u67D3\\u539F\\u6709\\u7684 zlib \\u76EE\\u5F55\\u3002"]}),`\n' +
      '`,(0,n.jsx)(i.p,{children:(0,n.jsx)(i.img,{src:"/images/zlib1.jpg",alt:"CMake \\u754C\\u9762"})}),`\n' +
      '`,(0,n.jsxs)(i.p,{children:["\\u70B9\\u51FB Configure \\u6309\\u94AE\\uFF0C\\u751F\\u6210\\u914D\\u7F6E\\u4FE1\\u606F\\uFF0C\\u5982\\u679C\\u5F39\\u51FA\\u65B0\\u5EFA ",(0,n.jsx)(i.code,{children:"_build"})," \\u76EE\\u5F55\\u786E\\u8BA4\\u7684\\u5BF9\\u8BDD\\u6846\\uFF0C\\u9009\\u62E9 Yes\\u3002"]}),`\n' +
      '`,(0,n.jsx)(i.p,{children:(0,n.jsx)(i.img,{src:"/images/zlib2.jpg",alt:"\\u786E\\u8BA4\\u5BF9\\u8BDD\\u6846"})}),`\n' +
      '`,(0,n.jsx)(i.p,{children:"\\u9009\\u62E9\\u4F60\\u60F3\\u8981\\u7F16\\u8BD1\\u7684\\u5E73\\u53F0\\u7248\\u672C\\uFF0C\\u8FD9\\u91CC\\u4F7F\\u7528 vs2019 \\u7F16\\u8BD1 x64 \\u4F4D\\u7684\\u9759\\u6001\\u5E93\\u3002"}),`\n' +
      '`,(0,n.jsx)(i.p,{children:(0,n.jsx)(i.img,{src:"/images/zlib3.jpg",alt:"\\u7F16\\u8BD1\\u7684\\u5E73\\u53F0\\u7248\\u672C"})}),`\n' +
      '`,(0,n.jsx)(i.p,{children:"\\u70B9\\u51FB Finish\\uFF0CCMake \\u4F1A\\u81EA\\u52A8\\u8BC6\\u522B\\u5F53\\u524D\\u7CFB\\u7EDF\\u7684\\u914D\\u7F6E\\u4FE1\\u606F\\uFF0C\\u5E76\\u751F\\u6210\\u76F8\\u5173\\u7684\\u914D\\u7F6E\\u6587\\u4EF6\\u3002"}),`\n' +
      '`,(0,n.jsx)(i.pre,{children:(0,n.jsx)(i.code,{children:`Selecting Windows SDK version 10.0.18362.0 to target Windows 10.0.18363.\\r\n' +
      'The C compiler identification is MSVC 19.24.28315.0\\r\n' +
      'Check for working C compiler: C:/Program Files (x86)/Microsoft Visual Studio/2019/Community/VC/Tools/MSVC/14.24.28314/bin/Hostx64/x64/cl.exe\\r\n' +
      'Check for working C compiler: C:/Program Files (x86)/Microsoft Visual Studio/2019/Community/VC/Tools/MSVC/14.24.28314/bin/Hostx64/x64/cl.exe -- works\\r\n' +    
      'Detecting C compiler ABI info\\r\n' +
      'Detecting C compiler ABI info - done\\r\n' +
      'Detecting C compile features\\r\n' +
      'Detecting C compile features - done\\r\n' +
      'Looking for sys/types.h\\r\n' +
      'Looking for sys/types.h - found\\r\n' +
      'Looking for stdint.h\\r\n' +
      'Looking for stdint.h - found\\r\n' +
      'Looking for stddef.h\\r\n' +
      'Looking for stddef.h - found\\r\n' +
      'Check size of off64_t\\r\n' +
      'Check size of off64_t - failed\\r\n' +
      'Looking for fseeko\\r\n' +
      'Looking for fseeko - not found\\r\n' +
      'Looking for unistd.h\\r\n' +
      'Looking for unistd.h - not found\\r\n' +
      'Renaming\\r\n' +
      '    C:/projects/zlib-1.2.11/zconf.h\\r\n' +
      "to 'zconf.h.included' because this file is included with zlib\\r\n" +
      'but CMake generates it automatically in the build directory.\\r\n' +
      'Configuring done\n' +
      '`})}),`\n' +
      '`,(0,n.jsx)(i.p,{children:"\\u5982\\u679C\\u754C\\u9762\\u663E\\u793A\\u7EA2\\u8272\\uFF0C\\u4E0D\\u7528\\u62C5\\u5FC3\\uFF0C\\u518D\\u6B21\\u70B9\\u51FB Configure \\u6309\\u94AE\\u5373\\u53EF\\u6D88\\u5931\\u3002"}),`\n' +
      '`,(0,n.jsx)(i.p,{children:"\\u8FD9\\u91CC\\u9762\\u56E0\\u4E3A\\u65E0\\u9700\\u914D\\u7F6E\\u5176\\u5B83\\u4FE1\\u606F\\uFF0C\\u6240\\u4EE5\\u53EF\\u4EE5\\u76F4\\u63A5\\u70B9\\u51FB Generate \\u751F\\u6210\\u5DE5\\u7A0B\\u6587\\u4EF6\\u3002\\u63A5\\u7740\\u70B9\\u51FB OpenProject \\u6309\\u94AE\\u6253\\u5F00\\u5DE5\\u7A0B\\u5373\\u53EF\\u3002"}),`\n' +
      '`,(0,n.jsx)(i.p,{children:(0,n.jsx)(i.img,{src:"/images/zlib4.jpg",alt:"\\u9879\\u76EE\\u5217\\u8868"})}),`\n' +
      '`,(0,n.jsxs)(i.p,{children:["\\u89E3\\u51B3\\u65B9\\u6848\\u4E2D\\u7684 zlib \\u53EF\\u4EE5\\u7F16\\u8BD1\\u52A8\\u6001\\u5E93\\uFF0Czlibstatic \\u53EF\\u4EE5\\u7F16\\u8BD1\\u9759\\u6001\\u5E93\\uFF0C\\u8FD9\\u91CC\\u6211\\u4EEC\\u9700\\u8981\\u9759\\u6001\\u5E93\\uFF0C\\u6240\\u4EE5\\u76F4\\u63A5\\u7F16\\u8BD1 zlibstatic \\u5DE5\\u7A0B\\u5373\\u53EF\\u3002\\u4F46\\u662F\\u6309\\u7167 Debug/Release \\u548C /MT \\u4EE5\\u53CA /MD \\u4E4B\\u5206\\uFF0C\\u81F3\\u5C11\\u53EF\\u4EE5\\u7EC4\\u5408\\u56DB\\u79CD\\u914D\\u7F6E\\uFF0C\\u6240\\u4EE5\\u6211\\u4EEC\\u5206\\u522B\\u9009\\u62E9\\u4E0D\\u540C\\u7684\\u914D\\u7F6E\\u5C5E\\u6027\\uFF0C\\u751F\\u6210\\u6700\\u540E\\u7684\\u56DB\\u79CD\\u5E93\\u6587\\u4EF6\\uFF0C\\u5E76\\u5C06\\u5B83\\u4EEC\\u653E\\u5230\\u4E0D\\u540C\\u7684\\u76EE\\u5F55\\u4E0B\\u3002\\u5177\\u4F53\\u7684\\u914D\\u7F6E\\u65B9\\u6CD5\\u53EF\\u4EE5\\u67E5\\u770B ",(0,n.jsx)(i.a,{href:"/yun-xing-shi-ku-pei-zhi-xiang-jie/",children:"\\u8FD0\\u884C\\u65F6\\u5E93\\u8BE6\\u89E3"})," \\u8FD9\\u7BC7\\u6587\\u7AE0\\uFF0C\\u6BCF\\u6B21\\u4FEE\\u6539\\u914D\\u7F6E\\u90FD\\u7F16\\u8BD1\\u4E00\\u6B21\\u5DE5\\u7A0B\\uFF0C\\u7F16\\u8BD1\\u6210\\u529F\\u540E\\uFF0C\\u5C06\\u751F\\u6210\\u7684\\u7ED3\\u679C\\u653E\\u5230\\u4E00\\u5F00\\u59CB\\u8BBE\\u5B9A\\u7684\\u76EE\\u5F55\\u4E0B\\uFF0C\\u6700\\u540E\\u7684\\u76EE\\u5F55\\u7ED3\\u6784\\u5982\\u4E0B\\uFF1A"]}),`\n' +
      '`,(0,n.jsx)(i.pre,{children:(0,n.jsx)(i.code,{children:`---\\r\n' +
      ' |--- include //\\u653E\\u5934\\u6587\\u4EF6\\r\n' +
      ' |--- lib\\r\n' +
      '       |--- Debug\\r\n' +
      '       |      |--- MDd\\r\n' +
      '       |      |     |-- zlibstaticd.lib\\r\n' +
      '       |      |\\r\n' +
      '       |      |--- MTd\\r\n' +
      '       |            |-- zlibstaticd.lib\\r\n' +
      '       |\\r\n' +
      '       |--- Release\\r\n' +
      '              |--- MD\\r\n' +
      '              |     |-- zlibstatic.lib\\r\n' +
      '              |\\r\n' +
      '              |--- MT\\r\n' +
      '                    |-- zlibstatic.lib\n' +
      '`})}),`\n' +
      '`,(0,n.jsxs)(i.p,{children:["\\u4E3A\\u4E86\\u65B9\\u4FBF\\u8FD9\\u91CC\\u6211\\u6253\\u5305\\u4E86\\u6700\\u540E\\u7684\\u7ED3\\u679C\\uFF0C\\u65B9\\u4FBF\\u5927\\u5BB6\\u76F4\\u63A5\\u4F7F\\u7528\\uFF0C\\u4E0D\\u8FC7\\u8FD9\\u91CC\\u53EA\\u6709 x64 \\u7248\\u672C\\uFF0C\\u5982\\u679C\\u4F60\\u7684\\u673A\\u5668\\u8FD8\\u662F 
32 \\u4F4D\\u7CFB\\u7EDF\\u5219\\u65E0\\u6CD5\\u4F7F\\u7528\\u3002",(0,n.jsx)(i.a,{href:"/download/",children:"zlib \\u9759\\u6001\\u5E93\\u4E0B\\u8F7D"})]}),`\n' +    
      '`,(0,n.jsxs)(i.blockquote,{children:[`\n' +
      '`,(0,n.jsxs)(i.p,{children:["\\u8FD9\\u91CC\\u6709\\u4E00\\u70B9\\u6CE8\\u610F\\uFF0C\\u5728 include \\u5934\\u6587\\u4EF6\\u7684\\u76EE\\u5F55\\u91CC\\u9762\\u9700\\u8981\\u52A0\\u5165 CMake \\u751F\\u6210\\u7684 zconf.h \\u6587\\u4EF6\\uFF0C\\u8FD9\\u4E2A\\u6587\\u4EF6\\u9ED8\\u8BA4\\u751F\\u6210\\u5728 ",(0,n.jsx)(i.code,{children:"_build/zconf.h"})," \\u91CC\\u9762\\uFF0C\\u5982\\u679C\\u5FD8\\u8BB0\\u8FD9\\u4E2A\\u5934\\u6587\\u4EF6\\uFF0C\\u5728\\u4F7F\\u7528 zlib \\u5E93\\u7684\\u65F6\\u5019\\uFF0C\\u53EF\\u80FD\\u4F1A\\u65E0\\u6CD5\\u627E\\u5230 zconf.h \\u800C\\u5BFC\\u81F4\\u7F16\\u8BD1\\u65E0\\u6CD5\\u901A\\u8FC7\\u3002"]}),`\n' +
      '`]})]})}}var M=k;return m(x);})();\n' +
      ';return Component;',
    headings: '[]',
    filename: 'C:\\projects\\mszlz_i18n\\docs\\posts\\06 zlib 静态编译教程.md'
  }

上面的输出是 06 zlib 静态编译教程.md 文章的输出结果,大家可以很容易发现,文章的内容被转义为了一段 JavaScript 代码。这就是 mdx-bundler 库的处理结果,该库会将文章内容编译为一个组件,最后在通过 React 进行渲染。

接下里就是初始化数据库的最后一步,将刚刚得到的数组存储到数据库中。首先获取 prisma 客户端链接:

backend/prisma.mjs
import { PrismaClient } from "@prisma/client"

let _db = null

if (process.env.NODE_ENV === "production") {
    _db = new PrismaClient();
} else {
    if (!global.__db) {
        global.__db = new PrismaClient({
            log: ['query']
        });
    }

    _db = global.__db;
}

function getDB() {
    return _db
}

export default getDB 

这里简单的缓存一下,防止文件被多次加载,以后获取客户端链接可以统一使用 getDB 函数。接着就是将记录插入数据库中。

backend/db.mjs
async function savePosts(posts) {
    const db = getDB()
    if (!db) {
        console.error('获取数据库实例失败!')
        return
    }

    //排序,确保日期越小对应的 ID 越小
    const initPosts = posts.sort((a, b) => {
        if (a.sort === b.sort) {
            if (a.createdAt.isSame(b.createdAt))
                return 0
            else
                return a.createdAt.isBefore(b.createdAt) ? -1 : 1
        }

        return a.sort - b.sort
    }).map(v => {
        return {
            ...omitBy(v, 'sort'),
            createdAt: v.createdAt.format('YYYY-MM-DD HH:mm:ss'),
            updatedAt: v.createdAt.format('YYYY-MM-DD HH:mm:ss'),
        }
    })

    await db.posts.deleteMany({})
    for (const v of initPosts) {
        await db.posts.create({ data: v })
    }

    // await db.posts.createMany({
    //     data: posts,
    //     skipDuplicates: true
    // })
}

export { savePosts }

代码逻辑只是单纯的数据库操作,先使用 deleteMany 函数删除现有表格中的数据,然后在使用 createMany 插入新的数据。因为 createMany 函数不支持 Sqlite 数据库,所以这里退化为使用 create 创建 n 次,不影响最终效果。

此外,在插入之前对数据做了一点顺序调整,保证数据是按照时间顺序插入,这样可以保证查询文章时候的顺序和发布时间的一致(目的是为了方便查询相邻文章,上一篇、下一篇),为了避免插入失败还需要剔除不必要的数据,这里使用了 omitBy 函数删除多余的属性:

backend/utils.mjs
function omitBy(obj, ...props) {
    const result = { ...obj };
    props.forEach(function (prop) {
      delete result[prop];
    });
    return result;
}

最后将整个流程封装为 initPostsToDB 函数:

init.mjs
async function initPostsToDB() {
    const posts = await getMDXList()
    await savePosts(posts)
}
export { initPostsToDB }

以后只需要执行该函数,就可以将 Markdown 的所有信息保存到数据库中。但是何时执行呢?理论上只需要在 Nextjs 运行前执行一次即可,可以加入一个预编译命令:

pageage.json
"scripts": {  "dev": "next dev",  "prebuild": "node ./src/backend/script.mjs",  "build": "next build",  "start": "next start",  "lint": "next lint"},

这个是 npm 的 一种机制prebuild 会自动在 build 命令执行前运行,趁着这个机会,可以执行上面提到的 initPostsToDB 函数,将 Markdown 的内容拷贝到数据库中,这样当 Nextjs 运行起来后,只需要操作数据库即可。

大家应该注意到,执行的脚本都是以 mjs 为后缀的文件,这是 nodejs 的规定。源码内使用了 import 导入方式,所以必须将后缀名改为 mjs,否则 node 在执行期间会报下面的警告:

(node:10636) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)

下面是 script.mjs 脚本的内容:

script.mjs
(async() => {
    const {initPostsToDB} = await import('./init.mjs')
    await initPostsToDB()
})()

到此就完成了数据库初始化的基本逻辑,整个数据库插入源码的目录如下:

backend  
├─ prisma.mjs  
├─ db.mjs      
├─ init.mjs    
├─ script.mjs  
└─ utils.mjs   

db.mjs 内部是数据库相关的操作逻辑,init.mjs 是 Markdown 文件解析逻辑,script.mjs 是预编译脚本执行的命令,utils.mjs 是函数执行过程中用到的工具函数。

4. 显示 Markdown 内容

当 Markdown 内容存储到数据库之后,显示查询数据就会变得很容易。本小结主要分为两个部分,第一部分讲解如何在首页显示最新的文章列表,第二部分讲解如何显示博客的文章内容。

4.1 最新文章

网站首页实现的文章中我们实现了 RecentPosts 组件,但是并没有完成所有代码,因为该组件涉及到 Markdown 文章列表的查询,现在有了数据库之后,就可以很容易实现这部分功能。

该组件的文章列表被设计成无限滚动模式,所以存在几个难点,第一个是如何实现无限滚动,第二个是如何拿到数据库中的文章列表数据。

4.1.1 无限滚动

对于无限滚动来说,已经有很多现成的第三方组件来完成该功能,这里使用的是一个叫做 react-infinite-scroll-component 的库,该库可以根据鼠标的动作自动触发指定的函数逻辑:

RecentPosts.js
<InfiniteScroll
    dataLength={recentPosts.posts.length}
    next={fetchMoreData}
    hasMore={recentPosts.hasMore}
    loader={
        <div className="animate-pulse text-center text-xl py-8">加载中……</div>
    }
    scrollThreshold="150px">
</InfiniteScroll>

其中 dataLength 属性表示数据的长度,next 参数是一个函数,表示到达底部后触发的动作,hasMore 一个布尔值,表示到达底部的时候是否需要触发 next 函数,loader 表示加载过程中显示的提示信息,scrollThreshold 表示滚动阈值,即当往下滚动多少时才会触发 next 函数。

4.1.2 获取数据

获取数据这里就比较麻烦了,首页显示目前被分为两个部分,第一部分是预加载的首屏页面,和静态页面类似,首屏页面被设计为显示当前博客的最新五篇文章,该页面是在 Nextjs 编译阶段生成的。第二部分是动态数据,当首屏页面出现之后,用户操作鼠标往下滚动的时候触发,前端代码会使用 fetch 函数请求后端剩下的文章数据,Nextjs 为了实现这种后台的 API 接口专门提供了一种方式,默认情况可以直接在 pages/api 文件夹下创建你的专属接口。

虽然功能简单,但是这里面几乎涉及到了 Nextjs 页面渲染的所有概念,这里顺便介绍一下:

  • SSG(Static Site Generation),静态生成。静态生成本质上就是预先将每个请求渲染为静态文件,每次页面请求都会重用该页面,这种方式也是 Nextjs 推荐的。好处非常明显,显示网页的过程相当于直接获取文件,并且很容易将网页部署到 CDN 上,访问速度会非常快,但遗憾的是无法处理动态数据,它只是单纯的静态页面而已。RecentPosts 组件就是使用该功能完成首屏的展示。

  • SSR(SSR,Server-Side Rendering),服务器端渲染。这种方式其实就是传统的动态页面,每个请求都会先读取服务器数据,再根据模板动态生成页面内容返回。因为每次请求都会重新生成 HTML 页面,所以速度上会更慢,但是好处是可以处理动态数据。常用来显示具有权限的实时页面,例如付费阅读功能,你每次请求服务器都可以查询当前用户是否具有浏览该页面的权限。

  • BSR(Broswer Side Render),浏览器端渲染。这种方式是前几年流行的单页面应用的数据处理方式,它和 SSR 比较类似,适合处理实时性数据。但是相比于 SSR 来说,这种方式对于 SEO 来说不是特别友好,三种方式中对 SEO 最友好的其实是 SSG,其次是 SSR。我个人认为 BSR 和 SSR 都适合显示经常变动的数据,但是 BSR 更适合那种局部更新的数据,特别是后端应用非常推荐。

如果你做过 web 开发,就知道上面这几种方式都不是 Nextjs 独有的,更不是它发明的,本质上都是老掉牙的东西了。

SSG 应该属于互联网始祖,网页刚出现的时候,就是使用这种方式加载网页的,没想到这么多年过去了,反而又有点流行的趋势,当然细节上肯定还是有区别的,但是本质上是一样的,就是加载静态文件而已。

SSR,这个是 SSG 的下一代,与之类似的有 PHP、ASP、JSP,这些玩应也是过去玩烂的东西,本质上就是动态数据和模板的组合,那个时期其实为了 SEO 也流行将网页转为 SSG,学名静态化。

BSR 这个算是萌新的,貌似也十来年的历史了,属于 web2.0 的产物,从 Ajax 出现就已经存在了,后期有一段时间单页面应用挺火,主要用的就是 BSR 来传递数据,直到现在 fetch 都出来好多年了。

RecentPosts 组件用到了 SSG 和 BSR 两种数据加载的方法,用 SSG 是为了首屏加载速度以及 SEO,BSR 主要是为了有更好的交互体验,反正我比较喜欢。

感觉时代在发展,很多技术却是在新瓶装旧酒,同一种技术结合不同的场景往往可能带来意想不到的后果。你要说没有创新吧,那肯定是侮辱人,任何技术结合现实场景都会有很多细节需要处理和改进,但你要说它有创新吧,核心的东西还是那几样,确实很纠结!😂

4.1.2.1 静态页面生成

先说 SSG,静态生成的方法主要是使用 Nextjs 提供的 getStaticProps 函数,该函数可以通过返回值的形式将数据传递给页面组件,它只会在服务端运行,主要提供静态页面生成所需要的数据信息。

下面是我博客的 getStaticProps 函数代码:

index.js
export async function getStaticProps() {
  const posts = await getRecentPosts()
  return {
    props: {
      posts
    }
  }
}

该函数主要用来查询数据,并将数据库的结果返回。下面是后端 getRecentPosts 函数的代码:

db.mjs
async function getRecentPosts(start = 0) {
    const db = getDB()
    if (!db) {
        console.error('获取数据库实例失败!')
        return []
    }

    const metas = await db.posts.findMany({
        where: {
            category: {in: ['博客', '编译', '研究']}
        },

        orderBy: {
            id: 'desc'
        },

        skip: parseInt(start, 10),

        take: 5,

        select: {
            title: true, 
            description: true, 
            slug: true, 
            category: true, 
            categorySlug: true, 
            author: true, 
            tags: true, 
            featureImage: true, 
            featureImageWidth: true, 
            featureImageHeight: true, 
            createdAt: true, 
            updatedAt: true,
        }
    })

    return metas.map(v => {
        return {
            ...v,
            title: getTitle(v.title)
        }
    })
}

该函数返回最新的五篇文章的基本信息,包括标题、描述、URL、分类、作者、图片等内容。其中有两个我个人的设置,一个是我只返回三个目录(博客、编译、研究)下的文章,这主要是因为我博客的类目决定的,第二个是我将标题重新修改了一下,这个也是因为我对标题格式决定的:

utils.mjs
//返回标题 0 返回标题 1 返回章节
function getTitle(v, type = 0) {
  let title = v
  const items = (v || '').split('|')

  if (items.length > type) {
      title = items[type].trim()
  }

  return title
}

标题的规则形式如下:标题 | 章节 。所以数据库直接查询的结果并不是真正的标题信息,而是可能涵盖额外的章节,这个功能组要是为了教程左侧的章节树准备的,这个功能是可选的,看个人喜好,你完全可以增加一个额外的章节字段来记录该功能。

设置完毕后,Nextjs 在编译期间会自动调用 getStaticProps 函数,并将函数的结果投递到页面组件中。你可以从组件的参数中获取这些数据:

pages/index.js
  export default function Home({posts}) {  return (    <>      <Header />      <Hero />      <TutorialList />       <RecentPosts posts={posts}/>      <Footer />    </>  )}

上面的代码是将文章列表传递给 RecentPosts 组件。接着修改组件代码:

RecentPosts.js
import React, { useState } from 'react'
import Link from 'next/link'

export function RecentPosts({ posts }) {
    const [recentPosts, setRecentPosts] = useState({
        posts: posts,
        hasMore: true
    })

    return (
        <>
            <div className="text-center pt-24 pb-16 text-gray-600">
                <h2 className="text-5xl md:text-6xl">最新文章</h2>
            </div>

            <section className="flex flex-wrap text-gray-600 max-w-screen-lg mx-auto mb-10">
                {
                    recentPosts.posts.map(v => (
                        <div key={v.slug} className="bg-gray-100 w-full mb-10 mx-4 block md:overflow-hidden md:flex">
                            <div className="md:w-1/2 transform transition-transform ease-in-out duration-500 hover:scale-110">
                                <Link href={`/${encodeURIComponent(v.slug)}/`}>
                                    <img src={v.featureImage} alt={v.title} />
                                </Link>
                            </div>
                            <div className="bg-white p-6 z-[1] shadow-md md:w-1/2 md:p-8 md:my-6 md:-ml-8">
                                <span className="text-primary text-sm bg-gray-200 rounded-sm px-2 py-1 mb-4 inline-block lg:text-base">
                                    <Link href={`/category/${encodeURIComponent(v.categorySlug)}/`} passHref>
                                        <a title={v.category}>{v.category}</a>
                                    </Link>
                                </span>
                                <h3 className="text-lg mb-2 lg:text-2xl">
                                    <Link href={`/${encodeURIComponent(v.slug)}/`} passHref>
                                        <a title={v.title}>{v.title}</a>
                                    </Link>
                                </h3>
                                <span className="w-1/4 mb-4 border-b-2 border-primary inline-block"></span>
                                <div className="text-sm mb-4 lg:text-base">
                                    {v.description}
                                </div>
                                <div className="text-xs text-gray-400 flex items-center">
                                    <svg className="w-3 h-3 mr-1" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="calendar-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V192H0v272zm320-196c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zM192 268c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zM64 268c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12v-40zM400 64h-48V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H160V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H48C21.5 64 0 85.5 0 112v48h448v-48c0-26.5-21.5-48-48-48z"></path></svg>
                                    <span className="">{v.createdAt}</span>
                                </div>
                            </div>
                        </div>
                    ))
                }
            </section>

        </>
    )
}

如果一切顺利,就可以在首先看到最新的五篇文章了:

最新文章
4.1.2.2 滚动显示最新文章

添加滚动加载剩余文章功能,首先需要创建 pages/api/recentposts.js 文件,Nextjs 路由默认是基于文件路径的,后台接口同样如此,上面的文件会自动生成下面的 URL 路径接口: api/recentposts ,我们只需要在文件中实现默认的导出函数,获取客户端发来的请求信息,返回对应的文章列表数据即可。

api/recentposts.js
import { getRecentPosts } from "../../backend/db.mjs"
import Joi from "joi"

export default function RecentPosts(req, res) {

    if(req.method !== 'POST') {
        return res.status(200).json({
            success: true,
            posts: []
        })
    } else {
        const submitSchema = Joi.object({
            count: Joi.number().integer().positive(),
        })

        const {err, value} = submitSchema.validate(req.body)
        if(err) {
            return res.status(200).json({
                success: false,
                message: err.message || '',
            })
        }

        return getRecentPosts(value.count)
        .then(function(posts) {
            res.status(200).json({
                success: true,
                posts: posts
            })
        })
        .catch(function(e) {
            console.error(e)
            res.status(200).json({
                success: false,
                posts: []
            })
        })
    }
}

其中为了防止别人用浏览器访问数据,所以直接使用了 POST 方法获取数据,非 POST 的请求全部拒绝,返回空数组。 Joi 是一个第三方库,主要用于校验用户发来的请求参数是否正确,你可以使用下面的命令安装:

npm install joi

剩下的逻辑几乎和上一节从数据库获取文章数据的逻辑一样,唯一的差别在于传入 getRecentPosts 函数的参数不同,该参数表示查询文章的起始索引位置。

后端接口实现完毕后,在前端可以通过 fetch 获取最新的文章数据:

RecentPosts.js
const fetchMoreData = () => {
    fetch('/api/recentposts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            count: recentPosts.posts.length || 0,
        })
    }).then(response => response.json())
        .then(function (resp) {
            if ((resp && resp.success) && (resp.posts && resp.posts.length > 0)) {
                setRecentPosts({
                    ...recentPosts,
                    posts: recentPosts.posts.concat(resp.posts),
                })
            } else {
                setRecentPosts({
                    ...recentPosts,
                    hasMore: false,
                })
            }
        })
}

再次回到 RecentPosts 组件的代码,新建 fetchMoreData 函数,该函数会在 InfiniteScroll 组件向下滚动的时候触发,函数内部加载远程数据,更新文章列表,React 会自动更新完成最后的显示效果。

下面是组件的完整显示代码:

组件完整代码
RecentPosts.js
import Link from 'next/link'
import InfiniteScroll from 'react-infinite-scroll-component'
import React, { useState } from 'react'

export function RecentPosts({ posts }) {

    const [recentPosts, setRecentPosts] = useState({
        posts: posts,
        hasMore: true
    })

    const fetchMoreData = () => {
        
        fetch('/api/recentposts', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                lastSlug: recentPosts.posts[recentPosts.posts.length - 1].slug || '',
                count: recentPosts.posts.length || 0,
            })
        }).then(response => response.json())
            .then(function (resp) {
                if ((resp && resp.success) && (resp.posts && resp.posts.length > 0)) {
                    setRecentPosts({
                        ...recentPosts,
                        posts: recentPosts.posts.concat(resp.posts),
                    })
                } else {
                    setRecentPosts({
                        ...recentPosts,
                        hasMore: false,
                    })
                }
            })
    }

    return (

        <InfiniteScroll
            dataLength={recentPosts.posts.length}
            next={fetchMoreData}
            hasMore={recentPosts.hasMore}
            loader={
                <div className="animate-pulse text-center text-xl py-8">加载中……</div>
            }
            scrollThreshold="150px"
        >
            <div className="text-center pt-24 pb-16 text-gray-600">
                <h2 className="text-5xl md:text-6xl">最新文章</h2>
            </div>

            <section className="flex flex-wrap text-gray-600 max-w-screen-lg mx-auto mb-10">
                {
                    recentPosts.posts.map(v => (
                        <div key={v.slug} className="bg-gray-100 w-full mb-10 mx-4 block md:overflow-hidden md:flex">
                            <div className="md:w-1/2 transform transition-transform ease-in-out duration-500 hover:scale-110">
                                <Link href={`/${encodeURIComponent(v.slug)}/`}>
                                    <img src={v.featureImage} title={v.title}  />
                                </Link>
                            </div>
                            <div className="bg-white p-6 z-[1] shadow-md md:w-1/2 md:p-8 md:my-6 md:-ml-8">
                                <span className="text-primary text-sm bg-gray-200 rounded-sm px-2 py-1 mb-4 inline-block lg:text-base">
                                    <Link href={`/category/${encodeURIComponent(v.categorySlug)}/`} passHref>
                                        <a title={v.category}>{v.category}</a>
                                    </Link>
                                </span>
                                <h3 className="text-lg mb-2 lg:text-2xl">
                                    <Link href={`/${encodeURIComponent(v.slug)}/`} passHref>
                                        <a title={v.title}>{v.title}</a>
                                    </Link>
                                </h3>
                                <span className="w-1/4 mb-4 border-b-2 border-primary inline-block"></span>
                                <div className="text-sm mb-4 lg:text-base">
                                    {v.description}
                                </div>
                                <div className="text-xs text-gray-400 flex items-center">
                                    <svg className="w-3 h-3 mr-1" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="calendar-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V192H0v272zm320-196c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zM192 268c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zM64 268c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12v-40zM400 64h-48V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H160V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H48C21.5 64 0 85.5 0 112v48h448v-48c0-26.5-21.5-48-48-48z"></path></svg>
                                    <span className="">{v.createdAt}</span>
                                </div>
                            </div>
                        </div>
                    ))
                }
            </section>

        </InfiniteScroll>

    )
}

至此最新文章模块算是大致搞定,首页也算是初步完成。其实还有一些其它可选内容,例如图片的惰性加载等等,不过那些都不是必须的,主要是分享思路。

4.2 文章显示

本文还差最后一块内容,如何显示文章内容。其核心主要是实现文章 URL 的注册,以及 Markdown 文本的渲染。

按照 Nextjs 默认的静态路由系统,每个网页都应该在 pages 目录下创建一个文件,如果你的网页不多,你甚至可以直接将 Markdown 文件放到该目录,然后使用 @next/mdx 插件进行渲染,但是咱们的文档都是中文的,所以不是很方便,为了解决这个问题我们在 Markdown 文件的首部添加了 slug 字段,用来保存文件的 URL 路径,现在的问题是如何让这些路径生效。

现在如果你访问首页最新文章中的链接,会发现返回 404 页面,这是因为咱们还没有编写处理这些页面的代码,解决这个问题的关键在于两个步骤,一个是使用 Nextjs 的动态路由,将所有页面逻辑统一到单独的页面进行处理,二是在该页面导出 getStaticPaths 函数,其实在前面路由系统的章节里面已经提到过该函数的使用方式,通过该函数你可以告诉 Nextjs 你需要预渲染的页面。

在实现该功能之前,首先需要查看当前文章的链接地址,目前我的博客默认的文章地址如下:

http://localhost:3000/zlib-jing-tai-bian-yi-jiao-cheng/
http://localhost:3000/yaffswen-jian-xi-tong-yi-zhi/

这两个地址是我直接从主页上复制下来的,应该可以很明显看出链接是扁平化的,只有单独的一个字段,表示文章的路径,如果想要捕获这些地址,就需要使用类似下面的动态路由:

[:slug].js

这样后台获取 slug 变量的值就是 zlib-jing-tai-bian-yi-jiao-cheng 或者 yaffswen-jian-xi-tong-yi-zhi 之类的路径信息。当然有些网站的路径是由多个字段组成的,例如:

http://localhost:3000/posts/zlib-jing-tai-bian-yi-jiao-cheng/
http://localhost:3000/posts/zlib-jing-tai-bian-yi-jiao-cheng/

捕获这些文章,你可以会使用下面的动态路由:

posts/[:slug].js

其实任何方案都可以,具体还是看你想在代码中如何处理,是否需要单独的页面,或者干脆一个路由捕获全部页面,前面我已经提到过,目前我的博客就是使用的这种方式:

[[...slug]].js

这种方式捕获的页面已经包含了主页,现在咱们就修改目前 pages 目录下的 index.js 文件,将其改名为 [[...slug]].js

这时候运行 Nextjs,会显示如下的错误信息:

error - Error: getStaticPaths is required for dynamic SSG pages and is missing for '/[[...slug]]'.
Read more: https://nextjs.org/docs/messages/invalid-getstaticpaths-value

提示信息很清楚,如果是动态路由,你必须导出 getStaticPaths 函数,只有这样 Nextjs 才能知道你的网站需要预渲染哪些页面。

这里先添加一个默认函数,让主页显示恢复正常:

[[...slug]].js
export async function getStaticPaths() {
  return {
    paths: [
      { params: { slug: ['']} },
    ],
    fallback: false,
  }
}

接下来要想其它页面生效,需要将要显示的文章路径添加到 paths 数组中。咱们可以很容易实现这个逻辑,只需要去数据库中查询所有文章的记录即可。

db.mjs
async function getPostPaths() {
    const db = getDB()
    if (!db) {
        console.error('获取数据库实例失败!')
        return []
    }

    const paths = await db.posts.findMany({
        select: {
            slug: true
        }
    })

    return paths.map(v => v.slug || '')
}

查询数据的操作很简单,获取所有文章记录的 slug 字段,然后直接将获取的结果塞入 paths 数组中:

[[...slug]].js
export async function getStaticPaths() {
  const slugs = await getPostPaths()
  const paths = [{ params: { slug: [] } }].concat(slugs.map(v => {
    return {
      params: { slug: Array.isArray(v) ? v : [v] }
    }
  }))

  return {
    paths,
    fallback: false,
  }
}

现在再去点击主页中最新文章模块中的文章链接,就会发现不会再显示 404 页面了。不过这样也不对,因为每个文章链接显示的都是博客主页,之所以这样是因为现在咱们的所有文章链接都会执行 [[...slug]].js 中的逻辑,而目前该文件默认渲染的就是主页信息,所以你会发现每篇文章显示的都是一模一样的主页,解决这个问题不难,只需要区分不同的页面即可。

还记得咱们前面说过的路由变量吗?对于目前的 [[...slug]].js 来说,这个变量的值就是 slug,你可以使用 console.log 在 getStaticProps 中打印看看:

[[...slug]].js
export async function getStaticProps({ params }) {
  console.log(params)
}

你会得到类似下面的结果:

{ slug: [ 'yaffswen-jian-xi-tong-yi-zhi' ] }

上面访问的是 http://localhost:3000/yaffswen-jian-xi-tong-yi-zhi/ 路径。如果你访问主页,会得到一个空对象:

{}

有一点需要注意,这里只能打印已经注册后的路由。潜在的执行逻辑是这样的,Nextjs 首先通过 getStaticPaths 获取你需要预渲染的所有页面路径,然后将路径信息传递给 getStaticProps 函数获取每个页面所需要的数据,在接着把这些数据传递给页面组件,页面组件将数据和渲染模板结合,最后生成最终 HTML 页面。

知道原理之后,解决这个问题非常简单,只需要根据不同的路径渲染不同的模板就好。为了方便,我们接下来准备将所有的模板放到一起,目前存在两个模板,一个是主页模板,一个是文章显示模板,在 src 目录下创建 templates 目录,当前布局如下:

templates       
├─ layouts      
│  ├─ home.js   
│  ├─ index.js  
│  └─ post.js   
└─ props        
   ├─ home.js   
   ├─ index.js  
   └─ post.js   

其中 layouts 用于存放模板组件,相当于当前 [[...slug]].js 文件中的 Home 组件,而 props 用于存放模板数据获取函数,相当于 getStaticProps 函数的内部逻辑。

你可以觉得疑惑,为什么将 layouts 和 props 分开,不能放到同一文件中么?这是因为 props 内的函数是在后台执行的,它内部可以调用 nodejs 相关的函数,例如文件处理等等,而 layouts 是有前端调用的,该函数内部不可以调用后端的函数,会直接报错。

定义了这些之后,就可以改造现有 [[...slug]].js 文件源码,删除 Home 组件,换一个更加泛化的名字:

[[...slug]].js
import { getLayout } from "../templates/layouts/index.js"
export default function Page({type, payload}) {
  const Layout = getLayout(type)
  return (
    <Layout {...payload}/>
  )
}

其中 getLayout 函数可以根据当前页面的类型动态获取布局组件,然后直接渲染即可。而 type 和 payload 两个参数则由 getStaticProps 函数获取:

[[...slug]].js
import { getProps } from "../templates/props/index.js"
export async function getStaticProps({params}) {

  let type = '404', slug = ''
  if(!params.slug) {
    type = 'home'
    slug = ''
  } else if(Array.isArray(params.slug)) {
    type = 'post'
    if(params.slug.length > 0) {
      slug = params.slug[0]
    }
  }

  //寻找对应的模板,获取模板属性
  const {layout = type, payload = {}} = await getProps(type, {slug})
  return {
    props: {
      type,
      payload,
    }
  }
}

type 的获取就像前面提到的逻辑,根据当前的 URL 变量来判断是哪种页面类型,而 payload 则是当前页面渲染所需要的数据,该数据通过 getProps 函数获取。

接下来就是两个核心函数的实现,首先是 getLayout:

layouts/index.js
import { HomeLayout } from "./home"
import { PostLayout } from "./post"

function Custom404() {
    return <h1>404 - Page Not Found</h1>
}

const Layouts = {
    home: HomeLayout,
    post: PostLayout
}

function getLayout(type) {
    const layout = Layouts[type]
    if(layout) {
        return layout
    }

    return Custom404
}

export { getLayout }

代码中只是定义一个对象,内部记录的不同类型页面需要渲染的页面组件,然后根据 type 返回对应的组件即可。为了防止出现意料之外的输入,还额外定义了一个 404 组件,等以后我们专门定义了 404 页面,不要忘记再将这里重新更新一下,替换掉临时的 404 页面。

接着是 getProps 函数:

props/index.js
import { homeProps } from "./home"
import { postProps } from "./post"

const Funs = {
    home: homeProps,
    post: postProps,
}

async function getProps(type, ...args) {
    let fun = Funs[type]
    if (!Funs[type]) {
        return () => ({})
    }

    const props = await fun(...args)
    return {
        layout: type,
        ...props
    }
}

export { getProps }

getProps 函数的逻辑和 getLayout 基本类似,只不过这里不是组件而是获取后台数据的函数而已,如果没找到对应的函数,则返回空对象。该函数的返回值特意设置为 {layout: type, ...props},一般情况下返回的 layout 属性都是传入的 type 参数,但是存在这样一种可能,就是 props 函数内部同样含有该属性,这会导致默认的 layout 被覆盖。

之所以这样处理,是因为在读取文章数据后,可能会特意去改变当前需要渲染的布局。特别是对我的博客来说,同样是显示文章,但是可以分为不同的类型,有普通的博客类型、也有含有目录的长文章,还可能是含有章节的教程。当我从数据库读取文章,发现文章的类型不是普通博客的时候,我会返回 layout 字段覆盖掉原来的默认布局,用这样的方法实现动态布局的渲染。当然这只是我实现动态布局的一种方式,仅供大家参考。

最后就是实现各个布局自己的渲染逻辑和数据获取逻辑,对于主页来说,我们只是将原来 [[...slug]].js 页面中的逻辑移动到 home.js 文件中:

import { Header } from "../../components/Header"
import { Footer } from "../../components/Footer"
import { Hero } from "../../components/Hero"
import { TutorialList } from "../../components/TutorialList"
import { RecentPosts } from "../../components/RecentPosts"

export function HomeLayout({ posts }) {

    return (
        <>
            <Header />
            <Hero  />
            <TutorialList />
            <RecentPosts 
                posts={posts}
            />
            <Footer />
        </>
    )
}

而对于文章页面来说,首先需要获取 URL 的路径信息,然后通过路径查询数据库获取对应的文章内容:

backend/db.mjs
//通过 slug 获取文章信息
async function getPostBySlug(slug) {
    const db = getDB()
    if (!db) {
        console.error('获取数据库实例失败!')
        return null
    }

    const post = await db.posts.findFirst({
        where: {
            slug: {
                equals: slug
            }
        },

        select: {
            title: true, 
            description: true, 
            content: true,
            slug: true, 
            category: true, 
            categorySlug: true, 
            words: true,
            author: true,
            readMins: true, 
            tags: true, 
            featureImage: true, 
            featureImageWidth: true, 
            featureImageHeight: true, 
            featureVideo: true,
            demoLink: true,
            sourceLink: true,
            createdAt: true, 
            updatedAt: true,
        }
    })

    return post ? {
        ...post,
        title: getTitle(post.title)
    } : null
}

接着就是使用该函数获取文章渲染数据的逻辑:

import { Header } from "../../components/Header"
import { Footer } from "../../components/Footer"
import { getMDXComponent } from 'mdx-bundler/client'
import { parseISO, format } from 'date-fns'
import React from "react"

export function PostLayout({ meta, content }) {
    const MDXContent = React.useMemo(() => getMDXComponent(content), [content])

    return (
        <>
            <Header />

            <article className="pb-4 pt-28 max-w-2xl mx-auto px-4 md:pt-40 text-gray-600">
                <h1 className="text-center font-extrabold text-4xl">{meta.title}</h1>
                <div className="mt-4 mb-8">
                    <ul className="text-xs text-gray-500 flex justify-center md:text-sm">
                        <li className="flex items-center mr-3">
                            <svg className="w-4 h-4 mr-1" aria-hidden="true" focusable="false" data-prefix="far" data-icon="user" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M313.6 304c-28.7 0-42.5 16-89.6 16-47.1 0-60.8-16-89.6-16C60.2 304 0 364.2 0 438.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-25.6c0-74.2-60.2-134.4-134.4-134.4zM400 464H48v-25.6c0-47.6 38.8-86.4 86.4-86.4 14.6 0 38.3 16 89.6 16 51.7 0 74.9-16 89.6-16 47.6 0 86.4 38.8 86.4 86.4V464zM224 288c79.5 0 144-64.5 144-144S303.5 0 224 0 80 64.5 80 144s64.5 144 144 144zm0-240c52.9 0 96 43.1 96 96s-43.1 96-96 96-96-43.1-96-96 43.1-96 96-96z"></path></svg>
                            <span>{meta.author}</span>
                        </li>
                        <li className="flex items-center mr-3">
                            <svg className="w-4 h-4 mr-1" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="calendar-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V192H0v272zm320-196c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zM192 268c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-40c-6.6 0-12-5.4-12-12v-40zM64 268c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12v-40zm0 128c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12v-40zM400 64h-48V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H160V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H48C21.5 64 0 85.5 0 112v48h448v-48c0-26.5-21.5-48-48-48z"></path></svg>
                            <time pubdate={meta.createdAt}>{format(parseISO(meta.createdAt), 'yyyy-MM-dd')}</time>
                        </li>
                        <li className="flex items-center mr-3">
                            <svg className="w-4 h-4 mr-1" aria-hidden="true" focusable="false" data-prefix="far" data-icon="eye" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z"></path></svg>
                            <span className="hidden md:block">{meta.words} 字,</span>
                            <span>阅读约 {Math.round(meta.readMins)} 分钟</span>
                        </li>
                    </ul>
                </div>

                <div className="prose mx-auto mt-8">
                    <MDXContent />
                </div>
            </article>

            <Footer />
        </>
    )
}

对于文章布局组件,整体上和主页的结构类似,Header 和 Footer 直接复用,只不过中间部分换成 MDXContent 组件内容,该组件用于文章的渲染,其中对于文章的元数据部分,还额外使用了一个叫做 date-fns 的第三方库,用于时间的格式化。

如果一切顺利就可以直接看到最后的文章显示效果:

文章显示效果

不过如果你按照教程的步骤走下来,这里大概率会出现文章内容显示正常,但是布局样式部分缺失的情况。这是因为现有的目录结构发生变化导致的,打开 tailwind.config.js 文件,修改 content 属性如下:

tailwind.config.js
module.exports = {
  content: [
    "./src/components/**/*.{js,ts,jsx,tsx}",
    "./src/pages/**/*.{js,ts,jsx,tsx}",
    "./src/templates/**/*.{js,ts,jsx,tsx}",
  ],

  //...
}

5. 总结

到此为止,咱们算是把 Markdown 文章显示相关的问题初步讲完了。其实真正显示 Markdown 文章的部分很少,只需要直接调用 mdx-bundler/client 中的组件即可,真正花费时间的是如何保存提取数据,如何组织文件结构,如何布局以及如何抽象组件等等,内容稍微有啰嗦,希望大家看过之后能有所收获。