xChar
·5 months ago

Shiki 是一个非常优秀的代码高亮库。

https://github.com/shikijs/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 导致即便我们只使用到了一种语言也加载全量的包。

虽然页面加载会变快一点,但是流量浪费的问题并没有解决。

按需加载语言解析

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 的方式,实现了动态加载需要的语言。

最后,我们再来看下请求数。

是不是少了很多,现在只有 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 然后再出现渲染,这个过程也是在浏览器中完成。

为了规避 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>
  )
}

最后,感谢 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 是一个简洁不简单的个人网站,都已经登上阮老师周刊了你还不赶快来用用吗。🥰

https://github.com/Innei/Shiro

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


Loading comments...