背景
近几年微前端越来越热,身边很多团队都在尝试做微前端的落地,在了解过微前端的基本概念之后,正好手头有个老旧项目改版重做的需求,因为这个项目比较特殊,面临一些比较棘手的问题:
- 项目为
express + ejs + jquery
前后端不分离项目 - 历史原因项目代码极难维护(主要体现在没有模块化和各种糟糕的冗余页面冗余代码)
- 历史原因中间件及一些查询逻辑冗余导致页面打开很慢
最开始的设想只是简单的用react
重构整个项目,实现模块化和前后端分离,这样以后就不用再维护恶心人的老代码了,但是由于当时另一个项目排期比较紧且更重要,所以完整的用react
重构整个项目时间上就来不及,一番构思之下我决定将新项目微前端化,因为这次老项目页面改版只涉及到了所有的详情相关页面,创作相关页面,其余的很多页面并无修改,针对此需求我决定对新项目进行微前端改造,按功能我对老项目进行了模块化拆解,拆解后微前端的模块结构如下:
- 主应用(用来挂载其他各子应用)
- 详情子应用(所有的详情页面)
- 登录子应用(登录注册相关模块)
- 创作子应用(文章创作编辑器模块)
- iframe子应用(通过iframe来加载暂未重构的老项目其他模块)
做了应用拆分之后,后续的开发看起来就一目了然了,用react + single-spa
分别去开发每个子应用和主应用就可以了,但是这里还面临一个问题,那就是剩余暂时不需要改的页面是服务端渲染的ejs+jquery
的项目,该怎么加载进新项目的主应用?我最后的方案是额外创建一个iframe
的壳应用,并且用react-router
路由控制加载,这里具体的方案后面会详细说。
至此新项目的大体架构就已经出来了,剩下的就是技术层面的实现细节~
single-spa原理
我们在使用诸如react
或vue
这些框架开发普通的单页应用时,会按照功能的封装或者样式的复用把页面拆分成很多的组件,组件可以拼装成一个个单独的页面,并且通过路由控制监听地址栏变化,动态加载不同的页面和组件,这就是单页应用运行的简单原理。
single-spa
原理也大同小异,也可以类比的理解为一种模块化开发的单页应用,只不过在单项目的单页应用中划分的模块是各种组件,而在微前端中各个模块就是各个独立的子应用,用户访问基座主应用时会按照配置注册各子应用,并且根据路由来确定分发到哪个子应用来加载,对应子应用加载之后会再根据路由分发到对应的子应用里的页面,这就是single-spa
的路由加载原理先路由分发应用,再应用分发路由
基座应用开发
根据前面的划分思路,可知我们的基座应用应该具有注册和加载子应用的功能,针对需求仔细分析发现:
我们的各个子应用基本都需要用到用户的身份信息诸如userid
等数据,但是这些数据的获取是在登录注册的逻辑里的,所以我们还需要一个类似redux
的东西,把需要共享的数据提升到基座应用进行存储和分发,就实现了各应用间数据共享,这也就是消息总线
single-spa
通过路由来分发应用,当url
分发不到对应的应用时应该进行404页面
跳转,并且从App
分享到微信
的链接,在不改动App
的情况下,因为新旧项目同一个页面路由不同,所以还需要在基座层进行一些特殊路由的重定向
所以在基座应用中我们需要实现以下功能:
- 加载子应用的模块加载器
- 一个全局的状态仓库负责状态共享和分发
- 路由控制模块
模块加载器
single-spa
中通常搭配使用systemjs
用于模块加载,用systemjs
请求打包好的子应用的single-spa
入口文件地址,之后systemjs
通过eval
解析并执行拿到的代码。因为要加载多个子应用,所以封装一个模块加载器
/*
/ice/src/Loader.js
*/
import * as singleSpa from "single-spa";
import createHistory from "history/createBrowserHistory";
export const history = createHistory();
/**
* 判断路由是否匹配
* @param {string} path 待匹配路径
*/
export const pathMatch = (path) => {
return (location) => {
return location.pathname.startsWith(`${path}`);
};
};
/**
* 通过应用打包生成的json文件获取对应入口文件
* @param {string} url
* @param {string} baseUrl
* @param {string} entryName
*/
export const getEntryfromJson = async (url, baseUrl, entryName) => {
const fetchResult = await fetch(url).then((s) => s.json());
const { entrypoints, publicPath } = fetchResult;
let truePath = `${publicPath}${entrypoints[entryName].assets[0]}`
return getResult;
};
/**
* 子应用加载器
* @param {string} name
* @param {string} pathName
* @param {string} appUrl
* @param {string} baseUrl
* @param {boolean} hasStore
* @param {GlobalEventDistributor} globalEventDistributor
*/
export const loadApp = async (
name,
pathName,
appUrl,
baseUrl,
hasStore,
globalEventDistributor
) => {
let store = {},
props = { globalEventDistributor };
try {
store = hasStore
? await getEntryfromJson(appUrl, baseUrl, "store")
: { storeInstance: null };
} catch (err) {
console.log(`加载${name}数据仓库失败:${err}`);
}
if (store.storeInstance && globalEventDistributor) {
props.store = store.storeInstance;
globalEventDistributor.registerStore(store.storeInstance);
}
SystemJS.config({ transpiler: "transpiler-module" });
props.history = history;
props.data = globalEventDistributor && globalEventDistributor.getState();
singleSpa.registerApplication(
name,
() => getEntryfromJson(appUrl, baseUrl, "singleSpaEntry"),
pathMatch(pathName),
props
);
};
上面没有直接请求文件地址,而是通过请求每个子项目生成的json
文件,从json
中读取对应文件的地址,这么做的目的是解决缓存问题,这样子项目打包生成的文件名后面的hash值每次都不一样,并且会写进json
中,从json
中我们就可以知道对应文件的名字是什么,就解决了诸如微信浏览器里面打开的缓存问题。
全局状态仓库
在single-spa
里面实现类似普通的单页react
应用中全局的redux
数据仓库功能的思路是这样的,在各子应用中实现各子应用的状态仓库,然后将各自的store
对象加载进基座应用,在基座应用实现一个全局的状态仓库类,整合实现store
的功能,代码如下:
/**
* 全局状态仓库类,提供整合注册store已经dispatch和getState功能
*/
export class GlobalEventDistributor{
constructor() {
super();
this.stores = [];
}
registerStore(store) {
this.stores.push(store);
}
dispatch(event) {
this.stores.forEach((s) => {
s.dispatch(event);
this.emit("dispatch");
});
}
getState() {
let state = {};
this.stores.forEach((s) => {
let currentState = s.getState();
state = { ...state, ...currentState };
});
return state;
}
subscribe(subHandle) {
this.stores.forEach(s => {
s.subscribe(() => {
subHandle.apply(null, [this.getState()])
})
})
}
}
single-spa入口文件
将所有的子应用注册在single-spa
的打包入口文件中注册
import * as singleSpa from "single-spa";
import { GlobalEventDistributor } from "./globalEventDistributor";
import { loadApp } from "./loader";
const init = async () => {
const globalEventDistributor = new GlobalEventDistributor();
const loadingArray = [];
loadingArray.push(
loadApp(
"login",
"/login",
"/app1/manifest.json",
"/app1",
true,
globalEventDistributor
)
);
loadingArray.push(
loadApp(
"show",
"/show",
"/app2/manifest.json",
"/app2",
false,
globalEventDistributor
)
);
loadingArray.push(
loadApp(
"create",
"/create",
"/app3/manifest.json",
"/app3",
true,
globalEventDistributor
)
);
loadingArray.push(
loadApp("iframe", "/iframe", "/app4/manifest.json", "/app4", false, null)
);
await Promise.all(loadingArray);
singleSpa.start();
};
init();
以上就完成了基座应用的基本开发,但还有一个小的优化点,因为我们在一开始的应用规划里有一个iframe
的子应用,即所有非新开发的页面的路由,要跳转到iframe
的子应用,用该应用里的iframe
来打开老项目里对应页面,所以我们需要在基座应用里设计一下路由控制的模块,这里我是用react-router
做的路由管理,新建一个route.js
文件,然后在webpack
里面把这个文件也单独设成一个打包入口就可以了,代码如下:
import React from "react";
import { BrowserRouter, Switch, Route, Redirect } from "react-router-dom"
import {history} from "./Loader.js"
const Router = () => {
return (
<BrowserRouter history={history}>
<Switch>
<Route path="/show"/>
<Route path="/login" />
<Route path="/create" />
<Route path="/iframe" />
<Redirect to={`/iframe${window.location.pathname}`} />
</Switch>
</BrowserRouter>
)
}
export default Router
因为在single-spa
的打包入口里面我们已经引入了systemjs
来加载对应应用,所以在路由变化为对应地址时,systemjs
会接管应用的加载,因此在这个route.js
里不必再通过react-router
来控制页面加载了,之所以会把各子应用的路由匹配写上,主要是为了在匹配到对应路由时不会触发Redirect
组件,上面代码就实现了非应用内实现的路由重定向到iframe
里打开的逻辑。
例如访问:www.abc.com/test
会被重定向为www.abc.com/iframe/test
,新的应用里我没有实现test这个路由对应的页面,而老项目里有,所以通过iframe来打开老页面,这样就实现了重构的目的里的渐进迁移
小结
以上就是基座应用的实现及思路,关于webpack配置
不再赘述,后面会继续分享子应用的实现及打包部署相关问题