ReactでShikiを使って、コードを色分けして表示する方法
Shiki は、VS Code と同じ TextMate の文法とテーマを使って、コードを色分けした HTML を作るライブラリです。React では、Shiki が返す HTML の木(hast)を React の要素に変えて表示すると、dangerouslySetInnerHTML を使わずに済みます。ブログやドキュメントのページで、コードブロックをシンタックスハイライトして見やすく表示したいときに使えます。
使う言語とテーマだけを読み込む highlighter を作り、それを使ってコードを表示するコンポーネントを TypeScript で書きました。ライト・ダークの2つのテーマの切り替え、色分けの準備ができる前の表示、読み込んでいない言語を渡したときの動き、読み込む JS の大きさを、ブラウザで動かして確かめています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは shiki 4.6.0(@shikijs/langs と @shikijs/themes も 4.6.0)、hast-util-to-jsx-runtime 2.3.7、react / react-dom 19.3.1、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました(読み込む JS の大きさは本番用ビルドで測りました)。試した言語は TSX と CSS で、Firefox と Safari、スマートフォンのブラウザ、サーバーでの描画(SSR や Server Components)は試していません。

使う言語とテーマだけを読み込む highlighter を作る
npm install shiki @shikijs/langs @shikijs/themes hast-util-to-jsx-runtime
shiki から codeToHtml を読み込む書き方がいちばん手軽ですが、公式ドキュメントのパフォーマンスについてのページでは、Web アプリでは shiki/core から必要なものだけを組み合わせる書き方(Fine-grained Bundle)を勧めています。この記事でもその書き方にしました。
// highlighter.ts
import { createHighlighterCore, type HighlighterCore } from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
let loading: Promise<HighlighterCore> | null = null;
export function loadHighlighter(): Promise<HighlighterCore> {
loading ??= createHighlighterCore({
themes: [
import('@shikijs/themes/github-light'),
import('@shikijs/themes/github-dark'),
],
langs: [
import('@shikijs/langs/tsx'),
import('@shikijs/langs/css'),
import('@shikijs/langs/json'),
],
engine: createJavaScriptRegexEngine(),
});
return loading;
}
createHighlighterCore は、渡したテーマと言語をすべて読み込んでから highlighter を返します。読み込み終わったあとは、codeToHtml や codeToHast を同期的に呼べます(公式ドキュメントの、最初にすべて読み込む動きの説明)。highlighter を作る処理は重いので、公式ドキュメントでは、1つ作ってアプリ全体で使い回すよう勧めています。上のコードでは、作りかけの Promise を loading に入れておき、2回目からはそれを返します。
engine には、JavaScript の RegExp で文法の正規表現を動かすエンジンを選びました。もう1つの Oniguruma エンジンは WebAssembly で動きます。公式ドキュメントでは、ブラウザで動かすときや、読み込む量を抑えたいときは JavaScript のエンジンが向いているとされています(公式ドキュメントの正規表現エンジンの説明)。
highlighter を待って、コードを React の要素にする
highlighter の読み込みを待つ部分は、フックに分けました。
// useHighlighter.ts
import { useEffect, useState } from 'react';
import type { HighlighterCore } from 'shiki/core';
import { loadHighlighter } from './highlighter';
export function useHighlighter(): HighlighterCore | null {
const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null);
useEffect(() => {
let ignore = false;
loadHighlighter().then((h) => {
if (!ignore) setHighlighter(h);
});
return () => {
ignore = true;
};
}, []);
return highlighter;
}
コードを表示するコンポーネントでは、highlighter の準備ができたら codeToHast で HTML の木を作り、hast-util-to-jsx-runtime の toJsxRuntime で React の要素に変えます。この組み合わせは、Shiki の公式ドキュメントの Next.js のページにある Custom Components の例と同じです。
// CodeBlock.tsx
import { Fragment, useMemo } from 'react';
import { jsx, jsxs } from 'react/jsx-runtime';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import { useHighlighter } from './useHighlighter';
type Props = { code: string; lang: string };
export function CodeBlock({ code, lang }: Props) {
const highlighter = useHighlighter();
const highlighted = useMemo(() => {
if (!highlighter) return null;
const loaded = highlighter.getLoadedLanguages().includes(lang);
const hast = highlighter.codeToHast(code, {
lang: loaded ? lang : 'text',
themes: { light: 'github-light', dark: 'github-dark' },
});
return toJsxRuntime(hast, { Fragment, jsx, jsxs });
}, [highlighter, code, lang]);
return (
highlighted ?? (
<pre className="shiki-plain">
<code>{code}</code>
</pre>
)
);
}
使うときは <CodeBlock code={文字列} lang="tsx" /> のように書きます。
検証環境で TSX のコード(6行)を表示すると、<pre> の class は shiki shiki-themes github-light github-dark になり、tabindex="0" が付いていました。1行ごとに <span class="line"> があり、6行合わせて 28 個の、色の付いた <span> が並んでいました。たとえば export の <span> には、color: rgb(215, 58, 73); --shiki-dark: #F97583; の style が付いていました。
色分けの準備ができるまでは、そのままの文字列を出す
highlighter の準備ができるまでは、highlighted が null なので、コードをそのまま <pre><code> に入れて出します。読み込みを止めて確かめると、4つのコードがすべてこの表示になり、テーマと言語のファイルを返したあとで、すべて色分けした表示に変わりました。
検証用のページでは、<pre> の文字の大きさ・行の高さ・余白を CSS でそろえておきました。この条件では、TSX のコードの <pre> の高さは、色分けの前もあとも 156.78px で変わりませんでした。高さが変わらないので、色分けが終わっても、その下の内容は押し下げられません。
ダークモードでは CSS 変数の色に切り替える
codeToHast に theme ではなく themes: { light, dark } を渡すと、ライトのテーマの色が color に、ダークのテーマの色が --shiki-dark という CSS 変数に入ります。背景色も同じで、<pre> には --shiki-dark-bg: #24292e が付いていました。
この変数を使う CSS は、自分で書きます。公式ドキュメントの Light/Dark Dual Themes にある、OS の設定に合わせる書き方を、コメントを除いてそのまま使いました。
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
ライトモードで表示すると、export の色は rgb(215, 58, 73) で、<pre> の背景は白でした。Playwright でダークモードにして表示すると、export の色は rgb(249, 117, 131)(#F97583)に、背景は rgb(36, 41, 46)(#24292e)に変わりました。
サイトのボタンでテーマを切り替える場合は、html.dark のような class で切り替える書き方も、同じ節に載っています(Class-based Dark Mode)。
読み込んでいない言語は、色を付けずに出す
createHighlighterCore で読み込んでいない言語を codeToHast に渡すと、例外になります。highlighter を直接呼んで試したところ、python を渡すと ShikiError: Language `python` not found, you may need to load it first が投げられました。React では、描画の中で投げられた例外を Error Boundary で受け止めないと、画面の表示が消えます(React の公式リファレンスの Error Boundary の説明)。そのため、CodeBlock では、渡された言語が読み込まれているかを getLoadedLanguages() で調べています。
検証環境で getLoadedLanguages() が返したのは ["tsx","css","json"] でした。読み込んでいない言語のときは 'text' を渡しています。lang="python" を渡した CodeBlock は、例外にならず、色の付かない <span> 1つに print("hello") を入れて表示しました。text は色分けをしない指定で、使えない言語が渡されたときの代わりに使えると、公式ドキュメントの Plain Text の節に書かれています。
lang の名前は、読み込んだ言語の名前と一致している必要があります。上の highlighter では、typescript や ts を渡しても、一覧にないので色は付きません。
コードの中の HTML のタグは、文字として出る
toJsxRuntime は、hast の木から React の要素を作ります。HTML の文字列をページに入れるわけではないので、コードに HTML のタグが書かれていても、そのまま文字として表示されます。
<img src="x" onerror="alert('XSS')"> という文字列を lang="tsx" で表示したところ、画面にはこの文字列が色分けされて出ました。ページの <main> の中に <img> 要素は1つもなく、alert のダイアログも開きませんでした。
codeToHtml で HTML の文字列を作った場合も、Shiki は < を < に変えていました。公式ドキュメントの Next.js のページのように、その文字列を dangerouslySetInnerHTML で入れる方法もあります。React の要素にしておくと、toJsxRuntime の components で <pre> を自分のコンポーネントに差し替えることもできます。
読み込む JS の大きさを、3つの書き方で比べる
本番用ビルド(esbuild の minify あり)で、コードの色分けが終わるまでにブラウザが読み込んだ JS を測りました。この記事の書き方は、ここまでの検証用のページ(4つのコードを並べたページ)で測りました。ほかの書き方は、TSX のコード1行だけを表示するページで測りました(Shiki を使わないページは、同じ1行をそのまま表示)。Shiki を使う書き方では、テーマはどれもライトとダークの2つです。gzip の大きさは、読み込んだファイルを Node.js の zlib(レベル 9)で圧縮して測ったものです。
| 書き方 | ファイル数 | 大きさ | gzip |
|---|---|---|---|
| Shiki を使わない(React だけ) | 1 | 223,572 バイト | 69,128 バイト |
shiki/core と JavaScript のエンジン(この記事の書き方) |
7 | 657,170 バイト | 162,326 バイト |
shiki/core と Oniguruma のエンジン |
8 | 804,935 バイト | 299,428 バイト |
shiki の codeToHtml |
7 | 778,880 バイト | 295,275 バイト |
Oniguruma のエンジンにすると、WebAssembly を文字列で埋め込んだファイル(211,991 バイト)が増えました。shiki の codeToHtml でも、同じ大きさのファイルを読み込んでいました。
テーマと言語のファイルの中でいちばん大きいのは、TSX の文法のファイルで、175,562 バイトありました。createHighlighterCore は、渡した言語をすべて最初に読み込みます。TSX のコードだけを表示する Oniguruma のエンジンのページでも、CSS の文法(53,391 バイト)と JSON の文法(2,850 バイト)を読み込んでいました。codeToHtml のほうは、渡された言語とテーマだけをその場で読み込むので(公式ドキュメントの Use Shorthands の説明)、CSS と JSON の文法は読み込みませんでした。createHighlighterCore には、実際に表示する言語だけを渡しておくと、最初に読み込む量を減らせます。
ビルドが出力するファイルの数にも差がありました。この記事の書き方では 7 個、合わせて 657,170 バイトでした。shiki から読み込んだ場合は、すべての言語とテーマのファイルが出力され、389 個、合わせて 9,978,738 バイトになりました。ブラウザが読み込むのはこのうち使うものだけですが、配信するファイルは増えます。





