首页
/ Slidev 实战:用 vue-runner Demo 为 Monaco Runner 扩展 Vue SFC 实时运行能力

Slidev 实战:用 vue-runner Demo 为 Monaco Runner 扩展 Vue SFC 实时运行能力

2026-09-05 10:04:23作者:戚魁泉Nursing

本文以仓库中的 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 buildpnpm 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*(\{[^}]*\})?(.*)$/

它依次捕获:语言标识(如 vuets)、{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 仅在 slidepresenter 上下文(以及嵌入模式下的 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 原子类 flexp2rounded 等,这些由 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 中追加了 vuetsjshtml 语言:

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

  1. 初始注册表内置 js / ts / javascript / typescript 四种语言,全部指向 runTypeScript——它先经 typescript.transpileModule(ESNext 模块 + ES2022 target)转译,并用 AST 转换器把 import 声明改写为 await import(...),再由 runJavaScript 包装执行;
  2. 随后遍历 #slidev/setups/code-runners(即用户项目 setup/code-runners.ts 导出的 setup 函数),Object.assign(runners, result) 把 vue-runner 注册的 vue 合并进注册表;
  3. run(code, lang, options) 时按语言 id 查表,查不到会抛出 Runner for language "xxx" not found,异常被捕获后统一转为 { error } 输出——所以在幻灯片上跑一个未注册语言的代码块,你会在输出区看到红字报错而不是页面崩溃;
  4. 模块级 createSingletonPromise 保证整个客户端只初始化一次 shiki 高亮器与注册表。

内置 JS/TS runner 还模拟了一个受控 consoleinfo/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.tsdocs/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} 才路由到 vue runner。

小结

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

登录后查看全文
热门项目推荐
相关项目推荐