微前端架构在容器平台的应用

源宝导读:随着业务的发展,天际-星舟平台未来需要解决与其他云共创共建,跨团队高效协作等诸多问题,而星舟现有的技术架构将难以支撑。本文将介绍星舟平台如何通过向更先进的“微前端”架构演进落地,以应对将来快速增长的业务挑战。

一、业务背景

    随着星舟平台业务的发展,面对全新蓝图规划,未来与其他云共创共建,存在跨团队协作开发、可拔插模块(各云团队独立开发维护)等需求,现有的架构设计不足以支撑全新星舟平台的业务规划。

具体体现在以下几点:

  1. 可插拔模块的综合平台。
        星舟平台未来可能会支持各云个性化模块接入(比如云链有自己的流水线管理,云客有自己的分支管理等),需要打造一个可插拔并且可独立维护模块的综合平台。

  2. 跨团队合作开发困难。
        天际+云客共创共建星舟平台,跨部门、跨团队协作需要高效的技术手段及方法策略。

  3. 巨石应用的维护困难。
        基于云擎的项目经验,传统的前端 SPA 开发模式可能不适用大型中台项目的发展,一方面,随着系统迭代,发展到一定程度,规模已经非常庞大。通过项目内的模块化,已经无法解决业务膨胀的问题,从启动和构建速度上就会比较慢;另一方面,随着应用框架的升级、变迁,业务的大量变动,项目上手的成本比较高:

  • 项目中的组件和功能模块会越来越多,导致整个项目的打包速度变慢;

  • 因为文件夹的数量会随着功能模块的增多而增多,查找代码会变得越来越慢;

  • 如果只改动其中一个模块的情况,需要把整个项目重新打包上线;

  • 目录层级和模块层级过深而且文件又多,定位文件会越来越慢;

  • 所有的项目都只能使用同一技术框架如:react、vue等;

  • 拓展成本高。
        由于全部代码在同一个环境下运行,每次拓展新功能的时候都必须结合上下文代码,在开发时需要确保这个功能不会影响到其他的正常功能,另外还会加重测试的流程和复杂度。

  • 二、技术选型

        通过与云内前端架构师进行探讨方案、对比业内成熟方案、剖析各种方案优劣,结合我们项目实际情况,最终选择微前端应用架构设计。

    2.1、什么是微前端

    微前端的概念由thoughtworks于2016年提出,此后很快被业界所接受,并在各互联网大厂中得到推广和应用。

    Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends
    微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

        微前端架构是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。

    目前比较常见的微前端实现方式:

    • 路由分发式。通过 HTTP 服务器的反向代理功能,来将请求路由到对应的应用上。

    • 前端微服务化。在不同的框架之上设计通讯、加载机制,以在一个页面内加载对应的应用。

    • 组合式继承:微应用化。通过软件工程的方式,在部署构建环境中,组合多个独立应用成一个单体应用。

    • 微件化。开发一个新的构建系统,将部分业务功能构建成一个独立的 chunk

    • 代码,使用时只需要远程加载即可。

    • 前端容器:iframe。通过将 iFrame 作为容器,来容纳其它前端应用。

    • 结合 Web Components 技术构建。借助于 Web Components 技术,来构建跨框架的前端应用。

    具体深入了解可以阅读《前端架构-从入门到微前端》。

    2.2、我们为什么选择qiankun
    • 广泛应用
          目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。另外,除了阿里本身,网易、每日优鲜、欢聚时代、小米等都有过项目在使用,相对比较成熟。

    • 接入容易

          由于主应用微应用都能做到技术栈无关,qiankun 对于用户而言只是一个类似 jQuery 的库,你需要调用几个 qiankun 的 API 即可完成应用的微前端改造。

    • 解耦/技术栈无关

          微前端的核心目标是将巨石应用拆解成若干可以自治的松耦合微应用,而 qiankun 的诸多设计均是秉持这一原则,如 HTML entry、沙箱、应用间通信等。这样才能确保微应用真正具备独立开发、独立运行的能力。

    • 基于前端微服务化(Single-SPA)
          应用可以自动加载、运行,并且能够与应用注册表进行联系, 每个应用的开发是完全隔离的,开发时互不影响。

    三、落地实践

    3.1、整体架构

        为保证各主要功能模块可由不同团队开发,并可独立使用与部署,整体采用乾坤@2.x微前端架构。 

    星舟微前端架构图

    星舟微前端流程图 

    3.2、接入微前端

        星舟框架除基本接入qiankun以外,还需要打通框架搭建,路由与菜单设计,数据交互设计,公共模块设计,部署方案思考等,下图是当时的技术预研思路:

    3.3、搭建基础框架

    为什么不使用create-react-app搭建?

    • 成熟的框架,尤其那些知名的开源框架,为了应对各种项目和场景,自然会包含大量的细节处理,但是这些处理往往都是冗余的。

    • create-react-app相对来说比较重,有很多我们不需要的包,webpack配置只能通过eject命令暴露或者react-app-rewired覆盖的方式去维护,自定义成本较高。

    • 微前端项目要求项目尽量的轻量级,对于webpack的配置和优化更加可控。

    3.4、微前端接入
    1. 主子应用分别安装

    yarn add qiankun  # or npm i qiankun -S
    
    1. 在主应用中注册微应用

    [src/subapp.ts]

    import { registerMicroApps, setDefaultMountApp, start } from 'qiankun';
    
    
    export const startMicroApps = () => {
      const host = window.location.host;
      let env = '';
      if (host.includes('test')) {
        env = '-test';
      } else if (host.includes('beta')) {
        env = '-beta';
      } else if (host.includes('dev')) {
        env = '-dev';
      } else if (host.includes('local')) {
        env = 'local';
      } else {
        env = '';
      }
    
    
      const httpDomain = env === '-dev' ? 'http' : 'https';
    
    
      const entry = registerMicroApps([
        {
          name: 'xxx',// 微应用的名称,微应用之间必须确保唯一
          entry:
            env === 'local'
              ? '//localhost:9101'
              : `${httpDomain}://starship${env}.mypaas.com.cn`,
          container: '#subapp',// 子应用挂载在主应用的节点
          activeRule: '/xxx',
          props: {
            routerBase: '/xxx', // 下发路由给子应用,子应用根据该值去定义qiankun环境下的路由
          },
          // loader: Render,
        },
        {
          name: 'pipeline',
          entry:
            env === 'local'
              ? '//localhost:9104'
              : `${httpDomain}://starship-pipeline${env}.mypaas.com.cn`,
          container: '#subapp',
          activeRule: '/pipeline',
          props: {
            routerBase: '/pipeline', 
          },
          // loader: Render,
        },
        ...
      ]);
    
    
      start();
    };
    

    [src/pages/portal/Portal.tsx]

    useEffect(() => {
        // 在主应用启动注册
        startMicroApps();
      }, []);
    

        3.子应用接入生命周期

    [src/index]

    // TODO 修改当前文件所有xxx为子应用name,并同样修改public中index.html的xxx为子应用name
    if (!(window as any).__POWERED_BY_QIANKUN__) {
      render();
    }
    
    
    function render(props = {}) {
      if (props) {
        // 注入 actions 实例
        actions.setActions(props);
      }
    
    
      ReactDOM.render(
        <App />,
        document.getElementById('xxx')
      );
    }
    
    
    /**
     * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
     * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
     */
    export async function bootstrap() {
      console.log('react app bootstraped');
    }
    
    
    /**
     * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
     */
    export async function mount(props) {
      render(props);
    }
    
    
    /**
     * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
     */
    export async function unmount(props) {
      ReactDOM.unmountComponentAtNode(
        props.container
          ? props.container.querySelector('#xxx')
          : document.getElementById('xxx'),
      );
    }
    
    1. 路由体系设计
      [src/routes/RouteView.tsx] 路由展示组件

    import React from 'react';
    import { Redirect, Route, Switch } from 'react-router-dom';
    
    
    interface IProps {
      routes: NSCommon.IRoute[];
    }
    
    
    const RouteView = (props: IProps) => {
      const { routes } = props;
      return (
        <Switch>
          {Array.isArray(routes) &&
            routes.map((item) => {
              if (item.redirect) {
                return <Redirect from={item.path} to={item.redirect} />;
              }
              return (
                <Route
                  key={item.path}
                  path={item.path}
                  render={(props) => (
                    <item.component {...props} routes={item.routes} />
                  )}
                />
              );
            })}
        </Switch>
      );
    };
    export default RouteView;
    

    [src/routes/RouteConfig.tsx] 主应用路由配置

    import React from 'react';
    
    
    const RouteConfig = [
      {
        path: '/login',
        component: React.lazy(() => import('../pages/user/Login')),
      },
      {
        path: '/',
        component: React.lazy(() => import('../pages/portal/Portal')),
      },
    ];
    
    
    export default RouteConfig;
    

    [src/routes/RouteConfig.tsx] 子应用路由配置

    import React from 'react';
    import BasicLayout from '../page/layout/BasicLayout';
    
    
    // TODO 修改xxx为子应用name
    const RouteConfig = [
      {
        path: '/xxx',// 跟注册表props中routerBase保持一致
        component: BasicLayout,
        routes: [
          // 基础数据管理
          {
            path: '/xxx/welcome',
            component: React.lazy(() => import('../page/welcome/Welcome')),
          },
          { path: '/xxx', redirect: '/xxx/welcome' },
        ],
      },
    ];
    
    
    export default RouteConfig;
    

    [src/routes/RouteConfig.tsx] 路由使用

    import { Spin } from 'antd';
    import React, { Suspense } from 'react';
    import { BrowserRouter as Router } from 'react-router-dom';
    import RouteConfig from './routes/RouteConfig';
    import RouteView from './routes/RouteView';
    
    
    const App = () => {
      return (
        <Router>
          <Suspense
            fallback={
              <div className="loading-position">
                <Spin />
              </div>
            }
          >
            <RouteView routes={RouteConfig} />
          </Suspense>
        </Router>
      );
    };
    
    
    export default App;
    
    1. 主应用数据数据交互设计,采用initGlobalState(state)实现 根据最少知识原则的设计,主应用只需要将应用id,应用名称, 团队id(星舟平台是基于团队的维度做业务拓展的,所以需要将团队id下沉到子应用)。

     [/src/actions.ts] 主应用初始化全局状态

    import { initGlobalState, MicroAppStateActions } from 'qiankun';
    const initialState = { refreshGroup: new Date().getTime() };
    // 初始化 state
    const actions: MicroAppStateActions = initGlobalState(initialState);
    export default actions;
    

    [/src/pages/portal/Portal.tsx] 主应用修改全局状态

    const handleClick = (menu) => {
        const currentMenu = menus.find((item) => menu.key === item.path);
        actions.setGlobalState({
          app_id: currentMenu && currentMenu.id,
          app_name: currentMenu && currentMenu.name,
        });
        history.push(menu.key);
      };
    

    [/src/index.tsx] 子应用接收主应用传入的全局状态

    import actions from './actions';
    
    
    function render(props = {}) {
      if (props) {
        // 注入 actions 实例
        actions.setActions(props);
      }
      ...
    }
    

    [/src/actions.ts] 子应用初始化主应用传入的全局状态

    function emptyAction(params?) {
      // 警告:提示当前使用的是空 Action
      console.warn('Current execute action is empty!');
    }
    
    
    class Actions {
      // 默认值为空 Action
      actions = {
        onGlobalStateChange: emptyAction,
        setGlobalState: emptyAction,
      };
    
    
      /**
       * 设置 actions
       */
      setActions(props) {
        this.actions = props;
      }
    
    
      /**
       * 映射回传
       */
      onGlobalStateChange(...args) {
        if (this.actions && Object.keys(this.actions).length > 0) {
          return this.actions.onGlobalStateChange(...args);
        }
      }
    
    
      /**
       * 映射改变
       */
      setGlobalState(...args) {
        return this.actions.setGlobalState(...args);
      }
    }
    
    
    const actions = new Actions();
    export default actions;
    

    [/src/page/layout/BasicLayout.tsx] 子应用处理联动

    useEffect(() => {
        actions.onGlobalStateChange((state, prev) => {
          // TODO 修改成当前应用名
          if (state.app_name == '运维中心') {
            // 子应用的业务处理
            dispatchRequest(dispatch, ActionTypes.COMMON_SET_DATA, {
              group_id: state.group_id,
              app_id: state.app_id,
            });
          }
        }, true);
      }, []);
    
    1. 主应用跨域配置(所有跨域由主应用代理)

    [webpack.config.js]

    ...
    devServer: {
        headers: {
          'Access-Control-Allow-Origin': '*',
        }, 
         proxy: {
          '/authapi': {
            target: 'http://xxx.com.cn',
            changeOrigin: true,
            pathRewrite: { '^/authapi': '/api' },
          },
          '/xxxapi': {
            target: 'http://xxx.com.cn',
            changeOrigin: true,
            pathRewrite: { '^/xxxapi': '/api' },
          }
          ...
         }
    }
    
    1. 子应用其他配置

    • 解决微应用加载的资源会 404
          使用 webpack 运行时 publicPath 配置,qiankun 将会在微应用 bootstrap 之前注入一个运行时的 publicPath 变量,你需要做的是在微应用的 entry js 的顶部添加如下代码:

    [/src/public-path.ts]

    // runtime publicPath 主要解决的是微应用动态载入的 脚本、样式、图片 等地址不正确的问题。()
    if ((window as any).__POWERED_BY_QIANKUN__) {
      __webpack_public_path__ = (window as any).__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
    }
    
    • 解决Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry

    const { name } = require('./package');
    
    
    output: {
        library: `${name}-[name]`,
        libraryTarget: 'umd',
        jsonpFunction: `webpackJsonp_${name}`,
      },
    
    • 样式隔离问题
          qiankun 将会自动隔离微应用之间的样式(开启沙箱的情况下),你可以通过手动的方式确保主应用与微应用之间的样式隔离。比如给主应用的所有样式添加一个前缀,或者假如你使用了 ant-design 这样的组件库,你可以通过这篇文档中的配置方式给主应用样式自动添加指定的前缀。

    //配置webpack修改less变量
    {
      loader: 'less-loader',
    + options: {
    +   modifyVars: {
    +     '@ant-prefix': 'yourPrefix',
    +   },
    +   javascriptEnabled: true,
    + },
    }
    
    
    //配置 antd ConfigProvider
    import { ConfigProvider } from 'antd';
    
    
    export const MyApp = () => (
      <ConfigProvider prefixCls="yourPrefix">
        <App />
      </ConfigProvider>
    );
    

        不过有三个组件需要特别注意,message/notification/Modal.confirm,是单独渲染在 ReactDOM.render 生成的 DOM 树节点上,无法共享 ConfigProvider 提供的 context 信息。

    // 使用 message.config、notification.config 和 Modal.config 方法全局设置 prefixCls
    message.config({
      prefixCls: 'ant-message',
    });
    notification.config({
      prefixCls: 'ant-notification',
    });
    Modal.config({
      rootPrefixCls: 'ant', // 因为 Modal.confirm 里有 button,所以 `prefixCls: 'ant-modal'` 不够用。
    });
    
    四、问题及解决方案
    4.1、如何提取出公共的依赖库(联合使用)
    1. 自定义通用工具库starship-toolkits
          随着星舟平台微前端框架的落地,每个子应用都具备独立仓库,组件复用和工具库复用是迫切需要考虑的问题。将权限、加载、样式等跟业务逻辑不相关的代码提取到独立的npm包中,便于共享使用。

    2. External
          在微应用中将公共依赖配置成 external,然后在主应用中导入这些公共依赖。

    3. Webpack5 跨应用代码共享 - Module Federation
          Module Federation 主要是用来解决多个应用之间代码共享的问题,可以让我们的更加优雅的实现跨应用的代码共享。

    4.2、批量提交多仓库代码到多环境脚本开发

        基于星舟平台多仓库设计,每次提交代码都需要推送到开发环境和测试环境。如果优化框架代码,需要手动对多个子仓库做重复的工作,每次累计耗时都在3-5分钟之间。为了提效,全新开发批量提交多仓库代码到多环境脚本,将时间缩短到平均30s以内(根据仓库的多少而定)。

    # 该脚本与项目文件夹平行放置
    #!/bin/bash
    # 获取当前文件下下面所有目录
    dir=$(ls -l /d/starship | awk '/^d/ {print $NF}')
    for i in $dir; do
      # 当前项目目录
      echo $i
      cd $i
      if [ -n "$(git status -s)" ]; then
        git add .
        read -p "请输入commit信息: " msg
        git commit -am"$msg"
        git pull
        git push
        oldBranch=$(git rev-parse --abbrev-ref HEAD)
        echo $oldBranch
        git checkout dev
        echo "切换到dev分支, 拉取最新代码并推送dev"
        git pull origin $oldBranch
        git pull origin dev
        git push origin dev
        echo "切换到原分支"
        git checkout $oldBranch
      fi
      cd ..
    done
    
    4.3、如何部署

        由于当前星舟构建脚本不支持代码仓库打包到同一地址,所以我们就不能将主应用和子应用部署到同一个服务上,因此采用以下方案:

        一般这么做是因为不允许主应用跨域访问微应用,主应用和微应用部署在不同的服务器,使用 Nginx 代理访问。将主应用服务器上一个特殊路径的请求全部转发到微应用的服务器上,即通过代理实现“微应用部署在主应用服务器上”的效果。

        例:主应用在 A 服务器,微应用在 B 服务器,使用路径 /app1 来区分微应用,即 A 服务器上所有 /app1 开头的请求都转发到 B 服务器上。

    // 主应用nginx配置
    /app1/ {
      proxy_pass http://www.b.com/app1/;
      proxy_set_header Host $host:$server_port;    
    }
    
    
    // 主应用注册微应用时,entry 可以为相对路径,activeRule 不可以和 entry 一样(否则主应用页面刷新就变成微应用)
    registerMicroApps([
      {
        name: 'app1', 
        entry: '/app1/', // http://localhost:8080/app1/
        container: '#container', 
        activeRule: '/child-app1', 
      },
    ],
    
    
    // 对于 webpack 构建的微应用,微应用的 webpack 打包的 publicPath 需要配置成 /app1/
    module.exports = {
      output: {
        publicPath: `/app1/`,
      }
    }
    

    五、总结及展望

    • 微前端架构的优点:

      • 每个应用都比较轻量级,缩小项目打包体积(平均每个子项目bundle不到100k),而整合后的公共资源只需加载一次,性能得到很大提升,项目上手难度降低。

      • 技术栈无关,以后新业务子应用可以直接选择vue + ant-design-vue技术栈(解决react人员欠缺问题)。

      • 更好的支持多团队共建(核心解决问题),其他团队只需要做好他们的子应用。

      • 可插拔。支持子应用可以随机组合的功能。

    • 微前端架构的缺点:

      • 学习成本和项目成本增高,如果是项目规模小、数量少的场景,不建议接入微前端。

      • 样式兼容性是需要提前考虑的问题。

      • 每个团队都有自己的技术选择,浏览器最终可能需要下载很多框架和重复代码。

    • 运用通用的架构思想设计微前端

      • 关注点分离,严格遵守单一职责原则(每个子应用的设计、每个子应用的功能设计),最少知识原则(主子应用交互只有app_id, app_name, group_id, mqtt信息),开闭原则等。分离之后的子应用和主应用应该高度独立和封闭(优点是不需要关系它们内部的具体实现,只关心输入和输出即可)。

        目前星舟已经完整落地微前端并完成旧项目的迁移,面向未来,星舟微前端还有许多细节需要去持续优化,例如:

      • 体验问题,如何在多个子应用中做到平滑切换。

      • 性能优化,从项目启动速度,页面加载速度, 构建速度,缓存机制等维度持续优化星舟微前端项目的性能。

      • 单元测试,对标devops3.0标准,接入jest+enzyme单元测试。

      ------ END ------

      作者简介

      王同学: 前端研发工程师,目前负责天际星舟平台的相关研发工作。

      也许您还想看

      前端数据层落地实践

      移动建模平台元数据存储架构演进

      AI云店小程序演变之路

      基于 Go 的微服务运行情况监控实践

      在明源云客,一个全新的服务会经历什么?

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值