大家好,我是风骨,本篇文章的主题是:为在线代码编辑器,搭建沙箱渲染环境,运行 React、Vue 组件代码。
熟悉 CodeSandbox、CodePen 这类在线编辑器的小伙伴都知道,它能够将编辑器中的字符串代码,通过一个沙箱环境来动态执行,并完成视图渲染。
要实现这样的沙箱渲染器,分以下步骤:
- 搭建独立的沙箱(Sandbox)
沙箱好处是提供一个隔离的环境来执行代码,避免对主环境造成影响;
- 沙箱通讯机制
这里我们采用 iframe 桥接父窗口与沙箱环境,通过 window.postMessage API 进行通信;
- 支持不同技术栈的字符串 Code 解析渲染
沙箱需要支持渲染 React、Vue 这样的组件语法代码,同时还要考虑多文件相互导入的模块机机制。
阅读完本文,你将收获:
- 父窗口与沙箱之间的通信机制;
- 浏览器运行环境下使用 Babel 对 JSX 进行编译;
- 将字符串代码变为可执行代码;
- 理解和实现模块化机制;
- 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>
前两个步骤比较容易理解,现在有了沙箱环境,并且通过通讯拿到了要渲染的 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 转换为可执行组件,同时还要处理依赖模块。
要实现这个功能我们需要分几个步骤来完成:
- 使用
Babel对React JSX语法进行编译,一方面是将其编译为 React.createElement() 这种 JS 语法,另一方面将其编译为 commonjs require() 模块,方便接下来处理依赖模块; - 将字符串代码变为可执行代码,推荐使用
new Function()构造函数来实现; - 实现一套类似
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、自定义模块化机制
实现一个模块化机制包含两部分:
- 一是为模块提供一个隔离作用域环境,且能通过传递
exports对象拿到模块导出的内容。这一点在上文Function构造函数中已经介绍; - 二是实现
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 输出如下:
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 单文件组件字符串代码转换为可执行的组件?
要实现这个功能我们需要分几个步骤来完成:
- Vue SFC 单文件组件包含三部分,使用
@vue/compiler-sfc parse模块将<template>、<script setup>、<style scope>三部分内容提取出来; <script setup>是一个语法糖,会将所有顶层变量自动暴露给模板,我们需要使用@vue/compiler-sfc compileScript模块将它解析为组件 setup()函数;- 将字符串
setup()函数变为可执行代码,同样需要用到new Function()。由于 Vue 代码经过 SFC 编译后,导入依赖模块依旧是import语法,在这里不实现require()函数,需要将import依赖模块作为参数变量传入; - 在处理
import模块时,如果是files中的模块,需要递归进行上述相同的 SFC 编译操作; - 最后,组合
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> 三部分的内容。
4.3、compileScript 编译 <script setup> 为 setup() 函数
// 编译脚本部分
const scriptResult = compileScript(descriptor, {
id: scopeId,
inlineTemplate: false,
});
在 scriptResult 中包含 bindings 属性绑定、imports 依赖模块集合、content 编译后的 setup() 函数
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 样式隔离的实现,从下面这张图可以一目了然其原理。
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 content、setup()、props、components 组合构成一个 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");
};
文末
感谢阅读。文章内容你觉得有用,可以点赞支持一下~