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 和我之间的一次聊天。我们试图使 Nuxt Content(它使用 Shiki 来高亮代码块)在边缘上工作。
我通过本地修补 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()
也会使 bundler(如 Rollup 和 webpack)能够构建模块关系图,并 将 bundle 代码作为 chunks 发出。
然后,我意识到实际上需要做的更多才能使其在边缘运行时上工作。由于 bundler 期望导入在构建时是可解析的(这意味着为了支持所有语言和主题),我们需要在代码库中每个语法和主题文件中列出所有 import 语句。这将导致一个巨大的 bundle 大小,其中包含一堆你可能实际上不会使用的语法和主题。这个问题在边缘环境中尤为重要,在边缘环境中,bundle 大小对于性能至关重要。
因此,我们需要找到一个更好的折衷方案,使其更好地工作。
分支 - Shikiji
意识到这可能会从根本上改变 Shiki 的工作方式,并且由于我们不想冒着通过我们的实验破坏现有 Shiki 用户的风险,我启动了 Shiki 的一个分支,名为 Shikiji。我从头开始重写了代码,同时牢记之前的 API 设计决策。目标是使 Shikiji 运行时不可知、高性能和高效,就像我们在 UnJS 的理念一样。
为了实现这一点,我们需要使 Shikiji 完全 ESM 友好、纯粹且 可 tree-shaking。这一直追溯到 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 编辑器的悬停类型信息。它由 Orta Therox 为 TypeScript 文档站点制作,你可以在那里找到 这里的原始源代码。Orta 还为 Shiki v0.x 版本创建了 Twoslash 集成。那时,Shiki 没有合适的插件系统,这使得 shiki-twoslash
不得不构建为 Shiki 的包装器,使其有点难以设置,因为现有的 Shiki 集成无法直接与 Twoslash 一起工作。
当我们在重写 Shikiji 时,我们也借此机会修改了 Twoslash 集成,这也是一种 dog-fooding 和验证可扩展性的方式。借助新的 HAST 内部结构,我们能够 将 Twoslash 集成为 transformer 插件,使其在 Shiki 工作的所有地方都能工作,并且以可组合的方式与其他 transformer 一起使用。
有了这个,我们开始考虑我们也许可以让 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 变得更好的过程中制作的许多其他工具。