动手练一练,使用 React 和 Next.js 做一个简单的博客网站(中)

2021-04-22 10:44:50 浏览数 (1)

“作者:Craig Bucklere 原文:Build a Blog with React and Next.js(sitepoint) 字数:4272 字 (非直译,有添加部分) 阅读: 10 分钟

大家好,在《动手练一练,使用 React 和 Next.js 做一个简单的博客网站(上)》一篇文章里,我们一起了解了什么是 Next.js,并手工创建了一个简单的 Next.js 项目,学会了如何基于模板创建简单的页面,本篇文章,我们继续完善这个案例。

一、基于MD文档生成动态路由

创建博客,自然少不了文章内容,如果我们每写一篇文章,就创建一个 JSX 单页面,这样太不现实,费事费力又不容易维护,我们开发人员更喜欢使用 Markdown 文档写文档。

庆幸的是,Next.js 允许我们使用 Markdown 作为文章的数据源,基于文件名生成动态路由,并且实现文件内容的 HTML 静态化。

1、在编写本功能时,最好停止 Next.js 服务(Ctrl | Cmd C)。

2、接下来,在项目的根目录里创建 articles 文件夹,把你的 Markdown 文件放置在这里,例如:articles/article-01.md,MD 文档格式如下所示:

代码语言:javascript复制
---
title: The first article
description: This is the first article.
date: 2020-10-01
---
This is an article post.
## Subheading
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

我们将文档的标题名称、文档描述、创建日期放置在 — 之间,Front-matter 这个 npm 插件基于这个格式可以读取上述相关信息提取文档的标题、描述、创建日期。要将 MD 文档格式化成网页的形式,我们还需要安装 remark 和 remark-html 这两个npm 插件,安装命令如下:

代码语言:javascript复制
npm i front-matter remark remark-html

3、安装完成后,我们要实现读取和格式化 MD 文档的功能,接下来创建 lib/posts-md.js 工具函数文件。getFileIds(dir) 函数返回一个 MD 文件名的数组(不包含 .md 扩展名的文件名),示例代码如下:

代码语言:javascript复制
import { promises as fsp } from 'fs';
import path from 'path';
import fm from 'front-matter';
import remark from 'remark';
import remarkhtml from 'remark-html';
import * as dateformat from './dateformat';
const fileExt = 'md';
// return absolute path to folder
function absPath(dir) {
  return (
    path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir)
  );
}
// return array of files by type in a directory and remove extensions
export async function getFileIds(dir = './') {
  const loc = absPath(dir);
  const files = await fsp.readdir(loc);
  return files
    .filter((fn) => path.extname(fn) === `.${fileExt}`)
    .map((fn) => path.basename(fn, path.extname(fn)));
}

获取到文件名数组后,我们需要解析 MD 的具体内容,比如文件的标题、描述、创建日期、具体的内容HTML格式化等,示例代码如下:

代码语言:javascript复制
export async function getFileData(dir = './', id) {
  const
    file = path.join(absPath(dir), `${id}.${fileExt}`),
    stat = await fsp.stat(file),
    data = await fsp.readFile(file, 'utf8'),
    matter = fm(data),
    html = (await remark().use(remarkhtml).process(matter.body)).toString();
  // date formatting
  const date = matter.attributes.date || stat.ctime;
  matter.attributes.date = date.toUTCString();
  matter.attributes.dateYMD = dateformat.ymd(date);
  matter.attributes.dateFriendly = dateformat.friendly(date);
  // word count
  const
    roundTo     = 10,
    readPerMin  = 200,
    numFormat   = new Intl.NumberFormat('en'),
    count       = matter.body.replace(/W/g, ' ').replace(/s /g, ' ').split(' ').length,
    words       = Math.ceil(count / roundTo) * roundTo,
    mins        = Math.ceil(count / readPerMin);
  matter.attributes.wordcount = `${ numFormat.format(words) } words, ${ numFormat.format(mins) }-minute read`;
  return {
    id,
    html,
    ...matter.attributes
  };
}

你可能注意到我使用了日期格式化功能,其功能定义在 lib/dateformat.js 文件,示例代码如下:

代码语言:javascript复制
// date formatting functions
const toMonth = new Intl.DateTimeFormat('en', { month: 'long' });
// format a date to YYYY-MM-DD
export function ymd(date) {
  return date instanceof Date
    ? `${date.getUTCFullYear()}-${String(date.getUTCMonth()   1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}` : '';
}
// format a date to DD MMMM, YYYY
export function friendly(date) {
  return date instanceof Date
    ? `${date.getUTCDate()} ${toMonth.format(date)}, ${date.getUTCFullYear()}` : '';
}

4、Next.js 使用带 [ ] 符号的特殊的文件名生成动态路由。接下来我们在 Pages 目录下创建这个特殊的文件 pages/articles/[id].js, Next.js 使用id作为路由的参数,生成 /articles/article-01 的页面路由。

pages/articles/[id].js 这个文件里实现Next.js 特有的 getStaticPaths() 函数功能(Static Generation),在项目构建时生成指定的路由路径,比如这个案例将 articles 目录下的 MD 文档返回如下的数组格式,id 将匹配 pages/articles/[id].js 对应的 [id] 参数生成动态路由:

代码语言:javascript复制
[
  { params: { id: "article-01" } },
  { params: { id: "article-02" } },
  { params: { id: "article-03" } },
  ...
]

这个方法调用 lib/posts-md.js 文件里读取 getFileIds 文件路径列表的方法,示例代码如下:

代码语言:javascript复制
import { getFileIds, getFileData } from '../../lib/posts-md';
// post directory
const postsDir = 'articles';
// dynamic route IDs
export async function getStaticPaths() {
  const
    paths = (await getFileIds(postsDir))
      .map((id) => ({ params: { id } }));
  return {
    paths,
    fallback: false,
  };
}

5、动态路由生成后,我们需要实现 MD 内容格式化渲染,我们实现Next.js 特有的异步方法 getStaticProps({ params }),在项目构建时调用这个函数(Static Generation),通过 id 参数调用 lib/posts-md.js 文件中 getFileData() 定义的方法,将 MD 文档内容异步回传至包含 postData 属性的组件内部(第六点的代码部分),示例代码如下:

代码语言:javascript复制
// dynamic route content
export async function getStaticProps({ params }) {
  return {
    props: {
      postData: await getFileData(postsDir, params.id),
    },
  };
}

6、拿到数据后,我们需要填充到组件的模板里,以更友好的形式展现,我们在 pages/articles/[id].js 里编写JSX的相关代码,将文章内容嵌套在上节组件模板内,示例代码如下:

代码语言:javascript复制
import Layout from '../../components/layout';
import Head from 'next/head';
...
export default function Article({ postData }) {
  // generate HTML from markdown content
  const html = `
    <h1>${ postData.title }</h1>
    <p class="time"><time datetime="${ postData.dateYMD }">${ postData.dateFriendly }</time></p>
    <p class="words">${ postData.wordcount }</p>
    ${ postData.html }
  `;
  return (
    <Layout hero="phone.jpg">
      <Head>
        <title>{ postData.title }</title>
        <meta name="description" content={ postData.description } />
      </Head>
      <article dangerouslySetInnerHTML={{ __html: html }} />
    </Layout>
  );
}

最后我们需要重启 Next.js 服务,一切正常的话,你会发现所有的 MD 文档可以同过 /articles/文件名的路径在浏览器上查看, 例如 http://localhost:3000/articles/article-01 对应 /articles/article-01.md这个 MD 文档,效果如下图所示:

二、创建博客列表页

有了博客相关的内容页,我们需要建一个按照文档创建时间倒序排列的博客列表页

1、首先我们在 lib/posts-md.js 文件里,定义一个 getAllFiles() 方法获取指定目录下文件列表:

  • 将 MD 文档的内容加载到数组里
  • 移除没有内容的文件
  • 按照文章的日期倒序排列
代码语言:javascript复制
// return sorted array of all posts for indexes
export async function getAllFiles(dir) {
  const
    now = dateformat.ymd(new Date()),
    files = await getFileIds(dir),
    data = await Promise.allSettled( files.map(id => getFileData(dir, id)) );
  return data
    .filter(md => md.value && md.value.dateYMD <= now)
    .map(md => md.value)
    .sort((a, b) => (a.dateYMD < b.dateYMD ? 1 : -1));
}

2、接下来我们新建一个博客列表页 pages/articles/index.js,创建一个异步方法 getStaticProps(),在项目构件时,调用刚才我们编写的方法 getAllFiles(),将文件列表内容返回组件的 postData 的属性里(第三点的代码部分),示例代码如下:

代码语言:javascript复制
import { getAllFiles } from '../../lib/posts-md';
const postsDir = 'articles';
// fetch array of all article posts
export async function getStaticProps() {
  return {
    props: {
      postData: await getAllFiles(postsDir),
    },
  };
}

3、接下来在我们需要将博客列表的内容输出到 pages/articles/index.js 页面进行显示,使用数组的 map() 方法迭代解析上述方法 postData 返回的内容,示例代码如下:

代码语言:javascript复制
import Layout from '../../components/layout';
import Pagelink from '../../components/pagelink';
import Head from 'next/head';
export default function ArticleIndex({ postData }) {
  return (
    <Layout hero="phone.jpg">
      <Head>
        <title>Article index</title>
        <meta name="description" content="A list of articles published on this site." />
      </Head>
      <h1>Article index</h1>
      <aside className="pagelist">
        { postData.map(post => (
          <Pagelink
            key={ post.id }
            postsdir={ postsDir }
            id={ post.id }
            title={ post.title }
            description={ post.description }
            dateymd={ post.dateYMD }
            datefriendly={ post.dateFriendly }
          />
        ))}
      </aside>
    </Layout>
  );
}

4、你可能注意到,上述代码我引用了一个<Pagelink>组件,其定义在 components/pagelink.js 文件里,此组件实现了显示文章的标题、链接、描述、日期等,示例代码如下:

代码语言:javascript复制
import Link from 'next/link';
export default function Pagelink(props) {
  const link = `/${ props.postsdir }/${ props.id }`;
  return (
    <article>
      <h2><Link href={ link }><a>{ props.title }</a></Link></h2>
      <p className="time"><time dateTime={ props.dateymd }>{ props.datefriendly }</time></p>
      <p>{ props.description }</p>
    </article>
  );
}

到这里博客列表页的功能就全部完成了,在浏览器输入 http://localhost:3000/articles 预览效果如下图所示:

所有的 MD 的文件都会罗列在此页面,随着内容的增加,你需要增加相关的逻辑进行分页,这里你就需要用到 getStaticPaths() 这个方法,并且需要此页面改成 pages/articles/[index].js(注:index可以换成你想要的参数,但是需要和getStaticPaths 方法中的参数对应),在页面构建时生成对应的页面路由,你可以参照第一部分基于MD文档生成动态路由这部分内容,具体的逻辑你可以考虑下怎么实现,这里就不再介绍了;

三、创建网站导航

为了让用户更方便浏览我们的博客网站,我们需要新建 components/navmenu.js 导航组件,用来实现网站导航的功能,由于功能简单,这里就不再解释,示例代码如下:

代码语言:javascript复制
import Link from 'next/link';
import Link from 'next/link';
// menu name and link
const menu = [
  { text: 'home', link: '/' },
  { text: 'about', link: '/about' },
  { text: 'articles', link: '/articles' }
];
// render menu
export default function Navmenu() {
  // get current page route
  const
    router = useRouter(),
    currentPage = router.pathname;
  return (
    <nav>
      <ul>
        { menu.map(item => (
          <Navlink
            key={ item.link }
            text={ item.text }
            link={ item.link }
            currentpage={ currentPage }
          />
        ))}
      </ul>
    </nav>
  )
}
// render individual menu link
function Navlink({ text, link, currentpage }) {
  if (link === currentpage) {
    return (
      <li className="active"><strong>{ text }</strong></li>
    );
  }
  else {
    return (
      <li><Link href={ link }><a>{ text }</a></Link></li>
    );
  }
}

导航组件完成后,我们将其引入 components/header.js 组件内,示例代码如下:

代码语言:javascript复制
import Navmenu from './navmenu';

更新后的 JSX 代码如下:

代码语言:javascript复制
...
<Navmenu />
<figure>
  <img src={ hero } width="400" height="300" alt="decoration" />
</figure>
...

完成后,博客导航的效果如下图所示:

四、使用Sass为博客添加全局样式

到这里,一个基于 MD 文档的简单博客网站到这里就完成了,最后我们要为网站添加样式,要不网站丑的实在看不下去。

Next.js 可以使用 Sass, Less, PostCSS, Styled JSX, CSS modules、plain old CSS等多种方式为站点添加样式,这里我们使用 Sass 为站点添加样式,这里我们手工为项目安装Sass:

代码语言:javascript复制
npm i sass

接下来我们可以为每个组件定义相关的样式,然后将其合并在一个 styles/global.scss 文件里,由于本篇文章重点讲述Next.JS 的用法,这里就不介绍如何编写Sass,感兴趣的同学可以点击文末的阅读原文下载本文的 Sass 样式:

代码语言:javascript复制
// settings
@import '01-settings/_variables';
@import '01-settings/_mixins';
// reset
@import '02-generic/_reset';
// elements
@import '03-elements/_primary';
// layout
@import '04-layout/_site';
// components
@import '05-components/_header';
@import '05-components/_footer';
@import '05-components/_article';

最后我们需要将 styles/global.scss 引入到 pages/_app.js 这个特殊的文件里,这样网站所有的页面都可以使用此样式,示例代码如下:

代码语言:javascript复制
import '../styles/global.scss';
export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
};

最后我们重新启动 Next.js 服务,你将会看到一个还算漂亮的博客首页,如下图所示:

未完待续

由于篇幅原因,今天的文章就到这里,一个基于 MD 文档的简单博客网站就完成了,通过本篇文章我们学习了如何基于MD文档生成动态路由,完成了文章内容页、列表页、导航功能,并为网站添加了漂亮的样式。在下篇文章里,我们为博客网站添加暗黑模式,基于接口数据渲染内容(服务端渲染),及如何编译项目将博客网站部署到 Node.js 服务器上或纯静态化部署,最后会提供完整的项目源码,敬请期待...

0 人点赞