最近把组内的一个比较大的微前端项目做了下重构。之前是基于 single-spa 结合软链接子应用代码来统一打包,存在以下问题:
- 基座有额外的维护成本。基座需要适配所有子应用的打包配置,比如 less-loader、ts-loader,这样造成的情况是新增应用的成本较大,有未知的修改基座的成本在,因为可能会修改构建配置
- 软链接子应用代码的形式,造成打包逻辑较为复杂,中途构建出错需要删除干净所有的软链
- 不支持子项目使用移动端适配插件,比如 px2rem
- 不支持子项目定义全局样式,会造成 css 污染
- css 隔离和 js 沙箱配置难度大
综上,重构目标有几点
- 去除基座的维护成本。将统一打包改为子应用分别打包,但是会相应地增加一些重复的公共依赖
- 更靠谱的前端编译。优化前端打包流程,去除软链接,减少前端构建出错的情况
- 更独立的子项目,涉及样式定义、移动端适配等
- 更容易接入新的子应用,支持 css 隔离和 js 沙箱
重构步骤
应用部署方式
共用一个端口,通过二级目录划分子应用,主应用通过一级目录路由访问对应的子应用
部署方式参考 https://qiankun.umijs.org/zh/cookbook#%E5%A6%82%E4%BD%95%E9%83%A8%E7%BD%B2
基座引入 qiankun
基座不限技术栈,可以基于 CRA5 新建
yarn add qiankun
修改入口文件 index.ts
import { registerMicroApps, start, setDefaultMountApp } from "qiankun";
registerMicroApps([
{
name: "reactApp",
// 需要确保斜线结尾,防止资源加载异常
entry: "/project/app1/",
container: "#app1-root",
activeRule: "/app1",
},
{
name: "vueApp",
entry: "/project/app2/",
container: "#app2-root",
activeRule: "/app2",
},
]);
// 启动 qiankun
start({
// 去除baidu脚本的跨域限制
// 注意:这种方式只支持异步引入baidu sdk,不支持在script标签引入
excludeAssetFilter: (url) => {
return url.indexOf("api.map.baidu.com") !== -1;
},
});
// 设置默认启动应用
setDefaultMountApp("/app1");
调整 index.html
// ...
<div id="root"></div>
<div id="app1-root"></div>
<div id="app2-root"></div>
异步引入 baidu jssdk
去除 script 标签引入的代码
<!--引入百度地图api-->
<script
type="text/javascript"
src="https://api.map.baidu.com/api?v=3.0&ak=你的密钥"
></script>
改成以下方式:
function loadBaiduJssdk(url) {
return new Promise((resolve, reject) => {
const scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.src = url;
document.body.appendChild(scriptEl);
scriptEl.onload = resolve;
scriptEl.onerror = reject;
});
}
// 异步加载
async function initMap() {
await loadBaiduJssdk("https://api.map.baidu.com/api?v=3.0&ak=你的密钥");
}
子应用配置
CRA5 新增配置
// config-overrides.js
const setOutputForQiankun = () => (config) => {
config.output.library = `${name}`;
config.output.libraryTarget = "umd";
config.output.globalObject = "window";
return config;
};
// ...
module.exports = {
webpack: override(
setOutputForQiankun(),
// ...
)
devServer: overrideDevServer((config) => {
config.historyApiFallback = true;
config.open = false;
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
}),
}
在 src 下新增 pubilc-path.js
(ts 也行)
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
调整入口文件(src/index.ts),暴露钩子函数。注意根节点要与 container 保持一致(也需要调整 public/index.html,默认根节点 id 为 root)
import "public-path.js";
// ...
function getSubRootContainer(container) {
return container
? container.querySelector("#app1-root")
: document.querySelector("#app1-root");
}
function render(props) {
const { container } = props;
ReactDOM.render(<App />, getSubRootContainer(container));
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
export async function bootstrap() {
console.log("react app bootstraped");
}
export async function mount(props) {
console.log("props from main framework", props);
render(props);
}
export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(getSubRootContainer(container));
}
以上代码基于 react16
react18 可以参考 https://github.com/ice-lab/icestark/issues/581
路由配置
basename 和 qianiun 的配置保持一致,如果是 qiankun 访问,需要追加主应用配置的路由前缀,参考如下
// app.js
// ...
return (
<Provider store={STORE}>
<Router
basename={
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.__POWERED_BY_QIANKUN__ ? "/app1" : process.env.REACT_APP_BASEURL
// 在环境变量中增加路由前缀 REACT_APP_BASEURL
// 和 activeRule 对齐,也就是 /app1
}
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<MainView />}
{/* ...其他路由 */}
{/* 路由重定向 */}
<Route path="*" element={<Navigate to="/" />}
</Routes>
</Suspense>
</Provider>
)
构建加速
因为项目的特殊性,每一次构建都要构建所有子项目,因此可以使用缓存,缓存各项目在主分支上最近可用的 build 包,主要是根据 commitId 判断缓存的有效性
最终达到的效果是只会构建有修改的子项目,其余子项目沿用缓存,无需重新安装依赖和构建
优化 TODO
- 理论上还能做 node_modules 的缓存
- qiankun 提取公共依赖,减少子项目重复安装