微前端概念

什么是微前端

微前端可以把大型项目拆分成不同类型的子项目,子项目可以单独发布上线,解决项目中多个技术时无法进行融合。每个子项目是独立的,又能都挂载到主项目插座上。核心=>大项目拆分,拆后再合并。

为什么需要微前端?

  • 不同团队之间,技术栈可能不同
  • 项目开始周期点不同,技术栈不同,老项目代码无法重构
  • 每个团队负责的项目都想独立开发,单独部署。

为了解决以上这些问题,就出现了微前端概念。可以将一个应用划分为多个子应用,将子应用打包成独立的lib,然后挂载到window上。当路径切换时,加载不同的子应用。

怎么做微前端

最开始诞生的框架single-spa。single-spa是前端微服务化JavaScript解决方案,实现路由劫持和应用加载(没有实现样式隔离和js运行隔离)。
后来qiankun框架诞生,基于single-spa框架实现了开箱即用的api。(single-spa + sandbox + import-html-entry)

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

应用通信:

  • 基于URL来进行数据传递,但是传递消息能力弱
  • 基于CustomEvent实现通信
  • 基于props主子应用间通信
  • 使用全局变量、Redux进行通信

    公共依赖:

  • CDN - externals

  • webpack联邦模块

    变量隔离

    快照沙箱

    通过将window对象创建成快照保存下来。
    缺点:性能不佳,需要将window的所有属性都进行保存。不支持多实例代理
    可以使用于旧版本浏览器,做方案降级备选。
    1. class SnapShotSandBox {
    2. constructor() {
    3. // 将window保存到代理对象上
    4. this.proxy = window;
    5. this.snapshot = new Map();
    6. // 自动调用active方法
    7. this.active();
    8. }
    9. // 沙箱激活
    10. active() {
    11. for (const key in window) {
    12. this.snapshot[key] = window[key];
    13. }
    14. }
    15. // 沙箱销毁
    16. deactive() {
    17. for (let key in window) {
    18. if (window[key] !== this.snapshot[key]) {
    19. // 还原保存的原来属性数据
    20. window[key] = this.snapshot[key];
    21. }
    22. }
    23. }
    24. }

    代理沙箱

    使用es6的新对象Proxy,代理window对象。
    1. let defaultValue = {};
    2. class ProxySandbox {
    3. constructor() {
    4. this.proxy = null;
    5. this.active();
    6. }
    7. active() {
    8. this.proxy = new Proxy(window, {
    9. get(target, key) {
    10. if (typeof target[key] === "function") {
    11. return target[key].bind(target);
    12. }
    13. return defaultValue[key] || target[key];
    14. },
    15. });
    16. }
    17. deactive() {
    18. defaultValue = {}
    19. }
    20. }

    样式隔离

  1. css-modules:将css分模块
  2. shadow dom:新语法,将dom挂载到shadow-root节点下
  3. minicssExtract:webpack插件
  4. css-in-js

    子应用之间通信,观察者模式

    1. class CustomEvent{
    2. // 事件监听
    3. on(eventName, cb){
    4. window.addEventListener(name, (e)=>{
    5. cb(e)
    6. })
    7. }
    8. // 事件触发
    9. emit(eventName, data){
    10. const event = new CustomEvent(eventName, data);
    11. window.dispatchEvent(eventName)
    12. }
    13. }
    利用window的全局对象进行调用。可以实现子应用之间的互相通信。

    single-spa

    构建子应用

    构建spa-vue子应用

    使用vue-cli创建项目,默认选择路由router功能
    vue create spa-vue

配置main.js

  1. //首先安装single-spa-vue。yarn add single-spa-vue
  2. import { createApp, h } from "vue";
  3. import App from "./App.vue";
  4. import router from "./router";
  5. import singleSpaVue from "single-spa-vue";
  6. // 在非子应用中正常挂载应用
  7. if (!window.singleSpaNavigate) {
  8. // delete appOptions.el;
  9. createApp(App).use(router).mount("#app");
  10. }
  11. // 如果在父应用中引用的子引用,当路由发生调整时,做绝对路径设置
  12. if (window.singleSpaNavigate) {
  13. __webpack_public_path__ = "http://localhost:8001/";
  14. }
  15. const vueLifeCycle = singleSpaVue({
  16. createApp,
  17. appOptions: {
  18. render() {
  19. return h(App, {
  20. name: this.name,
  21. mountParcel: this.mountParcel,
  22. singleSpa: this.singleSpa,
  23. });
  24. },
  25. },
  26. handleInstance: (app) => {
  27. app.use(router);
  28. },
  29. });
  30. // 子应用必须导出 以下生命周期 bootstrap、mount、unmount
  31. export const bootstrap = vueLifeCycle.bootstrap;
  32. export const mount = vueLifeCycle.mount;
  33. export const unmount = vueLifeCycle.unmount;
  34. export default vueLifeCycle;

添加vue.config.js配置文件

vue.config.js主要作用:

  • 设置打包的名称和格式
  • 设置开发环境的端口号,设置支持跨域 Access-Control-Allow-Origin
    1. module.exports = {
    2. configureWebpack: {
    3. output: {
    4. library: "singleVue",
    5. libraryTarget: "umd",
    6. },
    7. devServer: {
    8. port: 8001,
    9. headers: {
    10. "Access-Control-Allow-Origin": "*",
    11. },
    12. },
    13. },
    14. };

    修改路由的基础路径

    由于在主模块下通过路由前缀进行判断需要加载的子模块,所以约定下子spa-vue的路由为/vue开头
    1. const router = createRouter({
    2. history: createWebHistory("/vue"),
    3. routes
    4. })

    构建spa-react子应用

    使用react脚手架创建项目,需要使用react18之前版本
    npx create-react-app spa-react

安装single-spa-react插件

yarn add single-spa-react

修改src/index.js配置

  1. import React from "react";
  2. import ReactDOM from "react-dom";
  3. import "./index.css";
  4. import App from "./App";
  5. import singleSpaReact from "single-spa-react";
  6. // 在非子应用中正常挂载应用
  7. if (!window.singleSpaNavigate) {
  8. ReactDOM.render(<App />, document.getElementById("root"))
  9. }
  10. const reactLifecycles = singleSpaReact({
  11. React,
  12. ReactDOM,
  13. rootComponent: App,
  14. errorBoundary(err, info, props) {
  15. return <div>This renders when a catastrophic error occurs</div>;
  16. },
  17. });
  18. export const bootstrap = reactLifecycles.bootstrap;
  19. export const mount = reactLifecycles.mount;
  20. export const unmount = reactLifecycles.unmount;

添加react-app-rewired插件

由于需要修改react启动配置,react-app-rewired提供了个性化启动配置
yarn add react-app-rewired

  1. "scripts": {
  2. "start": "PORT=8002 react-app-rewired start",
  3. "build": "react-app-rewired build",
  4. "test": "react-app-rewired test",
  5. "eject": "react-app-rewired eject"
  6. },

添加config-overrides.js文件

  1. module.exports = {
  2. webpack: (config) => {
  3. config.output.library = "singleReact";
  4. config.output.libraryTarget = "umd";
  5. config.output.publicPath = "http://localhost:8002/";
  6. return config;
  7. },
  8. devServer: (configFn) => {
  9. return function (proxy, allowedHost) {
  10. const config = configFn(proxy, allowedHost);
  11. config.headers = {
  12. "Accsss-Control-Allow-Origin": "*",
  13. };
  14. return config;
  15. };
  16. },
  17. };

主应用构建

项目使用vue作为主应用

创建项目

vue create single-spa-main
默认选择包含路由router

添加single-spa插件

yarn add single-spa

修改main.js文件

  1. import { createApp } from "vue";
  2. import App from "./App.vue";
  3. import { registerApplication, start } from "single-spa";
  4. import router from "./router"
  5. createApp(App).use(router).mount("#app");
  6. // 动态加载js文件
  7. function loadScript(url) {
  8. return new Promise((resolve, reject) => {
  9. let script = document.createElement("script");
  10. script.type = "text/javascript";
  11. script.src = url;
  12. script.onload = resolve;
  13. script.onerror = reject;
  14. document.head.append(script);
  15. });
  16. }
  17. // singleSpa 缺陷:样式不隔离,不能动态加载js,没有js沙箱机制
  18. registerApplication(
  19. "myVueApp",
  20. async () => {
  21. console.log('object');
  22. await loadScript("http://localhost:8001/js/chunk-vendors.js");
  23. await loadScript("http://localhost:8001/js/app.js");
  24. return window.singleVue;
  25. },
  26. (location) => location.pathname.startsWith("/vue")
  27. );
  28. registerApplication(
  29. "myReactApp",
  30. async () => {
  31. await loadScript("http://localhost:8002/static/js/bundle.js");
  32. return window.singleReact;
  33. },
  34. (location) => location.pathname.startsWith("/react")
  35. );
  36. start();

修改App.vue文件

  1. <div id="nav">
  2. <router-link to="/">Home</router-link> |
  3. <router-link to="/vue">Vue</router-link> |
  4. <router-link to="/react">React</router-link>
  5. </div>
  6. <router-view/>

总结:

开发流程

  • 通过将子模块进行打包编译,将打包出的内容动态加载到主模块上
  • 打包的子模块,使用umd模式
  • 动态加载子模块的js,loadScript

    single-spa缺陷

  • 样式不隔离

  • js无法自动导入
  • js无隔离,添加多个全局变量

    qiankun

    使用主应用端口8080,加载子应用8001和8002;
    8080使用vue创建
    8001使用vue创建
    8002使用react创建

    创建主应用

    使用vue-cli创建vue主应用,默认安装vue-router
    vue create qiankun-base
    安装qiankun插件
    yarn add qiankun
    安装element-plus,设置el-menu 切换。
    yarn add element-plus

    修改main.js配置

    ```javascript import { createApp } from “vue”; import App from “./App.vue”; import router from “./router”; import { registerMicroApps, start } from “qiankun”; import ElementUI from “element-plus”; import “element-plus/dist/index.css”; // 加载多个子应用 let apps = [ { name: “vueApp”, entry: “http://localhost:8001“, container: “#vue”, activeRule: “/vue”, }, { name: “reactApp”, entry: “http://localhost:8002“, container: “#react”, activeRule: “/react”, }, ]; // registerMicroApps注册子应用 registerMicroApps(apps); // 启动主应用 start();

createApp(App).use(router).use(ElementUI).mount(“#app”);

  1. <a name="RwQ02"></a>
  2. ### 修改路由router配置
  3. ```javascript
  4. import { createRouter, createWebHistory } from "vue-router";
  5. import Home from "../views/Home.vue";
  6. const routes = [
  7. {
  8. path: "/",
  9. name: "Home",
  10. component: Home,
  11. },
  12. ];
  13. const router = createRouter({
  14. history: createWebHistory(),
  15. routes,
  16. });
  17. router.beforeEach((to, from, next) => {
  18. if (!history.state.current) {
  19. Object.assign(history.state, {
  20. current: from.fullPath,
  21. });
  22. }
  23. next();
  24. });
  25. export default router;

修改首页App.vue配置

  1. <div>
  2. <el-menu :router="true" mode="horizontal">
  3. <el-menu-item index="/">首页</el-menu-item>
  4. <el-menu-item index="/vue">vue应用</el-menu-item>
  5. <el-menu-item index="/react">react应用</el-menu-item>
  6. </el-menu>
  7. <router-view v-show="$route.name"></router-view>
  8. <div v-show="!$route.name" id="vue"></div>
  9. <div v-show="!$route.name" id="react"></div>
  10. </div>

创建子应用

创建vue子应用

使用vue-cli创建vue子应用,默认安装vue-router
vue create module-vue

设置vue.config.js

qiankun加载子应用代码是通过fetch获取,所以子应用要设置跨域

  1. module.exports = {
  2. configureWebpack: {
  3. output: {
  4. library: "vueApp",
  5. libraryTarget: "umd",
  6. },
  7. },
  8. devServer: {
  9. port: 8001,
  10. headers: {
  11. "Access-Control-Allow-Origin": "*",
  12. },
  13. },
  14. };

修改main.js配置

  1. import { createApp } from "vue";
  2. import App from "./App.vue";
  3. import router from "./router";
  4. // 注意:子应用和主应用都使用了vue,不能挂载到同一个节点,否则会报错。把子应用挂载到vueapp,并修改public/index.html
  5. // 不在qiankun框架下启动
  6. if (!window.__POWERED_BY_QIANKUN__) {
  7. createApp(App).use(router).mount("#vueapp");
  8. } else {
  9. __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  10. }
  11. export async function bootstrap() {}
  12. export async function mount(props) {
  13. createApp(App).use(router).mount("#vueapp");
  14. }
  15. export async function unmount() {}

修改router的配置

  1. import { createRouter, createWebHistory } from 'vue-router'
  2. import Home from '../views/Home.vue'
  3. const routes = [
  4. {
  5. path: '/',
  6. name: 'Home',
  7. component: Home
  8. },
  9. {
  10. path: '/about',
  11. name: 'About',
  12. component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  13. }
  14. ]
  15. const router = createRouter({
  16. // 修改vue子应用的路由都以/vue开头
  17. history: createWebHistory("/vue"),
  18. routes
  19. })
  20. export default router

创建react子应用

创建react子应用,要使用react17.X 和react-dom:17.x,路由react-router-dom:5.x.x
npx create-react-app module-react

首先要确保版本的正确;修改package.json中对应版本号;

由于要修改react启动的配置,所以安装插件react-app-rewired
yarn add react-app-rewired
修改启动方式

  1. "scripts": {
  2. "start": "react-app-rewired start",
  3. "build": "react-app-rewired build",
  4. "test": "react-app-rewired test",
  5. "eject": "react-app-rewired eject"
  6. },

创建config-overrides.js配置文件

  1. module.exports = {
  2. webpack: (config) => {
  3. config.output.library = `reactApp`;
  4. config.output.libraryTarget = "umd";
  5. config.output.publicPath = "http://localhost:8002/";
  6. return config;
  7. },
  8. devServer: function (configFunction) {
  9. return function (proxy, allowedHost) {
  10. const config = configFunction(proxy, allowedHost);
  11. config.headers = {
  12. "Access-Control-Allow-Origin": "*",
  13. };
  14. return config;
  15. };
  16. },
  17. };

修改启动端口号,创建.env文件

  1. PORT=8002
  2. WDS_SOCKET_PORT=8002

修改src/index.js配置

  1. import React from "react";
  2. import ReactDOM from "react-dom";
  3. import App from "./App";
  4. const render = () => {
  5. ReactDOM.render(<App />, document.getElementById("root"));
  6. };
  7. if (!window.__POWERED_BY_QIANKUN__) {
  8. render();
  9. }
  10. if (window.__POWERED_BY_QIANKUN__) {
  11. }
  12. export async function bootstrap() {}
  13. export async function mount(props) {
  14. render();
  15. }
  16. export async function unmount() {}

修改src/App.js

  1. import { BrowserRouter, Route, Link } from "react-router-dom";
  2. // react子应用的路由都以/react 开头
  3. const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "/";
  4. function App() {
  5. console.log("BASE_NAME", BASE_NAME);
  6. return (
  7. <BrowserRouter basename={BASE_NAME}>
  8. <Link to="/">首页</Link>
  9. <Link to="/about">关于</Link>
  10. <Route path="/" exact render={()=><Home />}></Route>
  11. <Route path="/about" render={()=><About />}></Route>
  12. </BrowserRouter>
  13. );
  14. }
  15. function Home() {
  16. return <div>Welcome home </div>;
  17. }
  18. function About() {
  19. return <div>Welcome about </div>;
  20. }
  21. export default App;

常见问题

如果主模块是vue,使用vue-router,子模块使用react的路由react-router。如果切换react模块的子路由之后再切换回其他子模块,会发生错误
Error with push/replace State DOMException: Failed to execute 'replaceState' on 'History': A history state object with URL '[http://localhost:8080undefined/](https://link.juejin.cn?target=http%3A%2F%2Flocalhost%3A8080undefined%2F)' cannot be created
原因是react-router和vue-router处理的差别,导致在页面跳转的时候一些内容的丢失。
解决办法参考资料

  1. //主应用使用的嵌套路由
  2. router.beforeEach((to, from, next) => {
  3. if (!window.history.state.current) window.history.state.current = to.fullPath;
  4. if (!window.history.state.back) window.history.state.back = from.fullPath;
  5. // 手动修改history的state
  6. return next();
  7. });
  8. // 或者使用
  9. router.beforeEach((to, from, next) => {
  10. if (!history.state.current) {
  11. Object.assign(history.state, {
  12. current: from.fullPath,
  13. });
  14. }
  15. next();
  16. });