微前端项目实战整理(single-spa+react+iframe)(一)

3,376 阅读5分钟

背景

近几年微前端越来越热,身边很多团队都在尝试做微前端的落地,在了解过微前端的基本概念之后,正好手头有个老旧项目改版重做的需求,因为这个项目比较特殊,面临一些比较棘手的问题:

  • 项目为express + ejs + jquery前后端不分离项目
  • 历史原因项目代码极难维护(主要体现在没有模块化和各种糟糕的冗余页面冗余代码)
  • 历史原因中间件及一些查询逻辑冗余导致页面打开很慢

最开始的设想只是简单的用react重构整个项目,实现模块化和前后端分离,这样以后就不用再维护恶心人的老代码了,但是由于当时另一个项目排期比较紧且更重要,所以完整的用react重构整个项目时间上就来不及,一番构思之下我决定将新项目微前端化,因为这次老项目页面改版只涉及到了所有的详情相关页面,创作相关页面,其余的很多页面并无修改,针对此需求我决定对新项目进行微前端改造,按功能我对老项目进行了模块化拆解,拆解后微前端的模块结构如下:

  • 主应用(用来挂载其他各子应用)
  • 详情子应用(所有的详情页面)
  • 登录子应用(登录注册相关模块)
  • 创作子应用(文章创作编辑器模块)
  • iframe子应用(通过iframe来加载暂未重构的老项目其他模块)

做了应用拆分之后,后续的开发看起来就一目了然了,用react + single-spa分别去开发每个子应用和主应用就可以了,但是这里还面临一个问题,那就是剩余暂时不需要改的页面是服务端渲染的ejs+jquery的项目,该怎么加载进新项目的主应用?我最后的方案是额外创建一个iframe的壳应用,并且用react-router路由控制加载,这里具体的方案后面会详细说。

至此新项目的大体架构就已经出来了,剩下的就是技术层面的实现细节~

single-spa原理

我们在使用诸如reactvue这些框架开发普通的单页应用时,会按照功能的封装或者样式的复用把页面拆分成很多的组件,组件可以拼装成一个个单独的页面,并且通过路由控制监听地址栏变化,动态加载不同的页面和组件,这就是单页应用运行的简单原理。
single-spa原理也大同小异,也可以类比的理解为一种模块化开发的单页应用,只不过在单项目的单页应用中划分的模块是各种组件,而在微前端中各个模块就是各个独立的子应用,用户访问基座主应用时会按照配置注册各子应用,并且根据路由来确定分发到哪个子应用来加载,对应子应用加载之后会再根据路由分发到对应的子应用里的页面,这就是single-spa的路由加载原理先路由分发应用,再应用分发路由

基座应用开发

根据前面的划分思路,可知我们的基座应用应该具有注册和加载子应用的功能,针对需求仔细分析发现:
我们的各个子应用基本都需要用到用户的身份信息诸如userid等数据,但是这些数据的获取是在登录注册的逻辑里的,所以我们还需要一个类似redux的东西,把需要共享的数据提升到基座应用进行存储和分发,就实现了各应用间数据共享,这也就是消息总线
single-spa通过路由来分发应用,当url分发不到对应的应用时应该进行404页面跳转,并且从App分享到微信的链接,在不改动App的情况下,因为新旧项目同一个页面路由不同,所以还需要在基座层进行一些特殊路由的重定向
所以在基座应用中我们需要实现以下功能:

  • 加载子应用的模块加载器
  • 一个全局的状态仓库负责状态共享和分发
  • 路由控制模块

模块加载器

single-spa中通常搭配使用systemjs用于模块加载,用systemjs请求打包好的子应用的single-spa入口文件地址,之后systemjs通过eval解析并执行拿到的代码。因为要加载多个子应用,所以封装一个模块加载器

/*
/ice/src/Loader.js
*/


import * as singleSpa from "single-spa";
import createHistory from "history/createBrowserHistory";
export const history = createHistory();

/**
 * 判断路由是否匹配
 * @param {string} path 待匹配路径
 */
export const pathMatch = (path) => {
  return (location) => {
    return location.pathname.startsWith(`${path}`);
  };
};

/**
 * 通过应用打包生成的json文件获取对应入口文件
 * @param {string} url 
 * @param {string} baseUrl 
 * @param {string} entryName 
 */
export const getEntryfromJson = async (url, baseUrl, entryName) => {
  const fetchResult = await fetch(url).then((s) => s.json());
  const { entrypoints, publicPath } = fetchResult;
  let truePath = `${publicPath}${entrypoints[entryName].assets[0]}`
  return getResult;
};

/**
 * 子应用加载器
 * @param {string} name 
 * @param {string} pathName 
 * @param {string} appUrl 
 * @param {string} baseUrl 
 * @param {boolean} hasStore 
 * @param {GlobalEventDistributor} globalEventDistributor 
 */
export const loadApp = async (
  name,
  pathName,
  appUrl,
  baseUrl,
  hasStore,
  globalEventDistributor
) => {
  let store = {},
    props = { globalEventDistributor };

  try {
    store = hasStore
      ? await getEntryfromJson(appUrl, baseUrl, "store")
      : { storeInstance: null };
  } catch (err) {
    console.log(`加载${name}数据仓库失败:${err}`);
  }
  if (store.storeInstance && globalEventDistributor) {
    props.store = store.storeInstance;
    globalEventDistributor.registerStore(store.storeInstance);
  }
  SystemJS.config({ transpiler: "transpiler-module" });
  props.history = history;
  props.data = globalEventDistributor && globalEventDistributor.getState();
  singleSpa.registerApplication(
    name,
    () => getEntryfromJson(appUrl, baseUrl, "singleSpaEntry"),
    pathMatch(pathName),
    props
  );
};


上面没有直接请求文件地址,而是通过请求每个子项目生成的json文件,从json中读取对应文件的地址,这么做的目的是解决缓存问题,这样子项目打包生成的文件名后面的hash值每次都不一样,并且会写进json中,从json中我们就可以知道对应文件的名字是什么,就解决了诸如微信浏览器里面打开的缓存问题。

全局状态仓库

single-spa里面实现类似普通的单页react应用中全局的redux数据仓库功能的思路是这样的,在各子应用中实现各子应用的状态仓库,然后将各自的store对象加载进基座应用,在基座应用实现一个全局的状态仓库类,整合实现store的功能,代码如下:

/**
 * 全局状态仓库类,提供整合注册store已经dispatch和getState功能
 */
export class GlobalEventDistributor{
  constructor() {
    super();
    this.stores = [];
  }

  registerStore(store) {
    this.stores.push(store);
  }

  dispatch(event) {
    this.stores.forEach((s) => {
      s.dispatch(event);
      this.emit("dispatch");
    });
  }

  getState() {
    let state = {};
    this.stores.forEach((s) => {
      let currentState = s.getState();
      state = { ...state, ...currentState };
    });
    return state;
  }
  
  subscribe(subHandle) {
    this.stores.forEach(s => {
      s.subscribe(() => {
        subHandle.apply(null, [this.getState()])
      })
    })
  }
}

single-spa入口文件

将所有的子应用注册在single-spa的打包入口文件中注册

import * as singleSpa from "single-spa";
import { GlobalEventDistributor } from "./globalEventDistributor";
import { loadApp } from "./loader";

const init = async () => {
  const globalEventDistributor = new GlobalEventDistributor();
  const loadingArray = [];
  loadingArray.push(
    loadApp(
      "login",
      "/login",
      "/app1/manifest.json",
      "/app1",
      true,
      globalEventDistributor
    )
  );
  loadingArray.push(
    loadApp(
      "show",
      "/show",
      "/app2/manifest.json",
      "/app2",
      false,
      globalEventDistributor
    )
  );
  loadingArray.push(
    loadApp(
      "create",
      "/create",
      "/app3/manifest.json",
      "/app3",
      true,
      globalEventDistributor
    )
  );
  loadingArray.push(
    loadApp("iframe", "/iframe", "/app4/manifest.json", "/app4", false, null)
  );
  await Promise.all(loadingArray);
  singleSpa.start();
};
init();

以上就完成了基座应用的基本开发,但还有一个小的优化点,因为我们在一开始的应用规划里有一个iframe的子应用,即所有非新开发的页面的路由,要跳转到iframe的子应用,用该应用里的iframe来打开老项目里对应页面,所以我们需要在基座应用里设计一下路由控制的模块,这里我是用react-router做的路由管理,新建一个route.js文件,然后在webpack里面把这个文件也单独设成一个打包入口就可以了,代码如下:

import React from "react";
import { BrowserRouter, Switch, Route, Redirect } from "react-router-dom"
import {history} from "./Loader.js"

const Router = () => {
  return (
    <BrowserRouter history={history}>
      <Switch>
        <Route path="/show"/>
        <Route path="/login" />
        <Route path="/create" />
        <Route path="/iframe" />
        <Redirect to={`/iframe${window.location.pathname}`} />
      </Switch>
    </BrowserRouter>
  )
}
export default Router

因为在single-spa的打包入口里面我们已经引入了systemjs来加载对应应用,所以在路由变化为对应地址时,systemjs会接管应用的加载,因此在这个route.js里不必再通过react-router来控制页面加载了,之所以会把各子应用的路由匹配写上,主要是为了在匹配到对应路由时不会触发Redirect组件,上面代码就实现了非应用内实现的路由重定向到iframe里打开的逻辑。
例如访问:www.abc.com/test会被重定向为www.abc.com/iframe/test,新的应用里我没有实现test这个路由对应的页面,而老项目里有,所以通过iframe来打开老页面,这样就实现了重构的目的里的渐进迁移

小结

以上就是基座应用的实现及思路,关于webpack配置不再赘述,后面会继续分享子应用的实现及打包部署相关问题