微前端实践(未完成)

172 阅读10分钟

什么是微前端?

微前端是一种设计思想,它将前端应用拆分成多个独立的小应用,每个小应用可以独立开发、部署和升级,同时它们可以共享公共的资源和组件。

有哪些微前端框架?

  1. Single-SPA:一个用于构建微前端应用的JavaScript库,它支持多种前端框架(如React、Vue、Angular等)。
  2. Qiankun:一个基于Single-SPA的微前端框架,它提供了更简单易用的API和更好的兼容性。

single-spa 实践

官方文档

三种应用类型

  • single-spa application/parcel‌:微前端架构中的微应用(子应用),可以使用Vue、React、Angular等框架。
    • single-spa application与路由相关联,根据路由决定访问哪个微应用;
    • 而single-spa parcel则不和路由关联,主要用于跨应用共享UI组件‌,重用UI
  • single-spa root config‌:创建微前端容器应用(主应用)的方式,通过容器应用加载和管理普通的微应用(子应用)。根应用负责加载其他子应用,并作为单页应用(SPA)的容器,将不同的子应用集成在一个页面中,并为每个子应用创建一个独立的上下文‌。
  • utility modules‌:公共模块应用,属于非渲染组件,用于跨应用共享JavaScript逻辑的微应用。

创建主应用 (脚手架的形式创建项目)

npm install --global create-single-spa # 全局安装create-single-spa,然后用create-single-spa命令创建应用
# 或者不想全局安装,可以使用下面的命令
npm init single-spa
# or
npx create-single-spa
# or 
yarn create single-spa
#创建容器应用
npx create-single-spa
? Directory for new project single-spa
? Select type to generate single-spa root config
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Would you like to use single-spa Layout Engine No
? Organization name (can use letters, numbers, dash or underscore) wxz

进入项目目录并启动

cd single-spa
npm start
  • 默认运行在 9000端口,访问 http://localhost:9000/ 即可看到默认Welcome页面。因为默认注册了一个@single-spa/welcome子应用
  • 具体api可看官方文档

默认文件 src\wxz-root-config.js

  • 注册子应用的地方,registerapplication 的参数看官方文档
  • 注意一下自己引用的 single-spa的版本和包类型,下面我是引用的 6.3.0版本的esm包。umd的包不是下面这样用的,会报错 registerApplication不是一个function
// src\wxz-root-config.js
import { registerApplication, start } from "single-spa";
// registerApplication: 注册子应用函数,每个子应用注册必须有三个参数:1. 子应用名称 2. 子应用入口,加载应用程序的代码 3. 子应用激活条件
registerApplication({
  name: "@single-spa/welcome", // 注册子应用名称
  app: () =>
    import(
      /* webpackIgnore: true */ // @ts-ignore-next
      "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
    ), // 子应用入口
  activeWhen: ["/"], // 子应用激活条件
});

// registerApplication({
//   name: "@wxz/navbar",
//   app: () =>
//     import(
//       /* webpackIgnore: true */ // @ts-ignore-next
//       "@wxz/navbar"
//     ),
//   activeWhen: ["/"],
// });
// 调用start函数之前,会加载所有注册的子应用,但不会初始化、挂载它们。start函数会启动应用,并开始监听路由变化,当路由变化时,会自动加载、挂载对应的子应用。
start({
  urlRerouteOnly: true, // 是否可以通过 history.pushState 和 history.replaceState 改变路由,默认是false
});

默认文件 src\index.ejs

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Root Config</title>
  <!-- 如果有跨域错误可以把下面这行注释掉 -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: localhost:* ws://localhost:*; style-src 'unsafe-inline' https:; object-src 'none';">
  <meta name="importmap-type" use-injector />
  <!-- If you wish to turn off import-map-overrides for specific environments (prod), uncomment the line below -->
  <!-- More info at https://github.com/single-spa/import-map-overrides/blob/main/docs/configuration.md#domain-list -->
  <!-- <meta name="import-map-overrides-domains" content="denylist:prod.example.com" /> -->

  <!-- Shared dependencies go into this import map -->
  <!-- 引用的公共库,可以把vue,vue-router,react,react-dom等cdn的形式引入放在这儿 -->
  <script type="injector-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js"
      }
    }
  </script>
  <link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js" as="module">

  <!-- Add your organization's prod import map URL to this script's src  -->
  <!-- <script type="injector-importmap" src="/importmap.json"></script> -->
  <!-- 加载子应用路径 -->
  <% if (isLocal) { %>
  <script type="injector-importmap">
    {
      "imports": {
        "@wxz/root-config": "//localhost:9000/wxz-root-config.js",
        "@single-spa/welcome": "https://cdn.jsdelivr.net/npm/single-spa-welcome/dist/single-spa-welcome.min.js"
      }
    }
  </script>
  <% } %>

  <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@4.1.0/dist/import-map-overrides.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@single-spa/import-map-injector@1.1.0/lib/import-map-injector.js"></script>
</head>
<body>
  <main></main>
  <script>
    window.importMapInjector.initPromise.then(() => {
      import('@wxz/root-config');
    });
  </script>
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>

创建子应用

我的目录结构:在 src/applications 下创建子应用,所有子应用会放在 src/applications注意 我创建子应用的时候,node版本用 20.17.0创建会失败,在填完 Organization name 后就退出终端了。换成 16.17.1 就创建成功了。 injector-importmap中添加包的时候,最后一个后面不要有逗号

创建一个没有使用任何框架基于webpack的demo

  1. 创建文件夹 webpackdemo, 进入该目录然后npm init -y
  2. 安装webpack和babel相关依赖,至少安装下面几个,够项目跑起来
{
  "name": "@wxz/webpackdemo", // 用registerApplication注册子应用时,import入口时,官方建议用 子应用 package.json 中的name,为了统一可以改一下名字,都 组织名/子应用名
  "version": "1.0.0",
  "description": "",
  "private": true,
  "scripts": {
    "start": "webpack serve"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.12.10",
    "@babel/preset-env": "^7.26.0",
    "babel-loader": "^8.3.0",
    "webpack": "^5.97.1",
    "webpack-cli": "^6.0.1",
    "webpack-dev-server": "^5.2.0"
  }
}
  1. 配置文件,webpackdemo 下 创建 webpack.config.js
// src\applications\webpackdemo\webpack.config.js
const singleSpaDefaults = require("webpack-config-single-spa"); //在webpackdemo子应用中没有安装,但在父级主应用中已经安装了
const { merge } = require("webpack-merge"); //在webpackdemo子应用中没有安装,但在父级主应用中已经安装了
const path = require("path");

module.exports = (webpackConfigEnv, argv) => {
  const defaultConfig = singleSpaDefaults({
    // 组织名称
    orgName: "wxz",
    // 项目名称
    projectName: "webpackdemo",
    webpackConfigEnv,
    argv
  });
  // 使用merge方法配置微应用默认端口
  return merge(defaultConfig, {
    devServer: {
      port: 9001
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /(node_modules)/, // 排除node_modules中的js文件(这些文件不处理)
          loader: "babel-loader"
        }
      ]
    }
  });
};

  1. 配置 babel 创建 babel.config.js
module.exports = {
  // 智能预设: 能够编译ES6的语法
  presets: ["@babel/preset-env"]
};
  1. webpackdemo/src 下创建入口文件,入口文件命名规则 组织名-子应用名.js, 组织名和项目名就是在 webpack.config.js 中配置的 orgNameprojectName.
    • 入口文件一定要导出 bootstrapmountunmount 三个方法,用于主应用加载子应用。且必须返回promise,注册子应用的registerApplication函数的第二个参数。可以参考官方文档的简单例子。 这里我们换成 async
let domEl = null;
export async function bootstrap(props) {
  domEl = document.createElement("div");
  domEl.id = "app1";
  document.body.appendChild(domEl);
}

export async function mount(props) {
  // 在这里通常使用框架将ui组件挂载到dom。请参阅https://single-spa.js.org/docs/ecosystem.html。
  domEl.textContent = "webpackdemo is mounted!";
}

export async function unmount(props) {
  // 在这里通常是通知框架把ui组件从dom中卸载。参见https://single-spa.js.org/docs/ecosystem.html
  domEl.textContent = "";
}

  1. 去父应用中注册子应用 src\wxz-root-config.js

注册函数参数参考 官方文档

import { registerApplication, start } from "single-spa";

function pathPrefix(prefix) {
  return function (location) {
    return location.pathname.startsWith(`${prefix}`);
  };
}
registerApplication({
  name: "@single-spa/welcome",
  app: () =>
    import(
      /* webpackIgnore: true */ // @ts-ignore-next
      "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
    ),
  activeWhen: (location) => location.pathname === "/", 
});

registerApplication({
  name: "@wxz/webpackdemo",
  app: () =>
    import(
      /* webpackIgnore: true */ // @ts-ignore-next
      "@wxz/webpackdemo"
    ),
  activeWhen: pathPrefix("/webpackdemo"),
});

start({
  urlRerouteOnly: true,
});

  1. src\index.ejs中引入子应用加载地址
  <% if (isLocal) { %>
  <script type="injector-importmap">
    {
      "imports": {
        "@wxz/root-config": "//localhost:9000/wxz-root-config.js",
        "@single-spa/welcome": "https://cdn.jsdelivr.net/npm/single-spa-welcome/dist/single-spa-welcome.min.js",
        "@wxz/webpackdemo": "//localhost:9001/wxz-webpackdemo.js"
      }
    }
  </script>
  <% } %>
  1. 启动子应用,在 webpackdemo 下执行 npm run start,启动成功后, 在主应用根目录启动主应用 npm run start,然后访问 http://localhost:9000/webpackdemo,可以看到子应用已经成功加载到主应用中。访问 http://localhost:9000可以看到 Welcome页

使用react框架的子应用

? Directory for new project reactapp
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Organization name (can use letters, numbers, dash or underscore) wxz
  1. 创建完 react,react-dom默认是最新的 19.0.0版本,用这个版本试了一下会有各种问题,所以我降级到 17.0.2版本
npm install react@17.0.2 react-dom@17.0.2
  1. src/index.ejs 里引共享包的地方,添加 reactreact-dom 的引用 怎么说呢,这里引用一定要加,不加报错 Failed to resolve module specifier "react"....巴拉巴拉的,但是加了后,不同的地址报不同的报错,最开始用的 cdn.jsdelivr.net的,会报错 , 后来在 在issue上看到个相同报错, 换 esm.sh的就好了。
 <script type="injector-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js",
        "react": "https://esm.sh/*react@17.0.2/index.js",
        "react-dom": "https://esm.sh/*react-dom@17.0.2/index.js"
      },
      "scopes": {
        "https://esm.sh/": {
          "object-assign": "https://esm.sh/*object-assign@4.1.1/index.js",
          "scheduler": "https://esm.sh/*scheduler@0.20.2/index.js"
        }
      }
    }
  </script>
  1. 配置文件src\applications\reactapp\webpack.config.js中给个端口,随便定义,不冲突就行
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa-react");

module.exports = (webpackConfigEnv, argv) => {
  const defaultConfig = singleSpaDefaults({
    orgName: "wxz",
    projectName: "reactapp",
    webpackConfigEnv,
    argv,
  });

  return merge(defaultConfig, {
    // modify the webpack config however you'd like to by adding to this object
    devServer: {
      port: 9002,
    }
  });
};

  1. src/wxz-root-config.js中注册子应用
registerApplication({
  name: "@wxz/reactapp",
  app: () =>
    import(
      /* webpackIgnore: true */ // @ts-ignore-next
      "@wxz/reactapp"
    ),
  activeWhen: pathPrefix("/reactapp"),
});
  1. src/index.ejs 里引入子应用加载地址.
<script type="injector-importmap">
    {
      "imports": {
        "@wxz/root-config": "//localhost:9000/wxz-root-config.js",
        "@single-spa/welcome": "https://cdn.jsdelivr.net/npm/single-spa-welcome/dist/single-spa-welcome.min.js",
        "@wxz/webpackdemo": "//localhost:9001/wxz-webpackdemo.js",
        "@wxz/reactapp": "//localhost:9002/wxz-reactapp.js"
      }
    }
  </script>
  1. 启动子应用,在启动主应用,然后访问 http://localhost:9000/reactapp,可以看到下面就成功了

image-1.png

使用vue框架的子应用

? Directory for new project vueapp
? Select type to generate single-spa application / parcel
? Which framework do you want to use? vue
? Organization name (can use letters, numbers, dash or underscore) wxz
  1. 唉,报错了,报错如下:

image-4.png 因为 vue-cli-plugin-single-spa内部用到了 systemjs-webpack-interop/SystemJSPublicPathWebpackPlugin: 它是用于处理 Webpack 打包后的模块加载路径的插件。主要用于在 SystemJS 中设置公共路径(public path),以确保模块能够正确加载。 但是 我用create-single-spa 最新版本去创建的项目,最新版是 5.0.5, 模板中默认是用import-map-injector use-injector 动态加载模块的,没有引入 system.js,在indx.ejs中引入一下system.js相关的包就不报错了

  1. 但是又有一个新的报错如下:

image-2.png

原因呢,import() 函数引入 vueapp 项目时,vue项目导出的的生命周期函数bootstrapmountunmount等没有拿到

暂时没找到原因, 所以试了 create-single-spa的版本降低到4.1.5,用这个版本创建项目,index.ejs中默认用的 system.js

创建子应用: create-single-spa的版本降低到4.1.5,用这个版本创建项目

创建vue框架项目

  1. 首先创建容器应用, 创建成功后启动可才能会报错 export没有之类的,可能因为官方默认注册的 welcom子应用更新了吧,不用管,删掉这个子应用的注册和引用
  2. create-single-spa创建 vue 子应用,创建成功后: 1) 在wxz-root-config.js中注册 2) 在index.ejs中引入子应用加载地址,以及 添加 vue依赖引用, 然后启动项目,但是报错了哈哈,报错如下:

创建:

? Directory for new project vueapp
? Select type to generate single-spa application / parcel
? Which framework do you want to use? vue
? Organization name (can use letters, numbers, dash or underscore) wxz

注册:rootconfig\src\wxz-root-config.js

// rootconfig\src\wxz-root-config.js
import { registerApplication, start } from "single-spa";

registerApplication({
  name: "@wxz/vueapp",
  app: () => System.import("@wxz/vueapp"),
  activeWhen: ["/vueapp"],
});

start({
  urlRerouteOnly: true,
});

引入:rootconfig\src\index.ejs

<script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js"
      }
    }
  </script>
   <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@wxz/root-config": "//localhost:9000/wxz-root-config.js",
        "@wxz/vueapp":"//localhost:9001/js/app.js"
      }
    }
  </script>
  <% } %>

image-5.png

3. 因为 vue-cli-plugin-single-spa 插件内打包输出是 umd, 修改成 system,在重新启动,就没有报错了

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  configureWebpack: {
    output: {
      libraryTarget: "system"
    },
    externals: ["vue", "vue-router"]
  }
})

image-3.png

创建react框架项目

? Directory for new project reactapp
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) wxz
? Project name (can use letters, numbers, dash or underscore) reactapp
  1. 注册、模板中添加加载地址, 添加reactreact-dom依赖, 然后启动,看到下图就成功了
import { registerApplication, start } from "single-spa";

registerApplication({
  name: "@wxz/vueapp",
  app: () => System.import("@wxz/vueapp"),
  activeWhen: ["/vueapp"],
});

registerApplication({
  name: "@wxz/reactapp",
  app: () => System.import("@wxz/reactapp"),
  activeWhen: ["/reactapp"]
});

start({
  urlRerouteOnly: true,
});

 <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js",
        "react": "https://unpkg.com/react@17/umd/react.production.min.js",
        "react-dom": "https://unpkg.com/react-dom@17/umd/react-dom.production.min.js",
        "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.2.0/umd/react-router-dom.min.js"
      }
    }
  </script>
   <script type="systemjs-importmap">
    {
      "imports": {
        "@wxz/root-config": "//localhost:9000/wxz-root-config.js",
        "@wxz/vueapp":"//localhost:9001/js/app.js",
        "@wxz/reactapp":"//localhost:9002/wxz-reactapp.js"
      }
    }
  </script>

image-6.png

create-single-spa@4.1.5的版本创建项目一路下来很顺利

子应用创建成功,每个子应用内部路由等和单独创建的一样使用

创建Parcel

  • Parcel用来创建公共UI,涉及跨框架共享UI时,需要使用Parcel;如果所有应用都是同一个框架,官方建议使用相同框架创建组件去引用,而不是使用Parcel
  • Parcel的定义可以使用任何single-spa支持的框架,它也是单独的应用,需要单独启动,但它不关联路由。
  • Parcel应用的模块访问地址也需要被添加到import-map中,其它微应用通过System.import方法进行引用
  • Parcel和微应用的区别是,Parcel需要手动挂载,微应用注册后访问会自动挂载
  1. 创建,假设用vue2框架
Directory for new project navparcel
? Select type to generate single-spa application / parcel
? Which framework do you want to use? vue
? Organization name (can use letters, numbers, dash or underscore) wxz
  1. 修改配置文件(navparcel\vue.config.js)打包输出 system
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
 transpileDependencies: true,
 configureWebpack: {
   output: {
     libraryTarget: "system"
   },
   externals: ["vue", "vue-router"]
 }
})

  1. rootconfig\src\index.ejs 引入
<script type="systemjs-importmap">
    {
      "imports": {
        "@wxz/root-config": "//localhost:9000/wxz-root-config.js",
        "@wxz/vueapp":"//localhost:9001/js/app.js",
        "@wxz/reactapp":"//localhost:9002/wxz-reactapp.js",
        "@wxz/navparcel": "//localhost:9003/js/app.js"
      }
    }
  </script>
  1. 写公共UI,先来个最简单的看效果 navparcel\src\App.vue
<template>
 <div id="app">
   logologologologologologologologologologologologologologologologologologologologologologo
 </div>
</template>

<script>

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

5. 启动 navparcel 项目

  1. 在reactapp中引用公共UI

image-13.png

image-9.png

  1. 在vueapp中引用公共UI

image-10.png

image-11.png

创建公共方法库

  • 跨应用的js逻辑代码,放一些公共方法等
  1. 创建
 Directory for new project tools
? Select type to generate in-browser utility module (styleguide, api cache, etc)
? Which framework do you want to use? none
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) wxz
? Project name (can use letters, numbers, dash or underscore) tools
  1. 添加要导出的公共方法
export function getUrlParams(url) {
    let urls = url || window.location.href;
    let params = {};
    let urlStr = urls.split('?')[1];
    if (urlStr) {
        let arr = urlStr.split('&');
        arr.forEach(item => {
            let arr2 = item.split('=');
            params[arr2[0]] = arr2[1];
        });
    }
    return params;
}

export function formatTime(time) {
    let date = new Date(time);
    let year = date.getFullYear();
    let month = date.getMonth() + 1;
    let day = date.getDate();
    let hour = date.getHours();
    let minute = date.getMinutes();
    let second = date.getSeconds();
    return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
  1. 在vueapp中使用,放哪用自己随意
  async mounted() {
    const { getUrlParams, formatTime } = await window.System.import('@wxz/tools');
    console.log('getUrlParams', getUrlParams());
    console.log('formatTime', formatTime('2025/01/21'));
  }

image-12.png

  1. 在reactapp中使用

image-14.png

image-15.png