天天看點

使用 React 和 Next.js 建構部落格

Next.js

是由 Vercel 建立和維護的基于 React 的應用程式架構。本教程将從零開始學習如何使用

Next.js

建構一個小型的部落格網站:

  • 基本頁面建立
  • Markdown

    檔案生成的動态路由
  • 靜态生成(在建構時渲染)
  • 伺服器端渲染(在請求時渲染)

文章涉及的代碼倉庫位址:https://github.com/QuintionTang/react-blog

Next.js 适合部落格嗎?

本教程将通過建立一個簡單的部落格來展示

Next.js

功能,那麼

Next.js

适合這樣的部落格的開發嗎?先來了解一下一般部落格都需要什麼?

使用 React 和 Next.js 建構部落格
  • WordPress 是一個内容管理系統 (CMS),它為三分之一的網站提供支援,通過在每次請求時将可編輯的資料庫内容渲染到 PHP 模闆中來為頁面提供服務。它非常适合定期更新的内容,但性能、安全性和資料備份需要一定的自定義設定。
  • 靜态站點生成器 (SSG),例如 Eleventy 或 Gatsby 建立預渲染檔案,無需伺服器端或資料庫即可快速建構靜态站點,在版本控制、性能和安全性都非常出色,但建構步驟和以開發人員為中心的過程可能會減慢釋出速度,尤其是在大型網站上。

Next.js

是一個基于 React 的應用程式架構,它幾乎沒有特定于部落格功能。但是,它可以提供了一種實作機制:

  1. 在可能的情況下,

    Next.js

    生成靜态内容,如 SSG,這些頁面加載速度非常快,可以被搜尋引擎快速收錄,并且可以在任何有或沒有 JavaScript 的裝置上閱讀。
  2. 在第一次加載後,

    Next.js

    應用程式的行為類似于單頁應用程式 (SPA),後續頁面和代碼會以漸進式下載下傳,無需重新整理整頁。
  3. Next.js

    為每個請求提供伺服器端渲染 (SSR),為個人使用者提供實時 CMS 更新或自定義内容變得更加容易。

如果網站可能會從基本部落格疊代為更複雜的網站,例如線上商店、新聞聚合服務、社交媒體平台等,可以考慮使用

Next.js

開始

本教程正在建構的内容,可以在 GitHub 上找到完整的代碼。可以通過在終端中輸入以下指令,在

Windows

macOS

Linux

上下載下傳、安裝和啟動它:

git clone [email protected]:QuintionTang/react-blog.git
cd react-blog
npm i
npm run dev
           

然後在浏覽器中輸入

localhost:3000

打開首頁。

從頭開始建構

Next.js

提供了一個

create-next-app

工具來快速開始使用應用程式模闆。本教程将展示如何從頭開始建構站點:如添加靜态資源或者頁面。

安裝 Next.js 和 React

與其他

Node.js

或者 VUE 項目一樣,首先建立一個目錄并初始化

package.json

檔案:

mkdir react-blog
cd react-blog
npm init
           

然後安裝

Next.js

React

作為依賴項:

npm install next react react-dom --save 
           

添加開發建構腳本設定,如下所示,在

package.json

檔案的

scripts

屬性中添加:

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
}
           
建立第一個頁面

Next.js

有一個基于檔案系統的路由器。在項目的

pages

目錄中建立的任何 React 元件檔案都會自動呈現為一個頁面。

要建立一個頁面,需要在

pages

目錄中添加一個

index.js

檔案。将以下代碼添加到

./pages/index.js

檔案中,傳回

JSX

代碼的功能性 React 元件:

export default function Home() {
    return (
        <>
            Next.js 部落格網站
            <p>
                這個部落格網站将使用 <a rel="nofollow" href="https://nextjs.org/">Next.js</a>。
            </p>
        </>
    );
}
           
JSX 必須在單個包含元素(例如

<div>

)中傳回。

<> ... </>

表示法定義了一個文檔片段,是以不需要額外的容器。

要啟動

Next.js

開發伺服器,從項目根目錄在終端中運作

npm run dev

(可以使用

npx next dev

),然後在浏覽器中打開

http://localhost:3000/

使用 React 和 Next.js 建構部落格

Next.js

已經确定頁面可以預渲染,是以它在開發模式下顯示一個閃電圖示。

可以在自動路由的頁面目錄中建立類似的檔案,如下:

  • pages/index.js

    用于呈現部落客要
  • pages/about.js

    呈現一個

    /about

    頁面
增加連結

在 JSX 代碼中使用标準 HTML

<a>

标簽建立指向另一個頁面的超連結。如果該頁面位于同一個

Next.js

站點内,浏覽器将會重新整理整個頁面。可以使用

next/link

中的

<Link>

元件實作頁面跳轉。在根頁面

/index.js

上建立指向

/about

頁面的連結,代碼如下:

import Link from "next/link";
export default function Home() {
    return (
        <>
            Next.js 部落格網站
            <p>
                這個部落格網站将使用 <a rel="nofollow" href="https://nextjs.org/">Next.js</a>。
            </p>
            <p>
                更多内容請點選{" "}
                <Link href="/about">
                    <a>關于我們...</a>
                </Link>
            </p>
        </>
    );
}
           

當點選

關于我們...

連結時,

Next.js

将使用

Ajax

請求下載下傳

/about

的内容一次并緩存,然後再頁面中呈現。

增加 <head> 元素

可以使用

next/head

中的

<Head>

元件來更改頁面标題和元标記,如下:

import Head from "next/head";
import Link from "next/link";
export default function Home() {
    return (
        <>
            <Head>
                <title>Next.js網站</title>
                <meta
                    name="description"
                    content="這是一個由 Next.js 驅動的網站"
                />
            </Head>
            Next.js 部落格網站
            <p>
                這個部落格網站将使用 <a rel="nofollow" href="https://nextjs.org/">Next.js</a>。
            </p>
            <p>
                更多内容請點選{" "}
                <Link href="/about">
                    <a>關于我們...</a>
                </Link>
            </p>
        </>
    );
}
           

點選浏覽器檢視源代碼,可以看到相關 HTML 标簽。

增加靜态資源

public

目錄用于存放靜态資源,如圖示、

robots.txt

或其它更新頻率低的檔案。可以增加自己的檔案或從初始項目存儲庫複制

favicon.ico

和圖像子目錄。

建立模闆

Next.js

使用 React 元件來實作模闆化,接下來項目根目錄下建立一個新的

components

檔案夾,然後添加

layout.js

來定義一個新的

<Layout>

元件:

import Header from "./header";
import Footer from "./footer";

export default function Layout({ children, title }) {
    return (
        <>
            <Header title={title} />
            <main>{children}</main>
            <Footer />
        </>
    );
}
           

任何使用此元件的頁面都會傳遞一個

props

對象,該對象包含作為子值

children

的内容。

<Layout>

還将引用了另外兩個元件,分别是

component/header.js

中的

<Header>

,主要呈現一個

<header>

,包含首頁連結、内聯 SVG Logo和 預設為

/images/header.jpg

的圖像:

import Link from "next/link";

export default function Header({ title }) {
    const headerImg = "/images/" + (title || "header.jpg");

    return (
        <header>
            <p className="logo">
                <Link href="/">
                    <a>
                        <svg
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 20 20"
                            width="50"
                            height="50"
                        >
                            <path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0zM6 18a1 1 0 001-1v-2.065a8.935 8.935 0 00-2-.712V17a1 1 0 001 1z"></path>
                        </svg>
                        Next.js 部落格
                    </a>
                </Link>
            </p>

            <figure>
                <img
                    src={headerImg}
                    height="80"
                    alt="decoration"
                />
            </figure>
        </header>
    );
}
           

第二個元件是

component/footer.js

中的

<Footer>

,呈現

<footer>

内容,其中包含指向 GitHub 存儲庫和内聯 SVG 的連結:

export default function Footer() {
    return (
        <footer>
            <p className="github">
                <a rel="nofollow" href="https://github.com/QuintionTang/react-blog">
                    <svg
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 512 512"
                        width="50"
                        height="50"
                    >
                        <path d="M256 32C132.3 32 32 134.9 32 261.7a229.3 229.3 0 00153.2 217.9 17.6 17.6 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4l-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6a84.6 84.6 0 012.2-60.8 18.6 18.6 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.2 208.2 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.6 18.6 0 015 .5 84.6 84.6 0 012.2 60.8 90.3 90.3 0 0123 61.6c0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.4 19.4 0 004-.4A229.2 229.2 0 00480 261.7C480 134.9 379.7 32 256 32z" />
                    </svg>
                    https://github.com/QuintionTang/react-blog
                </a>
            </p>
        </footer>
    );
}
           

接下來更新

pages/index.js

,将使用自定義的

<Layout>

元件:

import Layout from "../components/layout";
import Head from "next/head";
import Link from "next/link";
export default function Home() {
    return (
        <Layout>
            <Head>
                <title>Next.js網站</title>
                <meta
                    name="description"
                    content="這是一個由 Next.js 驅動的網站"
                />
            </Head>
            Next.js 部落格網站
            <p>
                這個部落格網站将使用 <a rel="nofollow" href="https://nextjs.org/">Next.js</a>。
            </p>
            <p>
                更多内容請點選{" "}
                <Link href="/about">
                    <a>關于我們...</a>
                </Link>
            </p>
        </Layout>
    );
}
           

然後對

pages/about.js

和建立的任何其他頁面執行相同操作。

import Layout from "../components/layout";
import Head from "next/head";

export default function Home() {
    return (
        <Layout title="share.png">
            <Head>
                <title>關于我們</title>
            </Head>

            關于我們
            <p>
                DevPoint 是 WEB
                開發的分享中心,用自己的熱情來分享網際網路的點滴,以此激勵自己加強學習提升自我。
            </p>
        </Layout>
    );
}
           

更新後的效果如下:

使用 React 和 Next.js 建構部落格
使用動态路由檢視部落格内容

使用 JSX 建立内容并不是特别實用,尤其是對于正常部落格文章,對于開發者比較喜歡 Markdown 的方式寫部落格。Next.js 可以使用任何來源建立頁面。這些可以在建構時靜态生成,并使用動态路由将資料映射到 URL。

在繼續之前,先來建立一個文章目錄,用于存放部落格的 Markdown 檔案。例如:

articles/article-01.md

---
title: 使用 React 和 Next.js 建構部落格
description: Next.js 是由 Vercel 建立和維護的基于 React 的應用程式架構。本教程将從零開始學習如何使用 Next.js 建構一個小型的部落格網站。
date: 2022-01-22
tags:
    - HTML
    - CSS
    - REACT
---

使用 React 和 Next.js 建構部落格

## 摘要

Next.js 是由 Vercel 建立和維護的基于 React 的應用程式架構。本教程将從零開始學習如何使用 Next.js 建構一個小型的部落格網站。

本教程将通過建立一個簡單的部落格來展示 Next.js 功能,那麼 Next.js 适合這樣的部落格的開發嗎?先來了解一下一般部落格都需要什麼?

-   WordPress 是一個内容管理系統 (CMS),它為三分之一的網站提供支援,通過在每次請求時将可編輯的資料庫内容渲染到 PHP 模闆中來為頁面提供服務。它非常适合定期更新的内容,但性能、安全性和資料備份需要一定的自定義設定。
-   靜态站點生成器 (SSG),例如 Eleventy 或 Gatsby 建立預渲染檔案,無需伺服器端或資料庫即可快速建構靜态站點,在版本控制、性能和安全性都非常出色,但建構步驟和以開發人員為中心的過程可能會減慢釋出速度,尤其是在大型網站上。

Next.js 是一個基于 React 的應用程式架構,它幾乎沒有特定于部落格功能。但是,它可以提供了一種實作機制:

1. 在可能的情況下,Next.js 生成靜态内容,如 SSG,這些頁面加載速度非常快,可以被搜尋引擎快速收錄,并且可以在任何有或沒有 JavaScript 的裝置上閱讀。
2. 在第一次加載後,Next.js 應用程式的行為類似于單頁應用程式 (SPA),後續頁面和代碼會以漸進式下載下傳,無需重新整理整頁。
3. Next.js 為每個請求提供伺服器端渲染 (SSR),為個人使用者提供實時 CMS 更新或自定義内容變得更加容易。
   如果網站可能會從基本部落格疊代為更複雜的網站,例如線上商店、新聞聚合服務、社交媒體平台等,可以考慮使用 Next.js。
           

内容的模闆以

---

來定義部落格的标題、釋出時間等中繼資料,

---

後面的為部落格的正文。接下來需要安裝解析内容的依賴,包括:

front-matter

remark

remark-html

,執行一下指令:

npm install front-matter remark remark-html --save
           

要讀取和解析 Markdown 檔案,需要添加相關邏輯,代碼所在檔案

libs/posts-md.js

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";

// 擷取檔案夾相對路徑
function absPath(dir) {
    return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
}

/**
 * 擷取檔案夾中 Markdown 檔案名清單,以數組形式傳回
 * @param {*} dir
 * @returns
 */
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)));
}

/**
 * 擷取單個 Markdown 檔案的内容
 * @param {*} dir
 * @param {*} id
 * @returns
 */
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();

    // 日期格式化
    const date = matter.attributes.date || stat.ctime;
    matter.attributes.date = date.toUTCString();
    matter.attributes.dateYMD = dateformat.ymd(date);
    matter.attributes.dateFriendly = dateformat.friendly(date);

    // 計數
    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,
    };
}
           

以上代碼涉及日期格式化代碼,檔案路徑

libs/dateformat.js

// 時間格式化
const toMonth = new Intl.DateTimeFormat("en", { month: "long" });

// 格式化為 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")}`
        : "";
}

// 格式化為 DD MMMM, YYYY
export function friendly(date) {
    return date instanceof Date
        ? `${date.getUTCDate()} ${toMonth.format(
              date
          )}, ${date.getUTCFullYear()}`
        : "";
}
           

Next.js 使用包含在

[

id

]

中的辨別符的檔案名來識别動态(生成)路由。建立一個名為

pages/articles/[id].js

的檔案:

Next.js

将使用

id

作為參數在

/articles/article-01

等路由處生成頁面,即部落格的詳情頁路由。

pages/articles/[id].js

中定義函數

getStaticPaths

,該函數傳回建構時要呈現的路徑資訊。

/**
 * 擷取部落格路徑資訊
 * @returns [ { params: { id: 'article-01' } } ]
 */
export async function getStaticPaths() {
    const paths = (await getFileIds(postsDir)).map((id) => {
        return { params: { id } };
    });
    console.log(paths);
    return {
        paths,
        fallback: false,
    };
}
           
設定

fallback: false

會在找不到路徑時出現 404 頁面。

接下來建立函數

getStaticProps

,函數在建構時擷取特定 ID 的資料以進行靜态生成。它在

params

對象中傳遞了一個

id

屬性,調用

libs/posts-md.js

中的

getFileData()

函數解析 Markdown 檔案。

/**
 * 解析路由擷取詳細内容
 * @param {*} param0
 * @returns
 */
export async function getStaticProps({ params }) {
    return {
        props: {
            postData: await getFileData(postsDir, params.id),
        },
    };
}
           

pages/articles/[id].js

除了解析部落格内容外,還需将内容導出為一個 React 元件,元件将

postData

渲染到前面建立的模闆中:

export default function Article({ postData }) {
    // 解析markdown内容
    const html = `
    ${postData.title}
    <p class="time"><time datetime="${postData.dateYMD}">${postData.dateFriendly}</time></p>
    <p class="words">${postData.wordcount}</p>
    ${postData.html}
  `;

    return (
        <Layout title="share.png">
            <Head>
                <title>{postData.title}</title>
                <meta name="description" content={postData.description} />
            </Head>

            <article dangerouslySetInnerHTML={{ __html: html }} />
        </Layout>
    );
}
           

dangerouslySetInnerHTML

屬性確定 HTML 不被編碼。
建立部落格清單頁

建立檔案

pages/articles/index.js

,這個頁面需要實作的功能是解析部落格清單,并傳回為一個 React 元件。在實作這個頁面功能之前,先來建立一個連結元件

Pagelink

Pagelink

元件實作部落格清單中單篇部落格的布局,建立檔案

components/pagelink.js

,代碼如下:

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.datefriendly}>{props.dateymd}</time>
            </p>
            <p>{props.description}</p>
        </article>
    );
}
           

完成單個部落格布局後,來看看部落格清單頁,代碼如下:

import { getAllFiles } from "../../libs/posts-md";
import Layout from "../../components/layout";
import Pagelink from "../../components/pagelink";
import Head from "next/head";

const postsDir = "articles";

export default function ArticleIndex({ postData }) {
    return (
        <Layout title="share.png">
            <Head>
                <title>部落格清單</title>
                <meta
                    name="description"
                    content="A list of articles published on this site."
                />
            </Head>

            部落格清單

            <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>
    );
}

/**
 * 擷取所有文章文章的數組
 * @returns
 */
export async function getStaticProps() {
    return {
        props: {
            postData: await getAllFiles(postsDir),
        },
    };
}
           
建立導航

一個完整的部落格站點,需要一個導航菜單,友善内容切換。接下來建立一個導航元件,建立檔案

components/navs.js

,導出一個

<Navs>

元件,代碼如下:

import { useRouter } from "next/router";
import Link from "next/link";

// menu name and link
const menu = [
    { text: "網站首頁", link: "/" },
    { text: "關于我們", link: "/about" },
    { text: "部落格清單", link: "/articles" },
];

export default function Navs() {
    const router = useRouter(),
        currentPage = router.pathname;

    return (
        <nav>
            <ul>
                {menu.map((item) => (
                    <NavItem
                        key={item.link}
                        text={item.text}
                        link={item.link}
                        currentpage={currentPage}
                    />
                ))}
            </ul>
        </nav>
    );
}

function NavItem({ 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>
        );
    }
}
           

下面将

Navs

元件加入到元件 Header 中,代碼如下:

import Link from "next/link";
import Navs from "./navs";  // 導航菜單

export default function Header({ title }) {
    const headerImg = "/images/" + (title || "cover.png");

    return (
        <header>
            <p className="logo">
                <Link href="/">
                    <a>
                        <svg
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 20 20"
                            width="50"
                            height="50"
                        >
                            <path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0zM6 18a1 1 0 001-1v-2.065a8.935 8.935 0 00-2-.712V17a1 1 0 001 1z"></path>
                        </svg>
                        Next.js 部落格
                    </a>
                </Link>
            </p>
            <Navs />
            <figure>
                <img src={headerImg} width="400" alt="decoration" />
            </figure>
        </header>
    );
}
           

到此一個簡單的部落格站點功能已經實作。

添加樣式

Next.js 提供了一系列樣式選項,包括 Sass、Less、PostCSS、Styled JSX、CSS 子產品和普通的CSS。 Sass 易于使用,因為不需要任何配置,按照依賴:

npm install sass --save  
           

根目錄下建立檔案夾

styles

,所有的樣式檔案都放在這個檔案夾下。樣式的入口未見為

master.scss

然後在檔案夾

pages

下建立檔案

_app.js

,将樣式檔案導入,完整代碼如下:

import "../styles/master.scss";

export default function App({ Component, pageProps }) {
    return <Component {...pageProps} />;
}
           

最終效果如下: