入门微前端,从single-spa到qiankun 【Sort-Plan】

·  阅读 441

截屏2022-05-23 下午2.36.39.png

1.微前端

1.1微前端的优势

  • 无技术栈限制:主框架不限制接入应用的技术栈,子应用具备完全自主权
  • 独立开发,独立部署,子应用的仓库独立,前后端可独立进行开发,部署完成后主框架自动完成同步更新
  • 独立运行时,每个子应用之间状态隔离,运行时状态不共享

微前端架构解决方案大概分成两类场景

  • 单实例:即同一时刻,只要一个子应用被展示,子应用具备一个完整的应用生命周期,通常基于url的变化来做子应用的切换
  • 多实例:同一时刻可展示多个字应用。通常使用web components方案来做子应用封装,子应用更像是一个业务组件而不是应用。

1.2实现微前端方案

2018 single-spa 是一个用于前端微服务化的 javascript 前端解决方案,(本身没有处理样式隔离,js 执行隔离)实现了路由的劫持和应用加载

2019 qiankun 基于 single-spa 提供了更加开箱即用的 api(single-spa+sandbox+import-html-entry)做到了,技术战无关,并且接入简单

总结:子应用可以独立部署,运行时动态加载主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap,mount,unmount 方法)

2.single-spa

2.1解决的问题

single-spa 实现了路由劫持和应用加载的功能。 Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。 使用 single-spa 进行前端架构设计可以带来很多好处,例如:

  • 在同一页面上使用多个前端框架而不用刷新页面] (react,vue等 )
  • 独立部署每一个单页面应用
  • 新功能使用新框架,旧的单页应用不用重写可以共存
  • 改善初始加载时间,延迟加载代码

2.2实践

截屏2022-05-23 下午2.57.42.png

直接使用命令创建项目:

vue create main-vue(主应用)

vue create child-vue(子应用)

子应用内部

子应用需要抛出这几个方法 bootstrap,mount,unmount

single-spa 子应用需要导入一些方法

因为是在vue项目中,所以需要安装single-spa-vue npm install single-spa-vue. src/main.js

import Vue from 'vue';
import App from './App.vue';
import router from './router';
import singleSpaVue from 'single-spa-vue';

Vue.config.productionTip = false;

const appOptions = {
    el: '#app', //挂载到父应用中的id为vue的标签中
    router,
    render: (h) => h(App),
};

const vueLifeCycle = singleSpaVue({
    Vue,
    appOptions,
});

// 如果是父应用引用我
if (window.singleSpaNavigate) {
    // eslint-disable-next-line no-undef
    __webpack_public_path__ = 'http://localhost:10000/';
}

if (!window.singleSpaNavigate) {
    delete appOptions.el;
    new Vue(appOptions).$mount('#app');
}
// 协议接入 子应用定义了协议,父应用调用这些方法
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
复制代码

需要父应用加载子应用,将子应用打包成一个个lib去给父应用使用

vue.config.js

const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({
    transpileDependencies: true,
    configureWebpack: {
        output: {
            library: 'singleVue',
            libraryTarget: 'umd',
        },
        devServer: {
            port: 10000,
        },
    },
});
复制代码

router/index.js

const router = new VueRouter({
    mode: 'history',
    // base: process.env.BASE_URL,
    base: '/vue',
    routes,
});
复制代码

主应用内部

src/main.js

import Vue from 'vue';
import App from './App.vue';
import router from './router';
import { registerApplication, start } from 'single-spa';

Vue.config.productionTip = false;

async function loadScript(url) {
    return new Promise((resolve, reject) => {
        let script = document.createElement('script');
        script.src = url;
        script.onload = resolve;
        script.onerror = reject;
        document.head.appendChild(script);
    });
}

registerApplication(
    'myVueApp',
    async () => {
        console.log('加载模块');
        await loadScript(`http://192.168.31.83:10000/js/chunk-vendors.js`);
        await loadScript(`http://192.168.31.83:10000/js/app.js`);
        return window.singleVue; // bootstrap mount unmount
    },
    (location) => location.pathname.startsWith('/vue'),
    // 用户切换到/vue的路径下,我需要加载刚才定义的子应用
);

start();

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

复制代码

App.js

<template>
    <div id="app">
        <router-link to="/vue">加载vue应用</router-link>
    </div>
</template>

<script>
export default {
    name: 'App',
};
</script>

<style></style>
复制代码

router/index.js

const router = new VueRouter({
    mode: 'history',
    base: process.env.BASE_URL,
    routes,
});
复制代码

2.3页面效果

  • 子应用页面:

截屏2022-05-23 下午3.28.46.png

  • 主应用页面: 截屏2022-05-23 下午3.26.35.png

  • 主应用点击按钮后:

截屏2022-05-23 下午3.29.12.png

3.qiankun

样式隔离

<body>
    <p>hello word</p>
    <div id="shadow"></div>

    <script>
        let shadowDOM =  shadow.attachShadow({mode:'closed'});// 外界无法访问 shadowdom

        let pElem = document.createElement('p');
        pElem.innerHTML = 'hell';

        let styleElm = document.createElement('style');

        styleElm.textContent = `p{color:red}`;
        shadowDOM.appendChild(styleElm);
        shadowDOM.appendChild(pElem);
        document.body.appendChild(pElem)
    </script>
</body>
复制代码

截屏2022-05-23 下午4.14.45.png

qiankun 可以确保子应用之间样式隔离,主应用跟子应用之间的样式冲突需要手动解决,以 antd 为例,主应用可以通过设置 prefixCls 样式前缀的方式避免冲突

挂在body上的话,是没有样式隔离的

document.body.appendChild(pElem)
复制代码

如果上面的代码加上这句:

截屏2022-05-23 下午4.22.39.png

js隔离

  • 单应用-快照沙箱
  • 多应用- proxy代理沙箱
  1. 如果应用加载刚开始的时候加载a应用 window.a 跳转到b应用 (window.a也可以被获取到)

  2. 单个应用切换,怎么实现隔离,a切换到b,创造一个干净的环境给子应用使用,

  3. 当切换时,可以选择丢弃属性和恢复属性 js沙箱 和 proxy

     //第一种,快照沙箱,之前拍一张,之后拍一张,做对比,将区别保存保存起来,再回到一年前
    复制代码
<body>
   <!-- <p>hello word</p> -->
   <div id="shadow"></div>

   <script>
      
       class SnapshotSandbox{
           constructor () {
               this.proxy = window; // window属性
               this.modifyPropsMap = {};// 记录在window上的修改
               this.active(); // 激活状态
           }

           active() {
               this.windowSnapshot = {};// 拍照
               for(const prop in window) { // 遍历window的属性
                   if (window.hasOwnProperty(prop)) {
                       this.windowSnapshot[prop] = window[prop]; // 拍个照
                   }
               }
               Object.keys(this.modifyPropsMap).forEach(p => {
                   window[p] = this.modifyPropsMap[p]; // 恢复之前保存的属性
               })
           }

           inactive() { // 失活状态
               // 非激活状态,要把属性,有变化的地方先存储起来
               for(const prop in window) {
                   if (window.hasOwnProperty(prop)) {
                       if (window[prop] !== this.windowSnapshot[prop]) {
                           this.modifyPropsMap[prop] = window[prop];  // 不相同的地方,存储起来
                           window[prop] = this.windowSnapshot[prop]; // 此时的状态,拍一个快照
                       }
                   }
               }
           }
       }
       let snadbox = new SnapshotSandbox();

       // 应用的运行 从开始到结束,切换后不会影响到全局
       ((window) => {
           window.a = 1;
           window.b = 2;

           console.log(window.a,window.b);
           snadbox.inactive();
           console.log(window.a,window.b);
           snadbox.active();
           console.log(window.a,window.b);
       })(snadbox.proxy)  // snadbox.proxy 就是window

   </script>
</body>

复制代码

如果是多应用就不能使用这种方法了 需要使用es6 proxy

代理沙箱,可以实现多应用沙箱,把不同的应用用不同的代理

截屏2022-05-23 下午4.15.43.png

实践

截屏2022-05-23 下午3.50.54.png

vue 主应用 qiankun-base

src/main.js

import Vue from 'vue';
import App from './App.vue';
import router from './router';
import Element from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
// import './styles/element-variables.scss';

import { registerMicroApps, start } from 'qiankun';

Vue.config.productionTip = false;
Vue.use(Element);

const apps = [
    {
        name: 'vueApp',
        entry: '//localhost:10000', //默认会加载这个html,解析里面的js 动态执行
        // 子应用需要解决跨域
        container: '#vue', // 容器
        activeRule: '/vue', // 激活路径
        props: { a: 1 },
    },
    {
        name: 'reactApp',
        entry: '//localhost:20000', //默认会加载这个html,解析里面的js 动态执行
        // 子应用需要解决跨域
        container: '#react',
        activeRule: '/react',
    },
];

registerMicroApps(apps); // 注册应用

start(); // 开启

// 点击按钮再加载子应用
// start({
//     prefetch: false, // 是否开启预加载
// });

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

复制代码

src/App.js

<template>
    <div>
        <!-- 基座可以放自己的路由 -->
        <el-menu :router="true" mode="horizontal">
            <el-menu-item index="/">Home</el-menu-item>
            <el-menu-item index="/vue">vue</el-menu-item>
            <el-menu-item index="/react">react</el-menu-item>
        </el-menu>

        <router-view></router-view>
        <!-- 基座可以放自己的路由,也可以引用其他子应用 -->
        <div id="vue"></div>
        <div id="react"></div>
    </div>
</template>

复制代码

vue 子应用 qiankun-vue

src/main.js

/* eslint-disable */
import Vue from 'vue';
import App from './App.vue';
import router from './router';

// Vue.config.productionTip = false

// new Vue({
//   router,
//   render: h => h(App)
// }).$mount('#app')

let instance = null;

function render() {
    instance = new Vue({
        router,
        render: (h) => h(App),
    }).$mount('#app'); // 这个是挂载到自己的html,基座会拿到挂载后的html中
}
if (window.__POWERED_BY_QIANKUN__) {
    // eslint-disable-next-line
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

if (!window.__POWERED_BY_QIANKUN__) {
    render();
}

// 子组件的协议
// eslint-disable-next-line
export async function bootstrap(props) {}

export async function mount(props) {
    console.log('vue 启动');
    render(props);
}
// eslint-disable-next-line
export async function unmount(props) {
    console.log('vue 卸载');
    instance.$destroy();
}

// 1.49

复制代码

src/App.js

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view/>
  </div>
</template>
复制代码

vue.config.js

module.exports = {
    devServer: {
        port: 10000,
        headers: {
            'Access-Control-Allow-Origin': '*',
        },
    },
    configureWebpack: {
        output: {
            library: 'vueApp',
            libraryTarget: 'umd',
        },
    },
};

复制代码

router/index.js

const router = new VueRouter({
    mode: 'history',
    // base: process.env.BASE_URL,
    base: '/vue',
    routes,
});
复制代码

react 子应用 qiankun-react

src/index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));

function render() {
    root.render(
        <React.StrictMode>
            <App />
        </React.StrictMode>,
    );
}

if (window.__POWERED_BY_QIANKUN__) {
    // eslint-disable-next-line
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

if (!window.__POWERED_BY_QIANKUN__) {
    render();
}

export async function bootstrap(props) {}

export async function mount(props) {
    console.log('react 启动');
    render();
}
// eslint-disable-next-line
export async function unmount(props) {
    console.log('react 卸载');
    root.unmount();
}

reportWebVitals();

复制代码

src/App.js

import logo from './logo.svg';
import './App.css';
import { BrowserRouter, Route, Link, Routes } from 'react-router-dom';

function App() {
    return (
        <BrowserRouter basename='/react'>
            <Link to='/'>首页</Link>
            <Link to='/about'>关于</Link>

            <Routes>
                <Route
                    path='/'
                    exact
                    element={<div>hhhhh王胖子主页</div>}></Route>

                <Route
                    path='/about'
                    exact
                    element={<div>about页面哦</div>}></Route>
            </Routes>
        </BrowserRouter>
    );
}

export default App;

复制代码

按照react项目中,修改webpack配置的插件: react-app-rewired

并修改package.json配置:

"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject"
  },
复制代码

新增:config-overrides.js文件

module.exports = {
    webpack: (config) => {
        config.output.library = 'reactApp';
        config.output.libraryTarget = 'umd';
        config.output.publicPath = 'http://localhost:20000/';
        return config;
    },
    deServer: (configFunction) => {
        return function (proxy, allowedHost) {
            const config = configFunction(proxy, allowedHost);
            config.port = '20000';
            config.headers = {
                'Access-Control-Allow-Origin': '*',
            };
            return config;
        };
    },
};
复制代码

新增.env文件:

PORT = 20000;
WDS_SOKECT_PORT = 20000;
复制代码

页面效果

vue子应用页面:

截屏2022-05-23 下午3.55.34.png

react子应用页面:

截屏2022-05-23 下午3.55.19.png vue主应用页面:

截屏2022-05-23 下午3.55.42.png

vue -> react:

截屏2022-05-23 下午3.56.09.png

react -> vue:

截屏2022-05-23 下午3.56.18.png

4. 微前端架构实践中的问题

主框架的定位:导航路由+资源加载框架,而要实现这样一套架构需要解决一些问题

4.1 路由系统及future state

我们在一个实现了微前端内核的产品中,正常访问一个子应用的页面时,可能会有这样一个链路:

  1. 访问 app.pay.com
  2. 点击导航中的某个子产品的链接app.pay.com/subApp
  3. subApp渲染并默认redirect到list页面app.pay.com/subApp/list
  4. 查看列表某一项信息app.pay.com/subApp/:id/…

此时浏览器的地址可能是app.pay.com/subApp/123/… ,想象下此时我们手动刷新一下浏览器,会发生什么?

由于子应用要都是lazy load的,当浏览器重新刷新时,主框架的资源会被重新加载,同时异步加载子应用的静态资源,由于此时主应用的路由系统已经激活,但子应用的资源可能还没有完全加载完毕,从而导致路由注册表里面发现没有能匹配子应用的/subApp/123/detail的规则,这时候会导致条notfound或者直接路由报错

这个问题在所有lazy load方式加载子应用的方案中都会碰到,future state

解决的思路,我们需要设计这样一套路由机制:

主框架配置子路由的路径为:

subApp:{
url:'/subAp/**',
entry:'./subApp.js'
}
复制代码

则当浏览器的地址为/subAp/abc时,

  1. 框架需要先加载entry资源,待entry资源加载完毕,
  2. 确保子应用的路由系统注册进框架之后,
  3. 再去由子应用的路由系统接管url change事件,
  4. 同时在子应用切出时,主框架需要触发相应的destroy事件,
  5. 子应用监听到该事件时,调用自己的卸载方法卸载应用

如 React 场景下 destroy = () => ReactDOM.unmountAtNode(container)。

要实现这样一套机制,可以自己去劫持url change时间从而实现自己的路由系统,也可以基于社区react-route在v4之后实现的,需要复写一部分路由发现逻辑single-spa

app entry

解决了路由的问题后,主框架和子应用集成的方式,也会成为一个需要关注的技术决策

构建时组合 VS 运行时组合

微前端架构模式下,子应用打包的方式由两种

构建时打包:

子应用通过package registry (也可以是npm package) 的方式,与主应用一起打包发布

  • 优点:主应用和子应用之间可以做答辩优化,依赖共享

  • 缺点:主子应用之间产品工具链耦合,工具链也是技术栈的一部分

运行时打包:

子应用自己构建打包,主应用运行时动态加载子应用资源

  • 优点:完全解耦,子应用完全和技术栈无关

  • 缺点:多出运行时的复杂度和overhead

要真正实现技术栈无关和独立部署两个目标,大部分场景下需要使用运行时加载的方案。

js entry VS html entry

  • js entry :子应用将资源打成一个entry script,比如single-spa的example中的方式。但是这个方式限制很多,比如要求子应用所以的资源打包到一个js bundle,包括css图片等,除了打包出来的体检庞大,资源的并行价值等特性也无法利用上

  • html entry: 更加灵活,直接将子应用打出来的html 作用入口,主框架可以通过fetch html的方式获取子应用的静态资源(涉及到跨域,需要配置),同时将html document作为子节点塞到主框架的容器中,这样不仅可以极大减少主应用的接入成本,子应用的开发方式和打包方式也基本不用跳转,而且解决子应用之间样式隔离的问题。

大概整理了一下这段时间学习微前端的一些笔记📒

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改