微前端原理实现
# 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后,其他普通元素就不会显示了。
图示
示例:
<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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# JS沙箱机制
单应用切换沙箱创造一个干净的环境给这个子应用使用,当切换时可以选择丢弃属性和恢复属性;当运行子应用时应该跑在内部沙箱环境中
- 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例);
- Proxy 代理沙箱,不影响全局环境;
图示模式:
# 快照沙箱
# 场景
快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过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
}
}
}
}
}
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);
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
}
}
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);
2
3
4
5
6
7
8
9
10
11
# 实现简化版SingleSpa
# 初始化开发环境
# 构建
rollup
配置
借助rollup
模块化和打包的能力;
npm init -y
npm install -D rollup rollup-plugin-serve
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,
],
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# SignelSpa
Api自定义
根据使用方式编写源码注册
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';
2
3
4
5
6
7
8
9
10
11
12
13
14
registerApplication
参数分别是:
appName
: 当前注册应用的名字loadApp
: 加载函数(必须返回的是promise),返回的结果必须包含bootstrap
、mount
和unmount
做为接入协议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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 应用的状态管理及启动
# 应用加载状态 - 生命周期
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);
}
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';
2
3
start.js
import { reroute } from "./navigations/reroute";
export let started = false;
export function start() {
started = true; // 需要挂载应用
reroute(); // 除了去加载应用还需要去挂载应用
}
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 };
}
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]);
}
}
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函数返回的bootstrap
、mount
、unmount
可能是数组形式,我们将这些函数进行组合
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;
}));
}
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));
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;
}
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;
}
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);
});
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;
}));
}
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;
}
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;
}
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]);
2
3
4
5
6
图示打印信息
# 路由劫持【要点】
处理手动路由切换
以上流程是用于初始化操作的,还需要当路径切换时重新加载应用
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);
};
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"
);
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);
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);
}
}
2
3
4
5
6
7
8
9
10
11
# 批处理加载等待【要点】
流程图:
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)
}
}
}
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);
}
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/