微前端起源

微前端的概念最早由 thoughtworks 在 2016 年提出。其核心思路是借鉴后端微服务架构理念,将一个单体的庞大的前端应用拆分为多个简单独立的前端工程。每个前端工程可以独立开发、测试、部署。最终再由一个容器应用,将拆分后的微前端工程组合为一个整体,面向用户提供服务
整体架构

微前端的价值

  • 技术栈无关
    主框架不限制接入应用的技术栈,子应用具备完全自主权
  • 独立开发、独立部署
    子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 独立运行时
    每个子应用之间状态隔离,运行时状态不共享

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

解决方案:

MPA: 多页面应用(Multi page web application)
SPA: 单页面应用(Single page web appliction)

MPA:

  • 优点: 部署简单、各应用之间硬隔离,天生具备技术栈无关、独立开发、独立部署的特性。
  • 缺点则也很明显,应用之间切换会造成浏览器重刷,由于产品域名之间相互跳转,流程体验上会存在断点。

SPA

  • 优点: 则天生具备体验上的优势,应用直接无刷新切换,能极大的保证多产品之间流程操作串联时的流程性。
  • 缺点则在于各应用技术栈之间是强耦合的。

常见的实现方式

  • 路由分发式。通过 HTTP 服务器的反向代理功能,来将请求路由到对应的应用上。
  • 前端微服务化。在不同的框架之上设计通讯、加载机制,以在一个页面内加载对应的应用。
  • 微应用。通过软件工程的方式,在部署构建环境中,组合多个独立应用成一个单体应用。
  • 微件化。开发一个新的构建系统,将部分业务功能构建成一个独立的 chunk 代码,使用时只需要远程加载即可。
  • 前端容器化。通过将 iFrame 作为容器,来容纳其它前端应用。
  • 应用组件化。借助于 Web Components 技术,来构建跨框架的前端应用。

    路由分发式

    路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。
    路由分发式

前端微服务化

前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完整的前端应用。其
微服务化

组合式集成:微应用化

微应用化,即在开发时,应用都是以单一、微小应用的形式存在,而在运行时,则通过构建系统合并这些应用,组合成一个新的应用。
微应用化

微件化

微件(widget),指的是一段可以直接嵌入在应用上运行的代码,它由开发人员预先编译好,在加载时不需要再做任何修改或者编译。
微件化

前端容器化

前端容器 iframe 或 web components

几种实现方式对比

微前端实现方式对比

Systemjs模块化解决方案
https://github.com/systemjs/systemjs

systemjs 是一个最小系统加载工具,用来创建插件来处理可替代的场景加载过程,包括加载 CSS 场景和图片,主要运行在浏览器和 NodeJS 中。它是 ES6 浏览器加载程序的的扩展,将应用在本地浏览器中。通常创建的插件名称是模块本身,要是没有特意指定用途,则默认插件名是模块的扩展名称。

通常它支持创建的插件种类有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// CSS 
System.import('my/file.css!')

// Image
System.import('some/image.png!image')

// JSON
System.import('some/data.json!').then(function(json){})

// Markdown
System.import('app/some/project/README.md!').then(function(html) {})

// Text
System.import('some/text.txt!text').then(function(text) {})

// WebFont
System.import('google Port Lligat Slab, Droid Sans !font')

System.register('name', [], function () { ... });

示例

1
2
3
4
5
6
7
8
9
10
<script src="system.js"></script>
<script type="systemjs-importmap">
{
"imports": {
"lodash": "https://unpkg.com/lodash@4.17.10/lodash.js"
}
}
</script>
<script type="systemjs-module" src="/js/main.js"></script>

webpack5 Module Federation

https://indepth.dev/posts/1173/webpack-5-module-federation-a-game-changer-in-javascript-architecture

1、模块联邦是什么
简单来说就是允许运行时动态决定代码的引入和加载。

1
2
3
4
5
6
7
8
9
10
11
app1
---index.js 入口文件
---bootstrap.js 启动文件 // 特殊处理
---App.js react组件

app2
---index.js 入口文件
---bootstrap.js 启动文件 // 特殊处理
---App.js react组件
---User.js react组件
---News.js react组件

2、代码结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/** app1 **/

/**
* index.js
**/
import React from 'react'
import ReactDom from 'react-dom'

import App from './App'

ReactDom.render(<App />, document.getElementById('root'))


/**
* App.js
**/

import React from 'react'

const User = React.lazy(() => import("app2/User"))

let _onbind = () => {
console.log('onBind')
}
const App = () => (
<div>
<h2>App1 Content</h2>
<hr/>
<React.Suspense fallback="Loading app2">
<User name={'app1 named'} onbind={ _onbind}/>
</React.Suspense>
</div>
)

export default App

暂时不用关心app2的代码,问题关键是: app1是如何引入app2的代码的?

3、Module federation的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* app1/webpack.config.js
**/
{
plugins:[
new HtmlWebpackPlugin({
template: path.join(__dirname, 'public/index.html')
}),
new Mfp({
filename:'app1.js',// 对外提供打包后的文件名,导入时会使用
name:'app1',// 微应用的名字
remotes: { // 引用外部的组件
app2: "app2@http://localhost:3001/app2.js",
},
// shared: ["react", "react-dom"],
shared: {
react: { singleton: true }, // singleton 只实例化一次
"react-dom": { singleton: true }
}
})
]
}
  • 配置:exposes/remotes
    app1项目引入 app2 的 News组件 User组件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * app2/webpack.config.js
    **/
    new Mfp({
    filename:'app2.js',// 对外提供打包后的文件名,导入时会使用
    name:'app2',// 微应用的名字
    exposes:{ // 暴露外部的组件
    './News':'./src/News.js', // 名字:具体那个一个组件
    './User':'./src/User.js',
    },
    })

    /**
    * app1/webpack.config.js
    **/
    new Mfp({
    filename:'app1.js',// 对外提供打包后的文件名,导入时会使用
    name:'app1',// 微应用的名字
    remotes: { // 引用外部的组件
    app2: "app2@http://localhost:3001/app2.js",
    },
    })
    我们重点关注 exposes/remotes
  • 提供了 exposes 选项的表示当前应用是一个 Remoteexposes 内的模块可以被其他的 Host 引用,引用方式为 import(${name}/${expose})
  • 提供了 remotes 选项的表示当前应用是一个 Host,可以引用 remoteexpose 的模块。

项目中如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* app1/App.js中通过 React.lazy 引用
* 使用 <React.Suspense></React.Suspense>包括
**/
import React from 'react'
const User = React.lazy(() => import("app2/User"))

const App = () => (
<div>
<h2>App1 Content</h2>
<hr/>

<React.Suspense fallback="Loading app">
<User/>
</React.Suspense>
</div>
)

export default App
  • 配置:shared
    除了前面提到的模块引入和模块暴露相关的配置外,还有个 shared 配置,主要是用来避免项目出现多个公共依赖。
    例如,我们当前的项目 app1,已经引入了一个 react/react-dom,而项目 app2 暴露的User组件也依赖了 react/react-dom。如果不解决这个问题,项目 app1 就会加载两个 react 库。
    1. remotes的代码自己不打包,类似external,例如app2/button就是加载app2打包的代码
    1. shared的代码自己是有打包的
  • 问题及解决方案
    1、配置shared后报错: Shared module is not available for eager consumption
    shared报错

解决方案:
增加bootstrap.js 通过 index.js 异步加载页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* webpack.config.js
**/
const config = {
module: {
rules: [
{
test: /bootstrap\.js$/,
loader: 'bundle-loader',
options: {
lazy: true,
},
},
]
}
}

/**
* index.js
**/
import bootstrap from './bootstrap'
bootstrap()

/**
* bootstrap.js
**/
import React from 'react'
import ReactDom from 'react-dom'

import App from './App'
ReactDom.render(<App />, document.getElementById('root'))


主要原因是 remote 暴露的 js 文件需要优先加载,如果 bootstrap.js 不是一个异步逻辑,在 import User 的时候,会依赖 app2app2.js,如果直接在 index.js 执行,app2app2.js 根本没有加载,所以会有问题。

  • 双向共享

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    /**
    * app1/webpack.config.js
    **/
    new Mfp({
    filename:'app1.js',
    name:'app1',
    exposes:{
    // 名字:具体那个一个组件
    './Button':'./src/Button.js',
    },
    })

    /**
    * app2/webpack.config.js
    **/
    new Mfp({
    filename:'app2.js',
    name:'app2',
    // 引用外部的组件
    remotes: {
    app1: "app1@http://localhost:3000/app1.js",
    },
    })

    /**
    * app2/News.js
    **/
    import React from 'react'

    const Button = React.lazy(() => import("app1/Button"))
    const News = () => (
    <div>
    App2 News组件
    <React.Suspense fallback="loading app1">
    <Button />
    </React.Suspense>
    </div>
    )

    export default News
  • 加载逻辑
    加载顺序

这里有一个点需要特别注意,就是入口文件 index.js 本身没有什么逻辑,反而将逻辑放在了 bootstrap.js 中,index.js 去动态加载 bootstrap.js。

参考文档:
https://micro-frontends.org/