Vue3微前端加载Mars3D地球不显示?四个隐藏Bug连环爆炸,我含泪踩坑两天终于全部修复
本文记录了在一个真实的 Vue 3 + Vite 项目中,使用京东 micro-app 微前端框架加载 Mars3D 三维地球子应用时踩过的所有坑和最终解决方案。如果你也遇到类似的问题,希望这篇文章能帮助你。如何赶时间,可以直接查看本人仓库中修改好的代码:https://gitee.com/hylab/mars3d-micro-app
背景
事情是这样的:我们有一个基于 Vue 3 + Vite 的 Mars3D 三维地球应用(子应用),独立运行时一切正常,地球渲染流畅,地形瓦片、底图、大气层都好好的。然后我们想把它接入一个 micro-app 微前端基座应用(主应用)里,通过 <micro-app> 标签加载子应用。
结果——地球死活不显示。
控制台各种报错,从 TypeError: Cannot read properties of undefined 到 SyntaxError: Cannot use import statement outside a module,再到 SecurityError: Failed to execute 'texImage2D',简直是报错全家桶。
经过几天反复调试,发现这背后其实有 四个独立的问题,每一个都足以让地球无法显示。下面逐一拆解。
项目结构
先看一下整体结构:
mars3d-micro-app/
├── mars3d-test/ # 子应用:Mars3D 三维地球
│ ├── src/
│ │ ├── App.vue # 地球初始化组件
│ │ ├── main.js # 子应用入口
│ │ └── style.css
│ ├── vite.config.js # 使用 vite-plugin-mars3d
│ └── package.json
└── micro-app-test/ # 主应用:micro-app 基座
├── src/
│ ├── App.vue # 基座布局 + micro-app 标签
│ ├── main.js # microApp.start() 配置
│ └── style.css
├── vite.config.js # 代理配置
└── package.json
技术栈:
- 主应用:Vue 3.5 + Vite 8 +
@micro-zoe/micro-app1.0-rc.30 - 子应用:Vue 3.5 + Vite 8 +
mars3d3.11 +mars3d-cesium1.140 +vite-plugin-mars3d4.2
子应用跑在 http://localhost:5173,主应用跑在 http://localhost:5174。
子应用是怎么工作的
在讲问题之前,先理清子应用独立运行时发生了什么。
子应用用了 vite-plugin-mars3d 插件,这个插件做了几件事:
- 把
mars3d-cesium(Cesium 的完整 UMD 构建)和mars3d都作为外联 UMD 脚本加载,不打包进 JS bundle - 在
index.html中注入一个内联脚本设置CESIUM_BASE_URL,告诉 Cesium 去哪里找 Workers、Assets、Widgets 等静态资源 - 应用代码里通过
window.Cesium和window.mars3d访问全局变量
子应用的 App.vue 核心逻辑很简单:
<script setup>
import { onMounted } from "vue";
function initMap() {
const mars3d = window.mars3d;
const Cesium = window.Cesium;
if (!mars3d || !Cesium) {
console.error("mars3d 或 Cesium 未加载完成");
return;
}
const map = new mars3d.Map("mars3dContainer", {
scene: { /* 场景配置 */ },
control: { /* 控件配置 */ },
terrain: { url: "https://data.mars3d.cn/terrain" },
basemaps: [ /* 底图配置 */ ],
});
}
onMounted(() => {
if (window.mars3d && window.Cesium) {
initMap();
} else {
// 轮询等待 UMD 脚本加载完成
const timer = setInterval(() => {
if (window.mars3d && window.Cesium) {
clearInterval(timer);
initMap();
}
}, 100);
setTimeout(() => clearInterval(timer), 10000);
}
});
</script>
<template>
<div id="mars3dContainer" class="mars3d-container"></div>
</template>
可以看到,子应用依赖 window.mars3d 和 window.Cesium 这两个全局变量,用轮询的方式等它们加载完。这个设计在独立运行时没问题,但在 micro-app 的沙箱里就出事了。
问题一:Cesium 全局变量在沙箱里"消失"了
现象
子应用在 micro-app 中加载后,控制台报:
Uncaught TypeError: Cannot read properties of undefined (reading 'default')
报错位置在 mars3d.js,它尝试通过 globalThis.Cesium 访问 Cesium 实例但拿到了 undefined。
原因分析
micro-app 默认使用 with 沙箱模式。在这个模式下,子应用的 JS 被包裹在一个 with(proxyWindow) 语句里执行。proxyWindow 是一个代理对象,所有对 window 的读写都被拦截到这个代理上。
Cesium 的 UMD 构建里有一行关键代码:
var Cesium = (function() {
// ... 几万行代码
return Cesium;
})();
注意这里用的是 var Cesium=。var 声明的变量是函数作用域的,不是全局作用域的。在 with(proxyWindow) 的包裹下,这个 var Cesium 声明的是包裹函数内的局部变量,不会穿透到 proxyWindow 上。所以 globalThis.Cesium 是 undefined,mars3d.js 自然就拿不到 Cesium 实例了。
解决方案
用 micro-app 的 plugins.global[0].loader 钩子,在代码加载时动态改写:
microApp.start({
plugins: {
global: [{
loader(code, address) {
if (typeof code !== "string") return code;
// 把 var Cesium= 改成 globalThis.Cesium=
// 让 Cesium 实例挂到全局作用域而不是函数作用域
if (address.includes("Cesium.js")) {
return code.replace("var Cesium=", "globalThis.Cesium=");
}
return code;
},
}],
},
});
同时,配合 escapeProperties 配置,把这些全局变量从子应用的 proxyWindow 上同步到主应用的 window 上:
microApp.start({
plugins: {
global: [{
escapeProperties: ["Cesium", "mars3d", "turf", "CESIUM_BASE_URL"],
loader(code, address) { /* ... */ },
}],
},
});
escapeProperties 的作用是:当子应用在 proxyWindow 上设置了这些属性时,自动把它们"逃逸"到真实的 window 上。这样在主应用上下文执行的代码也能访问到 Cesium 和 mars3d。
问题二:ES Module 脚本在 with 沙箱里无法执行
现象
上面的问题修完后,出现了新报错:
SyntaxError: Cannot use import statement outside a module
报错来自 @vite/client 和子应用的 src/main.js。
原因分析
Vite 在开发模式下,@vite/client(HMR 热更新客户端)和子应用入口 main.js 都是 <script type="module"> 脚本,使用了 ES Module 的 import 语法。
micro-app 的 with 沙箱在处理这些脚本时,会检查 script.type === 'module' 来判断是否是模块脚本。但问题出在:micro-app 把脚本代码取下来后,会用 new Function() 来执行,而不是通过创建原生的 <script type="module"> 元素。new Function() 创建的函数是在普通脚本上下文执行的,浏览器不会把它的内容当 ESM 处理,所以遇到 import 语句就报 SyntaxError。
本质上,micro-app 的 with 沙箱对 module 脚本的支持存在缺陷。
解决方案
在 loader 里拦截这两个脚本,把它们的代码替换为"创建原生 module script 元素"的逻辑,让浏览器自己去加载和执行:
const SUB_APP_URL = "http://localhost:5173";
microApp.start({
plugins: {
global: [{
loader(code, address) {
if (typeof code !== "string") return code;
// ... Cesium.js 处理 ...
// Module 脚本:with 沙箱下会被 new Function() 执行导致 import 报错
// 替换为创建 <script type="module"> 元素,用绝对 URL 由浏览器原生加载
if (
address.includes("@vite/client") ||
address.includes("/src/main.js")
) {
const absUrl = address.startsWith("http")
? address
: `${SUB_APP_URL}${address}`;
return `var __s=globalThis.document.createElement('script');__s.type='module';__s.src='${absUrl}';__s.__PURE_ELEMENT__=true;globalThis.document.head.appendChild(__s);`;
}
return code;
},
}],
},
});
这段代码做了什么?它把 micro-app 原本要 new Function() 执行的脚本代码,替换成了一段创建 <script type="module"> 元素并插入到 <head> 的逻辑。__PURE_ELEMENT__ 标记告诉 micro-app 不要再拦截这个脚本元素。浏览器原生加载这个 module 脚本后,import 语法就能正常工作了。
效果:子应用的 Vue 应用能够正常初始化,onMounted 回调也能正确触发。
问题三:DOM 和 CSS 导致 Canvas 高度为 0
现象
上面两个问题修完后,控制台不报错了,Vue 也正常初始化了,但地球还是不显示。打开 DevTools 检查 DOM,发现 micro-app 和 micro-app-body 这两个自定义元素的高度是 0。
原因分析
micro-app 是一个自定义元素(Custom Element),浏览器默认给它的 display 是 inline,高度自然是 0。Cesium 的 Canvas 需要一个有实际高度的容器才能渲染,容器高度为 0 时 Canvas 也不会显示。
另外,主应用和子应用都用了 #app 作为根元素 ID,导致 DOM 冲突。
解决方案
第一步:改子应用的根元素 ID,避免冲突。
子应用 index.html:
<div id="app"></div> <!-- 改前 -->
主应用 index.html:
<div id="micro-root"></div> <!-- 用不同的 ID -->
主应用 main.js:
createApp(App).mount("#micro-root"); // 挂载到 #micro-root
第二步:给 micro-app 和 micro-app-body 设置正确的尺寸。
主应用 style.css:
micro-app,
micro-app-body {
display: block;
width: 100%;
height: 100%;
}
主应用 App.vue 的 scoped 样式:
.sub-app-container {
flex: 1;
overflow: hidden;
}
.sub-app-container micro-app {
display: block;
width: 100%;
height: 100%;
}
确保从 html, body → #micro-root → .main-app → .sub-app-container → micro-app → micro-app-body → #app → #mars3dContainer 这条 DOM 链路上每一层都有明确的高度,不能有任何一个环节的高度塌陷为 0。
问题四:WebGL 跨域安全错误
现象
这是最隐蔽的一个问题。前面三个问题都修好后,控制台开始出现:
WebGL渲染运行出错 (页面已停止,请刷新页面)
SecurityError: Failed to execute 'texImage2D' on 'WebGL2RenderingContext':
The image element contains cross-origin data, and may not be loaded.
原因分析
这是一个经典的 WebGL 跨域安全问题。WebGL 的 texImage2D 方法会把图片绘制到 Canvas 上,浏览器出于安全考虑要求这些图片必须与当前页面同源(或设置了正确的 CORS 头)。如果图片来自不同源且没有 CORS 头,浏览器就会抛出 SecurityError。
在我们的场景中:
- 主应用运行在
http://localhost:5174 - 子应用运行在
http://localhost:5173 - Cesium 加载的静态资源(如
ion-credit.png、地形相关图片等)的 URL 由CESIUM_BASE_URL决定
vite-plugin-mars3d 在子应用的 index.html 中注入了一个内联脚本:
window['CESIUM_BASE_URL'] = '/assets/mars3d-cesium/'
在独立运行时,这个相对路径会解析为 http://localhost:5173/assets/mars3d-cesium/,与页面同源,没问题。
但在 micro-app 沙箱中,proxyWindow.location.origin 可能指向子应用 origin,导致 CESIUM_BASE_URL 解析为 http://localhost:5173/assets/mars3d-cesium/,而 WebGL 上下文运行在主应用 origin(http://localhost:5174)。于是 Cesium 从 5173 加载的图片被送进 5174 的 WebGL 上下文,触发跨域安全错误。
解决方案
分两步走:
第一步:在 loader 中改写 CESIUM_BASE_URL
microApp.start({
plugins: {
global: [{
loader(code, address) {
if (typeof code !== "string") return code;
// ... Cesium.js 处理 ...
// CESIUM_BASE_URL 内联脚本:改写为主应用 origin 的绝对路径
// 使 Cesium 从主应用同源加载资源,通过 vite proxy 转发到子应用
if (code.includes("CESIUM_BASE_URL")) {
const mainAppOrigin = window.location.origin;
return code.replace(
/window\['CESIUM_BASE_URL'\]\s*=\s*['"]([^'"]+)['"]/,
(_m, path) =>
`globalThis['CESIUM_BASE_URL'] = '${mainAppOrigin}${path}'`
);
}
// ... module 脚本处理 ...
return code;
},
}],
},
});
这里把 window['CESIUM_BASE_URL'] = '/assets/mars3d-cesium/' 改写成了 globalThis['CESIUM_BASE_URL'] = 'http://localhost:5174/assets/mars3d-cesium/'——用主应用的 origin。注意 loader 钩子是在主应用上下文执行的,所以 window.location.origin 拿到的是 http://localhost:5174。
这样 Cesium 请求资源时 URL 是 http://localhost:5174/assets/mars3d-cesium/...,与主应用同源,不会触发跨域安全问题。
第二步:在 Vite 中配置代理
资源请求到了主应用的 5174 端口,但实际文件在子应用的 5173 端口上。所以需要 Vite 的 dev server 代理来转发:
// micro-app-test/vite.config.js
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// 将 micro-app 标签作为自定义元素处理
isCustomElement: (tag) => tag.startsWith('micro-app'),
},
},
}),
],
server: {
proxy: {
'/assets/mars3d-cesium': {
target: 'http://localhost:5173',
changeOrigin: true,
},
'/assets/mars3d': {
target: 'http://localhost:5173',
changeOrigin: true,
},
},
},
});
这样,浏览器请求 http://localhost:5174/assets/mars3d-cesium/Workers/createVerticesFromHeightmap.js 时,Vite dev server 会代理转发到 http://localhost:5173/assets/mars3d-cesium/Workers/createVerticesFromHeightmap.js。对浏览器来说,这是同源请求,WebGL 不会再报 SecurityError。
完整配置一览
把上面四个问题的解决方案组合起来,主应用的关键文件最终长这样:
micro-app-test/src/main.js
import { createApp } from "vue";
import microApp from "@micro-zoe/micro-app";
import "./style.css";
import App from "./App.vue";
const SUB_APP_URL = "http://localhost:5173";
microApp.start({
plugins: {
global: [
{
// Cesium, mars3d, turf 在沙箱中执行后需要 escape 到主应用 window
escapeProperties: ["Cesium", "mars3d", "turf", "CESIUM_BASE_URL"],
loader(code, address) {
if (typeof code !== "string") return code;
// 1. Cesium.js: var Cesium= 是函数作用域,替换为 globalThis.Cesium=
if (address.includes("Cesium.js")) {
return code.replace("var Cesium=", "globalThis.Cesium=");
}
// 2. CESIUM_BASE_URL: 改写为主应用 origin,避免跨域 WebGL 错误
if (code.includes("CESIUM_BASE_URL")) {
const mainAppOrigin = window.location.origin;
return code.replace(
/window\['CESIUM_BASE_URL'\]\s*=\s*['"]([^'"]+)['"]/,
(_m, path) =>
`globalThis['CESIUM_BASE_URL'] = '${mainAppOrigin}${path}'`
);
}
// 3. Module 脚本: 替换为原生 <script type="module"> 元素
if (
address.includes("@vite/client") ||
address.includes("/src/main.js")
) {
const absUrl = address.startsWith("http")
? address
: `${SUB_APP_URL}${address}`;
return `var __s=globalThis.document.createElement('script');__s.type='module';__s.src='${absUrl}';__s.__PURE_ELEMENT__=true;globalThis.document.head.appendChild(__s);`;
}
return code;
},
},
],
},
});
createApp(App).mount("#micro-root");
micro-app-test/src/App.vue
<template>
<div class="main-app">
<div class="header">
<h1>Micro-App 基座应用</h1>
</div>
<div class="sub-app-container">
<micro-app name="mars3d-app" url="http://localhost:5173/"></micro-app>
</div>
</div>
</template>
<style scoped>
.main-app {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
height: 50px;
display: flex;
align-items: center;
padding: 0 20px;
background: #1890ff;
color: #fff;
flex-shrink: 0;
}
.sub-app-container {
flex: 1;
overflow: hidden;
}
.sub-app-container micro-app {
display: block;
width: 100%;
height: 100%;
}
</style>
micro-app-test/src/style.css
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#micro-root {
width: 100%;
height: 100%;
}
micro-app,
micro-app-body {
display: block;
width: 100%;
height: 100%;
}
micro-app-test/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('micro-app'),
},
},
}),
],
server: {
proxy: {
'/assets/mars3d-cesium': {
target: 'http://localhost:5173',
changeOrigin: true,
},
'/assets/mars3d': {
target: 'http://localhost:5173',
changeOrigin: true,
},
},
},
})
micro-app-test/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>micro-app-test</title>
</head>
<body>
<div id="micro-root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
子应用端需要的小改动
子应用 index.html 的根元素 ID 从 #app 改为不与主应用冲突的 ID(这里主应用已经用了 #micro-root,子应用保持 #app 也可以,但最好确保两边不重复)。
子应用的 vite.config.js 需要开启 CORS:
export default defineConfig({
plugins: [vue(), mars3dPlugin({ useStatic: true })],
server: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
})
调试技巧总结
在这个过程中积累了一些实用的调试技巧,分享给大家:
-
善用 micro-app 的
loader钩子:它可以在脚本加载前动态修改代码,是处理沙箱兼容性问题最灵活的工具。所有var声明、全局变量路径、内联脚本配置都可以在这里改写。 -
escapeProperties是连接沙箱内外的桥梁:子应用在proxyWindow上设置的变量默认不会出现在主应用的window上。如果你在主应用上下文执行的代码(如原生 module 脚本)需要访问子应用创建的全局变量,就用escapeProperties把它们"逃逸"出来。 -
WebGL 跨域问题用同源代理解决:不要试图给 Cesium 的图片加
crossOrigin属性(有些图片是 Cesium 内部创建的,你改不了),最干净的方案是让CESIUM_BASE_URL指向主应用 origin,再用 Vite proxy 转发。 -
micro-app 自定义元素需要显式设置尺寸:
micro-app和micro-app-body默认display: inline,高度为 0。一定要在 CSS 中显式设置display: block和height: 100%,否则 Canvas 类应用无法渲染。 -
module 脚本需要"逃出"沙箱:micro-app 的
with沙箱用new Function()执行脚本,不支持import语法。遇到 module 脚本时,最可靠的方式是在loader中把它替换为创建原生<script type="module">元素的代码,交给浏览器处理。
关于 iframe 沙箱的补充说明
micro-app 还提供了 iframe 沙箱模式(在 <micro-app> 标签上加 iframe 属性)。从理论上说,iframe 沙箱更接近真实的隔离环境,对 module 脚本的支持更好。
但在实际使用中发现,iframe 沙箱模式下 Mars3D 也会遇到自己的问题——主要是 Cesium 的 Web Worker 在 iframe 沙箱中创建 blob URL 时,blob 的 origin 会被设置为主应用 origin 而非子应用 origin,导致 worker 内的 ES Module 裸标识符(bare specifier)解析失败,worker 静默崩溃,地形 mesh 永远创建不出来。
所以在 with 沙箱模式下,通过本文的四个补丁反而能获得更可控的结果。当然,这取决于你的具体场景和 micro-app 版本,建议两种模式都试一下,看哪种更适合你的项目。
写在最后
回顾整个调试过程,四个问题的因果链是:
问题一:var Cesium 被困在沙箱函数作用域 → globalThis.Cesium 为 undefined → mars3d 拿不到 Cesium
问题二:module 脚本被 new Function 执行 → import 语法报错 → Vue 无法初始化
问题三:micro-app 元素高度为 0 → Canvas 容器无高度 → 地球渲染不出来
问题四:CESIUM_BASE_URL 指向子应用 origin → 跨域图片进 WebGL → SecurityError
每一个问题单独看都不复杂,但它们叠加在一起时,表现就是"地球不显示"这一个模糊的现象。希望这篇文章能帮到遇到类似问题的同学。
核心思路其实就一句话:理解沙箱对全局变量、脚本执行、DOM、网络请求这四个维度的影响,逐一排查。祝你的微前端 + 三维地球一切顺利。