什么是微服务?先看看维基百科的定义:

微服务(英语:Microservices)是一种软件架构风格,它是以专注于单一责任与功能的小型功能区块 (Small Building Blocks) 为基础,利用模块化的方式组合出复杂的大型应用程序,各功能区块使用与语言无关 (Language-Independent/Language agnostic)的API集相互通信。

换句话说,就是将一个大型、复杂的应用分解成几个服务,每个服务就像是一个组件,组合起来一起构建成整个应用。

想象一下,一个上百个功能、数十万行代码的应用维护起来是个什么场景?

  1. 牵一发而动全身,仅仅修改一处代码,就需要重新部署整个应用。经常有“修改一分钟,编译半小时”的情况发生。
  2. 代码模块错综复杂,互相依赖。更改一处地方的代码,往往会影响到应用的其他功能。

如果使用微服务来重构整个应用有什么好处?

一个应用分解成多个服务,每个服务独自服务内部的功能。例如原来的应用有 abcd 四个页面,现在分解成两个服务,第一个服务有 ab 两个页面,第二个服务有 cd 两个页面,组合在一起就和原来的应用一样。

当应用其中一个服务出故障时,其他服务仍可以正常访问。例如第一个服务出故障了, ab 页面将无法访问,但 cd 页面仍能正常访问。

好处:不同的服务独立运行,服务与服务之间解耦。我们可以把服务理解成组件,就像本小书第 3 章《前端组件化》中所说的一样。每个服务可以独自管理,修改一个服务不影响整体应用的运行,只影响该服务提供的功能。

另外在开发时也可以快速的添加、删除功能。例如电商网站,在不同的节假日时推出的活动页面,活动过后马上就可以删掉。

难点:不容易确认服务的边界。当一个应用功能太多时,往往多个功能点之间的关联会比较深。因而就很难确定这一个功能应该归属于哪个服务。

PS:微前端就是微服务在前端的应用,也就是前端微服务。

微服务实践

现在我们将使用微前端框架 qiankun 来构建一个微前端应用。之所以选用 qiankun 框架,是因为它有以下几个优点:

  • 技术栈无关,任何技术栈的应用都能接入。
  • 样式隔离,子应用之间的样式互不干扰。
  • 子应用的 JavaScript 作用域互相隔离。
  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

样式隔离

样式隔离的原理是:每次切换子应用时,都会加载该子应用对应的 css 文件。同时会把原先的子应用样式文件移除掉,这样就达到了样式隔离的效果。

我们可以自己模拟一下这个效果:

  1. <!-- index.html -->
  2. <!DOCTYPE html>
  3. <html lang="en">
  4. <head>
  5. <meta charset="UTF-8">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. <title>Document</title>
  8. <link rel="stylesheet" href="index.css">
  9. <body>
  10. <div>移除样式文件后将不会变色</div>
  11. </body>
  12. </html>
  1. /* index.css */
  2. body {
  3. color: red;
  4. }

11. 微服务实践 - 图1

现在我们加一段 JavaScript 代码,在加载完样式文件后再将样式文件移除掉:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Document</title>
  7. <link rel="stylesheet" href="index.css">
  8. <body>
  9. <div>移除样式文件后将不会变色</div>
  10. <script>
  11. setTimeout(() => {
  12. const link = document.querySelector('link')
  13. link.parentNode.removeChild(link)
  14. }, 3000)
  15. </script>
  16. </body>
  17. </html>

这时再打开页面看一下,可以发现 3 秒后字体样式就没有了。

11. 微服务实践 - 图2

JavaScript 作用域隔离

主应用在切换子应用之前会记录当前的全局状态,然后在切出子应用之后恢复全局状态。假设当前的全局状态如下所示:

  1. const global = { a: 1 }

在进入子应用之后,无论全局状态如何变化,将来切出子应用时都会恢复到原先的全局状态:

  1. // global
  2. { a: 1 }

官方还提供了一张图来帮助我们理解这个机制:

11. 微服务实践 - 图3

好了,现在我们来创建一个微前端应用吧。这个微前端应用由三部分组成:

  • main:主应用,使用 vue-cli 创建。
  • vue:子应用,使用 vue-cli 创建。
  • react: 子应用,使用的 react 16 版本。

对应的目录如下:

  1. -main
  2. -vue
  3. -react

创建主应用

我们使用 vue-cli 创建主应用(然后执行 npm i qiankun 安装 qiankun 框架):

  1. vue create main

如果主应用只是起到一个基座的作用,即只用于切换子应用。那可以不需要安装 vue-router 和 vuex。

改造 App.vue 文件

主应用必须提供一个能够安装子应用的元素,所以我们需要将 App.vue 文件改造一下:

  1. <template>
  2. <div class="mainapp">
  3. <!-- 标题栏 -->
  4. <header class="mainapp-header">
  5. <h1>QianKun</h1>
  6. </header>
  7. <div class="mainapp-main">
  8. <!-- 侧边栏 -->
  9. <ul class="mainapp-sidemenu">
  10. <li @click="push('/vue')">Vue</li>
  11. <li @click="push('/react')">React</li>
  12. </ul>
  13. <!-- 子应用 -->
  14. <main class="subapp-container">
  15. <h4 v-if="loading" class="subapp-loading">Loading...</h4>
  16. <div id="subapp-viewport"></div>
  17. </main>
  18. </div>
  19. </div>
  20. </template>
  21. <script>
  22. export default {
  23. name: 'App',
  24. props: {
  25. loading: Boolean,
  26. },
  27. methods: {
  28. push(subapp) { history.pushState(null, subapp, subapp) }
  29. }
  30. }
  31. </script>

可以看到我们用于安装子应用的元素为 #subapp-viewport,另外还有切换子应用的功能:

  1. <!-- 侧边栏 -->
  2. <ul class="mainapp-sidemenu">
  3. <li @click="push('/vue')">Vue</li>
  4. <li @click="push('/react')">React</li>
  5. </ul>

11. 微服务实践 - 图4

改造 main.js

根据 qiankun 文档说明,需要使用 registerMicroApps()start() 方法注册子应用及启动主应用:

  1. import { registerMicroApps, start } from 'qiankun';
  2. registerMicroApps([
  3. {
  4. name: 'react app', // app name registered
  5. entry: '//localhost:7100',
  6. container: '#yourContainer',
  7. activeRule: '/yourActiveRule',
  8. },
  9. {
  10. name: 'vue app',
  11. entry: { scripts: ['//localhost:7100/main.js'] },
  12. container: '#yourContainer2',
  13. activeRule: '/yourActiveRule2',
  14. },
  15. ]);
  16. start();

所以现在需要将 main.js 文件改造一下:

  1. import Vue from 'vue'
  2. import App from './App'
  3. import { registerMicroApps, runAfterFirstMounted, setDefaultMountApp, start, initGlobalState } from 'qiankun'
  4. let app = null
  5. function render({ loading }) {
  6. if (!app) {
  7. app = new Vue({
  8. el: '#app',
  9. data() {
  10. return {
  11. loading,
  12. }
  13. },
  14. render(h) {
  15. return h(App, {
  16. props: {
  17. loading: this.loading
  18. }
  19. })
  20. }
  21. });
  22. } else {
  23. app.loading = loading
  24. }
  25. }
  26. /**
  27. * Step1 初始化应用(可选)
  28. */
  29. render({ loading: true })
  30. const loader = (loading) => render({ loading })
  31. /**
  32. * Step2 注册子应用
  33. */
  34. registerMicroApps(
  35. [
  36. {
  37. name: 'vue', // 子应用名称
  38. entry: '//localhost:8001', // 子应用入口地址
  39. container: '#subapp-viewport',
  40. loader,
  41. activeRule: '/vue', // 子应用触发路由
  42. },
  43. {
  44. name: 'react',
  45. entry: '//localhost:8002',
  46. container: '#subapp-viewport',
  47. loader,
  48. activeRule: '/react',
  49. },
  50. ],
  51. // 子应用生命周期事件
  52. {
  53. beforeLoad: [
  54. app => {
  55. console.log('[LifeCycle] before load %c%s', 'color: green', app.name)
  56. },
  57. ],
  58. beforeMount: [
  59. app => {
  60. console.log('[LifeCycle] before mount %c%s', 'color: green', app.name)
  61. },
  62. ],
  63. afterUnmount: [
  64. app => {
  65. console.log('[LifeCycle] after unmount %c%s', 'color: green', app.name)
  66. },
  67. ],
  68. },
  69. )
  70. // 定义全局状态,可以在主应用、子应用中使用
  71. const { onGlobalStateChange, setGlobalState } = initGlobalState({
  72. user: 'qiankun',
  73. })
  74. // 监听全局状态变化
  75. onGlobalStateChange((value, prev) => console.log('[onGlobalStateChange - master]:', value, prev))
  76. // 设置全局状态
  77. setGlobalState({
  78. ignore: 'master',
  79. user: {
  80. name: 'master',
  81. },
  82. })
  83. /**
  84. * Step3 设置默认进入的子应用
  85. */
  86. setDefaultMountApp('/vue')
  87. /**
  88. * Step4 启动应用
  89. */
  90. start()
  91. runAfterFirstMounted(() => {
  92. console.log('[MainApp] first app mounted')
  93. })

这里有几个注意事项要注意一下:

  1. 子应用的名称 name 必须和子应用下的 package.json 文件中的 name 一样。
  2. 每个子应用都有一个 loader() 方法,这是为了应对用户直接从子应用路由进入页面的情况而设的。进入子页面时判断一下是否加载了主应用,没有则加载,有则跳过。
  3. 为了防止在切换子应用时显示空白页面,应该提供一个 loading 配置。
  4. 设置子应用的入口地址时,直接填入子应用的访问地址。

更改访问端口

vue-cli 的默认访问端口一般为 8080,为了和子应用保持一致,需要将主应用端口改为 8000(子应用分别为 8001、8002)。创建 vue.config.js 文件,将访问端口改为 8000:

  1. module.exports = {
  2. devServer: {
  3. port: 8000,
  4. }
  5. }

至此,主应用就已经改造完了。

创建子应用

子应用不需要引入 qiankun 依赖,只需要暴露出几个生命周期函数就可以:

  1. bootstrap,子应用首次启动时触发。
  2. mount,子应用每次启动时都会触发。
  3. unmount,子应用切换/卸载时触发。

现在将子应用的 main.js 文件改造一下:

  1. import Vue from 'vue'
  2. import VueRouter from 'vue-router'
  3. import App from './App.vue'
  4. import routes from './router'
  5. import store from './store'
  6. Vue.config.productionTip = false
  7. let router = null
  8. let instance = null
  9. function render(props = {}) {
  10. const { container } = props
  11. router = new VueRouter({
  12. // hash 模式不需要下面两行
  13. base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
  14. mode: 'history',
  15. routes,
  16. })
  17. instance = new Vue({
  18. router,
  19. store,
  20. render: h => h(App),
  21. }).$mount(container ? container.querySelector('#app') : '#app')
  22. }
  23. if (window.__POWERED_BY_QIANKUN__) {
  24. // eslint-disable-next-line no-undef
  25. __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  26. } else {
  27. render()
  28. }
  29. function storeTest(props) {
  30. props.onGlobalStateChange &&
  31. props.onGlobalStateChange(
  32. (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
  33. true,
  34. )
  35. props.setGlobalState &&
  36. props.setGlobalState({
  37. ignore: props.name,
  38. user: {
  39. name: props.name,
  40. },
  41. })
  42. }
  43. export async function bootstrap() {
  44. console.log('[vue] vue app bootstraped')
  45. }
  46. export async function mount(props) {
  47. console.log('[vue] props from main framework', props)
  48. storeTest(props)
  49. render(props)
  50. }
  51. export async function unmount() {
  52. instance.$destroy()
  53. instance.$el.innerHTML = ''
  54. instance = null
  55. router = null
  56. }

可以看到在文件的最后暴露出了 bootstrap mount unmount 三个生命周期函数。另外在挂载子应用时还需要注意一下,子应用是在主应用下运行还是自己独立运行:container ? container.querySelector('#app') : '#app'

配置打包项

根据 qiankun 文档提示,需要对子应用的打包配置项作如下更改:

  1. const packageName = require('./package.json').name;
  2. module.exports = {
  3. output: {
  4. library: `${packageName}-[name]`,
  5. libraryTarget: 'umd',
  6. jsonpFunction: `webpackJsonp_${packageName}`,
  7. },
  8. };

所以现在我们还需要在子应用目录下创建 vue.config.js 文件,输入以下代码:

  1. // vue.config.js
  2. const { name } = require('./package.json')
  3. module.exports = {
  4. configureWebpack: {
  5. output: {
  6. // 把子应用打包成 umd 库格式
  7. library: `${name}-[name]`,
  8. libraryTarget: 'umd',
  9. jsonpFunction: `webpackJsonp_${name}`
  10. }
  11. },
  12. devServer: {
  13. port: 8001,
  14. headers: {
  15. 'Access-Control-Allow-Origin': '*'
  16. }
  17. }
  18. }

vue.config.js 文件有几个注意事项:

  1. 主应用、子应用运行在不同端口下,所以需要设置跨域头 'Access-Control-Allow-Origin': '*'
  2. 由于在主应用配置了 vue 子应用需要运行在 8001 端口下,所以也需要在 devServer 里更改端口。

另外一个子应用 react 的改造方法和 vue 是一样的,所以在此不再赘述。

部署

我们将使用 express 来部署项目,除了需要在子应用设置跨域外,没什么需要特别注意的地方。

主应用服务器文件 main-server.js

  1. const fs = require('fs')
  2. const express = require('express')
  3. const app = express()
  4. const port = 8000
  5. app.use(express.static('main-static'))
  6. app.get('*', (req, res) => {
  7. fs.readFile('./main-static/index.html', 'utf-8', (err, html) => {
  8. res.send(html)
  9. })
  10. })
  11. app.listen(port, () => {
  12. console.log(`main app listening at http://localhost:${port}`)
  13. })

vue 子应用服务器文件 vue-server.js

  1. const fs = require('fs')
  2. const express = require('express')
  3. const app = express()
  4. const cors = require('cors')
  5. const port = 8001
  6. // 设置跨域
  7. app.use(cors())
  8. app.use(express.static('vue-static'))
  9. app.get('*', (req, res) => {
  10. fs.readFile('./vue-static/index.html', 'utf-8', (err, html) => {
  11. res.send(html)
  12. })
  13. })
  14. app.listen(port, () => {
  15. console.log(`vue app listening at http://localhost:${port}`)
  16. })

react 子应用服务器文件 react-server.js

  1. const fs = require('fs')
  2. const express = require('express')
  3. const app = express()
  4. const cors = require('cors')
  5. const port = 8002
  6. // 设置跨域
  7. app.use(cors())
  8. app.use(express.static('react-static'))
  9. app.get('*', (req, res) => {
  10. fs.readFile('./react-static/index.html', 'utf-8', (err, html) => {
  11. res.send(html)
  12. })
  13. })
  14. app.listen(port, () => {
  15. console.log(`react app listening at http://localhost:${port}`)
  16. })

另外需要将这三个应用打包后的文件分别放到 main-staticvue-staticreact-static 目录下。然后分别执行命令 node main-server.jsnode vue-server.jsnode react-server.js 即可查看部署后的页面。现在这个项目目录如下:

  1. -main
  2. -main-static // main 主应用静态文件目录
  3. -react
  4. -react-static // react 子应用静态文件目录
  5. -vue
  6. -vue-static // vue 子应用静态文件目录
  7. -main-server.js // main 主应用服务器
  8. -vue-server.js // vue 子应用服务器
  9. -react-server.js // react 子应用服务器

我已经将这个微前端应用的代码上传到了 github,建议将项目克隆下来配合本章一起阅读,效果更好。下面放一下 DEMO 的运行效果图:

11. 微服务实践 - 图5

11. 微服务实践 - 图6

11. 微服务实践 - 图7

小结

对于大型应用的开发和维护,使用微前端能让我们变得更加轻松。不过如果是小应用,建议还是单独建一个项目开发。毕竟微前端也有额外的开发、维护成本。

参考资料