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 或文件系统,但它仍然相当复杂,因为您需要手动在您的捆绑包或 CDN 中的某个位置提供语法和主题文件,然后调用 setCDN
方法来告诉 Shiki 在哪里加载这些文件。
该解决方案并不完美,但至少它使 Shiki 可以在浏览器中运行以高亮显示动态内容。从那时起,我们就一直使用这种方法 - 直到本文的故事开始。
开始
Nuxt 正在大力推动 web 到边缘,通过更低的延迟和更好的性能使网络更易于访问。与 CDN 服务器一样,诸如 CloudFlare Workers 之类的边缘托管服务已在全球部署。用户从最近的边缘服务器获取内容,而无需往返可能远在千里之外的原始服务器。在它提供的强大优势的同时,它也带来了一些权衡。例如,边缘服务器使用受限制的运行时环境。CloudFlare Workers 也不支持文件系统访问,并且通常不保留请求之间的状态。虽然 Shiki 的主要开销是提前加载语法和主题,但这在边缘环境中效果不佳。
一切都始于 Sébastien 和我之间的聊天。我们试图使使用 Shiki 来高亮代码块的 Nuxt Content 在边缘上工作。
我首先通过本地修补 shiki-es
(由 Pooya Parsa 构建的 Shiki 的 ESM 版本) 来进行实验,将语法和主题文件转换为 ECMAScript 模块 (ESM),以便构建工具可以理解和捆绑它。这样做是为了创建 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 之类的捆绑器能够构建模块关系图,并将捆绑的代码作为代码块发出。
然后,我意识到要使其在边缘运行时上工作,实际上需要的不仅仅是这些。由于捆绑器期望导入在构建时可解析(这意味着为了支持所有语言和主题),我们需要在代码库中的每个语法和主题文件中列出所有导入语句。这将导致一个巨大的捆绑包大小,其中包含许多您可能实际上不会使用的语法和主题。这个问题在边缘环境中尤为重要,因为捆绑包大小对于性能至关重要。
因此,我们需要找到一个更好的折衷方案,以使其更好地工作。
分支 - Shikiji
知道这可能会从根本上改变 Shiki 的工作方式,并且由于我们不想冒险通过我们的实验来破坏现有的 Shiki 用户,我启动了 Shiki 的一个分支,称为 Shikiji。我在保持先前 API 设计决策的同时,从头开始重写了代码。目标是使 Shiki 与运行时无关、高性能和高效,就像我们在 UnJS 的理念一样。
为了实现这一目标,我们需要使 Shikiji 完全兼容 ESM、纯粹且 可摇树。这包括 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
资产。这意味着我们的“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 集成,这也是一种 自我实践 并验证其可扩展性的方式。借助新的 HAST 内部结构,我们能够将 Twoslash 集成作为转换器插件,使其可以在 Shiki 可以工作的任何地方工作,并且可以与其他转换器组合使用。
有了这个,我们开始考虑是否可以让 Twoslash 在您正在浏览的网站 nuxt.com 上运行。nuxt.com 在底层使用 Nuxt Content,与 VitePress 等其他文档工具不同,Nuxt Content 提供的好处之一是它能够处理动态内容并在边缘运行。由于 Twoslash 依赖于 TypeScript 以及来自您的依赖项的庞大类型模块图,将所有这些东西运送到边缘或浏览器中并不理想。听起来很棘手,但我们接受了这个挑战!
我们首先想到的是使用 自动类型获取 技术,从 CDN 按需获取类型,您将在 TypeScript playground 上看到该技术。我们制作了 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 组件或组合式函数。
- 如果使用 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 的过程中制作的许多其他工具。