監聽 Markdown 檔案並熱更新 Next.js 頁面

2022-06-24 21:01:17

Next.js 提供了 Fast-Refresh 能力,它可以為您對 React 元件所做的編輯提供即時反饋。
但是,當你通過 Markdown 檔案提供網站內容時,由於 Markdown 不是 React 元件,熱更新將失效。

怎麼做

解決該問題可從以下幾方面思考:

  1. 伺服器如何監控檔案更新
  2. 伺服器如何通知瀏覽器
  3. 瀏覽器如何更新頁面
  4. 如何拿到最新的 Markdown 內容
  5. 如何與 Next.js 開發伺服器一起啟動

監控檔案更新

約定: markdown 檔案存放在 Next.js 專案根目錄下的 _contents/

通過 node:fs.watch 模組遞迴的監控 _contents 目錄,當檔案發生變更,觸發 listener 執行。
新建檔案 scripts/watch.js 監控 _contents 目錄。

const { watch } = require('node:fs');

function main(){
    watch(process.cwd() + '/_contents', { recursive: true }, (eventType, filename) => {
        console.log(eventType, filename)
    });
}

通知瀏覽器

伺服器端通過 WebSocket 與瀏覽器建立連線,當開發伺服器發現檔案變更後,通過 WS 通知瀏覽器更新頁面。
瀏覽器需要知道被更新的檔案與當前頁面所在路由是否有關,因此,伺服器端傳送給瀏覽器的訊息應至少包含當前
更新檔案對應的頁面路由。

WebSocket

ws 是一個簡單易用、速度極快且經過全面測試的 WebSocket 使用者端和伺服器實現。通過 ws 啟動 WebSocket 伺服器。

const { watch } = require('node:fs');
const { WebSocketServer } = require('ws')

function main() {
    const wss = new WebSocketServer({ port: 80 })
    wss.on('connection', (ws, req) => {
        watch(process.cwd() + '/_contents', { recursive: true }, (eventType, filename) => {
            const path = filename.replace(/\.md/, '/')
            ws.send(JSON.stringify({ event: 'markdown-changed', path }))
        })
    })
}

瀏覽器連線伺服器

新建一個 HotLoad 元件,負責監聽來自伺服器端的訊息,並熱實現頁面更新。元件滿足以下要求:

  1. 通過單例模式維護一個與 WebSocekt Server 的連線
  2. 監聽到伺服器端訊息後,判斷當前頁面路由是否與變更檔案有關,無關則忽略
  3. 伺服器端訊息可能會密集傳送,需要在載入新版本內容時做防抖處理
  4. 載入 Markdown 檔案並完成更新
  5. 該元件僅在 開發模式 下工作
import { useRouter } from "next/router"
import { useEffect } from "react"

interface Instance {
    ws: WebSocket
    timer: any
}

let instance: Instance = {
    ws: null as any,
    timer: null as any
}

function getInstance() {
    if (instance.ws === null) {
        instance.ws = new WebSocket('ws://localhost')
    }
    return instance
}

function _HotLoad({ setPost, params }: any) {
    const { asPath } = useRouter()
    useEffect(() => {
        const instance = getInstance()
        instance.ws.onmessage = async (res: any) => {
            const data = JSON.parse(res.data)
            if (data.event === 'markdown-changed') {
                if (data.path === asPath) {
                    const post = await getPreviewData(params)
                    setPost(post)
                }
            }
        }
        return () => {
            instance.ws.CONNECTING && instance.ws.close(4001, asPath)
        }
    }, [])
    return null
}

export function getPreviewData(params: {id:string[]}) {
    if (instance.timer) {
        clearTimeout(instance.timer)
    }
    return new Promise((resolve) => {
        instance.timer = setTimeout(async () => {
            const res = await fetch('http://localhost:3000/api/preview/', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(params)
            })
            resolve(res.json())
        }, 200)
    })
}

let core = ({ setPost, params }: any)=>null

if(process.env.NODE_ENV === 'development'){
    console.log('development hot load');
    core = _HotLoad
}

export const HotLoad = core

資料預覽 API

建立資料預覽 API,讀取 Markdown 檔案內容,並編譯為頁面渲染使用的格式。這裡的結果
應與 [...id].tsx 頁面中 getStaticProps() 方法返回的頁面資料結構完全一致,相關
邏輯可直接複用。

新建 API 檔案 pages/api/preview.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import { getPostData } from '../../lib/posts'

type Data = {
    name: string
}

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<Data>
) {
    if (process.env.NODE_ENV === 'development') {
        const params = req.body
        const post = await getPostData(['posts', ...params.id])
        return res.status(200).json(post)
    } else {
        return res.status(200)
    }
}

更新頁面

頁面 pages/[...id].tsx 中引入 HotLoad 元件,並傳遞 setPostData()paramsHotLoad 元件。

...
import { HotLoad } from '../../components/hot-load'

const Post = ({ params, post, prev, next }: Params) => {
    const [postData, setPostData] = useState(post)
    
    useEffect(()=>{
        setPostData(post)
    },[post])

    return (
        <Layout>
            <Head>
                <title>{postData.title} - Gauliang</title>
            </Head>
            <PostContent post={postData} prev={prev} next={next} />
            <BackToTop />
            <HotLoad setPost={setPostData} params={params} />
        </Layout>
    )
}

export async function getStaticProps({ params }: Params) {
    return {
        props: {
            params,
            post:await getPostData(['posts', ...params.id])
        }
    }
}

export async function getStaticPaths() {
    const paths = getAllPostIdByType()
    return {
        paths,
        fallback: false
    }
}

export default Post

啟動指令碼

更新 package.jsondev 指令碼:

"scripts": {
    "dev": "node scripts/watch.js & \n next dev"
},

總結

上述內容,整體概述了大致的實現邏輯。具體專案落地時,還需考慮一些細節資訊,
如:檔案更新時希望能夠在命令列提示更的檔名、針對個性化的路由資訊調整檔案與路由的匹配邏輯等。

Next.js 部落格版原文:https://gauliang.github.io/blogs/2022/watch-markdown-files-and-hot-load-the-nextjs-page/