SingleSPA + Create React App + Antd 构建微前端应用

前言

关于微前端的相关概念,这里就不再阐述。

目前基于 single-spa 来构建微前端应用是主流的实现,但是其官网的示例结合了大量他自己家的组件,较为繁琐。对于新手不太友好

本文将结合 Ant Design、SingleSpa、 Create React App 实现一个更加贴合实际业务场景的示例

我们将实现以下功能

  • 基于 Ant Design、SingleSpa、 Create React App 构建
  • 样式隔离
  • 远程加载子模块资源
  • 子应用自动生成包含 entrypoints 的 manifest 清单文件

完整代码可查阅:源码

基座

安装依赖

# 初始化项目
npx create-react-app main
# 安装依赖
npm install @ant-design/pro-layout axios single-spa url-join --save

基座需要实现的功能包括

  • 基础布局
  • 子应用生命周期管理(single-spa)
  • 子应用远程资源加载

这里为了演示方便,我们直接使用 @ant-design/pro-layout 来搭建基础布局

container.jsx

export default function Container ()  {
  const [settings, setSetting] = useState({ fixSiderbar: true });
  const [pathname, setPathname] = useState('/welcome');
  const history = useHistory();
  return (
    <div
      id="test-pro-layout"
      style={{
        height: '100vh',
      }}
    >
      <ProLayout
        {...defaultProps}
        location={{
          pathname,
        }}
        waterMarkProps={{
          content: 'Pro Layout',
        }}

        onMenuHeaderClick={(e) => console.log(e)}
        menuItemRender={(item, dom) => (
          <a
            href="javascript:void(0)"
            onClick={() => {
              setPathname(item.path)
              history.push(item.path)
            }}
          >
            {dom}
          </a>
        )}
        rightContentRender={() => (
          <div>
            <Avatar shape="square" size="small" icon={<UserOutlined />} />
          </div>
        )}
        {...settings}
      >
        <div id="container" ></div>
      </ProLayout>
    </div>
  );
};

这里需要注意的是,我们加了一个 id 为 container 的 div 用于承载子应用

完整代码可查阅源码,搭建完的效果如下图

截屏2021-07-09 下午2.56.48.png

我们这里配置了 home、 about 两个子应用

我们将应用信息保存在一个数组中,其中 host 表示加载子应用资源的地址、match用于路由匹配

然后通过 single-spa 的 registerApplication 来注册应用

registerApplication 接受了 4 个参数

  • appName: string.
    应用名称,需保证唯一

  • applicationOrLoadingFn: () => <Function | Promise>.
    加载子应用,返回 single-spa 所需的生命周期函数(mount、bootstrap、unmount)

  • activityFn: (location) => boolean.
    用于做子应用的路由匹配,参数为 window.location ,必须为纯函数,可以自己实现路由配规则

  • customProps?: Object | () => Object.
    在我们调用子应用的生命周期方法时,传递的参数。一般可用来传递共享的数据,例如 reduex 的 state,这里为了不增加复杂度,暂不使用。

applicationOrLoadingFn 加载子应用的时候,我们调用 loadResources 加载子应用远程资源

完整代码如下

// 配置子应用
const apps = [
  {
    name: 'home',
    host: 'http://localhost:3001',
    match: /^\/home/
  },
  {
    name: 'about',
    host: 'http://localhost:3002',
    match: /^\/about/
  }
]
// 注册应用
for (let i = 0, app = null; i < apps.length; i++) {
  app = apps[i];
  singleSpa.registerApplication(
    app.name, 
    async (arg) => {
      // 这里会去加载远程资源,加载完毕后,子应用暴露出 single-spa 需要的生命周期函数
      await loadResources(app.host);
      return window[app.name];
    },
    location => {
      return app.match.test(location.pathname)
    }
  );
}
 // 启动
singleSpa.start();

loadResources 加载子应用资源

加载子应用的远程资源的流程如下

子应用会提供一个 manifest.json ,描述了入口文件相关信息,类似

{
  "entrypoints": {
    "main": {
      "chunks": [
        "runtime-main",
        "vendors~main",
        "main"
      ],
      "assets": [
        "static/js/bundle.js",
        "..."
      "children": {
        
      },
      "childAssets": {
        
      }
    }
  },
  "publicPath": "http://localhost:3001/"
}

基座请求这个清单文件后,解析出 entrypoints 中的资源,创建对应的 style/script 标签加载资源

整个执行过程如下

image.png

具体代码

export const loadResources = async (url) => {
  const [css, js] = await getManifest(url);
  return Promise.all([loadStyles(css), loadScripts(js)]) 
}

加载 manifest.json

export const getManifest = (url) =>
  new Promise(async (resolve) => {
    const u = urlJoin(url, 'manifest.json');
    
    const { data } = await axios.get(u);
    
    const { entrypoints, publicPath } = data;
    const key = getFirstKey(entrypoints);
    if (!key) {
      return resolve([])
    }
    const assets = (entrypoints[key].assets || []).filter((file) =>
      /(\.css|\.js)$/.test(file)
    );
    const css = [],
      js = [];
    for (let i = 0; i < assets.length; i++) {
      const asset = assets[i];
      const assetPath = urlJoin(publicPath, asset);
      if (/\.css$/.test(asset)) {
        css.push(assetPath);
      } else if (/\.js$/.test(asset)) {
        js.push(assetPath);
      }
    }
    resolve([css, js])
  });

加载 style

export const loadStyles = async (res) => {
  res = (res || []).filter(href => !Boolean(hasLoadedStyle(href)))
  return Promise.all(res.map(loadStyle));
}

export const createStyle = async (url) => {
  return new Promise((resolve, reject) => {
    const styleLink = document.createElement("link");
    styleLink.link = url;
    styleLink.onload = resolve;
    styleLink.onerror = reject;
    document.head.appendChild(styleLink);
  });
};

加载 script

export const loadStyles = async (res) => {
  res = (res || []).filter(href => !Boolean(hasLoadedStyle(href)))
  return Promise.all(res.map(loadStyle));
}

export const createScript =  (url) => {
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.type = 'text/javascript';
    s.src = url;
    s.onload = resolve;

    s.onerror = (...rest) => reject(rest);
    document.head.appendChild(s);
});
};

子应用

这里我们有 home、about 两个子应用

npx create-react-app home


npx create-react-app about

子应用需要实现的功能包括:

  • 提供 manifest.json 清单文件
  • 构建改造
  • 样式隔离
  • 提供 single-spa 应用所需的生命周期函数

提供应用生命周期方法

这里我们使用 single-spa-react


import React from "react";
import ReactDOM  from "react-dom";
import singleSpaReact from 'single-spa-react';
import Container from './components/Container'
import './index.css';

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  domElementGetter: () => document.getElementById('container'),
  rootComponent: Container
});

export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;



构建改造

前面提到,在加载子应用资源后,子应用会把 single-spa 生命周期的函数暴露在 window 上提基座去调用,这需要我们队整个构建 output 做一下改造,我们项目是使用 create-react-app 创建的,这里我们使用 react-app-rewired来扩展

npm install react-app-rewired -D

然后将 package.jsonscripts 替换成 create-react-app 来执行

"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject"
  }

在项目跟目录创建 config-overrides.js

需要改的地方有两点

  • 1、指定导出的对象和挂载点
  • 2、解决跨域问题

如下

module.exports = {
  webpack: function(config, env) {
    // 应用名称
    config.output.library = 'about';
    config.output.libraryTarget = "window";
    // 默认是"/",因为子应用资源是在基座中执行的,需要重新指定,这里为了演示方便直接写死
    config.output.publicPath = 'http://localhost:3002/';
    return config;
  },
  // 解决跨域问题
  devServer: function(configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.disableHostCheck = true
      config.headers = config.headers || {}
      config.headers['Access-Control-Allow-Origin'] = '*'
      return config
    }
  }
}

提供 manifest.json

这里我们借助webpack-stats-plugin插件来实现

npm install webpack-stats-plugin -D

config-overrides.js 中添加配置

 config.plugins.push(
      new StatsWriterPlugin({
        fields: ['entrypoints', 'publicPath'],
        filename: "manifest.json" // 文件名称
      })
    )

加完重新启动项目,试试访问 localhost:{port}/manifest.json

样式隔离

这里我们使用 postcss-selector-namespace 添加命名空间的插件来实现,子应用的 css 选择器都是加上一个前缀来实现隔离

扩展 postcss 的配置我们用react-app-rewire-postcss

npm install react-app-rewire-postcss postcss-selector-namespace -D

继续修改 config-overrides.js,完整的配置文件如下

const { StatsWriterPlugin } = require("webpack-stats-plugin");
module.exports = {
  webpack: function (config, env) {
    require("react-app-rewire-postcss")(config, {
      plugins: (loader) => {
        console.log(loader)
        return [
          require("postcss-flexbugs-fixes"),
          require("postcss-preset-env")({
            autoprefixer: {
              flexbox: "no-2009",
            }
          }),
          require("postcss-selector-namespace")({
            namespace(css) {
              // 前缀,如果有全局样式不需要添加的,也可以在这里过滤
              return ".micro-frontend-home";
            },
          }),
        ]
      }
    });
    config.output.library = "home";
    config.output.libraryTarget = "window";
    // 默认是"/",因为子应用资源是在基座中执行的,需要重新指定,这里为了演示方便直接写死
    config.output.publicPath = "http://localhost:3001/";
    config.plugins.push(
      new StatsWriterPlugin({
        fields: ["entrypoints", "publicPath"],
        filename: "manifest.json", // 文件名
      })
    );
    return config;
  },
  devServer: function (configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.inline = true;
      config.disableHostCheck = true;
      config.headers = config.headers || {};
      config.headers["Access-Control-Allow-Origin"] = "*";
      return config;
    };
  },
};

完整代码可查阅:源码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容