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 できているかどうかは ↓ で試すことができる。