前言
关于微前端的相关概念,这里就不再阐述。
目前基于 single-spa 来构建微前端应用是主流的实现,但是其官网的示例结合了大量他自己家的组件,较为繁琐。对于新手不太友好
本文将结合 Ant Design、SingleSpa、 Create React App 实现一个更加贴合实际业务场景的示例
我们将实现以下功能
- 基于 Ant Design、SingleSpa、 Create React App 构建
- 样式隔离
- 远程加载子模块资源
- 子应用自动生成包含 entrypoints 的 manifest 清单文件
完整代码可查阅:源码
基座
安装依赖
# 初始化项目
npx create-react-app main
# 安装依赖
npm install @ant-design/pro-layout axios single-spa url-join --save
基座需要实现的功能包括
- 基础布局
- 子应用生命周期管理(single-spa)
- 子应用远程资源加载
这里为了演示方便,我们直接使用 @ant-design/pro-layout 来搭建基础布局
container.jsx
export default function Container () {
const [settings, setSetting] = useState({ fixSiderbar: true });
const [pathname, setPathname] = useState('/welcome');
const history = useHistory();
return (
<div
id="test-pro-layout"
style={{
height: '100vh',
}}
>
<ProLayout
{...defaultProps}
location={{
pathname,
}}
waterMarkProps={{
content: 'Pro Layout',
}}
onMenuHeaderClick={(e) => console.log(e)}
menuItemRender={(item, dom) => (
<a
href="javascript:void(0)"
onClick={() => {
setPathname(item.path)
history.push(item.path)
}}
>
{dom}
</a>
)}
rightContentRender={() => (
<div>
<Avatar shape="square" size="small" icon={<UserOutlined />} />
</div>
)}
{...settings}
>
<div id="container" ></div>
</ProLayout>
</div>
);
};
这里需要注意的是,我们加了一个 id 为 container 的 div 用于承载子应用
完整代码可查阅源码,搭建完的效果如下图
我们这里配置了 home、 about 两个子应用
我们将应用信息保存在一个数组中,其中 host
表示加载子应用资源的地址、match
用于路由匹配
然后通过 single-spa 的 registerApplication
来注册应用
registerApplication 接受了 4 个参数
appName: string
.
应用名称,需保证唯一applicationOrLoadingFn: () => <Function | Promise>
.
加载子应用,返回 single-spa 所需的生命周期函数(mount、bootstrap、unmount)activityFn: (location) => boolean
.
用于做子应用的路由匹配,参数为 window.location ,必须为纯函数,可以自己实现路由配规则customProps?: Object | () => Object
.
在我们调用子应用的生命周期方法时,传递的参数。一般可用来传递共享的数据,例如 reduex 的 state,这里为了不增加复杂度,暂不使用。
applicationOrLoadingFn
加载子应用的时候,我们调用 loadResources 加载子应用远程资源
完整代码如下
// 配置子应用
const apps = [
{
name: 'home',
host: 'http://localhost:3001',
match: /^\/home/
},
{
name: 'about',
host: 'http://localhost:3002',
match: /^\/about/
}
]
// 注册应用
for (let i = 0, app = null; i < apps.length; i++) {
app = apps[i];
singleSpa.registerApplication(
app.name,
async (arg) => {
// 这里会去加载远程资源,加载完毕后,子应用暴露出 single-spa 需要的生命周期函数
await loadResources(app.host);
return window[app.name];
},
location => {
return app.match.test(location.pathname)
}
);
}
// 启动
singleSpa.start();
loadResources 加载子应用资源
加载子应用的远程资源的流程如下
子应用会提供一个 manifest.json ,描述了入口文件相关信息,类似
{
"entrypoints": {
"main": {
"chunks": [
"runtime-main",
"vendors~main",
"main"
],
"assets": [
"static/js/bundle.js",
"..."
"children": {
},
"childAssets": {
}
}
},
"publicPath": "http://localhost:3001/"
}
基座请求这个清单文件后,解析出 entrypoints 中的资源,创建对应的 style/script 标签加载资源
整个执行过程如下
、
具体代码
export const loadResources = async (url) => {
const [css, js] = await getManifest(url);
return Promise.all([loadStyles(css), loadScripts(js)])
}
加载 manifest.json
export const getManifest = (url) =>
new Promise(async (resolve) => {
const u = urlJoin(url, 'manifest.json');
const { data } = await axios.get(u);
const { entrypoints, publicPath } = data;
const key = getFirstKey(entrypoints);
if (!key) {
return resolve([])
}
const assets = (entrypoints[key].assets || []).filter((file) =>
/(\.css|\.js)$/.test(file)
);
const css = [],
js = [];
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
const assetPath = urlJoin(publicPath, asset);
if (/\.css$/.test(asset)) {
css.push(assetPath);
} else if (/\.js$/.test(asset)) {
js.push(assetPath);
}
}
resolve([css, js])
});
加载 style
export const loadStyles = async (res) => {
res = (res || []).filter(href => !Boolean(hasLoadedStyle(href)))
return Promise.all(res.map(loadStyle));
}
export const createStyle = async (url) => {
return new Promise((resolve, reject) => {
const styleLink = document.createElement("link");
styleLink.link = url;
styleLink.onload = resolve;
styleLink.onerror = reject;
document.head.appendChild(styleLink);
});
};
加载 script
export const loadStyles = async (res) => {
res = (res || []).filter(href => !Boolean(hasLoadedStyle(href)))
return Promise.all(res.map(loadStyle));
}
export const createScript = (url) => {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.type = 'text/javascript';
s.src = url;
s.onload = resolve;
s.onerror = (...rest) => reject(rest);
document.head.appendChild(s);
});
};
子应用
这里我们有 home、about 两个子应用
npx create-react-app home
npx create-react-app about
子应用需要实现的功能包括:
- 提供 manifest.json 清单文件
- 构建改造
- 样式隔离
- 提供 single-spa 应用所需的生命周期函数
提供应用生命周期方法
这里我们使用 single-spa-react
import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from 'single-spa-react';
import Container from './components/Container'
import './index.css';
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
domElementGetter: () => document.getElementById('container'),
rootComponent: Container
});
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
构建改造
前面提到,在加载子应用资源后,子应用会把 single-spa 生命周期的函数暴露在 window 上提基座去调用,这需要我们队整个构建 output 做一下改造,我们项目是使用 create-react-app
创建的,这里我们使用 react-app-rewired
来扩展
npm install react-app-rewired -D
然后将 package.json
的 scripts
替换成 create-react-app
来执行
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
}
在项目跟目录创建 config-overrides.js
需要改的地方有两点
- 1、指定导出的对象和挂载点
- 2、解决跨域问题
如下
module.exports = {
webpack: function(config, env) {
// 应用名称
config.output.library = 'about';
config.output.libraryTarget = "window";
// 默认是"/",因为子应用资源是在基座中执行的,需要重新指定,这里为了演示方便直接写死
config.output.publicPath = 'http://localhost:3002/';
return config;
},
// 解决跨域问题
devServer: function(configFunction) {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.disableHostCheck = true
config.headers = config.headers || {}
config.headers['Access-Control-Allow-Origin'] = '*'
return config
}
}
}
提供 manifest.json
这里我们借助webpack-stats-plugin插件来实现
npm install webpack-stats-plugin -D
在 config-overrides.js
中添加配置
config.plugins.push(
new StatsWriterPlugin({
fields: ['entrypoints', 'publicPath'],
filename: "manifest.json" // 文件名称
})
)
加完重新启动项目,试试访问 localhost:{port}/manifest.json
样式隔离
这里我们使用 postcss-selector-namespace 添加命名空间的插件来实现,子应用的 css 选择器都是加上一个前缀来实现隔离
扩展 postcss 的配置我们用react-app-rewire-postcss
npm install react-app-rewire-postcss postcss-selector-namespace -D
继续修改 config-overrides.js
,完整的配置文件如下
const { StatsWriterPlugin } = require("webpack-stats-plugin");
module.exports = {
webpack: function (config, env) {
require("react-app-rewire-postcss")(config, {
plugins: (loader) => {
console.log(loader)
return [
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009",
}
}),
require("postcss-selector-namespace")({
namespace(css) {
// 前缀,如果有全局样式不需要添加的,也可以在这里过滤
return ".micro-frontend-home";
},
}),
]
}
});
config.output.library = "home";
config.output.libraryTarget = "window";
// 默认是"/",因为子应用资源是在基座中执行的,需要重新指定,这里为了演示方便直接写死
config.output.publicPath = "http://localhost:3001/";
config.plugins.push(
new StatsWriterPlugin({
fields: ["entrypoints", "publicPath"],
filename: "manifest.json", // 文件名
})
);
return config;
},
devServer: function (configFunction) {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.inline = true;
config.disableHostCheck = true;
config.headers = config.headers || {};
config.headers["Access-Control-Allow-Origin"] = "*";
return config;
};
},
};
完整代码可查阅:源码