我们的微前端是如何炼成的 |🏆 技术专题第四期

1,952 阅读16分钟

微前端相关的尝试,我很早就有尝试,当时还没有这么百花齐放跟铺天盖地的资料可以参考. 全靠当初的一些概念与实践将这套方案落了地.相信很多实践微前端的同学也看过这系列文章. 看掘金有相关的征文,今天就发掘金了.

希望可以给到大家一些思考.感谢大家的支持~

微前端的思考

近几年,微服务架构在后端技术社区大红大紫,它被认为是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等任意技术框架),并且不需要刷新页面.
  • (无需重构现有代码)使用新的技术框架编写代码,现有项目中的代码无需重构.
  • (更优的性能)每个独立模块的代码可做到按需加载,不浪费额外资源.
  • 每个独立模块可独立运行.

下面是一个微前端的演示页面 (你可能需要科学的上网) 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
  }
}

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

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

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来导入模块的对外输出的Store(后续会被称作模块对外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/'));
});

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

乾坤框架出现之前我们是如何对Umi框架做微前端的

今天我们就聊一聊,我们如何基于umi来打造一个更完善的前端微服务的子模块.

如果你用的是react以外的前端技术栈, 我的很多处理做法也可以应用在其他技术栈上.

希望对你也有所帮助.

优秀的umi框架

在前端中后台项目上,前端微服务化的需求相对是比较旺盛一些的.

说到中后台,很多企业都是基于antd的组件来构建自己的项目.

自去年的see conf之后,蚂蚁的一款可插拔的企业级 react 应用框架 umi发布了.

这款框架与antd息息相关,antd结合umi使用那是相当的自然与流畅.

可以说,基于umi与antd构建的项目非常的漂亮.这么优秀的框架,如果让他适用于我们的前端微服务架构,岂不美哉?

umi也有相关的类似微服务方案: github.com/umijs/umi-e…

但是umi提供的方案,有很大的局限性. 如果可以接入single-spa的微服务方案,独立开发,独立部署等等的前端微服务化红利, 会让你的项目日后有更大的发展空间.

基于umi插件机制做到前端微服务化

umi 提供了非常强大的插件机制,正是由于这一点,我们才可以让umi也可以接入到微服务架构中来

umi插件介绍

umi插件的基本介绍:

umijs.org/zh/plugin/

umi插件开发

这里介绍了如何开发一个简单的umi插件:

umijs.org/zh/plugin/d…

接入single-spa的umi插件

export default (api, opts) => {
  // 以下的所有代码都写在这里面哦
};

渲染入口处理方法

定义一个动态的元素,当我们的base app 需要加载子模块的时候,会渲染出子模块需要渲染元素.

我们的子模块找到了自己模块需要渲染的节点的时候,就会渲染出来.

  const domElementGetterStr = `
      function domElementGetter() {
        let el = document.getElementById('submodule-page')
        if (!el) {
          el = document.createElement('div')
          el.id = 'submodule-page'
        }
        let timer = null
        timer = setInterval(() => {
          if (document.querySelector('#submoduleContent.submoduleContent')) {
                document.querySelector('#submoduleContent.submoduleContent').appendChild(el)
                clearInterval(timer)
                timer = null
          }
        }, 100)

        return el
    }`

使用single-spa-react

在umi的入口文件导入single-spa-react ,根据模块的属性来判断模块在运行时是否渲染在root节点上还是指定节点

// 生产环境使用
if (process.env.NODE_ENV === 'production') {
 api.addEntryCodeAhead(`
    import singleSpaReact from 'single-spa-react';
    let reactLifecycles;
    reactLifecycles =  singleSpaReact({
        React,
        ReactDOM,
        rootComponent: (customProps) => window.g_plugins.apply('rootContainer', {
        initialValue: React.createElement(require('./router').default,customProps),
        }),
        domElementGetter: ${options.base?`() => document.getElementById('root')`:domElementGetterStr}
    });
  `);
}

对外导出标准的生命周期

清空umi原来的渲染方法,并且对外导出single-spa需要的生命周期.

// 生产环境使用
if (process.env.NODE_ENV === 'production') {
api.modifyEntryRender(``)

api.addEntryCode(`
    export const bootstrap = [
    reactLifecycles.bootstrap,
    ];

    export const mount = [
    reactLifecycles.mount,
    ];

    export const unmount = [
    reactLifecycles.unmount,
    ];
    `)
}

这样我们就得到了一个兼容single-spa的umi子模块.

打包相关

    api.modifyWebpackConfig((config) => {
     // 打包的还是amd模块
      config.output.libraryTarget = 'amd'

      // 指定模块名称
      config.output.library = options.name;

      //根据自己部署情况来修改outputPath
      config.output.path = resolve(`./dist/${options.deployPath}/`);
      
      // 根据自己部署情况来修改publicPath
      config.output.publicPath = options.deployPath;
      return config;
    })
  }


  api.modifyDefaultConfig(memo => ({
      // webpack的配置修改,umi也提供了 chainWebpack
      ...memo,
      //指定路由模式
      history: 'hash',

      // 导出用于通信的store文件
      // 如果你不知道这个是用来干什么的,可以读一读以前的文章
      chainWebpack(config) {
        config
          .entry('store').add('./src/store.js')
          .end()
      }
    }));

umi的全局变量问题

umi对外提供了很多的全局变量,当我们的微前端架构中,只有一个模块是umi构建的话,不需要考虑这个问题,如果有多个模块使用了umi,将会出现全局变量冲突的问题.还好umi的全局变量是有规范的,我们可以针对性处理.

我给出以下解决方案,可能相对暴力,但是也能解决冲突的问题.如果你有更好更加优雅的办法,欢迎交流.

大致思路是,在项目打包完成后,把每个文件的全局变量全部替换成其他的名字.

// 打包后替换全局变量以免冲突
api.onBuildSuccess(() => {
    const outPath = resolve('.', `dist/${options.deployPath}`);
    readdir(outPath, 'utf8', (err, data) => {

      data.forEach((item) => {
        if (!lstatSync(resolve(outPath, item)).isDirectory()) {
          readFile(resolve(outPath, item), 'utf8', (error, files) => {
            if (error) {
              console.log(error);
              return
            }
            // 替换全局变量
            const result = files.replace(/window.g_/g, `window.g_${options.name}_`);

            writeFile(resolve(outPath, item), result, 'utf8', (err2) => {
              if (err2) console.log(err2);
            });

          })
        }

      });

    })
  })

umi改造之后的本地开发方案

使用single-spa构建我们的微服务化的前端应用之后,其实有一个问题会一直困扰着我们, 就是如何有效的开发?如何与我们平时开发的前端应用一样简单,容易上手. 今天就以umi子模块为例,希望给到大家一个思路

今天我就介绍一种方法,希望对大家有帮助.

模块加载器

是否还记得我之前的模块加载器, alili.tech/archive/1a6…

我们只需要将原来模块的加载器,封装成npm包.

然后在我们开发子模块项目的时候,运行我们的加载器

// umi src/app.js
import bootstrap from '@demo/demo-module-dev-loader' //封装过后的npm包
import store from 'store'; // 我们用于通讯的store文件

export async function render(oldRender) {
  if (process.env.NODE_ENV === 'development') {
    const main = oldRender();
    const res = await window.fetch('./project.json');
    let currentProject = await res.json();
    bootstrap({
      main,
      store,  
      prefix: currentProject.prefix
    });
  } else {
    oldRender();
  }
}

module-dev-loader

我们的demo-module-dev-loader里一样会有一个 Bootstrap.js文件,我们对他进行一些小的修改.

import * as singleSpa from 'single-spa';
import { registerApp,registerLocal } from './Register'

export default   async  function bootstrap (local) {
    // 拿到我们的项目配置文件,但是我们的本地是没有这个文件的.

    // 我们需要通过webpack代理到我们的线上测试环境,来拿到这个文件
    // 我们需要通过webpack代理到我们的线上测试环境,来拿到这个文件
    // 我们需要通过webpack代理到我们的线上测试环境,来拿到这个文件

    // 重要的事情说三遍
    const projectConfig = await window.SystemJS.import('/project.config.js')
    const res = await window.fetch('/project.json')
    const currentProject = await res.json()
    let {projects} = projectConfig;

    // 移除当前项目,因为当前项目会使用registerLocal方法注册
    projects = projects.filter(ele => ele.name !== currentProject.name)

    // 注册我们的线上测试环境的
    for (let index = 0; index < projects.length; index++) {
        const project = projects[index];
        await registerApp({
            name: project.name,
            main: `${project.main}`,
            store: project.store,
            base: project.base,
            prefix: project.prefix
        });
    }
    // 重点!!!
    // 注册本地正在开发的模块
    local && registerLocal(local)
    singleSpa.start();
}

registerLocal 方法展示

// Register.js
// 在原来registerApp上做了一些删减,大概的原理是一模一样的
export function registerLocal({base,main,prefix,store,name='local'}){
  // 导入store模块
let storeModule = {}, customProps = { globalEventDistributor: globalEventDistributor };

storeModule = store && { storeInstance: null };

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

  singleSpa.registerApplication(name, async ()=> main, base ? (() => true) : hashPrefix({prefix}),customProps);
}


// 原来的registerApp方法,与以前一模一样,没有改动.
// 为了方便对比registerLocal,所以列出来 供大家参考
export async function registerApp(params) {
// 导入store模块
let storeModule = {}, customProps = { globalEventDistributor: globalEventDistributor };

// 尝试导入store
try {
    storeModule = params.store ? await window.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);
  }

  singleSpa.registerApplication(params.name, async ()=> await window.SystemJS.import(params.main), params.base ? (() => true) : hashPrefix(params), customProps);

}



umi插件

我们的umi插件也需要修改

// 还是原来的元素加载方法
  const domElementGetterStr = `
      function domElementGetter() {
        let el = document.getElementById('submodule-page')
        if (!el) {
          el = document.createElement('div')
          el.id = 'submodule-page'
        }
        let timer = null
        timer = setInterval(() => {
          if (document.querySelector('#submoduleContent.submoduleContent') && !document.querySelector('#submodule-page')) {
                document.querySelector('#submoduleContent.submoduleContent').appendChild(el)
                clearInterval(timer)
          }
        }, 100)

        return el
    }`


// 在umi 导入single-spa-react模块
// 并且封装我们的rootElement 组件,拿到我们的生命周期实例
    api.addEntryCodeAhead(`
    import singleSpaReact from 'single-spa-react';
    let reactLifecycles;
    reactLifecycles =  singleSpaReact({
        React,
        ReactDOM,
        rootComponent: (spa) => window.g_plugins.apply('rootContainer', {
        initialValue: React.createElement(require('./router').default),
        }),
        domElementGetter: ${options.base?`() => document.getElementById('root')`:domElementGetterStr}
    });
  `);


      // 开发环境
    if (process.env.NODE_ENV === 'development') {
      // 替换我们的渲染函数为以下内容,直接返回子模块的声明周期
      // 这样我们就可以在一开始写的的app.js里面的render函数,拿到这些返回的东西呢
      // 通过我们修改过后的模块加载器,注册到single-spa中
      api.modifyEntryRender(`
      const bootstrap = [
        reactLifecycles.bootstrap,
        ];

        const mount = [
        reactLifecycles.mount,
        ];

        const unmount = [
        reactLifecycles.unmount,
        ];
        return {
          bootstrap,
          mount,
          unmount
        }
    `);

    }

// 经过以上的修改之后,我们的umi会失去热更新的功能,
// 只能退而求其次,主动刷新浏览器,不过也不伤大雅
// 编译完成之后,主动刷新浏览器
  api.onDevCompileDone(() => {
    api.refreshBrowser()
  });

webpack代理

别忘了修改代理配置,为了可以直接拿到所有的项目位置

{
    devServer: {
    proxy: {
      '/project.config.js': {
        // 你的测试环境地址
        target: 'https://demo.xyz/',
      }
    }
  },
}

跨模块的组件共享尝试

前端微服务化之后,我们会面临一个问题: 模块之间重复代码不能复用的问题.

如果使用npm管理我们的重复代码,我们会多出维护npm包的成本. 在子模块更新npm包版本,也是一件很麻烦的事情. 在js文件体积上也没有任何的优化.

组件共享

今天我们就来聊一聊如何在多个模块中同时使用一个组件.

思路

在base模块管理公共组件,将组件封装成动态组件,这样在打包的时候我们就可以将该组件切割成单独文件了. 当其他的子模块需要这个组件的时候,向Base模块动态获取.

实践

动态组件的封装

为了让其他模块可以按需加载我们的公共组件,我们需要对已有的组件封装成动态组件.

我这里使用的是 umi/dynamic,

他是基于github.com/jamiebuilds… 封装了一层. 有兴趣的小伙伴可以自行了解.

import React from 'react';
import dynamic from 'umi/dynamic';
import PageLoading from '@/components/PageLoading'

export const Demo = dynamic(import( `../Demo`), {loading: () => <PageLoading />})

export default Demo;

对外提供获取动态组件的方法

在加载Base模块的时候,我们可以在window下暴露一个调用该模块动态组件的方法

window.getDynamicComponent = async function(name) {
  let component = null;
  component = await import(`@/components/dynamic/${name}`);
  return component[name];
};

子模块调用公共组件

因为base模块提供了一个获取公共组件的全局方法, 我们就可以在任何模块任何需要调用公共组件的地方去是使用它了.

    // 获取组件
   let component =  await window.getDynamicComponent('Demo')

为了方便这种公共组件的使用,我们可以将这一方法封装成一个组件. 在调用公共组件,我们只需要声明就好了.

import React, { Component } from 'react';

// Matrix 在黑客帝国中是母体的意思
export default class Matrix extends Component {
  state = {
    DynamicComponent: null
  }

  static defaultProps = {
    $name: ""
  }

  async componentWillMount() {
    this.renderComponent()
  }

  componentWillReceiveProps(nextProps) {
    this.props = nextProps
    this.renderComponent()
  }

  async renderComponent() {
    const { $name } = this.props;
    try {
      if ($name) {
        const component = await window.getDynamicComponent($name)
        this.setState({
          DynamicComponent: component
        })
      }
    } catch (error) {
      this.setState({
        DynamicComponent: null
      })
      console.error(error)
    }
  }


  render() {
    const { DynamicComponent } = this.state;
    // 继承所有props
    return DynamicComponent && <DynamicComponent {...this.props} />;
  }
}

在实际页面中调用我们的公共组件

import React, { Component } from 'react';
import Matrix from '@/components/Matrix'

export default class Page extends Component {
  render() {
    return (
      <div>
        <Matrix name="Demo" />
      </div>
    );
  }
}

对微件化的探索与实践

在微前端中,我们可以根据自己的业务需求,让子模块使用不同框架技术栈.虽然到了这一步已经很美好了,那这就是微前端的终点吗?

答案是否定的,微前端的边界还可以更进一步的拓宽.

之前的微前端的文章 alili.tech/archive/qh7… 给大家介绍了,如何在相同技术栈的子模块之间,相互调用React组件.

那今天要说的就是,如何在不同技术栈之间的子模块相调用不同技术栈的组件.

最终,我们只需要根据我们的需求调用相关功能的组件,我们不需要管他是 react ,vue或者是angular写的.

你只管用,只知道他是一个组件就好了,不用关心太多~ 对于团队的组件积累,是有极大好处的.

场景

一般情况下,一个公司的前端团队的技术栈都是统一的.但也有前端团队使用不统一技术栈的时候. 比如:

  1. 时代的变迁,升级技术栈导致内部技术栈不统一
  2. 项目众多,因为需求不一致,其他的技术栈对于项目更加有力
  3. ...其他管理原因

当我们已经使用微前端架构来构建我们的项目的时候,我们的子模块有可能因为我们项目的需求导致使用了其他的技术栈,

如果我们使用了其他的技术栈,我们原来封装的组件就不能在新的项目中用了,所以我们需要要求组件可以跨框架共享使用.

我们该怎么做?

这里有提到微件仓库模块,这是一个单独的项目.你可以理解是以前的旧项目,当你需要这个旧项目的某一个组件的时候,可以直接从这个项目里面拿.

你也可以做成一个只提供组件的项目,毕竟在运行时一个子模块挂载到我们的项目中来是没有任何资源消耗的.

我们只要知道我们需要的组件从哪里来就行了,然后根据组件还有之前定义好的路由找到这个组件,调用他,使用他就好了.

基于Web component封装我们的组件

不同框架开发的组件,差异很大.想要串在一起使用,基本上是不可能的. 好在目前所有的框架都支持让组件以webcomponent的形式存在.

react: react.docschina.org/docs/web-co…

vue : github.com/vuejs/vue-w…

angular: www.angular.cn/guide/eleme…

关于Web Components 的详细介绍

developer.mozilla.org/zh-CN/docs/…

加载性能

如果一个页面依赖了很多跨框架的组件,必然出现网络方面的性能问题.

我们会在请求的中间加一层node服务,当页面请求多个跨框架的组件的时候,我们的node就会合并成单个文件,并且保存在硬盘上.

所以说,当这个页面被请求过之后,页面零散的组件便会合并在一起,第二次其他用户请求就不会有这种合并文件的处理,直接返回静态资源给客户端.

这种方式也不会对nodejs有太多额外的压力,

因为现在的页面结构还是相对静态稳定的,没有太多的动态定制化的东西.这个方案足以应付大多数的应用场景.

尾巴

写到这里,简单的介绍了一些我们在微前端的实践中的一些思路与解决办法.

其实很多点还可以展开细讲,很多的性能优化方式也可以单独的开一个系列专题慢慢讨论.

微前端的玩法太多,现在也有一些成熟的框架方案.

因为这套实践的比较早,没有像大家现在这么幸福的有很多方案可以选择.

目前在内部使用的也很稳定,暂时也没有重构的计划.

希望这篇文章可以给到大家一些启发.

千万要记得点赞,评论,关注哟!!!