落地微前端 qiankun 理论与实践指北

一、前言:微前端是什么?

  • 微前端不是特指某一项技术,而是一种思想。是由2016年 ThoughtWorks Technology Radar 中提出的,借鉴后端微服务的架构模式,将 Web 应用由单一的单体应用转变为多个小型前端应用,聚合为一的应用。
  • 所以微前端不是指具体的库,不是指具体的框架,不是指具体的工具,而是一种理想与架构模式。
  • 微前端的核心三大原则就是:独立运行、独立部署、独立开发 所以满足这些的最佳人选就是 “iframe”!!! 
  • 为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。

    如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

    iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  • url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  • UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  • 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  • 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

微前端能解决我们什么问题?

举例: 一个持续多年的应用,经历几年的业务的更新迭代,当项目发展到一定程度的时候就会遇到以下问题

  1. 业务模块之间不断的堆叠,交错引用,业务耦合如何治理?
  2. 老技术、老代码不敢动,新技术、新架构又想用?
  3. 万年技术债?既要跟随业务敏捷迭代,又要保证代码库向好发展,旧的框架类库如何平稳升级?
  4. 一个项目多个团队开发,你冲突我,我冲突你,如何解决并行开发的冲突?
  5. 代码库持续膨胀,难以维护的项目代码,是屎上雕花?还是从头再来?

有没有一种可以分解复杂度,提升协作效率,支持灵活扩展的架构模式? 微前端应运而生—— “更友好的iframe” 将一个巨无霸应用拆解为一个个独立的微应用应用,而用户又是无感知的!

微前端核心原则:

  • 技术栈无关: 主应用不限制子应用接入的技术栈,每个应用的技术栈选型可以配合业务情景选择。
  • 独立开发、独立部署:既可以组合运行,也可以单独运行。
  • 环境隔离:应用之间 JavaScript、CSS 隔离避免互相影响
  • 消息通信:统一的通信方式,降低使用通信的成本
  • 依赖复用:解决依赖、公共逻辑需要重复维护的问题

这意味着我们可以循序渐进的进行巨石应用的拆解,去技术升级、去架构尝试、去业务拆解等等。以低成本、低风险的进行,为项目带来更多可能性

我们的项目适不适合改造成微前端项目模式?

看我们的项目满足不满足微前端化,先看能不能满足以下几点即可。

  • 是否有明确的业务边界,业务是否高度集中。
  • 业务是否高度耦合、项目是否足够庞大到需要拆分。
  • 团队中存在多个技术栈并且无法统一,需要接入同一套主系统。
  • 技术老旧,扩展困难,维护吃力不讨好。
  • 开发协同、部署维护等工作,效率低下,一着不慎,满盘皆输。

市面框架对比:

  • magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数。
  • icestark 阿里出品,是一个面向大型系统的微前端解决方案
  • single-spa 是一个将多个单页面应用聚合为一个整体应用的JavaScript 微前端框架
  • qiankun 蚂蚁金服出品,基于 single-spa 在 single-spa 的基础上封装
  • EMP YY出品,基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
  • MicroApp 京东出品,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架

综合以上方案对比之后,我们确定采用了 qiankun 特定中心路由基座式的开发方案,原因如下:

  • 保证技术栈统一、微应用之间完全独立,互不影响。
  • 友好的“微前端方案“,与技术栈无关接入简单、像iframe一样简单
  • 改造成本低,对现有工程侵入度、业务线迁移成本也较低。
  • 和原有开发模式基本没有不同,开发人员学习成本较低。
  • qiankun 的微前端有 3 年使用场景以及 Issue 问题解决积累,社区也比较活跃,在踩坑的路上更容易自救~

二、使用,主应用注册,微应用挂载 

qiankun 注册微应用的方式:

1、自动模式:使用 registerMicroApps + start,路由变化加载微应用

  • 当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配
  1. 首次load应用,创建子应用实例,渲染。
  2. 切到其他子应用后切回,会重新创建新的子应用实例并渲染。
  3. 之前的子应用实例 qiankun 直接不要了,即使你没有手动销毁实例。
  4. 采用这种模式的话 一定要在子应用暴露的 unmount 钩子里手动销毁实例,不然会导致内存泄漏。
  • activeRule - string | (location: Location) => boolean | Array<string | (location: Location) => boolean> 必选,微应用的激活规则。

  • 支持直接配置字符串或字符串数组,如 activeRule: '/app1' 或 activeRule: ['/app1', '/app2'],当配置为字符串时会直接跟 url 中的路径部分做前缀匹配,匹配成功表明当前应用会被激活。

  • 支持配置一个 active function 函数或一组 active function。函数会传入当前 location 作为参数,函数返回 true 时表明当前微应用会被激活。如 location => location.pathname.startsWith('/app1')

  1. 自动挂载:registerMicroApps + start
// ps:只需要主应用安装即可
yarn add qiankun 或
npm i qiankun -S
// 主应用/scr/main.js 
import { registerMicroApps, start } from 'qiankun';

// 1. 获取微应用配置
const MICRO_CONFIG = [
  {
    name: 'vue app', // 应用的名字 必填 唯一
    entry: '//localhost:3000', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
    container: '#yourContainer', // 挂载具体容器 ID
     // 3. 根据路由匹配,激活的子应用
    activeRule: '/yourActiveRule',
    props: {
        xxxx: '/' // 下发给子应用的消息
    }
  }
]

// 2. 注册微应用
registerMicroApps(MICRO_CONFIG)
// 启动微服务
start({
// 沙箱
  sandbox:{
    experimentalStyleIsolation:true
  }
}) 
// sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true。

💡 当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑。 所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

2、手动模式:使用 loadMicroApp 手动注册微应用

  1. 每个子应用都有一个唯一的实例ID,reload时会复用之前的实例
  2. 如果需要卸载则需要手动卸载 xxxMicroApp.unmount()

注:由于registerMicroApps的特性,会导致路由的keep alive 失效。

如果微应用不是直接跟路由关联的时候,你可以选择手动加载微应用的方式会更加灵活。

手动挂载: loadMicroApps

// 任意页面都可以注册

import { loadMicroApp } from 'qiankun';

// 获取应用配置并手动挂载,挂载后返回挂载对象
this.microApp = loadMicroApp({
    name: 'vue app', // 应用的名字 必填 唯一
    entry: '//localhost:7100', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
    container: '#yourContainer', // 挂载具体容器 ID
    activeRule: '/yourActiveRule', // 根据路由 激活的子应用
    props: {
        xxxx: '/' // 下发给子应用
    }
})

this.microApp.unmount() // 手动销毁~
// 主应用
<div id='yourContaier'>
    <route-link to='yourActiveRule'></route-link>
</div>

注意:容器和路由要和main.js挂载的保持一致

// router index.js
routes = [
  {path:'/yourRule:pathMatch(.*)'},
  component:()=>import()
]   

四、微应用挂载

  • React 微应用,以 create react app 生成的 react 18 项目为例

  1. 在 src 目录新增 public-path.js

    if (window.__POWERED_BY_QIANKUN__) {
    
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
    
    }
  2. 设置 history 模式路由的 base

    <BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>

  3. 入口文件 index.js 修改,为了避免根 id #root 与其他的 DOM 冲突,需要限制查找范围。

import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import 'moment/locale/zh-cn';
import 'antd/dist/antd.min.css';
 
 
function render(props: any) {
  const { container } = props;
  const root = createRoot(container ? container.querySelector('#root') : document.querySelector('#root'));
  root.render(
    // @ts-ignore
    // 通过qiankun启动时,全局加根路由,需与主应用配置一致
    <BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/dataProcess' : '/'} >
      <App />
    </BrowserRouter>
  );
};
 
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
};
 
export async function bootstrap() {
  console.log('[react18] react app bootstraped');
};
 
export async function mount(props: any) {
  console.log('[react18] props from main framework', props);
  render(props);
};
 
export async function unmount(props: any) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
};

配置文件,使用 craco 配置,根目录下创建 craco.config.js
 

const { name } = require('./package');
 
module.exports = {
  webpack: {
    configure: {
      output: {
        library: `${name}-[name]`,
        libraryTarget: 'umd',
        chunkLoadingGlobal: `webpackJsonp_${name}`,
        globalObject: 'window',
      }
    }
  },
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
    host: '0.0.0.0', //指定使用地址,默认localhost,0.0.0.0代表可以被外界访问
    historyApiFallback: true,
    hot: true,
    liveReload: true,
    // 配置代理解决跨域,以下为示例
    proxy:{
      "/api":{
        target:process.env.REACT_APP_BASEURL,
        changeOrigin:true,
      }
    }
  },
};


修改 package.json 中的 scripts命令,我们项目配置了不同环境变量 

"scripts": {
    "serve:dev": "env-cmd -f .env.dev craco start",
    "serve:st": "env-cmd -f .env.st craco start",
    "serve:uat": "env-cmd -f .env.uat craco start",
    "serve:prod": "env-cmd -f .env.prod craco start",
    "build:dev": "env-cmd -f .env.dev craco build",
    "build:st": "env-cmd -f .env.st craco build",
    "build:uat": "env-cmd -f .env.uat craco build",
    "build:prod": "env-cmd -f .env.prod craco build",
    "test": "jest",
    "eject": "react-scripts eject",
    "lint": "eslint --ext .js --ext .tsx src"
  },
  • Vue 微应用,以 vue 2.x 项目为例

  1. 在 src 目录新增 public-path.js

    if (window.__POWERED_BY_QIANKUN__) {
    
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
    
    }
  2. 入口文件 main.js 修改,为了避免根 id #app 与其他的 DOM 冲突,需要限制查找范围。

    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;
    
    }
  3. 打包配置修改(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}`,
    
    },
    
    },
    
    };

小结 经历这几步,qiankun 父应用与微应用就接入完成了。当父应用完成加载微应用的时候,微应用就会遵循对应的解析 规则,插入到父应用的HMTL中了。

👉 选择路由

最好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作

主模式子模式推荐接入影响解决方案备注
hashhash强烈推荐
hashhistory不推荐history.pushState改造成本大
historyhistory强烈推荐
historyhash推荐

PS: 每个模式之间的组合并不是接入就可以完成的,都需要一些改造,如:增加路由前缀,路由配置base设置,不同的模式activeRule的规则都不同

⚒ 路由改造工作

新增微应用路由前缀

新增前缀不是微应用必须的,但是为了从 URL 上与其他应用隔离,也是为了接入旧应用的时候,能让 activeRule 方法能识别并激活应用,故新增路由前缀。

父应用路由表

[
    // 主应用 router.js:如果想匹配任意路径,我们可以使用通配符 (*):
    {
        path: '/your-prefix',
        name: 'Home',
        component: Home
    },
     // 特定页面兜底 会匹配以 `/your-prefix` 开头的任意路径
     // 如:/your-prefix/404 , /your-prefix/no-permission ....
    {
        path: '/your-prefix/*',
        name: 'Home',
        component: Home
    }
]
复制代码

PS:子应用路由切换,由于应用与路由都是通过 URL 注册与销毁的,当子应用路由跳转地址,无法与父应用的路由地址匹配上的时候页面会销毁,需要注意路由匹配,或者增加路由兜底。

子应用 hash 模式

// hash 模式不能使用base,只能改前缀
new VueRouter({
    mode: 'hash',
    routes: [
        {
            //增加路由前缀判断
            path: `${ window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : ''}/login`,
            component: _import('login/index.vue')
        }
    ]
})
复制代码

子应用 history 模式

new VueRouter({
    mode: 'history',
    // **针对子应用是 history模式的时候,只用设置 router base 就好了,不用像hash 这么麻烦** 
    base: window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : null,
    routes: [
        {
            path: '/login',
            component: _import('login/index.vue')
        }
    ]
})

五、通信,使用  initGlobalState

  • 主应用
// src/const/micro/actions.js
import { initGlobalState } from 'qiankun'

export const initialState = {}

const actions = initGlobalState(initialState)

export default actions
复制代码
  • 主应用使用
import actions from '@/const/micro/actions'

// 设置
actions.setGlobalState({
   xxxxDataKey: xxxValue
})

// 监听全局
actions.onGlobalStateChange((state, prev) => {
  console.log(state, prev, '子应用的 state: 变更后的状态; prev 变更前的状态')
})
复制代码
  • 微应用
// src/const/micro/actions.js 封装一下到时候引入使用方便
function emptyAction() {
    // 警告:提示当前使用的是空 Action
    console.warn('Current execute action is empty!')
}

class Actions {
    // 默认值为空 Action
    actions = {
        onGlobalStateChange: emptyAction,
        setGlobalState: emptyAction
    }

    // 设置 actions
    setActions(actions) {
        this.actions = actions
    }

    // 映射监听
    onGlobalStateChange(...args) {
        return this.actions.onGlobalStateChange(...args)
    }

    // 映射设置
    setGlobalState(...args) {
        return this.actions.setGlobalState(...args)
    }
}

const actions = new Actions()
export default actions
复制代码
  • 微应用使用
import actions from './const/micro/actions'

export async function mount(props) {
    actions.setActions(props) // 设置一下 actions 对象
}

actions.onGlobalStateChange((state, prev) => {
  // 监听公共应用下发 state: 变更后的状态; prev 变更前的状态
})
复制代码

六、样式污染问题

以 antd 为例:

// 1、配置 webpack 修改 less 变量
{
  loader: 'less-loader',
+ options: {
+   modifyVars: {
+     '@ant-prefix': 'yourPrefix',
+   },
+   javascriptEnabled: true,
+ },
}
// 2、配置 antd ConfigProvider


import { ConfigProvider } from 'antd';

export const MyApp = () => (
  <ConfigProvider prefixCls="yourPrefix">
    <App />
  </ConfigProvider>
);

sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true

因为我们选的沙箱隔离方案为实验性沙箱experimentalStyleIsolation,会遇到在子应用点开任一有弹出消息控件时回到主应用,弹出层消息并没有消失,还好antd提供了getPopupContainer方法可以解决。

 给控件添加getPopupContainer={triggerNode => triggerNode.parentElement}

 <Select 
    defaultValue="lucy" 
    getPopupContainer={triggerNode => triggerNode.parentElement || document.body} 
    style={{ width: 120 }} 
    allowClear >
      <Option value="lucy">Lucy</Option>
 </Select>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值