Vue3微前端加载Mars3D地球不显示?四个隐藏Bug连环爆炸,我含泪踩坑两天终于全部修复

2026-08-15 开发技术 6 次阅读 0 次点赞
本文记录了在Vue 3 + Vite项目中,使用京东micro-app微前端框架加载Mars3D三维地球子应用时遇到的四个关键问题及解决方案。问题包括:Cesium全局变量在沙箱中丢失导致mars3d无法访问;ES Module脚本在with沙箱中因new Function执行而报import语法错误;micro-app自定义元素默认尺寸为0导致Canvas无法渲染;以及CESIUM_BASE_URL跨域导致WebGL SecurityError。作者通过micro-app的loader钩子改写代码、配置escapeProperties、设置元素尺寸和Vite代理等手段逐一解决。核心思路是理解沙箱对全局变量、脚本执行、DOM和网络请求的影响,并提供了完整的配置示例和调试技巧。

本文记录了在一个真实的 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 undefinedSyntaxError: 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-app 1.0-rc.30
  • 子应用:Vue 3.5 + Vite 8 + mars3d 3.11 + mars3d-cesium 1.140 + vite-plugin-mars3d 4.2

子应用跑在 http://localhost:5173,主应用跑在 http://localhost:5174

子应用是怎么工作的

在讲问题之前,先理清子应用独立运行时发生了什么。

子应用用了 vite-plugin-mars3d 插件,这个插件做了几件事:

  1. mars3d-cesium(Cesium 的完整 UMD 构建)和 mars3d 都作为外联 UMD 脚本加载,不打包进 JS bundle
  2. index.html 中注入一个内联脚本设置 CESIUM_BASE_URL,告诉 Cesium 去哪里找 Workers、Assets、Widgets 等静态资源
  3. 应用代码里通过 window.Cesiumwindow.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.mars3dwindow.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.Cesiumundefinedmars3d.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 上。这样在主应用上下文执行的代码也能访问到 Cesiummars3d

问题二: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-appmicro-app-body 这两个自定义元素的高度是 0。

原因分析

micro-app 是一个自定义元素(Custom Element),浏览器默认给它的 displayinline,高度自然是 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-appmicro-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-containermicro-appmicro-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': '*',
    },
  },
})

调试技巧总结

在这个过程中积累了一些实用的调试技巧,分享给大家:

  1. 善用 micro-app 的 loader 钩子:它可以在脚本加载前动态修改代码,是处理沙箱兼容性问题最灵活的工具。所有 var 声明、全局变量路径、内联脚本配置都可以在这里改写。

  2. escapeProperties 是连接沙箱内外的桥梁:子应用在 proxyWindow 上设置的变量默认不会出现在主应用的 window 上。如果你在主应用上下文执行的代码(如原生 module 脚本)需要访问子应用创建的全局变量,就用 escapeProperties 把它们"逃逸"出来。

  3. WebGL 跨域问题用同源代理解决:不要试图给 Cesium 的图片加 crossOrigin 属性(有些图片是 Cesium 内部创建的,你改不了),最干净的方案是让 CESIUM_BASE_URL 指向主应用 origin,再用 Vite proxy 转发。

  4. micro-app 自定义元素需要显式设置尺寸micro-appmicro-app-body 默认 display: inline,高度为 0。一定要在 CSS 中显式设置 display: blockheight: 100%,否则 Canvas 类应用无法渲染。

  5. 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、网络请求这四个维度的影响,逐一排查。祝你的微前端 + 三维地球一切顺利。

标签: Mars3DCesium
最后更新于8小时前

评论 (0)

登录 后发表评论

暂无评论,快来发表第一条评论吧!