知识储备篇
什么是微前端( or 微前端架构 )?
定义
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
简单来说就是将大型企业级web应用拆分成一个个小型工程,也就是“微应用”,再通过主应用将微应用串联整合起来,实现所有应用页面的展示。
特点 & 优势
- 技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权
- 独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立运行时
每个微应用之间状态隔离,运行时状态不共享
核心价值
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
为什么选择微前端?
在开发一个企业级是中后台项目时,生命周期会相对较长,通常随着需求增加以及功能的不断完善,我们的项目会越来越庞大,此时我们的应用就会演变成一个巨石应用。随之而来就导致项目难维护,新人上手成本高。
梳理巨石应用的弊端如下:
-
项目庞大引用依赖包多,打包上线慢;
-
项目初期野蛮生长过度,代码难以维护,产品迭代举步维艰;
-
toB应用对于客户定制化需求,很难快速响应迭代;
所以,当我们有一个相对独立且完整的新需求,往往是由一个新团队来开发,并希望可支持独立开发&部署,最终期望有入口接入原应用,并和原应用优雅的融合,
Why Not Iframe?
确立了微前端架构后,当我们开始思考实现方案时,首先想到的就是iframe,那么我们可以来盘点一下iframe的优势和使用痛点。
优势(隔离)
js和css隔离,不需要考虑微应用和主应用之间的js和css冲突
使用痛点(难以突破的隔离)
- 无状态的URL,主应用浏览器刷新 iframe url 状态丢失,后退前进按钮无法控制iframe。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
- iframe高度不能自适应。
问题解决方案
- 问题1:监听URL变化,js控制iframe的URL
- 问题2: 无法解决,除非通过父子应用通信,实现一套“弹框 UI 的数据协议”,由父应用展示弹窗。
- 问题3: 父子应用通信。
- 问题4: 可以睁一只眼闭一只,暂不解决
- 问题5: 无法解决
这些痛点解决起来很麻烦,有点强行解决了也会是父子应用耦合度很高,这使开发者不能专注业务逻辑,并给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
什么是「乾坤(qiankun)」?
承接着上面提到的iframe,那么除此之外能否有一个相对无痛的微应用解决方案呢?
有,那就是qiankun。qiankun介绍
简介
qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
特点
-
📦 基于 single-spa 封装,提供了更加开箱即用的 API。
-
📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
-
💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
-
🛡 样式隔离,确保微应用之间样式互相不干扰。
-
🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
-
⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
-
🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。
核心API
其实qiankun的API很少,3分钟就能get,下面分两个场景来说明:
也可以在这里查看详细的文档:qiankun.umijs.org/zh/api
场景一:路由激活
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // 微应用的名称,微应用之间必须确保唯一
entry: '//localhost:7100',// 微应用入口,表示微应用html的访问地址
container: '#yourContainer',// 微应用的容器节点的选择器或者 Element 实例
activeRule: '/yourActiveRule',// 微应用的激活规则,匹配到这个前缀时会激活当前应用
},
{
name: 'vue app',
entry: { scripts: ['//localhost:7100/main.js'] },
container: '#yourContainer2',
activeRule: '/yourActiveRule2',
},
]);
start();// 启动 qiankun。有prefetch,sandbox的可选参数
场景二:手动加载
import { loadMicroApp } from 'qiankun';
loadMicroApp({
name: 'app', // 微应用的名称,微应用之间必须确保唯一
entry: '//localhost:7100', // 微应用入口,表示微应用的访问地址
container: '#yourContainer', // 微应用的容器节点的选择器或者 Element 实例
});
由上面可以看出,使用乾坤的过程中,就是需要告诉乾坤需要加载的应用名字是什么,去哪里请求到页面,放到主应用的哪个元素下。
项目实战篇
基于qiankun(乾坤)搭建微前端项目
接下来分别从主应用和子应用两个方面来说明项目如何搭建,需要注意的是主应用和子应用都可以选择各自不同的技术栈,这不影响微前端架构的搭建,具体如下:
主应用
1. 安装 qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
2. 在主应用中注册微应用
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
},
{
name: 'vue app',
entry: { scripts: ['//localhost:7100/main.js'] },
container: '#yourContainer2',
activeRule: '/yourActiveRule2',
},
]);
start();
or 手动加载一个微应用
import { loadMicroApp } from 'qiankun';
loadMicroApp({
name: 'app',
entry: '//localhost:7100',
container: '#yourContainer',
});
比如,直接在某组件的生命周期中执行loadMicroApp,以引入一个微应用,示例如下:
import { FC, useState, useEffect } from 'react';
import { loadMicroApp } from 'qiankun';
const MicroAppA: FC = () => {
const [app, setApp] = useState({})
useEffect(() => {
const a = loadMicroApp({
name: 'social-relationship',
entry: '//localhost:3001',
container: '#reactContainer',
});
setApp(a);
return () => {
(app as any).unmount && (app as any).unmount();
}
}, [])
return <div>
<div id="reactContainer"></div>
</div>
}
export default MicroAppA
微应用( React项目 )
1.创建项目
npx create-react-app my-app
cd my-app
npm start
引入react-router-dom,编写自己的业务页面代码。
2.在 src 目录新增 public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
⚠️注意:如果使用ts,需要加一行注释避免构建报错。
- __POWERED_BY_QIANKUN__ 代表是否是以微服务架构乾坤启动的,如果是通过主应用启动并访问到子应用,这个参数会是true;
- __INJECTED_PUBLIC_PATH_BY_QIANKUN__代表由乾坤主应用注入的公共路径publicPath,其实就是在主应用代码中配置的entry,经过赋值之后就可以从__webpack_public_path__取到主应用的entry了;(ps: 这个值在一个iframe页面中,设置src的属性值时有用到)
3.设置 history 模式路由的 base
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
⚠️注意:此处的/app-react,需要和主应用协议好,主应用如果是希望在/app-react路由下展示微应用,需要这样设置,具体场景如下:
主应用中/app-react/koc这个路径的定义有讲究,可以说是完全取决于微应用,微应用必须定义了basename, 且存在/koc这个路由。主应用休想用/app-react/test这个path去匹配和展示微应用的/koc;
4.入口文件 index.js 修改
为了避免根 id #root 与其他的 DOM 冲突,需要限制查找范围。
在接入qiankun时暴露出几个生命周期钩子函数,在不使用qiankun时,直接render;
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
function render(props) {
const { container } = props;
ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
export async function bootstrap() {
console.log('[react16] react app bootstraped');
}
export async function mount(props) {
console.log('[react16] props from main framework', props);
render(props);
}
export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
5.修改 webpack 配置
安装插件 @rescripts/cli,当然也可以选择其他的插件,例如 react-app-rewired。
npm i -D @rescripts/cli
根目录新增 .rescriptsrc.js, 配置构建出口的名称和一些参数,proxy设置允许跨域等;
const { name } = require('./package');
module.exports = {
webpack: (config) => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
return config;
},
devServer: (_) => {
const config = _;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
config.historyApiFallback = true;
config.hot = false;
config.watchContentBase = false;
config.liveReload = false;
return config;
},
};
修改 package.json:
- "start": "react-scripts start",
+ "start": "rescripts start",
- "build": "react-scripts build",
+ "build": "rescripts build",
- "test": "react-scripts test",
+ "test": "rescripts test",
- "eject": "react-scripts eject"
微应用( vue项目 )
vue项目的改造方式可参考官网文章:
qiankun.umijs.org/zh/guide/tu…
微应用隔离解决方案
样式隔离
背景
最初我们在接入主应用时遇到的问题是,微应用和主应用同时使用了antd或者是同一个class类名,就出现了一个问题,当加载了子应用后,主应用的样式受到了影响;
思考
所以使用微应用的时候,我们需要考虑样式隔离问题,我们可以思考到的三个不同场景,以及期望应该是如下:
- 主应用样式不影响某个微应用样式
- 某个微应用样式不能影响主应用样式
- 某个微应用的样式不能影响其他微应用的样式
当然在qiankun设计的时候也考虑到了样式隔离的问题,并给我们提供一些提示,官方文档对此的说明如下:
官网链接:API 说明
方案
这里我们只考虑简单的单实例场景来看看这几个配置方案的作用:
sandbox的值
效果
问题
true(默认)
子应用之间隔离
主应用和子应用没有隔离
{ experimentalStyleIsolation: true }
子应用之间隔离 + 子应用不影响主应用
( 子应用样式加了特殊选择器,来限制样式所控制的范围 )
主应用会影响子应用
(比如:主应用有 layout:10px!important ,子应用也有这个layout 类名还是会影响)
{ strictStyleIsolation: true }
子应用之间隔离 + 主子应用互不影响
(基于ShadowDOM的实现,将子应用容器内部的内容包裹一个ShadowDOM)
-
会引入其他问题,官网还没有提供一个完整的改造方式;
-
开发者对ShadowDOM的使用熟练程度有限,有风险
考虑到以上几点,最终我们的方案为:antd加自定义前缀,其他样式通过css module 方式开发
antd自定义前缀
antd的默认前缀是"antd",如果主应用和子应用都使用这个UI框架的话,会出现互相影响,所以我们修改每个子应用的前缀用来区分和避免影响;
接下来说一下如何进行antd前缀的自定义,其方式参见下面的步骤:
-
我们安装 craco (一个对 create-react-app 进行自定义配置的社区解决方案,antd推荐使用),并修改
package.json里的scripts属性。$ yarn add @craco/craco
/* package.json */ "scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
- "start": "craco start",
- "build": "craco build",
- "test": "craco test", }
-
然后在项目根目录创建一个
craco.config.js用于修改默认配置。/* craco.config.js */ module.exports = { // ... };
-
自定义主题需要用到类似 less-loader 提供的 less 变量覆盖功能。我们可以引入 craco-less 来帮助加载 less 样式和修改变量,需要把
src/App.css文件修改为src/App.less,然后修改样式引用为 less 文件。/* src/App.js */
- import './App.css';
- import './App.less';
/* src/App.less */
- @import '~antd/dist/antd.css';
- @import '~antd/dist/antd.less';
-
安装
craco-less并修改craco.config.js文件如下。$ yarn add craco-less
const CracoLessPlugin = require('craco-less');
module.exports = { plugins: [ { plugin: CracoLessPlugin, options: { lessLoaderOptions: { lessOptions: { modifyVars: { '@primary-color': '#1DA57A' }, javascriptEnabled: true, }, }, }, }, ], };
当然,除了@primary-color ,也可以修改配置其他的less变量,具体可以参见antd文档。
Css module使用
Css module 特点就是可以让每个组件中引入的css样式文件,仅作用于当前组件而不是作用于全局,原理是解析时将进入的模块css封装在一个哈希值类名下,这个类名是独一无二的也就实现了模块之间的隔离;
什么是css module? & 如何使用?
www.ruanyifeng.com/blog/2016/0…
js隔离
目前,并没有遇到每个子应用内部的变量会互相污染的情况,子应用没有对window这样的变量有自己的处理,只是有的场景取了主应用window的值。如果遇到有污染的情况,有人提出在子应用加载的钩子里将window进行快照,然后在卸载的钩子里重新赋值以达到隔离效果。