banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Shiki 性能最適化 - 必要に応じた読み込み構文解析

Shiki は非常に優れたコードハイライトライブラリです。

Github Repo not found

The embedded github repo could not be found…

よく知られているように、コードハイライトライブラリには多くの言語パーサーが含まれており、読み込みが遅くなり、無駄なパーサーを読み込むことで大量のトラフィックが浪費されます。

このセクションでは、Shiki に必要な言語パーサーをオンデマンドで読み込む方法を実装します。

動的読み込み#

始める前に、いくつかの準備作業を行う必要があります。

Shiki コンポーネントの定義はこのようになります。

```tsx import { bundledLanguages, getHighlighter } from 'shiki' import type { FC } from 'react'

import { codeHighlighter } from './core'

const highlighter = await getHighlighter({
themes: [
import('shiki/themes/github-light.mjs'),
import('shiki/themes/github-dark.mjs'),
],
langs: Object.keys(bundledLanguages),
})
export const HighLighter: FC<{
lang: string
content: string
}> = async (props) => {
const { lang: language, content: value } = props

return (
  <div
    dangerouslySetInnerHTML={{
      __html: codeHighlighter(highlighter, {
        attrs: '',
        code: value,
        lang: language || '',
      }),
    }}
  />
)

}


</Tab>
<Tab label="page.tsx">

```tsx
const HighLighter = dynamic(() =>
  import('../../components/Shiki/Shiki').then((mod) => mod.HighLighter),
)

export default () => {
  return (
    <HighLighter lang="javascript" content="console.log('Hello, World!')" />
  )
}

このように、Shiki コンポーネントはページ上で遅延読み込みされ、ページが読み込まれた後に Shiki が読み込まれますが、すべての Shiki bundledLanguages を参照しているため、たとえ一つの言語しか使用しなくても全パッケージが読み込まれます。

image

ページの読み込みは少し速くなりますが、トラフィックの浪費問題は解決されていません。

オンデマンドでの言語パーサーの読み込み#

Shiki では、必要な言語を動的に読み込む方法が提供されており、一度にすべてを読み込む必要はありません。

getLoadedLanguages + getLoadedLanguages を使用して、この機能を実現するコンポーネントをラップできます。

上記のコードを改造します:

'use client'
import { use } from 'react'

const codeHighlighterPromise = (async () => {
  if (typeof window === 'undefined') return
  const [{ getHighlighterCore }, getWasm, { codeHighlighter }] =
    await Promise.all([
      // Next.js 14.2.x に問題があるため、ここではフロントエンドで dynamic import する必要があります
      import('shiki/core'),
      import('shiki/wasm').then((m) => m.default),
      import('./core'),
    ])

  const core = await getHighlighterCore({
    themes: [
      import('shiki/themes/github-light.mjs'),
      import('shiki/themes/github-dark.mjs'),
    ],
    langs: [],// ここでは言語を読み込まない
    loadWasm: getWasm,
  })

  return {
    codeHighlighter: core,
    fn: (o: { lang: string; attrs: string; code: string }) => {
      return codeHighlighter(core, o)
    },
  }
})()

export const HighLighter: FC<{
  lang: string
  content: string
}> = (props) => {
  const highlighter = use(codeHighlighterPromise) // `use` を使用して Promise の解決を待つ
  const { lang: language, content: value } = props
  use(
    useMemo(async () => {
      async function loadShikiLanguage(language: string, languageModule: any) {
        const shiki = highlighter?.codeHighlighter
        if (!shiki) return
        if (!shiki.getLoadedLanguages().includes(language)) { // 読み込みが必要かどうかを判断
          await shiki.loadLanguage(await languageModule())
        }
      }

      const { bundledLanguages } = await import('shiki/langs')

      if (!language) return
      const importFn = (bundledLanguages as any)[language]
      if (!importFn) return
      return loadShikiLanguage(language || '', importFn) // ここで動的に lang を読み込む
    }, [highlighter?.codeHighlighter, language]),
  )
  const highlightedHtml = useMemo(() => {
    return highlighter?.fn?.({
      attrs: '',
      code: value,
      lang: language ? language.toLowerCase() : '',
    })
  }, [language, value, highlighter])
  return (
    <div
      dangerouslySetInnerHTML={{
        __html: highlightedHtml!,
      }}
    />
  )
}

上記の例では、use フック + Promise の方法を使用して、必要な言語を動的に読み込むことを実現しました。

最後に、リクエスト数を見てみましょう。

image

かなり減りましたね、現在はリクエスト数が 17 になっています。

いくつかの後話#

Next.js App router で Shiki を Server Component で読み込まない場合、つまり動的にレンダリングされたコードのニーズがある場合、Shiki をラップするために Client Component を使用する必要がありますが、現在 App router の Client Component は一時的に top-level await をサポートしていません(おそらく永遠にサポートされないでしょう)。たとえば、上記の例では、use を使用して Shiki HighLighter の完了を待っていますが、その代償は何でしょうか?

Shiki はサーバーサイドレンダリングされることは決してなく、これにより初回表示の CLS(ページの揺れと理解できます)が発生します。上記の例では、Shiki コンポーネントをこのように使用するしかありません。

import { lazy, Suspense } from 'react'

const HighLighter = lazy(() => // lazy または dynamic を使用します。Next.js 14.2.x では dynamic を使用するとエラーが発生しますが、低バージョンでは影響を受けません
  import('../../components/Shiki/Shiki').then((mod) => ({
    default: mod.HighLighter,
  })),
)

export default () => {
  return (
    <Suspense fallback="Loading...">
      <HighLighter lang="javascript" content="console.log('Hello, World!')" />
    </Suspense>
  )
}

結果は、リフレッシュ後に Suspense fallback Loading が表示され、その後レンダリングされるというプロセスがブラウザ内で完了します。

image

CLS を回避するための現在の解決策は、Suspense 内の fallback が事前に占有されることだけです。ハイライトの変換は視覚的な差異が発生しますが、少なくともページの揺れによる誤操作を避けることができます。

Note

下記の解決策は、あなたのコンポーネントの使用者が Client Component のみである場合に適用されます。したがって、以下の例では、このページは Server Component ではないと仮定します。なぜなら、ページが Server Component であれば、この解決策は無意味だからです。Server Component であれば、Suspense fallback 内で Shiki の非同期レンダリングによって生成されたコード構造をレンダリングできます。


実際には、ビジネスはこれよりもはるかに複雑です。


したがって、動的コンポーネントのネストが多すぎる場合は、Next.js App Router アーキテクチャを使用すべきではありません。少なくとも今はそうです。

import { lazy, Suspense } from 'react'

const HighLighter = lazy(() =>
  import('../../components/Shiki/Shiki').then((mod) => ({
    default: mod.HighLighter,
  })),
)

export default () => {
  const code = `console.log('Hello, World!')`
  return (
    <Suspense
      fallback={
        <pre>
          <code>{code}</code>
        </pre>
      }
    >
      <HighLighter lang="javascript" content={code} />
    </Suspense>
  )
}

image

最後に、antfu に感謝します。

https://github.com/shikijs/shiki/issues/658

上記のデモは、ShiroShiki コンポーネント から簡略化されています。

https://github.com/Innei/Shiro/blob/6f75a2e66cfaf669c0762d6b478dee7e18ecfb8d/src/components/ui/code-highlighter/shiki/Shiki.tsx

Shiro はシンプルでありながら簡単ではない個人サイトで、すでに阮老師の週刊に登場しています。あなたもぜひ使ってみてください。🥰

Github Repo not found

The embedded github repo could not be found…

この記事は Mix Space によって xLog に同期更新されました。
元のリンクは https://innei.in/posts/dev-story/shiki-dynamic-load-language


読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。