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 走向边缘,通过降低延迟和提高性能使 web 更易于访问。像 CDN 服务器一样,边缘托管服务如 CloudFlare Workers 部署在世界各地。用户从最近的边缘服务器获取内容,而不需要往返数千英里之外的源服务器。它提供了惊人的好处,但也带来了一些权衡。例如,边缘服务器使用受限的运行时环境。CloudFlare Workers 也不支持文件系统访问,通常不保留请求之间的状态。虽然 Shiki 的主要开销是预先加载语法和主题,但这在边缘环境中可能不会很好地工作。
这一切都始于 Sébastien 和我之间的一次聊天。我们试图使 Nuxt Content 工作在边缘上,它使用 Shiki 来高亮代码块。
我开始通过修补 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 绑定,其中包含 悬挂的承诺,这将使 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 Content、VitePress 和 Astro 已经迁移到它。我们收到的反馈也非常积极。
合并回
我是 Shiki 的团队成员,并且不时帮助发布。虽然 Pine 是 Shiki 的负责人,他忙于其他事情,Shiki 的迭代放慢了。在 Shikiji 的实验期间,我 提出了一些改进,这可能有助于 Shiki 获得现代结构。虽然大家普遍同意这个方向,但会有很多工作要做,没有人开始工作。
虽然我们很高兴使用 Shikiji 解决了我们的问题,但我们当然不想看到社区被两个版本的 Shiki 分割。在与 Pine 的一次通话中,我们达成了将两个项目合并为一个的共识:
我们非常高兴看到我们在 Shikiji 中的工作已经合并回 Shiki,这不仅对我们自己有用,也使整个社区受益。有了这次合并,它 解决了我们多年来在 Shiki 中约 95% 的开放问题:
Shiki 现在也有了 一个全新的文档站点,你还可以在你的浏览器中直接使用它(感谢不可知的方法!)。许多框架现在与 Shiki 内置集成,也许你已经在某个地方使用了它!
Twoslash
Twoslash 是一个集成工具,用于从 TypeScript Language Services 检索类型信息并生成到你的代码片段中。它本质上使你的静态代码片段具有类似于你的 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>
// 尝试悬停在下面的标识符上以查看类型
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 更好的过程中制作的许多其他工具。