banner
innei

innei

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

Shiki 性能優化 - 按需加載語法解析

Shiki 是一個非常優秀的代碼高亮庫。

眾所周知,代碼高亮庫包含了眾多的語言解析器,導致加載比較緩慢,並且加載太多無用的解析器造成大量流量浪費。

這一節我們來實現按需加載 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` 去 wait 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)) { // 需要判斷是否加載
          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 hook + Promise 的方式,實現了動態加載需要的語言。

最後,我們再來看下請求數。

image

是不是少了很多,現在只有 17 個請求數了。

一些後話#

在 Next.js App router 中如果不採用 Server Component 去加載 Shiki 的話,也就是說你有類似動態渲染代碼的需求,你就必須使用 Client Component 去封裝 Shiki,但是現在在 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,所以下面的例子中,我們假設此 Page 不能是一個 Server Component,因為如果 Page 是一個 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

上面的 DEMO 簡化於 Shiro 中的 Shiki 組件

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

Shiro 是一個簡潔不簡單的個人網站,都已經登上阮老師週刊了你還不趕快來用用嗎。🥰

此文由 Mix Space 同步更新至 xLog
原始鏈接為 https://innei.in/posts/dev-story/shiki-dynamic-load-language


載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。