手写微前端 simple-qiankun

lxf2023-05-19 01:39:35

前端

开门见山,关于微前端网上已经有很好的文章,这里不再赘述,分享一下自己觉得比较好的讲微前端原理的文章:
# 微前端-最容易看懂的微前端知识
# 30分钟快速掌握微前端qiankun的所有核心技术
# 微前端连载 6/7:微前端框架 - qiankun 大法好

手写微前端系列:
# 从零开始写一个微前端框架
# 手把手教你写一个简易的微前端框架

qiankun 的使用及原理

手写微前端 simple-qiankun

qiankun 官网快速上手: qiankun.umijs.org/zh/guide/ge…

  • 只需要主应用安装 qiankun,注册子应用路由匹配规则,然后开启应用。
  • 子应用需要暴露 bootstrap、mount、unmount 3个生命周期函数,通过 umd 打包输出的时候主应用可以获取到这些生命周期,控制子应用的加载渲染。 qiankun 配置使用 demo git 地址:github.com/YY88Xu/qian…
// 主应用配置 main-vue3/src/main.js
import { registerMicroApps, start } from 'qiankun'
const apps = [
  {
    name: 'vue2', // 应用的名字
    entry: 'http://localhost:2001/', // 默认加载这个html,解析里面的js动态的执行(子应用必须支持跨域,内部使用的是 fetch)
    container: '#sub-container', // 要渲染到的容器名id
    activeRule: '/vue2' // 通过哪一个路由来激活
  },
  {
    name: 'vue3',
    entry: 'http://localhost:3001/',
    container: '#sub-container',
    activeRule: '/vue3',
  }
];
// 当匹配到activeRule的时候,请求获取entry资源,渲染到container中
registerMicroApps(apps); // 注册应用
start({
  sandbox: {
    strictStyleIsolation: true, // 使用shadow dom解决样式冲突
    // experimentalStyleIsolation: true // 通过添加选择器范围来解决样式冲突
  }
}); // 开启应用

手写微前端

手写微前端 simple-qiankun 微前端的本质是通过监控路由的变化,根据配置的子应用路由匹配规则,匹配到相应的子应用,根据 entry 远程 fetch 获取 HTML 的内容,解析 HTML 里面的 script 标签和 css 标签,fetch 获取这些资源,执行获取的 script 代码,将 css 获取的内容添加到 HTML DOM 中;根据配置的路由渲染规则,将 HTML 渲染到配置的主应用 container 中。

这其中涉及很多细小的知识点,通过手写一遍,不仅可以弄懂微前端的实现原理,而且可以加固自己的基础。

监控路由变化

监控路由变化的目的是为了能根据路由找到应该渲染的子应用信息。路由模式有两种:hash 模式和 history 模式。hash 模式需要监控 window.onhashchange 事件;history 模式 需要监控 pushState、 replaceState、 window.onpopstate 事件。pushState、 replaceState 不包括浏览器的前进、后退,所以也需要对 window.onpopstate 事件进行监控。更多细节可以参考:/post/684490…

// main-vue3/src/micro-fe/rewrite-router.js
import {handleRouter} from './handle-router'

// 缓存上一个路由,下一个路由
let prevRoute = ""
let nextRoute = window.location.pathname

export const getPrevRoute = ()=> prevRoute
export const getNextRoute = ()=> nextRoute

export const rewriteRouter = ()=>{
  window.addEventListener('popstate', ()=>{
    // popstate 触发的时候,路由已经完成导航了
    prevRoute = nextRoute
    nextRoute = window.location.pathname
    handleRouter()
  })

  const rawPushState = window.history.pushState
  window.history.pushState = (...args) => {
    // 导航前
    prevRoute = window.location.pathname
    rawPushState.apply(window.history, args)
    // 导航后
    nextRoute = window.location.pathname
    handleRouter()
  }

  const rawReplaceState = window.history.replaceState
  window.history.replaceState = (...args)=>{
    // 导航前
    prevRoute = window.location.pathname
    rawReplaceState.apply(window.history, args)
    // 导航后
    nextRoute = window.location.pathname
    handleRouter()
  }
}

手写实现的是 history 路由模式;其中 prevRoute、nextRoute 两个变量记录了路由变化前和变化后的值,这样根据路由变化前后的值可以定位到路由变化前后的子应用,卸载路由变化前的子应用,加载路由变化后的子应用。

匹配子应用

正如前面说的,根据 prevRoute、nextRoute 两个变量可以匹配到路由变化前后的子应用信息,如果前一个子应用存在的话,卸载(unmount)之前的子应用;如果后一个子应用存在的话,加载(bootstrap、mount)新的子应用。

这里涉及到了子应用关键的生命周期函数 bootstrap、mount unmount如何获取远程子应用暴露的这三个声明周期函数呢?下面加载子应用的时候再讲。

加载子应用

为了让 css 在开发模式的时候也单独打包出来,对于 vue cli 生成的项目,需要配置如下:

// vue.config.js
module.exports = {
  css: {
    extract: true  //将组件中的 CSS 提取至一个独立的 CSS 文件中
  }
}

这样在开发模式的时候,css 文件也会单独生成。

手写微前端 simple-qiankun 手写微前端 simple-qiankun

先根据匹配到的 entry 远程获取到 HTML,解析 HTML 后,再异步 fetch 获取需要的 js 和 css 文件。

渲染子应用

现在已经有了 HTML 文件、CSS 文件和 JS 文件,"万事俱备,只欠东风",我们只需要将 HTML 字符串转为 DOM 元素,将 CSS 文件生成 style 标签加入 DOM 元素的中;然后执行 JS 文件,最后将 DOM 元素加载到对应的容器中,即完成了子应用的渲染。

在这个过程中,我们需要解决三个问题:

  1. 样式隔离
  2. JavaScript 隔离
  3. 获取子应用的 bootstarp、mount、unmount 生命周期

样式隔离

样式隔离主要包括主应用、子应用样式的隔离,各子应用样式的隔离,不受彼此的影响。核心就是在子应用加载渲染的时候对其样式加一层从而进行隔离。常用的手段有 CSS Modules、shadow dom 或者使用 postcss 的插件。

CSS Modules:对于 Vue CLI 项目是原始支持 CSS Modules 的用法的,只需要在组件中的 <style> 上添加 module 特性:

<style module>
.red {
  color: red;
}
.bold {
  font-weight: bold;
}
</style>

在组件中访问 CSS Modules, 在 <style> 上添加 module 后,一个叫做 $style 的计算属性就会被自动注入组件。

<template>
  <div>
    <p :class="$style.red">
      hello red!
    </p>
  </div>
</template>

更多使用细节:cloud.tencent.com/developer/a…
CSS Modules 在编译后会给 CSS 样式名称重新一个新的 class, 从而保证样式不会相同。

手写微前端 simple-qiankun

Shadow DOM 的特性是 DOM 元素在开启 Shadow DOM 后里面设置的样式只会影响该 DOM 以及它的子集 DOM,不会影响其他 DOM 元素。利用这个特点在子应用需要渲染的 DOM 上开启 Shadow DOM,便可实现子应用的样式不影响主应用的效果。
本文采用 Shadow DOM 方式实现 CSS 样式隔离。Shadow DOM 元素对主文档的 JavaScript 选择器隐身,比如 querySelector。所以在选择子应用的渲染容器的时候需要判断一下,需要使用shadowRoot 去筛选:

// main-vue3/src/micro-fe/handle-router.js
// 加载子应用时,添加一个开启 Shadow DOM 的 DIV 
export const handleRouter = async ()=>{
   ...
  const container = document.querySelector(app.container)
  const subWrap = document.createElement('div')
  subWrap.id = "__inner_sub_wap__"
  const shadowDom = subWrap.attachShadow({mode: 'open'})
  shadowDom.innerHTML = template.innerHTML

  container.innerHTML = ""
  container.appendChild(subWrap)
  ...
}

在子应用 mount 函数中加一层判断:

// app-vue3/src/main.js
function render(props = {}) {
  instance = createApp(App)
  const { container } = props;
  const shadowApp = container.firstChild.shadowRoot.querySelector('#app')
  instance.mount(shadowApp ? shadowApp : '#app');
}

更多使用细节:zh.javascript.info/shadow-dom

postcss postcss-selector-namespace 插件可以为样式添加对应的 namespace,从而达到样式隔离的效果,当然我们也可以自己实现 postcss 插件。

JavaScript 隔离

借鉴 qiankun 的 JavaScript 隔离方案(# 说说微前端JS沙箱实现的几种方式) # 15分钟快速理解qiankun的js沙箱原理及其实现 本文主要介绍两种:快照沙箱和代理沙箱。

快照沙箱
主要的方法 activeinactiveactive 表示激活该沙箱,并将 window 上的变量记录在 snapshotWindow 上,对原始 window 上的变量进行 snapshot,并将 modifyMap 修改的值赋值到 window 变量上 。inactive 表示注销该沙箱,这时候要对比激活时快照和当前 window 上变量值的不一致,存储在 modifyMap 变量上,下一次该沙箱激活的时候重新赋值给 window 上。

// main-vue3/src/micro-fe/snapshot-sandbox.js
export class SnapshotSandbox{
  constructor(name){
    this.name = name
    this.proxy = window
    this.snapshotWindow = {}
    this.modifyMap = {}
  }
  active(){
    this.snapshotWindow = {}
    for(let key in window){
      this.snapshotWindow[key] = window[key]
    }
    for(let key in this.modifyMap){
      window[key] = this.modifyMap[key]
    }
  }
  inactive(){
    for(let key in window){
      if(this.snapshotWindow[key] !== window[key]){
        // 记录变化的
        this.modifyMap[key] = window[key]
        // 恢复快照时值
        window[key] = this.snapshotWindow[key]
      }
    }
  }
}

代理沙箱
主要的方法也是 activeinactive,Proxy 对 window 进行代理,get 访问的时候,先去 fakeWindow 中查找,没有的话才会从原始 rawWindow 上取值;set 只有在沙箱激活的时候才会进行赋值操作。

// main-vue3/src/micro-fe/proxy-sandbox.js
export class ProxySandbox{
  active(){
    this.sandboxRunning = true
  }
  inactive(){
    this.sandboxRunning = false
  }
  constructor(name){
    this.name = name
    const rawWindow = window 
    const fakeWindow = {}
    const proxy = new Proxy(fakeWindow, {
      set: (target, prop, value)=>{
        if(this.sandboxRunning){
          target[prop] = value
          return true
        }
      },
      get: (target, prop)=>{
        // 如果 fakeWindow 里面有,就从 fakeWindow 里面取,否则,就从外面的 window 里面取
        let value = prop in target ? target[prop] : rawWindow[prop]
        return value
      }
    })
    this.proxy = proxy
  }
}

关于 JavaScript 隔离的两种方法介绍完后,就是使用问题了。在子应用 mount 的时候初始化对应的沙箱,并激活该沙箱。在子应用 unmount 的时候冻结该沙箱,这样就可以保证各个子应用在运行时 JavaScript 的相互隔离。

还有一点就是执行子应用的 JavaScript 代码的时候需要调整其执行环境到该沙箱环境中。

对于字符串 JavaScript 的执行有两种方式:evalnew Function()

// main-vue3/src/micro-fe/import-html.js
async function execScripts(global){
    ...
    scripts.forEach((code) => {
      window.proxy = global
      const scriptText = `
        ((window) => {
          ${code}
        })(window.proxy)
      `
      //eval(scriptText)
      new Function(scriptText)()
    });
 }

获取子应用生命周期

有两种方式,第一种比较直接就是在子应用中把 bootstrap、mount unmount 3个生命周期暴露在 window 全局对象上。第二种在上面的执行完某个子应用的 JS 代码后,window 对象上自动添加了该应用的信息:

手写微前端 simple-qiankun

应用通信

qiankun 应用间通信篇原理:/post/684490…
本文介绍两种实现应用通信的方式:customevent 和 发布订阅方法

customevent

实现 Custom 类,将自定义事件添加到 window 对象上。

// main-vue3/src/micro-fe/global/data-custom.js
export class Custom{
  // 事件监听
  on(name, cb){
    window.addEventListener(name, e=>{
      cb(e.detail)
    })
  }
  // 事件触发
  emit(name, data){
    const event = new CustomEvent(name, {detail: data})
    window.dispatchEvent(event)
  }
}

使用:

// main-vue3/src/main.js
import { Custom } from './micro-fe/global/data-custom.js'

const globalCustom = new Custom()
// 事件监听
globalCustom.on("build", (data)=>{
  console.log(data)
})
window.globalCustom = globalCustom


// 子应用
// app-vue3/src/App.vue
    emitBuild(){
      const globalCustom = window.globalCustom
      // 事件触发
      globalCustom.emit("build", 100)
    }

自定义事件——Event和CustomEvent

发布订阅

createStore 方法利用闭包将 getStore, update, subscribe 保存在内存中。

// main-vue3/src/micro-fe/global/data-store.js
export const createStore = (initData = {}) => (()=>{
  let store = initData
  // 管理所有订阅者
  const observers = []

  // 获取 store
  const getStore = ()=> store

  // 更新store
  const update = (value) => {
    if (value !== store) {
      // 执行store的操作
      const oldValue = store
      // 将store更新
      store = value
      // 通知所有的订阅者,监听store的变化
      observers.forEach(async item => await item(store, oldValue))
    }
  }
  
  // 添加订阅者
  const subscribe = (fn) => {
    observers.push(fn)
  }

  return {
    getStore,
    update,
    subscribe,
  }
})()

使用:

// main-vue3/src/main.js
import { createStore } from './micro-fe/global/data-store.js'
const store = createStore()

window.store = store
// 订阅
store.subscribe((newValue, oldValue) => {
  console.log(newValue, oldValue, '---')
})


// 子应用
// app-vue3/src/App.vue
    emitBuild(){
      const globalCustom = window.globalCustom
      // 更新
      globalCustom.emit("build", 100)
    }

源码地址

源码地址:github.com/YY88Xu/simp…
演示效果:

手写微前端 simple-qiankun

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!