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 正在努力將 web 推向邊緣,透過更低的延遲和更好的效能,使 web 更易於存取。與 CDN 伺服器一樣,邊緣託管服務(如 CloudFlare Workers)部署在全球各地。使用者從最近的邊緣伺服器取得內容,而無需往返可能在數千英里之外的原始伺服器。儘管它提供了很棒的優點,但也帶來了一些權衡。例如,邊緣伺服器使用受限的運行時環境。CloudFlare Workers 也不支援檔案系統存取,並且通常不會在請求之間保留狀態。雖然 Shiki 的主要開銷是預先載入文法和主題,但這在邊緣環境中無法很好地運作。
這一切都始於 Sébastien 和我之間的一次聊天。我們試圖讓使用 Shiki 來突顯程式碼區塊的 Nuxt Content 在邊緣上運作。
我開始實驗,在本地修補 shiki-es
(由 Pooya Parsa 構建的 Shiki 的 ESM 版本),將文法和主題檔案轉換為 ECMAScript 模組 (ESM),以便構建工具可以理解和捆綁它。這樣做的目的是為了建立程式碼 bundle,供 CloudFlare Workers 使用,而無需使用檔案系統或發出網路請求。
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 特有的 API,僅在 Node.js 中運作。靜態地使用 import()
也會使捆綁器(如 Rollup 和 webpack)能夠建構模組關係圖,並 將捆綁的程式碼作為 chunks 發出。
然後,我意識到要使其在邊緣運行時環境中運作,實際上需要更多的工作。由於捆綁器期望導入在構建時可解析(意味著為了支援所有語言和主題),我們需要在程式碼庫中每個文法和主題檔案中列出所有導入語句。這將導致一個巨大的 bundle 大小,其中包含一堆您可能實際上不會使用的文法和主題。這個問題在邊緣環境中尤其重要,在邊緣環境中,bundle 大小對於效能至關重要。
因此,我們需要找到一個更好的中間地帶,使其更好地運作。
分支 - Shikiji
了解到這可能會從根本上改變 Shiki 的運作方式,並且由於我們不想冒著透過我們的實驗破壞現有 Shiki 使用者的風險,我啟動了 Shiki 的一個分支,名為 Shikiji。我從頭開始重寫程式碼,同時牢記之前的 API 設計決策。目標是使 Shikiji 成為運行時環境不可知、高效能和高效的,就像我們在 UnJS 的理念一樣。
為了實現這一目標,我們需要使 Shikiji 完全與 ESM 友好、純粹且 tree-shakable。這一直向上追溯到 Shiki 的依賴項,例如 vscode-oniguruma
和 vscode-textmate
,它們以 Common JS (CJS) 格式提供。vscode-oniguruma
還包含由 emscripten
生成的 WASM 綁定,其中包含 懸而未決的 promise,這將導致 CloudFlare Workers 無法完成請求。我們最終將 WASM 二進制檔案嵌入到 base64 字串 中並作為 ES 模組發布,手動重寫 WASM 綁定以避免懸而未決的 promise,並 vendored vscode-textmate
從其原始程式碼編譯並產生高效的 ESM 輸出。
最終結果非常有希望。我們設法讓 Shikiji 在任何運行時環境中運作,甚至可以 從 CDN 導入並在瀏覽器中運行,只需一行程式碼。
我們還藉此機會改進了 Shiki 的 API 和內部架構。我們從簡單的字串串聯切換到使用 hast
,為產生 HTML 輸出建立抽象語法樹 (AST)。這為公開 Transformers API 開啟了可能性,允許使用者修改中間 HAST 並進行許多以前很難實現的酷炫整合。
暗/亮模式支援 是一個經常被要求的功能。由於 Shiki 採取的靜態方法,因此無法在渲染時動態更改主題。過去的解決方案是產生兩次突顯的 HTML,並根據使用者的偏好切換其可見性 - 這效率不高,因為它會重複 payload,或者使用 CSS 變數主題,這會失去 Shiki 擅長的細緻突顯效果。憑藉 Shikiji 擁有的新架構,我退後一步並重新思考了這個問題,並 提出了 分解常見 token 並將多個主題合併為內聯 CSS 變數的想法,這提供了高效的輸出,同時符合 Shiki 的理念。您可以在 Shiki 的文件中了解更多相關資訊。
為了使遷移更容易,我們還建立了 shikiji-compat
相容性層,它使用 Shikiji 的新基礎並提供向後相容性 API。
為了使 Shikiji 在 Cloudflare Workers 上運作,我們遇到了最後一個挑戰,因為它們不支援從內聯二進制數據 啟動 WASM 實例。相反,由於安全原因,它需要導入靜態 .wasm
資源。這意味著我們的「All-in-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 編輯器的 hover 類型資訊。它由 Orta Therox 為 TypeScript 文件網站製作,您可以在那裡找到 此處的原始程式碼。Orta 也為 Shiki v0.x 版本建立了 Twoslash 整合。在那時,Shiki 沒有適當的插件系統,這使得 shiki-twoslash
必須建構為 Shiki 的包裝器,使其有點難以設定,因為現有的 Shiki 整合無法直接與 Twoslash 協同運作。
當我們重寫 Shikiji 時,我們也藉此機會修改了 Twoslash 整合,這也是一種 dog-fooding 並驗證可擴展性的方法。憑藉新的 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 並快取結果以進行邊緣渲染。這樣,我們可以避免將任何額外的依賴項運送到最終 bundle,但仍然可以在網站上擁有豐富的互動式程式碼片段
<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 的使命不僅是為開發人員打造更好的框架,也是為了讓整個前端和 web 生態系統變得更好。 我們不斷突破界限,並支持現代 web 標準和最佳實踐。我們希望您喜歡新的 Shiki、unwasm、Twoslash 以及我們在改進 Nuxt 和 web 的過程中製作的許多其他工具。