一. 什么是微前端?
微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。
微前端的核心在于拆, 拆完后在合!
微前端架构具备以下几个核心价值:
-
技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权 -
独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新 -
增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
-
独立运行时
每个微应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
2018年 Single-SPA诞生了, single-spa 是一个用于前端微服务化的 JavaScript 前端解决 方案 (本身没有处理样式隔离, js 执行隔离) 实现了路由劫持和应用加载。
2019年 qiankun 基于Single-SPA, 提供了更加开箱即用的 API ( single-spa + sandbox + import-html-entry ) 做到了,技术栈无关、并且接入简单(像iframe 一样简单)
子应用可以独立构建,运行时动态加载,主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap、mount、unmount方法)。
那这与iframe有何区别呢? 如果使用 iframe , iframe 中的子应用切换路由时用户刷新页面就尴尬了。而微前端技术可以很好的解决这个问题。
微前端的应用隔离
应用隔离问题主要分为主应用和微应用,微应用和微应用之间的JavaScript执行环境隔离,CSS样式隔离。
CSS隔离:当主应用和微应用同屏渲染时,就可能会有一些样式会相互污染,如果要彻底隔离CSS污染,可以采用CSS Module 或者命名空间的方式,给每个微应用模块以特定前缀,即可保证不会互相干扰,可以采用webpack的postcss插件,在打包时添加特定的前缀。
而对于微应用与微应用之间的CSS隔离就非常简单,在每次应用加载时,将该应用所有的link和style 内容进行标记。在应用卸载后,同步卸载页面上对应的link和style即可。
JavaScript隔离:每当微应用的JavaScript被加载并运行时,它的核心实际上是对全局对象Window的修改以及一些全局事件的改变,例如jQuery这个js运行后,会在Window上挂载一个window.$对象,对于其他库React,Vue也不例外。为此,需要在加载和卸载每个微应用的同时,尽可能消除这种冲突和影响,最普遍的做法是采用沙箱机制(SandBox)。
沙箱机制的核心是让局部的JavaScript运行时,对外部对象的访问和修改处在可控的范围内,即无论内部怎么运行,都不会影响外部的对象。通常在Node.js端可以采用vm模块,而对于浏览器,则需要结合with关键字和window.Proxy对象来实现浏览器端的沙箱。
代码部分
主容器
App.vue
<template>
<div>
<el-menu :router="true" mode="horizontal">
<!--基座中可以放自己的路由-->
<el-menu-item index="/">Home</el-menu-item>
<!--引用其他子应用-->
<el-menu-item index="/vue">vue应用</el-menu-item>
<el-menu-item index="/react">react应用</el-menu-item>
</el-menu>
<router-view ></router-view>
<div id="vue"></div>
<div id="react"></div>
</div>
</template>
main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);
import {registerMicroApps,start} from 'qiankun';
const apps = [
{
name:'vueApp', // 应用的名字
entry:'//localhost:10000', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
container:'#vue', // 容器名
activeRule:'/vue', // 激活的路径
props:{a:1}
},
{
name:'reactApp',
entry:'//localhost:20000', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
container:'#react',
activeRule:'/react',
}
]
registerMicroApps(apps); // 注册应用
start({
prefetch:false // 取消预加载
});// 开启
new Vue({
router,
render: h => h(App)
}).$mount('#app')
route.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
子容器
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render(){
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
}
if(!window.__POWERED_BY_QIANKUN__){
render();
}
export async function bootstrap(){
}
export async function mount() {
render()
}
export async function unmount(){
ReactDOM.unmountComponentAtNode( document.getElementById('root'));
}
app.js
import React from 'react';
import logo from './logo.svg';
import './App.css';
import { BrowserRouter, Route, Link } from 'react-router-dom'
function App() {
return (
<BrowserRouter basename="/react">
<Link to="/">首页</Link>
<Link to="/about">关于页面</Link>
<Route path="/" exact render={() => (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
)}></Route>
<Route path="/about" render={()=><h1>about页面</h1>}></Route>
</BrowserRouter>
);
}
// qiankun 无关的技术栈
export default App;
二. 微前端实际中遇到的问题
微前端技术固然很好用,它解决了我们项目中的很多痛点,可以做到各应用之间完全解耦,独立部署,独立技术栈等,这在升级老旧项目时候尤其关键(saas就很典型)。但是当一个项目接入了过多的微前端应用时,一些问题也随之而来。
- 一个很简单的需求可能就需要同时发布多个应用,而目前ship应用发布流程比较繁琐,所以很耗时间。
- 各应用之前切换时缓存的状态无法保留(keepAlive的页面失效)
- 一些公共组件和方法的在多个应用中复用较为麻烦,经常需要再各个应用中单独开发,这样使得维护成本升高。
针对第一个和第二个问题,目前尚未有太好的解决方法,因为这个是微前端带来便利同时自然也会产生的问题,比较合适的方案就是开发时尽量将业务类型相近的页面放到同一个微前端应用中,保证一条业务线尽量是在同一个微应用中去打开。(比如佣金查询页=>佣金修改页面=>佣金详情页,就应该放到同一个微前端应用中,如果是分别放到三个微前端应用中,就会造成前面所述的问题,体验较差。)
第三个问题是需要重点解决的问题,这个再我们的应用中非常常见,而且随着微前端应用的扩展,随着而来的问题也是几何倍的增加。
针对微前端公共模块复用方案
1.利用webpack5模块联邦机制 (ModuleFederationPlugin)(个人比较推荐)
模块联邦的优势:webpack5引入联邦模式是为了更好的共享代码。 在此之前,共享代码一般用npm发包来解决。 npm发包需要经历构建,发布,引用三阶段,而联邦模块可以直接引用其他应用代码,实现热插拔效果。对比npm的方式更加简洁、快速、方便。
高级概念:每个构建都充当一个容器,也可将其他构建作为容器。通过这种方式,每个构建都能够通过从对应容器中加载模块来访问其他容器暴露出来的模块。共享模块是指既可重写的又可作为向嵌套容器提供重写的模块。它们通常指向每个构建中的相同模块,例如相同的库。
简单的示例ModuleFede...ion.zip
建立两个应用,一个remote应用, 一个host应用。
remote应用
webpack.config.js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
},
devtool: "cheap-module-source-map",
devServer: {
port: 3003,
},
module: {
rules: [
{
test: /\.(jgp|png|gif)$/i,
use: {
loader: 'url-loader',
options: {
limit: 2048,
name: '[name]_[hash].[ext]',
},
}
},
{
test: /\.scss$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
importLoaders: 2,
}
}, 'sass-loader', 'postcss-loader']
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.jsx?$/,
exclude: path.resolve(__dirname, 'node_modules'),
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: ["@babel/preset-react"],
}
}
},
]
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html'
}),
new CleanWebpackPlugin(),
new webpack.HotModuleReplacementPlugin(),
new ModuleFederationPlugin({
filename: 'user.js',
name: 'remote',
exposes: {
'./demo': './src/App.js'
}
})
]
}
App.js
import React from 'react'
function App() {
console.log('remote服务');
return (
<div>
我是remote服务!
</div>
)
}
export default App;
host应用
webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
...
devServer: {
port: 3003,
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
one: 'remote@http://localhost:3003/user.js'
}
})
]
}
App.js
import React from 'react'
const Demo = React.lazy(() => import('one/demo'));
function App() {
return (
<div>
我是host服务
<React.Suspense fallback="loaing">
<Demo></Demo>
</React.Suspense>
</div>
)
}
export default App;
运行代码:
在3003端口的remote服务正常启动
在3002端口的host服务已经将remote组件中的app.js文件成功渲染到了自身页面。
总结:
联邦模块的优势非常明显,它可以跳出微前端的束缚,实现在不同应用不同平台的跨界使用,甚至可以算是另一种微前端的解决方案。
使用也比npm包更加的灵活,更新简单快捷,非常适合解决在微前端各应用中公共逻辑复用的问题。
当然我也只建议在自身某几个微前端应用内小范围使用。毕竟不受版本管理,灵活性好的反面就是耦合较高,修改风险增大。
tip: 因为现在大多数项目是使用的umi,再简单做个umi接入的demo示例(参考文章)
remote应用
config.js
const { ModuleFederationPlugin } = require("webpack").container;
export default defineConfig({
.....
publicPath: '//localhost:3003/',
webpack5: {},
chainWebpack(memo) {
memo
.plugin('mf')
.use(ModuleFederationPlugin, [{
filename: 'user.js',
name: 'remote',
exposes: {
'./demo': './src/components/Button/index'
},
shared: { react: { eager: true }, reactDom: { eager: true } }
}])
},
})
host应用
config.js
const { ModuleFederationPlugin } = require("webpack").container;
export default defineConfig({
.....
dynamicImport: {},
webpack5: {},
chainWebpack(memo) {
memo
.plugin('mf')
.use(ModuleFederationPlugin, [{
name: "host",
remotes: {
one: 'remote@http://localhost:3003/user.js'
}
shared: { react: { eager: true }, reactDom: { eager: true } }
}])
},
})
运行结果:成功在host端加载成功
2.利用乾坤registerMicroApps
在注册应用信息的方法registerMicroApps中,通过props给子应用传递数据。此方法优势是用法很简单,且可以传递组件和方法,缺点是只能从主应用传递给子应用。有不少场景不太适用。
import { registerMicroApps } from 'qiankun';
registerMicroApps(
[
{
name: 'app1',
entry: '//localhost:8080',
container: '#container',
activeRule: '/react',
props: {
name: 'kuitos', // 主应用需要传递给微应用的数据
},
},
]
);
3.利用window全局共享(不推荐)
此方法与方法2基本相同,也是只能从主应用传递给子应用,方法2是将共享的数据挂载在props上进行传递。而此方法是直接在主应用中window.XXX粗暴的去挂载方法,然后就可以在子应用中通过window去访问。
4.setGlobalState 方法
定义全局状态,并返回通信方法,通常在主应用使用,微应用通过 props 获取通信方法。
主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
子应用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}