一篇文章带你了解微前端

156 阅读7分钟

微前端的作用

  • 不同团队、不同技术栈、同时开发一个应用
  • 每个团队开发的模块都可以独立开发、独立部署
  • 实现增量迁移

实现微前端解决方案

  • 怎么将应用拆分
  • 怎么进行应用通信
  • 怎么进行应用隔离

iframe

优点:

  • 微前端最简单解决方案,通过ifram加载子应用
  • 通信通过postMessage
  • 完美的沙箱机制自带应用隔离
  • 非常简单,无需任何改造
  • 完美隔离,js、css都是独立的运行环境
  • 不限制使用,页面上可以放多个iframe 来组合业务

缺点:

  • 每次进来都需要加载,状态不能保留
  • 完全的隔离导致与子应用的通信不方便(postMessage、hash等)
  • 布局限制,比如子应用里有一个Model,显示的时候只能在那一小块地方展示,不能全屏展示
  • 无法进行资源共享,整个应用全量资源加载,加载太慢了
  • 用户体验差(弹框只能在iframe中、在内部切换刷新就会丢失状态)

Web Components

  • 将前端应用程序分解为自定义HTML元素
  • 基于CustomEvent 实现通信
  • Shadow DOM天生的作用域隔离

缺点:

  • 浏览器支持问题、学习成本、调试困难、修改样式困难等问题

single-spa

  • single-spa 通过路由劫持实现应用自由的加载(采用SystemJS),提供应用间公共组件加载及公共业务逻辑处理,子应用需要暴露固定的生命周期钩子 bootstrap、mount、unmount,接入协议
  • 基于props主子应用间通信
  • 无沙箱机制,需要自己实现js沙箱以及css沙箱

缺点:

  • 学习成本、无沙箱机制、需要对原有的应用进行改造、子应用间相同资源重复加载问题
  • 没有预加载子模块的功能

实现css隔离的几种方法:

  • vue scoped
  • shadow-root
  • single-spa

实现js隔离的方法:

  • iframe
  • proxy 隔离 : js沙箱 js运行在沙箱中
  • 快照隔离(单实例可以,多实例不友好)

公共依赖

  • systemjs -importmap 引入公共依赖
  • script标签引入

single-spa 使用

  • npm install create-single-spa -g 下载single-spa 包
  • create-single-spa 项目名 //创建single-spa项目 parcel (基座)
  • XXX-root-config.js 文件中
registerApplication({
  name:"@xxx/react",//react/vue/Angluar
  app:()=> System.import("@xxx/react"),
  activeWhen:(location)=>location.pathname === '/react',
})

同时在index.ejs文件中

<script type="systemjs-importmap">
  {
    "imports":{
      "@xx/root-config":"//localhost:9000/xxx-root-config.js",
      "@xx/react":"//localhost:3000/xxx-react.js",
    }
  }
</script>

single-spa 实现(手写)

在index.html 中

<script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script>
let { registerApplication,start } = singleSpa

利用esModules

import { registerApplication,start  } from "./single-spa/single-spa.js"

single-spa.js

import { registerApplication} from "./application/app.js"
import { start } from "./start.js"

application/app.js

export function registerApplication(){
  
}

start.js

export function start(){
  
}
// 实现子应用的注册、挂载、切换、卸载功能

/**
 * 子应用状态
 */
// 子应用注册以后的初始状态
const NOT_LOADED = 'NOT_LOADED'
// 表示正在加载子应用源代码
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'
// 执行完 app.loadApp,即子应用加载完以后的状态
const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED'
// 正在初始化
const BOOTSTRAPPING = 'BOOTSTRAPPING'
// 执行 app.bootstrap 之后的状态,表是初始化完成,处于未挂载的状态
const NOT_MOUNTED = 'NOT_MOUNTED'
// 正在挂载
const MOUNTING = 'MOUNTING'
// 挂载完成,app.mount 执行完毕
const MOUNTED = 'MOUNTED'
const UPDATING = 'UPDATING'
// 正在卸载
const UNMOUNTING = 'UNMOUNTING'
// 以下三种状态这里没有涉及
const UNLOADING = 'UNLOADING'
const LOAD_ERROR = 'LOAD_ERROR'
const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN'

// 存放所有的子应用
const apps = []

/**
 * 注册子应用
 * @param {*} appConfig = {
 *    name: '',
 *    app: promise function,
 *    activeWhen: location => location.pathname.startsWith(path),
 *    customProps: {}
 * }
 */
export function registerApplication (appConfig) {
  apps.push(Object.assign({}, appConfig, { status: NOT_LOADED }))
  reroute()
}

// 启动
let isStarted = false
export function start () {
  isStarted = true
}

function reroute () {
  // 三类 app
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
  if (isStarted) {
    performAppChanges()
  } else {
    loadApps()
  }

  function loadApps () {
    appsToLoad.map(toLoad)
  }

  function performAppChanges () {
    // 卸载
    appsToUnmount.map(toUnmount)
    // 初始化 + 挂载
    appsToMount.map(tryToBoostrapAndMount)
  }
}

/**
 * 挂载应用
 * @param {*} app 
 */
async function tryToBoostrapAndMount(app) {
  if (shouldBeActive(app)) {
    // 正在初始化
    app.status = BOOTSTRAPPING
    // 初始化
    await app.bootstrap(app.customProps)
    // 初始化完成
    app.status = NOT_MOUNTED
    // 第二次判断是为了防止中途用户切换路由
    if (shouldBeActive(app)) {
      // 正在挂载
      app.status = MOUNTING
      // 挂载
      await app.mount(app.customProps)
      // 挂载完成
      app.status = MOUNTED
    }
  }
}

/**
 * 卸载应用
 * @param {*} app 
 */
async function toUnmount (app) {
  if (app.status !== 'MOUNTED') return app
  // 更新状态为正在卸载
  app.status = MOUNTING
  // 执行卸载
  await app.unmount(app.customProps)
  // 卸载完成
  app.status = NOT_MOUNTED
  return app
}

/**
 * 加载子应用
 * @param {*} app 
 */
async function toLoad (app) {
  if (app.status !== NOT_LOADED) return app
  // 更改状态为正在加载
  app.status = LOADING_SOURCE_CODE
  // 加载 app
  const res = await app.app()
  // 加载完成
  app.status = NOT_BOOTSTRAPPED
  // 将子应用导出的生命周期函数挂载到 app 对象上
  app.bootstrap = res.bootstrap
  app.mount = res.mount
  app.unmount = res.unmount
  app.unload = res.unload
  // 加载完以后执行 reroute 尝试挂载
  reroute()
  return app
}

/**
 * 将所有的子应用分为三大类,待加载、待挂载、待卸载
 */
function getAppChanges () {
  const appsToLoad = [],
    appsToMount = [],
    appsToUnmount = []
  
  apps.forEach(app => {
    switch (app.status) {
      // 待加载
      case NOT_LOADED:
        appsToLoad.push(app)
        break
      // 初始化 + 挂载
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (shouldBeActive(app)) {
          appsToMount.push(app)
        } 
        break
      // 待卸载
      case MOUNTED:
        if (!shouldBeActive(app)) {
          appsToUnmount.push(app)
        }
        break
    }
  })
  return { appsToLoad, appsToMount, appsToUnmount }
}

/**
 * 应用需要激活吗 ?
 * @param {*} app 
 * return true or false
 */
function shouldBeActive (app) {
  try {
    return app.activeWhen(window.location)
  } catch (err) {
    console.error('shouldBeActive function error', err);
    return false
  }
}

// 让子应用判断自己是否运行在基座应用中
window.singleSpaNavigate = true
// 监听路由
window.addEventListener('hashchange', reroute)
window.history.pushState = patchedUpdateState(window.history.pushState)
window.history.replaceState = patchedUpdateState(window.history.replaceState)
/**
 * 装饰器,增强 pushState 和 replaceState 方法
 * @param {*} updateState 
 */
function patchedUpdateState (updateState) {
  return function (...args) {
    // 当前url
    const urlBefore = window.location.href;
    // pushState or replaceState 的执行结果
    const result = Reflect.apply(updateState, this, args)
    // 执行updateState之后的url
    const urlAfter = window.location.href
    if (urlBefore !== urlAfter) {
      reroute()
    }
    return result
  }
}

Module federation

  • 通过模块联邦将组件进行打包导出使用
  • 共享模块的方式进行通信
  • 无css沙箱和js沙箱

缺点:

  • 需要webpack5

systemjs

systemjs-importmap 类似于webpack importMap 在imports对象中查找所需要的资源

systemjs实现

1、新建html 主应用文件基座,并用systemjs-importmap 应用公共资源包

<script type="systemjs-importmap" >
  //加载cdn资源 
  {
    "imports":{
      "react":"https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js",
      "react-dom":"https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"
    }
  }
</script>
<div id="root" ></div>
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.10.1/system.min.js" ></script>
<script>
  //引入所需要的子应用,导入打包后的包,来进行加载,采用的规范为system规范
  //systemjs 是如何定义的   打包后的结果 System.register(依赖列表,回调函数返回值 setters、execute )
  //react,react-dom 加载后调用setters 将对应的 结果赋予给webpack
  const newMapUrl ={}

  //解析importMap
  funciton processScripts(){
     Array.from(document.querySelectorAll('script')).forEach(script=>{
       if(script.type==='systemjs-importmap'){
         const imports = JSON.parse(script.innerHTML).imports
         Object.entries(imports).forEach(([key,value])=>newMapUrl[key]=value)
       }
     })
  }

 // 加载资源
function load(id){
  return new Promise((resolve,reject)=>{
    const script = document.createElement('script')
    script.src=id
    script.true = true
    document.head.appendChild(script)
    script.addEventListener('load',function(){
      resolve()
    })
  })
}
let set = new Set()
function saveGlobalProperty(){
  for(let k in window){
    if(set.has(k)) continue;
    set.add(k)
  }
}
saveGlobalProperty()

function getLastGlobalProperty(){
  for(let k in window){
    if(set.has(k)) continue;
    set.add(k)
    return window[k]
  }
}

 let lastRegister;

  // 模块规范 用来加载system模块的
  class SystemJs{
  import(id){ //这个id原则上是一个第三方路径cdn
  
    return Promise.resolve(processScripts()).then(()=>{
        // 当前路径查找对应的资源 index.jsw
      const lastSepIndex = location.href.lastIndexOf('/')
      const baseURL = location.href.slice(0,lastSepIndex+1)
      if(id.startWith('./')){
        return baseURL +id.slice(2)
      }
    }).then((id)=>{
      // 根据文件的路径,来加载资源
      console.log(id)
      return laod(id).then((register)=>{
        let {setters,execute:exe} = register[1](()=>{})
        execute = exe
        console.log('文件加载完毕')
        return [register[0],setters]
      }).then(([registeration,setters])=>{
        return Promise.all(registeration.map((dep,i)=>{
          return load(dep).then(()=>{
            const property = getLastGlobalProperty()
            setters[i](property)
          })
        }))
      }).then(()=>{
        execute()
      })
    })
  }
    register(deps,declare){
     lastRegister = [deps,declare] 
    }
}

 const System= new SystemJs

  
  System.import('./index.js').then(()=>{
    console.log('模块加载完毕')
  })
</script>

qiankun

qiankun 基于shadow-Dom/shadow-root 实现样式隔离的

qiankun 实践

//qiankun 安装
yarn add qiankun  # or  npm i qiankun -S

//使用
import { loadMicroApp } from 'qiankun';

// 加载微应用
loadMicroApp({
  name: 'reactApp',
  entry: '//localhost:7100',
  container: '#container',
  props: {
    slogan: 'Hello Qiankun',
  },
});

基座的app.js

import { loadMicroApp } from "qiankun"

function App(){
  const containerRef =React.createRef();
  useEffect(()=>{
    loadMicroApp({
      name:'m-static',
      entry:'//localhost:30000',
      container:containerRef.current
    })
  })


    return (
     <div className="App">
      <BrowserRouter>
        <Link to="/react">react应用</Link>
        <Link to="/vue">vue应用</Link>
      </BrowserRouter>
      <div ref={containerRef} ></div>
      <div id="container" ></div>
     </div>
    )
}

基座应用引入 registerApps.js


import { registerMicroApps, start } from 'qiankun'
const loader = (loading)=>{
  console.log('加载状态',loading)
}
registerMicroApps([
  {
    name:'reactApp',//name
    entry:'//localhost:10000',//默认启动入口
    activeRule:'/react',//当路径为/react 的时候加载
    container:"#container" ,//  应用挂在位置
    loader,
    props:{a:1}//给子应用传参
  }
],{
  beforeLoad(){
    console.log('before Load')
  },
  beforeMount(){
    
  },
  afterMount(){},
  beforeUnmount(){},
  afterUnmount(){},
  
})

.env

PORT =10000
WDS_SOCKET_PORT=1000

子应用的index.js

import './public-path.js'

let root;
function render(props){
  const container = props.container
  root =ReactDOM.creatRoot( container ? container.querySelector('#root') : document.getElementById("root"));
}

//qiankun 提供了一些标识,用于表示当前应用是否在父应用中被引入过
if(!window.__POWERED_BY_QIANKUN__){
  render({})
}
// render()

// qiankun 要求应用暴露的格式是umd格式
//修改webpack配置
// npm install @rescripts/cli --force


exprot async function bootstrap(props){}
exprot async function mount(props){
  render(props)
}
exprot async function unmount(props){
  root.unmount();
}
//如果是vue的话 下面内容放vue.config.js 中
module.export={
  webpack:(config)=>{
    config.output.libraryTarget = 'umd'
    config.output.library = 'react' //打包的格式是umd格式
    return config
  },
  //解决子应用的跨域问题
  devServer:(config)=>{
    config.headers = {
      "Access-control-Allow-Origin":"*"
    }
    return config
  }
}

"scripts":{
  "start":"rescripts start",
  "build":"rescripts build",
  "test":"rescripts test",
  "eject":"rescripts eject",
}

在子应用的src下增加 public-path.js

if(window.__POWERED_BY_QIANKUN__){
  //eslint-diable-next-line  no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
  
}

qiankun 下 新增html 子应用

<!DOCTYPE html>
<html lang='en'>
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge" >
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>静态应用</title>
  </head>
  <body>
    <div id="static"></div>
    <script entry >
      const app =document.getElementById("static");
      function render(){
        app.innerHTML='static'
      }
      if(!window.__POWERED_BY_QIANKUN__){
        render()
      }
      window["m-static"] ={
        bootstrap:async ()=>{
          
        },
        mount:async ()=>{
          render()
        },
        unmount:async ()=>{
          
        }
      }
    </script>
  </body>  
</html>
//启动的包
//下个包 http-server --port 30000 --cors

qiankun 的沙箱实现原理