微前端是一种软件架构,可以将前端应用拆解成一些更小的能够独立开发部署的微型应用,然后再将这些微应用进行组合使其成为整体应用的架构模式。微前端架构类似于组件架构,但不同的是,组件不能独立构建和发布,但是微前端中的应用是可以的。微前端架构与框架无关,每个微应用都可以使用不同的框架。
迁移是一项非常耗时且艰难的任务,比如有一个管理系统使用 AngularJS 开发维护已经有三年时间,但是随时间的推移和团队成员的变更,无论从开发成本还是用人需求上,AngularJS 已经不能满足要求,于是团队想要更新技术栈,想在其他框架中实现新的需求,但是现有项目怎么办?直接迁移是不可能的,在新的框架中完全重写也不太现实。
使用微前端架构就可以解决问题,在保留原有项目的同时,可以完全使用新的框架开发新的需求,然后再使用微前端架构将旧的项目和新的项目进行整合。这样既可以使产品得到更好的用户体验,也可以使团队成员在技术上得到进步,产品开发成本也降到的最低。
在目前的单页应用架构中,使用组件构建用户界面,应用中的每个组件或功能开发完成或者bug修复完成后,每次都需要对整个产品重新进行构建和发布,任务耗时操作上也比较繁琐。
在使用了微前端架构后,可以将不能的功能模块拆分成独立的应用,此时功能模块就可以单独构建单独发布了,构建时间也会变得非常快,应用发布后不需要更改其他内容应用就会自动更新,这意味着你可以进行频繁的构建发布操作了。
因为微前端构架与框架无关,当一个应用由多个团队进行开发时,每个团队都可以使用自己擅长的技术栈进行开发,也就是它允许适当的让团队决策使用哪种技术,从而使团队协作变得不再僵硬。
微前端的使用场景:
拆分巨型应用,使应用变得更加可维护
兼容历史应用,实现增量开发
在微前端架构中,除了存在多个微应用以外,还存在一个容器应用,每个微应用都需要被注册到容器应用中。
微前端中的每个应用在浏览器中都是一个独立的 JavaScript 模块,通过模块化的方式被容器应用启动和运行。
使用模块化的方式运行应用可以防止不同的微应用在同时运行时发生冲突。
在微前端架构中,当路由发生变化时,容器应用首先会拦截路由的变化,根据路由匹配微前端应用,当匹配到微应用以后,再启动微应用路由,匹配具体的页面组件。
在微应用中可以通过发布订阅模式实现状态共享,比如使用 RxJS。
通过 import-map 和 webpack 中的 externals 属性。
在微前端架构中,微应用被打包为模块,但浏览器不支持模块化,需要使用 systemjs 实现浏览器中的模块化。
systemjs 是一个用于实现模块化的 JavaScript 库,有属于自己的模块化规范。
在开发阶段我们可以使用 ES 模块规范,然后使用 webpack 将其转换为 systemjs 支持的模块。
案例:通过 webpack 将 react 应用打包为 systemjs 模块,在通过 systemjs 在浏览器中加载模块
npm install [email protected] [email protected] [email protected] html-webpack-
[email protected] @babel/[email protected] @babel/[email protected] @babel/[email protected].
@babel/[email protected] [email protected].
// package.json
{
"name": "systemjs-react",
"scripts": {
"start": "webpack serve"
},
"dependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"babel-loader": "^8.2.2",
"html-webpack-plugin": "^4.5.1",
"webpack": "^5.17.0",
"webpack-cli": "^4.4.0",
"webpack-dev-server": "^3.11.2"
}
}
// webpack.config.js
const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
mode: "development",
entry: "./src/index.js",
output: {
path: path.join(__dirname, "build"),
filename: "index.js",
libraryTarget: "system"
},
devtool: "source-map",
devServer: {
port: 9000 ,
contentBase: path.join(__dirname, "build"),
historyApiFallback: true
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/react"]
}
}
}
plugins: [
new HtmlWebpackPlugin({
inject: false,
template: "./src/index.html"
})
],
externals: ["react", "react-dom", "react-router-dom"]
}
systemjs-react
single-spa 是一个实现微前端架构的框架。
在 single-spa 框架中有三种类型的微前端应用:
single-spa-application / parcel:微前端架构中的微应用,可以使用 vue、react、angular 等框架。
single-spa root config:创建微前端容器应用。
utility modules:公共模块应用,非渲染组件,用于跨应用共享 javascript 逻辑的微应用。
npm install [email protected] -g
创建微前端应用目录:mkdir workspace && cd “$_”
创建微前端容器应用:create-single-spa
应用文件夹填写 container
应用选择 single-spa root config
组织名称填写 study
组织名称可以理解为团队名称,微前端架构允许多团队共同开发应用,组织名称可以标识应用由哪个团队开发。
应用名称的命名规则为 @组织名称/应用名称
,比如 @study/todos
启动应用:npm start
访问应用:localhost:9000
默认代码解析
// workspace/container/src/study-root-config.js
import { registerApplication, start } from "single-spa"
/*
注册微前端应用
1. name: 字符串类型, 微前端应用名称 "@组织名称/应用名称"
2. app: 函数类型, 返回 Promise, 通过 systemjs 引用打包好的微前端应用模块代码
(umd)
3. activeWhen: 路由匹配时激活应用
*/
registerApplication({
name: "@single-spa/welcome",
app: () =>
System.import(
"https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
),
activeWhen: ["/"]
})
// start 方法必须在 single spa 的配置文件中调用
// 在调用 start 之前, 应用会被加载, 但不会初始化, 挂载或卸载.
start({
// 是否可以通过 history.pushState() 和 history.replaceState() 更改触发
single-spa 路由
// true 不允许 false 允许
urlRerouteOnly: true
})
mkdir lagou && cd "$_"
const { merge } = require("webpack-merge")
const singleSpaDefaults = require("webpack-config-single-spa")
module.exports = () => {
const defaultConfig = singleSpaDefaults({
// 组织名称
orgName: "study",
// 项目名称
projectName: "lagou"
})
return merge(defaultConfig, {
devServer: {
port: 9001
}
})
}
"scripts": {
"start": "webpack serve"
}
let lagouContainer = null
export const bootstrap = async function () {
console.log("应用正在启动")
}
export const mount = async function () {
console.log("应用正在挂载")
lagouContainer = document.createElement("div")
lagouContainer.innerHTML = "Hello Lagou"
lagouContainer.id = "lagouContainer"
document.body.appendChild(lagouContainer)
}
export const unmount = async function () {
console.log("应用正在卸载")
document.body.removeChild(lagouContainer)
}
registerApplication({
name: "@study/lagou",
app: () => System.import("@study/lagou"),
activeWhen: ["/lagou"]
})
// 注意: 参数的传递方式发生了变化, 原来是传递了一个对象, 对象中有三项配置, 现在是传递了三个参数
registerApplication(
"@single-spa/welcome",
() =>
System.import(
"https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
),
location => location.pathname === "/"
)
创建应用:create-single-spa
应用目录输入 todos
框架选择 react
修改应用端口 && 启动应用
"scripts": {
"start": "webpack serve --port 9002",
}
}
registerApplication({
name: "@study/todos",
app: () => System.import("@study/todos"),
activeWhen: ["/todos"]
})
默认情况下,应用中的 react 和 react-dom 没有被 webpack 打包, single-spa 认为它是公共库,不应该单独打包。
// react、react-dom 的引用是 index.ejs 文件中 import-map 中指定的版本
import React from "react"
import ReactDOM from "react-dom"
// single-spa-react 用于创建使用 React 框架实现的微前端应用
import singleSpaReact from "single-spa-react"
// 用于渲染在页面中的根组件
import rootComponent from "./root.component"
// 指定根组件的渲染位置
const domElementGetter = () => document.getElementById("todosContainer")
// 错误边界函数
const errorBoundary = () => 发生错误时此处内容将会被渲染
// 创建基于 React 框架的微前端应用, 返回生命周期函数对象
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent,
domElementGetter,
errorBoundary
})
// 暴露必要的生命周期函数
export const { bootstrap, mount, unmount } = lifecycles
import React from "react"
import {BrowserRouter, Switch, Route, Redirect, Link} from "react-router-
dom"
import Home from "./pages/Home"
import About from "./pages/About"
export default function Root(props) {
return (
{props.name}
Home
About
)
}
const { merge } = require("webpack-merge")
const singleSpaDefaults = require("webpack-config-single-spa-react")
module.exports = (webpackConfigEnv, argv) => {
const defaultConfig = singleSpaDefaults({
orgName: "study",
projectName: "todos",
webpackConfigEnv,
argv
})
return merge(defaultConfig, {
externals: ["react-router-dom"]
})
}
创建应用:create-single-spa
项目文件夹填写 realworld
框架选择 Vue
生成 Vue 2 项目
提取 vue && vue-router
// vue.config.js
module.exports = {
chainWebpack: config => {
config.externals(["vue", "vue-router"])
}
}
"scripts": {
"start": "vue-cli-service serve --port 9003",
}
import Vue from "vue"
import VueRouter from "vue-router"
import singleSpaVue from "single-spa-vue"
import App from "./App.vue"
Vue.use(VueRouter)
Vue.config.productionTip = false
// 路由组件
const Foo = { template: "foo" }
const Bar = { template: "bar" }
// 路由规则
const routes = [
{ path: "/foo", component: Foo },
{ path: "/bar", component: Bar }
]
// 路由实例
const router = new VueRouter({ routes, mode: "history", base: "/realworld"
})
const vueLifecycles = singleSpaVue({
Vue,
// 应用配置
appOptions: {
// 路由
router,
// 渲染组件
render(h) {
return h(App, {
// 向组件中传递的数据
props: {
name: this.name,
mountParcel: this.mountParcel,
singleSpa: this.singleSpa
}
})
}
}
})
// 导出生命周期函数
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount
{{ name }}
Go to Foo
Go to Bar
Parcel 用来创建公共 UI,涉及到跨框架共享 UI 时需要使用 Parcel。
Parcel 的定义可以使用任何 single-spa 支持的框架,它也是单独的应用,需要单独启动,但是它不关联路由。
Parcel 应用的模块访问地址也需要被添加到 import-map 中,其他微应用通过 System.import 方法进行 引用。
需求:创建 navbar parcel,在不同的应用中使用它。
import React from "react"
import ReactDOM from "react-dom"
import singleSpaReact from "single-spa-react"
import Root from "./root.component"
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Root,
errorBoundary(err, info, props) {
// Customize the root error boundary for your microfrontend here.
return null
}
})
export const { bootstrap, mount, unmount } = lifecycles
export default function Root(props) {
return (
@single-spa/welcome{" "}
@study/lagou{" "}
@study/todos{" "}
@study/realworld
)
}
externals: ["react-router-dom"]
"scripts": {
"start": "webpack serve --port 9004",
}
{
"imports": {
"@study/navbar": "//localhost:9004/study-navbar.js"
}
}
import Parcel from "single-spa-react/parcel"
用于放置跨应用共享的 JavaScript 逻辑,它也是独立的应用,需要单独构建单独启动。
创建应用:create-single-spa
文件夹填写 tools
应用选择 in-browser utility module (styleguide, api cache, etc)
修改端口,启动应用
"scripts": {
"start": "webpack serve --port 9005",
}
export function sayHello(who) {
console.log(`%c${who} Say Hello`, "color: skyblue")
}
import React, { useEffect, useState } from "react"
function useToolsModule() {
const [toolsModule, setToolsModule] = useState()
useEffect(() => {
System.import("@study/tools").then(setToolsModule)
}, [])
return toolsModule
}
const Home = () => {
const toolsModule = useToolsModule()
if (toolsModule) toolsModule.sayHello("todos")
return Todos home works
}
export default Home
{{ name }}
async handleClick() {
let toolsModule = await window.System.import("@study/tools")
toolsModule.sayHello("realworld")
}
跨应用通信可以使用 RxJS,因为它无关于框架,也就是可以在任何其他框架中使用。
{
"imports": {
"rxjs":
"https://cdn.jsdelivr.net/npm/[email protected]/bundles/rxjs.umd.min.js"
}
}
import { ReplaySubject } from "rxjs"
export const sharedSubject = new ReplaySubject()
useEffect(() => {
let subjection = null
if (toolsModule) {
subjection = toolsModule.sharedSubject.subscribe(console.log)
}
return () => subjection.unsubscribe()
}, [toolsModule])
async mounted() {
let toolsModule = await window.System.import("@study/tools")
toolsModule.sharedSubject.subscribe(console.log)
}
允许使用组件的方式声明顶层路由,并且提供了更加便捷的路由API用来注册应用。
下载布局引擎 npm install [email protected]
构建路由
import { registerApplication, start } from "single-spa"
import { constructApplications, constructRoutes } from "single-spa-layout"
// 获取路由配置对象
const routes = constructRoutes(document.querySelector("#single-spa-layout"))
// 获取路由信息数组
const applications = constructApplications({
routes,
loadApp({ name }) {
return System.import(name)
}
})
applications.forEach(registerApplication)
start({
urlRerouteOnly: true
})