文章·  

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 采取了不同的方法,即预先高亮(highlighting ahead of time)。它将高亮后的 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 走向边缘计算(Edge),让 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 中有效的 API。将 import() 静态化,也能让打包工具如Rollup等等webpack能够构建模块关系图并将打包代码输出为块(chunks).

随后,我意识到让它在边缘运行时工作其实需要做更多事情。由于打包工具期望导入在构建时是可解析的(意味着为了支持所有语言和主题,我们需要在代码库中的每个语法和主题文件中列出所有导入语句),这最终会导致巨大的包体积,包含许多你可能根本用不到的语法和主题。这个问题在边缘环境中尤为重要,因为包体积对性能至关重要。

因此,我们需要找到一个更好的折中方案。

分支 - Shikiji

深知这可能会从根本上改变 Shiki 的工作方式,而且考虑到我们不想冒着因实验而破坏现有 Shiki 用户的风险,我启动了 Shiki 的一个分支,名为Shikiji。我从零开始重写了代码,同时记住了之前的 API 设计决策。目标是使 Shiki 变得与运行时无关、高性能且高效,这正如我们在 Nuxt 所秉持的哲学一样。UnJS.

为了实现这一目标,我们需要使 Shikiji 完全兼容 ESM、纯净且可树摇(tree-shakable)。这甚至延伸到了 Shiki 的依赖项,如vscode-oniguruma等等vscode-textmate,它们是以Common JS (CJS)格式提供的。vscode-oniguruma 还包含一个由emscripten生成的 WASM 绑定,其中包含悬挂 Promise(dangling promises),会导致 CloudFlare Workers 无法完成请求。最终我们将 WASM 二进制文件嵌入为base64 字符串并将其作为 ES 模块发布,手动重写了 WASM 绑定以避免悬挂 Promise,并移植了 vscode-textmate从其源代码编译并生成高效的 ESM 输出。

最终结果非常有前途。我们设法让 Shikiji 在任何运行时环境下工作,甚至具备了通过 CDN 导入并在浏览器中运行且只需一行代码的能力。

我们也借此机会改进了 Shiki 的 API 和内部架构。我们从简单的字符串拼接切换为使用hast,创建了一个抽象语法树 (AST) 来生成 HTML 输出。这为暴露转换器 API(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 通话后,我们达成了将两个项目合并为一个的共识

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 集成,这也是一种狗粮测试(dog-fooding)并验证可扩展性的方式。有了新的 HAST 内部结构,我们能够将 Twoslash 集成为一个转换器插件,使它在 Shiki 支持的任何地方都能工作,并且可以以一种可组合的方式与其他转换器一起使用。

有了这个,我们开始考虑或许可以让 Twoslash 在你正在浏览的 nuxt.com 上运行。nuxt.com 底层使用Nuxt Content,与其他像 VitePress 这样的文档工具不同,Nuxt Content 提供的优势之一是它能够处理动态内容并运行在边缘环境中。由于 Twoslash 依赖于 TypeScript 以及来自你依赖项的巨大类型模块图,将所有这些东西发送到边缘或浏览器是不理想的。听起来很棘手,但挑战接受!

我们首先想到的是从 CDN 按需获取类型,使用自动类型获取(Auto-Type-Acquisition)技术,你会看到它在TypeScript 游乐场上使用。我们制作了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,这里可以找到我们制作的一些集成:

在以下位置查看更多集成Shiki 文档

结论

我们在 Nuxt 的使命不仅是为开发者构建一个更好的框架,还要让整个前端和 Web 生态系统变得更美好。我们不断突破边界,支持现代 Web 标准和最佳实践。希望你喜欢我们为让 Nuxt 和 Web 变得更好而制作的新的Shiki, unwasm, Twoslash以及许多其他工具。