🔃

Next.jsでMarkdown記事の快適なホットリロードを実現する

2023/07/01に公開

TL;DR

Markdown ファイルの変更を監視する WebSocket サーバを別プロセスで立て、ファイル更新時に記事情報をフロント(WebSocket クライアント)に送って反映させる

モチベーション

Next.js でブログなどを実装する場合、各記事をいちいち JSX で書く人は稀で、Markdown で記述して HTML に変換し表示させることが多いと思う

この場合基本的には当該記事ページの getStaticProps で Markdown ファイルの中身を取得し、(多くの場合 HTML に変換もした上で)ページコンポーネントに渡す実装になる

export const getStaticProps = async ({ params }) => {
  const slug = params.slug;

  // ローカルの Markdown ファイルの中身を読む
  const fileContent = await readFile(
    path.resolve("src", "posts", `${slug}.md`),
  );
  const fileContentString = fileContent.toString();

  // HTMLに変換
  const body = await markdownToHtml(fileContentString);

  return {
    props: {
      content: body,
    },
  };
};

これだけで特に問題なく Markdown で記事を記述できるが、執筆中に記事を加筆して保存してもページ側に反映されることはなく、自分でリロードする必要がある
SPA の悪いところとしてこういうときにスクロール位置が保持されないこともあり、執筆体験があまりよろしくない

できれば hot reload (特に HMR や fast refresh ではないため原始的な hot reload というワードを使うことにする)のように保存した変更内容がすぐにページに反映されてほしい
このとき、願わくば一度ページが真っ白になったりスクロール位置がページ最上部に戻されるようなことも避けたい

既存手法

以下のようなものがあるが、それぞれ付記しているように自分の求める執筆体験には届かなかった

  • next-remote-watch を使う
    • 必要な作業が少なく手軽に導入できるのは良い
    • 差分の反映が遅く、またページが一度ブランクになる
  • 別スクリプトでファイルを監視し、更新されたタイミングでページに無意味な変更を加えて無理やり HMR させる[1]
    • 単純にページがリビルドされるのでスクロール位置がリセットされる

今回採用した手法

zenn-clizenn preview はページの更新も速くまさに自分の求めているものに近かったためどのように実現されているか読んでみたところ、Markdown ファイルが変更されたら WebSocket を介してフロントに変更後の記事を送り描画させているようだった [2]
WebSocket のリアルタイム性を活かした形になっている

今回はこれを参考に同じような流れを実装したが、面倒そうだったのでファイル監視の機構を Next.js 側に組み込むことは考えず、シンプルに別プロセスに分けた
Custom server など使えば押し込めたりするのかもしれない(憶測)が、全く詳しくなく、また Next.js のお作法から大なり小なり逸れることになるため将来の維持を考えるとむやみに利用したくない

監視サーバを next dev と別個に起動する面倒さはあるが、執筆体験としては間違いなく他の手法より良いため十分に価値があると思う

実装

ファイル監視に chokidar、WebSocket に Socket.IO を利用する

yarn add -D chokidar express @types/express socket.io socket.io-client


ファイルの変更を監視する WebSocket サーバは以下のようなもの
今回はこれを ts-node で実行している

import chokidar from "chokidar";
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";

const host = "localhost";
const port = 4000;

const app = express();
const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: {
    // フロントの origin
    origin: "http://localhost:3000",
  },
});

const onContentChange = async (filePath: string) => {
  // getStaticProps 同様の処理
  const fileContent = await readFile(filePath);
  const fileContentString = fileContent.toString();
  const body = await markdownToHtml(fileContentString);
  const post = {
    content: body,
  };

  // フロントに送る
  io.emit("postChange", post);
};

// chokidar を用いたファイルの変更監視
const watcher = chokidar.watch("src/posts", { ignoreInitial: true });
watcher.on("change", (path) => void onContentChange(path));

httpServer.listen(port, host, () => {
  io.on("connection", (socket) => {
    console.log(`User connected ${socket.id}`);

    socket.on("disconnect", () => {
      console.log(`User disconnected ${socket.id}`);
    });
  });
});

基本的には getStaticProps に書いていたのと同じファイルの読み取りと変換の処理を行い、io.emit でフロントに送れば良いだけということになる
場合によってはモジュールに切り出してある程度共通化すれば良いだろう


サーバから送られてきた変更後の記事をフロントで受け取り、表示を更新する

const Page: NextPage<Props> = (originalProps) => {
  const [hotProps, setHotProps] = useState<Props>();

  useEffect(() => {
    if (process.env.NODE_ENV === "development") {
      const socket = io("http://localhost:4000");

      // お好みで
      socket.on("connect", () => console.log("Local posts watcher: Connected"));
      socket.on("disconnect", () =>
        console.log("Local posts watcher: Disconnected"),
      );

      // 記事データの更新
      socket.on("postChange", (props: Props) => {
        // サーバから送られてきたのが他の記事であった場合無視
        if (props.meta.slug === originalProps.meta.slug) setHotProps(props);
      });

      return () => {
        socket.close();
      };
    }
  }, []);

  const props = hotProps ?? originalProps;

  return {
    // ...
  };
};

export default Page;

エンドユーザまで配信されるクライアントのコードにこのような処理を書く気持ち悪さは若干あるが、個人的にはまあ許容できる
if (process.env.NODE_ENV === "development") なのでもしかすると "production" だと勝手に消してくれたりするかも (とりあえず next export で静的に書き出した上でざっと grep してみた限りでは消えていそうだったが、他のデプロイ方法などちゃんと確認してはいません)

また、このとき getStaticProps からページごとにユニークな key を返しておいた方が良い
こうしないと記事 A から記事 B に直接遷移した場合 state (この場合 hotProps)の値が保持されたままとなり、ページ遷移したはずなのに表示上の記事が更新されないような挙動になる(これは今回の手法とは関係のない dynamic routes の仕様[3])

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  // ...

  return {
    props: {
      key: meta.slug,
      content: body,
    },
  };
};

おまけ

ts-node で zenn-markdown-html を使う

今回実際には Markdown から HTML の変換に zenn-markdown-htmlを使用したが、import markdownHtml from "zenn-markdown-html" するとどうしても markdownHtml is not a function のようなエラーになってしまった
本来ならば default export された関数そのものが markdownHtml として import されるべきはずが、それが .default property に入ってしまっている
既存の getStaticProps でも同じモジュールを使いたいので Next.js (の中の Webpack)と ts-node (の後ろの Node.js)の両者で動くよう工夫する必要があるが、Astro でも同様の報告があり、以下のような workaround が可能 [4]

import markdownHtmlLib from "zenn-markdown-html";

type MarkdownHtml = typeof markdownHtmlLib;
type MarkdownHtmlAtBuild = { default: MarkdownHtml };

const markdownHtml =
  typeof markdownHtmlLib === "function"
    ? markdownHtmlLib
    : (markdownHtmlLib as MarkdownHtmlAtBuild).default;

埋め込みコンテンツ

上記の通り zenn-markdown-html を使用すると埋め込みコンテンツが利用できるが、ファイル更新に伴い記事の HTML をまるごと書き換えると当然その iframe が再読み込みされ、layout shift が起きてしまう(そしてこれは本家 zenn-editor でも解決されていない)
埋め込みが多い記事ではかなりのストレスになるため、執筆中はとりあえずこれらの埋め込みを無理やり無効化して素のリンクにしておくような処理を挟んだ

const sanitizeEmbeds = (body: string) =>
  body
    .split("\n")
    // URL単体の行は先頭に `/ ` を入れることで埋め込みを無効化
    .map((line) => (line.match(/^https?:\/\/.*$/) ? `/ ${line.trim()}` : line))
    .join("\n");


脚注
  1. Next.js ブログの markdown 編集時に表示更新する ↩︎

  2. zenn-editor/packages/zenn-cli/src/server/lib/server.ts · zenn-dev/zenn-editor ↩︎

  3. Page state is not reset for navigating between dynamic routes · Issue #9992 · vercel/next.js ↩︎

  4. Astro で zenn-markdown を使う ↩︎

Discussion