微前端框架MicroApp本地开发改造篇--vite适配

前言

花了一个多月时间和同事一起将老项目完成了微前端改造,方便后期迭代不再拘泥于陈旧的技术栈。特此记录一些踩坑细节和具体实现流程,引申出对micro-app框架源码和vite开发服务器的更深入理解,写下此文以期温故知新。

MicroApp原理简述

看完标题你可能会好奇,本地开发不是都交给vite/webpack-dev-server了吗,有什么需要介入或者配置的?所以这里简单提一下microapp的原理。

目前市面上的微前端框架大概可以分为三类:其一是以single-spa和qiankun为代表的路由映射子应用,监听url变化切换渲染的容器;二是基于webpack5的模块联邦,让一个应用可以动态加载独立部署的另一个应用的代码;三则是microapp使用的方法,即HTML Entry + CustomELement。通过将子应用封装在自定义元素中,用HTML(通常是你使用的前端框架的index.html)的url获取子应用dom和script、link资源,能够完全控制管理多个子应用的渲染和跳转。

官网的示例,定义了micro-app这个自定义元素,通过url去fetch到子应用的资源,在内部控制它的渲染逻辑

遇到的问题

问题1.开发环境跨域

可以看到,主应用和子应用通常部署在不同的域名下。那么主应用fetch请求子应用资源时就会遇到跨域问题。生产环境下,可以在nginx网关托管子应用的静态资源时配置CORS相关设置,或者直接在同域名端口下通过不同路径区分部署(如果可以的话后面会有docker部署篇,这里暂时按下不表)。

但是本地单独调试主应用/子应用的时候呢,vite/webpack-dev-server的开发服务器可是在你的localhost。只单独起主/子应用的话,如果要调试的功能和整个应用全局的布局/全局共享状态相关呢?如果想开发环境下完全可控,同时启动主应用和子应用的本地开发服务器,也是在不同的端口。

问题2.本地调试主应用和多个子应用?

如果同时启动主应用和子应用,在主应用中插入的micro-app标签的url需要指向你的子应用本地开发服务器地址,而不是线上你分配给子应用的地址。难道每次启动的时候都去手改?即使舍得这个功夫,在多个子应用同时存在时又如何管理呢?总归不够优雅。

跨域问题

代码准备

用vite起两个vue3的项目,分别作为主子应用。在主应用中安装micro-app作为依赖(以后有机会记录一下用pnpm管理微前端的monorepo),加载:

js 复制代码
import microApp from "@micro-zoe/micro-app";
microApp.start();

注意,让vite的vue插件认识micro-app标签是自定义元素,而非Vue组件:

js 复制代码
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.includes("micro-app"),
        },
      },
    }),
  ],

在主应用的App.vue中添加micro-app标签,全局唯一的name,url为子应用的地址:

js 复制代码
<micro-app name="sub-vue" url="http://localhost:8002" iframe></micro-app>

后台起一个Express处理一下请求:

js 复制代码
const express = require("express");
const app = express();
const port = 3000;

app.get("/api/a", (req, res) => {
  res.send("Hello from /api/a!");
});

app.get("/api/b", (req, res) => {
  res.send("Hello from /api/b!");
});

app.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
});

把请求代理到后端的3000端口,我们在主子应用分别fetch试一下,看起来没什么问题:

js 复制代码
  server: {
    port: 8001,
    proxy: {
      "^/api": {
        target: "http://localhost:3000",
      },
    },
  },
js 复制代码
fetch("/api/b", { method: "GET"})
  .then((response) => response.text())
  .then((data) => console.log("Response:", data))
  .catch((error) => console.error("Error:", error));

但是如果携带cookie之后:

js 复制代码
app.get("/api/a", (req, res) => {
  res.cookie("user-1", "session-id:1", { httpOnly: true });
  res.send("Hello from /api/a!");
});

app.get("/api/b", (req, res) => {
  res.cookie("user-2", "session-id:2", { httpOnly: true });
  res.send("Hello from /api/b!");
});

fetch("/api/b", {
  method: "GET",
  credentials: "include", // 设置为携带 Cookie
})

为什么会跨域?

一些必要的延伸:微前端通常需要JS和CSS沙箱隔离,JS沙箱隔离是为了防止变量污染全局。micro-app中有两种沙箱:一种是with沙箱,里面重写了xhr方法,去拼接子应用自己的域名:

iframe沙箱更简单粗暴,因为代码通过在新建的同名iframe标签里执行,可以直接用base标签指定所有相对路径的前缀,包括资源script、link,请求fetch、xhr等:

会这么做的原因也很好理解,通常我们在自己的项目里加载资源都会写相对路径,避免每次都写完整的冗长前缀。而子应用主应用通常分属不同的域名下,子应用获取资源补全的路径应该是自己线上的域名。所以我们现在应该知道,为什么浏览器会认为这是个从localhost:8001(当前地址栏网页)到localhost:8002(实际服务器地址)的跨域请求。

Solve It

问题转换为如何处理到8001请求的跨域。我们习惯将vite当作一个本地静态资源的服务器,当浏览器请求时将插件处理过的文件通过Native ESM提供。其实当我们写了proxy后,8001端口上的进程还会帮我们将网络请求转发到target url:这个代理来自node-http-proxy。在configure函数里可以拿到这个代理的实例。看一下文档里提到的事件:

我们的需求是在返回的响应中修改允许携带cookie的相关响应头。而在proxyRes事件中,我们能拿到请求和响应:

js 复制代码
  proxy: {
    "^/api": {
      target: "http://localhost:3000",
      configure: (proxy) =>  {
        proxy.on("proxyRes", (proxyRes) => {
          proxyRes.headers["access-control-allow-credentials"] = true;
        })
      }
    },
  },

轻松又愉快?

But Then...

如果有一天,我们突然有需求,要标识客户端类型/请求类型等,给每个请求加参数自然是不太可行。于是我们决定给配置通用的自定义请求头(axios可以在请求拦截器里做,fetch考虑重写全局方法):

js 复制代码
fetch("/api/b", {
  method: "GET",
  credentials: "include", // 设置为携带 Cookie
  headers: {
    "x-client": "PC Chrome", // 自定义请求头
  }
});

报错一样吗,好像又有点不一样。多了一个关键字preflight(预检)。

复习下跨域相关知识:

form MDN

定义了自定义请求头,超过了简单请求规定的request headers的范围,所以浏览器发送了预检请求。而方法为OPTIONS的预检请求看起来并没有被vite开发服务器的server.proxy处理,毕竟本来也不是开发者控制的行为。加上允许的请求头项和打印,结果相同,并没有执行。

js 复制代码
proxy.on("proxyRes", (proxyRes) => {
    console.log(proxyRes);
    proxyRes.headers["access-control-allow-credentials"] = true;
    proxyRes.headers["access-control-allow-headers"] = "x-client";
})

换个思路,开发服务器其实就是一个express服务,根据请求提供文件系统的文件。根据vite文档,可以给开发服务器配置CORS,为express中CORS中间件的实例:

js 复制代码
  server: {
    // ...
    cors: {
      origin: (origin, cb) => cb(null, origin),
      allowedHeaders: ["x-client"],
      credentials: true,
    },
  },

又可以轻松愉快的继续开发了。

聪明的你可能已经想到了,为什么开头不带cookie和custom headers的时候简单请求可以跨域呢。可以看下server.cors的默认配置:

默认允许了localhost、v4和v6的本地回环地址为源的访问。

进一步的,有了cors配置,前文的configure server是否就不需要了呢?理论上是可以的,但是一来在configure函数里可以拿到代理的实例,以及完整的请求和响应,可以在这里做更多自定义逻辑和对响应更细粒度的控制。再来我们团队发现,代理在一些情况下必须在configure里手动拼接请求流,如SSE长连接。

本地调试url指向问题

实际应用中,在主应用配置子应用路由时(这里只讨论SPA的情况),将某个子应用下的路径都放进一个组件下,这个组件包含micro-app标签。我们的做法是将name和url放在路由元信息里,在路由组件中获取后设置在micro-app标签上,从而区分不同的子应用。而在具体某个子应用中,交由子应用内部自己的路由实现。

如果是因为权限控制等原因,路由表是应用初始化时从后端拿的,要做到本地调试时接入本地开发地址就更麻烦了。我们可以在localhost里写一个特殊的键,提供本地开发相关的信息。加载的时候优先判断localhost里有无特殊的键名,存在的话当本地开发处理,没有的话fallback到使用路由meta信息里的url。

js 复制代码
<script setup>
import { useRoute } from "vue-router";
import { watch } from "vue"

const route = useRoute();
const subAppName = ref("");
const subAppUrl = ref("");
const localDevKey = "__LAOBANJIU_MICRO_APP_DEV__";

watch(
  () => route.path, 
  () => {
    const name = route.meta?.subAppName;
    const devInfoJson = localStorage.getItem(localDevKey);
    if (devInfoJson) {
      // 有对应值,走本地开发
      try {
        const devInfo = JSON.parse(devInfoJson);
        subAppUrl.value = devInfo?.[name];
      } catch (e) {
        console.log(`Parse Error: ${e}`);
      }
    } else {
      // 用路由表信息
      subAppUrl.value = route.meta?.url;
    }
    subAppName.value = name;
  },
  { immediate: true }
);
</script>

<template>
  <micro-app :name="subAppName" :url="subAppUrl" iframe></micro-app>
</template>

后记

实战中解决问题结合的是思考、试验并求证的能力,很多时候经过多次试错后才有纸上得来终觉浅的感受。问题很多时候并不复杂,我们更多要学会的是一步步思考后庖丁解牛,最后找到思路。解决的过程也巩固了对作为基础的知识和概念的理解。

如有遗误,敬请斧正。原创不易,后续会更新,欢迎关注。

相关推荐
恋猫de小郭12 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅19 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606120 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了20 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅20 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅20 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅21 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment21 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅21 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊21 小时前
jwt介绍
前端