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 是一個簡潔不簡單的個人網站,都已經登上阮老師週刊了你還不趕快來用用嗎。🥰
此文由 Mix Space 同步更新至 xLog
原始鏈接為 https://innei.in/posts/dev-story/shiki-dynamic-load-language