Handwritten micro front end simple-qiankun

micro frontend

Straight to the point, there are already very good articles on the micro-front-end Internet. I won’t repeat them here. I will share the articles that I think are better about the principles of micro-front-ends:
# Micro-front-end-the easiest-to-understand micro-front-end knowledge
# Quick mastery in 30 minutes All the core technologies of micro front-end qiankun
# Micro front-end serialization 6/7: micro-front-end framework - qiankun Dafa is good

Handwritten micro-front-end series:
# Write a micro-front-end framework from scratch
# Teach you to write a simple micro-front-end framework

The use and principle of qiankun

image.png

Quick start on qiankun official website: qiankun.umijs.org/zh/guide/ge…

  • You only need to install qiankun in the main application, register the sub-application route matching rules, and then start the application.
  • The sub-application needs to expose the three life cycle functions of bootstrap, mount, and unmount. When packaging and outputting through umd, the main application can obtain these life cycles and control the loading and rendering of the sub-application.

The qiankun configuration uses the demo git address:

// 主应用配置 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 // 通过添加选择器范围来解决样式冲突
  }
}); // 开启应用
复制代码

Handwriting Micro Frontend

image.pngThe essence of the micro-frontend is to monitor the change of the route, match the corresponding sub-application according to the configured sub-application routing matching rules, obtain the HTML content according to the remote fetch of the entry, parse the script tags and css tags in the HTML, and fetch to obtain these resources , execute the obtained script code, and add the content obtained by css to the HTML DOM; according to the configured routing rendering rules, the HTML is rendered into the configured main application container.

This involves a lot of small knowledge points. By handwriting, you can not only understand the implementation principle of the micro front-end, but also strengthen your own foundation.

Monitor route changes

监控路由变化的目的是为了能根据路由找到应该渲染的子应用信息。路由模式有两种:hash 模式和 history 模式。hash 模式需要监控 window.onhashchange 事件;history 模式 需要监控 pushState、 replaceState、 window.onpopstate 事件。pushState、 replaceState 不包括浏览器的前进、后退,所以也需要对 window.onpopstate 事件进行监控。更多细节可以参考:juejin.cn/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 文件也会单独生成。

先根据匹配到的 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, 从而保证样式不会相同。

image.png

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 对象上自动添加了该应用的信息:

image.png

应用通信

The principle of communication between qiankun applications: juejin.cn/post/684490…This
article introduces two ways to realize application communication: customevent and publish-subscribe method

customevent

Implement the Customclass to add custom events to the windowobject.

// 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)
  }
}
复制代码

use:

// 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)
    }

复制代码

Custom Events - Event and CustomEvent

publish subscribe

createStoreThe method utilizes a closure to getStore, update, subscribe keep the in memory.

// 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,
  }
})()
复制代码

use:

// 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)
    }
复制代码

Source address

Demonstration effect:

8go8l-8f4li.gif

Guess you like

Origin juejin.im/post/7079379620348313637