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 组件。
Shiro 是一个简洁不简单的个人网站,都已经登上阮老师周刊了你还不赶快来用用吗。🥰
https://github.com/Innei/Shiro
此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/dev-story/shiki-dynamic-load-language