微前端原理实现

# CSS隔离方案

# 子应用之间样式隔离

  • Dynamic Stylesheet动态样式表,当应用切换时移除老应用样式,添加新应用样式;

# 主子应用的样式隔离

  • BEM(Block Element Modifier)预定项目前缀;
  • CSS-Modules打包时生成不冲突的选择器名;
  • Shadw Dom 可以实现真正的隔离机制;【推荐】
  • css-in-js

# 影子Dom

# 定义

shadow-dom 其实是浏览器的一种能力,它允许在浏览器渲染文档(document)的时候向其中的 Dom 结构中插入一棵 DOM 元素子树,但是特殊的是,这棵子树(shadow-dom)并不在主 DOM 树中。

  • 直译过来就是影子DOM:他是独立封装的一段html代码块,他所包含的html标签、css样式和js行为都可以隔离、隐藏起来。
  • 与IFrame有点类似,不过IFrame是另外一个独立的html页面,shadow DOM是当前html页面的一个代码片段。
  • 他是由Web Components (opens new window)里提出的一项技术,其他还有Custom elements、HTML templates、HTML Imports这些。
  • shadow DOM并不是一个特别新的概念,html中的video标签就是使用shadow DOM的一个案例。使用它时,你在html只会看到一个video标签,但实际上播放器上还有一系列按钮和其他操作,这些就都是封装到shadow dom中的,对外界是不可见的。
  • 作用:shadow DOM可以把一部分html代码隔离起来,与外部完全不会互相干扰。
# 使用
  • 操作shadow DOM里的元素其实和普通DOM元素的操作一样,例如添加子节点、设置属性,以及为节点添加自己的样式(例如通过 element.style.foo属性),或者为整个 Shadow DOM添加样式(例如在<style>元素内添加样式)。
  • 使用shadow DOM时,首先要找到一个普通的标签元素(部分标签不行,比如button)作为shadow DOM的宿主元素,我们称为shadow host。然后通过host元素调用attachShadow({mode: 'open'})(mode要设为'open',才能后续往shadow DOM添加元素),attachShadow会返回一个元素,我们称为shadow root,它相当于shadow dom中的根元素。host元素有个属性shadowRoot就是指向它的。
  • 需要注意的一点是:如果shadow host下面有其他普通元素,在添加了Shadow Root后,其他普通元素就不会显示了

图示

image-20200730205533618

示例:

 <div>
        <p>hello world</p>
        <div id="shadowHost"></div>
    </div>
    <script>
        // dom的api  const shadowHost = document.querySelector('#shadowHost')
        let shadowDOM = document.getElementById('shadowHost').attachShadow({ mode: 'open' }); // 外界无法访问 shadow dom   【closed】
        let pElm = document.createElement('p');
        pElm.innerHTML = 'samy hello';
        let styleElm = document.createElement('style');
        styleElm.textContent = `
            p{color:red}
        `
        shadowDOM.appendChild(styleElm)
        shadowDOM.appendChild(pElm);
        // document.body.appendChild(pElm);
    </script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# JS沙箱机制

单应用切换沙箱创造一个干净的环境给这个子应用使用,当切换时可以选择丢弃属性和恢复属性当运行子应用时应该跑在内部沙箱环境中

  • 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例);
  • Proxy 代理沙箱,不影响全局环境;

图示模式:

image-20200730215023228

# 快照沙箱

# 场景

快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过proxy代理沙箱来实现

# 原理

  • 激活时将当前window属性进行快照处理
  • 失活时用快照中的内容和当前window属性比对;如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性
  • 在次激活时,再次进行快照,并用上次修改的结果还原window

# 模拟实现

class SnapshotSandbox {
  constructor() {
    this.proxy = window; 
    this.modifyPropsMap = {}; // 修改了那些属性
    this.active();
  }
  active() {
    this.windowSnapshot = {}; // window对象的快照
    for (const prop in window) {
      if (window.hasOwnProperty(prop)) {
        this.windowSnapshot[prop] = window[prop]; // 将window上的属性进行拍照
      }
    }
    Object.keys(this.modifyPropsMap).forEach(p => {
      window[p] = this.modifyPropsMap[p];
    });
  }
  inactive() {
    for (const prop in window) { // diff 差异
      if (window.hasOwnProperty(prop)) {
        // 将上次拍照的结果和本次window属性做对比
        if (window[prop] !== this.windowSnapshot[prop]) {
          this.modifyPropsMap[prop] = window[prop]; // 保存修改后的结果
          window[prop] = this.windowSnapshot[prop]; // 还原window
        }
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let sandbox = new SnapshotSandbox();
((window) => {
  window.a = 1;
  window.b = 2;
  window.c = 3
  console.log(a,b,c)
  sandbox.inactive();
  console.log(a,b,c)
})(sandbox.proxy);
1
2
3
4
5
6
7
8
9

# Proxy代理沙箱【要点】

# 场景

每个应用都创建一个proxy来代理window,好处是每个应用都是相对独立,不需要直接更改全局window属性!

# 模拟实现

class ProxySandbox {
  constructor() {
    const rawWindow = window;
    const fakeWindow = {}
    const proxy = new Proxy(fakeWindow, {
      set(target, p, value) {
        target[p] = value;
        return true
      },
      get(target, p) {
        return target[p] || rawWindow[p];
      }
    });
    this.proxy = proxy
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
  window.a = 'hello';
  console.log(window.a)
})(sandbox1.proxy);
((window) => {
  window.a = 'world';
  console.log(window.a)
})(sandbox2.proxy);
1
2
3
4
5
6
7
8
9
10
11

# 实现简化版SingleSpa

# 初始化开发环境

# 构建 rollup 配置

借助rollup模块化和打包的能力;

npm init -y
npm install -D  rollup rollup-plugin-serve
1
2

rollup.config.js umd设置,确保可以挂载到window上使用;

import serve from "rollup-plugin-serve";
export default {
  input: "./src/micro-my.js",
  output: {
    file: "./lib/umd/micro-my.js",
    format: "umd",
    name: "microMy",
    sourcemap: true,
  },
  plugins: [
    process.env.ENV === "development"
      ? serve({
          open: true,
          openPage: "/index.html",
          port: 3000,
          contentBase: "",
        })
      : null,
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# SignelSpaApi自定义

根据使用方式编写源码注册

const apps = [];
export function registerApplication(appName,loadApp,activeWhen,customProps){
    apps.push({
        name:appName,
        loadApp,
        activeWhen,
        customProps,
    });
}
export function start(){
    // todo...
}
export {registerApplication} from './applications/app.js';
export {start} from './start.js';
1
2
3
4
5
6
7
8
9
10
11
12
13
14

registerApplication参数分别是:

  • appName: 当前注册应用的名字
  • loadApp: 加载函数(必须返回的是promise),返回的结果必须包含bootstrapmountunmount做为接入协议
  • activityWhen: 满足条件时调用loadApp方法
  • customProps:自定义属性可用于父子应用通信

使用:

<script src="./lib/umd/micro-my.js"></script>
  <script>
    microMy.registerApplication('app1',
      async () => {
        return {
          bootstrap: async () => {
            console.log('应用启动');
          },
          mount: async () => {
            console.log('应用挂载');
          },
          unmount: async () => {
            console.log('应用卸载')
          }
        }
      },
      location => location.hash.startsWith('#/app1'),
      { store: { name: 'samy' } }
    );
    microMy.start();
  </script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 应用的状态管理及启动

# 应用加载状态 - 生命周期

image-20200802172948044

export const NOT_LOADED = "NOT_LOADED"; // 应用初始状态
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载资源
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 还没有调用bootstrap方法
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 启动中
export const NOT_MOUNTED = "NOT_MOUNTED"; // 没有调用mount方法
export const MOUNTING = "MOUNTING"; // 正在挂载中
export const MOUNTED = "MOUNTED"; // 挂载完毕
export const UPDATINMG = "UPDATINMG"; // 更新中
export const UNMOUNTING = "UNMOUNTING"; // 解除挂载
export const UNLOADING = "UNLOADING"; // 完全卸载中
export const LOAD_ERR = "LOAD_ERR";
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";
// 当前应用是否被激活
export function isActive(app) {
  return app.status === MOUNTED;
}
// 当前这个应用是否要被激活
export function shouldBeActive(app) {
  //如果返回true 那么应用应该就开始初始化等一系列操作
  return app.activeWhen(window.location);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

加载应用并启动

micro-my.js

// 先指定使用方法 再去写源码
export {registerApplication} from './applications/app'
export {start} from './start';
1
2
3

start.js

import { reroute } from "./navigations/reroute";
export let started = false;
export function start() {
  started = true; // 需要挂载应用
  reroute(); // 除了去加载应用还需要去挂载应用
}
1
2
3
4
5
6

# reroute方法【要点】

这个方法是整个Single-SPA中最核心的方法,当路由切换时也会执行该逻辑

# 1):获取对应状态的app

根据状态筛选对应的应用

export function getAppChanges() {
  const appsToUnmount = []; // 要卸载的app
  const appsToLoad = []; // 要加载的app
  const appsToMount = []; // 需要挂载的app
  apps.forEach((app) => {
    const appSholdBeActive = shouldBeActive(app); // 需不需要被加载
    switch (app.status) {
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appSholdBeActive) {
          appsToLoad.push(app);
        }
        break;
      case NOT_BOOTSTRAPPED:
      case BOOTSTRAPPING:
      case NOT_MOUNTED:
        if (appSholdBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED: // unmount
        if (!appSholdBeActive) {
          appsToUnmount.push(app);
        }
    }
  });
  return { appsToUnmount, appsToLoad, appsToMount };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 2):预加载应用

当用户没有调用start方法时,默认会先进行应用的加载

export function reroute() {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
  // start方法调用时是同步的,但是加载流程是异步饿
  if (!started) {
    return loadApps(); // 注册应用时 需要预先加载
  } else {
    return performAppChanges(); // start时调用;app装载
  }

  // 预加载应用
  async function loadApps() {
    let apps = await Promise.all(appsToLoad.map(toLoadPromise)); // 就是获取到bootstrap,mount和unmount方法放到app上
  }

  // 根据路径来装载应用
  async function performAppChanges() {
    // 先卸载不需要的应用;再去加载需要的应用
    let unmountPromises = appsToUnmount
      .map(toUnmountPromise)
      .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
      
    // 这个应用可能需要加载 但是路径不匹配加载app1的时候,这个时候切换到了app2
    const loadThenMountPromises = appsToLoad.map(async (app) => {
      app = await toLoadPromise(app); // 将需要求加载的应用拿到 => 加载 => 启动 => 挂载
      app = await toBootstrapPromise(app);
      return toMountPromise(app);
    });
    const mountPromises = appsToMount.map(async (app) => {
      app = await toBootstrapPromise(app);
      return toMountPromise(app);
    });

    //已经加载过了的应用 (启动 => 挂载)
    await Promise.all(unmountPromises); // 等待先卸载完成
    await Promise.all([...loadThenMountPromises, ...mountPromises]);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

用户load函数返回的bootstrapmountunmount可能是数组形式,我们将这些函数进行组合

function flattenFnArray(fns) {
  fns = Array.isArray(fns) ? fns : [fns];
  // 通过promise链来链式调用  多个方法组合成一个方法; compose的类似实现方式
  return (props) =>
    fns.reduce((p, fn) => p.then(() => fn(props)), Promise.resolve());
}

export async function toLoadPromise(app) {
  if (app.loadPromise) {
    return app.loadPromise; //缓存机制
  }
  return (app.loadPromise = Promise.resolve().then(async () => {
    app.status = LOADING_SOURCE_CODE;
    let { bootstrap, mount, unmount } = await app.loadApp(app.customProps);
    app.status = NOT_BOOTSTRAPPED; // 没有调用bootstrap方法
    // 希望将多个promise组合在一起compose
    app.bootstrap = flattenFnArray(bootstrap);
    app.mount = flattenFnArray(mount);
    app.unmount = flattenFnArray(unmount);
    delete app.loadPromise;
    return app;
  }));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# app运转逻辑

路由切换时卸载不需要的应用

import {toUnmountPromise} from '../lifecycles/unmount';
import {toUnloadPromise} from '../lifecycles/unload';
    // 先卸载不需要的应用;再去加载需要的应用
    let unmountPromises = appsToUnmount
    .map(toUnmountPromise)
      .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
1
2
3
4
5
6

为了更加直观,就采用最简单的方法来实现,调用钩子,并修改应用状态

import { UNMOUNTING, NOT_MOUNTED ,MOUNTED} from "../applications/app.helpers";
export async function toUnmountPromise(app){
    if(app.status != MOUNTED){
        return app;
    }
    app.status = UNMOUNTING;
    await app.unmount(app);
    app.status = NOT_MOUNTED;
    return app;
}
1
2
3
4
5
6
7
8
9
10
import { NOT_LOADED, UNLOADING } from "../applications/app.helpers";
const appsToUnload = {};
export async function toUnloadPromise(app){
    if(!appsToUnload[app.name]){
        return app;
    }
    app.status = UNLOADING;
    delete app.bootstrap;
    delete app.mount;
    delete app.unmount;
    app.status = NOT_LOADED;
}
1
2
3
4
5
6
7
8
9
10
11
12

匹配到没有加载过的应用 (加载=> 启动 => 挂载)

const loadThenMountPromises = appsToLoad.map(async (app) => {
    app = await toLoadPromise(app);
    app = await toBootstrapPromise(app);
    return toMountPromise(app);
});
1
2
3
4
5

这里需要注意一下,可能还有没加载完的应用这里不要进行重复加载

// 通过promise链来链式调用  多个方法组合成一个方法; compose的类似实现方式
function flattenFnArray(fns) {
  fns = Array.isArray(fns) ? fns : [fns];
  return (props) =>
    fns.reduce((p, fn) => p.then(() => fn(props)), Promise.resolve());
}

export async function toLoadPromise(app) {
  if (app.loadPromise) {
    return app.loadPromise; //缓存机制
  }
  return (app.loadPromise = Promise.resolve().then(async () => {
    app.status = LOADING_SOURCE_CODE;
    let { bootstrap, mount, unmount } = await app.loadApp(app.customProps);
    app.status = NOT_BOOTSTRAPPED; // 没有调用bootstrap方法
    app.bootstrap = flattenFnArray(bootstrap);// 希望将多个promise组合在一起compose
    app.mount = flattenFnArray(mount);
    app.unmount = flattenFnArray(unmount);
    delete app.loadPromise;
    return app;
  }));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { NOT_MOUNTED, MOUNTING, MOUNTED } from "../applications/app.helpers";

export async function toMountPromise(app) {
  if (app.status !== NOT_MOUNTED) {
    return app;
  }
  app.status = MOUNTING;
  await app.mount(app.customProps);
  app.status = MOUNTED;
  return app;
}
1
2
3
4
5
6
7
8
9
10
11
import { MOUNTED, UNMOUNTING, NOT_MOUNTED } from "../applications/app.helpers";

export async function toUnmountPromise(app) {
  if (app.status != MOUNTED) {// 当前应用没有被挂载直接什么都不做
    return app;
  }
  app.status = UNMOUNTING;
  await app.unmount(app.customProps);
  app.status = NOT_MOUNTED;
  return app;
}
1
2
3
4
5
6
7
8
9
10
11

已经加载过了的应用 (启动 => 挂载)

const mountPromises = appsToMount.map(async (app) => {
    app = await toBootstrapPromise(app);
    return toMountPromise(app);
});
await Promise.all(unmountPromises); // 等待先卸载完成
await Promise.all([...loadThenMountPromises,...mountPromises]); 
1
2
3
4
5
6

图示打印信息

image-20200803000521155

# 路由劫持【要点】

处理手动路由切换

以上流程是用于初始化操作的,还需要当路径切换时重新加载应用

export const routingEventsListeningTo = ["hashchange", "popstate"];
function urlReroute() {
  reroute([], arguments); // 会根据路径重新加载不同的应用
}
// 存储hashchang和popstate注册的方法
const capturedEventListeners = {
  hashchange: [], // 后续挂载的事件先暂存起来
  popstate: [], // 当应用切换完成后可以调用
};

// 处理应用加载的逻辑是在最前面
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
  if (
    routingEventsListeningTo.indexOf(eventName) >= 0 &&
    !capturedEventListeners[eventName].some((listener) => listener == fn)
  ) {
    capturedEventListeners[eventName].push(fn);
    return;
  }
  return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, fn) {
  if (routingEventsListeningTo.indexOf(eventName) >= 0) {
    capturedEventListeners[eventName] = capturedEventListeners[
      eventName
    ].filter((l) => l !== fn);
    return;
  }
  return originalRemoveEventListener.apply(this, arguments);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

为了保证应用加载逻辑最先被处理,对路由的一系列的方法进行重写,确保加载应用的逻辑最先被调用,其次手动派发事件

// 如果是hash路由hash变化时可以切换; 浏览器路由是h5api的,如果切换时不会触发popstate,故需手动重写
function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    updateState.apply(this, arguments); // 调用切换方法
    const urlAfter = window.location.href;
    if (urlBefore !== urlAfter) {
      urlReroute(new PopStateEvent("popstate")); // 重新加载应用 传入事件源
    }
  };
}

window.history.pushState = patchedUpdateState(
  window.history.pushState,
  "pushState"
);
window.history.replaceState = patchedUpdateState(
  window.history.replaceState,
  "replaceState"
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 加载应用

await Promise.all(appsToLoad.map(toLoadPromise)); // 加载后触发路由方法
callCapturedEventListeners(eventArguments);

await Promise.all(unmountPromises); // 等待先卸载完成后触发路由方法
callCapturedEventListeners(eventArguments);
1
2
3
4
5

校验当前是否需要被激活,在进行启动和挂载

function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromise.then(() =>
        shouldBeActive(app) ? toMountPromise(app) : app
      )
    );
  } else {
    return unmountAllPromise.then(() => app);
  }
}
1
2
3
4
5
6
7
8
9
10
11

# 批处理加载等待【要点】

流程图:

image-20200805234046055

export function reroute(pendings = [], eventArguments) {
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments
      })
    });
  }
  // ...
  if (started) {
    appChangeUnderway = true;
    return performAppChanges();
  }
  async function performAppChanges() {
    // ...
    finishUpAndReturn(); // 完成后批量处理在队列中的任务
  }
  function finishUpAndReturn(){
    appChangeUnderway = false;
    if(peopleWaitingOnAppChange.length > 0){
      const nextPendingPromises = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];
      reroute(nextPendingPromises)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

这里的思路和Vue.nextTick一样,如果当前应用正在加载时,并且用户频繁切换路由。我们会将此时的reroute方法暂存起来,等待当前应用加载完毕后再次触发reroute渲染应用,从而节约性能!

完成一轮应用加载时,需要手动触发用户注册的路由事件!

callAllEventListeners();
function callAllEventListeners() {
  pendingPromises.forEach((pendingPromise) => {
    callCapturedEventListeners(pendingPromise.eventArguments);
  });
  callCapturedEventListeners(eventArguments);
}
1
2
3
4
5
6
7

# 参考链接

  • https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model/Using_dynamic_styling_information

  • https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM

  • https://zh-hans.single-spa.js.org/docs/api/

上次更新: 2022/04/15, 05:41:30
×