一、为什么要改造成走微前端的开发模式?
使用背景
- 由于我所在的团队目前主要是做一套服务于政府信息化建设性的项目,也就是我们常说的toG。这个项目由将近十几个中标公司的产品研发团队来共同完成,每个公司负责一个子业务系统,且每个系统的复杂程度都是我之前所做的项目都无法比拟的。我们公司子系统从立项到上线耗时一年半的时间,如此庞大的项目,团队中很多成员都是头一次做。所以,在项目经验,以及周而复始的跟各个承接商完成对接上都带来巨大的挑战,当然有压力才会有成长;
- 项目的前端架构是由其中一个包承担着统一门户的角色,然后其他包的子业务系统以iframe的方式嵌入到门户系统中,各包的技术栈统一为单页Vue+ElementUI。
- 由于公司内部也在做一套SAAS化的产品,现在同时也需要将现有的这套服务于政府的项目可以完全脱离其他各个承接商的依赖,实现跟公司SAAS化产品的无缝对接。
- 这套SAAS化产品所有的子系统都共用一套用户体系,然后通过租户去不同的省份和业务方落地。我们所有的子系统都是共用一套登陆体系,相同的菜单结构,相同的header,相同的tab页签切换,只是每个子系统的业务逻辑是不同的。就前端而言,如果还是采用之前的传统的单页模式来开发的,如果有二十个省份要落地,那我们就需要开发二十套单页项目,这样,我们一些公用的菜单,用户体系,头部等都要重复的去拷贝,无论是前端的开发成本还是后期维护的成本都是巨大的。所以,综合考虑,接入微前端也许是一个不错的选择。
这里是公司SAAS化产品的一个简单的demo示例图
登录
系统入口页
子系统
- 公司的这套SAAS化产品前端的架构是基于微前端SingleSpa来实现,由另外一个前端团队率先带头探索完成。所以,我们这套服务于政府的项目要想接入公司的这SAAS化产品中,就需要完成对接公司这套微前端架构。在接入之前,我这边需要先搞清楚公司这套微前端架构从头到尾是如何搭建的,话不多说,我们就从0开始搭建一个属于自己的微前端架构。
二、如何搭建一个适合于自己公司体系的微前端项目?
市场
目前很多大厂也在用qiankun之类别人封装好的框架,开箱即用。But,别人封装好的对于咱们而言,不一定就是适用的;So,要想真正搞明白微前端的核心理念,还是需要自己动手去折腾一番才可以,动手之前我们先捋一捋微前端与传统单页的区别;
- 微前端与传统的单页应用有什么区别?
传统的单页
微前端
- 如何从0到1搭建出一套适用于自己公司的微前端?
-
从上图我们可以清楚的看到,微前端的核心其实就是由一个模块加载器(enter),
一个Base模块(navbar)(登录+菜单+头部等公共部分),不同的子系统模块这三个部分组成。我这边也动手创建了4个前端工程
,分别为:入口(enter)、navbar(base)、子系统一、子系统二。
-
入口应用(enter)通过systemjs按照不同的子系统的不同路由标识去按需加载各自打包好的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>
- 首先,将微前端依赖的插件通过script引入。
- 然后,我们首先通过system.js把一些项目中需要的公共的插件,如vue、vue-router、ElementUi等全局引入。
- 最后,借助webpack的htmlWebpackPlugin插件中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"
以上为入口应用(enter)的几步核心配置,当然这里在enter的打包上我们需要注意一点,我们libraryTarget统一都走UMD的打包模式。
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去除掉,因为这些插件在我们的enter中已经引入了,所以在navbar系统中也只有开发环境的时候才会用到,测试跟生产环境是不需要重复引入的。
- publicPath: ‘/navbar’ 定义打包出来的虚拟路径,这里必须要指定,而且跟各个子业务系统不能重名;
- libraryTarget: ‘umd’,依然采用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;
这里通过systemjs-webpack-interop中setPublicPath注册navbar,然后把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这三个single-spa的周期的钩子上。
页面路由的改造
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;
我们需要在路由的前边加上统一的访问前缀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实例。
这里只是一个demo的演示,链接:http://spa.caoyuanpeng.com:9001/,有兴趣的小伙伴可以点击这个链接体验一下。
- 首先,通过上述的demo我们可以清晰的看到,第一次访问域名去加载navbar的时候,会先生成一个navbar的实例(这里用两个按钮子系统1与子系统2来展示navbar的内容),当我们点击子系统1按钮的时候,会自动生成一个children1实例,页面渲染出子系统1的内容,而navbar的实例继续保留不被销毁。
- 然后,当我们点击子系统2按钮的时候,会自动生成一个children2实例,页面渲染出子系统2的内容。此时,children1实例就会被销毁掉,而navbar的实例继续保留不被销毁。
- 至此,我们就可以将一个微前端的应用从头到尾的搭建完成了。
三、微前端在生产环境的部署与传统单页部署有什么区别?
- 传统的单页应用我们只是需要通过jenkins或者docker来打包我们的静态,然后通过脚本把打包好的静态包拷贝到静态服务器上,最后通过Nginx来代理启动即可。
- 微前端的部署是跟我们传统的单页有一些区别的,因为我们本质上由一个主应用去加载不同的子应用,也就是主应用与base应用,主应用与子应用等之间是互相依赖的。所以,在部署的时候,我们需要想办法通过构建脚本把这几个对应关系处理清楚才可以顺利启动微前端。
- 如果jenkins的pipeline来构建的话,我们需要注意的就是每次构建主应用之前先删除上一次旧的构建目录,然后再拷贝新的主应用目录。我们可以通过创建软连接的方式将主应用的文件目录链接到跟子应用目录保持同一级即可。
这里是通过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这里只需要location到入口应用里的index.html文件即可。
- 子系统在服务器上的文件路径必须要跟我们子系统的路由标识名称保持一致,否则无法加载到系统的文件。
- 子系统不需要额外单独配置nginx,只需要配置一个入口应用的即可。
四、改造成微前端后的优势与不足有哪些?
优势
- 缩小系统的打包体积,子系统的平均bundle不到几百k,所有的公共的js文件跟css文件只是需要加载一次。
- 可以兼容各种技术栈,在同一个页面中我们可以使用多种技术框架(React, Vue, AngularJS),并且不需要刷新页面。
- 如果有子系统想接入微前端,接入成本低,无需重构代码。
- 每个子系统的代码可做到按需加载,不浪费额外资源。
- 每个子系统可以是单独的git工程,且可以独立部署。
- 不同的git项目之间可以做到以每个独立的页面路由为基础进行任意模块拼装。
- 用户体验更好,用户在无感知的情况下可以同时去加载多个子系统。
不足
- 由于在加载的过程中会生成多个Vue实例,需要在全局的样式上制定详细的规范,否则会造成各种样式污染。
- 子系统中使用了external将一些公共的插件做了抽离,vue、vue-router、element-ui等我们同时需要避免构造函数带来的污染。
- 子系统的路由守卫要做到避免互相之间受到影响。
- 并不是所有的场景都适用于微前端这套架构,当你的项目足够多,且都是类似的单页项目、且需要将不同的子系统功能组合到一个大的系统中等这些场景才适合做微前端的改造。
五、未来能够在微前端的基础上做出哪些更好的突破?
- 让后端小伙伴按照微服务的方式输出,前端根据路由把子系统按照功能模块输出,这样就可以完全做到不同子系统之间可以任意按照模块进行功能的拼装,完全实现微前端。当然这样难度也相对较大,各个子系统之间的数据互联互通是个很大的问题。
- 前端按照模块进行封装输出,后端返回的数据格式在各个子系统之间保持统一。
六、总结
- 伴随着前端组件化,工程化的不断演进,微前端的架构也许会越来越受到欢迎。
- 如果你有上述同样的业务场景,从技术创新的角度上,我觉得可以去尝试着去改造一番试试,也许会有你意想不到的惊喜跟收获。