手动实现一个微前端qiankun

250 阅读3分钟

前言

作为前端开发者,微前端架构已经成为解决大型应用复杂性的重要手段。本文将带你简单了解下qiankun的原理,并尝试手写实现一个简化版的微前端框架。

微前端核心概念

微前端是一种将多个独立交付的前端应用组合成一个更大整体的架构风格。主要解决:

  • 巨石应用拆解
  • 技术栈无关
  • 独立开发部署
  • 增量升级

qiankun核心原理

qiankun的核心原理可以概括为以下几个部分:

  • 应用加载:动态加载子应用的HTML、JS、CSS资源
  • 应用隔离:实现JS沙箱和CSS样式隔离
  • 应用通信:提供父子应用、子应用间的通信机制
  • 生命周期:管理子应用的挂载、卸载等生命周期

手动实现简单版qiankun

qiankun的使用

  • 主应用中下载qiankun框架,注册子应用,运行
  • 子应用中对外暴露3个生命周期函数:bootstrap、mount、unmount

主应用修改

子应用修改

  • main.js
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

let app = null

function render(props = {}) {
  const { container } = props
  console.log('container', container)
  app = new Vue({
    render: h => h(App),
  })

  // 使用提供的容器或默认容器
  const mountEl = container ? container.querySelector('#app') : '#app'
  console.log('mountEl', mountEl)

  app.$mount(mountEl)
}

// 独立运行时直接渲染
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

// qiankun 生命周期钩子
export async function bootstrap() {
  console.log('[vue3] app bootstrap')
}

export async function mount(props) {
  console.log('[vue3] mount props', props)
  render(props)
}

export async function unmount() {
  console.log('[vue2] unmount')
  app?.$unmount?.()
  app = null
}
  • vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  // 配置打包输出格式
  configureWebpack: {
    output: {
      library: `vue2-sub-app`,
      libraryTarget: 'umd',
    }
  },
  // 开发环境跨域配置
  devServer: {
    port: 3001,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  },
  // 生产环境publicPath配置
  publicPath: process.env.NODE_ENV === 'production' ? '/vue2-sub-app/' : '/'
})

主应用修改

  • main.js
import { registerMicroApps, start } from 'qiankun'
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'

const app = createApp(App)

// 注册子应用
registerMicroApps([
  {
    name: 'vue-sub-app', // 子应用名称,与子应用的package.json中的name一致
    entry: '//localhost:3001', // 子应用入口
    container: '#subapp-container', // 子应用挂载节点
    activeRule: '/vue-sub-app', // 子应用路由前缀
    props: {} // 主应用传递给子应用的数据
  },
  {
    name: 'vue2-sub-app',
    entry: '//localhost:3002',
    container: '#subapp-container',
    activeRule: '/vue2-sub-app',
    props: {
      enableReact19Features: true
    }
  }
])

// 启动qiankun
start()
app.use(router)
app.mount('#app')
  • App.vue
<script setup>
import HelloWorld from "./components/HelloWorld.vue";

const goVue = () => {
  location.href = "/vue-sub-app";
};

const goVue2 = () => {
  location.href = "/vue2-sub-app";
};
</script>

<template>
  <div>
    <h2>微前端</h2>
    <!-- <button @click="goVue2">vue2</button>
    <button @click="goVue">vue3</button> -->
    <router-link to="/vue2-sub-app">vue2</router-link>
    <router-link to="/vue-sub-app">vue3</router-link>
    <!-- 子应用渲染 -->
    <div id="subapp-container"></div>
  </div>
</template>

手动实现qiankun

实现registerMicroApps方法

let _apps = [];
// 保存注册的路由
export const registerMicroApps = (apps) => {
  _apps = [...apps]
}

实现start方法

  • 监听路由变换,取出对应的app配置
  • 根据配置的路径,加载页面资源
  • 执行js方法,将dom节点挂载到父页面
let prev = '';
let next = '';
const start = () => {
    const rawPushState = window.history.pushState;
    window.history.pushState = function (...args) {
        prev = window.location.pathname; // 记录上次路由
        rawPushState.apply(window.history, args); // 执行原生方法
        next = window.location.pathname; // 记录当前路由
        handleRoute()
    }
}
const handleRoute = async () => {
   // 上一个子应用执行销毁
   const prevApp = _apps.find(item => item.activeRule === prev);
   if (prevApp) {
    await prevApp.unmount();
   }
  
  // 取出当前app
  const path = window.location.pathname;
  const app = _apps.find(item => item.activeRule === path);
  if (!app) return
  
  const { template, execScripts } = await importHTML(app.entry)
  const container = document.querySelector(app.container)
  container.appendChild(template)
  window.__POWERED_BY_QIANKUN__ = true // 设置变量,让子应用按要求渲染
  const appExports = await execScripts(); // 执行子应用的js
  
  // 设置子应用生命周期函数
  app.bootstrap = appExports.bootstrap;
  app.mount = appExports.mount;
  app.unmount = appExports.unmount;
  
  // 执行生命周期函数
  await app.bootstrap?.()
  await app.mount?.({
    container: document.querySelector(app.container),
  })
}

// 
const importHTML = async url => {
  // 加载子页面
  const html = await fetch(url).then(res => res.text())
  const template = document.createElement('div')
  template.innerHTML = html
    
  // 取出html中的js并加载
  const scripts = template.querySelectorAll('script')
  const getExternalScripts = async () => {
    return Promise.all([...scripts].map(async script => {
      const src = script.getAttribute('src')
      if (!src) {
        return script.innerHTML
      } else {
        return fetch(src.startsWith('http') ? src : `${url}${src}`).then(res => res.text())
      }

    })
    )
  }
  
  // 执行子页面js
  const execScripts = async () => {
    const scripts = await getExternalScripts();
    const module = { exports: {} }
    const exports = module.exports
    scripts.forEach(code => {
      eval(code)
    })
    return module.exports
  }
  return {
    template,
    getExternalScripts,
    execScripts
  }
}

最后

通过手动实现简化版qiankun,我们深入理解了微前端的核心原理;对于JS沙箱和样式隔离还要进一步了解。