什么是微前端
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently . – Micro Frontends 微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值:
技术栈无关 主框架不限制接入应用的技术栈,微应用具备完全自主权
独立开发、独立部署 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
增量升级 在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
独立运行时 每个微应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith )后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
更多关于微前端的相关介绍,推荐大家可以去看这几篇文章:
举一个具体的例子,假如一个 Web 应用有 N 多个功能模块,比如有数据管理、大屏配置、流程编辑、安全报告,每个模块自身其实已经很复杂了,如果放到一个工程里,并且是由一个团队负责,那倒也问题不大,顶多就是打包慢一点。但如果是由不同的团队负责,并且每个模块又需要独立发布,独立部署,那么这种情况下,微前端架构就显得尤为重要了。
在了解到微前端架构之前,我们甚至尝试过将各个功能模块构建为依赖包,然后由一个独立的、类似 Portal 的工程安装这些依赖包,通过路由来加载这些模块。这种方案虽然能够在开发态将各个模块独立开来,但在生产环境其实还是将所有模块打包到一起了,倘若一个模块出现问题,还是需要将所有模块一并打包更新,独立发布、部署的核心问题还是没有解决。
可能是你见过最完善的微前端解决方案🧐
关于 qiankun 的使用,有机会再去整理。
Angular 框架下无懈可击的微前端框架和一体化解决方案
由于公司的技术栈是 Angular,所以选择了它。对于较早版本(如@worktile/planet@12、@worktile/planet@13),他们团队并没有提供链接中如此详细的帮助网站,只能依赖 README 和源码摸索使用,中间踩了不少坑,所以记录下早期版本的使用步骤。
这里以 Angular@13 为例,使用 Angular CLI 初始化工程。
一、初始化工程并安装相关依赖 1 2 3 4 5 6 ng new portal --routing -S -g --style=less ng g app1 --routing -S --style=less // Angular 本身支持在一个工程中创建多个应用,这里的 app1 app2 为子应用名称,后面会用到 ng g app2 --routing -S --style=less npm i @worktile/planet@13 --save npm i @angular-builders/custom-webpack@13 webpack-assets-manifest@5 --save-dev
二、主应用配置 1. 标记是否为集成环境 我们期望子应用在开发时,能够独立运行,能够显示各自子模块对应的导航菜单,比如数据管理子应用,还包含数据库管理、文件系统管理等,在本地开发时,如果只能通过地址栏输入 URL 切换页面,那就太麻烦了。
而当子应用被 Portal 集成时,子应用的导航菜单将不再显示,而是由 Portal 统一提供。所以我们需要一种方式,让子应用知道当前是否被集成到 Portal 中,从而决定是否显示导航菜单。
为此,我们在 Portal 中创建一个全局变量 window.__POWERED_BY_PLANET__
,值为 true,表示当前是集成环境。当子应用开始运行时,通过该变量判断当前环境,从而决定是否显示导航菜单(子应用单独启动时是获取不到该变量的,因为它是在 Portal 中定义的)。
1 2 3 4 5 6 7 ... (window as any ).__POWERED_BY_PLANET__ = true ; platformBrowserDynamic ().bootstrapModule (AppModule ) .catch (err => console .error (err));
2. 配置路由 @worktile/planet
采用主应用和子应用互相同步路由的方式实现页面切换,当进入子应用路由时,主应用其实是没有对应的路由信息的,所以需要导航到空白页面,然后由子应用接管路由显示对应的页面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ... const routes : Routes = [ { path : '**' , component : EmptyComponent } ]; @NgModule ({ declarations : [ AppComponent , ], imports : [ BrowserModule , BrowserAnimationsModule , NgxPlanetModule , RouterModule .forRoot (routes, { useHash : true }), ], providers : [], bootstrap : [AppComponent ] }) export class AppModule { }
3. 注册子应用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 import { Component , OnInit } from '@angular/core' ;import { Planet , SwitchModes } from '@worktile/planet' ;@Component ({ selector : 'app-root' , templateUrl : './app.component.html' , styleUrls : ['./app.component.less' ] }) export class AppComponent implements OnInit { get loadingDone () { return this .planet .loadingDone ; } constructor ( private planet: Planet ) { } ngOnInit ( ) { this .planet .setOptions ({ switchMode : SwitchModes .coexist , errorHandler : error => { console .error (`Failed to load resource, error:` , error); } }); this .planet .registerApps ([ { name : 'app1' , hostParent : '#app-host-container' , routerPathPrefix : '/app1/' , resourcePathPrefix : '/app1/' , preload : true , scripts : [ 'main.js' , 'polyfills.js' ], styles : [ 'styles.css' ], manifest : '/app1/assets-manifest.json' }, { name : 'app2' , hostParent : '#app-host-container' , hostClass : 'thy-layout' , routerPathPrefix : '/app2/' , resourcePathPrefix : '/app2/' , preload : true , scripts : [ 'main.js' , 'polyfills.js' ], styles : [ 'styles.css' ], manifest : '/app2/assets-manifest.json' } ]); this .planet .start (); } }
4. 配置子应用菜单、容器 1 2 3 4 5 6 7 8 <nav > <a [routerLink ]="['/app1/test']" routerLinkActive ="active" > 应用1</a > <a [routerLink ]="['/app2/test']" routerLinkActive ="active" > 应用2</a > </nav > <router-outlet > </router-outlet > <div id ="app-host-container" > </div > <div *ngIf ="!loadingDone" > 加载中...</div >
5. 配置代理,方便开发 1 2 3 4 5 6 7 8 9 10 11 12 13 const PROXY_CONFIG = { "/app1" : { "target" : 'http://localhost:3000' , "secure" : false }, "/app2" : { "target" : 'http://localhost:3100' , "secure" : false } } module .exports = PROXY_CONFIG
三、子应用配置 1. 修改程序入口 前面提到我们期望子应用在开发时,能够独立运行,所以不能直接使用 @worktile/planet
提供的如下方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 defineApplication ('app1' , (portalApp: PlanetPortalApplication ) => { return platformBrowserDynamic ([ { provide : PlanetPortalApplication , useValue : portalApp } ]) .bootstrapModule (AppModule ) .then (appModule => { return appModule; }) .catch (error => { console .error (error); return null ; }); });
而是重新提供一个 dyBootstrap
方法,在内部通过 window.__POWERED_BY_PLANET__
判断是否为集成环境。为了方便各子应用使用,将其封装到 projects/frame/public-api
中,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { Type , CompilerOptions , NgModuleRef } from "@angular/core" import { platformBrowserDynamic } from "@angular/platform-browser-dynamic" import { PlanetPortalApplication , defineApplication } from "@worktile/planet" import { BootstrapOptions } from "@worktile/planet/application/planet-application-ref" export const dyBootstrap = (appName: string ) => { return { bootstrapModule<M>(moduleType : Type <M>, compilerOptions?: (CompilerOptions & BootstrapOptions ) | Array <CompilerOptions & BootstrapOptions >): Promise <NgModuleRef <M>> { return new Promise ((resolve, reject ) => { if ((window as any ).__POWERED_BY_PLANET__ ) { return defineApplication (appName, { template : `<app-${appName} ></app-${appName} >` , bootstrap : (portalApp: PlanetPortalApplication ) => { return platformBrowserDynamic ([ { provide : PlanetPortalApplication , useValue : portalApp } ]).bootstrapModule (moduleType, compilerOptions) } }) } else { return platformBrowserDynamic ().bootstrapModule (moduleType, compilerOptions) } }) } } }
那么子应用的启动函数就变成了:
1 2 3 4 5 6 7 8 import { dyBootstrap } from 'projects/frame/public-api' ;platformBrowserDynamic ().bootstrapModule (AppModule ) .catch (err => console .error (err)); dyBootstrap ('app1' ).bootstrapModule (AppModule ) .catch (err => console .error (err));
2. 修改打包配置 添加自定义 webpack 文件
1 2 3 4 5 6 7 8 9 const WebpackAssetsManifest = require ('webpack-assets-manifest' );module .exports = { optimization : { runtimeChunk : false }, plugins : [new WebpackAssetsManifest ()] };
修改 angular.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 "build" : { "builder" : "@angular-builders/custom-webpack:browser" , "options" : { "customWebpackConfig" : { "path" : "./extra-webpack.config.js" , "mergeStrategies" : { "externals" : "replace" , "module.rules" : "append" } } , "baseHref" : "/app1/" , "deployUrl" : "/app1/" , ... } , "configurations" : { ... "development" : { ... "vendorChunk" : false , ... } } } , "serve" : { "builder" : "@angular-builders/custom-webpack:dev-server" , ... }
3. 修改一级模块 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <app-root></app-root> <app-app1 > </app-app1 > selector : 'app-root' ,selector : 'app-app1' ,<router-outlet > </router-outlet >
4. 添加一级路由 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import { NgModule } from '@angular/core' ;import { BrowserModule } from '@angular/platform-browser' ;import { HttpClientModule } from '@angular/common/http' ;import { BrowserAnimationsModule } from "@angular/platform-browser/animations" ;import { RouterModule , Routes } from '@angular/router' ;import { EmptyComponent } from '@worktile/planet' ;import { NzMessageModule } from 'ng-zorro-antd/message' ;import { BasicComponent , BasicModule } from 'projects/frame/public-api' ;import { AppComponent } from './app.component' ;const routes : Routes = [ { path : '' , redirectTo : 'app1' , pathMatch : 'full' }, { path : 'app1' , component : BasicComponent , children : [ { path : 'test' , loadChildren : () => import ('./test/test.module' ).then (m => m.TestModule ), data : { firstMenu : true , alias : '测试' } } ] }, { path : '**' , component : EmptyComponent }, ] @NgModule ({ declarations : [ AppComponent , ], imports : [ BrowserModule , BrowserAnimationsModule , HttpClientModule , BasicModule , NzMessageModule , RouterModule .forRoot (routes, { useHash : true }) ], providers : [], bootstrap : [AppComponent ] }) export class AppModule { }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import { Component , OnInit } from '@angular/core' ;@Component ({ selector : 'dy-basic' , templateUrl : './basic.component.html' , styleUrls : ['./basic.component.less' ] }) export class BasicComponent { multiApp = (window as any).__POWERED_BY_PLANET__ ; } <router-outlet *ngIf="multiApp; else devTemp" ></router-outlet> <ng-template #devTemp > <div class ="layout pr" > <div class ="header" > 这里是导航栏</div > <div class ="layout fdr container pr" > <div class ="menu" > 这里是侧边栏</div > <div class ="layout" > <router-outlet > </router-outlet > </div > </div > </div > </ng-template >
四、修改启动脚本 1 2 3 4 5 6 7 "scripts" : { "ng" : "ng" , "start" : "ng serve --proxy-config proxy.config.js" , "start1" : "ng serve app1 --port 3000" , "start2" : "ng serve app2 --port 3100" } ,
之后就可以通过 npm run start
启动主应用,npm run start1
启动子应用1,npm run start2
启动子应用2 进行测试了。