前言
花了一个多月时间和同事一起将老项目完成了微前端改造,方便后期迭代不再拘泥于陈旧的技术栈。特此记录一些踩坑细节和具体实现流程,引申出对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>
后记
实战中解决问题结合的是思考、试验并求证的能力,很多时候经过多次试错后才有纸上得来终觉浅的感受。问题很多时候并不复杂,我们更多要学会的是一步步思考后庖丁解牛,最后找到思路。解决的过程也巩固了对作为基础的知识和概念的理解。
如有遗误,敬请斧正。原创不易,后续会更新,欢迎关注。