【微前端】部分知识点整理,qiankun

283 阅读6分钟

微前端

概念介绍:

  1. 核心:拆分巨型应用,允许技术栈不同【不建议同时使用多个不同技术栈】,并且最后聚合为一;
  2. 拆分的应用可以独立开发、独立部署;
  3. 类似于微服务架构,但微服务只拆分不用聚合;
  4. 最佳使用场景:一些B端管理系统,既能兼容集成历史系统,也可以将新的系统集成进来,且不影响原先的交互体验;
  5. 缺点:
    • 应用拆分依赖基础设施构建,一旦大量应用依赖于同一基础设施,维护会变成一个挑战;
    • 拆分粒度越小,架构会变得复杂,维护成本变高;
    • 技术栈一旦多元化,意味着技术栈混乱;

Micro Frontends

架构实现:

中心化:基座模式

  • Single-Spa --2018年
  • Qiankun --2019年 基于Single-Spa + sandbox + import-html-entry

去中心化:自组织模式

  • webpack5 模块联邦 --2020年 诞生背景:跨应用加载模块
  • EMP基于 Module Federation (模块联邦) --2020年,接入成本低,解决第三方依赖包问题
  • lcestark 阿里飞冰微前端框架

qiankun框架介绍

  • 主应用要安装qiankun,子应用不需要安装qiankun;
  • 子应用要注意解决跨域问题,必须打包出umd库格式;

微前端运行原理:

项目环境准备:

  1. 准备一个项目,项目包含中心基座父应用(vue2),以及子应用(vue2、react);
  2. 子组件在入口文件中完成 bootstrapmountunmount三个钩子函数导出;以及配置文件中完成导出格式配置,开启跨域解决跨域问题;
import './public-path'  // 注意:动态设置 public-path要放在入口文件的顶部
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'

Vue.config.productionTip = false

let instance = null
let router = null
let store = null

function render(props) {
  console.log(props)
  router = createRouter()
  store = createStore()
  instance = new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount(props.container
    ? props.container.querySelector('#app') // 渲染到子应用
    : '#app' // 子应用独立运行
  )
}

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

// 不需要安装任何依赖
// 只需要在入口文件导出三个必须的钩子函数,给主应用使用
// 钩子函数必须返回 Promise

// 启动调用
export const bootstrap = async () => {
  console.log('app-vue2 bootstrap')
}

// 挂载调用
export const mount = async (props) => {
  console.log('app-vue2 mount')
  // props.onGlobalStateChange((state, prev)=>{
  //   console.log('app-vue2 =>',state, prev)
  // }, true) // 传一个true 一上来就调用
  render(props)
}

// 卸载调用
export const unmount = async () => {
  console.log('app-vue2 unmount')
  // 清除Vue实例运行期间产生的内容
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  // 清除路由实例
  router = null;
  // 清除容器实例
  store = null;
}
  1. 父组件在入口文件需要完成注册子应用和启动微前端;

    micro-fe是自定义的微前端模块,用于完成理解微前端架构原理

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerMicroApps, start } from './micro-fe'

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

// 1. 注册子应用
registerMicroApps([
  {
    // 当访问 activeRule 的时候,用js请求加载 entry,然后渲染到 container 上
    name: 'vue2App', // app name registered
    entry: '//localhost:8082',
    container: '#subapp-container',
    activeRule: '/app-vue2',
  },
  // {
  //   name: 'vue3App', // app name registered
  //   entry: '//localhost:8083',
  //   container: '#subapp-container',
  //   activeRule: '/yourActiveRule',
  // },
  {
    name: 'reactApp', // app name registered
    entry: '//localhost:8081',
    container: '#subapp-container',
    activeRule: '/app-react',
  },
])

// 2.启动微前端架构系统
start()

微前端原理简单实现

微前端简单运行原理:

  1. 监视路由变化;2.匹配子应用;3.加载子应用;4.渲染子应用;
  1. 监视路由变化:此部分在到rewrite-router.js模块中

    let prevRoute = ''; // 记录上一个路由
    let nextRoute = ''; // 记录当前路由 也就是上一个路由跳转后的下个路由
    
    export const getPrevRoute = () => prevRoute
    export const getNextRoute = () => nextRoute
    
    export const rewriteRouter = () =>{
        // 完成1.监视路由变化;2.匹配子应用
        // ...
    }
    
    1. hash 路由:window.onhashchange

    2. history 路由:

      用户在浏览器前进后退的行为调用的history.go、history.back、history.forward的方法,以使用 popstate 事件劫持:window.onpopstate rewriteRouter方法部分代码:

      const rewriteRouter = () =>{
      // ...
      window.addEventListener('popstate', ()=>{
      console.log('popstate')
       hanleRouter()
       // ...
        })
       }
      

      通过点击行为调用的pushState、replaceState方法,需要通过函数重写的方式进行劫持;

      // ...
      // pushState
      const rawPushState = window.history.pushState; //做备份
      window.history.pushState = (...args) =>{ // 重写pushState
          rawPushState.apply(window.history, args)
          console.log('监视到pushState的变化')
          hanleRouter()
      }
      // replaceState
      const rawReplaceState = window.history.replaceState; //做备份
      window.history.replaceState = (...args) =>{ // 重写pushState
       rawReplaceState.apply(window.history, args)
          hanleRouter()
          console.log('监视到replaceState的变化')
      }
      // ...
      
  2. 匹配子应用;

    此部分在到rewrite-router.js模块中:

    处理路由变化hanleRouter()方法,此方法在监视路由后执行;

    export const handleRouter = () =>{}
    
    1. 获取当前路由路径
    window.location.pathname
    
    1. 在传进来的 apps 配置中查找是否匹配
    import { getApps } from '.'
    // import ...
    
    export const handleRouter = async () =>{
        const apps = getApps()
        const app = apps.find(item => window.loacation.pathname.startsWith(item.activeRule))
        if(!app) return
    }
    
    1. 完成前进后退行为
    // ...
    // 引入获取到的当前路由以及上一个路由
    import { getPrevRoute,getNextRoute } from './rewrite-router';
    
    export const handleRouter = async () =>{
      // ...
      // 卸载上一个应用
      const prevApp = apps.find(item => getPrevRoute().startsWith(item.activeRule))
      // 如果有上一个应用,则先销毁
      if(prevApp) {
        await unmount(prevApp)
      }
      // ...
    }
    
  3. 加载子应用;
    请求获取子应用的资源:HTML、CSS、JS

    export const handleRouter = async () =>{
            // ...   
        // 加载子应用到父应用指定的容器中
            const html = await fetch(app.entry).then(res => res.text())
        const container = document.querySelector(app.container);
        container.innerHTML = html; // 此时并不能成功显示子应用,但已经获取到了html文本
    }
    

    上述方式并不能成功显示子应用,原因是:

    • 客户端渲染需要执行 JavaScript 来生成内容

    • 浏览器出于安全考虑,innerHTML 中的script不会加载执行,需要手动加载;

     // 处理子应用中HTML的script内容
    import { fetchResource } from './fetch-resource' // 获取子应用入口html文本
    export const importHtml = async (url) => {
     // qiankun中加载文档html使用封装的库是import-html-entry 
     // import-html-entry => 其会返回的几个重要的数据:template 处理之后的html模板字符串、getExternalScript 所有script脚本代码等...
    
     const html = await fetchResource(url);
     const template = document.createElement('div');
     // template.innerHTML = '<p>hello</p>' // test
     template.innerHTML = html // 将HTML文本转出DOM,就可以进行下一步获取script脚本并手动加载
    
     // 获取script脚本
     const scripts = template.querySelectorAll('script')
    
     // 获取所有script标签的代码文本,因为是DOM节点返回伪数组[code, code, ...]
     function getExternalScripts() {
       // 将伪数组转为数组
       return Promise.all(Array.from(scripts).map(script => {
        //有两种 script 代码,一种是src引用,另一种是在标签体内   
         const src = script.getAttribute('src') 
         if (!src) { // 如果是引用直接返回在promise参数
           return Promise.resolve(script.innerHTML)
         } else { // 如果是标签体内,改写成完整的url返回promise参数
           return fetchResource(
             src.startsWith('http') ? src : `${url}${src}`
           )
         }
       }))
     }
    
     // 获取并执行所有的script 脚本代码
     async function execScripts() {
       // 获取改造后的 scripts 数组
       const scripts = await getExternalScripts()
       // 手动加载 script 代码 
       // ...
     }
    
     // ...
    
     return {
       template,
       getExternalScripts,
       // execScripts
     }
    }
    

    完成手动加载 script 代码前提,要了解umd库打包格式;

    vue2子应用配置mode:'development' npm run build 打包后观察dist中的js文件

    // umd打包库格式 => 兼容不同的模块规范
    
    (function webpackUniversalModuleDefinition(root, factory) {
      // root => window
      // factory => function() { // 子应用代码 ... // 导出处结果 return { ... } }
    
      // 判断是否是CommonJS 模块规范
      if (typeof exports === 'object' && typeof module === 'object')
        module.exports = factory();
    
    
      // 判断是否是 AMD 模块规范
      else if (typeof define === 'function' && define.amd)
        define([], factory);
    
    
      // 判断是否是 CommonJS 模块规范
      else if (typeof exports === 'object')
        exports["vue2App"] = factory();
    
      // window[xxx] = factory()
      else //判断是否是属性名xxx子组件
        root["vue2App"] = factory();
    })(window, function () {
      // 这是vue2子组件内部的code 
      // ...
    
      // 最后返回的导出结果
      return {
        a: 1,
        b: 2
      }
    });
    

    我们如果使用匹配名字就无法兼顾多个子应用,所有直接手动构造一个 CommonJS 模块的环境

    // ...
    // 获取并执行所有的script 脚本代码
    async function execScripts() {
        const scripts = await getExternalScripts()
        // 手动构造一个 CommonJS 模块环境
        const module = {exports: {}}
        const exports = module.exports
        console.log(scripts)
        scripts.forEach(code=>{
            eval(code)
        })
        // 获取子应用代码执行后导出的三个钩子
        console.log(module.exports)
        return module.exports
    }
    
    return {
        template,
        getExternalScripts,
        execScripts
    }
    // ...
    

    handle-router.js 引用调用

    // ...
    import { importHtml } from './import-html';
    
    export const handleRouter = async () =>{
      // ...
      // 3.加载子应用到父应用指定的容器中
      const container = document.querySelector(app.container);
      const { template, execScripts } = await importHtml(app.entry);
      container.appendChild(template);
    }
    
  4. 渲染子应用

    import { importHtml } from './import-html';
    
    export const handleRouter  = async () =>{
        // ...
        const appExports = await execScripts();
        app.bootstrap = appExports.bootstrap
        app.mount = appExports.mount
        app.unmount = appExports.unmount
    
        await bootstrap(app)
        await mount(app)
        // ...
    }
    
    // 封装一层钩子函数
    async function bootstrap(app) {
      app.bootstrap && (await app.bootstrap())
    }
    async function mount(app) {
      app.mount && (await app.mount({
        // 将容器传入
        container: document.querySelector(app.container)
      }))
    }
    async function unmount(app) {
      app.unmount && (await app.unmount({
        // 将容器传入
        container: document.querySelector(app.container)
      }))
    }
    
  5. 配置全局变量,解决子应用独立运行与静态资源加载问题

    handle-router.js

    // ... handleRouter 
    // 类似 qiankun 配置子应用全局变量 子应用用于判断是否独立运行
    window.__POWERED_BY_QIANKUN__ = true;
    window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + '/';
    // ... handleRouter