2022 你还不会微前端吗 (上) — 从巨石应用到微应用

前言

微前端系列分为 上/下 两篇,本文为 上篇 主要还是了解微前端的由来、概念、作用等,以及基于已有的微前端框架进行实践,并了解微前端的核心功能所在,而在 下篇 中主要就是通过自定义实现一个微前端框架来加深理解。

1249B853.png

微前端是什么?

微前端 是一种类似于 微服务 的概念,因此要想更好的了解微前端,就必须先了解一下微服务。

微服务

微服务架构 是将一个庞大的业务系统按照业务模块拆分成 若干个独立的子系统,每个子系统都是一个独立的应用,它是一种将应用构建成一系列按 业务领域 划分模块的、小的自治服务的软件架构方式,倡导将 复杂的单体应用 拆分成 若干个功能单一、松偶合的服务,目的是降低开发难度、增强扩展性、便于敏捷开发,及持续集成与交付活动。

与微服务相对的另一个概念是传统的 单体式应用程序( Monolithic application ),单体式应用内部包含了 所有需要的服务,且各个服务功能模块具有 强耦合性(相互依赖),导致难以进行拆分和扩容。

image.png

简单来说,单体式应用程序 其实就是一台服务器处理了需要所有的功能,微服务 就是将功能按照业务模块划分成了不同的独立服务,各个微服务间通过 HTTP 协议进行通信,通过注册中心观测微服务状态。

微服务 概念主要存在于后端开发,但这个概念是不是和你听说过的 微前端 很像了。

微前端

随着大前端的快速发展 和 SPA 的大规模应用,也带来了新的问题,而这些问题都催化出了 微前端 的概念:

  • 项目功能不断增多、体积不断增大(巨石应用),导致打包时间成正比例增长,是否能保证更好的 项目扩展
  • 前端技术更新太快,一个项目历经一两年也许就需要进行项目升级,甚至是切换技术栈,但仍需要老项目的代码,是否能进行 新老版本的兼容
  • 团队技术栈不一,又需要保证同一项目的开发,是否能保证不同团队的 独立开发
  • SPA 项目的任何变动都需执行完整的打包、部署,是否能保证不同内容 独立部署

微前端 是一种类似于 微服务 的架构,是一种由独立交付的 多个前端应用 组成整体的架构风格,将前端应用分解成一些更小、更简单的能够 独立开发、测试、部署 的应用,而对外表现仍是 单个内聚的产品

微前端框架

微前端框架的核心

一个微前端框架至少要保证如下的核心功能:

  • 技术栈无关
    • 主框架不限制接入子应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署
    • 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级
    • 在面对各种复杂场景时,通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  • 独立运行时
    • 每个微应用之间状态隔离,运行时状态不共享

single-spa

single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。

核心原理

基座 (主) 应用 中注册所有 App 的路由,single-spa 保存各子应用的路由映射关系,充当微前端控制器 Controler,当对应的 URL 变换时,除了匹配 基座应用 本身的路由外,还会匹配 子应用 路由并加载渲染子应用。

子应用会经过如下过程

  • 下载 (loaded)
  • 初始化 (initialized)
  • 挂载 (mounted)
  • 卸载 (unmounted)

single-spa 还会通过 生命周期 为这些过程提供对应的 钩子函数

qiankun

qiankun 是一个基于 single-spa 的 微前端 实现库,目的是提供更简单、无痛的构建一个生产可用微前端架构系统。

包括在 single-spa 文档中也有推荐使用 qiankun

single-spa 实践

创建子应用

Vue3 子应用

为了快速的创建应用,这里通过 vue create vue3-micro-app 来快速创建技术栈为 Vue3子应用

以下在子应用中的处理方式可用 single-spa-vue 来简化

页面效果如下

子应用入口文件

为了子应用既可以独立运行,也可以在基座应用中运行,需要在子应用入口文件进行一些修改,具体如下:

  • 将原本的初始化内容封装在自定义的 render 函数中,目的是可以在不同的环境执行初始化操作
    • 若当前在基座应用中进行渲染,则其页面内容对应的挂载容器需要指定为基座容器中对应的 DOM 节点
    • window.singleVue3 不存在时意味着是子应用独立运行,此时直接按照原本的初始化方式进行即可,即直接调用 render() 函数
  • 子应用必须导出 bootstrap、mount、unmount 等生命周期函数,且其返回值类型要为 fullfilled 状态的 Promise,否则后续操作不会执行
  • 定义 instance 变量存储实例对象,方便在当前子应用在基座应用中被切换时可以执行真正的卸载子应用
// main.ts

import { createApp } from 'vue'
import type { App as AppType } from 'vue'
import App from './App.vue'
import router from './router'

let instance: AppType

function render(container?: string) {
    instance = createApp(App)
    instance.use(router).mount(container || '#micro-vue-app')
}

// 当 window.singleVue3 不存在时,意味着是子应用单独运行
if (!window.singleVue3) {
    render();
}

// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = () => {
    return Promise.resolve()
};
export const mount = (props: any) => {
    render(props.container);
    return Promise.resolve()
};
export const unmount = () => {
    instance.unmount();
    return Promise.resolve()
};
复制代码

为什么要将 x.mount('#app') 换成 x.mount('#micro-vue-app') ?

如果你明白 子应用基座应用 中的渲染方式就不难理解了,因为当前这个子应用的挂载容器的 id="app" 而基座应用中的默认挂载容器也是 id="app",这显然会导致冲突,初始化渲染时会渲染基座应用本身,但是当你切换到 vue3 的子应用时,就会发现当前子应用的内容整个覆盖了基座应用的内容,因为此时子应用在进行挂挂载的时候,会把已经渲染 基座应用 的容器再一次作为 子应用 的容器进行渲染,于是内容就会被完全替换成子应用的内容。

基座应用被子应用替换效果如下:

路由配置

路由模式为 hash 模式,默认路由配置:

import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
复制代码

打包配置

vue.config.js 下必须将打包后的输出格式指定为 umd 格式:

module.exports = {
  configureWebpack: {
    output: {
      library: 'singleVue3',
      libraryTarget: 'umd',
      globalObject: 'window',
    },
    devServer: {
      port: 5000,
    },
  },
}
复制代码

React 子应用

类似的,这里通过 npx create-react-app react-micro-app 来快速创建技术栈为 React子应用

以下在子应用中的处理方式可用 single-spa-react 来简化

页面效果如下

子应用入口文件

此部分核心内容和上述的 vue3 子应用一致,不在额外说明,入口文件代码如下:

// index.js

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'

let root = null

function render(props = {}) {
  const container = document.getElementById(
    props.container ? props.container.slice(1) : 'root',
  )

  if(!container) return
  
  root = ReactDOM.createRoot(container)
  root.render(
    <React.StrictMode>
      <App {...props} />
    </React.StrictMode>,
  )
}

// 当 window.singleReact 不存在时,意味着是子应用单独运行
if (!window.singleReact) {
  render()
}

// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = () => {
  return Promise.resolve()
}
export const mount = (props) => {
  render(props)
  return Promise.resolve()
}
export const unmount = () => {
  root.unmount()
  return Promise.resolve()
}
复制代码

路由配置

路由模式为 hash 模式,自定义路由配置:

<Router>
    <Switch>
      <Route path="/" exact>
        <Home />
      </Route>
      <Route path="/about" exact>
        <About />
      </Route>
    </Switch>
</Router>
复制代码

打包配置

对于 React 应用来讲,要么是一开始就是自定义 webpack 相关配置,要么是基于 crate-react-app 内置的 webpack 配置进行修改,由于上面是通过 crate-react-app 的方式创建的项目,因此可以基于其内置的配置文件进行修改:

不想通过 eject 的方式去修改配置文件,可通过 react-app-rewired 进行重写

  • 执行 npm run eject 将内置的 webpack 配置像暴露出来,会生成一个 scripts 目录 和 config 目录 image.png

  • 这里只需要关注 config 目录即可,因为 webpack.config.js 在该目录下 image.png

  • 为其中的 output 配置添加上如下两行配置项即可(需重新启动)

     output: {
        library: 'singleReact',
        libraryTarget: 'umd',
        globalObject: 'window',
        ....
     }
    复制代码

创建基座应用

基座应用的技术栈这里选择 Vue2,同样可以通过 vue create vue2-main-app 的方式创建对应的基座应用:

路由配置

为了让 基座应用 的路由看起来更美观,这里选择 history 模式(也可选 hash 模式),接着配置具体路由:

  • 基座应用的路由
    • 配置对应的路由路径
    • 指定对应组件作为路由渲染视图,如 HomeView 组件
  • 微应用的路由
    • 只需要设定对应的路由路径,不需要指定对应的具体组件
import Vue from 'vue'
import VueRouter, { RouteConfig } from 'vue-router'
import HomeView from '../views/HomeView.vue'

Vue.use(VueRouter)

const routes: Array<RouteConfig> = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/vue3-micro-app',
    name: 'about',
  },
  {
    path: '/react-micro-app',
    name: 'about',
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router
复制代码

子应用挂载容器

基座应用中的 <router-view /> 是为了渲染基座应用自身的路由视图,而子应用是不能通过 <router-view /> 来渲染,因此,我们需要在基座应用中设定一个 Dom 节点专门用于渲染子应用的视图,这里就将子应用的内容挂载在 <div id="micro-content"></div> 的节点中:

<!-- 主内容 -->
<main class="content">
  <!-- 基座应用路由视图渲染 -->
  <router-view />

  <!-- 子应用挂载容器 -->
  <div id="micro-content"></div>
</main>
复制代码

菜单配置

菜单配置实际就是指定路由的跳转,具体包含内容如下:

  • Home 菜单渲染的视图内容是 基座应用 中对应的 HomeView 组件
  • Vue3-micro-app 菜单渲染视图是名为 vue3-micro-app子应用
  • Home 菜单渲染视图是基名为 react-micro-app子应用

基座应用注册子应用

基座应用 中尚未注册 子应用 时的页面效果如下:

为了切换菜单路由时,对应的子应用能够被正确的渲染在基座应用中,需要我们在基座应用中注册子应用:

  • 通过 pnpm install single-spa -S 安装 single-spa
  • 通过 single-spa 中提供的 registerApplication()start() 函数完成注册和启动,该逻辑可抽离到 registerApplication.ts
    // registerApplication.ts
    
    import { registerApplication, start } from 'single-spa';
    
    // 子应用
    export const applications = [{
        name: 'singleVue3',
        async activeWhen() {
            await loadScript('http://localhost:5000/js/chunk-vendors.js');
            await loadScript('http://localhost:5000/js/app.js');
            return window.singleVue3
        },
        app(location: Location) {
            return location.pathname.startsWith('/vue3-micro-app')
        },
        customProps: {
            container: '#micro-content'
        }
    },
    {
        name: 'singleReact',
        async activeWhen() {
            await loadScript('http://localhost:3000/static/js/main.js');
            return window.singleReact
        },
        app(location: Location) {
            return location.pathname.startsWith('/react-micro-app')
        },
        customProps: {
            container: '#micro-content'
        }
    }]
    
    // 加载子应用 script
    export const loadScript = async (url: string) => {
        await new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = url;
            script.onload = resolve;
            script.onerror = reject;
            document.head.appendChild(script)
        });
    }
    
    // 注册子应用
    export const registerApps = (apps: any[] = applications) => {
        apps.forEach(({
            name,
            activeWhen,
            app,
            customProps = {}
        }) => registerApplication(name, activeWhen, app, customProps)
        );
    
        start();
    }
    复制代码
  • main.ts 中导入并执行 registerApplication() 即可

效果预览

源码地址

其中包含了 子应用 在基座应用中 特定位置的渲染,以及 子应用 自身路由的切换时的效果,可以看出子应用路由和主应用路由互不影响。

qiankun 实践

有了前面 single-spa 的基础,通过 qiankun 来实现微前端更加简单了,因为 qiankun 本身就是基于 single-spa 实现的微前端架构系统,目的是提供更简单、简洁的方式接入。

下面我们还是使用上述的三个项目来通过 qiankun 的形式来实现微前端。

配置基座应用

vue2-main-app 的入口文件 registerApplication.ts 中使用 qiankun 进行简单配置即可,相比于上面 single-spa 的方式来说更简单:

// registerApplication.ts

import { registerMicroApps, start } from 'qiankun';

// 默认子应用
export const applications = [
    {
        name: 'singleVue3', // app name registered
        entry: 'http://localhost:5000',
        container: '#micro-content',
        activeRule: '/vue3-micro-app',
      },
      {
        name: 'singleReact', // app name registered
        entry: 'http://localhost:3000',
        container: '#micro-content',
        activeRule: '/react-micro-app',
      },
]

// 注册子应用
export const registerApps = (apps: any[] = applications) => {
    registerMicroApps(applications);

    start();
}
复制代码

配置子应用

子应用 部分该导出的生命周期还是要导出,值得注意的是生命周期中的 container 已经是对应基座应用中的 真实 DOM 节点,而不是 CSS 选择器,因此只需要进行简单的修改即可,具体如下所示:

vue3-micro-app 子应用

// src/main.ts

import { createApp } from 'vue'
import type { App as AppType } from 'vue'
import App from './App.vue'
import router from './router'

let instance: AppType

function render(container?: string) {
    instance = createApp(App)
    // 这里的 container 已经是对应基座应用中的真实 DOM 节点,而不是 CSS 选择器
    instance.use(router).mount(container || '#micro-vue-app')
}

// 当 window.singleVue3 不存在时,意味着是子应用单独运行
if (!window.singleVue3) {
    render();
}

// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = () => {
    return Promise.resolve()
};
export const mount = (props: any) => {
    render(props.container);
    return Promise.resolve()
};
export const unmount = () => {
    instance.unmount();
    return Promise.resolve()
};
复制代码

react-micro-app 子应用

// src/index.js

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'

let root = null

function render(props = {}) {
  // 这里的 container 已经是对应基座应用中的真实 DOM 节点,而不是 CSS 选择器
  const container = props.container || document.getElementById('root')

  if(!container) return

  root = ReactDOM.createRoot(container)
  root.render(
    <React.StrictMode>
      <App {...props} />
    </React.StrictMode>,
  )
}

// 当 window.singleReact 不存在时,意味着是子应用单独运行
if (!window.singleReact) {
  render()
}

// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = () => {
  return Promise.resolve()
}
export const mount = (props) => {
  render(props)
  return Promise.resolve()
}
export const unmount = () => {
  root.unmount()
  return Promise.resolve()
}
复制代码

子应用配置 CORS

前面说过基座应用是需要将子应用的入口文件加载到当前应用下来执行的,这个过程第一步就是请求对应的入口文件,由于浏览器 同源策略 的限制,我们必须要在子应用中配置当前子应用的资源是允许被跨域请求的。

子应用没有配置 CORS 发生跨域

vue3-micro-app 配置 CORS

vue.config.js 中配置 devServer 既可,其中的 devServer 可以配置所有符合 webpack-dev-server 的选项:

module.exports = {
  publicPath: '//localhost:5000',
  configureWebpack: {
    output: {
      library: 'singleVue3',
      libraryTarget: 'umd',
      globalObject: 'window',
    },
    devServer: {
      port: 5000,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
    },
  },
}
复制代码

react-micro-app 配置 CORS

因为之前是通过 npm run eject 的方式暴露出来和 webpack 相关的配置,在查看对应的 config\webpackDevServer.config.js 配置发现其内部已经默认做了 CORS 配置

最后

经过以上的实践,下面简单地对微前端框架核心内容进行自己的理解:

  • 技术栈无关
    • 任何一个子应用不论使用什么技术栈,最终都会被编译为 JavaScript 代码,因此真正在执行时无论基座应用还是子应用都已经是同一种语言形式
  • 独立开发、独立部署
    • 子应用本质上就是普遍使用的 spa 单页面应用,因此当然可以拥有独立代码仓库进行关联,可独立发布运行,也可作为子应用运行,只需要做好不同环境的兼容即可
  • 增量升级
    • 子应用能够独立开发部署,自然支持自身应用的功能的独立扩展,又或者是接入新的子应用
  • 独立运行时
    • 保证多个子应用在基座应用中运行时,自身的状态不受其他子应用的影响

本篇文章就这里就结束了,下一篇文章再去聊聊微前端的实现原理,以及通过自己实现一个微前端的方式加深理解。

希望本篇文章能对你有所帮助!!!

124AF130.jpg

参考

猜你喜欢

转载自juejin.im/post/7155266023568965640