注册应用
往主应用注册子应用列表,保存到内部的appList中。之后配合路由拦截功能,根据activeRule读取对应的子应用信息。访问对应的入口地址获取html内容进行加载。
修改子应用的打包方式为umd模式,这样好配合后面JS运行获取导出的子应用事件及沙箱环境
// 子应用结构
[
{
name: 'react app', // 全局名称
entry: '//localhost:7100', // 子应用服务访问地址
container: '#yourContainer', // 在主应用内展示的容器 id
activeRule: '/yourActiveRule', // 路由拦截的前缀地址
},
]
// 修改打包方式
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
路由拦截
子应用是通过切换访问地址来变更的,所以就需要对路由的变化进行监听。主要分为API和浏览器切换的监听
- 重写
pushState/replaceState()方法,在执行时触发原方法及子应用切换- 可以内部直接执行切换函数,也可通过触发自定义事件来执行切换
- 监听
popstate事件实现在浏览器前进/回退及hash变动时,也触发子应用的切换 - 其他:在路由切换时会保存两个记录
window.__ORIGIN_APP__和window.__CURRENT_SUB_APP__为新旧两次的activeRule
// *拦截并重写 pushState/replaceState 方法
window.history.pushState = patchRouter(window.history.pushState, 'micro_push');
window.history.replaceState = patchRouter(window.history.replaceState, 'micro_replace');
// *添加自定义事件监听
window.addEventListener('micro_push', turnApp);
window.addEventListener('micro_replace', turnApp);
// *监听 hash/hsitory 事件。浏览器前进/回退时触发
window.addEventListener('popstate', function () {
// *执行对应的子应用
turnApp();
});
切换子应用
在路由变化时进行应用的切换,从__ORIGIN_APP__获取上一个应用。销毁它的沙箱信息及触发destoryed钩子函数。从__CURRENT_SUB_APP__获取当前应用信息,执行beforeLoad钩子函数并加载内容。
- 销毁上一个应用的沙箱对象,执行上一个应用
destoryed钩子函数 - 执行主应用和新子应用的
beforeLoad生命周期钩子 - 执行
loadHtml方法,加载子应用的内容- 解析对应的
html页面内容,获取dom文本和script/css信息 - 将
dom挂载到对应的容器id下 - 使用沙箱模式执行
js/css内容
- 解析对应的
运行及隔离
通过上一步已经挂载相应的DOM节点,但还未运行JS代码。而JS字符串大致有两种运行方式。
eval(js string)new Function(js string)()
export const performScriptForFunction = (script, appName, global) => {
window.__CURRENT_PROXY__ = global;
// *在自运行函数内执行,这样可以控制访问的 window
const scriptText = `
return ((window) => {
${script}
return window['${appName}']
})(window.__CURRENT_PROXY__)
`;
return new Function(scriptText)();
};
JS沙箱
开发中经常需要在window上扩张一下功能,而服务会在多个子应用之间切换。那么为了防止它们之间修改了同一个window属性,所以需要对运行环境进行沙箱隔离。
- 快照沙箱 SnapShotSandbox (兼容老浏览器)
- 先拷贝一份原始的
window对象所有的属性,之后上一个应用卸载时再用备份恢复
- 先拷贝一份原始的
/**
* 快照沙箱
* 把 window 对象的所有属性都备份一遍
* 之后在销毁时在恢复它
*/
export class SnapShotSandbox {
constructor() {
// 1.代理对象
this.proxy = window;
this.active();
}
// 沙箱激活
active() {
// 创建一个沙箱快照
this.snapshot = new Map();
// 遍历全局环境进行记录。相当于把当前 window 对象整个备份一遍
for (const key in window) {
this.snapshot[key] = window[key];
}
}
// 沙箱销毁
inactive() {
for (const key in window) {
// 把和备份不相同的内容,用备份的恢复
if (window[key] !== this.snapshot[key]) {
// 还原操作
window[key] = this.snapshot[key];
}
}
}
}
- 代理沙箱 ProxySanbox
- 创建一个假的
window出来,如果设置值就设置在fakeWindow上,这样就不会影响全局变量了。在取值时就判断属性是存在于fakeWindow上还是window上
- 创建一个假的
export class ProxySandbox {
proxy;
running = false;
constructor() {
const fakeWindow = Object.create(null); // 子应用的沙箱容器
this.proxy = new Proxy(window, {
get(target, key) {
// 执行函数时,重新指定一下 this -> window 。这样才能把对象指回去防止意外
if (typeof target[key] === 'function') {
return target[key].bind(target);
}
// 如果代理上没有,就去原本上查找
return fakeWindow[key] || target[key];
},
set(target, key, value) {
if (this.running) {
fakeWindow[key] = value;
}
return true;
},
});
this.active();
}
active() {
this.running = true;
}
inactive() {
this.running = false;
}
}
css隔离
- 在支持
shadowDOM情况下,将整个DOM放入创建的shadow内实现严格的样式隔离
// *从真实节点获取 innerHTML 并删除,然后放入 shadowDOM 内
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// 兼容处理
shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
- 或是改写子应用的所有样式,在前面添加一个特殊选择器来限制其范围。并将外联
<link>样式改为内联的<style>- 需要配合
MutationObserver去监听新增加<style>标签并添加特殊的选择器前缀
- 需要配合
<style>
div[data-micro=appName] .abc {}
</style>
<div data-micro="appName">
<p class="abc">子应用的节点内容</p>
</div>
<script>
// 原本的
const styleNodes = document.querySelector('style');
// 临时的
const _styleNode = document.createElement('style');
// 只有挂载到 body 后才有 StyleSheet 属性
document.body.appendChild(_styleNode);
// 拷贝一份内容
const textNode = document.createTextNode(styleNodes.textContent);
_styleNode.appendChild(textNode);
let newCSS = '';
// cssRules 是个类数组对象,包含 { type, cssText, selectorText } 等信息
Array.prototype.slice
.call(_styleNode.sheet.cssRules, 0)
.forEach((cssRule) => {
let { cssText } = cssRule;
cssText = cssText.replace(/^[\s\S]+{/, (selectors) => {
return `[data-micro=appName] ${selectors}`;
});
newCSS += cssText;
});
// 在替换原本 <style> 内容
styleNodes.textContent = newCSS;
// 再清除临时 style
_styleNode.removeChild(textNode);
</script>
完善功能
预加载
当用户在浏览器当前子应用时,可以在空闲阶段去加载其他未加载子应用。这样可以提高后续访问的效率。
- 定义全局的
resource_cache资源缓存对象,将子应用的dom/js/css等信息按name保存。当后续访问到该应用时,先判断resource_cache上是否存在。 - 配合
requestIdleCallback函数在浏览器空闲阶段,去访问appList上还未加载的应用资源进行保存
全局通信
- 可通过创建自定义的事件通信函数,并挂载到
window上以供访问 - 或是创建个闭包性质的全局
store,并挂载到window上以供访问
// 事件通信
export class Custom {
on(name, cb) {
window.addEventListener(name, (e) => {
cb(e.detail);
});
}
emit(name, data) {
const evt = new CustomEvent(name, { detail: data });
window.dispatchEvent(evt);
}
}
// 全局 store
export const createStore = (initDate = {}) =>
(() => {
let store = initDate;
const observers = []; // 管理所有订阅者,依赖
// 获取 store
const getStore = () => store;
// 更新 store
const updte = (value) => {
if (value !== store) {
const oldValue = store;
store = value;
// 通知所有的订阅者,监听 store 的变化
observers.forEach(async (item) => await item(store, oldValue));
}
};
// 添加订阅者
const subscribe = (fn) => {
observers.push(fn);
};
return {
getStore,
updte,
subscribe,
};
})();