单实例微前端设计思想

  1. 拿到子应用构建后的资源清单,一般项目中都会生成一个asset-manifest.json文件,用来记录各个子应用的入口资源url信息,以便在切换不同子应用时使用模块加载器去远程加载。因为每次子应用更新后入口资源的hash通常会变化,所以需要服务端定时去更新该配置表,以便框架能及时加载子应用最新的资源;
  2. 同样,子应用之间的一些公共依赖通过配置文件记录;
  3. 主应用监听路由按需加载子应用模块;
  4. 主应用请求获取到JS模块后,可以通过eval、new Function、SystemJS方式执行JS模块,拿到子应用生命周期钩子,挂载到DOM。这一过程为了避免全局变量的命名冲突,应该做到JS沙箱隔离;
  5. 主应用请求获取到CSS模块后,拼接CSS字符串,使用style标签动态注入到页面中,为了避免子应用之间样式冲突,在unmount阶段将注入的style标签删除;

single-spa

Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。 使用 single-spa 进行前端架构设计可以带来很多好处,例如:

single-spa 借鉴了组件生命周期的思想,它为应用设置了针对路由的生命周期。当应用匹配路由处于激活状态时,应用会把自身的内容挂载到页面上;反之则卸载。

该框架核心提供两个功能,我这里称之为加载器和包装器。加载器用来调度子应用,决定何时展示哪一个子应用,可以把它理解为电源。包装器可以把子应用进行包装,给子应用提供生命周期钩子,并将其导出,使得加载器可以使用它们,它相当于电源适配器。

img

single-spa-react是针对react项目的包装器

import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react';

const domElementGetter = () => {
  let el = document.getElementById("micro-content");
  if (!el) {
    el = document.createElement('div');
    el.id = 'micro-content';
    document.body.appendChild(el);
  }

  return el;
}

export const singleSpaPacker = (rootComponent: React.FC<any>) => {
  const reactLifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent,
    domElementGetter,
  })
  
  const bootstrap = (props: any) => {
    return reactLifecycles.bootstrap(props);
  }

  const mount = (props: any) => {
    return reactLifecycles.mount(props);
  }

  const unmount = (props: any) => {
    return reactLifecycles.unmount(props);
  }

  return { bootstrap, mount, unmount };
}
import React from 'react';
import { HashRouter } from 'react-router-dom';
import { renderRoutes }  from 'react-router-config';
import routes from './config/routes';
import { Provider } from 'mobx-react';
import stores from './stores';

const App = () => {
  return (
    <HashRouter>
      <Provider {...stores}>{renderRoutes(routes)}</Provider>
    </HashRouter>
  )
}

export default App;
import ReactDOM from 'react-dom';
import React from 'react';
import { singleSpaPacker } from './utils';
import App from './App';

if (process.env.NODE_ENV === 'development') {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('root')
  );
}

const { bootstrap, mount, unmount } = singleSpaPacker(App);
export { bootstrap, mount, unmount };

registerApplicationsingle-spa提供的加载器,包含四个参数:

  • appName: 注册的应用名称;
  • applicationOrLoadingFn:应用入口文件(必须是一个函数,返回一个函数或者一个promise);
  • activityFn:控制应用是否激活的函数(必须是一个纯函数,接受window.location作为参数,返回一个boolean);
  • customProps:在包装器生命周期函数中传递给子应用的props(应该是一个对象,可选)。
import { LifeCycles, registerApplication, start } from 'single-spa';

/** 匹配路由 */
const pathPrefix = (prefix: string) => {
  return (location: Location) => location.hash.startsWith(prefix);
}

/** 按需加载子应用 */
const applicationOrLoadingFn = async (url: string, appName: string): Promise<LifeCycles> => {
  // coding
}

registerApplication(appName, ({ name }) => applicationOrLoadingFn(url, name), pathPrefix(hash));

start();

子应用资源清单

上面single-spa方案流程中提到子应用构建生成的资源清单应该在部署时写入资源配置表中,例子中我准备直接请求子应用的资源清单。

构建配置 stats-webpack-plugin 插件,生成一个资源清单manifest.json文件,create-react-app搭建的react项目中webpack默认使用webpack-manifest-plugin生成资源清单。

子应用模块加载

动态加载子应用主要是让子应用自己将内容渲染到某个 DOM 节点,因而动态加载的目的主要是执行子应用的代码,另外是需要拿到子工程声明的一些生命周期钩子。

由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用。

// src/config-overrides.js
const pkg = require('./package.json');
const path = require('path')

module.exports = function override(config, env) {
  config.entry = path.resolve(__dirname, 'src/index.tsx');
  config.output.library = pkg.name;
  config.output.libraryTarget = 'window';

  delete config.optimization;

  return config;
};

我在使用create-react-app脚手架搭建React微前端项目时,选择umd模块格式,但是执行子应用脚本时生命周期钩子没有赋值给window[pkg.name],因为主应用全局环境下存在define函数属性,导致webpackUniversalModuleDefinition在做环境判断时采用的是amd模块格式,而主应用中并没有引入RequestJS,因此无法拿到钩子

image-20210723141439393.png

image-20210723141741936.png

因此,我将libraryTarget 设置成 'window',但是window[pkg.name]拿到的值却是数值1,查看main.chunk.js发现window[pkg.name]得到的是数组push后的返回值

image-20210723142314077.png

后面我将删除掉webpackoptimization配置后,window[pkg.name]能拿到钩子

image-20210723143057727.png

JS加载

dynamic script

/** 获取构建后生成的资源清单asset-manifest.json */
const fetchAssets = (url: string) => {
  return new Promise<string[]>(async (resolve) => {
    const mainfest = 'asset-manifest.json';
    const { data } = await axios.get(`${url}/${mainfest}?version=${Date.now()}`);
    const { files } = data;
    resolve(Object.values(files as string[]).filter(s => /\^*.(js|css)$/.test(s)));
  });
}

/** dynamic script */
const insertScript = (src: string) => {
  return new Promise<void>((resolve, reject) => {
    const script = document.createElement('script');
    script.charset = 'utf-8';
    script.async = true;
    script.crossOrigin = 'anonymous';
    script.onload = () => {
      document.head.removeChild(script);
      resolve();
    };
    script.onerror = reject;
    script.src = src;
    document.head.appendChild(script);
  }) 
}

/** 按需加载子应用 */
const applicationOrLoadingFn = async (url: string, appName: string): Promise<LifeCycles> => {
  /** 获取mainfest */
  const files = await fetchAssets(url);
  const JSList = files.filter(s => /\^*.js$/.test(s));
  /** 动态执行JS模块 */  
  await Promise.all(JSList.map((file: string) => insertScript(`${url}${file}`)))

  /** 返回子应用生命周期钩子 */
  return (window as any)[appName];
}

启动项目访问子应用时,发现window[appName]并没有拿到钩子,但是动态脚本全部写入到页面中,请求也发起了

image.png

image.png

image.png

我很怀疑上面的现场,为了验证这个问题,我写了一个html,直接注入script,然后使用http-server -p 8888 --cors启动

<!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">
  <title>Document</title>
</head>
<body>
  <script src="http://localhost:8234/static/js/main.00f2ddab.js?version=1626774357341"></script>
  <script src="http://localhost:8234/static/js/1.5ba77f0d.chunk.js?version=1626774357341"></script>
  <script src="http://localhost:8234/static/js/2.023d622e.chunk.js?version=1626774357352"></script>
  <script src="http://localhost:8234/static/js/3.92fd6021.chunk.js?version=1626774357374"></script>
</body>
</html>

发现例子通过window[appName]可以拿到声明周期钩子

image.png

那么为什么动态注入script没有执行呢?因为React 从设计层面上就具备了很好的防御 XSS 的能力,所以虽然你在"检查元素"时看到了<script>,但HTML里的代码实际是\&lt;script\&gt;,注入行为没成功,代码没执行

React 官方中提到了 React DOM 在渲染所有输入内容之前,默认会进行转义。它可以确保在你的应用中,永远不会注入那些并非自己明确编写的内容。所有的内容在渲染之前都被转换成了字符串,因此恶意代码无法成功注入,从而有效地防止了 XSS 攻击。详情可以参考浅谈 React 中的 XSS 攻击

eval/new Function

new Function()方案:用 fetch 或者其他请求库直接拿到子应用内容,然后用 new Function 将子模块作为 function 的函数体来执行,将输出挂载到 window,跟 eval 是类似的,但使用起来会清晰一些。

eval()方案:用 fetch 或者其他请求库直接拿到子应用内容,直接 eval 执行,将输出挂载到 window

/** 根据路由加载子应用 */
const applicationOrLoadingFn = async (url: string, appName: string): Promise<LifeCycles> => {
  /** 获取mainfest */
  const files = await fetchAssets(url);
  const JSList = files.filter(s => /\^*.js$/.test(s));

  await Promise.all(JSList.map((file: string) => axios.get(`${url}${file}?version=${Date.now()}`))).then(res => {
    res.forEach(r => {
      // new Function(r.data)()
      // eval(r.data)会报错
      window.eval(r.data) // 或者eval.call(null, r.data)
    })
  })

  return (window as any)[appName];
}

注意:使用eval执行子应用模块时需要指定eval执行函数体内的this指针,不然控制台会报Cannot read property 'webpackJsonpicec-cloud-inquiry-mall-react' of undefined

image-20210723154858823.png

image-20210723155702754.png

因为eval在松散模式下运行代码会在当前的作用域中创建局部变量,不会把webpackJsonpicec-cloud-inquiry-mall-react挂载到全局环境中,因此拿不到,所以需要在全局环境中调用eval。参考以 eval() 和 new Function() 执行JavaScript代码

window.eval(r.data) // 或者eval.call(null, r.data)

SystemJS

SystemJS 是一个插件化的、通用的模块加载器,它能在浏览器或者 NodeJS 上动态加载模块,并且支持 CommonJS、AMD、全局模块对象和 ES6 模块,也是Angular2官推的加载器。通过使用插件,它不仅可以加载 JavaScript,还可以加载 CoffeeScript TypeScript。虽然Chrome浏览器其实已经支持js代码中的import、export有一段时间了,但是其他浏览器还不支持,要想使用import动态加载只能引入polyfill

通常它支持创建的插件种类有:

  • CSS System.import('my/file.css!')
  • Image System.import('some/image.png!image')
  • JSON System.import('some/data.json!').then(function(json){})
  • Markdown System.import('app/some/project/README.md!').then(function(html) {})
  • Text System.import('some/text.txt!text').then(function(text) {})
  • WebFont System.import('google Port Lligat Slab, Droid Sans !font')

SystemJS原理核心思想:动态创建script标签执行模块,将执行结果模块导出

image-20210724160217422.png

SystemJS是在页面完成加载后解析importmap,然后加载执行模块,但是我的importmap并不是在页面加载前注入的,而是在加载完后动态注入的,因此不会被解析执行。那怎么办呢?好在SystemJS@6.4.0之后开始支持动态注入importmap,需要在主应用html中引入dynamic-import-maps.js

// dynamic-import-maps.js
(function(){/*
 * Support for live DOM updating import maps
 */
new MutationObserver(function (mutations) {
  for (var i = 0; i < mutations.length; i++) {
    var mutation = mutations[i];
    if (mutation.type === 'childList')
    for (var j = 0; j < mutation.addedNodes.length; j++) {
      var addedNode = mutation.addedNodes[j];
      if (addedNode.tagName === 'SCRIPT' && addedNode.type === 'systemjs-importmap' && !addedNode.sp) {
        System.prepareImport(true);
        break;
      }
    }
  }
}).observe(document, { childList: true, subtree: true });}());

image-20210724162455123.png

因此,我们可以在加载函数中动态注入importmap,然后执行返回子应用声明周期钩子

interface IImports {
  imports: {
    [key: string]: string;
  }
}

/** 动态注入importMap */
const insertNewImportMap = (newMapJSON: IImports) => {
  return new Promise<void>(resolve => {
      const newScript = document.createElement('script');
      newScript.type = 'systemjs-importmap';
      newScript.text = JSON.stringify(newMapJSON);
      
      /** 删除上次注入的importmap */
      [].filter.call(document.querySelectorAll('script'), (script) => script.type === 'systemjs-importmap').forEach(s => document.head.removeChild(s));

      document.head.appendChild(newScript);
      resolve();
  })
}

/** 根据路由加载子应用 */
const applicationOrLoadingFn = async (url: string, appName: string): Promise<LifeCycles> => {
  /** 获取mainfest */
  const files = await fetchAssets(url);
  /** 动态importmap */
  const maps: IImports = { imports: {} } as IImports;
  const JSList = files.filter(s => /\^*.js$/.test(s));

  JSList.forEach(async (file: string) => {
    maps.imports[`@${appName}${file}`] = `${url}${file}?version=${Date.now()}`
  });
  await insertNewImportMap(maps);
  
  await Promise.all(Object.keys(maps.imports).map((key: string) => (window as any).System.import(key)))

  return (window as any)[appName];
}
疑问:SystemJS也是动态创建script加载执行模块,跟直接创建动态script加载执行模块有什么区别吗?为什么SystemJS动态script加载执行模块时不会遇到XSS防御呢?

CSS加载

dynamic style

获取资源清单中的所有CSS,拼接成字符串,使用<style>注入页面中

/** 根据路由加载子应用 */
const applicationOrLoadingFn = async (url: string, appName: string): Promise<LifeCycles> => {
  /** 获取mainfest */
  const files = await fetchAssets(url);
  /** 动态importmap */
  const maps: IImports = { imports: {} } as IImports;
  const JSList = files.filter(s => /\^*.js$/.test(s));
  const styleText: string = '';
  const CSSList = files.filter(s => /\^*.css$/.test(s));

  /** 注入importmap */
  JSList.forEach(async (file: string) => {
    maps.imports[`@${appName}${file}`] = `${url}${file}?version=${Date.now()}`
  });
  await insertNewImportMap(maps);
  
  /** 注入style */
  await Promise.all(CSSList.map((file: string) => axios.get(`${url}${file}?version=${Date.now()}`))).then(res => {
    styleText = res.map(r => r.data).join('\n');
    const style = document.createElement('style');
    style.id = appName;
    style.appendChild(document.createTextNode(styleText));
    document.head.appendChild(style);
  })
  
  await Promise.all(Object.keys(maps.imports).map((key: string) => (window as any).System.import(key)))

  return (window as any)[appName];
}

沙箱隔离

JS沙箱

针对 JS 隔离的问题,我们独创了一个运行时的 JS 沙箱。简单画了个架构图:

img

即在应用的 bootstrapmount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。

class Sandbox {
  constructor(name) {
    this.name = name;
    this.modifyMap = {}; // 存放修改的属性
    this.windowSnapshot = {};
  }

  active() {
    // 缓存active状态的沙箱
    this.windowSnapshot = {};
    for (const item in window) {
      this.windowSnapshot[item] = window[item];
    }
    Object.keys(this.modifyMap).forEach(p => {
      window[p] = this.modifyMap[p];
    })
  }

  inactive() {
    for (const item in window) {
      if (this.windowSnapshot[item] !== window[item]) {
        // 记录变更
        this.modifyMap[item] = window[item];
        // 还原window
        window[item] = this.windowSnapshot[item];
      }
    }
  }
}
/** single-spa应用包装器 */
export const singleSpaPacker = (rootComponent: React.FC<any>) => {

  const reactLifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent,
    domElementGetter,
  })

  const sandbox = new Sandbox('');
  
  const bootstrap = (props: any) => {
    diffSandbox.active();
    return reactLifecycles.bootstrap(props);
  }

  const mount = (props: any) => {
    return reactLifecycles.mount(props);
  }

  const unmount = (props: any) => {
    diffSandbox.inactive();
    return reactLifecycles.unmount(props);
  }

  return { bootstrap, mount, unmount };
}

CSS隔离

在使用create-react-app创建的项目中,如果照下面方式引入样式是不会进行css modulepostcss处理

import 'index.css';
// 或者import 'index.scss';

const Demo = () => (
  <span className='text'/>
)

// 构建后注入页面的样式
<style>
  .text {}
</style>

一般我们在项目中使用第三方组件库时会这样引入,比如引入antd样式

import 'antd/dist/antd.css';

假如两个子应用中都是用了antd,但是如果引入的antd版本不一样,那么就可能会产生样式冲突。

附上react-scripts构建时对样式处理的关键代码,不难看出对import 'index.css'或者import 'index.scss'这两种引入方式不会进行css modulepostcss处理

// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    isEnvDevelopment && require.resolve('style-loader'),
    isEnvProduction && {
      loader: MiniCssExtractPlugin.loader,
      // css is located in `static/css`, use '../../' to locate index.html folder
      // in production `paths.publicUrlOrPath` can be a relative path
      options: paths.publicUrlOrPath.startsWith('.')
        ? { publicPath: '../../' }
        : {},
    },
    {
      loader: require.resolve('css-loader'),
      options: cssOptions,
    },
    {
      // Options for PostCSS as we reference these options twice
      // Adds vendor prefixing based on your specified browser support in
      // package.json
      loader: require.resolve('postcss-loader'),
      options: {
        // Necessary for external CSS imports to work
        // https://github.com/facebook/create-react-app/issues/2677
        ident: 'postcss',
        plugins: () => [
          require('postcss-flexbugs-fixes'),
          require('postcss-preset-env')({
            autoprefixer: {
              flexbox: 'no-2009',
            },
            stage: 3,
          }),
          // Adds PostCSS Normalize as the reset css with default options,
          // so that it honors browserslist config in package.json
          // which in turn let's users customize the target behavior as per their needs.
          postcssNormalize(),
        ],
        sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
      },
    },
  ].filter(Boolean);
  if (preProcessor) {
    loaders.push(
      {
        loader: require.resolve('resolve-url-loader'),
        options: {
          sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
          root: paths.appSrc,
        },
      },
      {
        loader: require.resolve(preProcessor),
        options: {
          sourceMap: true,
        },
      }
    );
  }
  return loaders;
};


// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
  test: cssRegex,
  exclude: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction
      ? shouldUseSourceMap
      : isEnvDevelopment,
  }),
  // Don't consider CSS imports dead code even if the
  // containing package claims to have no side effects.
  // Remove this when webpack adds a warning or an error for this.
  // See https://github.com/webpack/webpack/issues/6571
  sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
  test: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction
      ? shouldUseSourceMap
      : isEnvDevelopment,
    modules: {
      getLocalIdent: getCSSModuleLocalIdent,
    },
  }),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
  test: sassRegex,
  exclude: sassModuleRegex,
  use: getStyleLoaders(
    {
      importLoaders: 3,
      sourceMap: isEnvProduction
        ? shouldUseSourceMap
        : isEnvDevelopment,
    },
    'sass-loader'
  ),
  // Don't consider CSS imports dead code even if the
  // containing package claims to have no side effects.
  // Remove this when webpack adds a warning or an error for this.
  // See https://github.com/webpack/webpack/issues/6571
  sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
  test: sassModuleRegex,
  use: getStyleLoaders(
    {
      importLoaders: 3,
      sourceMap: isEnvProduction
        ? shouldUseSourceMap
        : isEnvDevelopment,
      modules: {
        getLocalIdent: getCSSModuleLocalIdent,
      },
    },
    'sass-loader'
  ),
},

针对上述不会进行CSS modulepostcss处理的引入方式场景需要做样式隔离。在加载子应用时给动态注入的<style>打上id标识,并将被加载的子应用名通过customProps传递给钩子,在子应用卸载时删除动态注入的<style>

registerApplication(appName, ({ name }) => applicationOrLoadingFn(url, name), (name, location) => ({
    appName: name,
}))
/** single-spa应用包装器 */
export const singleSpaPacker = (rootComponent: React.FC<any>) => {

  const reactLifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent,
    domElementGetter,
  })

  const sandbox = new Sandbox('');
  
  const bootstrap = (props: any) => {
    diffSandbox.active();
    return reactLifecycles.bootstrap(props);
  }

  const mount = (props: any) => {
    return reactLifecycles.mount(props);
  }

  const unmount = (props: any) => {
    /** 还原环境 */
    diffSandbox.inactive();
    /** 删除注入的样式 */
    document.getElementById(props.appName)?.remove();
    return reactLifecycles.unmount(props);
  }

  return { bootstrap, mount, unmount };
}

从子应用A切到子应用B然后再切回子应用A时发现卸载后的<style>不会重新注入页面,这是因为single-spa只会在首次加载子应用才会执行加载函数,其余情况走的都是缓存,因此上述做法不行。

然后我想了下能不能在mount阶段对相应的<style>加上display: block;,在unmount阶段对相应的<style>加上display: none;,控制样式生效呢?但是实际上发现这并不能控制<style>生效或者失效,加上display: none;也会生效。

const mount = (props: any) => {
  const styleLabel = document.getElementById(props.appName);
  if (styleLabel) {
    styleLabel.style.display = 'block';
  }
  return reactLifecycles.mount(props);
}

const unmount = (props: any) => {
  const styleLabel = document.getElementById(props.appName);
  if (styleLabel) {
    styleLabel.style.display = 'none';   
  }
  diffSandbox.inactive();
  return reactLifecycles.unmount(props);
}

那我将两次初次加载注入页面的<style>记录到全局对象中,然后通过customProps传递给子应用,在mount阶段注入页面,在unmount阶段删除

let styleTag: any = {};

/** 根据路由加载子应用 */
const applicationOrLoadingFn = async (url: string, appName: string): Promise<LifeCycles> => {
  /** 获取mainfest */
  const files = await fetchAssets(url);
  /** 获取JS脚本,并出入到importsmap中,后续通过systemjs获取 */
  // const maps: IImports = { imports: {} } as IImports;
  let styleText: string = '';
  const JSList = files.filter(s => /\^*.js$/.test(s));
  
  await Promise.all(JSList.map((file: string) => axios.get(`${url}${file}?version=${Date.now()}`))).then(res => {
    res.forEach(r => {
      // new Function(r.data)();
      eval.call(null, r.data);
    })
  })
  
  // SystemJS加载方式
  // JSList.forEach(async (file: string) => {
  //   maps.imports[`@${appName}${file}`] = `${url}${file}?version=${Date.now()}`
  // });
  // await insertNewImportMap(maps);
  // await Promise.all(Object.keys(maps.imports).map((key: string) => (window as any).System.import(key)))

  /** 注入style */
  const CSSList = files.filter(s => /\^*.css$/.test(s));
  await Promise.all(CSSList.map((file: string) => axios.get(`${url}${file}?version=${Date.now()}`))).then(res => {
    styleText = res.map(r => r.data).join('\n');
    const style = document.createElement('style');
    style.id = appName;
    style.appendChild(document.createTextNode(styleText));
    // 记录子应用style
    styleTag[appName] = style;
  })

  return (window as any)[appName];
}

/** 注册single-spa应用 */
routerConfig.forEach((item) => {
  const { appName, hash, url } = item;
  registerApplication(appName, ({ name }) => applicationOrLoadingFn(url, name), pathPrefix(hash), (name) => ({
    appName: name,
    styleTag // 将子应用style传递给钩子
  }));
})

子应用钩子注入、删除<style>

/** single-spa应用包装器 */
export const singleSpaPacker = (rootComponent: React.FC<any>) => {

  const reactLifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent,
    domElementGetter,
  })

  const diffSandbox = new DiffSandbox('');
  
  const bootstrap = (props: any) => {
    diffSandbox.active();
    return reactLifecycles.bootstrap(props);
  }

  const mount = (props: any) => {
    document.head.appendChild(props.styleTag[props.name]);
    return reactLifecycles.mount(props);
  }

  const unmount = (props: any) => {
    props.styleTag[props.appName].remove();
    diffSandbox.inactive();
    return reactLifecycles.unmount(props);
  }

  return { bootstrap, mount, unmount };
}

github传送门:https://github.com/Revelation...

参考

差点被SystemJs惊掉了下巴,解密模块加载黑魔法


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。