微前端框架qiankun的使用方法及原码实现

538 阅读5分钟

一. 介绍

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

1.1 什么是微前端

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

  • 技术栈无关 主框架不限制接入应用的技术栈,微应用具备完全自主权

  • 独立开发、独立部署 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 增量升级 在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时 每个微应用之间状态隔离,运行时状态不共享

1.2 qiankun 核心设计理念

🥄 简单

由于主应用微应用都能做到技术栈无关,qiankun 对于用户而言只是一个类似 jQuery 的库,你需要调用几个 qiankun 的 API 即可完成应用的微前端改造。同时由于 qiankun 的 HTML entry 及沙箱的设计,使得微应用的接入像使用 iframe 一样简单。

🍡 解耦/技术栈无关

微前端的核心目标是将巨石应用拆解成若干可以自治的松耦合微应用,而 qiankun 的诸多设计均是秉持这一原则,如 HTML entry、沙箱、应用间通信等。这样才能确保微应用真正具备 独立开发、独立运行的能力。

1.3 qiankun的特性

1. HTML Entry

qiankun 通过 HTML Entry 的方式来解决 JS Entry 带来的问题,让你接入微应用像使用iframe 一样简单。

2. 样式隔离

qiankun 实现了两种样式隔离

  • 严格的样式隔离模式,为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响

  • 实验性的方式,通过动态改写 css 选择器来实现,可以理解为 css scoped 的方式

3. 运行时沙箱

qiankun 的运行时沙箱分为 JS 沙箱样式沙箱

  • JS 沙箱:为每个微应用生成单独的 window proxy 对象,配合 HTML Entry 提供的 JS 脚本执行器 (execScripts) 来实现 JS 隔离,确保微应用之间 全局变量/事件不冲突;

  • 样式沙箱:通过重写 DOM 操作方法,来劫持动态样式和 JS 脚本的添加,让样式和脚本添加到正确的地方,即主应用的插入到主应用模版内,微应用的插入到微应用模版,并且为劫持的动态样式做了 scoped css 的处理,为劫持的脚本做了 JS 隔离的处理

1.4 为什么不是 iframe

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

二. 使用方法

1. 主应用中注册子应用:

(1) 在主应用安装qiankun库
yarn add qiankun 或 npm install qiankun -S
(2) 在入口文件(vue中的main.js或者react中的index.js)注册子应用
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'subapp-name1', // 名称
    entry: '//localhost:7100', // 子应用HTML入口
    container: '#yourContainer1', // 渲染到哪里
    activeRule: '/yourActiveRule1', // 路由匹配规则
  },
  {
    name: 'subapp-name2',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);

start();

2. 子应用

1. 在 src 目录新增 public-path.js, 并且在react的入口文件 index.js(vue的 main.js) 中引入:
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// __webpack_public_path__全局变量在package.json中设置
"eslintConfig": {
    ...,
    "globals": {
      "__webpack_public_path__": true
    }
 },
2. 为了避免根 id与其他的 DOM 冲突,需要限制查找范围。

React入口文件 index.js 修改

import './public-path'; // 导入
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

function render(props) {
  const { container } = props;
  ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}

// 如果全局变量不存在,则渲染到子应用本身的容器中
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

// 导出三个必要的生命周期函数
export async function bootstrap() {}

export async function mount(props) {
  render(props);
}

export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : 	document.querySelector('#root'));
}

Vue 入口文件 main.js 修改

import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

let router = null;
let instance = null;
function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

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

export async function bootstrap() {}
export async function mount(props) {
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
3. 修改 webpack 配置

React

安装插件 @rescripts/cli

const { name } = require('./package');

module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd'; // 打包出来的库文件必须是umd格式
    return config;
  },

  devServer: (_) => {
    const config = _;
      
    config.headers = {
      'Access-Control-Allow-Origin': '*', // 必须设置允许跨域
    };
      
    return config;
  },
};

修改package.json文件:

"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test",

Vue

修改打包配置(vue.config.js):

const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
    },
  },
};

三. 原码实现

1. 新建文件夹micro-app,然后在文件中新建index.js
import { rewriteRouter } from './rewrite-router.js'
import { handleRouter } from './handle-router.js'

let _apps = []

export const getApps = () => _apps

export const registerMicroApps = (apps) => {
    _apps = apps
}

exporst const start = () => {
    rewriteRouter()
    handleRouter() // 初始化路由
}
2. 新建rewrite-router.js
import { handleRouter } from './handle-router.js'

// 路由重写
let prevRoute = '' // 上一个路由
let nextRoute = window.location.pathname // 下一个路由

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

export const rewriteRouter = () => {
    // 1、监听路由变化		
    // 		hash路由监听 window.onhashchange
    
    // 		history路由监听
    // 			history.go、history.back、history.forward 使用popstate事件
    // 			pushState、replaceState需要通过重写的方式进行劫持
    window.addEventListener('popstate', () => {
        // popstate触发的时候,路由已经完成
        prevRoute = nextRoute
        nextRoute = window.location.pathname
        handleRouter()
    })
    
    const rawPushState = window.history.pushState
    window.history.pushState = (...args) => {
        // 导航前
        prevRoute = window.loacation.pathname
        rawPushState.apply(window.history, args) // 这是在真正改变历史记录
        nextRoute = window.location.pathname
        // 导航后
         handleRouter()
    }
    
    const rawReplaceState = window.history.replaceState
    window.history.replaceState = (...args) => {
        prevRoute = window.loacation.pathname
        rawReplaceState.apply(window.history, args)
        nextRoute = window.location.pathname
        handleRouter()
    }
}
3. 新建handle-router.js
import { getPrevRoute, getNextRoute } from './rewrite-router.js'
import { importHTML } from './import-html.js'

// 处理路由
export const handleRouter = async () => {
    const apps = getApps()
    // 获取上一个路由应用
    const prevApp = apps.find(item => {
        return getPrevRoute().startsWith(item.activeRule)
    })
    // 获取下一个路由应用
    const app = apps.find(item => getNextRoute().startsWith(item.activeRAule))
   
    // 如果有上一个应用,则先销毁
    if (prevApp) await unmount(prevApp)
    
    if (!app) return
    
    // 3、加载子应用
    // 请求获取子应用的资源:HTML、CSS、JS
    // const html = await fetch(app.entry).then(res => res.text())
    // const container = document.querySelector(app.container)
    
    // 此时dom已经挂载,但是页面看不见,原因:
    // 1.客户端渲染需要通过执行JavaScript来生成内容
    // 2.浏览器出于安全考虑,innerHTML中的script不会加载执行
    
    // container.innerHTML = html
    
    // 所以需要手动加载子应用的script
    // 执行 script中的代码
    // eval 或 new Function
    // qiankun使用的是import-html-entry库
    const { template, getExternalScripts, execScripts} = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)
    
    // 配置全局环境变量
    window.__POWERED_BY_QIANKUN__ = true
    window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + '/'
    
    const appExports = await execScripts()
    
    app.bootstrap = appExports.bootstrap
    app.mount = appExports.mount
    app.unmount = appExports.unmount
    
    await bootstrap(app)
    
    4、渲染子应用
}

// 导出三个生命周期函数
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)
    }))
}
4. 新建import-html.js
import {fetchResourse} from './fetch-resourse.js'

// 导出执行script后的HTML
export const importHTML = async (url) => {
    const html = await fetchResourse(url)
    const template = document.createElement('div')
    template.innerHTML = html
    
    const scripts = template.querySelectorAll('script')
    
    // 获取所有script标签的代码: [代码,代码]
    function getExternalScripts () {
        return Promise.all(Array.from(scripts).map(script => {
            const src = script.getAttribute('src')
            if (!src) {
                return Promise.resolve(script.innerHTML)
            } else {
                return fetchResourse(src.startsWith('http') ? src : `${url}${src}`)
            }
        }))
    }
    
    // 获取并执行所有的script标签代码
    async function execScripts () {
        const scripts = await getExternalScripts()
        
        // 手动构造一个CommonJS模块环境
        const module = { exports: {}}
        const exports = module.exports
        
        scripts.forEach(code => {
            // eval执行的代码可以访问外部变量
            eval(code)
        })
        return module.exports
    }
    return {
        template,
        getExternalScripts,
        execScripts
    }
}
5. 新建fetch-resourse.js
export const fetchResourse = (url) => fetch(url).then(res => res.text()) // 请求封装

后续会附上DEMO仓库地址,敬请期待~