🎨

【React + Vite】Prism.jsでコードハイライトを実装する

2025/02/07に公開

はじめに

この記事では、Viteで動いているReactプロジェクトに、Prism.jsというライブラリを使ってコードハイライトを実装します。

この際、以下の方針で進めます。

  • ハイライトする言語は動的に決める
  • 既存のCDNは使わない
  • Prism.js以外のライブラリは導入しない

Prism.jsの使い方

Prism.jsでコードハイライトをするには、以下が必要です。

  • Prism.js本体: 言語ファイルを元にコードを解析し、spanタグを追加する
  • 言語ファイル: それぞれの言語の解析方法が書かれたファイル
  • カラーテーマ: 解析されたspanタグを元にスタイリングする

これらはPrism.js内に含まれているので、自分で用意する必要はありません。
詳細は過去に記事にしているので、こちらをご覧ください。

Prism.jsを使ってみる

まずはPrism.jsを動かし、最低限のハイライトができるようにします。

インストール

Prism.jsを使うには2つの方法があります。

今回はCDNを使わない方針のため、prismjsパッケージをインストールして使います。

bun add prismjs @types/prismjs

Highlightコンポーネントを作る

続いて、渡されたコードをハイライトするHighlightコンポーネントを作ります。
このコンポーネントは以下のpropsを受け取ります。

  • children: ハイライトしたいコードの文字列
  • lang: コードの言語、必須

また、以下の処理を行います。

  1. useRefを使ってcode要素のDOMオブジェクトを取得する
  2. useLayoutEffectで↓の処理を副作用として実行する
  3. Prism.jsのhighlightElement関数を、1を使って呼び出す
イメージ
function Highlight() {
  // code要素が入るref
  const ref = useRef<HTMLElement>(null)
  useLayoutEffect(() => {
    // 要素をハイライトする
    if (ref.current) highlightElement(ref.current)
  }, [children, lang])

  return (/* 略 */)
}

useLayoutEffect

useLayoutEffectuseEffectと大体同じですが、副作用の実行タイミングが少し違います。

  • useEffect: ブラウザが画面を再描画した後に実行される
  • useLayoutEffect: ブラウザが画面を再描画する前に実行される

今回の場合、highlightElementの呼び出しにはDOMオブジェクトが必要です。
ですが、ハイライト前の要素をブラウザに描画してほしくはありません。
そのため、その中間で実行されるuseLayoutEffectを使っています。

実装

これを実装したものがこちらです。

src/Highlight.tsx
import { highlightElement } from "prismjs";
import { useLayoutEffect, useRef } from "react";

export default function Highlight({ children, lang }: {
  children: string
  lang: string
}) {
  const ref = useRef<HTMLElement>(null)
  useLayoutEffect(() => {
    if (ref.current) highlightElement(ref.current)
  }, [children, lang])

  return (
    <pre>
      <code className={`lang-${lang}`} ref={ref}>
        {children}
      </code>
    </pre>
  )
}

カラーテーマを選ぶ

Prism.jsを使うには、カラーテーマ(各トークンの色を指定するCSSファイル)が必要です。
これは自作することもできますが、今回は配布されているものを使います。

カラーテーマのCSSファイルはprismjsパッケージ内に含まれているので、import文を追加するだけで読み込むことができます。

src/Highlight.tsx
// カラーテーマを読み込む
import "prismjs/themes/prism.min.css";

カラーテーマ一覧は公式サイトの右ボタンから見ることができます。


準備ができたので、Highlightコンポーネントを使ってみます。
呼び出し例はこちらです。

src/App.tsx
export default function App() {
  return (
    <main>
      <p>普通の文章</p>
      <Highlight lang="js">
        console.log('Hello World!')
      </Highlight>
    </main>
  )

すると、ブラウザに以下が表示されると思います。
ハイライトの様子

プラグインを追加する

Prism.jsのプラグインにはいろいろなものがありますが、ここでは下記を導入します。

  • autoloader: 必要な言語ファイルを自動で読み込む
  • normalize-whitespace: 余分な空白を削除する

一覧はこちら

やり方

これらのプラグインはPrism.jsのnpmパッケージに含まれています。
そのため、普通にimportするのが一番簡単です。

src/Highlight.tsx
// カラーテーマ
import "prismjs/themes/prism.min.css";

// プラグイン
import "prismjs/plugins/autoloader/prism-autoloader.js";
import "prismjs/plugins/normalize-whitespace/prism-normalize-whitespace.min.js";
外部ライブラリを活用する

プラグインやカラーテーマの設定を簡単に行う方法として、vite-plugin-prismjsがあります。
これはbabel-plugin-prismjsをVite向けにラップしたもので、設定をvite.config.tsから行えます。

設定例:

vite.config.ts
// ...
import prism from 'vite-plugin-prismjs'

export default defineConfig({
  plugins: [
    react(),
    prism({
      plugins: ['autoloader', 'normalize-whitespace'],
      theme: 'tomorrow',
      css: true,
    })
  ],
})

normalize-whitespaceの効果

このプラグインは、コードに余分な余白が含まれていた場合削除してくれます。

例えば、以下のようにコードを2行に増やしてみます。
Reactの仕様?によりデフォルトだと改行が反映されないため、文字列になるよう`で囲っています。

<Highlight lang="js">{`
  const message = 'Hello World!'
  console.log(message);
`}</Highlight>

normalize-whitespaceを導入していない場合、余分な空白が上下左右に入ってしまいます。
しかし導入している場合、以下のように余分な空白がない状態で表示されます。

normalize-whitespaceの導入風景

autoloaderが動くように設定する

先ほど導入したautoloaderですが、実は今のままだと動いていません。
これは言語ファイルへのパスが間違っているためです。

このままだとTypeScriptやTSXなどがハイライトできないので、修正する必要があります。

なおautoloaderは、動的に言語ファイルを読み込むためのプラグインです。
言語ファイルは各言語のハイライト方法が書かれたファイルで、ハイライトにはそのコードの言語に対応した言語ファイルが必要になります。

通常、autoloader/componentsというURLから言語ファイルを探します。
しかし、今はVite上で動かしているため、そもそも言語ファイルは公開されていません。

対処方法

この問題に対処する方法は2つあります。

  • localhostに言語ファイルを公開する
  • 既存のCDNを活用する

今回は既存のCDNは使わない方針のため、前者を使います。
具体的には以下のようにします。

  1. Viteの静的アセットとしてnode_modulesにある言語ファイルを配信する
  2. autoloaderの言語ファイルのパスを設定する
  3. TypeScriptなどデフォルトだとない言語をハイライトしてみる

言語ファイルを配信する

これは意外と簡単で、publicディレクトリ内の好きなパスにファイルをコピーするだけです。
ここではpostinstallを使い、bun installnpm install)が行われたときにコピーされるようにしています。

package.json
"scripts": {
  // ...
  "postinstall": "bun run add-prism-languages",
  "add-prism-languages": "mkdir -p public/prismjs/components/ && cp -r node_modules/prismjs/components/ public/prismjs/components/"
}

設定が終わったら、一度bun add-prism-languagesbun installしてコピーしてください。

パスを設定する

次に、言語ファイルまでのパスを設定します。
設定はPrismオブジェクトのplugin.autoloader?.languages_pathプロパティから変更できます。

src/Highlight.tsx
import { highlightElement, plugins } from "prismjs";

// autoloaderの言語ファイルのパスを設定
plugins.autoloader?.languages_path = "/prismjs/components/"

なお、この設定は↑で配信した言語ファイルのパスと揃えてください。


試しにカウンターのコードをハイライトしてみると、このようになると思います。

src/App.tsx
export default function App() {
  return (
    <main>
      <p>普通の文章</p>
      <Highlight lang="jsx">{`
        export default function Counter() {
          const [count, setCount] = useState(0)
          return (
            <div>
              <h3>{count}</h3>
              <button onClick={() => setCount(count + 1)}>add</button>
            </div>
          )
        }
      `}</Highlight>
    </main>
  )
}

カウンターのコードのハイライト

参考

https://paulund.co.uk/add-prismjs-using-vite
https://www.npmjs.com/package/vite-plugin-prismjs?activeTab=dependencies

Discussion