🏔浅谈微前端理论与qiankun实践

183 阅读6分钟

此文章记录巨石应用改造为微前端过程中遇到的问题以及解决方法,如读者遇到不好理解的地方可评论或者私信.

解决什么问题

  • 业务耦合
  • 新老架构的兼容
  • 项目参与人数多,冲突
  • 项目庞大
  • 启动/热更新/打包速度缓慢
  • 灵活扩展
  • 分解复杂度

原则

  • 技术栈无关
  • 独立开发/部署
  • 环境隔离
  • 依赖复用
  • 统一消息通信

改造条件

  • 明确的业务边界/高度集中
  • 项目是否庞大到需要拆分
  • 技术老旧,扩展困难,不易维护
  • 协同开发效率低

常见框架

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

qiankun 优点

  • 保证技术栈统一(Vue
  • 改造成本低
  • 社区活跃
  • 文档明确(single-spa ❌

拆分规则

  • 核心原则:高内聚低耦合
  • 减少彼此重新和依赖
  • 不要拆分太细致
  • 考虑未来场景
  • 子应用只做自己的事情
💡 建议按照业务来拆分
  • 保持核心业务的独立性
  • 业务关联紧密的可以合为一个子应用,相反可以拆成多个子应用

使用 qiankun

💡 选择基座的模式
  • 通用中心路由基座式:只有公共功能的主应用

    菜单栏、登录、退出...不包含任何业务逻辑

  • 特定中心路由基座式: 一个含业务代码的项目

    作为基座,所有新功能作为子应用引入

💡 注册子应用的方式
  • 自动模式: 使用 registerMicroApp + start, 监听路由变化加载子应用
  • 手动模式: 使用 loadMicroApp 手动注册子应用

应用加载流程图

💡 qiankun是如何通过 import-html-entry 加载微应用

应用加载流程图

简易流程:

  1. qiankun 会用 原生 fetch 方法,请求微应用的 entry 获取微应用资源,然后通过 response.text 把获取内容转为字符串。
  2. 将 HTML 字符串传入 processTpl 函数,进行 HTML 模板解析,通过正则匹配 HTML 中对应的 javaScript(内联、外联)、css(内联、外联)、代码注释、entryignore 收集并替换,去除 html/head/body 等标签,其他资源保持原样
  3. 将收集的 styles 外链URL对象通过 fetch 获取 css,并将 css 内容以 <style> 的方式替换到原来 link标签的位置
  4. 收集 script 外链对象,对于异步执行的 JavaScript 资源会打上 async 标识 ,会使用 requestIdleCallback 方法延迟执行。
  5. 接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,最后通过 eval 去创建一个执行上下文执行 js 代码,通过传入 proxy 改变 window 指向,完成 JavaScript 沙箱隔离。源码位置
  6. 由于 qiankun 是自执行函数执行微应用的 JavaScript,因此在加载后的微应用中是看不到 JavaScript 资源引用的,只有一个资源被执行替换的标识。
  7. 当一切准备就绪的时候,执行微应用的 JavaScript 代码,渲染出微应用

路由模式选择与改造

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

资源共享

  1. 共享模块方式

    • npm 依赖
    • git submodule or git subtree
    • webpack Externals(无法支持多版本共存的情况
    • webpack DLL
  2. 通过主应用共享资源给子应用

    • props 方式
    • window 方式

应用通信

  • 基于 URL
    • 使用简单、通用性强,但能力较弱,不适用复杂的业务场景
  • 基于 Props
    • 应用给子应用传值。适用于主子应用共享组件、公共方法调用等
  • 发布/订阅模式
    • 一对多关系,观察者和被观察者是抽象耦合的。但是数据链路难跟踪
  • 状态管理模式
    • 能够统一管理,链路清晰,易维护
  • 基于 localStoragesessionStorage 实现的通信方式
    • 不推荐,因为 JSON.stringify() 会造成数据丢失,它只会对Number、String、Booolean、Array转换,对于undefined、function、NaN、 regExp、Date 都会丢失本身的值
  • qiankun initGlobalState

实践

原项目

改造项目为云管理平台,针对多云和混合云环境的云基础设施等资源管理,提供”云管理+云服务+云运营+云监控”服务

  • 项目庞大, 800+路由
  • 2K+ 组件
  • 启动/构建服务缓慢, 5 分钟起步
  • 对于刚接手人员来说太复杂

拆分子应用

根据业务拆分为1 个主应用(main-web), 6 个子应用,主应用采用通用中心路由基座式,

路由

主应用和子应用统一采用 history 模式

// 子应用
new Router({
    base: window.__POWERED_BY_QIANKUN__ ? '/subAppName/' : '/',
    mode: 'history',
    routes: constantRouter
})

资源共享

构建公共组件库,发布到 npm

通信

使用 qiankun 的 initGlobalState

  • 主应用

    // actions.js 初始化 actions
    import { initGlobalState } from 'qiankun'
    import type { MicroAppStateActions } from 'qiankun'
    import store from '@/store'
    
    const initialState = {
      name: 'main-web',
      onmessage: (data: any) => console.log(data),
      userData: null,
      permissions: null
    }
    const actions: MicroAppStateActions = initGlobalState(initialState)
    
    actions.onGlobalStateChange((state, prev) => {
      // state: 变更后的状态; prev 变更前的状态
      // console.log('from:', state.name, 'current:', state, 'pre:', prev)
      store.commit('SET_ONMESSAGE', state.onmessage)
    })
    
    export default actions
    
    • 何时使用(通知子应用)

      用户登录后保存用户数据

      用户登录后保存权限数据(路由和按钮权限)

  • 子应用

    // actions.js 初始化 actions
    class Actions {
      actions = {
        name: '',
        onGlobalStateChange: emptyAction,
        setGlobalState: emptyAction
      }
    
      init(props: any, callback: any) {
        this.actions = props;
        this.onGlobalStateChange(callback)
      }
    
      onGlobalStateChange(callback: any, fireImmediately:boolean = true) {
        return this.actions.onGlobalStateChange(callback, fireImmediately);
      }
    
      setGlobalState(state: IState) {
        this.actions.setGlobalState({
          ...state,
          name: this.actions.name
        });
      }
    }
    const actions = new Actions();
    export default actions;
    
    • 何时使用

      mount 生命周期中注册 actions,监听主应用的 state,变化时进行保存用户数据和操作路由

      // main.js
      // 监听主应用的 state,变化时进行保存/操作用户数据和路由
      export async function mount(props: any) {
        console.log('cmp app mounted')
        render(props)
        actions.init(props, (state: any) => {
          const { permissions, userData } = state;
          userData && store.commit('SET_USERDATA', userData);
          if (!store.getters.addRoutes && permissions) {
            store.dispatch('permission/GenerateRoutes', permissions);
          }
        })
      }
      

      当子应用注册 websocket 时,将监听事件传递给主应用

      actions.setGlobalState({
        name: 'cmp-web',
        onmessage
      })
      

子应用单独运行时处理

本地开发或者生产环境单独运行子应用时,使体验和从主应用访问无差异

  1. 子应用中的 login/ 404/ 401 等组件与主应用中的相同
  2. 子应用在初始化时单独获取权限和用户数据
let instance: any = null
function render(props: any = {}) {
  const { appPath = '', container } = props;
  instance = new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount(container ? container.querySelector('#app') : '#app');
  store.commit('SET_APP_PATH', appPath)
}

if (!(window as any).__POWERED_BY_QIANKUN__) {
  console.log('独立运行子应用')
	// 单独获取权限和用户数据
  startPermission()
  render()
}
// 子应用单独运行时不会运行 mount 函数
export async function mount(props: any) {
  console.log('cmp app mounted')
  render(props)
  actions.init(props, (state: any) => {
    const { permissions, userData } = state;
    userData && store.commit('SET_USERDATA', userData);
    if (!store.getters.addRoutes && permissions) {
      store.dispatch('permission/GenerateRoutes');
    }
  })
}

部署

注意修改 webpack 中的 publicPath 地址

  • Nginx 配置
    # 访问主应用 https://xxx.xxx.xxx:60006
    server {
        listen       60006 ssl;
        server_name  localhost;
        root         /opt/consoles/main-web/;
        location / {
            root  /opt/consoles/main-web/;
            index  index.html index.htm;
            try_files  $uri $uri/ /index.html;
        }
        location  ~^/(subAppName1|subAppName2|subAppName3|subAppName4|subAppName5)\/(static\/img) {
            proxy_pass https://127.0.0.1:60003;
        }
        location /web-common-resource {
            proxy_pass https://127.0.0.1:60003;
        }
    }

    # 访问子应用 https://xxx.xxx.xxx:60003/subAppName1
    server {
        listen       60003 ssl;
        server_name  localhost;
        root         /opt/consoles;
        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Methods *;
        add_header Access-Control-Allow-Headers *;
        location / {
            root   /opt/consoles/subAppName1/;
            index   index.html index.htm;
            try_files  $uri $uri/ /index.html;
        }
        location  ~^/(subAppName1|subAppName2|subAppName3|subAppName4|subAppName5|web-common-resource) {
            root   /opt/consoles;
            index   index.html  index.htm;
            try_files  $uri $uri/ /$1/index.html;
        }
    }