本文将会带您实现一个简单的 Vue SFC Playground。
VueSfcEditor.vue
编辑后同步源码给VuePlayground.vue
组件,VuePlayground.vue
组件内部使用@vue/compiler-sfc
编译源码成浏览器可执行的脚本给 Preview.vue
执行渲染。
Preview.vue
组件首次渲染时创建一个 iframe 容器。 监听到代码变更时,通过iframe.contentWindow.postMessage
方法将编译后的代码传递给 iframe。iframe 监听 message
事件,将传递的代码包裹在 script
标签中执行。
这么做的原因在于,希望预览组件能够在 iframe 沙盒中独立运行,和主应用互不干涉。而在浏览器中想要使用 vue,需要下载 vue 浏览器包,为了避免下载 vue 导致渲染闪烁,采用消息传递的方式来局部渲染。
移动 VueSfcEditor.vue
到 src/components
中。并在该目录创建 Preview/index.vue
,Preview/preview-template.html
和 VuePlayground.vue
。
@vue/compiler-sfc
npm install @vue/compiler-sfc
App.vue
<script setup>
import VuePlayground from "./components/VuePlayground.vue";
</script>
<template>
<vue-playground></vue-playground>
</template>
VuePlayground.vue
组件<template>
<div class="vue-playground">
<div class="vue-playground__editor">
<VueSfcEditor></VueSfcEditor>
</div>
<div class="vue-playground__preview">
<Preview></Preview>
</div>
</div>
</template>
<script setup>
import VueSfcEditor from "./VueSfcEditor.vue";
import Preview from "./Preview/index.vue";
import { provide, reactive } from "vue";
import {
parse,
compileScript,
compileTemplate,
compileStyle,
} from "@vue/compiler-sfc";
// 默认代码
const DefaultCode = `
<script setup>
import { ref } from 'vue'
const msg = ref('Hello World!')
<\/script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg" />
</template>
`;
const state = reactive({
// sfc 源代码
code: DefaultCode.trim(),
updateCode(code) {
state.code = code;
},
// 编译过程
compile(code) {
const { descriptor } = parse(code);
let _code = `
if(window.__app__){
window.__app__.unmount();
}
window.__app__ = null;
`;
const componentName = "__AppVue__";
// 编译脚本。
if (descriptor.script || descriptor.scriptSetup) {
const script = compileScript(descriptor, {
inlineTemplate: true,
id: descriptor.filename,
});
_code += script.content.replace(
"export default",
`window.${componentName} =`
);
}
// 非 setup 模式下,需要编译 template。
if (!descriptor.scriptSetup && descriptor.template) {
const template = compileTemplate(descriptor.template, {
id: descriptor.filename,
});
_code =
_code +
";" +
template.code.replace(
"export function",
`window.${componentName}.render = function`
);
}
// 创建 vue app 实例并渲染。
_code += `;
import { createApp } from "vue";
window.__app__ = createApp(window.${componentName});
window.__app__.mount("#app");
if (window.__style__) {
window.__style__.remove();
}
`;
// 编译 css 样式。
if (descriptor.styles?.length) {
const styles = descriptor.styles.map((style) => {
return compileStyle({
source: style.content,
id: descriptor.filename,
}).code;
});
_code += `
window.__style__ = document.createElement("style");
window.__style__.innerHTML = ${JSON.stringify(styles.join(""))};
document.body.appendChild(window.__style__);
`;
}
return _code;
},
});
provide("store", state);
</script>
<style scoped>
.vue-playground {
display: flex;
height: 100%;
}
.vue-playground > * {
flex: 1;
}
</style>
VueSfcEditor.vue
<template>
<!-- ... -->
</template>
<script setup>
import {
// ...
inject
} from "vue";
// ...
// 注入store
const store = inject("store");
onMounted(() => {
if (!vueSfcEditor.value) return;
const view = new EditorView({
doc: store.code,
extensions: [
// ...
EditorView.updateListener.of((view) => {
if (view.docChanged) {
store.updateCode(view.state.doc.toString());
}
}),
],
parent: vueSfcEditor.value,
});
});
</script>
Preview/preview-template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="app"></div>
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3.4.21/dist/vue.esm-browser.js"
}
}
</script>
<script>
// 监听 message,preview/index.vue 通过 postmessage 传递需要执行的代码
window.addEventListener("message", ({ data }) => {
const { type, code } = data;
if(type === "eval") {
handleEval(code);
}
});
const evalScriptElements = [];
// 处理需要执行的代码
function handleEval(code) {
// 移除历史脚本
if(evalScriptElements.length) {
evalScriptElements.forEach(el => el.remove());
}
// 创建新的脚本元素
const script = document.createElement("script");
script.setAttribute("type", "module");
script.innerHTML = code;
evalScriptElements.push(script);
// 插入到 body 中。
document.body.appendChild(script);
}
</script>
</body>
</html>
Preview/index.vue
<template>
<div class="preview" ref="preview"></div>
</template>
<script setup>
import { ref, onMounted, watch, inject, onUnmounted } from "vue";
import PreviewTemplate from "./preview-template.html?raw";
const preview = ref();
let proxy = null;
// 注入store
const store = inject("store");
// 创建沙盒
function createSandbox() {
const template = document.createElement("iframe");
template.setAttribute("frameborder", "0");
template.style = "width: 100%; height:100%";
template.srcdoc = PreviewTemplate;
preview.value.appendChild(template);
template.onload = () => {
proxy = createProxy(template);
};
}
// 创建代理,用于监听code 变化,告诉沙盒重新渲染
function createProxy(iframe) {
let _iframe = iframe;
const stopWatch = watch(() => store?.code, compile, { immediate: true });
function compile(code) {
if (!code?.trim()) return;
const compiledCode = store?.compile(code);
_iframe.contentWindow.postMessage(
{ type: "eval", code: compiledCode },
"*"
);
}
// 销毁沙盒
function destory() {
_iframe?.remove();
_iframe = null;
stopWatch?.();
}
return {
compile,
destory,
};
}
onMounted(createSandbox);
onUnmounted(() => proxy?.destory());
</script>
<style scoped>
.preview {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>