Article·  

Shiki v1.0 的演变

Shiki v1.0 带来了许多改进和特性 - 看看 Nuxt 如何推动 Shiki 的演变!

Shiki 是一个使用 TextMate 语法和主题 的语法高亮器,与驱动 VS Code 的引擎相同。它为你的代码片段提供了最准确和最美观的语法高亮。它由 Pine Wu 在 2018 年创建,当时他是 VS Code 团队的一部分。它最初是作为一个使用 Oniguruma 进行语法高亮的实验开始的。

PrismHighlight.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 走向边缘,通过降低延迟和提高性能使 web 更易于访问。像 CDN 服务器一样,边缘托管服务如 CloudFlare Workers 部署在世界各地。用户从最近的边缘服务器获取内容,而不需要往返数千英里之外的源服务器。它提供了惊人的好处,但也带来了一些权衡。例如,边缘服务器使用受限的运行时环境。CloudFlare Workers 也不支持文件系统访问,通常不保留请求之间的状态。虽然 Shiki 的主要开销是预先加载语法和主题,但这在边缘环境中可能不会很好地工作。

这一切都始于 Sébastien 和我之间的一次聊天。我们试图使 Nuxt Content 工作在边缘上,它使用 Shiki 来高亮代码块。

Sébastien 和 Anthony 之间的聊天记录

我开始通过修补 shiki-es(由 Pooya Parsa 提供的 Shiki 的 ESM 构建)来进行实验,将语法和主题文件转换为 ECMAScript 模块 (ESM),以便构建工具能够理解并打包。这是为了创建 CloudFlare Workers 可以消费的代码包,而无需使用文件系统或进行网络请求。

Before - 从文件系统读取 JSON 资源
import fs from 'fs/promises'

const cssGrammar = JSON.parse(await fs.readFile('../langs/css.json', 'utf-8'))
After - 使用 ESM 导入
const cssGrammar = await import('../langs/css.mjs').then(m => m.default)

我们需要将 JSON 文件包装成 ESM 的内联字面量,这样我们就可以使用 import() 动态导入它们。不同之处在于 import() 是一个标准 JavaScript 特性,可以在任何地方工作,而 fs.readFile 是一个 Node.js 特定的 API,只在 Node.js 中工作。拥有静态的 import() 也会使像 Rollupwebpack 这样的打包器能够构建模块关系图,并 将打包的代码作为块发出

然后,我意识到实际上需要更多才能使其在边缘运行时上工作。由于打包器期望在构建时解析导入(这意味着要支持所有语言和主题),我们需要在代码库中每个语法和主题文件中的所有导入语句列表。这最终会得到一个巨大的打包大小,其中包含许多你可能实际上并不使用的语法和主题。这个问题在边缘环境中尤为重要,打包大小对性能至关重要。

所以,我们需要找到一个更好的折中方案使其工作得更好。

派生 - Shikiji

知道这可能会根本改变 Shiki 的工作方式,并且因为我们不想冒险用我们的实验破坏现有的 Shiki 用户,我开始了 Shiki 的一个分支,名为 Shikiji。我从头重写了代码,同时考虑到之前的 API 设计决策。目标是使 Shiki 运行时不可知,性能高且高效,就像我们在 UnJS 中的哲学一样。

为了实现这一点,我们需要使 Shikiji 完全 ESM 友好,纯净且 可摇树。这一直延伸到 Shiki 的依赖项,如 vscode-onigurumavscode-textmate,它们以 Common JS (CJS) 格式提供。vscode-oniguruma 还包含由 emscripten 生成的 WASM 绑定,其中包含 悬挂的承诺,这将使 CloudFlare Workers 无法完成请求。我们最终通过将 WASM 二进制嵌入到 base64 字符串 中,并将其作为 ES 模块发货,手动重写 WASM 绑定以避免悬挂的承诺,并将 vscode-textmate 进行厂商化,从源代码编译并产生高效的 ESM 输出。

最终结果非常有希望。我们设法让 Shikiji 在任何运行时环境中工作,甚至可以 从 CDN 导入并在浏览器中运行,只需一行代码。

我们还抓住机会改进了 Shiki 的 API 和内部架构。我们从简单的字符串连接转换为使用 hast,为生成 HTML 输出创建了抽象语法树 (AST)。这为公开一个 转换器 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 Module Integration 提案。它已经被集成到 Nitro 以拥有自动化的 WASM 目标 中。我们希望 unwasm 将帮助开发人员在处理 WASM 时获得更好的体验。

总的来说,Shikiji 的重写效果很好。Nuxt ContentVitePressAstro 已经迁移到它。我们收到的反馈也非常积极。

合并回

我是 Shiki 的团队成员,并且不时帮助发布。虽然 Pine 是 Shiki 的负责人,他忙于其他事情,Shiki 的迭代放慢了。在 Shikiji 的实验期间,我 提出了一些改进,这可能有助于 Shiki 获得现代结构。虽然大家普遍同意这个方向,但会有很多工作要做,没有人开始工作。

虽然我们很高兴使用 Shikiji 解决了我们的问题,但我们当然不想看到社区被两个版本的 Shiki 分割。在与 Pine 的一次通话中,我们达成了将两个项目合并为一个的共识:

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

我们非常高兴看到我们在 Shikiji 中的工作已经合并回 Shiki,这不仅对我们自己有用,也使整个社区受益。有了这次合并,它 解决了我们多年来在 Shiki 中约 95% 的开放问题

Shikiji 合并回 Shiki

Shiki 现在也有了 一个全新的文档站点,你还可以在你的浏览器中直接使用它(感谢不可知的方法!)。许多框架现在与 Shiki 内置集成,也许你已经在某个地方使用了它!

Twoslash

Twoslash 是一个集成工具,用于从 TypeScript Language Services 检索类型信息并生成到你的代码片段中。它本质上使你的静态代码片段具有类似于你的 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 在任何运行时中运行。然而,这听起来仍然不是最理想的解决方案,因为它仍然需要进行许多网络请求,这可能会破坏在边缘运行的目的。

在底层工具的几次迭代之后(例如在 @nuxtjs/mdc,Nuxt Content 使用的 markdown 编译器),我们设法采取了混合方法,并制作了 nuxt-content-twoslash,它在构建时运行 Twoslash 并将结果缓存以供边缘渲染。这样,我们可以避免将任何额外的依赖项运送到最终捆绑包中,但仍然在网站上拥有丰富的交互式代码片段:

<script setup>
// 尝试悬停在下面的标识符上以查看类型
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.jsvuejs/language-tools 提供动力。随着 Volar 变得框架不可知,并且框架共同工作,我们期待着看到这样的集成在未来扩展到更多的语法,如 Astro 和 Svelte 组件文件。

集成

如果你想在自己的网站上尝试 Shiki,你可以在这里找到我们已经制作的一些集成:

Shiki 的文档 上查看更多集成

结论

我们在 Nuxt 的使命不仅是为开发人员制作一个更好的框架,而且还要使整个前端和 web 生态系统变得更好。 我们不断推动边界,并支持现代 web 标准和最佳实践。我们希望你享受新的 ShikiunwasmTwoslash 以及我们在使 Nuxt 和 web 更好的过程中制作的许多其他工具。