自制 React、Vue 沙箱渲染器

557 阅读9分钟

大家好,我是风骨,本篇文章的主题是:为在线代码编辑器,搭建沙箱渲染环境,运行 React、Vue 组件代码

熟悉 CodeSandbox、CodePen 这类在线编辑器的小伙伴都知道,它能够将编辑器中的字符串代码,通过一个沙箱环境来动态执行,并完成视图渲染。

要实现这样的沙箱渲染器,分以下步骤:

  1. 搭建独立的沙箱(Sandbox)

沙箱好处是提供一个隔离的环境来执行代码,避免对主环境造成影响;

  1. 沙箱通讯机制

这里我们采用 iframe 桥接父窗口与沙箱环境,通过 window.postMessage API 进行通信;

  1. 支持不同技术栈的字符串 Code 解析渲染

沙箱需要支持渲染 React、Vue 这样的组件语法代码,同时还要考虑多文件相互导入的模块机机制。

阅读完本文,你将收获:

  1. 父窗口与沙箱之间的通信机制;
  2. 浏览器运行环境下使用 Babel 对 JSX 进行编译;
  3. 将字符串代码变为可执行代码;
  4. 理解和实现模块化机制;
  5. React、Vue 字符串组件代码渲染为可运行组件。

1、搭建沙箱环境

沙箱是一个独立的运行环境,且还需要能够被 iframe 标签加载到父窗口中,因此我们可以认为沙箱是一个前端项目。

要搭建一个 React 沙箱运行环境,可以使用 Vite、Next.js Cli 快速生成项目。

要搭建一个 Vue 项目作为沙箱运行环境,可以使用 Vite Cli 快速生成项目。

# Next.js
npx create-next-app@latest

# Vite
npm create vite@latest vue-sandbox -- --template vue

当我们执行 npm run dev 启动项目,就代表运行了一个沙箱环境,使用 iframe 标签将其接入到主应用中。

2、沙箱通讯机制

通过 iframe 标签将其接入到主应用(父窗口)后,接下来就是它们之间的相互通信。

比如:

  • 在沙箱环境初始完成后,向父窗口推送通知 IFRAME_LOADED 标识沙箱准备就绪;
  • 父窗口收到 IFRAME_LOADED 通知后,便可以将 Component Code 发送给沙箱;
  • 沙箱收到 Component Code 通知后便可以运行传递过来的代码。

二者的通信采用 window.postMessage API,下面是一个简单通信示例:

// SandBox.html
<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      window.addEventListener("message", (event) => {
        if (event.data.type === "codes") {
          console.log("3、收到代码,开始处理 codes", event.data.files);
          // ... 处理 codes
        }
      });
      // 准备就绪
      window.onload = () => {
        console.log("1、iframe 准备就绪,通知父窗口");
        window.parent.postMessage({
          type: "IFRAME_LOADED",
        }, "*");
      };
    </script>
  </body>
</html>

// ParentWindow.html
<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      const handleMessage = (event) => {
        const iframe = document.querySelector("#sandbox");
        if (event.data.type === "IFRAME_LOADED") {
          console.log("2、收到 iframe 准备就绪的消息,发送代码到 iframe");
          // 发送代码到 iframe
          iframe.contentWindow?.postMessage({
            type: "codes",
            files: {
              "index.js": "console.log('Hello, world!')",
            },
          }, "*");
        }
      };
      window.addEventListener("message", handleMessage, false);
    </script>

    <iframe id="sandbox" src="SandBox.html" width="100%" height="100%"></iframe>
  </body>
</html>

image.png

前两个步骤比较容易理解,现在有了沙箱环境,并且通过通讯拿到了要渲染的 Component Code,接下来就是不同技术栈的沙箱,完成不同技术栈语法代码的解析和渲染。

3、沙箱渲染 React 组件

现在我们假设父窗口是上面示例中的 ParentWindow.html,它在 iframe 沙箱准备就绪后推送如下 React Component files 代码:

iframe.contentWindow?.postMessage({
  type: "codes",
  data: {
    files: {
      "App.tsx": `
        import React from 'react';
        import Button from "./Button";
        const App = () => {
          return <Button>Click me</Button>;
        };
        export default App;
        `,
      "Button.tsx": `
        import React from 'react';
        const Button = ({ children }: { children: React.ReactNode }) => {
          return <button>{children}</button>;
        };
        export default Button;
        `,
    },
    entryFile: "App.tsx",
  },
}, "*");

files 包含两个组件,其中 App.tsx 是入口文件,且依赖 Button.tsx。

现在我们要思考一个问题:在沙箱环境中如何将 字符串 Codes 转换为可执行组件,同时还要处理依赖模块

要实现这个功能我们需要分几个步骤来完成:

  1. 使用 BabelReact JSX 语法进行编译,一方面是将其编译为 React.createElement() 这种 JS 语法,另一方面将其编译为 commonjs require() 模块,方便接下来处理依赖模块
  2. 将字符串代码变为可执行代码,推荐使用 new Function() 构造函数来实现;
  3. 实现一套类似 commonjs require() 模块化 简易版本,通过模块化机制拿到每个 file 中导出的变量,同时处理模块间相互导入。

3.1、初始化沙箱环境并接入通讯机制

使用 Vite Cli 创建一个 React + TS 项目

npm create vite@latest react-sandbox -- --template react-ts

npm run dev 启动项目,在 App.tsx 中接入通信机制:

import { useEffect } from "react";

function App() {
  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      const { type, data } = event.data;
      if (type === "codes") {
        // TODO: 处理代码
        console.log(data);
      }
    };

    window.addEventListener("message", handleMessage as EventListener);
    window.parent.postMessage({ type: "IFRAME_LOADED" }, "*");
    return () => {
      window.removeEventListener("message", handleMessage as EventListener);
    };
  }, []);

  return <div>Vue Sandbox</div>;
}

export default App;

3.2、使用 Babel 编译 JSX 代码

安装 Babel 依赖:

npm install @babel/standalone -D

@babel/standalone 适用于在浏览器中实时转换代码的场景,@babel/core 适用于 Node.js 环境中运行。

使用 babel transform api 对代码进行转换:

import { transform } from "@babel/standalone";

const code = `
import React from 'react';
import Button from "./Button";
const App = () => {
  return <Button>Click me</Button>;
};
export default App;
`;

const transformedCode = transform(code, {
  filename: "App.tsx",
  presets: ["react", "typescript"],
  plugins: ["transform-modules-commonjs"],
}).code;

console.log(transformedCode);

转换后输出为:

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = void 0;
var _react = _interopRequireDefault(require("react"));
var _Button = _interopRequireDefault(require("./Button"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
const App = () => {
  return /*#__PURE__*/_react.default.createElement(_Button.default, null, "Click me");
};
var _default = exports.default = App;

3.3、使用 Function 将字符串 Code 变为可执行代码

通过 Function 构造函数,可将字符串代码作为函数体调用执行。如下示例,前 n - 1 个参数定义了函数的形参,最后一个参数用于定义函数体代码。

const fn = new Function("val1", "val2", "console.log(val1 + val2)");
fn(1, 2) // 3

这里提及一下,Function 构造函数参数的意义在于:实现模块化机制。如下代码,通过传递 exports 对象,来拿到导出的模块。

const code = `
exports.name = "风骨";
exports.age = 18;
`;

const fn = new Function("exports", code);
const module = { exports: {} };
fn(module.exports);
console.log(module.exports); // {name: '风骨', age: 18}

3.4、自定义模块化机制

实现一个模块化机制包含两部分:

  1. 一是为模块提供一个隔离作用域环境,且能通过传递 exports 对象拿到模块导出的内容。这一点在上文 Function 构造函数中已经介绍;
  2. 二是实现 require() 导入模块方法,模块包含两类:1 是外部包即 node_modules 三方模块,2 是相对路径下的内部文件,如上文中 App.tsx 依赖的 Button.tsx 文件,递归对该模块进行同样的解析操作。

下面我们通过代码来理解模块化机制:

import path from "path-browserify";
import React from "react";
import ReactDOM from "react-dom";
import * as Antd from "antd";
import * as AntdIcons from "@ant-design/icons";

// 定义可能用到的外部模块
const externalModules: Record<string, any> = {
  react: React,
  "react-dom": ReactDOM,
  antd: Antd,
  "@ant-design/icons": AntdIcons,
};

// 定义文件对象
const files = {
  "index.ts": `
    const nameModule = require('./name');
    const antd = require('antd');
    exports.user = {
      name: nameModule.name,
      age: 18,
      antd,
    };
  `,
  "name.ts": `
    exports.name = '风骨';
  `,
};

const cacheModules: Record<string, any> = {};

const processFile = (filename: string) => {
  if (cacheModules[filename]) {
    return cacheModules[filename].exports;
  }

  // 1、定义 module 对象
  const module = {
    exports: {},
  };

  // 2、实现 require 方法
  const require = (importPath: string) => {
    const resolvedPath = importPath.startsWith(".")
      ? path.join(path.dirname(filename), importPath).replace(/^\//, "")
      : importPath;

    const possiblePaths = [
      resolvedPath,
      resolvedPath + ".ts",
      resolvedPath + ".tsx",
      resolvedPath + "/index.ts",
      resolvedPath + "/index.tsx",
    ];

    // 1、在 files 中查找文件
    const normalizedPath = Object.keys(files).find((file) =>
      possiblePaths.includes(file)
    );
    if (normalizedPath) {
      // 处理内容模块,递归解析依赖的模块
      return processFile(normalizedPath);
    } else {
      // 处理外部依赖库模块
      if (importPath in externalModules) {
        return externalModules[importPath];
      }
      throw new Error(`Module ${importPath} not found`);
    }
  };

  // 3、创建一个 Function 代码执行环境
  const fn = new Function(
    "require",
    "module",
    "exports",
    files[filename as keyof typeof files]
  );

  // 4、调用函数执行代码
  fn(require, module, module.exports);

  // 5、缓存模块
  cacheModules[filename] = module;
  return module.exports;
};

processFile("index.ts");

console.log(cacheModules);

经过模块解析,cacheModules 输出如下:

image.png

3.5、将入口文件模块作为组件渲染到页面

如果对应到真实场景,在拿到 cacheModules 导出模块以后,我们将入口文件对应的模块变量,作为组件渲染到页面上。伪代码如下:

const [Component, setComponent] = useState<React.ReactNode | null>(null)
// 保存入口模块变量
const component = cacheModules[入口文件路径].exports.default; // function App() {}
setComponent(React.createElement(component, {})); // 将 function App 转换为 ReactElement

// 渲染到页面
<div>{Component}</div>

3.6、完整实现

将以上细分的内容相结合,得到完整代码:

import { useEffect, useState } from "react";
import { transform } from "@babel/standalone";
import path from "path-browserify";
import React from "react";
import ReactDOM from "react-dom";
import * as Antd from "antd";
import * as AntdIcons from "@ant-design/icons";

// 定义可能用到的外部模块
const externalModules: Record<string, any> = {
  react: React,
  "react-dom": ReactDOM,
  antd: Antd,
  "@ant-design/icons": AntdIcons,
};

function App() {
  const [Component, setComponent] = useState<React.ReactNode | null>(null);

  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      const { type, data } = event.data;
      if (type === "codes") {
        parseComponents(data.files, data.entryFile);
      }
    };

    window.addEventListener("message", handleMessage as EventListener);
    window.parent.postMessage({ type: "IFRAME_LOADED" }, "*");
    return () => {
      window.removeEventListener("message", handleMessage as EventListener);
    };
  }, []);

  const parseComponents = (
    files: Record<string, string>,
    entryFile: string
  ) => {
    const cacheModules: Record<string, any> = {};

    const processFile = (filename: string) => {
      if (cacheModules[filename]) {
        return cacheModules[filename].exports;
      }

      const code = files[filename];
      // Babel 对 JSX、TS 等语法进行转换,转换成 JS 代码
      const transformedCode = transform(code, {
        filename,
        presets: ["react", "env", "typescript"],
        plugins: ["transform-modules-commonjs"],
      }).code;

      // 定义 module 对象
      const module = {
        exports: {},
      };

      // 实现 require 方法
      const require = (importPath: string) => {
        const resolvedPath = importPath.startsWith(".")
          ? path.join(path.dirname(filename), importPath).replace(/^\//, "")
          : importPath;

        const possiblePaths = [
          resolvedPath,
          resolvedPath + ".ts",
          resolvedPath + ".tsx",
          resolvedPath + "/index.ts",
          resolvedPath + "/index.tsx",
        ];

        // 在 files 中查找文件
        const normalizedPath = Object.keys(files).find((file) =>
          possiblePaths.includes(file)
        );
        if (normalizedPath) {
          // 递归解析依赖的模块
          return processFile(normalizedPath);
        } else {
          // 处理外部依赖库模块
          if (importPath in externalModules) {
            return externalModules[importPath];
          }
          throw new Error(`Module ${importPath} not found`);
        }
      };

      // 创建一个 Function 代码执行环境
      const fn = new Function("require", "module", "exports", transformedCode);

      // 调用函数执行代码
      fn(require, module, module.exports);

      // 缓存模块
      cacheModules[filename] = module;
      return module.exports;
    };

    const component = processFile(entryFile).default; // function App() {}
    setComponent(React.createElement(component, {})); // 将 function App 转换为 ReactElement
  };

  return <div>{Component}</div>;
}

export default App;

4、沙箱渲染 Vue 组件

同样,我们假设父窗口是上面示例中的 ParentWindow.html,它在 iframe 沙箱准备就绪后推送如下 Vue Component files 代码:

iframe.contentWindow?.postMessage(
  {
    type: "codes",
    data: {
      files: {
        "App.vue": `
          <script setup>
          import { ref } from "vue";
          import Button from "./Button.vue";
          const count = ref(0);
          <\/script>
          <template>
            <div class="red">
              计数: 
              <Button :click="() => count++" :count="count" />
            </div>
          </template>
          <style scoped>
          .red {
            color: red;
          }
          </style>
      `,
        "Button.vue": `
          <template>
            <button @click="props.click">Count is: {{ props.count }}</button>
          </template>
          <script setup>
          const props = defineProps({
            click: Function,
            count: Number,
          });
          <\/script>
        `,
      },
      entryFile: "App.vue",
    },
  },
  "*"
);

files 包含两个组件,其中 App.vue 是入口文件,且依赖 Button.vue。同时每一个文件都代表一个 Vue SFC 单文件组件。

现在我们要思考一个问题:如何将 Vue 单文件组件字符串代码转换为可执行的组件?

要实现这个功能我们需要分几个步骤来完成:

  1. Vue SFC 单文件组件包含三部分,使用 @vue/compiler-sfc parse 模块将 <template>、<script setup>、<style scope> 三部分内容提取出来;
  2. <script setup> 是一个语法糖,会将所有顶层变量自动暴露给模板,我们需要使用 @vue/compiler-sfc compileScript 模块将它解析为 组件 setup() 函数;
  3. 将字符串 setup() 函数变为可执行代码,同样需要用到 new Function()。由于 Vue 代码经过 SFC 编译后,导入依赖模块依旧是 import 语法,在这里不实现 require() 函数,需要将 import 依赖模块作为参数变量传入;
  4. 在处理 import 模块时,如果是 files 中的模块,需要递归进行上述相同的 SFC 编译操作;
  5. 最后,组合 template content、setup()、props、components 共同构成一个 componentOptions,使用 createApp() 将组件渲染到页面。

4.1、初始化沙箱环境并接入通讯机制

使用 Vite Cli 创建一个 Vue + TS 项目

npm create vite@latest vue-sandbox -- --template vue-ts

npm run dev 启动项目,在 App.tsx 中接入通信机制:

<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { parseComponents } from "./StringComponentParser";

const handleMessage = (event: MessageEvent) => {
  const { type, data } = event.data;
  if (type === "codes") {
    // TODO: 处理代码
    parseComponents(data.files, data.entryFile);
  }
};

onMounted(() => {
  window.addEventListener("message", handleMessage);
  window.parent.postMessage({ type: "IFRAME_LOADED" }, "*");
});

onUnmounted(() => {
  window.removeEventListener("message", handleMessage);
});
</script>

<template>
  <div>Vue sandbox</div>
  <div id="sandbox-container"></div>
</template>

4.2、parse 拆解 Vue SFC 的三部分内容

import { parse, compileScript, compileStyle } from "@vue/compiler-sfc";

// 解析 SFC
const { descriptor } = parse(files[filename]);

在 descriptor 中包含 <template>、<script setup>、<style scope> 三部分的内容。

image.png

4.3、compileScript 编译 <script setup>setup() 函数

// 编译脚本部分
const scriptResult = compileScript(descriptor, {
  id: scopeId,
  inlineTemplate: false,
});

在 scriptResult 中包含 bindings 属性绑定、imports 依赖模块集合、content 编译后的 setup() 函数

image.png

4.4、compileStyle 编译 scope 样式

// 生成唯一的 scopeId
const scopeId = `data-v-${Math.random().toString(36).substring(2, 10)}`;

// 编译样式部分,核心是处理 scoped 隔离样式
const styleResult = compileStyle({
  source: descriptor.styles[0]?.content || "",
  scoped: descriptor.styles[0]?.scoped,
  id: scopeId,
  filename,
});

// 创建样式标签并添加到文档头部
if (styleResult.code) {
  const styleElement = document.createElement("style");
  styleElement.textContent = styleResult.code;
  document.head.appendChild(styleElement);
}

这段代码是将 <style scope> 标签内的样式,以 <header> <style> 形式插入到页面中。值得注意的是 scope 样式隔离的实现,从下面这张图可以一目了然其原理。

image.png

4.5、依赖模块处理

compileScript 这一环节可以从中拿到 imports 依赖模块集合,这样节省了我们手动匹配 import 语句的工作。

  • 对于外部 node_modules 模块,我们可以事先定义一个 externalModules 集合;
  • 对于 files 内部模块,递归进行上述 SFC 编译工作;
// 定义可能用到的外部模块
const externalModules: Record<string, any> = {
  vue: Vue,
  // ... other external modules
};

// 参数 imports 示例:imports = { "Vue": { imported: "default", local: "Vue", source: "vue" }, { "ref": { imported: "ref", local: "ref", source: "vue" } } }
const moduleMap = Object.entries(imports).reduce(
  (acc: Record<string, any>, [, importDetail]) => {
    const resolvedPath = importDetail.source.startsWith(".")
      ? path
          .join(path.dirname(filename), importDetail.source)
          .replace(/^\//, "")
      : importDetail.source;
    const possiblePaths = [
      resolvedPath,
      resolvedPath + ".vue",
      resolvedPath + "/index.vue",
    ];
    // 在 files 中查找文件
    const normalizedPath = Object.keys(files).find((file) =>
      possiblePaths.includes(file)
    );
    if (normalizedPath) {
      // 递归解析依赖的模块
      const componentOptions = processFile(normalizedPath);
      acc[importDetail.local] = componentOptions;
    } else {
      // 处理外部依赖库模块
      const moduleExports = externalModules[resolvedPath] || {};
      if (importDetail.imported === "default") {
        acc[importDetail.local] = moduleExports;
      } else {
        acc[importDetail.local] = moduleExports[importDetail.imported];
      }
    }
    return acc;
  },
  {}
);

// 移除脚本中的 import 语句和 export 语句
scriptResult.content = scriptResult.content
  .replace(
    /import\s+(?:([A-Za-z0-9_$]+)(?:\s*,\s*)?)?(?:{([^}]*)})?\s+from\s+(['"][^'"]+['"])/g,
    ""
  )
  // 将 "export default" 改成变量。
  .replace(/export\s+default\s+/g, "const _component = ")
  // 将下面这段代码从中移除,否则在 <template> 中无法访问到 setup 函数中暴露的变量。(原因暂时不详)
  .replace(
    /Object.defineProperty\(__returned__,\s+'__isScriptSetup',\s+{ enumerable: false, value: true }\)/g,
    ""
  );

4.6、组合构成 componentOptions

最后,将 template contentsetup()propscomponents 组合构成一个 componentOptions,接下来通过 createApp(componentOptions).mount("#sandbox-container") 即可完成组件渲染。

// 执行编译后的脚本代码,将依赖模块作为参数传入,返回 setup 函数
const dynamicSetupFunction = new Function(
  ...Object.keys(moduleMap),
  `
    ${scriptResult.content};
    return _component.setup;
  `
);

// 创建组件选项对象
const componentOptions = {
  template: descriptor.template?.content || "",
  setup: dynamicSetupFunction(...Object.values(moduleMap)),
  // 指定 scopeId,将会在组件容器元素上添加 data-v-xxx 属性,实现样式隔离(.red[data-v-nqxjbq8d] {xxx})。
  __scopeId: scopeId,
  // 添加 props 定义
  props,
  // 注册导入的组件
  components: Object.entries(moduleMap).reduce(
    (acc: Record<string, any>, [key, value]) => {
      if (value && typeof value === "object" && "template" in value) {
        acc[key] = value;
      }
      return acc;
    },
    {}
  ),
};

4.7、完整实现

import { createApp } from "vue/dist/vue.esm-bundler.js";
import { parse, compileScript, compileStyle } from "@vue/compiler-sfc";
import path from "path-browserify";
import * as Vue from "vue/dist/vue.esm-bundler.js";

// 定义可能用到的外部模块
const externalModules: Record<string, any> = {
  vue: Vue,
  // ... other external modules
};

// 定义 files 已加载的模块
const filesModules: Record<string, any> = {};

export const parseComponents = (
  files: Record<string, string>,
  entryFile: string
) => {
  const processFile = (filename: string) => {
    if (filesModules[filename]) {
      return filesModules[filename];
    }

    // 解析 SFC
    const { descriptor } = parse(files[filename]);

    // 生成唯一的 scopeId
    const scopeId = `data-v-${Math.random().toString(36).substring(2, 10)}`;

    // 编译脚本部分
    const scriptResult = compileScript(descriptor, {
      id: scopeId,
      inlineTemplate: false,
    });

    // 代码中的模块导入解析集合
    const imports = scriptResult.imports || {};

    // 获取 props 定义
    const props = Object.keys(scriptResult.bindings || {}).reduce(
      (acc: string[], key) => {
        if (scriptResult.bindings![key] === "props") {
          acc.push(key);
        }
        return acc;
      },
      []
    );

    // 编译样式部分,核心是处理 scoped 隔离样式
    const styleResult = compileStyle({
      source: descriptor.styles[0]?.content || "",
      scoped: descriptor.styles[0]?.scoped,
      id: scopeId,
      filename,
    });

    // 创建样式标签并添加到文档头部
    if (styleResult.code) {
      const styleElement = document.createElement("style");
      styleElement.textContent = styleResult.code;
      document.head.appendChild(styleElement);
    }

    // 示例:imports = { "Vue": { imported: "default", local: "Vue", source: "vue" }, { "ref": { imported: "ref", local: "ref", source: "vue" } } }
    const moduleMap = Object.entries(imports).reduce(
      (acc: Record<string, any>, [, importDetail]) => {
        const resolvedPath = importDetail.source.startsWith(".")
          ? path
              .join(path.dirname(filename), importDetail.source)
              .replace(/^\//, "")
          : importDetail.source;
        const possiblePaths = [
          resolvedPath,
          resolvedPath + ".vue",
          resolvedPath + "/index.vue",
        ];
        // 在 files 中查找文件
        const normalizedPath = Object.keys(files).find((file) =>
          possiblePaths.includes(file)
        );
        if (normalizedPath) {
          // 递归解析依赖的模块
          const componentOptions = processFile(normalizedPath);
          acc[importDetail.local] = componentOptions;
        } else {
          // 处理外部依赖库模块
          const moduleExports = externalModules[resolvedPath] || {};
          if (importDetail.imported === "default") {
            acc[importDetail.local] = moduleExports;
          } else {
            acc[importDetail.local] = moduleExports[importDetail.imported];
          }
        }
        return acc;
      },
      {}
    );

    // 移除脚本中的 import 语句和 export 语句
    scriptResult.content = scriptResult.content
      .replace(
        /import\s+(?:([A-Za-z0-9_$]+)(?:\s*,\s*)?)?(?:{([^}]*)})?\s+from\s+(['"][^'"]+['"])/g,
        ""
      )
      .replace(/export\s+default\s+/g, "const _component = ")
      // 原因暂时不详:如果存在下面这行代码会导致 template 中无法访问到 setup 函数中暴露的变量。
      .replace(
        /Object.defineProperty\(__returned__,\s+'__isScriptSetup',\s+{ enumerable: false, value: true }\)/g,
        ""
      );

    // 执行编译后的脚本代码,将依赖模块作为参数传入,返回 setup 函数
    const dynamicSetupFunction = new Function(
      ...Object.keys(moduleMap),
      `
        ${scriptResult.content};
        return _component.setup;
      `
    );

    // 创建组件选项对象
    const componentOptions = {
      template: descriptor.template?.content || "",
      setup: dynamicSetupFunction(...Object.values(moduleMap)),
      // 指定 scopeId,将会在组件容器元素上添加 data-v-xxx 属性,实现样式隔离(.red[data-v-nqxjbq8d] {xxx})。
      __scopeId: scopeId,
      // 添加 props 定义
      props,
      // 注册导入的组件
      components: Object.entries(moduleMap).reduce(
        (acc: Record<string, any>, [key, value]) => {
          if (value && typeof value === "object" && "template" in value) {
            acc[key] = value;
          }
          return acc;
        },
        {}
      ),
    };

    // 缓存模块
    filesModules[filename] = componentOptions;

    return componentOptions;
  };

  const componentOptions = processFile(entryFile);

  // 创建应用并挂载
  createApp(componentOptions).mount("#sandbox-container");
};

文末

感谢阅读。文章内容你觉得有用,可以点赞支持一下~