手把手教你搭建前端微服务【乾坤】
本文搭建一个基于 Vue3.0 的微服务框架,并以 Angular、Vue3.0、React 作为微服务,接入主应用。文章比较详细,步骤较多,走完,一个基础的微服务框架就真的搭建好了。我们先将学习最小化,亲力亲为搭建好之后,再去考虑怎么优化,有坑的话,记得留言哦~
1. 页面结构
下图是我们搭建的系统的页面结构,在主应用中,包含 header 和左侧的 menu 部分,container 部分用来放置我们的微服务,本文章会使用 Vue 3.0、Angular、React 三个前端流行框架作为示例。
2. 目录结构
我们的项目目录结构如下,main 用来存放主系统,sub 存放子系统。另外,package.json 目前先不管,后面会揭秘。
├── main
└── sub
└── sub-react
└── sub-vue
└── sub-angular
3. 实操
3.1 创建一个 MSP 的项目
mkdir msp
cd msp
npm init msp -y
3.2 按照上面的目录,我们开始搭建
1、首先是搭建一个以 Vue 3.0
为框架的主应用,vue-cli 传送门
主应用主要负责登录、注册、菜单的业务,其他的业务逻辑,咱就不要放在主应用了,主应用越简单越好。
vue create main
创建的时候我们选用 Vue 3.0、Babel、Vuex、CSS Pre-processor(Less) 和 Linter/Formatter(Standard)。
主应用我们不需要路由配置的,路由需要由我们自己去掌控。
eslint 的配置我们选择在放在单独的文件里。
为了减少我们在 ESLint 上纠结,我们先暂时配置 eslint 如下(main/eslintrc.js):
module.exports = {
root: true,
env: {
node: true,
},
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
parserOptions: {
parser: 'babel-eslint',
},
rules: {},
globals: {
__webpack_public_path__: true, // 全局变量注入,否则 linter 会报错
},
};
接下来,我们继续创建好三个子应用,然后我们在一起更改主、子应用的配置。
2、搭建一个以Vue 3.0
为框架的子应用sub-vue
,跟主应用一样的手法。
mkdir sub
cd sub
vue create sub-vue
3、搭建一个以 React
为框架的子应用sub-react
,Create React App.
npx create-react-app sub-react
4、搭建一个以Angular
为框架的子应用sub-angular
,Create Angular App
ng new sub-angular
一路选择 Yes,并选择 Scss 预编译。
经过上面四步之后,实际目录结构如下:
├── package.json
├── main
│ ├── public
│ └── src
│ ├── assets
│ ├── components
│ ├── qiankun
│ └── store
└── sub
├── sub-angular
│ └── src
│ ├── app
│ ├── assets
│ └── environments
├── sub-react
│ ├── public
│ └── src
└── sub-vue
├── public
└── src
├── assets
├── components
├── routes
├── store
└── views
3.3 改造主、子应用
3.3.1 改造子应用sub-vue
- 重命名
src/router
成src/routes
,这个文件夹改成直接导出所有的路由配置,而不直接导出路由实例,我们把路由放到 main.js 里面配置
import Home from '../views/Home.vue';
const routes = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/about',
name: 'About',
component: () =>
import(/* webpackChunkName: "about" */ '../views/About.vue'),
},
];
export default routes;
- 修改
src/main.js
:包装出 render 函数,方便根据运行环境,来运行项目
import './public-path';
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import routes from './routes';
import store from './store';
let router = null;
let instance = null;
// todo: 在乾坤调用 render 函数的时候,会附上一些信息,比如说容器的挂载节点等
function render(props = {}) {
const { container } = props;
router = createRouter({
// 如果是运行在乾坤的环境下,所有的路由路径会在开头加上/sub-vue
history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/sub-vue' : '/'),
routes,
});
instance = createApp(App);
instance.use(router);
instance.use(store);
instance.mount(container ? container.querySelector('#app') : '#app');
}
// 如果不是乾坤环境,直接运行render,从而让子应用可以独立运行
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
- 修改
src/main.js
:添加三个生命周期函数(bootstrap、mount、unmount),这三个函数会被主应用调用,来实现子应用的启动、挂载、卸载
export async function bootstrap() {
// 这个函数可以学学怎么用的,加个颜色
console.log('%c ', 'color: green;', 'vue3.0 app bootstraped');
}
export async function mount(props) {
render(props);
}
export async function unmount() {
instance.unmount();
instance._container.innerHTML = '';
instance = null;
router = null;
}
- 在
src
目录下添加文件public-path.js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
如果出现 eslint 报错'webpack_public_path' is not defined ,那么修改一下 eslint 的配置文件,重启服务。
module.exports = {
root: true,
env: {
node: true,
},
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/prettier'],
parserOptions: {
parser: 'babel-eslint',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
globals: {
__webpack_public_path__: true,
},
};
- 修改 webpack 配置
在 src 下新增vue.config.js
,主要目的是更改项目的打包方式,为了让主应用能正确识别微应用暴露出来的一些信息(生命周期钩子等),记住我们的子应用端口是 8080 哦!
const path = require('path');
const { name } = require('./package');
function resolve(dir) {
return path.join(__dirname, dir);
}
module.exports = {
outputDir: 'dist',
assetsDir: 'static',
filenameHashing: true,
devServer: {
hot: true,
disableHostCheck: true,
port: '8080',
overlay: {
warnings: false,
errors: true,
},
clientLogLevel: 'warning',
compress: true,
headers: {
// 一定要加的,因为乾坤会用http的请求去获取子应用的代码,那么必然会出现跨域的问题。
'Access-Control-Allow-Origin': '*',
},
historyApiFallback: true,
},
configureWebpack: {
resolve: {
alias: {
'@': resolve('src'),
},
},
output: {
// 把子应用打包成 umd 库格式,从而主应用能够获取到子应用导出的生命周期钩子函数
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
},
},
};
第一个子应用sub-vue
配置结束,跑一跑?如果没啥问题,子应用应该已经运行起来,为了好看一些,我们更改一下/src/views/Home.vue
的内容:
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png" />
<HelloWorld msg="Welcome to Your Sub Vue App" />
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue';
export default {
name: 'Home',
components: {
HelloWorld,
},
};
</script>
3.3.2 注册子应用sub-vue
- 在主应用中添加乾坤
我们只需要在主应用中引入乾坤,子应用是不依赖乾坤的,大大减少了代码侵入性。
cd main
yarn add qiankun # perhaps npm i qiankun -S
- 在主应用中注册子应用
创建一个目录qiankun
,用来单独放置乾坤相关的文件,为了方便拓展,我们把所有子应用放在一个数组中,这样如果有新注册的子应用,只需要往数组中添加配置即可。
mkdir src/qiankun
touch src/qiankun/index.js
index.js 内容如下:
import {
registerMicroApps,
runAfterFirstMounted,
setDefaultMountApp,
start,
} from 'qiankun';
/**
* Step1 注册子应用
*/
const microApps = [
{
name: 'sub-vue',
developer: 'vue3.x',
entry: '//localhost:8080',
activeRule: '/sub-vue',
},
];
const apps = microApps.map((item) => {
return {
...item,
container: '#subapp-container', // 子应用挂载的 div
props: {
developer: item.developer,
routerBase: item.activeRule,
},
};
});
// 子应用挂载的几个生命周期钩子
registerMicroApps(apps, {
beforeLoad: (app) => {
console.log('before load app.name====>>>>>', app.name);
},
beforeMount: [
(app) => {
console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
},
],
afterMount: [
(app) => {
console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name);
},
],
afterUnmount: [
(app) => {
console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
},
],
});
/**
* Step2 设置挂载的子应用
*/
setDefaultMountApp('/sub-vue');
/**
* Step3 启动应用
*/
start();
runAfterFirstMounted(() => {
console.log('[MainApp] first app mounted');
});
export default apps;
- 修改
App.vue
,导入我们的微应用数组,绘制菜单
<template>
<div class="layout-wrapper">
<header class="layout-header">
<div class="logo">MSP</div>
</header>
<main class="layout-main">
<aside class="layout-aside">
<ul>
<li v-for="item in microApps" :key="item.name" @click="goto(item)">
{{ item.name }}
</li>
</ul>
</aside>
<section class="layout-section" id="subapp-container"></section>
</main>
</div>
</template>
<script>
import microApps from './qiankun';
export default {
name: 'App',
data() {
return {
isLoading: true,
microApps,
};
},
methods: {
goto(item) {
history.pushState(null, item.activeRule, item.activeRule);
},
},
};
</script>
<style lang="less">
* {
margin: 0;
padding: 0;
}
html,
body,
.layout-wrapper {
height: 100%;
overflow: hidden;
}
.layout-wrapper {
.layout-header {
height: 50px;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
line-height: 50px;
position: relative;
.logo {
float: left;
margin: 0 50px;
}
.userinfo {
position: absolute;
right: 100px;
top: 0;
}
}
.layout-main {
height: calc(100% - 50px);
overflow: hidden;
display: flex;
justify-content: space-evenly;
.layout-aside {
width: 190px;
ul {
margin: 50px 0 0 20px;
border-right: 2px solid #aaa;
li {
list-style: none;
display: inline-block;
padding: 0 20px;
color: #aaa;
margin: 20px 0;
font-size: 18px;
font-weight: 400;
cursor: pointer;
&.active {
color: #42b983;
text-decoration: underline;
}
&:hover {
color: #444;
}
}
}
}
.layout-section {
width: 100%;
height: 100%;
}
}
}
</style>
- 更改主应用运行端口
由于我们目前所有的子应用都是在我们自己的电脑是运行,所以,每个子应用的端口不能重复。第一个子应用的端口是 8080,主应用我们现在改成 8443。在主应用的根目录下创建vue.config.js
module.exports = {
devServer: {
port: '8443',
clientLogLevel: 'warning',
disableHostCheck: true,
compress: true,
historyApiFallback: true,
},
};
到这里,我们已经配置好主应用 main, 并注册了子应用 sub-vue。跑起来,不出啥意外,你应该可以看到这个样子的页面。
3.3.3 改造子应用sub-angular
- 首先在
src
下添加public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 设置
history
模式路由的 base,如果是运行在乾坤环境下,就以/sub-angular/
开头,修改src/app/app-routing.module.ts
文件
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { APP_BASE_HREF } from '@angular/common';
const routes: Routes = [];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: [
{
provide: APP_BASE_HREF,
// @ts-ignore
useValue: window.__POWERED_BY_QIANKUN__ ? '/sub-angular' : '/',
},
],
})
export class AppRoutingModule {}
- 修改入口文件,包装出
render
函数,使其在乾坤环境下,由生命钩子执行render
,在本地环境,直接render
,修改src/main.ts
文件
import './public-path';
import { enableProdMode, NgModuleRef } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
let app: void | NgModuleRef<AppModule>;
async function render() {
app = await platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch((err) => console.error(err));
}
if (!(window as any).__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap(props: Object) {
console.log(props);
}
export async function mount(props: Object) {
render();
}
export async function unmount(props: Object) {
console.log(props);
// @ts-ignore
app.destroy();
}
- 修改 webpack 打包配置
- 先安装 @angular-builders/custom-webpack 插件
npm i @angular-builders/custom-webpack -D
- 在根目录增加 custom-webpack.config.js ,内容为:
const appName = require('./package.json').name;
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
output: {
library: `${appName}-[name]`,
libraryTarget: 'umd',
chunkLoadingGlobal: `webpackJsonp_${appName}`,
},
};
- 修改
angular.json
,将[packageName] > architect > build > builder
和[packageName] > architect > serve > builder
的值改为我们安装的插件,将我们的打包配置文件加入到[packageName] > architect > build > options
。
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"sub-angular": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
"@schematics/angular:application": {
"strict": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"customWebpackConfig": {
"path": "./custom-webpack.config.js"
},
"outputPath": "dist/sub-angular",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"configurations": {
"production": {
"browserTarget": "sub-angular:build:production"
},
"development": {
"browserTarget": "sub-angular:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "sub-angular:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
}
}
}
}
},
"defaultProject": "sub-angular"
}
- 解决 zone.js 的问题
在父应用引入 zone.js,需要在 import qiankun 之前引入。
将微应用的 src/polyfills.ts 里面的引入 zone.js 代码删掉。
-
import 'zone.js/dist/zone';
在微应用的 src/index.html
里面的 标签加上下面内容,微应用独立访问时使用。
<script src="https://unpkg.com/zone.js" ignore></script>
- 修正 ng build 打包报错问题,修改 tsconfig.json 文件,参考 issues/431
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es5",
"typeRoots": ["node_modules/@types"],
"module": "es2020",
"lib": ["es2018", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
- 为了防止主应用或其他微应用也使用 angular 时,
<app-root></app-root>
会冲突的问题,建议给<app-root>
加上一个唯一的 id,比如说当前应用名称
修改src/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SubAngular</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script src="https://unpkg.com/zone.js" ignore></script>
</head>
<body>
<app-root id="sub-angular"></app-root>
</body>
</html>
修改`src/app/app.component.ts :
import { Component } from '@angular/core';
@Component({
selector: '#sub-angular app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
title = 'sub-angular';
}
- 老规矩,改改页面样式,好看一点
修改 app.component.html
:
<h1>This is an Angular APP!</h1>
<router-outlet></router-outlet>
到这里我们已经接入了一个 Angular 框架的前端微应用。
3.3.3 改造子应用sub-react
以 create react app
生成的 react 17 项目为例。
- 在 src 目录新增 public-path.js:
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 入口文件 index.js 修改,为了避免根 id #root 与其他的 DOM 冲突,需要限制查找范围。
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render(props) {
const { container } = props;
ReactDOM.render(
<App />,
container
? container.querySelector('#root')
: document.querySelector('#root')
);
}
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('[react16] react app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
console.log('[react16] props from main framework', props);
render(props);
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(
container
? container.querySelector('#root')
: document.querySelector('#root')
);
}
/**
* 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
*/
export async function update(props) {
console.log('update props', props);
}
- 修改 webpack 配置
我们使用react-app-rewired
来修改react-scripts
的 webpack 配置。
npm i react-app-rewired -D
在根目录下添加config-overrides.js
:
const { name } = require('./package.json');
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const webpack = require('webpack');
module.exports = {
webpack: function override(config, env) {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
// Remove 'react-refresh' from the loaders.
for (const rule of config.module.rules) {
if (!rule.oneOf) continue;
for (const one of rule.oneOf) {
if (
one.loader &&
one.loader.includes('babel-loader') &&
one.options &&
one.options.plugins
) {
one.options.plugins = one.options.plugins.filter(
(plugin) =>
typeof plugin !== 'string' || !plugin.includes('react-refresh')
);
}
}
}
config.plugins = config.plugins.filter(
(plugin) =>
!(plugin instanceof webpack.HotModuleReplacementPlugin) &&
!(plugin instanceof ReactRefreshPlugin)
);
return config;
},
devServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.open = false;
config.hot = false;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
return config;
};
},
};
修改package.json
的 scripts
部分:
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
}
- 修改端口,添加文件
.env
:
SKIP_PREFLIGHT_CHECK=true
BROWSER=none
PORT=3000
- 老规矩,改改我们页面,让页面好看点
自己删掉哪些没用的 CSS 叭,回头再仔细加
修改 src/App.js
:
import './App.css';
function App() {
return <div className='App'>This is a React APP!</div>;
}
export default App;
- 最后一步了,子应用注册到主应用
修改 main/src/qiankun/index.js
的 变量 microApps
:
const microApps = [
{
name: 'sub-vue',
developer: 'vue3.x',
entry: '//localhost:8080',
activeRule: '/sub-vue',
},
{
name: 'sub-angular',
developer: 'angular13',
entry: '//localhost:4200',
activeRule: '/sub-angular',
},
{
name: 'sub-react',
developer: 'react16',
entry: '//localhost:3000',
activeRule: '/sub-react',
},
];
到这里我们已经把三个微应用接入主应用,接下来,我们分别把四个应用运行起来。这个大家应该都会滴。
运行主应用:
cd main
npm run serve
运行子应用 sub-vue
:
cd sub/sub-vue
npm run serve
运行子应用 sub-react
:
cd sub/sub-react
npm run start
运行子应用 sub-angular
:
cd sub/sub-angular
npm run start
终于看到页面咯?
4. 本地开发配置优化
每次跑这么多命令,运行各个微应用,还是蛮累的。下面我们介绍一个利器npm-run-all
,来批量运行我们的微应用。
npm i npm-run-all -D
修改 package.json 下的 scripts:
"scripts": {
"start": "npm-run-all --parallel start:*",
"start:sub-react": "cd sub/sub-react && npm run start",
"start:sub-vue": "cd sub/sub-vue && npm run serve",
"start:sub-angular": "cd sub/sub-angular && npm run start",
"start:main": "cd main && npm run serve"
},
执行 npm run start
,唤起所有 APP。到这里,我们的 MSP 就已经搭建完成咯!
5. 小结
子应用改造:
- 添加
public-path.js
,为什么需要加呢?2. 避免根 id 与其他的 DOM 冲突,需要限制查找范围。 - 完成四个钩子函数,并导出。
- 修改 webpack 配置,添加
'Access-Control-Allow-Origin': '*'
,允许跨域请求文件;修改打包方式,从而让乾坤拿到导出的钩子函数,获取掌控权。