Slidev 实战:用 vue-runner Demo 为 Monaco Runner 扩展 Vue SFC 实时运行能力
本文以仓库中的 demo/vue-runner 演示工程为主线,完整讲解 Slidev 的 {monaco-run} 代码块如何把代码变成可运行编辑器,并逐行剖析其中的 Vue SFC Code Runner 实现——从 @vue/compiler-sfc 编译、import 重写、new Function 求值到 createApp 挂载。读完后你可以掌握自定义 Code Runner 的完整机制,并能在自己的 Slidev 演示中运行任意自定义语言代码。
Demo 项目做了什么
vue-runner 是 Slidev 仓库自带的一个最小演示工程,只有一页幻灯片加一个自定义 runner 实现,目的如第 32 行的原文所述:"This is a demo to prove the extensibility of Slidev Code Runners"——证明 Code Runners 机制的可扩展性。
工程结构非常精简:
demo/vue-runner/
├── setup/
│ ├── code-runners.ts # 核心:Vue SFC Runner 的实现
│ └── shiki.ts # 补充 vue 语言的 shiki 高亮
├── slides.md # 演示幻灯片
└── package.json # 依赖与脚本
第一页幻灯片里只有一个 vue {monaco-run} 代码块(见 slides.md),内容是一个完整的 Vue 单文件组件:
<script setup>
import { computed, ref } from 'vue'
const counter = ref(1)
const doubled = computed(() => counter.value * 2)
function inc() { counter.value++ }
</script>
<template>
<div class="select-none text-lg flex gap-4 items-center">
<span class="text-gray text-lg">
<span class="text-orange">{{ counter }}</span>
* 2 =
<span class="text-green">{{ doubled }}</span>
</span>
<button class="border border-main p2 rounded" @click="inc">+1</button>
<button class="border border-main p2 rounded" @click="counter -= 1">-1</button>
</div>
</template>
当 monaco 功能开启时,这个代码块会被转换成 Monaco 编辑器:观众不仅可以在演示中直接编辑这个 SFC,还能点击运行按钮把整个组件实时挂载到幻灯片上,看到计数器工作、doubled 计算属性随 counter 变化——这就是本 demo 想展示的“在幻灯片里跑一个真实 Vue 组件”。
package.json 中依赖了两样关键东西:vue 和 @vue/compiler-sfc(见 demo/vue-runner/package.json),分别供运行时创建组件和编译 SFC 使用。本地运行该 demo 可参考其中的脚本,例如 pnpm dev(使用 nodemon 监听 packages/slidev/dist 变化后启动 slidev ./slides.md)、pnpm build、pnpm export 等。
顺带一提:slides.md 第 34 行提示读者"Refer to
./setup/monaco-runner.ts",但仓库中实际的实现文件是 setup/code-runners.ts,这是 demo 中一处遗留的命名不一致,阅读源码时以实际文件为准。
{monaco-run} 代码块的底层机制
在动手写 Vue Runner 之前,先弄清 {monaco-run} 是如何被解析和渲染的。
语法如何变成 Monaco 组件
代码块标记的解析发生在 Vite 插件侧的 markdown-it 语法转换层。packages/slidev/node/syntax/codeblock/monaco.ts 定义了核心正则:
const RE_MONACO = /^([\w'-]+)?\s*\{(monaco[\w-]*)\}\s*(\{[^}]*\})?(.*)$/
它依次捕获:语言标识(如 vue、ts)、{monaco...} 变体、以及可选的属性对象(如 {autorun:false})。转换器随后:
- 检查全局配置中
config.monaco是否启用(config.monaco === true || config.monaco === mode); - 用 lz-string 将代码
compressToBase64压缩,避免把大段代码原文写进模板; - 当变体为
monaco-run时输出runnable属性,最终生成形如:
<Monaco v-bind="{autorun:false}" runnable lang="vue" code-lz="..." />
也就是说,{monaco-run} 与 {monaco} 的区别仅在于是否携带 runnable 标记,真正决定"能不能跑"的是运行时的 runner 注册表。
CodeRunner 组件:Run 按钮、自动运行与输出展示
携带 runnable 属性的代码块会挂上 packages/client/internals/CodeRunner.vue 组件,它的行为细节包括:
- 运行入口:模板右下角的播放按钮触发
triggerRun,内部是debounce(200, ...),防止连续点击造成并发执行; - autorun 语义:props 中
autorun支持boolean | 'once'。演示模式下autorun为真时会对code建立watch(..., { immediate: true }),即修改代码即重新求值;打印模式下则强制为'once',只跑一次保证截图/导出结果稳定; - 模式限制:
disabled仅在slide、presenter上下文(以及嵌入模式下的overview)为false,在其他渲染上下文中会显示"Code is disabled"; - showOutputAt:与
v-click相同的取值方式,组件通过$clicksContext.calculate注册点击节点,输出区域只在对应点击步激活时显示(见 docs/features/monaco-run.md 中的{showOutputAt:'+1'}示例); - 结果渲染:runner 的返回值可以是 HTML 字符串、错误文本、带高亮语言的文本,或一个 DOM 元素(
element,通过DomElement.vue挂载)——vue-runner 正是利用了最后一种。
核心实现:写一个 Vue SFC Code Runner
打开 demo/vue-runner/setup/code-runners.ts,整个 Vue Runner 只有 38 行。它按 Slidev 约定的入口导出:
import { defineCodeRunnersSetup } from '@slidev/types'
export default defineCodeRunnersSetup(() => {
return {
async vue(code) {
// ...
},
}
})
约定本身定义在 docs/custom/config-code-runners.md:在项目根目录创建 ./setup/code-runners.ts,返回对象中每个 key 是一种语言 id,对应一个 (code, ctx) => outputs 的函数。这里以 vue 为 key,意味着 vue {monaco-run} 代码块会把 SFC 源码交给它。
下面按实现顺序拆解这个函数体(完整代码见 setup/code-runners.ts):
1. 按需引入编译工具链
const Vue = await import('vue')
const { parse, compileScript } = await import('@vue/compiler-sfc')
vue 与 @vue/compiler-sfc 都是浏览器环境可用的包(这也是 demo 的 package.json 把二者列为 devDependencies 的原因),parse 负责解析 SFC 结构,compileScript 负责把 <script setup> 与模板编译为可执行的组件导出。
2. 编译 SFC:内联模板 + 命名导出
const sfc = parse(code)
let scripts = compileScript(sfc.descriptor, {
id: sfc.descriptor.filename,
genDefaultAs: '__Component',
inlineTemplate: true,
}).content
两个编译选项是关键:
inlineTemplate: true:把<template>编译出的渲染函数直接内联进脚本,而不是引用外部的template对象。这样编译产物才是自包含的,无需再单独处理模板编译结果;genDefaultAs: '__Component':<script setup>编译后的默认导出会被命名为__Component,方便后续拼上return __Component。
demo 原文也注明:"Compile the script, note this demo does not handle Vue styles"——<style> 块在这条链路上是被忽略的,样式需写在模板的 class 中(demo 用了 UnoCSS 原子类 flex、p2、rounded 等,这些由 Slidev 全局样式提供)。
3. 重写 Vue 的 import 语句
scripts = scripts.replace(
/import (\{[^}]+\}) from ['"]vue['"]/g,
(_, imports) => `const ${imports.replace(/\sas\s/g, ':')} = Vue`,
)
scripts += '\nreturn __Component'
compileScript 的产物仍是 ES Module 语法,而浏览器里无法直接 import 'vue'。demo 用一个简单的正则替换把 import { ref, computed } from 'vue' 改写为对象解构 const { ref, computed } = Vue,其中 \sas\s → : 的处理兼容了 import { ref as r } 这类重命名写法。随后在脚本末尾追加 return __Component,让整个脚本变成一个"返回组件"的函数体。
4. 求值并挂载组件
// Note this is not sandboxed, it's NOT secure.
const component = new Function(`return (Vue) => {${scripts}}`)()(Vue)
const app = Vue.createApp(component)
const el = document.createElement('div')
app.mount(el)
return { element: el }
new Function(...)把改写后的脚本包装成一个立即调用的工厂函数,参数传入完整的Vue命名空间,拿回编译产物中的组件对象;Vue.createApp(component)+app.mount(el)将组件挂载到一个新建的div;- 返回
{ element: el }命中了CodeRunnerOutputDom这一输出类型,CodeRunner 组件会用DomElement.vue把它放进输出区域,Vue 响应式系统因此照常工作,点击幻灯片上的按钮就能改变计数。
demo 源码在 new Function 上一行明确注释了安全边界:"Note this is not sandboxed, it's NOT secure."——这段代码直接在主线程求值任意代码,只适合本地演示,不应照搬到不可信输入的场景。
5. 让 shiki 认识 vue 语言
Runner 只负责"运行",代码块左侧的语法高亮仍由 shiki 完成。setup/shiki.ts 在 shiki setup 中追加了 vue、ts、js、html 语言:
import { defineShikiSetup } from '@slidev/types'
export default defineShikiSetup((): ShikiSetupReturn => {
return {
langs: ['ts', 'js', 'vue', 'html'],
}
})
缺少这一步时,{monaco-run} 编辑器里 vue 语言可能拿不到正确的文法定义。
Code Runner API:类型与组装链路
demo 用到的 API 在类型层有完整定义,理解它们有助于写更复杂的 runner。
输入:Runner Context
runner 的第二个参数 ctx 类型为 packages/types/src/code-runner.ts 中的 CodeRunnerContext:
export interface CodeRunnerContext {
options: Record<string, unknown> // 代码块上 {runnerOptions:...} 传入的选项
highlight: (code, lang, options?) => string // shiki 高亮
run: (code, lang) => Promise<CodeRunnerOutputs> // 调用其它 runner
}
vue-runner 只用了第一个参数 code;而 ctx.run 允许你在一个 runner 里复用另一个 runner(例如先用 JS runner 转译再执行),ctx.options 则能把代码块上的自定义属性透传到 runner 内部。
输出:五种形态
export type CodeRunnerOutput =
| CodeRunnerOutputHtml // { html } 直接渲染 HTML(注意:Slidev 不做 sanitize,来源须可信)
| CodeRunnerOutputDom // { element } 挂载一个 DOM 元素 ← vue-runner 用的就是它
| CodeRunnerOutputError // { error } 以红色文本显示错误
| CodeRunnerOutputText // { text, class?, highlightLang? } 可带 shiki 高亮
| CodeRunnerOutputText[] // 多行文本
element 形态是"跑一个组件"类需求(Vue、React、Svelte 等)的正确姿势:它把渲染结果直接作为 DOM 插入输出区,事件、响应式都能生效。
组装链路:内置 runner 与用户 setup 如何合并
客户端侧的组装逻辑在 packages/client/setup/code-runners.ts:
- 初始注册表内置
js / ts / javascript / typescript四种语言,全部指向runTypeScript——它先经typescript.transpileModule(ESNext 模块 + ES2022 target)转译,并用 AST 转换器把import声明改写为await import(...),再由runJavaScript包装执行; - 随后遍历
#slidev/setups/code-runners(即用户项目setup/code-runners.ts导出的 setup 函数),Object.assign(runners, result)把 vue-runner 注册的vue合并进注册表; run(code, lang, options)时按语言 id 查表,查不到会抛出Runner for language "xxx" not found,异常被捕获后统一转为{ error }输出——所以在幻灯片上跑一个未注册语言的代码块,你会在输出区看到红字报错而不是页面崩溃;- 模块级
createSingletonPromise保证整个客户端只初始化一次 shiki 高亮器与注册表。
内置 JS/TS runner 还模拟了一个受控 console(info/log/debug/warn/error 都汇入结果列表,clear 清空输出),并支持通过 __slidev_import 从扫描导入的依赖模块中取包——对应 docs/custom/config-code-runners.md 介绍的 monacoRunAdditionalDeps headmatter 选项:
monacoRunAdditionalDeps:
- ./path/to/dependency
- lodash-es
依赖路径相对 snippets 目录解析,包名须与代码中 import 的名字完全一致。
适用边界与生产化注意事项
demo 在第 36 行诚实地给出了限制声明:"there is a lot of edge cases that this demo is not handling. Extra work is needed to make it production ready."。结合源码可以归纳出几条明确的边界:
- 无沙箱:runner 在主线程用
new Function执行用户代码,setup/code-runners.ts与 docs/custom/config-code-runners.md("They run in the browser without a sandbox environment")都强调了这一点。若要跑不可信代码,需要自研 runner 转发到远程服务或 Web Worker; - 不支持
<style>:编译链路只处理了 script 与 template,样式需借助主题全局样式或模板内联; - import 重写是简化版:
scripts.replace(...)的正则只覆盖import {...} from 'vue'这一形态,从其它包导入的写法无法处理(源码注释亦说明"it doesn't work with imports from other packages"); - 语言 id 必须与代码块语言一致:runner 以 key 注册,
vue {monaco-run}才路由到vuerunner。
小结
vue-runner demo 用最少的代码串起了 Slidev Code Runners 的完整链路:{monaco-run} 标记经 monaco.ts 转换器变成 <Monaco runnable> 组件,CodeRunner.vue 提供编辑与执行交互,而 setup/code-runners.ts 里的 38 行实现(SFC 解析 → 内联模板编译 → import 解构化 → new Function 求值 → createApp 挂载 → 返回 { element })展示了如何把任意语言接入这套机制。如果你想在自己的演示里运行其它框架或 DSL,参照 docs/custom/config-code-runners.md 的约定实现一个同构的 runner 即可;更完整的输出类型与上下文定义可查 packages/types/src/code-runner.ts。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00