分享一个前段时间开发的小玩具,是一个"网页端在线编辑 + 实时预览"的 Playground,聊一聊为什么会做它以及里面一些比较有意思的技术实现
项目地址:browser-playground
预览截图:

可以看到,他的主要功能就是在页面中嵌入一个代码编辑器和一个实时预览器,类似于一个非常简版的IDE + dev-server,市场其实已经有一些类似的解决方案,但是他们要么太重(CodeSandbox)要么太简陋,于是就有了这款 browser-playground。
1. 背景和简介
如果你做过组件库文档/Design System/内部平台,应该遇到过类似需求:
- 文档里放一段代码示例,读者改两行就能看到效果(live demo)
- 需要多文件结构(
components/*、utils/*),而不是单文件粘贴 - 希望把"可用能力"拆成插件:有的页面只要 React,有的页面要 Vue,有的要表单双向联动
这套 SDK 主要面向"对性能、安全性要求不高的示意代码片段"。它不是严格沙箱:当前预览执行采用 new Function(...),因此不适合直接执行不可信用户代码。
如果你就是想要"在文档页上放个 Live Demo",那它基本就是为你准备的。
使用方式也很简单,以下是一个支持 ts 类型提示的基础示例:
tsx
import { Playground } from '@browser-playground/core';
import { typesPlugin } from '@browser-playground/plugin-types';
<Playground
entryFile="/src/App.tsx"
initialFiles={{
'/src/App.tsx': `export default function App(){ return <div style={{ padding: 8 }}>Hello</div> }`
}}
plugins={[typesPlugin()]}
/>;
除此之外,browser-playground 还提供了一些更为炫酷的效果,比如代码<-->表单的双向联动可以让你的 demo 既适合程序员也适合产品,没法上传视频贴个示意图好了:

2. 设计理念
2.1 轻量化
如果你想要的是一个足够全面的 Web IDE,那市面上已经有很多可供选择的方案,比如 CodeSandbox,browser-playground 的定位是"够用就好"
- 编译在浏览器里完成:
@rollup/browser+@babel/standalone,没有任何服务端 - 预览执行简单直接:
new Function执行 bundle,拿到默认导出 - 默认限制导入:只允许相对/绝对路径;裸导入必须显式允许(防止随便
import 'xxx'失控)
取舍很明确:它要的是"文档 demo 的开发体验",不是"生产级沙箱/大工程构建器"。
2.2 可插拔
playground-core 只提供最基础的 React 语法的编辑和渲染能力,更多能力可以通过插件进行拓展,目前自带的插件包括:
plugins/plugin-vue:Vue SFC 编译 + 高亮plugins/plugin-types:Monaco TS 类型注入(React/JSX + 自定义三方包.d.ts)
你也完全可以自定义插件来拓展 playground 的能力
2.3 可组合
SDK 的核心输入输出其实就几样:
initialFiles/entryFile:虚拟文件系统入口plugins:按需叠加能力dependencies:宿主注入三方包(共享运行时)formValue/onFormValueChange:表单双向联动(SDK 不提供表单 UI)
你可以只要最小闭环,也可以逐步把能力叠上去,你可以自由选择任意的表单方案,也可以自由组合编辑器和渲染器的位置和样式,一切都是可组合的
3. 关键技术实现
3.1 编辑器和虚拟文件系统
编辑器我选择的是 Monaco,也就是 vscode 的背后实现,monaco 提供了完善的能力、健全的生态和很强的拓展能力,完美符合我们的场景
虚拟文件系统建模很朴素:
ts
type VirtualFileSystem = Record<string, string>; // '/src/App.tsx' -> code
关键点不在建模,而在 Monaco:必须把每个虚拟文件注入为 model,否则 ts 语言服务根本不知道还有别的文件存在(Monaco 的 ts 类型解析运行在一个独立的 worker 中)
做法就是在初始化/文件变更时:
- 对每个文件
createModel(code, language, uri) - 已存在则同步内容,并确保语言 id 正确(
.tsx、.vue等)
对于那些外部依赖,例如 react 和一些三方包,plugin-types 里会把 .d.ts 处理成纯文本,通过 typescriptDefaults.addExtraLib 注入,来保证整体的类型完备
3.2 实时渲染
渲染包括两步,一是处理文件系统,二是处理语法转换,整体流程大概是:
transformVirtualFiles(可通过插件扩展):例如.vue-> ESM,并生成虚拟 entry- Rollup 虚拟模块插件:
resolveId:只允许相对/绝对导入load:从files[id]读取源码transform:Babel 把 TS/TSX/JSX 转成 ESM
- 输出 iife:
generate({ format: 'iife', globals }) - 执行:
new Function(...runtimeGlobalNames, code)(...runtimeGlobals),取 default export
为了让 React/Vue 都能跑,我把运行时抽象成两类:
react:default export 是 React Componentdom:default export 是{ mount(el), unmount?() }(Vue 插件走这个)
3.3 "共享运行时"三方包注入
既然 Playground 跑在宿主页面里,那第三方依赖没必要在浏览器里重复打包。
做法是:
- 宿主安装并
import真实模块对象 - 通过
<Playground dependencies={{ dayjs }} />传入 - 编译时把
dayjs加入 Rollupexternal/globals - 执行时把模块对象作为
new Function参数注入进去
这样"三方包能不能用、能用哪些"完全由业务控制,Playground 只负责消费。
3.4 双向联动
映射声明长这样:
ts
// @pg-mapping ['info', 'name']
const name = 'jack';
含义是:name 绑定到 formValue.info.name。
实现上我用 Babel AST 做两件事:
- 表单 -> 代码:定位被标注的变量初始化表达式(
init),按start/end做最小文本替换(避免无意义格式化) - 代码 -> 表单:从 AST 抽取字面量值,合并成 patch,通过
onFormValueChange回传
后续可能会做的一些事情:
- 把文件系统处理、映射、编译等过程放到单独的 worker 里面,主要是防止 playground 的错误上升影响到页面本身
- 更多的插件,比如可以通过 WASM 支持 python、rust 等其他语言
- 更好的编辑体验,比如内置 console、devtools 等
如果你也在做文档网站的 live demo,或者想做一个"让非研发也能改出效果"的示意区,欢迎试试