从0到1去搭建一个适合自己公司的微前端架构
一、为什么要改造成走微前端的开发模式?
使用背景
我们团队主要承担政府信息化建设相关项目(即常说的"toG"项目)。这些项目的共同特点是由近十几个中标公司的产品研发团队共同完成,在具体实施过程中每个公司负责一个子业务系统。与以往所做的项目相比这些系统的复杂程度堪称上乘因此从立项到上线耗时半年之久如此庞大的系统集成工作给团队带来了前所未有的挑战同时也带来了巨大的压力只有经历风雨才能获得成长的机会;
项目的前端架构采用一个包负责整合到统一门户系统中其他包的子业务系统则通过iframe技术实现各子业务系统的嵌入所有项目的前端技术栈统一采用单页Vue框架结合ElementUI组件;
由于公司内部也在推进SaaS化产品建设现在需要将现有的政府服务型项目完全脱离其他承接商的支持实现与公司内部SaaS化产品的无缝对接;
这套SaaS化产品的共用用户体系支持在不同省份和业务方进行落地部署每个子系统都共享一套统一的用户认证体系相同的菜单结构以及标准的header布局其中tab页切换遵循一致模式但每个子系统的业务逻辑存在差异;
就前端开发而言如果仍然采用传统的单页模式开发面对二十个省份的需求就需要构建二十个独立的单页系统这将导致大量公用功能模块(如菜单体系认证体系等)需要重复构建从而大幅增加开发成本并带来后期维护的巨大压力因此综合考虑接入微前端方案可能是一个更加高效的选择。
这里是公司SAAS化产品的一个简单的demo示例图
登录

系统入口页

子系统

公司的这款SAAS 化产品前端架构基于微前端框架SingleSpa实现,并由另一支前端团队率先完成了这项技术探索工作。因此需要对现有的微前端架构进行对接才能接入到公司的这一系列SAAS 化产品中。在对接前有必要深入了解我们政府项目所需的微前端架构是如何整体设计与构建的,在没有深入了解之前建议我们从零开始构建一个属于自己的微前端架构系统。
二、如何搭建一个适合于自己公司体系的微前端项目?
市场
目前多家大厂都在使用类似于qiankun这些现成的第三方框架包,并且方便直接使用。
然而,在咱们这里来说,并非所有现成的第三方框架都是适用的。
因此,在深入理解微前端的核心理念之前,请务必投入时间和精力进行实际操作。
在开始开发之前,请先梳理一下微前端与传统单页之间的区别。
- 微前端与传统的单页应用有什么区别?
传统的单页

微前端

- 如何从0到1搭建出一套适用于自己公司的微前端?
通过查看上图可以看出

入口应用(enter)借助系统JS动态加载相应打包好的app.js文件,并根据不同的子系统的独特路由标识来进行精准的资源调用。
enter应用的改造
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="importmap-type" content="systemjs-importmap">
<link rel="stylesheet" href="/style/common.css">
<title>微前端入口</title>
<script type="systemjs-importmap">
{
"imports": <%= JSON.stringify(htmlWebpackPlugin.options.meta.all) %>
}
</script>
<!-- 引入样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<link rel="preload" href="/js/single-spa.min.js" as="script" crossorigin="anonymous" />
<link rel="preload" href="/js/vue.min.js" as="script" crossorigin="anonymous" />
<script src='/js/minified.js'></script>
<script src="/js/import-map-overrides.js"></script>
<script src="/js/system.min.js"></script>
<script src="/js/amd.min.js"></script>
<script src="/js/named-exports.js"></script>
<script src="/js/named-register.min.js"></script>
<script src="/js/use-default.min.js"></script>
</head>
<body>
<script>
(function() {
Promise.all([
System.import('single-spa'),
System.import('vue'),
System.import('vue-router'),
System.import('element-ui')]).then(function (modules) {
var singleSpa = modules[0];
var Vue = modules[1];
var VueRouter = modules[2];
var ElementUi = modules[3];
Vue.use(VueRouter)
Vue.use(ElementUi)
<% for (let app in htmlWebpackPlugin.options.meta.route) { %>
singleSpa.registerApplication(
'<%= app %>',
function () {
return System.import('<%= htmlWebpackPlugin.options.meta.route[app] %>')
},
function(location) {
<% if (app !== 'navbar') { %>
return location.pathname.split('/')[1] === '<%= app %>'
<% } else { %>
return true
<% } %>
})
<% } %>
singleSpa.start();
})
})()
</script>
<import-map-overrides-full show-when-local-storage="overrides-ui"></import-map-overrides-full>
</body>
</html>
html

- 首先, 通过script 引入微前端所需的插件.
- 首先使用system.js 将常用的 Vue、Vue Router 和 ElementUI 等插件进行全局导入.
- 最后利用 webpack 的 html Psreguarder 插件 meta 属性处理打包好的 app.js 文件, 并采用遍历方法以确保良好的可维护性和扩展性; 另外也可以通过 promise 异步引入简化流程.
webpack配置
new HtmlWebpackPlugin({
filename: 'index.html',
template: resolve(__dirname, '../index.ejs'),
inject: false,
title: 'title',
minify: {
collapseWhitespace: false
},
meta: {
all: Object.assign(config[0], config[1]),
route: config[1],
outputTime: new Date().getTime()
}
})

- 子系统的 app.js 利用 config 进行不同环境下 app.js 的加载操作,并且借助 Node 环境变量来进行开发测试生产等三个环境的区分工作。
开发环境
module.exports = {
"navbar": '//localhost:8002/navbar/app.js',
"children1": '//localhost:8003/children1/app.js',
"children2": '//localhost:8004/children2/app.js',
};
测试环境
const host = process.env.HOST;
module.exports = {
"navbar": host + '/navbar/app.js',
"children1": host + '/children1/app.js',
"children2": host + '/children2/app.js',
};
生产环境
const host = process.env.HOST;
module.exports = {
"navbar": host + '/navbar/app.js',
"children1": host + '/children1/app.js',
"children2": host + '/children2/app.js',
};
在我们的入口中集成构建的host即用于访问开发环境与生产环境所配置的域名
"build": "rimraf dist && cross-env NODE_ENV=production HOST=//spa.caoyuanpeng.com:9001 webpack --config build/webpack.prod.config.js"
以下是改写后的文本
改写说明
output: {
path: resolve(__dirname, '../dist'),
publicPath: '/',
filename: '[name].js',
chunkFilename: 'js/[name]-[chunkhash:6].js',
library: 'app',
libraryTarget: 'umd'
}
- 关于navbar应用的改造
打包
webpack.base.config.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HappyPack = require('happypack');
const BasePlugins = require('./plugins');
const { resolve } = path;
const isDevMode = process.env.NODE_ENV === 'development';
module.exports = {
devtool: process.env.NODE_ENV !== 'production' ? 'source-map' : 'none',
// 入口
entry: {
app: ['webpack-hot-middleware/client', resolve(__dirname, main)]
},
// 出口
output: {
filename: 'app.js',
path: resolve(__dirname, '../dist'),
chunkFilename: 'js/[name]-[chunkhash:6].js',
publicPath: isDevMode ? '/' : '/navbar',
// library: 'navbar',
libraryTarget: 'umd'
},
externals: isDevMode ? {} : ['vue', 'vue-router', 'element-ui'],
plugins: [
...BasePlugins,
new MiniCssExtractPlugin({
filename: 'css/[name].[hash:6].css',
chunkFilename: 'css/[id].[hash:6].css'
}),
new HappyPack({
/* * 必须配置
*/
// id 标识符,要和 rules 中指定的 id 对应起来
id: 'babel',
// 需要使用的 loader,用法和 rules 中 Loader 配置一样
// 可以直接是字符串,也可以是对象形式
loaders: ['babel-loader?cacheDirectory']
})
],
module: {
rules: [
{
test:/\.css$/,
use:[
{
loader: isDevMode ? 'style-loader' : MiniCssExtractPlugin.loader
},
'css-loader', {
loader: 'postcss-loader',
options: {
plugins: [require('autoprefixer')]
}
}
]
},
{
test: /\.less$/,
use:[
{
loader: isDevMode ? 'style-loader' : MiniCssExtractPlugin.loader
},
'css-loader',
'less-loader',
{
loader: 'postcss-loader',
options: {
plugins: [require('autoprefixer')]
}
}
]
},
{
test: /\.js$/,
exclude: /node_modules/,
use: ['happypack/loader?id=babel'],
},
{
test: /\.(jpg|jpeg|png|gif)$/,
loaders: 'url-loader',
exclude: /node_modules/,
options: {
limit: 8192,
outputPath: 'img/',
name: '[name]-[hash:6].[ext]'
}
},
{
test: /\.(woff|woff2|svg|eot|ttf)$/,
use: [
{
loader: 'file-loader',
options: {
outputPath: 'fonts/',
name: '[name].[ext]'
}
}
]
},
{
test: /\.vue$/,
use: ['vue-loader']
}
]
},
resolve: {
extensions: ['.js', 'json', '.less', '.css', '.vue'],
alias: {
vue$: 'vue/dist/vue.common.js',
'@': resolve(__dirname, '../src'),
'pages': resolve(__dirname, '../src/pages'),
}
}
};

- 在生产环境和测试环境中打包时我们应用externals工具将vue vue-router以及element-ui这几个插件进行排除这是因为它们已经在我们的进入阶段已经被引入因此在导航系统中只有开发阶段才会需要用到而测试和生产环境中是不需要重复引入这些插件的。
- 定义打包后的虚拟路径为\texttt{publicPath}这一参数必须明确指定以避免与其他子业务系统的名称发生冲突。
- 针对库目标配置我们继续采用UMD打包方案这一策略有助于确保构建过程的一致性和可维护性。
navbar系统的entry改造
base.js
import '@babel/polyfill';
import { setPublicPath } from 'systemjs-webpack-interop';
import Vue from 'vue';
import VueRouter from 'vue-router';
import Element from 'element-ui';
import singleSpaVue from 'single-spa-vue';
import routes from '../router';
const baseFn = () => {
// 默认控制台不输出vue官方打印日志
Vue.config.productionTip = false;
// 使用devtools调试
Vue.config.devtools = true;
// 注册navbar
setPublicPath('navbar');
// 生成vue-router实例
const router = new VueRouter({
mode: 'history',
routes
});
Vue.use(VueRouter);
Vue.use(Element);
// appOptions抽离
const appOptions = {
render: h => <div id="navbar">
<router-view></router-view>
</div>,
router
};
// 注册single-spa-vue实例
const vueLifecycles = singleSpaVue({
Vue,
appOptions
});
return vueLifecycles;
}
export default baseFn;

这里借助系统JS-Webpack-互操作库中setPublicPath方法配置导航栏,并将Vue实例与router及vuex组件绑定至singleSpaVue中。
main.dev.js
import BaseFn from './base';
BaseFn();
main.prod.js
import BaseFn from './base';
const vueLifecycles = BaseFn();
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
在生产环境中,vueLifecycles 被配置为在 bootstrap、mount、unmount 这三个单页应用周期的钩子处注册。
页面路由的改造
import View from '@/pages/components/view';
const Template = () => import(/* webpackChunkName: "index" */ '@/pages/index');
const routes = [
{
path: '/navbar',
component: Template,
meta: {
title: '菜单'
},
children: [
{
path: '*',
component: View,
meta: {
title: ''
}
}
]
}
];
export default routes;

我们需要于所有路由请求之前统一添加一个访问前缀 named navbar ,其主要目的在于为了让入口 enter 通过不同的路由路径来触发对应的导航前缀并按需求加载相应的 app.js 。需要注意的是,在此场景下我们的核心目标是确保 navbar 应用能够持续运行而不发生销毁现象。具体来说,在 children 部署时应当设置 path 为空字符串。
- 关于子系统应用的改造
子系统的改造基本上与上述navbar的改造相似,在Webpack配置以及页面路由方面存在主要差异。
output: {
filename: 'app.js',
path: resolve(__dirname, '../dist'),
chunkFilename: 'js/[name]-[chunkhash:6].js',
publicPath: isDevMode ? '/' : '/children2',
library: 'children2',
libraryTarget: 'umd'
}
publicPath 要设置为children2 ,以及library 要设置为children2 。
再就是路由这里需要将子系统的页面统一访问前缀设置为children2
import View from '@/pages/components/view';
const Template = () => import(/* webpackChunkName: "index" */ '@/pages/index');
const Detail = () => import(/* webpackChunkName: "detail" */ '@/pages/test');
const routes = [
{
path: '/children2',
component: View,
meta: {
title: '子应用'
},
children: [
{
path: 'index',
component: Template,
meta: {
title: '首页'
}
},
{
path: 'detail',
component: Detail,
meta: {
title: '详情'
}
}
]
}
];
export default routes;

- 当子系统完成app.js文件的加载时,随后(single-spa-vue)将根据不同的路由路径依次创建独立的Vue实例。
这个页面仅作为一个demonstration提供给用户使用。您可访问此链接:http://spa.caoyuanpeng.com:9001以进行体验。



- 首先,在下面提供的演示中可以看出,在首次访问该域名并试图加载navbar时(这里使用两个按钮子系统1与子系统2来展示navbar的内容),会自动生成一个nav bar对象(即第一个实例)。当点击子系统1按钮时,在此过程中会生成一个children1对象,并在其基础上呈现相应的内容;而初始的nav bar对象则继续得以保留而不被销毁。
- 接着,在同样情况下(即再次尝试加载或显示该nav bar),当点击子系统2按钮时,则会生成另一个children2对象,并在其基础上呈现相应的内容;与此同时,在children1对象销毁的同时,默认情况下初始的nav bar对象也会被保留而不被销毁。
- 由此所述的过程完成之后,则可顺利地将整个微前端应用从头到尾搭建完毕。
三、微前端在生产环境的部署与传统单页部署有什么区别?
- 传统的单页应用部署通常只需利用jenkins 或docker工具来完成静态资源的打包部署工作。
- 然后通过Nginx 进行负载均衡即可完成。
- 微前端的部署方式与传统单页存在显著差异。
- 在部署过程中需要特别注意的是一个主应用程序负责加载多个子应用程序。
- 具体而言,在构建过程中需要明确主应用程序与各个子应用程序之间的依赖关系。
- 具体操作包括:
- 每次构建前都需要先清除上次旧构建目录
- 然后将新的主应用程序文件夹复制到相应位置
- 最后通过创建软链接的方式实现文件目录间的关联
这里是通过jenkins 打包好拷贝到我们指定的静态文件目录。

nginx上代理主应用
server {
listen 9001;
server_name spa.caoyuanpeng.com;
location / {
root /home/single-spa-vue;
try_files $uri $uri/ /index.html;
index index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
nginx

- 我们仅在
nginx环境中通过location指令指向入口应用的index.html文件即可。 - 子系统在服务器上的路径必须与自身的路由标识一致;否则将无法加载至系统中。
- 子系统无需单独配置
nginx参数;只需指定一个入口应用即可。
四、改造成微前端后的优势与不足有哪些?
优势
- 降低系统的打包体积规模,在实现过程中所有公共端点JS文件与CSS文件均仅需加载一次。
- 系统完全兼容多种技术架构,在同一页面中能够灵活集成React、Vue以及AngularJS等多种框架体系,并无需刷新页面即可完成切换。
- 若希望将子系统接入微前端架构体系中,则接入门槛极低且无需对现有代码库进行重构工作。
- 各子系统代码实现按需加载机制设计,在保证资源使用效率的同时避免浪费额外资源空间。
- 每个子系统均独立成一个git工程实体,并可轻松实现自主部署操作。
- 基于独立页面路由设计原则能够自由组合搭配各个功能模块组合体实现灵活应用。
- 在不影响用户体验的前提下允许多个子系统同时加载并展示给终端用户。
不足
- 在加载阶段会产生多个Vue实例,在整体样式管理中制定详细规定以规避样式污染问题。
- 通过外部化的方式分离了一些公共组件以规避因构造函数而引入的各种潜在污染问题。
- 确保路由守护机制互不影响以维持系统的稳定运行。
- 只有当项目的规模较大且类型单一,并且需要整合不同子系统功能到一个统一的大系统中时才适合采用微前端架构进行改造。
五、未来能够在微前端的基础上做出哪些更好的突破?
- 前端开发团队应将各功能模块独立封装为可复用的组件形式,
backend返回的数据格式需在各子系统中保持一致以确保数据流转的顺畅性。
六、总结
- 随着前端组件化和工程化的发展不断深入, 微前端架构可能逐渐得到越来越多人的认可。
- 当你遇到类似的情况时, 从技术创新的角度出发, 建议你可以尝试进行一次改造实验看看效果如何——可能会带来意想不到的好处。
