Shiki 是一個語法突顯器,它使用 TextMate 文法和主題,與 VS Code 使用相同的引擎。它為您的程式碼片段提供了最精確且美觀的語法突顯。它由 Pine Wu 在 2018 年創建,當時他是 VS Code 團隊的一員。它最初是一個使用 Oniguruma 進行語法突顯的實驗。
與現有的語法突顯器(例如 Prism 和 Highlight.js,它們旨在瀏覽器中執行)不同,Shiki 採用了提前突顯的不同方法。它將突顯的 HTML 傳送到客戶端,以零 JavaScript 產生精確且美觀的語法突顯。它很快就開始流行,並成為非常受歡迎的選擇,尤其是在靜態網站產生器和文件網站中。
雖然 Shiki 很棒,但它仍然是一個設計在 Node.js 上執行的函式庫。這表示它僅限於突顯靜態程式碼,並且在處理動態程式碼時會有問題,因為 Shiki 無法在瀏覽器中運作。此外,Shiki 依賴 Oniguruma 的 WASM 二進制檔,以及 JSON 格式的大量文法和主題檔案。它使用 Node.js 檔案系統和路徑解析來載入這些檔案,而這在瀏覽器中是無法存取的。
為了改善這種情況,我發起了這個 RFC,後來以 這個 PR 落地,並在 Shiki v0.9 中發布。雖然它抽象了檔案載入層,根據環境使用 fetch 或檔案系統,但使用起來仍然相當複雜,因為您需要手動將文法和主題檔案放在您的 bundle 或 CDN 中的某處,然後呼叫 setCDN
方法來告訴 Shiki 從哪裡載入這些檔案。
這個解決方案並不完美,但至少它讓 Shiki 能夠在瀏覽器中執行以突顯動態內容。從那時起我們就一直在使用這種方法 - 直到本文的故事開始。
開始
Nuxt 正在努力將 網頁推向邊緣,透過更低的延遲和更好的效能使網路更易於存取。與 CDN 伺服器一樣,邊緣託管服務(例如 CloudFlare Workers)遍布全球。使用者從最近的邊緣伺服器取得內容,而無需往返可能相隔數千英里的來源伺服器。儘管它提供了很棒的好處,但它也帶來了一些權衡。例如,邊緣伺服器使用受限的運行時環境。CloudFlare Workers 也不支援檔案系統存取,並且通常不會在請求之間保留狀態。雖然 Shiki 的主要開銷是預先載入文法和主題,但在邊緣環境中效果不佳。
這一切都始於 Sébastien 和我之間的聊天。我們試圖讓使用 Shiki 來突顯程式碼區塊的 Nuxt Content 在邊緣上工作。
我開始透過在本地修補 shiki-es
(由 Pooya Parsa 建立的 Shiki ESM 版本)進行實驗,以將文法和主題檔案轉換為 ECMAScript 模組 (ESM),以便可以被建置工具理解和捆綁。這樣做的目的是為了建立 CloudFlare Workers 使用的程式碼 bundle,而無需使用檔案系統或發出網路請求。
import fs from 'fs/promises'
const cssGrammar = JSON.parse(await fs.readFile('../langs/css.json', 'utf-8'))
const cssGrammar = await import('../langs/css.mjs').then(m => m.default)
我們需要將 JSON 檔案包裝到 ESM 中作為內嵌的字面值,以便我們可以使用 import()
來動態導入它們。不同之處在於,import()
是一個標準的 JavaScript 功能,可在任何地方運作,而 fs.readFile
是一個僅在 Node.js 中運作的 Node.js 特定 API。靜態地使用 import()
也會讓 Rollup 和 webpack 等打包工具能夠建構模組關係圖,並將捆綁的程式碼以區塊的形式發出。
然後,我意識到,要使其在邊緣執行時環境中運作,實際上需要更多步驟。由於打包工具期望導入在建置時可解析(這表示為了支援所有語言和主題),我們需要在程式碼庫中每個文法和主題檔案中列出所有導入語句。這最終會導致一個龐大的 bundle 大小,其中包含大量您可能實際上不會使用的文法和主題。這個問題在邊緣環境中尤其重要,因為 bundle 大小對於效能至關重要。
因此,我們需要找出更好的中間點,使其更好地運作。
分支 - Shikiji
知道這可能會從根本上改變 Shiki 的運作方式,而且由於我們不想讓我們的實驗冒著破壞現有 Shiki 使用者的風險,所以我建立了一個名為 Shikiji 的 Shiki 分支。我從頭開始重寫了程式碼,同時牢記先前的 API 設計決策。目標是使 Shiki 與執行時環境無關、高效且高效,就像我們在 UnJS 中的理念一樣。
為了實現這一點,我們需要讓 Shikiji 完全與 ESM 相容、純粹且 可 tree-shake。這一直延伸到 Shiki 的依賴項,例如 vscode-oniguruma
和 vscode-textmate
,它們以 Common JS (CJS) 格式提供。vscode-oniguruma
還包含由 emscripten
產生的 WASM 綁定,其中包含懸掛的 Promise,這會導致 CloudFlare Workers 無法完成請求。我們最終將 WASM 二進制檔嵌入到 base64 字串中,並將其作為 ES 模組發佈,手動重寫 WASM 綁定以避免懸掛的 Promise,並且供應商化 vscode-textmate
從其原始碼編譯並產生高效的 ESM 輸出。
最終結果非常有希望。我們設法讓 Shikiji 在任何執行時環境中運作,甚至有可能從 CDN 導入並在瀏覽器中執行,只需一行程式碼。
我們還藉此機會改進了 Shiki 的 API 和內部架構。我們從簡單的字串串聯切換到使用 hast
,為產生 HTML 輸出建立抽象語法樹 (AST)。這開啟了公開 Transformers API 的可能性,讓使用者可以修改中間 HAST 並進行許多以前很難實現的酷炫整合。
支援深色/淺色模式是一個經常被要求的功能。由於 Shiki 採用靜態方法,因此無法在渲染時即時變更主題。過去的解決方案是產生兩次突顯的 HTML,並根據使用者的偏好切換它們的可見性 - 這效率不高,因為它會重複有效負載,或者使用 CSS 變數主題,這失去了 Shiki 非常擅長的精細突顯。藉助 Shikiji 擁有的新架構,我退一步重新思考了這個問題,並想出了將常見標記分解並將多個主題合併為內嵌 CSS 變數的想法,這在與 Shiki 的理念保持一致的同時,提供了高效的輸出。您可以在 Shiki 的文件中了解更多相關資訊。
為了讓遷移更容易,我們還建立了 shikiji-compat
相容層,它使用 Shikiji 的新基礎並提供向後相容性 API。
為了讓 Shikiji 在 Cloudflare Workers 上運作,我們還有最後一個挑戰,因為它們不支援從內嵌的二進制資料啟動 WASM 實例。相反,它需要為了安全起見而導入靜態 .wasm
資產。這表示我們的「全 ESM」方法在 CloudFlare 上效果不佳。這需要使用者額外的工作來提供不同的 WASM 來源,這使得體驗比我們預期的更困難。此時,Pooya Parsa 介入並建立了通用層 unjs/unwasm
,它支援即將推出的 WebAssembly/ES 模組整合提案。它已整合到 Nitro 中以具有自動化的 WASM 目標。我們希望 unwasm
能幫助開發人員在使用 WASM 時獲得更好的體驗。
總體而言,Shikiji 重寫效果良好。Nuxt Content、VitePress 和 Astro 都已遷移到它。我們收到的回饋也非常積極。
合併回主幹
我是 Shiki 團隊的一員,並且不時協助發布版本。雖然 Pine 是 Shiki 的領導者,但他忙於其他事情,Shiki 的迭代速度減慢了。在 Shikiji 的實驗期間,我提出了一些改進,可以幫助 Shiki 獲得現代化的結構。雖然大家普遍同意這個方向,但仍有相當多的工作要做,而且沒有人開始著手。
雖然我們很高興使用 Shikiji 來解決我們遇到的問題,但我們當然不希望看到社群被兩個不同版本的 Shiki 分裂。與 Pine 通話後,我們達成共識,將這兩個專案合併為一個。
我們非常高興看到我們在 Shikiji 的工作成果合併回 Shiki,這不僅對我們自己有益,也造福了整個社群。透過這次合併,它解決了我們在 Shiki 中多年來約 95% 的未解決問題。
Shiki 現在也有了全新的文件網站,您可以在瀏覽器中直接體驗(這歸功於其不可知論的方法!)。許多框架現在都內建了 Shiki 的整合,也許您已經在某處使用了它!
Twoslash
Twoslash 是一個整合工具,可以從 TypeScript 語言服務擷取類型資訊,並將其產生到您的程式碼片段中。它本質上是讓您的靜態程式碼片段具有類似於 VS Code 編輯器的懸停類型資訊。它由 Orta Therox 為 TypeScript 文件網站所建立,您可以在這裡找到原始程式碼。Orta 還為 Shiki v0.x 版本建立了 Twoslash 整合。當時,Shiki 沒有適當的插件系統,這使得 shiki-twoslash
必須建立為 Shiki 的包裝器,使其難以設定,因為現有的 Shiki 整合無法直接與 Twoslash 搭配使用。
當我們重寫 Shikiji 時,我們也藉此機會修改了 Twoslash 整合,這也是一種自我測試並驗證可擴展性的方式。有了新的 HAST 內部結構,我們能夠將 Twoslash 整合為轉換器插件,使其在 Shiki 可以運作的任何地方都能運作,並且可以組合使用其他轉換器。
有了這個,我們開始考慮是否有可能讓 Twoslash 在 nuxt.com 上運作,也就是您正在瀏覽的網站。nuxt.com 底層使用 Nuxt Content,與 VitePress 等其他文件工具不同,Nuxt Content 提供的好處之一是它能夠處理動態內容並在邊緣執行。由於 Twoslash 依賴 TypeScript 以及來自您依賴項的大型類型模組圖,因此將所有這些東西運送到邊緣或瀏覽器並非理想的做法。聽起來很棘手,但我們接受挑戰!
我們首先想到的是從 CDN 按需獲取類型,使用您會在 TypeScript playground 上看到的 Auto-Type-Acquisition 技術。我們建立了 twoslash-cdn
,允許 Twoslash 在任何執行階段運行。但是,這聽起來仍然不是最優的解決方案,因為它仍然需要進行許多網路請求,這可能會違背在邊緣運行的目的。
在對底層工具進行幾次迭代後(例如在 @nuxtjs/mdc
,Nuxt Content 使用的 markdown 編譯器),我們設法採用了混合方法,並建立了 nuxt-content-twoslash
,它在建置時運行 Twoslash,並快取結果以進行邊緣渲染。這樣我們就可以避免將任何額外的依賴項運送到最終的捆綁包中,同時仍然在網站上擁有豐富的互動式程式碼片段。
<script setup>
// Try hover on identifiers below to see the types
const count = useState('counter', () => 0)
const double = computed(() => count.value * 2)
</script>
<template>
<button>Count is: {{ count }}</button>
<div>Double is: {{ double }}</div>
</template>
在此期間,我們還藉此機會與 Orta 一起重構了 Twoslash,使其具有更有效率和現代化的結構。它還允許我們擁有 twoslash-vue
,提供如您在上方所看到的 Vue SFC 支援。它由 Volar.js 和 vuejs/language-tools
提供支援。隨著 Volar 逐漸成為框架不可知論者,以及各框架之間的協同工作,我們期待看到這種整合在未來擴展到更多語法,例如 Astro 和 Svelte 元件檔案。
整合
如果您想在自己的網站上試用 Shiki,您可以在這裡找到我們所建立的一些整合
- Nuxt
- 如果使用 Nuxt Content,Shiki 是內建的。對於 Twoslash,您可以在其之上新增
nuxt-content-twoslash
。 - 如果沒有,您可以使用
nuxt-shiki
將 Shiki 作為 Vue 元件或 composibles 使用。
- 如果使用 Nuxt Content,Shiki 是內建的。對於 Twoslash,您可以在其之上新增
- VitePress
- Shiki 是內建的。對於 Twoslash,您可以使用
vitepress-twoslash
。
- Shiki 是內建的。對於 Twoslash,您可以使用
- 低階整合 - Shiki 為 markdown 編譯器提供官方整合
markdown-it
-markdown-it
的插件rehype
-rehype
的插件
在 Shiki 的文件上查看更多整合
結論
我們在 Nuxt 的使命不僅是為開發人員提供更好的框架,也是為了讓整個前端和網路生態系統變得更好。 我們不斷突破界限,並支持現代網路標準和最佳實踐。我們希望您喜歡新的 Shiki、unwasm、Twoslash 以及我們在使 Nuxt 和網路變得更好的過程中開發的許多其他工具。