@vue/repl 是Vue playground的实现的源代码仓库。本文探究下该仓库下到底实现了什么样的功能以及使用到的一些技术和技巧。
页面布局 基础架构
Repl 组件使用了 SplitPane组件 同时分为左右两个编辑面板和输出面板 使用动态slot插入面板组件和编辑组件
Repl.vue
<div class="vue-repl">
<SplitPane :layout="layout">
<template #[editorSlotName]>
<EditorContainer :editor-component="editor" />
</template>
<template #[outputSlotName]>
<Output
ref="output"
:editor-component="editor"
:show-compile-output="props.showCompileOutput"
:ssr="!!props.ssr"
/>
</template>
</SplitPane>
</div>
SplitPane组件
<div
ref="container"
class="split-pane"
:class="{
dragging: state.dragging,
'show-output': store.showOutput,
vertical: isVertical,
}"
@mousemove="dragMove"
@mouseup="dragEnd"
@mouseleave="dragEnd"
>
// 左边区域
<div
class="left"
:style="{ [isVertical ? 'height' : 'width']: boundSplit + '%' }"
>
<slot name="left" />
// 中间拖拽bar
<div class="dragger" @mousedown.prevent="dragStart" />
</div>
// 右边区域
<div
class="right"
:style="{ [isVertical ? 'height' : 'width']: 100 - boundSplit + '%' }"
>
// 右边区域显示宽高view bar
<div v-show="state.dragging" class="view-size">
{{ `${state.viewWidth}px x ${state.viewHeight}px` }}
</div>
<slot name="right" />
</div>
// 切换输出
<button class="toggler" @click="store.showOutput = !store.showOutput">
{{
store.showOutput
? splitPaneOptions?.codeTogglerText || '< Code'
: splitPaneOptions?.outputTogglerText || 'Output >'
}}
</button>
</div>
上述是SplitPane组件的基本构成和功能
编辑器组件 EditorContainer.vue 主要展现文件和文件代码 vue代码等 用于实时操作编辑组件代码,通过外部编辑器实现。一般是 Monaco 编辑器组件实现。
<div class="editor-container">
<props.editorComponent
:value="store.activeFile.code"
:filename="store.activeFile.filename"
@change="onChange"
/>
<Message v-show="showMessage" :err="store.errors[0]" />
<div class="editor-floating">
<ToggleButton
v-if="editorOptions?.showErrorText !== false"
v-model="showMessage"
:text="editorOptions?.showErrorText || 'Show Error'"
/>
<ToggleButton
v-if="editorOptions?.autoSaveText !== false"
v-model="autoSave"
:text="editorOptions?.autoSaveText || 'Auto Save'"
/>
</div>
</div>
Repl 里的store
要实现Playground的功能 Repl库里实现了一个用于管理和维护不同文件内容和文件名称的store 来实现不同tsx jsx 等文件的编译。json文件的解析。importMap 内容的解析和插入。 同时包括tsconfig文件的初始化和加载等逻辑。
当前所有关联的组件内容和相关联的文件都组织在File 对象里面。
store.ts 管理组织File对象
export function useStore(
{
files = ref(Object.create(null)), // files对象
...
}
transform.ts 编译不同类型的文件
compileFile 函数 编译文件类型 /repl/src/transform.ts
css 文件的处理 源代码不做转换
if (filename.endsWith('.css')) {
compiled.css = code
return []
}
tsx jsx 等文件的编译
if (REGEX_JS.test(filename)) {
const isJSX = testJsx(filename)
if (testTs(filename)) {
code = transformTS(code, isJSX)
}
if (isJSX) {
code = await import('./jsx').then(({ transformJSX }) =>
transformJSX(code),
)
}
compiled.js = compiled.ssr = code
return []
}
transformTS 功能
编译ts 用到 sucrase 这个库相对于babel 和ts 原生的编译能力更快。
import { type Transform, transform } from 'sucrase'
function transformTS(src: string, isJSX?: boolean) {
return transform(src, {
transforms: ['typescript', ...(isJSX ? (['jsx'] as Transform[]) : [])],
jsxRuntime: 'preserve',
}).code
}
transformJsx jsx文件使用babel来进行编译。
import { transform } from '@babel/standalone'
import jsx from '@vue/babel-plugin-jsx'
export function transformJSX(src: string) {
return transform(src, {
plugins: [jsx],
}).code!
}
json文件的解析 拼接导出
if (filename.endsWith('.json')) {
let parsed
try {
parsed = JSON.parse(code)
} catch (err: any) {
console.error(`Error parsing ${filename}`, err.message)
return [err.message]
}
compiled.js = compiled.ssr = `export default ${JSON.stringify(parsed)}`
return []
}
vue文件的编译 利用到 vue/compiler-sfc单文件编译库里面的编译能力。
const { errors, descriptor } = store.compiler.parse(code, {
filename,
sourceMap: true,
templateParseOptions: store.sfcOptions?.template?.compilerOptions,
})
if (errors.length) {
return errors
}
....
....
通过上述的编译操作 将vue文件编译成 script style template 三个模块 并分别调用对应的方法处理script template style 模块的代码。
const hasScoped = descriptor.styles.some((s) => s.scoped)
let clientCode = '' // csr 渲染编译之后的代码
let ssrCode = '' // ssr 渲染编译之后的代码
const appendSharedCode = (code: string) => {
clientCode += code
ssrCode += code
}
处理没有加 setup部分的script 代码块
let clientScript: string
let bindings: BindingMetadata | undefined
try {
;[clientScript, bindings] = await doCompileScript(
store,
descriptor,
id,
false,
isTS,
isJSX,
)
} catch (e: any) {
return [e.stack.split('\n').slice(0, 12).join('\n')]
}
处理加了setup 的代码块
if (descriptor.scriptSetup || descriptor.cssVars.length > 0) {
try {
const ssrScriptResult = await doCompileScript(
store,
descriptor,
id,
true,
isTS,
isJSX,
)
ssrCode += ssrScriptResult[0]
} catch (e) {
ssrCode = `/* SSR compile error: ${e} */`
}
} else {
// the script result will be identical.
ssrCode += clientScript
}
template 模板编译的部分
同时编译处理 csr 模板 和ssr 模板
针对jsx的处理
if (isJSX) {
const { transformJSX } = await import('./jsx')
clientCode &&= transformJSX(clientCode)
ssrCode &&= transformJSX(ssrCode)
}
styles 模块处理的部分
最后的代码拼接
if (clientCode || ssrCode) {
const ceStyles = isCE
? `\n${COMP_IDENTIFIER}.styles = ${JSON.stringify(styles)}`
: ''
appendSharedCode(
`\n${COMP_IDENTIFIER}.__file = ${JSON.stringify(filename)}` +
ceStyles +
`\nexport default ${COMP_IDENTIFIER}`,
)
compiled.js = clientCode.trimStart()
compiled.ssr = ssrCode.trimStart()
}
具体模块的编译能力需要深入 @vue/compiler-sfc 这个包。
Output.vue 输出组件
// 这里定义了右侧顶部栏的模块 tabs模块
<div class="tab-buttons">
<button
v-for="m of modes"
:key="m"
:class="{ active: mode === m }"
@click="mode = m"
>
<span>{{ m }}</span>
</button>
</div>
<div class="output-container">
// 这里是预览组件 展示代码更改之后的实时预览效果
<Preview ref="preview" :show="mode === 'preview'" :ssr="ssr" />
// 这里是编译之后的js css ssr 模块 使用Monaco 编辑器做展示效果
<props.editorComponent
v-if="mode !== 'preview'"
readonly
// 当前文件类型 js css ssr
:filename="store.activeFile.filename"
// 内容从当前活动文件的 activeFile compiled 对象中取值。
:value="store.activeFile.compiled[mode]"
:mode="mode"
/>
</div>
Preview.vue 预览组件
<div
v-show="show"
ref="container"
class="iframe-container"
:class="{ [theme]: previewTheme }"
/>
<Message :err="(previewOptions?.showRuntimeError ?? true) && runtimeError" />
<Message
v-if="!runtimeError && (previewOptions?.showRuntimeWarning ?? true)"
:warn="runtimeWarning"
/>
很明显 预览组件实现了一个iframe 的沙箱机制,将实时渲染之后的结果挂载在 div container DOM节点之上。
iframe 沙箱机制 创建一个iframe节点 并将渲染内容挂载在iframe节点之上。
引入preview html 文件 srcdoc.html
import srcdoc from './srcdoc.html?raw'
<!doctype html>
<html>
<head>
<style>
html.dark {
color-scheme: dark;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
</style>
<!-- PREVIEW-OPTIONS-HEAD-HTML -->
........
//省略部分代码
........
<!-- ES Module Shims: Import maps polyfill for modules browsers without import maps support (all except Chrome 89+) -->
<script
async
src="https://cdn.jsdelivr.net/npm/es-module-shims@1.5.18/dist/es-module-shims.wasm.js"
></script>
<script type="importmap">
<!--IMPORT_MAP-->
</script><!-- -->
</head>
<body>
<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->
</body>
</html>
处理importMap 和head Html占位符 placeholder html 占位符等内容
const importMap = store.value.getImportMap()
const sandboxSrc = srcdoc
.replace(
/<html>/,
`<html class="${previewTheme.value ? theme.value : ''}">`,
)
.replace(/<!--IMPORT_MAP-->/, JSON.stringify(importMap))
.replace(
/<!-- PREVIEW-OPTIONS-HEAD-HTML -->/,
previewOptions.value?.headHTML || '',
)
.replace(
/<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->/,
previewOptions.value?.placeholderHTML || '',
)
sandbox.srcdoc = sandboxSrc
containerRef.value?.appendChild(sandbox)
PreviewProxy 沙箱机制 待下文阐述~