Hi 🙋‍♀️about me 😎posts 📚

Next.js で SSR 時に highlight.js を適応する

intro

ブログサービスなどはマークダウンファイルから HTML を生成するケースなど多いと思われるが、特に技術系のブログではコードのハイライティングが必須となり、highlight.js を使っている人も多いと思われる。そこで SSR 時に highlight.js を適応する実装方法について紹介してみようと思う。

公式のサイト通りの手順で対応する場合、クライアントサイドでの処理となってしまうので、FOUC やバンドルサイズの増加が懸念される。

https://github.com/highlightjs/highlight.js

そこで今回は highlight.js の仕組みを理解し、SSR 時にて適応するための方法を紹介する。

highlight.js の仕組み

hljs.highlightAll() を起動することで、ハイライト指定のある Node に対して class の付与操作を行っている。 https://github.com/highlightjs/highlight.js#getting-started

Next.js で書いて見るとこんな感じだ。

import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import 'highlight.js/styles/github.css';
import marked from 'marked';
import { useEffect } from 'react';

// どの言語をハイライトしたいかを登録
hljs.registerLanguage('javascript', javascript);

const Page = ({ html }: { html: string }) => {
  useEffect(() => {
    // client side で invoke
    hljs.highlightAll();
  }, [hljs]);
  return (
    <div className="grid place-items-center h-screen">
      <div
        className=" grid place-content-center my-6 p-10 border-white border"
        // 組み上げた HTML string をそのまま文字列として注入
        dangerouslySetInnerHTML={{ __html: html }}
      ></div>
    </div>
  );
};

export default Page;

// server side deda fetching
// @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
export const getStaticProps = async () => {
  // markdown file を HTML string に変換したと仮定
  // 本来ならここで fs.read などしてとってくるが inline で割愛
  const md = `
  \`\`\`js
  const popo = () => { console.log(42); };
  \`\`\`
  `;
  const html = marked(md);
  // 初期引数として渡す
  return {
    props: { html },
  };
};

hljs.highlightAll() 実行前は、もちろんスタイルが当たっていない。

Node に付与されている class を確認する。これは marked によって default で割り振られる class だ。

hljs.highlightAll() の実態は、中で対象の NodeList に対して heighlightBlock を call しているだけだ。

hljs.highlightAll() を抜けるとスタイルが当たっている。

Node をみてると新たに hljs javascritp が追加されているのがわかる。

つまりこの関数自体がやっていることは class の付与だけなので、これを servder side で markdown から HTML を組み立てる際にやってしまおうという話だ。

highlight on server

だいたいの markdown converter には、カスタムの renderer が実装できるようになっている。

今回使っている marked にも同様にカスタム renderer が存在する。

https://marked.js.org/using_pro#renderer

hook できる要素はかなり揃っているので、やりたいことはカスタムすれば大抵できると思う。

今回の場合であればこんな感じでできる。

import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import 'highlight.js/styles/github.css';
import marked from 'marked';

const Page = ({ html }: { html: string }) => {
  // client side には hljs の bundle が不要になる
  return (
    <div className="grid place-items-center h-screen">
      <div
        className=" grid place-content-center my-6 p-10 border-white border"
        // 組み上げた HTML string をそのまま文字列として注入
        dangerouslySetInnerHTML={{ __html: html }}
      ></div>
    </div>
  );
};

export default Page;

// server side deda fetching
// @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
export const getStaticProps = async () => {
  // どの言語をハイライトしたいかを登録
  // code split の都合上、getStaticProps の中に記載する
  hljs.registerLanguage('javascript', javascript);
  // 本来ならここで fs.read などしてとってくるが inline で割愛
  const md = `
  \`\`\`js
  const popo = () => { console.log(42); };
  \`\`\`
  `;
  const renderer = new marked.Renderer();
  renderer.code = (code, lang) => {
    // lang の指定がある場合はそれを優先、なければ default で bash の syntax で heighlight
    code = hljs.highlight(lang || 'bash', code).value;
    //
    return `<code class="hljs language-${lang}"><pre>${code}</pre></code>`;
  };
  const html = marked(md, { renderer });
  return {
    props: { html },
  };
};

こうすることで、ロジックを server side に隠蔽し、なおかつ Next.js の場合は server side で完結する code は client bundle に含まれないため client side の bundle サイズを減らすことができる。

before:

after:

だいたい 10kb くらい節約できている。

ちなみに、Next.js で client/server 間で code が意図通りに split できているかどうかは ↓ で試すことができる。

https://next-code-elimination.vercel.app/