微前端从理论到实践

1,944 阅读9分钟

微前端

日常说一下什么是微前端先。可能话有点多,如果不想看直接直接看代码实现

single-spa实践--------github地址

qiankun实践-------github地址

什么是微前端和为什么要用

微前端的理念就是是借鉴后端微服务

微服务是一种用于构建应用的架构方案。微服务架构有别于更为传统的单体式方案,可将应用拆分成多个核心功能。每个功能都被称为一项服务,可以单独构建和部署,这意味着各项服务在工作(和出现故障)时不会相互影响。

什么?还是不知道什么是微前端?好吧,其实我也不是太了解。所以我就从网上找的一些资料总结加上我自己的理解说一下。

    假如,你开了一家组装电子产品的小工厂,然后作着作着发现,赚钱了(什么?开工厂没赚到钱?那想什么扩张,老老实实做好自己的就可以了,别想太多了),可以升级一下。于是你开始招人,自己不仅组装,有一些配件还要自己生产。但是问题来了,工厂就这么大,招人太多就会显得太挤但是工厂附近又没有地方了,而且有的技术人员时负责组装配件,有的负责生产配件,然后混在一起工作,这样又不太好。并且工厂又小,有时候那些原材料运过来都容易搞混。这样就会让整个工作流程弄混乱,反倒会影响到效率。


下面我们换微前端的思想重新走一趟。

    还是你开了一家组装电子产品类的小工厂,又赚钱了,好像可以扩张搞点升级了。工厂附近没有地方可以扩张了,我们就选一个离工厂稍微远一点的地方开一个负责生产配件的工厂,而且将不同的技术人员分开,确认原材料/工作流程这些不会混在一起。这样我们又可以组装电子产品买钱,而且生产的配件不仅可以供应给主工厂,还可以独立运营去卖给其他商家。然后又有钱去开其他类型的工厂了。


我们换成开发过程中的简单说一下

    上面说的最初的主工厂就是我们的原来的主项目,如果主项目的业务并不多,那就没有必要去用到微前端,因为只会增加开发成本而已。工厂附近没有地方就是形容主项目过于庞大,不好维护了。我们需要将主项目拆分成多个子项目去维护。生产配件的工厂则是我们的子项目,而且我们的子项目可以用不同于主项目的技术栈(子项目之间也可以用不同的技术栈像Vue、React什么的,不会互相影响,这就是可以拥有不同的技术人员,可以物尽其用),独立运营就是指我们的子项目也可以独立部署访问


就像上面说的,只有当我们的主项目过于庞大,庞大到不好维护才需要去使用到微前端,不然将一个小的主项目拆分成更小的更多的子项目,只会让你的开发工作变得更加繁琐

微前端的各种实现方式

微前端有很多种实现方式,像可以使用iFrame、npm包引入的思想、single-spa/qiankun等,他们各有优劣,这里也不一一细举,大家要是喜欢可以自行查找,下面是我找的几篇文章,喜欢的可以去看一下。

实施微前端的六种方式

关于微前端你必须了解的三种实现方式

实施微前端的六种方式(上)

实话说得好,光说不练假把式,下面进入实践环节(以现在比较成熟的两种为例子single-spa和阿里的qiankun,其实iFrame也算成熟了,但是我个人觉得没有下面这两个好就没有去实践了)

single-spa

首先直接上官网链接 single-spa(这个如果进不去就等一会儿,有点卡)

还有我自己single-spa项目实践的github地址

创建项目

首先创建三个项目,一个主项目(father-vue)和两个子项目(child-vue和child-react)。这里三个项目用什么框架都可以。什么,没有听过vue和react,这个......,那推荐先去看看vue吧,看完vue的话可以看看从Vue到React/Redux的学习,小黑的我一步一步过来(react或者vue知道一个就可以,另外一个是我用来做演示的)

vue create father-vue
vue create child-vue
npx create-react-app child-react

目录结构如下: single-spa官网提供了一个插件(或者说命令行脚本),可以直接安装single-spa-vue并修改项目配置文件,你的项目适用于一个single-spa项目或是一个子应用。

vue add single-spa

这个CLI(控制台命令行接口)插件将会做下面的事情:

  • 修改webpack配置,从而使你的项目适用于一个single-spa项目或是一个子应用。
  • 安装single-spa-vue.
  • 修改你的main.js或main.ts文件,从而使你的项目适用于一个single-spa项目或是一个子应用。
  • 添加set-public-path.js,从而有序地使用systemjs-webpack-interop来设置你的应用的public path。

不过在下面的项目我并不会去这样做,这种白给的我不要(其实我已经试过一次这种,真的太简单了/牛逼,不过学习东西肯定要手写一下)。

child-vue

安装single-spa-vue npm install single-spa-vue -s 修改main.js文件

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

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

//将原来的渲染封装一层,方便复用
const vueOptions = {
    //挂载到父项目的哪个元素
    el: "#single-spa-vue",
    router,
    render: h => h(App)
};

const vueLifecycles = singleSpaVue({
    Vue,
    appOptions: vueOptions
});

//当只作为自己运行,不是作为子项目的时候
if (!window.singleSpaNavigate) {
    delete vueOptions.el;
    //挂载到项目本身
    new Vue(vueOptions).$mount('#app');
} else {
    //当作为子项目的时候,需要这个来修改加载路径,不然子项目加载js时会以父项目的端口去加载
    //这里如果报错的话,有可能是eslint的问题,要不就加eslint注释,要不就把eslint关了
    __webpack_public_path__ = "http://localhost:9999/"
}


//single-spa的规定/协议 需要导出的三个方法
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

在根目录下新增vue.config.js文件

module.exports = {
    devServer: {
        port: 9999,
    },
    configureWebpack: {
        devtool: 'none', // 不打包sourcemap
        output: {
            library: "singleVue", // 导出名称
            libraryTarget: "window", //挂载目标
        }
    },
}

router/index.js

const router = new VueRouter({
    mode: 'history',
    base: '/vue',//这里需要增加一个前缀,为了父项目引用的时候使用
    routes
})

我还简化一些vue文件的布局,这里就不写了 子项目就改造完成了,虽然还有一些问题,不过等全部完成再一起说。下面是react子项目的改造,如果不想看可以先跳到父项目,先看父项目的改造

child-react

这里不会讲react的教程

安装single-spa-react npm install single-spa-react

修改配置文件,修改react配置文件需要安装一个插件npm install react-app-rewired,然后创建一个config-overrides.js文件

module.exports = {
    webpack: (config) => {
    	//挂载名
        config.output.library = "singleReact";
        //挂载对象
        config.output.libraryTarget = "window";
        //文件加载前缀
        config.output.publicPath = "http://localhost:10000/";
        return config;
    },
    devServer: (configFunction) => {
        return function (proxy, allowedHost) {
            const config = configFunction(proxy, allowedHost)
            config.port = '10000'
            return config;
        }
    }
}

根目录增加一个.env文件来修改端口号

PORT=10000
WDS_SOCKET_PORT=10000

还要修改package.json里面的一部分代码

    "scripts": {
        "start": "react-app-rewired start",//react-app-rewired本来是react-scripts
        "build": "react-app-rewired build",
        "test": "react-app-rewired test",
        "eject": "react-app-rewired eject"
    },

修改index.js文件

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


function render () {
    ReactDOM.render(
        <React.StrictMode>
            <App />
        </React.StrictMode>,
        document.getElementById('single-spa-react')
    );
}

if (!window.singleSpaNavigate) {
    //挂载到项目本身
    ReactDOM.render(
        <React.StrictMode>
            <App />
        </React.StrictMode>,
        document.getElementById('root')
    );
} else {
}

export async function bootstrap (props) {
}

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

export async function unmount (props) {
    ReactDOM.unmountComponentAtNode(document.getElementById('single-spa-react'))
}

我这里修改了一下app.js文件是为了演示路由的跳转,记得安装npm install react-router-dom

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

function App() {
  return (
    <BrowserRouter basename="/react">
      <div>我是react页面</div>
      <Link to="/">react根页面</Link>
      <Link to="/about">react的about页面</Link>
      <Route
        exact
        path="/"
        render={() => {
          return (
            <div className="App">
              <header className="App-header">
                <img src={logo} className="App-logo" alt="logo" />
                <p>
                  Edit <code>src/App.js</code> and save to reload.
                </p>
                <a
                  className="App-link"
                  href="https://reactjs.org"
                  target="_blank"
                  rel="noopener noreferrer"
                >
                  Learn React
                </a>
              </header>
            </div>
          )
        }}
      ></Route>
      <Route
        path="/about"
        render={() => {
          return <h1>我是react的about页面</h1>
        }}
      ></Route>
    </BrowserRouter>
  )
}

export default App

father-vue

安装single-spa npm install single-spa --save -d

修改main.js文件

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

// 动态加载脚本的方法
const runScript = async (url) => {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = resolve;
        script.onerror = reject;
        const firstScript = document.getElementsByTagName('script')[0];
        firstScript.parentNode.insertBefore(script, firstScript);
    });
};

//注册微前端服务 第一个子应用
registerApplication(
    // 子应用名称,就是我们vue.config.js里面的导出名称
    'singleVue',
    async () => {
        //加载子项目的js模块,目前这里是先写死,后面会提到如何改造这个
        await runScript('http://127.0.0.1:9999/js/chunk-vendors.js');
        await runScript('http://127.0.0.1:9999/js/app.js');
        return window.singleVue;
    },
    // 路径前面加上 /vue 
    location => location.pathname.startsWith('/vue'),
);
registerApplication(
    // 子应用名称,就是我们vue.config.js里面的导出名称
    'singleReact',
    async () => {
        //加载子项目的js模块,目前这里是先写死,后面会提到如何改造这个
        //顺序不能错,而且react的没有优化前有点恶心,0.chunk.js这个可能会变,所以你如果控制台报错,就看看子项目加载的js文件和下面的是不是一致
        await runScript('http://127.0.0.1:10000/static/js/bundle.js');
        await runScript('http://127.0.0.1:10000/static/js/0.chunk.js');
        await runScript('http://127.0.0.1:10000/static/js/main.chunk.js');
        return window.singleReact;
    },
    // 路径前面加上 /vue 
    location => location.pathname.startsWith('/react'),
);
start();
new Vue({
    router,
    render: h => h(App)
}).$mount('#app')

app.vue文件

<template>
    <div id="app">
        <div id="nav">
            <router-link to="/">Home</router-link> |
            <router-link to="/vue">vue</router-link>|
            <router-link to="/react">react</router-link>
        </div>
        <router-view />
        //子项目各自对应节点
        <div id="single-spa-vue"></div>
        <div id="single-spa-react"></div>
    </div>
</template>

看看效果

single-spa的问题

上面我说过single-spa其实还存在一些问题,下面我举一些例子

  • css没有隔离机制,就是css样式会互相影响,上面的例子我忘记去弄一下,这里就不重新搞了,react搞得我好累
  • js动态加载问题,上面已经体现出来了,我们不可能写死js文件路径
  • 应用通信,我在网上大概找了一圈,没有找到什么比较好的通信方法,如果有找到同学可以留言一下。

这些,都有方法解决,如果想知道上面的问题在single-spa应该怎么解决,推荐一篇掘金文章Single-Spa + Vue Cli 微前端落地指南

这里我就不说怎么解决,我们直接用另一个微前端框架,因为他已经帮我们解决了这些问题,这个框架就是下面要说的qiankun。

qiankun

首先直接上官网链接 qiankun

还有我自己qiankun项目实践的github地址

创建项目

这个和上面的一样

vue create father-vue
vue create child-vue
npx create-react-app child-react

child-vue

同样创建一个vue.config.js文件

module.exports = {
    devServer: {
        port: 9999,
        headers:{
            // 由于 qiankun 是通过 fetch 去获取微应用的引入的静态资源的,所以必须要求这些静态资源支持跨域
            "Access-Control-Allow-Origin":"*"
        }
    },
    configureWebpack: {
        devtool: 'none', // 不打包sourcemap
        output: {
            library: "qiankunVue", // 导出名称
            libraryTarget: "window", //挂载目标
        }
    },
}

这里有一个不同点,就是需要配置跨域处理,因为官网也说明了这一点

微应用静态资源一定要支持跨域吗?
是的。
由于 qiankun 是通过 fetch 去获取微应用的引入的静态资源的,所以必须要求这些静态资源支持跨域。
如果是自己的脚本,可以通过开发服务端跨域来支持。如果是三方脚本且无法为其添加跨域头,可以将脚本拖到本地,由自己的服务器 serve 来支持跨域。

子应用不用像single-spa那样去引用single-spa-vue,不过同样只需要导出三个方法 main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

// 方便控制当子应用从父应用卸载时用
let instance = null;

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

// qiankun用来判断是否为独立运行
if (!window.__POWERED_BY_QIANKUN__) {
    // 当为独立运行的时候
    render();
}
if (window.__POWERED_BY_QIANKUN__) {
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

export async function bootstrap (props) {
    // 可以接收从父应用传过来的参数
    console.log(props)
}

export async function mount (props) {
    console.log(props)
    render();
}

// 卸载子应用
export async function unmount (props) {
    instance.$destroy();
    instance = null;
}

router/index.js

const router = new VueRouter({
    mode: 'history',
    base: '/vue',//这里需要增加一个前缀,为了父项目引用的时候使用
    routes
})

写到这里的时候,我在网上看到另一个改路由的方法,这里也放进来,但是我就不去尝试了 在main.js里面去修改

router = new VueRouter({
	base: window.__POWERED_BY_QIANKUN__ ? '/app1' : '/',
	mode: 'history',
	routes,
});

vue子应用写到这里就可以,同样可以跳到父应用那里先看了

child-react

index.js

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

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

if (!window.__POWERED_BY_QIANKUN__) {
    //挂载到项目本身
    render();
}

export async function bootstrap (props) {
}

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

export async function unmount (props) {
    ReactDOM.unmountComponentAtNode(document.getElementById('root'))
}

config-overrides.js修改成可以跨域

devServer: (configFunction) => {
    return function (proxy, allowedHost) {
        const config = configFunction(proxy, allowedHost)
        config.headers = {
            "Access-Control-Allow-Origin": "*"
        }
        config.port = '10000'
        return config;
    }
}

剩下的和上面的single-spa-react配置一样

father-vue

安装qiankun依赖 npm install qiankun

main.js修改

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerMicroApps, setDefaultMountApp, start } from "qiankun"
//registerMicroApps还有其他具体参数,例如一些微应用的生命周期,具体请看官方文档
registerMicroApps(
    [
        {
            // 应用名称
            name: 'qiankunVue',
            // 加载的对应html的js文件
            entry: '//localhost:9999',
            // 挂载到哪里
            container: '#qiankunVue',
            // 路径
            activeRule: '/vue',
            // 传参 子应用可以接受
            props: {
                data: '我是父应用传的数据'
            }
        },
        {
            // 应用名称
            name: 'qiankunReact',
            // 加载的js
            entry: '//localhost:10000',
            // 挂载到哪里
            container: '#qiankunReact',
            // 路径
            activeRule: '/react',
            // 传参 子应用可以接受
            props: {
                data: '我是父应用传的数据'
            }
        },
    ]
)
// 设置默认子应用
// setDefaultMountApp("/vue");

//start有很多其他配置项,推荐去官网看看,例如上面提到的css隔离机制
start({
    // 取消预加载
    prefetch:false
})
//注意 我这里改了绑定的对象下面会讲到
new Vue({
    router,
    render: h => h(App)
}).$mount('#main-app')

app.vue文件修改id

<template>
    <div id="main-app">
        <div id="nav">
            <router-link to="/">Home</router-link> |
            <router-link to="/about">主应用的About</router-link>|
            <router-link to="/vue">Vue</router-link>|
            <router-link to="/react">React</router-link>
        </div>
        <router-view />
        <div id="qiankunVue"></div>
        <div id="qiankunReact"></div>
    </div>
</template>

index.html修改id

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
      <!-- 这里改了id-->
    <div id="main-app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

注意,我上面父应用的挂载的元素id全改为了mian-app,因为我控制台报了下面的错误。其实也可以在子应用改,只是当时敲的时候改了父的而已,我上网找了一下 看看效果 其实qiankun还有其他api,具体想了解请移步官网查看。

这里再放一次两个项目的github地址。如果觉得文章稍微有点作用,帮忙点个👍吧,谢谢大家了。如果发现里面哪里有问题也请帮忙指点一下🚀。

single-spa

qiankun

参考文档

Single-Spa + Vue Cli 微前端落地指南

QianKun2.0.11版本在Vue中的使用