前端微服务化解决方案

作者: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示例源码

微前端子项目示例源码

  • 1
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
微服务设计与解决方案 微服务架构现在是谈到企业应用架构时必聊的话题,微服务之所以火热也是因为相对之前的应用开发方式有很多优点,如更灵活、更能适应现在需求快速变更的大环境。 本文将介绍微服务架构的演进、优缺点和微服务应用的设计原则,然后着重介绍作为一个"微服务应用平台"需要提供哪些能力、解决哪些问题才能更好的支撑企业应用架构。 微服务平台也是我目前正在参与的,还在研发过程中的平台产品,平台是以SpringCloud为基础,结合了普元多年来对企业应用的理解和产品的设计经验,逐步孵的一个微服务应用平台。 目录: 一、微服务架构演进过程 二、微服务架构的好处 三、微服务应用4个设计原则 四、微服务架构带来的问题 五、微服务平台的19个落地实践 六、总结展望 微服务设计与解决方案全文共18页,当前为第1页。一、微服务架构演进过程 微服务设计与解决方案全文共18页,当前为第1页。 近年来我们大家都体会到了互联网、移动互联带来的好处,作为IT从业者,在生活中时刻感受互联网好处的同时,在工作中可能感受的却是来自自互联网的一些压力,那就是我们传统企业的IT建设也是迫切需要转型,需要面向外部客户,我们也需要应对外部环境的快速变、需要快速创新,那么我们的IT架构也需要向互联网企业学习作出相应的改进,来支撑企业的数字转型。 我们再看一下应用架构的演进过程,回忆一下微服务架构是如何一步一步进产生的,最早是应用是单块架构,后来为了具备一定的扩展和可靠性,就有了垂直架构,也就是加了个负载均衡,接下来是前几年比较火的SOA,主要讲了应用系统之间如何集成和互通,而到现在的微服务架构则是进一步在探讨一个应用系统该如何设计才能够更好的开发、管理更加灵活高效。 微服务架构的基本思想就是"围绕业务领域组件来创建应用,让应用可以独立的开发、管理和加速"。 二、微服务架构的好处 微服务设计与解决方案全文共18页,当前为第2页。 微服务设计与解决方案全文共18页,当前为第2页。 我们总结了四个方面的优点,分别如下: 是每个微服务组件都是简单灵活的,能够独立部署。不再像以前一样,应用需要一个庞大的应用服务器来支撑。 可以由一个小团队负责更专注专业,相应的也就更高效可靠。 微服务之间是松耦合的,微服务内部是高内聚的,每个微服务很容易按需扩展。 微服务架构与语言工具无关,自由选择合适的语言和工具,高效的完成业务目标即可。 看到这里,大家会觉得微服务架构挺不错,然而还会有一些疑问,什么样的应用算是一个微服务架构的应用?该怎样设计一个微服务架构的应用?那我们来一起看看我们推荐的微服务应用的设计原则。 三、微服务应用4个设计原则 我们总结了四个原则推荐给大家: AKF拆分原则 前后端分离 无状态服务 Restful通信风格 1.AKF拆分原则 微服务设计与解决方案全文共18页,当前为第3页。 微服务设计与解决方案全文共18页,当前为第3页。 AKF扩展立方体(参考《The Art of Scalability》),是一个叫AKF的公司的技术专家抽象总结的应用扩展的三个维度。理论上按照这三个扩展模式,可以将一个单体系统,进行无限扩展。 X 轴 :指的是水平复制,很好理解,就是讲单体系统多运行几个实例,做个集群加负载均衡的模式。 Z 轴 :是基于类似的数据分区,比如一个互联网打车应用突然或了,用户量激增,集群模式撑不住了,那就按照用户请求的地区进行数据分区,北京、上海、四川等多建几个集群。 Y 轴 :就是我们所说的微服务的拆分模式,就是基于不同的业务拆分。 场景说明:比如打车应用,一个集群撑不住时,分了多个集群,后来用户激增还是不够用,经过分析发现是乘客和车主访问量很大,就将打车应用拆成了三个乘客服务、车主服务、支付服务。三个服务的业务特点各不相同,独立维护,各自都可以再次按需扩展。 2.前后端分离 前后端分离原则,简单来讲就是前端和后端的代码分离也就是技术上做分离,我们推荐的模式是最好直接采用物理分离的方式部署,进一步促使进行更彻底的分离。不要继续以前的服务端模板技术,比如JSP ,把Java JS HTML CSS 都堆到一个页面里,稍复杂的页面就无法维护。这种分离模式的方式有几个好处: 前后端技术分离,可以由各自的专家来对各自的领域进行优,这样前端的用户体验优效果会更好。 微服务设计与解决方案全文共18页,当前为第4页。分离模式下,前后端交互界面更加清晰,就剩下了接口和模型,后端的接口简洁明了,更容易维护。 微服务设计与解决方案全文共18页,当前为第4页。 前端多渠道集成场景更容易实现,后端服务无需变更,采用统一的数据和模型,可以支撑前端的web UI\ 移动App等访问。 3.无状态服务 对于无状态服务,首先说一下什么是状态:如果一个数据需要被多个服务共享,才能完成一

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值