160  
查询码:00000232
前端微服务化解决方案
来源:https://blog.csdn.net/yidichaxiang/article/details/106190166
作者: 朱凡 于 2021年08月28日 发布在分类 / FM组 / FM_App 下,并于 2021年08月28日 编辑
模块 前端 一个 我们 路由 应用 import 项目 服务 文件

前端微服务化解决方案


作者:Alili前端大暴炸的前端微服务化解决方案系列
链接:https://www.jianshu.com/u/2aa7a9ad33ad
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

近几年,微服务架构在后端技术社区大红大紫,它被认为是IT软件架构的未来技术方向.我们如何借鉴后端微服务的思想来构建一个现代化前端应用?
在这里我提供一个可以在产品中真正可以落地的前端微服务解决方案.

微服务化后端前后端对比

后端微服务化的优势:

  1. 复杂度可控: 体积小、复杂度低,每个微服务可由一个小规模开发团队完全掌控,易于保持高可维护性和开发效率。
  2. 独立部署: 由于微服务具备独立的运行进程,所以每个微服务也可以独立部署。
  3. 技术选型灵活: 微服务架构下,技术选型是去中心化的。每个团队可以根据自身服务的需求和行业发展的现状,自由选择最适合的技术栈。
  4. 容错: 当某一组建发生故障时,在单一进程的传统架构下,故障很有可能在进程内扩散,形成应用全局性的不可用。
  5. 扩展: 单块架构应用也可以实现横向扩展,就是将整个应用完整的复制到不同的节点。

前端微服务化后的优势:

  1. 复杂度可控: 每一个UI业务模块由独立的前端团队开发,避免代码巨无霸,保持开发时的高速编译,保持较低的复杂度,便于维护与开发效率。
  2. 独立部署: 每一个模块可单独部署,颗粒度可小到单个组件的UI独立部署,不对其他模块有任何影响。
  3. 技术选型灵活: 也是最具吸引力的,在同一项目下可以使用如今市面上所有前端技术栈,也包括未来的前端技术栈。
  4. 容错: 单个模块发生错误,不影响全局。
  5. 扩展: 每一个服务可以独立横向扩展以满足业务伸缩性,与资源的不必要消耗;

我们何时需要前端微服务化?

  1. 项目技术栈过于老旧,相关技能的开发人员少,功能扩展吃力,重构成本高,维护成本高.
  2. 项目过于庞大,代码编译慢,开发体差,需要一种更高维度的解耦方案.
  3. 单一技术栈无法满足你的业务需求

其中面临的问题与挑战

我们即将面临以下问题:

  • 我们如何实现在一个页面里渲染多种技术栈?
  • 不同技术栈的独立模块之间如何通讯?
  • 如何通过路由渲染到正确的模块?
  • 在不同技术栈之间的路由该如何正确触发?
  • 项目代码别切割之后,通过何种方式合并到一起?
  • 我们的每一个模块项目如何打包?
  • 前端微服务化后我们该如何编写我们的代码?
  • 独立团队之间该如何协作?

技术选型

经过各种技术调研我们最终选择的方案是基于 Single-SPA 来实现我们的前端微服务化.

Single-SPA

一个用于前端微服务化的JavaScript前端解决方案

使用Single-SPA之后,你可以这样做:

  • (兼容各种技术栈)在同一个页面中使用多种技术框架(React, Vue, AngularJS, Angular, Ember等任意技术框架),并且不需要刷新页面.
  • (无需重构现有代码)使用新的技术框架编写代码,现有项目中的代码无需重构.
  • (更优的性能)每个独立模块的代码可做到按需加载,不浪费额外资源.
  • 每个独立模块可独立运行.

下面是一个微前端的演示页面 (你可能需要科学的上网)
https://single-spa.surge.sh/

以上是官方例子,但是官方例子中并没有解决一个问题.就是各种技术栈的路由实现方式大相径庭,如何做到路由之间的协同?
后续文章会讲解,如何解决这样的问题.

单体应用对比前端微服务化

普通的前端单体应用


微前端架构


Single-SPA的简单用法

1.创建一个HTML文件

<html>
<body>
  <div id="root"></div>
  <script src="single-spa-config.js"></script>
</body>
</html>

2.创建single-spa-config.js 文件

// single-spa-config.js
import * as singleSpa from 'single-spa';

// 加载react 项目的入口js文件 (模块加载)
const loadingFunction = () => import('./react/app.js');

// 当url前缀为 /react的时候.返回 true (底层路由)
const activityFunction = location => location.pathname.startsWith('/react');

// 注册应用 
singleSpa.registerApplication('react', loadingFunction, activityFunction);

//singleSpa 启动
singleSpa.start();

封装React项目的渲染出口文件

我们把渲染react的入口文件修改成这样,便可接入到single-spa

import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import RootComponent from './root.component'

if (process.env.NODE_ENV === 'development') {
 // 开发环境直接渲染
 ReactDOM.render(<RootComponent />, document.getElementById('root'))
}

//创建生命周期实例
const reactLifecycles = singleSpaReact({
 React,
 ReactDOM,
 rootComponent: RootComponent
 domElementGetter: () => document.getElementById('root')
})

// 项目启动的钩子
export const bootstrap = [
 reactLifecycles.bootstrap,
]
// 项目启动后的钩子
export const mount = [
 reactLifecycles.mount,
]
// 项目卸载的钩子
export const unmount = [
 reactLifecycles.unmount,
]


微前端的模块加载器,主要功能为:

  • 项目配置文件的加载
  • 项目对外接口文件的加载(消息总线会用到,后续会提)
  • 项目入口文件的加载

以上也是每一个单模块,不可缺少的三部分

配置文件

我们实践微前端的过程中,我们对每个模块项目,都有一个对外的配置文件.
是模块在注册到singe-spa时候所用到的信息.


{
  "name": "name", //模块名称
  "path": "/project", //模块url前缀
  "prefix": "/module-prefix/", //模块文件路径前缀
  "main": "/module-prefix/main.js", //模块渲染出口文件
  "store": "/module-prefix/store.js",//模块对外接口
  "base": true 
  // 当模块被定性为baseApp的时候,
  // 不管url怎么变化,项目也是会被渲染的,
  // 使用场景为,模块职责主要为整个框架的布局或者一直被渲染,不会改变的部分
 }

当我们的模块,有多种url前缀的时候,path也可以为数组形式


{
  "path": ["/project-url-path1/","/project-url-path2/"], //项目url前缀
 }

配置自动化

我们每个模块都有上面所描述的配置文件,当我们的项目多个模块的时候,我们需要把所有模块的配置文件聚合起来.
我这里也有写一个脚本.

micro-auto-config

使用方法:

npm install micro-auto-config -g

# 在项目根目录,用pm2启动该脚本,便可启动这个项目的配置自动化
pm2 start micro-auto-config

大概思路是:当模块部署,服务器检测到项目文件发生改变,便开始找出所有模块的配置文件,把他们合并到一起.
以数组包对象的形式输出一个总体的新配置文件project.config.js.
当我们一个模块配置有更新,部署到线上的时候,项目配置文件会自动更新.

模块加载器

这个文件直接引入到html中,也就是上一篇文章中的single-spa-config.js升级版.
在加载模块的时候,我们使用SystemJS作为我们的模块加载工具.

"use strict";
import '../libs/es6-promise.auto.min'
import * as singleSpa from 'single-spa'; 
import { registerApp } from './Register'

async function bootstrap() {
  // project.config.js 文件为所有模块的配置集合
  let projectConfig = await SystemJS.import('/project.config.js' )

  // 遍历,注册所有模块
  projectConfig.projects.forEach( element => {
    registerApp({
      name: element.name,
      main: element.main,
      url: element.prefix,
      store:element.store,
      base: element.base,
      path: element.path
    });
  });
  
  // 项目启动
  singleSpa.start();
}

bootstrap()

Register.js

import '../libs/system'
import '../libs/es6-promise.auto.min'
import * as singleSpa from 'single-spa';

// hash 模式,项目路由用的是hash模式会用到该函数
export function hashPrefix(app) {
  return function (location) {
    let isShow = false
    //如果该应用 有多个需要匹配的路劲
    if(isArray(app.path)){
      app.path.forEach(path => {
        if(location.hash.startsWith(`#${path}`)){
          isShow = true
        }
      });
    }
    // 普通情况
    else if(location.hash.startsWith(`#${app.path || app.url}`)){
      isShow = true
    }
    return isShow;
  }
}

// pushState 模式
export function pathPrefix(app) {
  return function (location) {
    let isShow = false
    //如果该模块 有多个需要匹配的路径
    if(isArray(app.path)){
      app.path.forEach(path => {
        if(location.pathname.indexOf(`${path}`) === 0){
          isShow = true
        }
      });
    }
    // 普通情况
    else if(location.pathname.indexOf(`${app.path || app.url}`) === 0){
      isShow = true
    }
    return isShow;
  }
}

// 应用注册
export async function registerApp(params) {

  singleSpa.registerApplication(params.name, () => SystemJS.import(params.main), params.base ? (() => true) : pathPrefix(params));

}

//数组判断 用于判断是否有多个url前缀
function isArray(o){
  return Object.prototype.toString.call(o)=='[object Array]';
}


微前端的消息总线,主要的功能是搭建模块与模块之间通讯的桥梁.

黑盒子

问题1:

应用微服务化之后,每一个单独的模块都是一个黑盒子,
里面发生了什么,状态改变了什么,外面的模块是无从得知的.
比如模块A想要根据模块B的某一个内部状态进行下一步行为的时候,黑盒子之间没有办法通信.这是一个大麻烦.

问题2

每一个模块之间都是有生命周期的.当模块被卸载的时候,如何才能保持后续的正常的通信?

ps. 我们必须要解决这些问题,模块与模块之间的通讯太有必要了.

打破壁垒

在github上single-spa-portal-example,给出来一解决方案.

基于Redux实现前端微服务的消息总线(不会影响在编写代码的时候使用其他的状态管理工具).

大概思路是这样的:
每一个模块,会对外提供一个 Store.js.这个文件
里面的内容,大致是这样的.

import { createStore, combineReducers } from 'redux'

const initialState = {
 refresh: 0
}

function render(state = initialState, action) {
 switch (action.type) {
  case 'REFRESH':
   return { ...state,
    refresh: state.refresh + 1
   }
  default:
   return state
 }
}

// 向外输出 Reducer
export const storeInstance = createStore(combineReducers({ namespace: () => 'base', render }))

对于这样的代码,有没有很熟悉?
对,他就是一个普通的Reducer文件,
每一个模块对外输出的Store.js,就是一个模块的Reducer.

Store.js 如何被使用?

我们需要在模块加载器中,导出这个Store.js

于是我们对模块加载器中的Register.js文件 (该文件在上一章出现过,不懂的同学可以往回看)

进行了以下改造:

import * as singleSpa from 'single-spa';

//全局的事件派发器 (新增)
import { GlobalEventDistributor } from './GlobalEventDistributor' 
const globalEventDistributor = new GlobalEventDistributor();


// hash 模式,项目路由用的是hash模式会用到该函数
export function hashPrefix(app) {
...
}

// pushState 模式
export function pathPrefix(app) {
...
}

// 应用注册
export async function registerApp(params) {
  // 导入派发器
  let storeModule = {}, customProps = { globalEventDistributor: globalEventDistributor };

  // 在这里,我们会用SystemJS来导入模块的对外输出的Reducer(后续会被称作模块对外API),统一挂载到消息总线上
  try {
    storeModule = params.store ? await SystemJS.import(params.store) : { storeInstance: null };
  } catch (e) {
    console.log(`Could not load store of app ${params.name}.`, e);
    //如果失败则不注册该模块
    return
  }

  // 注册应用于事件派发器
  if (storeModule.storeInstance && globalEventDistributor) {
    //取出 redux storeInstance
    customProps.store = storeModule.storeInstance;

    // 注册到全局
    globalEventDistributor.registerStore(storeModule.storeInstance);
  }

  //当与派发器一起组装成一个对象之后,在这里以这种形式传入每一个单独模块
  customProps = { store: storeModule, globalEventDistributor: globalEventDistributor };

  // 在注册的时候传入 customProps
  singleSpa.registerApplication(params.name, () => SystemJS.import(params.main), params.base ? (() => true) : pathPrefix(params), customProps);
}

全局派发器 GlobalEventDistributor

全局派发器,主要的职责是触发各个模块对外的API.

GlobalEventDistributor.js

export class GlobalEventDistributor {

  constructor() {
    // 在函数实例化的时候,初始一个数组,保存所有模块的对外api
    this.stores = [];
  }

  // 注册
  registerStore(store) {
    this.stores.push(store);
  }

  // 触发,这个函数会被种到每一个模块当中.便于每一个模块可以调用其他模块的 api
  // 大致是每个模块都问一遍,是否有对应的事件触发.如果每个模块都有,都会被触发.
  dispatch(event) {
    this.stores.forEach((s) => {
      s.dispatch(event)
    });
  }

  // 获取所有模块当前的对外状态
  getState() {
    let state = {};
    this.stores.forEach((s) => {
      let currentState = s.getState();
      console.log(currentState)
      state[currentState.namespace] = currentState
    });
    return state
  }
}

在模块中接收派发器以及自己的Store

上面提到,我们在应用注册的时候,传入了一个customProps,里面包含了派发器以及store.
在每一个单独的模块中,我们如何接收并且使用传入的这些东西呢?

import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import RootComponent from './root.component'
import { storeInstance, history } from './Store'
import './index.less'


const reactLifecycles = singleSpaReact({
 React,
 ReactDOM,
 rootComponent: (spa) => {
  // 我们在创建生命周期的时候,把消息总线传入的东西,以props的形式传入组件当中
  // 这样,在每个模块中就可以直接调用跟查询其他模块的api与状态了
  return <RootComponent store={spa.customProps.store.storeInstance} globalEventDistributor={spa.customProps.globalEventDistributor} />
 },
 domElementGetter: () => document.getElementById('root')
})

export const bootstrap = [
 reactLifecycles.bootstrap,
]

export const mount = [
 reactLifecycles.mount,
]

export const unmount = [
 reactLifecycles.unmount,
]

路由分发式微前端

从应用分发路由到路由分发应用

用这句话来解释,微前端的路由,再合适不过来.

路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。
就当前而言,通过路由分发式的微前端架构应该是采用最多、最易采用的 “微前端” 方案。但是这种方式看上去更像是多个前端应用的聚合,即我们只是将这些不同的前端应用拼凑到一起,使他们看起来像是一个完整的整体。但是它们并不是,每次用户从 A 应用到 B 应用的时候,往往需要刷新一下页面。 -- 引用自phodal 微前端的那些事儿

在模块加载器那一章的示例代码,已经非常充分了展示了路由分发应用的步骤.

在单页面前端的路由,目前有两种形式,
一种是所有主流浏览器都兼容多hash路由,
基本原理为url的hash值的改变,触发了浏览器onhashchange事件,来触发组件的更新

还有一种是高级浏览器才支持的 History API,
在window.history.pushState(null, null, "/profile/");的时候触发组件的更新


// hash 模式,项目路由用的是hash模式会用到该函数
export function hashPrefix(app) {
  return function (location) {
    let isShow = false
    //如果该应用 有多个需要匹配的路劲
    if(isArray(app.path)){
      app.path.forEach(path => {
        if(location.hash.startsWith(`#${path}`)){
          isShow = true
        }
      });
    }
    // 普通情况
    else if(location.hash.startsWith(`#${app.path || app.url}`)){
      isShow = true
    }
    return isShow;
  }
}

// pushState 模式
export function pathPrefix(app) {
  return function (location) {
    let isShow = false
    //如果该模块 有多个需要匹配的路径
    if(isArray(app.path)){
      app.path.forEach(path => {
        if(location.pathname.indexOf(`${path}`) === 0){
          isShow = true
        }
      });
    }
    // 普通情况
    else if(location.pathname.indexOf(`${app.path || app.url}`) === 0){
      isShow = true
    }
    return isShow;
  }
}

// 应用注册
export async function registerApp(params) {
  // 第三个参数为,该模块是否显示
  singleSpa.registerApplication(params.name, // 模块名字
                 () => SystemJS.import(params.main), // 模块渲染的入口文件
                 params.base ? (() => true) : pathPrefix(params) // 模块显示的条件
                 );

}

路由分发应用

当url前缀,与配置中的url前缀保持一致的时候,
singleSpa会激活对应的模块,然后把模块内容渲染出来.

应用分发路由

在模块被激活的时候,模块会读取url,再渲染到对的页面.

这就是微前端路由的路由工作流程

微前端路由的挑战

Hash路由

在目前所有支持spa的前端框架中,都支持了Hash路由.
Hash路由都工作大致原理就是: url的Hash值的改变,触发了浏览器onhashchange事件,进而来触发组件的更新.
所有的前端的框架,都是基于onhashchange来更新我们的页面的.
当我们的架构使用微前端的话,如果选择hash路由,便可以保证所有的前端技术框架的更新事件都是一致的.
所以使用Hash路由也是最省心的.如果不介意Hash路由中url的#字符,在微前端中使用Hash也是推荐的.

HTML5 History 路由

大家都知道,HTML5中History对象上新增了两个API(pushState与replaceState).
在这两个新API的作用下,我们也是可以做到页面无刷新,并且更新页面的.并且url上不需要出现#号.
保持了最高的美观度(对于一些人来讲).
当然现在几乎所有的主流SPA技术框架都支持这一特性.
但是问题是,这两个API在触发的时候,是没有一个全局的事件触发的.
多种技术框架对History路由的实现都不一样,就算是技术栈都是 React,他的路由都有好几个版本.

那我们如何保证一个项目下,多个技术框架模块的路由做到协同呢?

只有一个history

前提: 假设我们所有的项目用的都是React,我们的路由都在使用着同一个版本.

思路: 我们是可以这样做的,在我们的base前端模块(因为他总是第一个加载,也是永远都不会被销毁的模块)中的Store.js,
实例化一个React router的核心库history,通过消息总线,把这个实例传入到所有的模块中.
在每个模块的路由初始化的时候,是可以自定义自己的history的.把模块的history重新指定到传入的history.
这样就可以做到,所有模块的路由之间的协同了.
因为当页面切换的时候,history触发更新页面的事件,当所有模块的history都是一个的时候,所有的模块都会更新到正确的页面.
这样就保证了所有模块与路由都协同.

如果你看不懂我在讲什么,直接贴代码吧:

//Base前端模块的 Store.js
import { createStore, combineReducers } from 'redux'

// react router 的核心库 history
import createHistory from 'history/createBrowserHistory'

const history = createHistory()

// 传出去
export const storeInstance = createStore(combineReducers({ namespace: () => 'base' ,history }))


// 应用注册
export async function registerApp(params) {
  ...

  // history 直接引入进来,用systemjs直接导入实例
  try {
    storeModule = params.store ? await SystemJS.import(params.store) : { storeInstance: null };
  } catch (e) {
    ...
  }
  ...

  // 跟派发器一起放进 customProps 中
  customProps = { store: storeModule, globalEventDistributor: ... };


  // 在注册的时候传入 customProps
  singleSpa.registerApplication(params.name, 
                () => SystemJS.import(params.main), 
                params.base ? (() => true) : pathPrefix(params), 
                customProps // 应用注册的时候,history会包含在 customProps 中,直接注入到模块中
                );
}


// React main.js
import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import RootComponent from './root.component'

const reactLifecycles = singleSpaReact({
 React,
 ReactDOM,
 rootComponent: (spa) => {
  // 在这里,把history传入到组件
  return <RootComponent history={spa.customProps.history}/>
 },
 domElementGetter: () => document.getElementById('root')
})

...


// RootComponent
import React from 'react'
import { Provider } from 'react-redux' 
export default class RootComponent extends React.Component {
  render() {
    return <Provider store={this.state.store}>
      // 在这里重新指定Router的history
     <Router history={this.props.history}>
      <Switch>
        ...
      </Switch>
     </Router>
    </Provider>
  }
}

以上就是让所有模块的路由协同,保证只有一个history的用法

多技术栈模块路由协同

问题: 用上面的方式是可行的,但是遗憾的是,他的应用场景比较小,只能在单一技术栈,单一路由版本的情况下使用.
微前端最大的优势之一就是自由选择技术栈.
在一个项目中,使用多个适合不同模块的技术栈.

思路: 我们其实是可以通过每一个模块对外输出一个路由跳转到接口,基于消息总线的派发,让每一个模块渲染到正确的页面.
比如 模块A要跳转到/a/b/c,模块a先更新到/a/b/c路由的页面,然后通过消息总线,告诉所有模块,现在要跳转到/a/b/c了.
然后其他模块,有/a/b/c这个路由都,就直接跳转,没有的就什么都不做.

我们可以这样做:

// Store.js
import { createStore, combineReducers } from 'redux'
import createHistory from 'history/createBrowserHistory'
const history = createHistory()

// 对外输出一个to的接口,当一个模块需要跳转界面的时候,会向所有的模块调用这个接口,
// 然后对应的模块会直接渲染到正确的页面
function to(state, action) {
 if (action.type !== 'to' ) return { ...state, path: action.path }
 history.replace(action.path)
 return { ...state, path: action.path }
}

export const storeInstance = createStore(combineReducers({ namespace: () => 'base', to }))

export { history }

微前端打包构建

微前端项目的打包,是有一些需要注意的点
以webpack为例:

amd模块

在之前的文章,我们有提到我们的加载器,是基于System.js来做的.
所以我们微前端的模块最终打包,是要符合模块规范的.
我们使用的是amd模块规范来构建我们的模块.

指定基础路径

因为模块打包后,调用模块出口文件的,是模块加载器.
为了清晰的管理每个模块,并且正确的加载到我们每一个模块的资源,
我们给模块的资源都指定一个publicPath.

下面给出一个简单的 webpack 配置,这些配置我只是列出一些必要选项.
并不是一个完整的webpack配置,后续我会提供完整的微前端的Demo,提供大家参考
这些配置都是基于create-react-app的配置做的修改.
只要明白了配置的意图,明白我们打包出来的最终是一个什么样的包,
不管打包工具以后怎么变,技术栈怎么变,最后都是可以对接到微前端中来.

这里给出project.json的内容,便于后面的配置文件的阅读

// project.json
{
  "name": "name", //模块名称
  "path": "/project", //模块url前缀
  "prefix": "/module-prefix/", //模块文件路径前缀
  "main": "/module-prefix/main.js", //模块渲染出口文件
  "store": "/module-prefix/store.js",//模块对外接口
  "base": true // 是否为baseapp
 }


// 引入项目配置文件,也是前面说的 模块加载器必要文件之一
const projectConfig = require('./project.json')

let config = {
 entry: {
  main: paths.appIndexJs, //出口文件,模块加载器必要文件之一
  store: paths.store // 对外api的reducer文件,模块加载器必要文件之一
 },
 output: {
  path: paths.appBuild,
  filename: '[name].js?n=[chunkhash:8]',
  chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
  publicPath: projectConfig.prefix, //在output中指定模块配置好的 publicPath
  libraryTarget: 'amd', //对外输出 amd模块,便于 system.js加载
  library: projectConfig.name, //模块的名称
 },
 },
 module: {
  rules: [
   {
    oneOf: [
     {
      test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
      // loader: 'happypack/loader?id=url',
      loaders: [{
       loader: require.resolve('url-loader'),
       options: {
        limit: 5000,
        name: 'static/media/[name].[hash:8].[ext]',
        publicPath: projectConfig.prefix, //我们需要在静态文件的loader加上publicPath
       },
      }]
     },
     {
      test: /\.(js|jsx|mjs)$/,
      include: paths.appSrc,
      loader: 'happypack/loader?id=babel',
      options: {
        name: 'static/js/[name].[hash:8].[ext]',
        publicPath: projectConfig.prefix, //在静态文件的loader加上publicPath
       },
     },
     {
      loader: require.resolve('file-loader'),
      exclude: [/\.(js|jsx|mjs)$/, /\.html$/, /\.json$/],
      options: {
       name: 'static/media/[name].[hash:8].[ext]',
       publicPath: projectConfig.prefix, //在静态文件的loader加上publicPath
      },
     },
    ],
   },
  ],
 },
}

部署

前端单页面的部署,不管怎么自动化,工具怎么变.
都是把打包好的静态文件,放到服务器的正确位置下.
微前端的部署,是一个应用聚合的过程,我们如何把一个个模块最后接入到一个完整的项目中的呢?

微前端应用完整目录

一般会放在一个nginx配置好的静态目录里,或者是其他web容器的一个静态目录.
看到这个目录结构,你应该能理解为什么要额外的配置publicPath了吧.

├── index.html       // 首先浏览器会加载这个index.html,html里面会引入一个bootstrap.js的文件
├── bootstrap.js      // 这个bootstrap.js是之前说的模块加载器打包过后的代码,
│              // 模块加载器会先加载 `project.config.js`,得到所有模块的配置.
│              // 然后才开始加载每个项目中的main.js文件,注册应用,注入store.js
│
├── project.config.js    // 这个文件存到是该项目的所有模块的配置,是代码自动生成的
│              // 之前有提到过项目配置自动化,是这个项目中唯一动态的文件.
│              // 目的是让模块的配置文件更新,或者新增新模块的时候,模块会自动挂载到项目中来
│              // 他会遍历每一个模块的project.json文件,取出内容,合并到一起
│
├── projectA        // 模块A目录
│  ├── asset-manifest.json
│  ├── favicon.ico
│  ├── main.js       // 渲染用的出口文件
│  ├── manifest.json
│  ├── project.json    // 模块的配置文件
│  ├── static
│  │  ├── js
│  │  │  ├── 0.86ae3ec3.chunk.js
│  │  └── media
│  │    └── logo.db0697c1.png
│  └── store.js      //对外输出的store.js 文件
└── projectB        // 模块B (重要文件的位置,与模块A是一致的)
  ├── asset-manifest.json
  ├── main.js
  ├── manifest.json
  ├── project.json
  ├── static
  │  ├── js
  │  │  ├── 0.86ae3ec3.chunk.js
  │  └── media
  │    └── logo.db0697c1.png
  └── store.js

配置自动化

我们每个模块都有上面所描述的配置文件,当我们的项目多个模块的时候,我们需要把所有模块的配置文件聚合起来.
我这里也有写一个脚本.

micro-auto-config

使用方法:

npm install micro-auto-config -g

# 在项目根目录,用pm2启动该脚本,便可启动这个项目的配置自动化
pm2 start micro-auto-config --name 你的项目名称-auto-config

这样之后project.config.js就会自动生成,以及模块变动之后也会重新生成.

动态入口

当有新的子模块会挂载到项目中的时候,在UI中肯定需要一个新的入口进入子模块的UI.
而这样一个入口,是需要动态生成的.

例如:图中左边的菜单,不应该是代码写死的.而是根据每个模块提供的数据自动生成的.

不然每次发布新的模块,我们都需要在最外面的这个框架修改代码.这样就谈不上什么独立部署了.

静态数据共享

想要达到上面所的效果,我们可以这样做.

// ~/common/menu.js

import { isUrl } from '../utils/utils'
let menuData = [
 {
  name: '模块1',
  icon: 'table',
  path: 'module1',
  rank: 1,
  children: [
   {
    name: 'Page1',
    path: 'page1',
   },
   {
    name: 'Page2',
    path: 'page2',
   },
   {
    name: 'Page3',
    path: 'page3',
   },
  ],
 }
]
let originParentPath = '/'
function formatter(data, parentPath = originParentPath, parentAuthority) {
  ...
}

// 在这里,我们对外导出 这个模块的菜单数据
export default menuData


// Store.js
import { createStore, combineReducers } from 'redux'
import menuDate from './common/menu'
import createHistory from 'history/createBrowserHistory'
const history = createHistory()
...

// 我们拿到数据之后,用一个reducer函数返回我们的菜单数据.
function menu() {
 return menuDate
}

...


// 最终以Store.js对外导出我们的菜单数据,在注册的时候,每个应用都可以拿到这个数据了
export const storeInstance = createStore(combineReducers({ namespace: () => 'list', menu, render, to }))

当我们的Base模块,拿到所有子模块的菜单数据,把他们合并后,就可以渲染出正确的菜单了.

二次构建

进一步优化我们的微前端性能

在微前端这种形势的架构,每个模块都会输出固定的文件,比如之前说的:

  • 项目配置文件
  • Store.js 文件
  • main.js 渲染入口文件

这三个,是微前端架构中每个模块必要的三个文件.

在模块加载器启动整个项目的时候,都必须要加载所有模块的配置文件与Store.js文件.
在前面的文章中有说配置自动化的问题,这其实就是一种简单的二次构建.
虽然每一个模块的配置文件体积不是很大,但是每一个文件都会加载,是项目启动的必要文件.
每一个文件都会占一个http请求,每一个文件的阻塞都会影响项目的启动时间.

所以,我们的Store.js也必须是要优化的.
当然如果我们的模块数量不是很多的话,我们没有优化的必要.但是一旦项目变得更加庞大,有好几十个模块.
我们不可能一次加载几十个文件,我们必须要在项目部署之后,还要对整个项目重新再次构建来优化与整合我们的项目.

我们的Store.js 是一个amd模块,所以我们需要一个合并amd模块的工具.

Grunt or Gulp

像这样的场景,用grunt,gulp这样的任务管理工具再合适不过了.
不管这两个工具好像已经是上个世纪的东西了,但是他的生态还是非常完善的.用在微前端的二次构建中非常合适.

例如Gulp:

const gulp = require('gulp');
const concat = require('gulp-concat');
 
gulp.task('storeConcat', function () {
  gulp.src('project/**/Store.js')
    .pipe(concat('Store.js')) //合并后的文件名
    .pipe(gulp.dest('project/'));
});

像这样的优化点还有非常多,在项目发布之后,在二次构建与优化代码.
在后期庞大的项目中,是有很多空间来提升我们项目的性能的.

Demo

前端微服务化 Micro Frontend Demo

微前端模块加载器

微前端Base App示例源码

微前端子项目示例源码

yidichaxiang




 推荐知识

 历史版本

修改日期 修改人 备注
2021-08-28 21:14:51[当前版本] 朱凡 创建版本

 附件

附件类型

GIFGIF

知识分享平台 -V 4.8.7 -wcp