微前端框架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>

后记

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

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

相关推荐
VT.馒头15 分钟前
【力扣】2629. 复合函数——函数组合
前端·javascript·算法·leetcode
╰つ゛木槿17 分钟前
NPM安装与配置全流程详解(2025最新版)
前端·npm·node.js
每天吃饭的羊35 分钟前
React 性能优化
前端·javascript·react.js
hzw05101 小时前
使用pnpm管理前端项目依赖
前端
风清扬雨1 小时前
Vue3中v-model的超详细教程
前端·javascript·vue.js
高志小鹏鹏1 小时前
掘金是不懂技术吗,为什么一直用轮询调接口?
前端·websocket·rocketmq
八了个戒1 小时前
「JavaScript深入」一文说明白JS的执行上下文与作用域
前端·javascript
高志小鹏鹏1 小时前
Tailwind CSS都更新到4.0了,你还在抵触吗?
前端·css·postcss
qingyun9892 小时前
封装AJAX(带详细注释)
前端·ajax·okhttp
鱼樱前端2 小时前
前端工程化面试题大全也许总有你遇到的一题~
前端·javascript·程序员