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


加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。