banner
innei

innei

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

Shiki Performance Optimization - On-demand Loading Syntax Parsing

Shiki is an excellent code highlighting library.

As we all know, code highlighting libraries contain numerous language parsers, which leads to slow loading and a waste of traffic due to loading unnecessary parsers.

In this section, we will implement on-demand loading of language parsers required by Shiki.

Dynamic Loading#

Before we start, we need to do some preparation work.

The definition of the Shiki component is similar to this.

```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!')" />
  )
}

In this way, the Shiki component is lazily loaded on the page, loading Shiki after the page is loaded. However, we reference all Shiki bundledLanguages, which results in loading the entire package even if we only use one language.

image

Although the page loading speed is slightly improved, the problem of wasted traffic is not solved.

On-Demand Loading of Language Parsers#

Shiki provides a method to dynamically load the required languages instead of loading all of them at once.

We can use getLoadedLanguages + getLoadedLanguages to encapsulate a component that implements this functionality.

Let's modify the above code:

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

const codeHighlighterPromise = (async () => {
  if (typeof window === 'undefined') return
  const [{ getHighlighterCore }, getWasm, { codeHighlighter }] =
    await Promise.all([
      // There is a problem with Next.js 14.2.x, so we can only dynamically import here
      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: [],// Do not load any languages here
    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 `use` to wait for Promise resolved
  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)) { // Check if it is loaded
          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) // Dynamically load lang here
    }, [highlighter?.codeHighlighter, language]),
  )
  const highlightedHtml = useMemo(() => {
    return highlighter?.fn?.({
      attrs: '',
      code: value,
      lang: language ? language.toLowerCase() : '',
    })
  }, [language, value, highlighter])
  return (
    <div
      dangerouslySetInnerHTML={{
        __html: highlightedHtml!,
      }}
    />
  )
}

In the above example, we use the use hook + Promise to dynamically load the required languages.

Finally, let's take a look at the number of requests.

image

As you can see, there are fewer requests now, only 17.

Some Final Thoughts#

In the Next.js App router, if we don't use Server Components to load Shiki, which means you have a need for dynamic rendering of code, you must use Client Components to encapsulate Shiki. However, the current Client Components in the App router do not support top-level await (and may never support it). In the example above, I use use to wait for the completion of the Shiki HighLighter, but what is the cost?

Shiki will never be rendered on the server side, which leads to Cumulative Layout Shift (CLS), which can be understood as page shaking. In the example above, we can only use the Shiki component in this way.

import { lazy, Suspense } from 'react'

const HighLighter = lazy(() => // Use lazy or dynamic, in Next.js 14.2.x, using dynamic will cause an error, but it is not affected in lower versions
  import('../../components/Shiki/Shiki').then((mod) => ({
    default: mod.HighLighter,
  })),
)

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

The effect is that after refreshing, the Suspense fallback "Loading..." appears first, and then the rendering appears. This process is also completed in the browser.

image

To avoid CLS, the current solution is to make the fallback in Suspense occupy the space in advance. Although there will still be visual differences in the conversion of highlights, at least it can avoid the page shaking caused by CLS.

Note

The following solution is applicable when the component using your component can only be a Client Component. So in the example below, we assume that this Page cannot be a Server Component, because if the Page is a Server Component, this solution is meaningless. Because if it is a Server Component, we can render the code structure asynchronously rendered by Shiki in the Suspense fallback.


In fact, the actual business is much more complicated than this.


So when you need too many nested dynamic components, you should not use the Next.js App Router architecture, at least not now.

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

Finally, thanks to antfu for providing the solution.

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

The above DEMO is simplified from the Shiro in the Shiki component.

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

Shiro is a simple but not simple personal website. It has even been featured in Ruan Lao Shi's newsletter. Why don't you come and try it out? 🥰

This article is synchronized and updated to xLog by Mix Space
The original link is https://innei.in/posts/dev-story/shiki-dynamic-load-language


Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.