通过 100+ 技巧学习 Nuxt!
文章·  

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 或文件系统,但它仍然相当复杂,因为你需要手动将语法和主题文件放在你的 bundle 或 CDN 中的某个位置,然后调用 setCDN 方法来告诉 Shiki 从哪里加载这些文件。

这个解决方案并不完美,但至少它使得在浏览器中运行 Shiki 来高亮动态内容成为可能。从那时起,我们一直在使用这种方法 - 直到本文的故事开始。

开始

Nuxt 正在大力推动 Web 到边缘,通过更低的延迟和更好的性能使 Web 更易访问。与 CDN 服务器一样,边缘托管服务(如 CloudFlare Workers)部署在世界各地。用户从最近的边缘服务器获取内容,而无需往返可能远在千里之外的源服务器。虽然它提供了极好的好处,但也带来了一些权衡。例如,边缘服务器使用受限的运行时环境。CloudFlare Workers 也不支持文件系统访问,并且通常不保留请求之间的状态。虽然 Shiki 的主要开销是预先加载语法和主题,但这在边缘环境中效果不佳。

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

Chat History Between Sébastien and Anthony

我通过本地修补 shiki-esPooya Parsa 的 Shiki 的 ESM 构建版本)开始实验,将语法和主题文件转换为 ECMAScript 模块 (ESM),以便构建工具可以理解和打包它们。这样做是为了创建代码 bundle,供 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,仅在 Node.js 中有效。静态地使用 import() 也会使 bundler(如 Rollupwebpack)能够构建模块关系图,并 将 bundle 代码作为 chunks 发出

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

因此,我们需要找到一个更好的折衷方案,使其更好地工作。

分支 - Shikiji

意识到这可能会从根本上改变 Shiki 的工作方式,并且由于我们不想冒着通过我们的实验破坏现有 Shiki 用户的风险,我启动了 Shiki 的一个分支,名为 Shikiji。我从头开始重写了代码,同时牢记之前的 API 设计决策。目标是使 Shikiji 运行时不可知、高性能和高效,就像我们在 UnJS 的理念一样。

为了实现这一点,我们需要使 Shikiji 完全 ESM 友好、纯粹且 可 tree-shaking。这一直追溯到 Shiki 的依赖项,例如 vscode-onigurumavscode-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 ContentVitePressAstro 都已迁移到它。我们收到的反馈也非常积极。

合并回主干

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

虽然我们很高兴使用 Shikiji 来解决我们遇到的问题,但我们当然不希望看到社区因两个不同版本的 Shiki 而分裂。在与 Pine 通话后,我们达成共识,将两个项目合并为一个。

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

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

Shikiji Merged Back to Shiki

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 集成为 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.jsvuejs/language-tools 驱动。随着 Volar 发展为框架无关,框架协同工作,我们期待看到此类集成扩展到更多语法,例如 Astro 和 Svelte 组件文件。

集成

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

Shiki 的文档上查看更多集成

结论

我们在 Nuxt 的使命不仅是为开发人员构建更好的框架,还要使整个前端和 Web 生态系统变得更好。 我们不断突破界限,并支持现代 Web 标准和最佳实践。我们希望你喜欢新的 ShikiunwasmTwoslash 以及我们在使 Nuxt 和 Web 变得更好的过程中制作的许多其他工具。