likes
comments
collection
share

构建属于自己的 Vue SFC Playground

作者站长头像
站长
· 阅读数 1

0. 目录

1. 前言

本文将会带您实现一个简单的 Vue SFC Playground。

2. 实现思路

2.1 基本逻辑

VueSfcEditor.vue 编辑后同步源码给VuePlayground.vue组件,VuePlayground.vue组件内部使用@vue/compiler-sfc 编译源码成浏览器可执行的脚本给 Preview.vue 执行渲染。

构建属于自己的 Vue SFC Playground

2.2 渲染逻辑

Preview.vue 组件首次渲染时创建一个 iframe 容器。 监听到代码变更时,通过iframe.contentWindow.postMessage 方法将编译后的代码传递给 iframe。iframe 监听 message 事件,将传递的代码包裹在 script 标签中执行。

这么做的原因在于,希望预览组件能够在 iframe 沙盒中独立运行,和主应用互不干涉。而在浏览器中想要使用 vue,需要下载 vue 浏览器包,为了避免下载 vue 导致渲染闪烁,采用消息传递的方式来局部渲染。

3. 开发

3.1 整理项目

移动 VueSfcEditor.vuesrc/components 中。并在该目录创建 Preview/index.vuePreview/preview-template.htmlVuePlayground.vue

构建属于自己的 Vue SFC Playground

3.2 安装 @vue/compiler-sfc

npm install @vue/compiler-sfc

3.3 修改 App.vue

<script setup>
import VuePlayground from "./components/VuePlayground.vue";
</script>

<template>
  <vue-playground></vue-playground>
</template>

3.4 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>


3.5 修改 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>

3.6 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>

3.7 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>

4. 效果/预览

构建属于自己的 Vue SFC Playground

预告