了解微前端

585 阅读5分钟

1 背景

『微前端』这个概念最早是2016年底出现在ThoughtWork上,它把『微服务』的概念扩展到了前端世界。下面有两张图来类比『微服务』和『微前端』。

Untitled.png 图1: 从整体一个team → 前端后端2个team → 1个前端Team 对应多个微服务

Untitled 1.png 图2:展示的是一个纵向的组织结构,各Team借助『微前端』分别负责独立的部分

2 微前端是什么?

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

3 为什么要用微前端?

随着前端项目越来越大、功能越来越复杂、技术框架越来越多,相互之间互不兼容,对于开发和维护是个巨大的挑战,微前端的技术刚好可以解决这些问题。微前端主要有如下优势:

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

4 微前端与微服务

  • 『微服务』是指后端服务,它们在自己的操作系统中运行,管理自己的数据库并通过网络进行彼此间的通信。『微前端』是指存在于浏览器中的微服务,他们之间的通信发生在内存中,而不是通过网络进行通信。
  • 他们都可以独立的构建和部署。将DOM视为微前端使用的共享资源。一个微前端的DOM不能够被其他微前端触及,类似于一个微服务的数据库不应该被其他没有权限的微服务触及。
  • 每个微前端都拥有独立的git仓库、package.json和构建工具配置,每个微前端可以由不同的团队进行管理,并可以自主选择框架。

5 市面上都有哪些微前端解决方案?

当前较具影响的方案有:

  • single-spa 微前端方案鼻祖,实现了路由劫持和应用加载
  • qiankun 基于single-spa封装,提供了更加开箱即用的 API
  • Isomorphic Layout Composer - 一个将微前端组成部分支持SSR完整的解决方案

6 微前端single-spa的主要构成

6.1 single-spa 项目类型

  1. single-spa applications:为一组特定路由渲染组件的微前端。
  2. single-spa parcels: 不受路由控制,渲染组件的微前端,官方不推荐使用。
  3. utility modules: 非渲染组件,用于暴露共享javascript逻辑的微前端。

6.2 single-spa 项目结构

  • root-config 根配置,可以理解为主应用,包含各应用共享的根 HTML 页面,和各应用的注册信息,如6.3.1.
  • app-parcel 子应用,独立应用程序,对外暴露了生命周期,便于主应用统一管理,如6.3.2、6.3.3
  • util-module 公共模块,为其他微前端应用导出要导入的功能,常见示例包括样式指南、身份验证助手和API助手。这些模块不需要向single-spa注册,但是对于维护几个single-spa应用程序和parcel之间的一致性非常重要。

6.3 single-spa如何使用?

6.3.1 主应用 root-config

  • 通用的HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Root Config</title>
  <!--
    Remove this if you only support browsers that support async/await.
    This is needed by babel to share largeish helper code for compiling async/await in older
    browsers. More information at https://github.com/single-spa/create-single-spa/issues/112
  -->
  <script src="https://cdn.jsdelivr.net/npm/regenerator-runtime@0.13.7/runtime.min.js"></script>
  <!--
    This CSP allows any SSL-enabled host and for arbitrary eval(), but you should limit these directives further to increase your app's security.
    Learn more about CSP policies at https://content-security-policy.com/#directive
  -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: *:* ws://*:*; style-src 'unsafe-inline' https:; object-src 'none';">
  <meta name="importmap-type" content="systemjs-importmap" />
  <!-- If you wish to turn off import-map-overrides for specific environments (prod), uncomment the line below -->
  <!-- More info at https://github.com/joeldenning/import-map-overrides/blob/master/docs/configuration.md#domain-list -->
  <!-- <meta name="import-map-overrides-domains" content="denylist:prod.example.com" /> -->

  <!-- Shared dependencies go into this import map. Your shared dependencies must be of one of the following formats:

    1. System.register (preferred when possible) - https://github.com/systemjs/systemjs/blob/master/docs/system-register.md
    2. UMD - https://github.com/umdjs/umd
    3. Global variable

    More information about shared dependencies can be found at https://single-spa.js.org/docs/recommended-setup#sharing-with-import-maps.
  -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js"
      }
    }
  </script>
  <link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js" as="script">

  <!-- Add your organization's prod import map URL to this script's src  -->
  <!-- <script type="systemjs-importmap" src="/importmap.json"></script> -->

  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "react": "https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.production.min.js",
        "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.production.min.js",
        "@demo/root-config": "//localhost:9000/demo-root-config.js",
        "@demo/app-react": "//localhost:8081/demo-app-react.js",
        "@demo/app-vue": "//localhost:8082/js/app.js"
      }
    }
  </script>
  <% } %>

  <!--
    If you need to support Angular applications, uncomment the script tag below to ensure only one instance of ZoneJS is loaded
    Learn more about why at https://single-spa.js.org/docs/ecosystem-angular/#zonejs
  -->
  <!-- <script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.3/dist/zone.min.js"></script> -->

  <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>
  <% if (isLocal) { %>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.js"></script>
  <% } else { %>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>
  <% } %>
</head>
<body>
  <noscript>
    You need to enable JavaScript to run this app.
  </noscript>
  <main>
    <a href="/react">React子应用</a>&nbsp;<a href="/vue">Vue子应用</a>
  </main>
  <script>
    System.import('@demo/root-config');
  </script>
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>
  • 应用注册
// single-spa-config.js
import { registerApplication, start } from 'single-spa';
// Simple usage
registerApplication(
  'app2',
  () => import('src/app2/main.js'),
  (location) => location.pathname.startsWith('/app2'),
  { some: 'value' }
);
// Config with more expressive API
registerApplication({
  name: 'app1',
  app: () => import('src/app1/main.js'),
  activeWhen: '/app1',
  customProps: {
    some: 'value',
  }
);
start();

6.3.2 React 子应用

import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";

const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: Root,
  errorBoundary(err, info, props) {
    // Customize the root error boundary for your microfrontend here.
    return null;
  },
});

export const { bootstrap, mount, unmount } = lifecycles;

6.3.3 Vue子应用

import { h, createApp } from 'vue';
import singleSpaVue from 'single-spa-vue';

import App from './App.vue';

const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render() {
      return h(App, {
      });
    },
  },
});
export const { bootstrap, mount, unmount } = vueLifecycles;

6.3.4 父应用向子应用传递数据

registerApplication({
  name: "@demo/app-react",
  app: () => System.import("@demo/app-react"),
  activeWhen: ["/react"],
  customProps: { authToken: "d83jD63UdZ6RS6f70D0" }  // 传递authToken到子应用
});
registerApplication({
  name: "@demo/app-vue",
  app: () => System.import("@demo/app-vue"),
  activeWhen: ["/vue"],
  customProps: { authToken: "d83jD63UdZ6RS6f70D0" } // 传递authToken到子应用
});
// react 接收 父应用的参数 authToken,根组件的props可以直接获取
// root.component.js
export default function Root(props) {
  return <section>{props.name} is mounted! authToken:{props.authToken}</section>;
}

// vue 接收 父应用的参数 authToken,需要在根组件传入一下
const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render() {
      return h(App, {
        authToken: this.authToken
      });
    },
  },
});

6.3.5 utility modules 通用模块

  • 通用模块实例代码
export function authenticatedFetch(url, init) {
  return fetch(url, init).then(r => {
    // Maybe do some auth stuff here
    return r.json()
  })
}
  • 在single-spa应用中引用的实例代码
import React from 'react'
import { authenticatedFetch } from '@org-name/api';
export function Foo(props) {
  React.useEffect(() => {
    const abortController = new AbortController()
    authenticatedFetch(`/api/clients/${props.clientId}`, {signal: abortController.signal})
    .then(client => {
      console.log(client)
    })
    return () => {
      abortController.abort()
    }
  }, [props.clientId])
  return null
}

7. qiankun的项目构成

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统

7.1 qiankun 项目类型

相比single-spa,qiankun更简单好用,没有那么多复杂的概念,只需要了解两个概念:

  • 主应用,负责微应用注册、路由接管
  • 微应用,入口文件添加 bootstrap、mount、unmount 三个生命周期钩子,并导出为umd模块

7.2 qiankun 如何使用

7.2.1 在主应用注册微应用

import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3000',
    container: '#container',
    activeRule: '/app-react',
  },
  {
    name: 'vueApp',
    entry: '//localhost:8080',
    container: '#container',
    activeRule: '/app-vue',
  },
  {
    name: 'angularApp',
    entry: '//localhost:4200',
    container: '#container',
    activeRule: '/app-angular',
  },
]);
// 启动 qiankun
start();

7.2.2 React 微应用

  1. 在 src 目录新增 public-path.js:
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 设置 history 模式路由的 base:
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
  1. 修改入口文件 index.js ,导出生命周期
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

function render(props) {
  const { container } = props;
  ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}

if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

export async function bootstrap() {
  console.log('[react16] react app bootstraped');
}

export async function mount(props) {
  console.log('[react16] props from main framework', props);
  render(props);
}

export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
  1. 修改 webpack 配置
  • 解决跨域问题
// webpack-dev-server
headers: {
            'Access-Control-Allow-Origin': '*'
        }
  • 设置导出为umd模块
output: {
        path: projectRoot,
        filename: 'js/[name].js',
        chunkFilename: 'chunk/[name].chunk.js',
        publicPath: '/',
+       library: `${name}-[name]`,
+       libraryTarget: 'umd',
+       jsonpFunction: `webpackJsonp_${name}`,
    },

7.2.3 Vue 微应用

  1. 同react创建public-path
  2. 修改入口文件导出生命周期
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

let router = null;
let instance = null;
function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
  1. 打包配置修改(vue.config.js):
const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

8 项目部署

建议:主应用和微应用都是独立开发和部署,即它们都属于不同的仓库和服务。每个应用程序(又名微服务,又名ES模块)都可以独立开发和部署。团队可以按照自己的进度进行工作,独立开发、测试和部署

扩展阅读: