文章·  

Shiki v1.0 的演进

Shiki v1.0 带来了许多改进和新功能——看看 Nuxt 如何推动 Shiki 的演进!
Anthony Fu

Anthony Fu

@antfu

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),以便构建工具能够理解和打包它们。这是为了创建 CloudFlare Workers 可以使用的代码包,而无需使用文件系统或发出网络请求。

之前 - 从文件系统读取 JSON 资产
import fs from 'fs/promises'

const cssGrammar = JSON.parse(await fs.readFile('../langs/css.json', 'utf-8'))
之后 - 使用 ESM 导入
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 用户的风险,所以我开始了 Shiki 的一个分支,叫做Shikiji。我从头开始重写了代码,同时牢记了以前的 API 设计决策。目标是使 Shiki 运行时无关、高性能和高效,就像我们在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,创建了一个抽象语法树 (AST) 来生成 HTML 输出。这开启了公开Transformer 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 通话后,我们达成共识,将这两个项目合并为一个

feat!: 将 Shikiji 合并回 Shiki 用于 v1.0 #557

我们非常高兴看到我们在 Shikiji 中的工作已合并回 Shiki,这不仅对我们自己有效,也造福了整个社区。通过这次合并,它解决了 Shiki 多年来大约 95% 的未解决问题

Shiki 现在还拥有一个全新的文档网站,你也可以在浏览器中直接体验(感谢其与运行时无关的方法!)。许多框架现在都内置了与 Shiki 的集成,也许你已经在某个地方使用它了!

Twoslash

Twoslash是一个集成工具,用于从TypeScript 语言服务中检索类型信息并生成到您的代码片段中。它本质上使您的静态代码片段具有与 VS Code 编辑器类似的悬停类型信息。它由Orta TheroxTypeScript 文档站点创建,您可以在此处找到原始源代码。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 在任何运行时中运行。然而,这听起来仍然不是最优解决方案,因为它仍然需要发出许多网络请求,这可能会违背在边缘运行的目的。

经过对底层工具(例如 Nuxt Content 使用的 markdown 编译器@nuxtjs/mdc)进行几次迭代后,我们设法采取了混合方法并创建了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,这里有一些我们已经完成的集成

查看更多集成,请访问Shiki 的文档

结论

Nuxt 的使命不仅是为开发者构建一个更好的框架,而且是让整个前端和网络生态系统变得更好。 我们不断突破界限,支持现代网络标准和最佳实践。我们希望您喜欢新的Shiki, unwasm, Twoslash以及我们在构建 Nuxt 和改善网络过程中创建的许多其他工具。