微前端无界方案
介绍
无界利用 iframe 和 webcomponent 来搭建天然的 js 沙箱和 css 沙箱。
能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 支持、应用共享等用户的核心诉求。
无界的使用
主应用
1、引入
// 无框架时使用'wujie'
import Wujie from 'wujie';
// 当结合框架时使用'wujie-xxx'
// import Wujie from "wujie-vue2";
// import Wujie from "wujie-vue3";
// import Wujie from "wujie-react";
const { bus, setupApp, preloadApp, startApp, destroyApp } = Wujie;
提示
如果主应用是 vue 框架可直接使用 wujie-vue,react 框架可直接使用 wujie-react
2、设置子应用
【非必须】由于 preloadApp
和 startApp
的参数重复,为了避免重复输入,可以通过 setupApp
来统一设置默认参数。
setupApp({
name: '唯一id',
url: '子应用地址',
exec: true,
el: '容器',
sync: true,
});
3-1、启动子应用
startApp({ name: '唯一id' });
3-2、预加载
preloadApp({ name: '唯一id' });
3-3、以组件形式调用
无界支持以组件的形式使用。
vue
安装
# vue2 框架
npm i wujie-vue2 -S
# vue3 框架
npm i wujie-vue3 -S
引入
// main.js
// vue2
import WujieVue from 'wujie-vue2';
// vue3
// import WujieVue from "wujie-vue3";
// 全局注册组件(以vue为例)
Vue.use(WujieVue);
const { bus, setupApp, preloadApp, startApp, destroyApp } = WujieVue;
使用
使用 组件,相当于使用了startApp
来调用,因此可以忽略startApp
的使用了!!
<template>
<!-- 单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
<WujieVue
width="100%"
height="100%"
name="vue2"
:url="vue2Url"
:sync="true"
:fetch="fetch"
:props="props"
:beforeLoad="beforeLoad"
:beforeMount="beforeMount"
:afterMount="afterMount"
:beforeUnmount="beforeUnmount"
:afterUnmount="afterUnmount"
></WujieVue>
<!-- 子应用通过$wujie.bus.$emit(event, args)出来的事件都可以直接@event来监听 -->
</template>
<script>
// import hostMap from "./hostMap";
export default {
computed: {
vue2Url() {
// 这里拼接成子应用的域名(例如://localhost:7200/home)
return hostMap('//localhost:7200/') + `#/${this.$route.params.path}`;
},
},
};
</script>
// hostMap.js
const map = {
'//localhost:7100/': '//wujie-micro.github.io/demo-react17/',
'//localhost:7200/': '//wujie-micro.github.io/demo-vue2/',
'//localhost:7300/': '//wujie-micro.github.io/demo-vue3/',
'//localhost:7500/': '//wujie-micro.github.io/demo-vite/',
};
export default function hostMap(host) {
if (process.env.NODE_ENV === 'production') return map[host];
return host;
}
WujieVue组件
接收的参数如下:
WujieVue组件
接收的参数基本上与startApp
的一致。
不同之处在于startApp
有html
、el
,没有width
、height
。
const wujieVueOptions = {
name: 'WujieVue',
props: {
width: { type: String, default: '' },
height: { type: String, default: '' },
name: { type: String, default: '' },
loading: { type: HTMLElement, default: undefined },
url: { type: String, default: '' },
sync: { type: Boolean, default: false },
prefix: { type: Object, default: undefined },
alive: { type: Boolean, default: false },
props: { type: Object, default: undefined },
replace: { type: Function, default: undefined },
fetch: { type: Function, default: undefined },
fiber: { type: Boolean, default: true },
degrade: { type: Boolean, default: false },
plugins: { type: Array, default: null },
beforeLoad: { type: Function, default: null },
beforeMount: { type: Function, default: null },
afterMount: { type: Function, default: null },
beforeUnmount: { type: Function, default: null },
afterUnmount: { type: Function, default: null },
activated: { type: Function, default: null },
deactivated: { type: Function, default: null },
loadError: { type: Function, default: null },
},
};
子应用改造
无界对子应用的侵入非常小,在满足跨域条件下子应用可以不用改造。
1、前提
子应用的资源和接口的请求都在主域名发起,所以会有跨域问题,子应用必须做cors 设置。
app.use((req, res, next) => {
// 路径判断等等
res.set({
'Access-Control-Allow-Credentials': true,
'Access-Control-Allow-Origin': req.headers.origin || '*',
'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
'Content-Type': 'application/json; charset=utf-8',
});
// 其他操作
});
2、生命周期改造
改造入口函数:
- 将子应用路由的创建、实例的创建渲染挂载到
window.__WUJIE_MOUNT
函数上 - 将实例的销毁挂载到
window.__WUJIE_UNMOUNT
上 - 如果子应用的实例化是在异步函数中进行的,在定义完生命周期函数后,请务必主动调用无界的渲染函数
window.__WUJIE.mount()
具体操作可以参考下面示例
// vue 2
if (window.__POWERED_BY_WUJIE__) {
let instance;
window.__WUJIE_MOUNT = () => {
const router = new VueRouter({ routes });
instance = new Vue({ router, render: (h) => h(App) }).$mount('#app');
};
window.__WUJIE_UNMOUNT = () => {
instance.$destroy();
};
} else {
new Vue({ router: new VueRouter({ routes }), render: (h) => h(App) }).$mount('#app');
}
// vue 3
if (window.__POWERED_BY_WUJIE__) {
let instance;
window.__WUJIE_MOUNT = () => {
const router = createRouter({ history: createWebHistory(), routes });
instance = createApp(App);
instance.use(router);
instance.mount('#app');
};
window.__WUJIE_UNMOUNT = () => {
instance.unmount();
};
} else {
createApp(App)
.use(createRouter({ history: createWebHistory(), routes }))
.mount('#app');
}
// vite
declare global {
interface Window {
// 是否存在无界
__POWERED_BY_WUJIE__?: boolean;
// 子应用mount函数
__WUJIE_MOUNT: () => void;
// 子应用unmount函数
__WUJIE_UNMOUNT: () => void;
// 子应用无界实例
__WUJIE: { mount: () => void };
}
}
if (window.__POWERED_BY_WUJIE__) {
let instance: any;
window.__WUJIE_MOUNT = () => {
const router = createRouter({ history: createWebHistory(), routes });
instance = createApp(App)
instance.use(router);
instance.mount("#app");
};
window.__WUJIE_UNMOUNT = () => {
instance.unmount();
};
/*
由于vite是异步加载,而无界可能采用fiber执行机制
所以mount的调用时机无法确认,框架调用时可能vite
还没有加载回来,这里采用主动调用防止用没有mount
无界mount函数内置标记,不用担心重复mount
*/
window.__WUJIE.mount()
} else {
createApp(App).use(createRouter({ history: createWebHistory(), routes })).mount("#app");
}
// react
if (window.__POWERED_BY_WUJIE__) {
window.__WUJIE_MOUNT = () => {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
};
window.__WUJIE_UNMOUNT = () => {
ReactDOM.unmountComponentAtNode(document.getElementById('root'));
};
} else {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
}
3、构建工具的配置说明
vue-cli
子应用的路有模式为 history
时,publicPath
不要带.
,正确使用:publicPath: "/"
子应用的路有模式为 hash
时,publicPath
可以带.
,正确使用:publicPath: "./"
// vue.config.js
module.exports = {
outputDir: 'dist',
// histotry模式设置publicPath不要带.
publicPath: '/',
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': '*',
},
port: '7201',
},
};
无界 功能介绍
运行模式
无界有三种运行模式:单例模式
、保活模式
、重建模式
其中保活模式
、重建模式
子应用无需做任何改造,而单例模式
则需要做生命周期改造!!!
在微前端框架中,子应用会随着主应用页面的打开和关闭反复的激活和销毁(单例模式:生命周期模式)。而在无界微前端框架中,子应用还可以以其他方式进行处理(保活模式、重建模式),这样会进入完全不同的处理流程。
保活模式
子应用的 alive
设置为 true
时进入保活模式
,内部的数据和路由的状态不会随着页面切换而丢失。
在保活模式下,子应用只会进行一次渲染,页面发生切换时承载子应用 dom
的 webcomponent
会保留在内存中,当子应用重新激活时无界会将内存中的 webcomponent
重新挂载到容器上。
注意:
- 保活模式下改变
url
子应用的路由不会发生变化,需要采用 通信 的方式对子应用路由进行跳转。- 保活的子应用的实例不会销毁,子应用被切走了也可以响应
bus
事件,非保活的子应用切走了监听的事件也会全部销毁,需要等下次重新mount
后重新监听。
单例模式
子应用的 alive
为 false
且进行了生命周期改造时进入单例模式
。
子应用页面如果切走,会调用 window.__WUJIE_UNMOUNT
销毁子应用当前实例,子应用页面如果切换回来,会调用 window.__WUJIE_MOUNT
渲染子应用新的实例。
在单例式下,改变 url
子应用的路由会发生跳转到对应路由。
注意:
如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候将name
设置为同一个,这样可以共享一个wujie
实例,承载子应用js
的iframe
也实现了共享,不同页面子应用的url
不同,切换这个子应用的过程相当于:销毁当前应用实例 => 同步新路由 => 创建新应用实例
。
重建模式(一般不使用,非常消耗资源)
子应用既没有设置为保活模式,也没有进行生命周期的改造则进入了重建模式
,每次页面切换不仅会销毁承载子应用 dom
的 webcomponent
,还会销毁承载子应用 js
的 iframe
,相应的 wujie
实例和子应用实例都会被销毁。
重建模式下改变 url
子应用的路由会跳转对应路由,但是在 路由同步 场景并且子应用的路由同步参数已经同步到主应用 url
上时则无法生效,因为改变 url
后会导致子应用销毁重新渲染,此时如果有同步参数则同步参数的优先级最高。
生命周期
无界提供的生命周期,与 vue 的生命周期设计非常类似。
其中,保活模式
下,才会执行activated
,deactivated
,其余的生命周期在单例模式
下都会执行。
beforeLoad
类型:
type lifecycle = (appWindow: Window) => any;
子应用开始加载静态资源前触发
beforeMount
类型:
type lifecycle = (appWindow: Window) => any;
子应用渲染(调用window.__WUJIE_MOUNT
)前触发
afterMount
类型:
type lifecycle = (appWindow: Window) => any;
子应用渲染(调用window.__WUJIE_MOUNT
)后触发
beforeUnmount
类型:
type lifecycle = (appWindow: Window) => any;
子应用卸载(调用window.__WUJIE_UNMOUNT
)前触发
afterUnmount
类型:
type lifecycle = (appWindow: Window) => any;
子应用卸载(调用window.__WUJIE_UNMOUNT
)后触发
activated
类型:
type lifecycle = (appWindow: Window) => any;
子应用保活模式
下,进入时触发
deactivated
类型:
type lifecycle = (appWindow: Window) => any;
子应用保活模式
下,离开时触发
loadError
类型:
type loadErrorHandler = (url: string, e: Error) => any;
子应用加载资源失败后触发
通讯方式
无界提供三种方式进行通信
props 通信
主应用可以通过props
注入数据和方法:
<WujieVue :props="{ data: xxx, methods: xxx }"></WujieVue>
子应用可以通过$wujie
来获取:
const props = window.$wujie?.props; // {data: xxx, methods: xxx}
注意:
子应用是通过全局属性$wujie
获取props
,而不是在生命周期中获取!!!
window 通信
由于子应用运行的 iframe
的 src
和主应用是同域的,所以相互可以直接通信
主应用调用子应用的全局数据:
window.document.querySelector('iframe[name=子应用id]').contentWindow.xxx;
子应用调用主应用的全局数据:
window.parent.xxx;
eventBus 通信
无界提供一套去中心化的通信方案,主应用和子应用、子应用和子应用都可以通过这种方式方便的进行通信, 详见 EventBus Api
主应用使用方式:
// 如果使用wujie
import { bus } from "wujie";
// 如果使用wujie-vue
import WujieVue from "wujie-vue";
const { bus } = WujieVue;
// 如果使用wujie-react
import WujieReact from "wujie-react";
const { bus } = WujieReact;
// 主应用监听事件
bus.$on("事件名字", function (arg1, arg2, ...) {});
// 主应用发送事件
bus.$emit("事件名字", arg1, arg2, ...);
// 主应用取消事件监听
bus.$off("事件名字", function (arg1, arg2, ...) {});
子应用使用方式:
// 子应用监听事件
window.$wujie?.bus.$on("事件名字", function (arg1, arg2, ...) {});
// 子应用发送事件
window.$wujie?.bus.$emit("事件名字", arg1, arg2, ...);
// 子应用取消事件监听
window.$wujie?.bus.$off("事件名字", function (arg1, arg2, ...) {});
预加载
tips:预加载能力可以极大的提升子应用打开的首屏时间,但同时在首次渲染时阻塞主应用,当预加载多个子应用时,会出现比较长的白屏时间!!!
预加载
预加载指的是在应用空闲的时候 requestIdleCallback
将所需要的静态资源提前从网络中加载到内存中,详见 preloadApp
预执行
预执行指的是在应用空闲的时候将子应用提前渲染出来,可以进一步提升子应用打开时间。
只需要在 preloadApp
中将 exec
设置为 true
即可。
注意:由于子应用提前渲染可能会导致阻塞主应用的线程,所以无界提供了类似 react-fiber 方式来防止阻塞线程,详见 fiber
路由同步
路由同步
路由同步会将子应用路径的 path+query+hash
通过 window.encodeURIComponent
编码后挂载在主应用 url
的查询参数上,其中 key
值为子应用的 name。
开启路由同步后,刷新浏览器或者将 url
分享出去子应用的路由状态都不会丢失,当一个页面存在多个子应用时无界支持所有子应用路由同步,浏览器刷新、前进、后退子应用路由状态也都不会丢失
开启参数 sync
注意
只有无界实例在初次实例化的时候才会从
url
上读回路由信息,一旦实例化完成后续只会单向的将子应用路由同步到主应用url
上
重点:
wujie 提供了路由同步的功能,主应用无需注册子应用路由,也可以实现跨应用的跳转动作。
此功能非常 nice,解决了像 qiankun 这样的微前端框架,父子应用之间路由注册的老大难问题。
短路径(路由别名)
无界提供短路径的能力,当子应用的 url
过长时,可以通过配置 prefix
来缩短子应用同步到主应用的路径,无界在选取短路径的时候,按照匹配最长路径原则选取短路径。
完成匹配后子应用匹配到的路径将被{短路径} + 剩余路径
的方式挂载到主应用 url
上,注意在匹配路径的时候请不要带上域名。
示例:
<WujieVue
width="100%"
height="100%"
name="xxx"
:url="xxx"
:sync="true"
:prefix="{
prod: '/example/prod',
test: '/example/test',
prodId: '/example/prod/debug?id=',
}"
></WujieVue>
此时子应用不同路径将转换如下:
/example/prod/hello => {prod}/hello
/example/test/name => {test}/name
/example/prod/debug?id=5&age=10 => {prodId}5&age=10
路由跳转
主应用为 history 模式
子应用 A 要打开子应用 B
以 vue 主应用为例,子应用 A 的 name 为 A, 主应用 A 页面的路径为/pathA
,子应用 B 的 name 为 B,主应用 B 页面的路径为/pathB
主应用 A 页面:
<template>
<!-- 子应用 A -->
<wujie-vue name="A" url="//hostA.com" :props="{jump}" ></WujieVue>
</template>
<script>
export default {
methods: {
jump(location) {
this.$router.push(location);
}
}
}
</script>
子应用 A 通过调用主应用传递的 jump 函数,跳转到子应用 B 的页面
// 子应用 A 点击跳转处理函数
function handleJump() {
window.$wujie?.props.jump({ path: '/pathB' });
}
子应用 A 要打开子应用 B 的指定路由
上面的方法,A 应用只能跳转到应用 B 的在主应用的默认路由,如果需要跳转到 B 应用的指定路由比如 /test
:
- 子应用 B 开启路由同步能力
- 子应用的点击跳转函数:
// 子应用 A 点击跳转处理函数
function handleJump() {
window.$wujie?.props.jump({ path: '/pathB', query: { B: '/test' } });
}
由于跳转后的链接的查询参数带上了 B 应用的路径信息,而子应用 B 开启了路由同步的能力,所以能从 url 上读回需要同步的路径,注意这种办法只有在 B 应用未曾激活过才生效。
子应用 B 为保活应用
如果子应用 B 是保活应用并且没有被打开过,也就是还没有实例化,上述的打开指定路由的方式可以正常工作,但如果子应用 B 已经实例化,保活应用的内部数据和路由状态都会保存下来不随子应用切换而丢失。
这时如果要打开子应用 B 的指定路由可以使用通信的方式 :
子应用 A 点击跳转处理函数
// 子应用 A 点击跳转处理函数
function handleJump() {
window.$wujie?.bus.$emit('routeChange', '/test');
}
子应用 B
// 子应用 B 监听并跳转
window.$wujie?.bus.$on('routeChange', (path) => this.$router.push({ path }));
主应用为 hash 模式
当主应用为 hash 模式时,主应用路由的 query 参数会挂载到 hash 的值后面,而无界路由同步读取的是 url 的 query 查询参数,所以需要手动的挂载查询参数
子应用 A 要打开子应用 B
同上
子应用 A 要打开子应用 B 的指定路由
- 主应用 的 jump 修改:
<template>
<wujie-vue name="A" url="//hostA.com" :props="{jump}"></wujie-vue>
</template>
<script>
export default {
methods: {
jump(location, query) {
// 跳转到主应用B页面
this.$router.push(location);
const url = new URL(window.location.href);
url.search = query
// 手动的挂载url查询参数
window.history.replaceState(null, "", url.href);
}
}
</script>
-
子应用 B 开启路由同步能力
-
子应用的点击跳转函数:
function handleJump() {
window.$wujie?.props.jump({ path: "/pathB" } , `?B=${window.encodeURIComponent("/test")}`});
}
子应用 B 为保活应用
同上
插件系统
无界的插件体系主要是方便用户在运行时去修改子应用代码从而避免去改动仓库代码,详见API
html-loader
无界提供插件在运行时对子应用的 html 文本进行修改
- 示例
const plugins = [
{
// 对子应用的template进行的aaa替换成bbb
htmlLoader: (code) => {
return code.replace('aaa', 'bbb');
},
},
];
js-excludes
如果用户想加载子应用的时候,不执行子应用中的某些js
文件
那么这些工作可以放置在js-excludes
中进行
- 示例
const plugins = [
// 子应用的 http://xxxxx.js 或者符合正则 /test\.js/ 脚本将不在子应用中进行
{ jsExcludes: ['http://xxxxx.js', /test\.js/] },
];
js-ignores
如果用户想子应用自己加载某些js
文件(通过script
标签),而非框架劫持加载(通常会导致跨域)
那么这些工作可以放置在js-ignores
中进行
- 示例
const plugins = [
// 子应用的 http://xxxxx.js 或者符合正则 /test\.js/ 脚本将由子应用自行加载
{ jsIgnores: ['http://xxxxx.js', /test\.js/] },
];
警告
jsIgnores 中的 js 文件由于是子应用自行加载没有对 location 进行劫持,如果有对 window.location.href 进行操作复制请务必替换成 window.$wujie.location.href 的操作,否则子应用的沙箱会被取代掉
js-before-loaders
如果用户想在html
中所有的js
之前做:
- 在子应用运行一个
src="http://xxxxx"
的脚本 - 在子应用中运行一个内联的 js 脚本
<script>content</script>
- 执行一个回调函数
那么这些工作可以放置在js-before-loaders
中进行
- 示例
const plugins = [
{
// 在子应用所有的js之前
jsBeforeLoaders: [
// 插入一个外联脚本
{ src: 'http://xxxx.js' },
// 插入一个内联监本
{ content: 'console.log("test")' },
// 执行一个回调,打印子应用名字
{
callback(appWindow) {
console.log('js-before-loader-callback', appWindow.__WUJIE.id);
},
},
],
},
];
js-loader
如果用户想将子应用的某个js
脚本的代码进行替换,可以在这个地方进行处理
- 示例
const plugins = [
{
// 将url为aaa.js的脚本中的aaa替换成bbb
// code 为脚本代码、url为脚本的地址(内联脚本为'')、base为子应用当前的地址
jsLoader: (code, url, base) => {
if (url === 'aaa.js') return code.replace('aaa', 'bbb');
},
},
];
警告
- 对于 esm 脚本不会经过 js-loader 插件处理
- 对于 js-ignores 脚本不会经过 js-loader 插件处理
js-after-loader
如果用户想在html
中所有的js
之后做:
- 在子应用运行一个
src="http://xxxxx"
的脚本 - 在子应用中运行一个内联的 js 脚本
<script>content</script>
- 执行一个回调函数
那么这些工作可以放置在js-after-loaders
中进行
- 示例
const plugins = [
{
jsAfterLoaders: [
// 插入一个外联脚本
{ src: 'http://xxxx.js' },
// 插入一个内联监本
{ content: 'console.log("test")' },
// 执行一个回调,打印子应用名字
{
callback(appWindow) {
console.log('js-after-loader-callback', appWindow.__WUJIE.id);
},
},
],
},
];
css-excludes
如果用户想加载子应用的时候,不加载子应用中的某些css
文件
那么这些工作可以放置在css-excludes
中进行
- 示例
const plugins = [
// 子应用的 http://xxxxx.css 脚本将不在子应用中加载
{ cssExcludes: ["http://xxxxx.css" /test\.css/] },
];
css-ignores
如果用户想子应用自己加载某些css
文件(通过link
标签),而非框架劫持加载(通常会导致跨域)
那么这些工作可以放置在css-ignores
中进行
- 示例
const plugins = [
// 子应用的 http://xxxxx.css 或者符合正则 /test\.css/ 脚本将由子应用自行加载
{ cssIgnores: ['http://xxxxx.css', /test\.css/] },
];
css-before-loaders
如果用户想在html
中所有的css
之前做:
- 插入一个
src="http://xxxxx"
的外联样式脚本 - 插入一个
<style>content</style>
的内联样式脚本
那么这些工作可以放置在css-before-loaders
中进行
- 示例
const plugins = [
{
// 在子应用所有的css之前
cssBeforeLoaders: [
//在加载html所有的样式之前添加一个外联样式
{ src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css' },
//在加载html所有的样式之前添加一个内联样式
{ content: 'img{width: 300px}' },
],
},
];
css-loader
无界提供插件在运行时对子应用的css
文本进行修改
- 示例
const plugins = [
{
// 对css脚本动态的进行替换
// code 为样式代码、url为样式的地址(内联样式为'')、base为子应用当前的地址
cssLoader: (code, url, base) => {
console.log('css-loader', url, code.slice(0, 50) + '...');
return code;
},
},
];
css-after-loaders
如果用户想在html
中所有的css
之后做:
- 插入一个
src="http://xxxxx"
的外联样式脚本 - 插入一个
<style>content</style>
的内联样式脚本
那么这些工作可以放置在css-after-loaders
中进行
- 示例
const plugins = [
{
cssAfterLoaders: [
//在加载html所有样式之后添加一个外联样式
{ src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css' },
//在加载html所有样式之后添加一个内联样式
{ content: 'img{height: 300px}' },
],
},
];
windowAddEventListenerHook
子应用的window
添加监听事件时执行的回调函数
- 示例
无界子应用的dom
渲染在webcomponent
中,js
在iframe
中运行,往往子应用在外部的容器滚动,所以监听window
的scroll
事件是无效的,可以将处理window
的scroll
事件绑定在滚动容器中
const plugins = [
{
windowAddEventListenerHook(iframeWindow, type, handler, options) {
container.addEventListener(type, handler, options);
},
},
];
windowRemoveEventListenerHook
子应用的window
移除监听事件时执行的回调函数
- 示例
const plugins = [
{
windowAddEventListenerHook(iframeWindow, type, handler, options) {
container.addEventListener(type, handler, options);
},
windowRemoveEventListenerHook(iframeWindow, type, handler, options) {
container.removeEventListener(type, handler, options);
},
},
];
documentAddEventListenerHook
子应用的document
添加监听事件时执行的回调函数
- 示例
无界子应用的dom
渲染在webcomponent
中,js
在iframe
中运行,往往子应用在外部的容器滚动,所以监听document
的scroll
事件是无效的,可以将处理document
的scroll
事件绑定在滚动容器中
const plugins = [
{
documentAddEventListenerHook(iframeWindow, type, handler, options) {
container.addEventListener(type, handler, options);
},
},
];
documentRemoveEventListenerHook
子应用的document
移除监听事件时执行的回调函数
- 示例
const plugins = [
{
documentAddEventListenerHook(iframeWindow, type, handler, options) {
container.addEventListener(type, handler, options);
},
documentRemoveEventListenerHook(iframeWindow, type, handler, options) {
container.removeEventListener(type, handler, options);
},
},
];
appendOrInsertElementHook
子应用往body
、head
插入元素后执行的回调函数
- 示例
const plugins = [
{
// element 为真正插入的元素,iframeWindow 为子应用的 window, rawElement为原始插入元素
appendOrInsertElementHook(element, iframeWindow, rawElement) {
console.log(element, iframeWindow, rawElement);
},
},
];
patchElementHook
子应用创建元素后执行的回调函数
- 示例
const plugins = [
{
patchElementHook(element, iframeWindow) {
console.log(element, iframeWindow);
},
},
];
降级处理
无界提供无感知的降级方案
在非降级场景下,子应用的dom
在webcomponent
中,运行环境在iframe
中,iframe
对dom
的操作通过proxy
来代理到webcomponent
上,而webcomponent
和proxy
IE
都无法支持,这里采用另一个的iframe
替换webcomponent
,用Object.defineProperty
替换proxy
来做代理的方案
注意
无界并没有对 es6 代码进行 polyfill,因为每个用户对浏览器的兼容程度是不一样的引入的 polyfill 也不一致,如果需要在较低版本的浏览器中运行,需要用户自行 通过 babel 来添加 polyfill。
优点:
- 降级的行为由框架判断,当浏览器不支持时自动降级
- 降级后,应用之间也保证了绝对的隔离度
- 代码无需做任何改动,之前的预加载、保活还有通信的代码都生效,用户不需要为了降级做额外的代码改动导致降级前后运行的代码不一致
- 用户也可以强制降级,比如说当前浏览器对
webcomponent
和proxy
是支持的,但是用户还是想将 dom 运行在 iframe 中,就可以将degrade
设置为 true
缺点:
- 弹窗只能在子应用内部
- 由于无法使用
proxy
,无法劫持子应用的location
,导致访问window.location.host
的时候拿到的是主应用的host
,子应用可以从$wujie.location
中拿到子应用正确的host
应用共享依赖
一个微前端系统可能同时运行多个子应用,不同子应用之间可能存在相同的包依赖,那么这个依赖就会在不同子应用中重复打包、重复执行造成性能和内存的浪费
这里提供一种工程上的策略结合无界的插件能力,可以有效的解决这个问题
以这个场景举例:主应用使用到了 lodash
,子应用 A 也使用到了相同版本的 lodash
警告
应用共享原理是主应用和子应用运行 iframe 沙箱同域可以共享内存,对于组件库这样有副作用的第三方包,可能无法共享
子应用只运行在微前端框架
主应用:
- 修改主应用的 index.js,将共享包挂载到主应用的 window 对象上
// index.js
import lodash from 'lodash';
// 将需要共享的包挂载到主应用全局
window.lodash = lodash;
- 加载子应用时注入插件,将主应用的 lodash 赋值到子应用的 window 对象上
<WujieVue
name="A"
url="xxxxx"
:plugins="[
{
jsBeforeLoaders: [{ content: 'window.lodash = window.parent.lodash' }]
}
]"
></WujieVue>
子应用: webpack
设置 externals
module.exports = {
externals: {
lodash: {
root: 'lodash',
commonjs: 'lodash',
commonjs2: 'lodash',
amd: 'lodash',
},
},
};
子应用需要单独运行
由于上面步骤将 lodash
使用 externals
去掉了,所以子应用如果需要单独运行的话,会报错。为了让子应用可以单独运行需要做如下步骤:
- 在子应用的
html
的head
中,插入脚本标签,使用cdn的外连接,将lodash.js
放进去。 - 在
<script>
标签内添加ignore
标志。无界执行子应用时,这个标签会忽略,但子应用单独运行时不受ignore
影响会执行:
<head>
<script src="xxxx/cdn/lodash.js" ignore >
<head>
当然也可以不添加 ignore
标志而采用无界的插件将这个脚本排除在外:
<WujieVue
name="A"
url="xxxxx"
:plugins="[
{
jsExcludes: ['xxxx/cdn/lodash.js']
}
]"
></WujieVue>
API 说明
主应用
setupApp
-
类型:
Function
-
参数:
cacheOptions
-
返回值:void
type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;
type baseOptions = {
/** 唯一性用户必须保证 */
name: string;
/** 需要渲染的url */
url: string;
/** 需要渲染的html, 如果用户已有则无需从url请求 */
html?: string;
/** 代码替换钩子 */
replace?: (code: string) => string;
/** 自定义fetch */
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/** 注入给子应用的属性 */
props?: { [key: string]: any };
/** 自定义运行iframe的属性 */
attrs?: { [key: string]: any };
/** 自定义降级渲染iframe的属性 */
degradeAttrs?: { [key: string]: any };
/** 子应用采用fiber模式执行 */
fiber?: boolean;
/** 子应用保活,state不会丢失 */
alive?: boolean;
/** 子应用采用降级iframe方案 */
degrade?: boolean;
/** 子应用插件 */
plugins?: Array<plugin>;
/** 子应用生命周期 */
beforeLoad?: lifecycle;
beforeMount?: lifecycle;
afterMount?: lifecycle;
beforeUnmount?: lifecycle;
afterUnmount?: lifecycle;
activated?: lifecycle;
deactivated?: lifecycle;
loadError?: loadErrorHandler;
};
type preOptions = baseOptions & {
/** 预执行 */
exec?: boolean;
};
type startOptions = baseOptions & {
/** 渲染的容器 */
el: HTMLElement | string;
/**
* 路由同步开关
* 如果false,子应用跳转主应用路由无变化,但是主应用的history还是会增加
* https://html.spec.whatwg.org/multipage/history.html#the-history-interface
*/
sync?: boolean;
/** 子应用短路径替换,路由同步时生效 */
prefix?: { [key: string]: string };
/** 子应用加载时loading元素 */
loading?: HTMLElement;
};
type optionProperty = 'url' | 'el';
/**
* 合并 preOptions 和 startOptions,并且将 url 和 el 变成可选
*/
type cacheOptions = Omit<preOptions & startOptions, optionProperty> & Partial<Pick<startOptions, optionProperty>>;
- 详情:
setupApp
设置子应用默认属性,非必须。startApp
、preloadApp
会从这里获取子应用默认属性,如果有相同的属性则会直接覆盖
startApp
-
类型:
Function
-
参数:
startOption
-
返回值:
Promise<Function>
type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;
type startOption {
/** 唯一性用户必须保证 */
name: string;
/** 需要渲染的 url */
url: string;
/** 需要渲染的 html, 如果用户已有则无需从 url 请求 */
html?: string;
/** 渲染的容器 */
el: HTMLElement | string;
/** 子应用加载时 loading 元素 */
loading?: HTMLElement;
/** 路由同步开关, false 刷新无效,但是前进后退依然有效 */
sync?: boolean;
/** 子应用短路径替换,路由同步时生效 */
prefix?: { [key: string]: string };
/** 子应用保活模式,state 不会丢失 */
alive?: boolean;
/** 注入给子应用的数据 */
props?: { [key: string]: any };
/** js 采用 fiber 模式执行 */
fiber?: boolean;
/** 子应用采用降级 iframe 方案 */
degrade?: boolean;
/** 自定义运行 iframe 的属性 */
attrs?: { [key: string]: any };
/** 自定义降级渲染 iframe 的属性 */
degradeAttrs?: { [key: string]: any };
/** 代码替换钩子 */
replace?: (codeText: string) => string;
/** 自定义 fetch,资源和接口 */
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/** 子应插件 */
plugins: Array<plugin>;
/** 子应用生命周期 */
beforeLoad?: lifecycle;
/** 没有做生命周期改造的子应用不会调用 */
beforeMount?: lifecycle;
afterMount?: lifecycle;
beforeUnmount?: lifecycle;
afterUnmount?: lifecycle;
/** 非保活应用不会调用 */
activated?: lifecycle;
deactivated?: lifecycle;
/** 子应用资源加载失败后调用 */
loadError?: loadErrorHandler
};
- 详情:
startApp
启动子应用,异步返回destroy
函数,可以销毁子应用,一般不建议用户调用,除非清楚的理解其作用
警告
一般情况下不需要主动调用destroy
函数去销毁子应用,除非主应用再也不会打开这个子应用了,子应用被主动销毁会导致下次打开该子应用有白屏时间
name
、replace
、fetch
、alive
、degrade
这五个参数在preloadApp
和startApp
中须保持严格一致,否则子应用的渲染可能出现异常
name
-
类型:
String
-
详情: 子应用唯一标识符
技巧
如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候建议将 name 设置为同一个,这样可以共享一个实例
url
-
类型:
String
-
详情: 子应用的路径地址
- 如果子应用为
单例模式
,改变 url 则可以让子应用跳转到对应子路由 - 如果子应用为
保活模式
,改变 url 则无效,需要采用 通信 的方式对子应用路由进行跳转 - 如果子应用为
重建模式
,改变 url 子应用的路由会跳转对应路由,但是在 路由同步 场景并且子应用的路由同步参数已经同步到主应用 url 上时则无法生效,因为改变 url 后会导致子应用销毁重新渲染,此时如果有同步参数则同步参数的优先级最高
- 如果子应用为
html
-
类型:
String
-
详情: 子应用的 html,设置后子应用将直接读取该值,没有设置则子应用通过 url 请求获取
el
-
类型:
HTMLElement | string
-
详情: 子应用渲染容器,子应用渲染容器的最好设置好宽高防止渲染问题,在 webcomponent 元素上无界还设置了 wujie_iframe 的 class 方便用户自定义样式
loading
-
类型:
HTMLElement
-
详情: 自定义的
loading
元素,如果不想出现默认加载,可以赋值一个空元素:document.createElement('span')
sync
-
默认值:
false
-
类型:
Boolean
-
详情: 路由同步模式,开启后无界会将子应用的 name 作为一个 url 查询参数,实时同步子应用的路径作为这个查询参数的值,这样分享 URL 或者刷新浏览器子应用路由都不会丢失。
警告
这个同步是单向的,只有打开 URL 或者刷新浏览器的时候,子应用才会从 URL 中读回路由,假如关闭路由同步模式,浏览器前进后退可以正常作用到子应用,但是浏览器刷新后子应用的路由会丢失
prefix
-
类型:
{[key: string]: string }
-
详情: 短路径的能力,当子应用开启路由同步模式后,如果子应用链接过长,可以采用短路径替换的方式缩短同步的链接。
alive
-
默认值:
false
-
类型:
Boolean
-
详情:
保活模式,子应用实例
instance
和webcomponent
都不会销毁,子应用的状态和路由都不会丢失,切换子应用只是对webcomponent
的热插拔如果子应用不想做生命周期改造,子应用切换又不想有白屏时间,可以采用保活模式
如果主应用上有多个菜单栏跳转到子应用的不同页面,此时不建议采用保活模式。因为子应用在保活状态下 startApp 无法更改子应用路由,不同菜单栏无法跳转到指定子应用路由,推荐
单例模式
技巧
预执行模式结合保活模式可以实现类似 ssr 的效果,包括页面数据的请求和渲染全部提前完成,用户可以瞬间打开子应用
props
-
类型:
{ [key: string]: any }
-
详情: 注入给子应用的数据
fiber
-
默认值:
true
-
类型:
Boolean
-
详情:
js 的执行模式,由于子应用的执行会阻塞主应用的渲染线程,当设置为 true 时 js 采取类似 react fiber 的模式方式间断执行,每个 js 文件的执行都包裹在 requestidlecallback 中,每执行一个 js 可以返回响应外部的输入,但是这个颗粒度是 js 文件,如果子应用单个 js 文件过大,可以通过拆包的方式降低达到 fiber 模式效益最大化
技巧
打开主应用就需要加载的子应用可以将 fiber 设置为 false 来加快加载速度
其他场景建议采用默认值
degrade
-
默认值:
false
-
类型:
Boolean
-
详情:
主动降级设置,无界方案采用了 proxy 和 webcomponent 等技术,在有些浏览器上可能出现不兼容的情况,此时无界会自动进行降级,采用一个的 iframe 替换 webcomponent,用 Object.defineProperty 替换 proxy,理论上可以兼容到 IE 9,但是用户也可以将 degrade 设置为 true 来主动降级
警告
一旦采用降级方案,弹窗由于在 iframe 内部将无法覆盖整个应用
attrs
-
类型:
{ [key: string]: any }
-
详情: 自定义 iframe 属性,子应用运行在 iframe 内,attrs 可以允许用户自定义 iframe 的属性
replace
-
类型:
(codeText: string) => string
-
详情: 全局代码替换钩子
技巧
replace 函数可以在运行时处理子应用的代码,如果子应用不方便修改代码,可以在这里进行代码替换,子应用的 html、js、css 代码均会做替换
fetch
-
类型:
(input: RequestInfo, init?: RequestInit) => Promise<Response>
-
详情: 自定义 fetch,添加自定义 fetch 后,子应用的静态资源请求和采用了 fetch 的接口请求全部会走自定义 fetch
技巧
对于需要携带 cookie 的请求,可以采用自定义 fetch 方式实现:(url, options) => window.fetch(url, { …options, credentials: “include” })
plugins
- 类型:
Array<plugin>
interface ScriptObjectLoader {
/** 脚本地址,内联为空 */
src?: string;
/** 脚本是否为 module 模块 */
module?: boolean;
/** 脚本是否为 async 执行 */
async?: boolean;
/** 脚本是否设置 crossorigin */
crossorigin?: boolean;
/** 脚本 crossorigin 的类型 */
crossoriginType?: 'anonymous' | 'use-credentials' | '';
/** 内联 script 的代码 */
content?: string;
/** 执行回调钩子 */
callback?: (appWindow: Window) => any;
}
interface StyleObjectLoader {
/** 样式地址, 内联为空 */
src?: string;
/** 样式代码 */
content?: string;
}
type eventListenerHook = (iframeWindow: Window, type: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => void;
interface plugin {
/** 处理 html 的 loader */
htmlLoader?: (code: string) => string;
/** js 排除列表 */
jsExcludes?: Array<string | RegExp>;
/** js 忽略列表 */
jsIgnores?: Array<string | RegExp>;
/** 处理 js 加载前的 loader */
jsBeforeLoaders?: Array<ScriptObjectLoader>;
/** 处理 js 的 loader */
jsLoader?: (code: string, url: string, base: string) => string;
/** 处理 js 加载后的 loader */
jsAfterLoaders?: Array<ScriptObjectLoader>;
/** css 排除列表 */
cssExcludes?: Array<string | RegExp>;
/** css 忽略列表 */
cssIgnores?: Array<string | RegExp>;
/** 处理 css 加载前的 loader */
cssBeforeLoaders?: Array<StyleObject>;
/** 处理 css 的 loader */
cssLoader?: (code: string, url: string, base: string) => string;
/** 处理 css 加载后的 loader */
cssAfterLoaders?: Array<StyleObject>;
/** 子应用 window addEventListener 钩子回调 */
windowAddEventListenerHook?: eventListenerHook;
/** 子应用 window removeEventListener 钩子回调 */
windowRemoveEventListenerHook?: eventListenerHook;
/** 子应用 document addEventListener 钩子回调 */
documentAddEventListenerHook?: eventListenerHook;
/** 子应用 document removeEventListener 钩子回调 */
documentRemoveEventListenerHook?: eventListenerHook;
/** 子应用 向 body、head 插入元素后执行的钩子回调 */
appendOrInsertElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
/** 子应用劫持元素的钩子回调 */
patchElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
/** 用户自定义覆盖子应用 window 属性 */
windowPropertyOverride?: (iframeWindow: Window) => void;
/** 用户自定义覆盖子应用 document 属性 */
documentPropertyOverride?: (iframeWindow: Window) => void;
}
- 详情: 无界插件,在运行时动态的修改子应用代理。
beforeLoad
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,加载子应用前调用
beforeMount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 mount 之前调用
afterMount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 mount 之后调用
beforeUnmount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 unmount 之前调用
afterUnmount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 unmount 之后调用
activated
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,保活子应用进入时触发
deactivated
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,保活子应用离开时触发
loadError
-
类型:
(url: string, e: Error) => any
-
详情: 生命周期钩子,子应用加载资源失败后触发
注意
如果子应用没有做生命周期改造,beforeMount、afterMount、beforeUnmount、afterUnmount 这四个生命周期都不会调用,非保活子应用 activated、deactivated 这两个生命周期不会调用
preloadApp
-
类型:
Function
-
参数:
preOption
type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;
type preOptions {
/** 唯一性用户必须保证 */
name: string;
/** 需要渲染的 url */
url: string;
/** 需要渲染的 html, 如果用户已有则无需从 url 请求 */
html?: string;
/** 注入给子应用的数据 */
props?: { [key: string]: any };
/** 自定义运行 iframe 的属性 */
attrs?: { [key: string]: any };
/** 自定义降级渲染 iframe 的属性 */
degradeAttrs?: { [key: string]: any };
/** 代码替换钩子 */
replace?: (code: string) => string;
/** 自定义 fetch,资源和接口 */
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/** 子应用保活模式,state 不会丢失 */
alive?: boolean;
/** 预执行模式 */
exec?: boolean;
/** js 采用 fiber 模式执行 */
fiber?: boolean;
/** 子应用采用降级 iframe 方案 */
degrade?: boolean;
/** 子应插件 */
plugins: Array<plugin>;
/** 子应用生命周期 */
beforeLoad?: lifecycle;
/** 没有做生命周期改造的子应用不会调用 */
beforeMount?: lifecycle;
afterMount?: lifecycle;
beforeUnmount?: lifecycle;
afterUnmount?: lifecycle;
/** 非保活应用不会调用 */
activated?: lifecycle;
deactivated?: lifecycle;
/** 子应用资源加载失败后调用 */
loadError?: loadErrorHandler
};
- 详情: 预加载可以极大的提升子应用首次打开速度
警告
- 资源的预加载会占用主应用的网络线程池
- 资源的预执行会阻塞主应用的渲染线程
name
、replace
、fetch
、alive
、degrade
这五个参数在preloadApp
和startApp
中须保持严格一致,否则子应用的渲染可能出现异常
name
-
类型:
String
-
详情: 子应用唯一标识符
技巧
如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候建议将 name 设置为同一个,这样可以共享一个实例
url
-
类型:
String
-
详情: 子应用的路径地址
html
-
类型:
String
-
详情: 子应用的 html,设置后子应用将直接读取该值,没有设置则子应用通过 url 请求获取
props
-
类型:
{ [key: string]: any }
-
详情: 注入给子应用的数据
警告
exec
为true
此时子应用代码会预执行,如果子应用运行依赖props
的数据则须传入props
或者子应用做好兼容props
不存在,否则子应用运行可能报错
attrs
-
类型:
{ [key: string]: any }
-
详情: 自定义
iframe
属性,子应用运行在iframe
内,attrs
可以允许用户自定义iframe
的属性
replace
-
类型:
(codeText: string) => string
-
详情: 全局代码替换钩子
技巧
replace 函数可以在运行时处理子应用的代码,如果子应用不方便修改代码,可以在这里进行代码替换,子应用的 html、js、css 代码均会做替换
fetch
-
类型:
(input: RequestInfo, init?: RequestInit) => Promise<Response>
-
详情: 自定义 fetch,添加自定义 fetch 后,子应用的静态资源请求和采用了 fetch 的接口请求全部会走自定义 fetch
技巧
对于需要携带 cookie 的请求,可以采用自定义 fetch 方式实现:
(url, options) => window.fetch(url, { ...options, credentials: "include" })
alive
-
默认值:
false
-
类型:
Boolean
-
详情:
保活模式,子应用实例instance
和webcomponent
都不会销毁,子应用的状态和路由都不会丢失,切换子应用只是对webcomponent
和容器的热插拔如果子应用不想做生命周期改造,子应用切换又不想有白屏时间,可以采用保活模式
如果主应用上有多个菜单栏跳转到子应用的不同页面,此时不建议采用保活模式。因为子应用在保活状态下
startApp
无法更改子应用路由,不同菜单栏无法跳转到指定子应用路由,推荐单例模式技巧
预执行模式结合保活模式可以实现类似 ssr 的效果,包括页面数据的请求和渲染全部提前完成,用户可以瞬间打开子应用
exec
-
默认值:
false
-
类型:
Boolean
-
详情: 预执行模式,为 false 时只会预加载子应用的资源,为 true 时会预执行子应用代码,极大的加快子应用打开速度
fiber
-
默认值:
true
-
类型:
Boolean
-
详情:
js 的执行模式,由于子应用的执行会阻塞主应用的渲染线程,当设置为 true 时 js 采取类似
react fiber
的模式方式间断执行,每个 js 文件的执行都包裹在requestidlecallback
中,每执行一个 js 可以返回响应外部的输入,但是这个颗粒度是 js 文件,如果子应用单个 js 文件过大,可以通过拆包的方式降低达到fiber
模式效益最大化技巧
打开主应用就需要加载的子应用可以将
fiber
设置为 false 来加快加载速度其他场景建议采用默认值
degrade
-
默认值:
false
-
类型:
Boolean
-
详情:
主动降级设置,无界方案采用了
proxy
和webcomponent
等技术,在有些浏览器上可能出现不兼容的情况,此时无界会自动进行降级,采用一个的iframe
替换webcomponent
,用Object.defineProperty
替换 proxy,理论上可以兼容到 IE 9,但是用户也可以将degrade
设置为 true 来主动降级警告
一旦采用降级方案,弹窗由于在 iframe 内部将无法覆盖整个应用
plugins
- 类型:
Array<plugin>
interface ScriptObjectLoader {
/** 脚本地址,内联为空 */
src?: string;
/** 脚本是否为 module 模块 */
module?: boolean;
/** 脚本是否为 async 执行 */
async?: boolean;
/** 脚本是否设置 crossorigin */
crossorigin?: boolean;
/** 脚本 crossorigin 的类型 */
crossoriginType?: 'anonymous' | 'use-credentials' | '';
/** 内联 script 的代码 */
content?: string;
/** 执行回调钩子 */
callback?: (appWindow: Window) => any;
}
interface StyleObjectLoader {
/** 样式地址, 内联为空 */
src?: string;
/** 样式代码 */
content?: string;
}
type eventListenerHook = (iframeWindow: Window, type: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => void;
interface plugin {
/** 处理 html 的 loader */
htmlLoader?: (code: string) => string;
/** js 排除列表 */
jsExcludes?: Array<string | RegExp>;
/** js 忽略列表 */
jsIgnores?: Array<string | RegExp>;
/** 处理 js 加载前的 loader */
jsBeforeLoaders?: Array<ScriptObjectLoader>;
/** 处理 js 的 loader */
jsLoader?: (code: string, url: string, base: string) => string;
/** 处理 js 加载后的 loader */
jsAfterLoaders?: Array<ScriptObjectLoader>;
/** css 排除列表 */
cssExcludes?: Array<string | RegExp>;
/** css 忽略列表 */
cssIgnores?: Array<string | RegExp>;
/** 处理 css 加载前的 loader */
cssBeforeLoaders?: Array<StyleObject>;
/** 处理 css 的 loader */
cssLoader?: (code: string, url: string, base: string) => string;
/** 处理 css 加载后的 loader */
cssAfterLoaders?: Array<StyleObject>;
/** 子应用 window addEventListener 钩子回调 */
windowAddEventListenerHook?: eventListenerHook;
/** 子应用 window removeEventListener 钩子回调 */
windowRemoveEventListenerHook?: eventListenerHook;
/** 子应用 document addEventListener 钩子回调 */
documentAddEventListenerHook?: eventListenerHook;
/** 子应用 document removeEventListener 钩子回调 */
documentRemoveEventListenerHook?: eventListenerHook;
/** 子应用 向 body、head 插入元素后执行的钩子回调 */
appendOrInsertElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
/** 子应用劫持元素的钩子回调 */
patchElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
/** 用户自定义覆盖子应用 window 属性 */
windowPropertyOverride?: (iframeWindow: Window) => void;
/** 用户自定义覆盖子应用 document 属性 */
documentPropertyOverride?: (iframeWindow: Window) => void;
}
- 详情: 无界插件,在运行时动态的修改子应用代理。
beforeLoad
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,加载子应用前调用
beforeMount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 mount 之前调用
afterMount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 mount 之后调用
beforeUnmount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 unmount 之前调用
afterUnmount
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,子应用 unmount 之后调用
activated
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,保活子应用进入时触发
deactivated
-
类型:
(appWindow: Window) => any
-
详情: 生命周期钩子,保活子应用离开时触发
loadError
-
类型:
(url: string, e: Error) => any
-
详情: 生命周期钩子,子应用加载资源失败后触发
注意
如果子应用没有做生命周期改造,beforeMount、afterMount、beforeUnmount、afterUnmount 这四个生命周期都不会调用,非保活子应用 activated、deactivated 这两个生命周期不会调用
destroyApp
-
类型:
Function
-
参数:
string
,子应用name
-
返回值:
void
主动销毁子应用,承载子应用的 iframe
和 shadowRoot
都会被销毁,无界实例也会被销毁,相当于所有的缓存都被清空,除非后续不会再使用子应用,否则都不应该主动销毁。
子应用
全局变量
无界会在子应用的window对象
中注入一些全局变量:
declare global {
interface Window {
// 是否存在无界
__POWERED_BY_WUJIE__?: boolean;
// 子应用公共加载路径
__WUJIE_PUBLIC_PATH__: string;
// 原生的querySelector
__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__: typeof Document.prototype.querySelector;
// 原生的querySelectorAll
__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__: typeof Document.prototype.querySelectorAll;
// 原生的window对象
__WUJIE_RAW_WINDOW__: Window;
// 子应用沙盒实例
__WUJIE: WuJie;
// 子应用mount函数
__WUJIE_MOUNT: () => void;
// 子应用unmount函数
__WUJIE_UNMOUNT: () => void;
// 注入对象
$wujie: {
bus: EventBus;
shadowRoot?: ShadowRoot;
props?: { [key: string]: any };
location?: Object;
};
}
}
window.__POWERED_BY_WUJIE__
是否存在无界。
返回值:Boolean
window.__WUJIE_PUBLIC_PATH__
子应用公共加载路径
返回值:String
window.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__
原生的 querySelector
返回值:typeof Document.prototype.querySelector
window.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__
原生的 querySelectorAll
返回值:typeof Document.prototype.querySelectorAll
window.__WUJIE_RAW_WINDOW__
原生的 window 对象
返回值:Window 对象
window.__WUJIE
子应用沙盒实例
返回值:WuJie 实例
window.__WUJIE_MOUNT
子应用 mount 函数
返回值:() => void
window.__WUJIE_UNMOUNT
子应用 unmount 函数
返回值:() => void
window.$wujie
无界对子应用注入了 w u j i e 对象,可以通过 wujie对象,可以通过 wujie对象,可以通过wujie 或者 window.$wujie 获取
- 类型:
{ bus: EventBus; shadowRoot?: ShadowRoot; props?: { [key: string]: any }; location?: Object; }
window.$wujie.bus
同 bus
window.$wujie.shadowRoot
- 类型:
ShadowRoot
子应用的渲染容器 shadow DOM
window.$wujie.props
- 类型:
{ [key: string]: any }
主应用注入的数据
window.$wujie.location
-
类型:
Object
-
由于子应用的
location.host
拿到的是主应用的host
,无界提供了一个正确的location
挂载到挂载到$wujie 上 -
当采用
vite
编译框架时,由于script
的标签type
为module
,所以无法采用闭包的方式将location
劫持代理,子应用所有采用window.location.host
的代码需要统一修改成$wujie.location.host
-
当子应用发生降级时,由于
proxy
无法正常工作导致location
无法代理,子应用所有采用window.location.host
的代码需要统一修改成$wujie.location.host
-
当采用非
vite
编译框架时,proxy
代理了window.location
,子应用代码无需做任何更改。
公共使用
bus
- 类型:
EventBus
type callback = (...args: Array<any>) => any;
export declare class EventBus {
private id;
private eventObj;
constructor(id: string);
$on(event: string, fn: callback): EventBus;
/** 任何$emit都会导致监听函数触发,第一个参数为事件名,后续的参数为$emit的参数 */
$onAll(fn: (event: string, ...args: Array<any>) => any): EventBus;
$once(event: string, fn: callback): void;
$off(event: string, fn: callback): EventBus;
$offAll(fn: callback): EventBus;
$emit(event: string, ...args: Array<any>): EventBus;
$clear(): EventBus;
}
- 详情: 去中心化的事件平台,类 Vue 的事件 api,支持链式调用。
$on
-
类型:
(event: string, fn: callback) => EventBus
-
参数:
{string} event
事件名{callback} fn
回调参数
-
详情: 监听事件并提供回调
$onAll
-
类型:
(fn: (event: string, ...args: Array<any>) => any) => EventBus
-
参数:
{callback} fn
回调参数
-
详情: 监听所有事件并提供回调,回调函数的第一个参数是事件名
$once
-
类型:
(event: string, fn: callback) => void
-
参数:
{string} event
事件名{callback} fn
回调参数
-
详情: 一次性的监听事件
$off
-
类型:
(event: string, fn: callback) => EventBus
-
参数:
{string} event
事件名{callback} fn
回调参数
-
详情: 取消事件监听
$offAll
-
类型:
(fn: callback) => EventBus
-
参数:
{callback} fn
回调参数
-
详情: 取消监听所有事件
$emit
-
类型:
(event: string, ...args: Array<any>) => EventBus
-
参数:
{string} event
事件名{Array<any>} args
其他回调参数
-
详情: 触发事件
$clear
-
类型: Function
-
详情: 清空 EventBus 实例下所有监听事件
警告
子应用在被销毁或者重新渲染(非保活状态)时框架会自动调用清空上次渲染所有的订阅事件
子应用内部组件的渲染可能导致反复订阅(比如在 mounted 生命周期调用 w u j i e . b u s . wujie.bus. wujie.bus.on),需要用户在 unmount 生命周期内手动调用 w u j i e . b u s . wujie.bus. wujie.bus.off 来取消订阅
原理
设计原理
渲染子应用步骤:
- 创建和主应用同源的 iframe,路径携带了子路由的路由信息
同源是为了方便应用间的通信,子应用需要能支持跨域;iframe 实例化完成后需立即中断加载 html,防止进入主应用的路由逻辑污染子应用 - 解析子应用的入口 html
识别出 html 部分,分离 style 和 js;处理 css 重新注入 html ;创建 webComponent 并挂载 html - 创建 script 标签,并插入到 iframe 的 head 中
- 在 iframe 中拦截 document 对象,统一将 dom 指向 shadowRoot
这样弹窗或者冒泡组件就可以正常覆盖主应用
接下来的三步分别解决 iframe 的三个缺点:
✅ 通信非常困难的问题:iframe 和主应用是同域的,天然的共享内存通信,而且无界提供了一个去中心化的事件机制
✅ dom 割裂严重的问题:主应用提供一个容器给到 shadowRoot 插拔,shadowRoot 内部的弹窗也就可以覆盖主应用
✅ 路由状态丢失的问题:浏览器的前进后退可以天然的作用到 iframe 上,此时监听 iframe 的路由变化并同步到主应用,如果刷新浏览器,就可以从 url 读回保存的路由
将这套机制封装进无界框架:
我们可以发现:
✅ 首次白屏的问题:wujie 实例可以提前实例化,包括 shadowRoot、iframe 的创建、js 的执行,以此来加快子应用首次打开的时间
✅ 切换白屏的问题:一旦 wujie 实例可以缓存下来,子应用的切换成本变的极低,如果采用保活模式,那么相当于 shadowRoot 的插拔
会带来一定对内存开销:未激活子应用的 shadowRoot 和 iframe 内存常驻,保活模式每个页面都需要独占一个 wujie 实例
沙箱机制
无界在底层采用 proxy + Object.defineproperty
的方式将 js-iframe
中对 dom
操作劫持代理到 webcomponent
shadowRoot
容器中,开发者无感知也无需关心。
应用加载机制和 js 沙箱机制
将子应用的js
注入主应用同域的iframe
中运行。
iframe
是一个原生的window
沙箱,内部有完整的history
和location
接口,子应用实例instance
运行在iframe
中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。
/**
* iframe插入脚本
* @param scriptResult script请求结果
* @param iframeWindow
* @param rawElement 原始的脚本
*/
export function insertScriptToIframe(scriptResult: ScriptObject | ScriptObjectLoader,
iframeWindow: Window,
rawElement ? : HTMLScriptElement) {
const { src, module, content, crossorigin, crossoriginType, async, attrs, callback, onload } = scriptResult as ScriptObjectLoader;
// ...
if (!iframeWindow.__WUJIE.degrade && !module) {
code = `(function(window, self, global, location) {
${code}
}).bind(window.__WUJIE.proxy)(
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxyLocation,
);`;
}
// ...
}
收益
天然 js 沙箱,不会污染主应用环境
不用修改主应用window
任何属性,只在iframe
内部进行修改(注意点:无界的 js 沙箱是借助iframe
实现)应用切换没有清理成本
由于不污染主应用,子应用销毁也无需做任何清理工作
iframe 连接机制 和 css 沙箱机制
css 沙箱机制:
采用 webcomponent 创建一个 wujie
自定义元素来实现页面的样式隔离,将子应用的完整结构渲染在内部。
iframe 连接机制:
子应用的实例 instance
在 iframe
内运行,dom
在主应用容器下的 webcomponent
内,通过代理 iframe
的 document
到 webcomponent
,可以实现两者的互联;将 document
的查询类接口(getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body
)全部代理到 webcomponent
,这样 instance
和 webcomponent
就精准的链接起来。
shadowRoot 的插拔:
当子应用发生切换,iframe
保留下来,子应用的容器可能销毁,但 webcomponent
依然可以选择保留,这样等应用切换回来将 webcomponent
再挂载回容器上,子应用可以获得类似 vue
的 keep-alive
的能力
收益
天然 css 沙箱
直接物理隔离,样式隔离子应用不用做任何修改(注意点:无界的 css 沙箱是借助webcomponent
实现)完整的 DOM 结构 >
webcomponent
保留了子应用完整的html
结构,样式和结构完全对应,子应用不用做任何修改天然适配弹窗问题 >
document.body
的appendChild
或者insertBefore
会代理直接插入到webcomponent
,子应用不用做任何改造(注意点:像子应用弹窗这类最外层的 DOM,依然是处于容器之内)子应用保活
子应用保留iframe
和webcomponent
,应用内部的state
不会丢失(注意点:子应用的保活是由于保留了 DOM)
常见问题及解决方案
1、请求资源报错
请求报错为:Access to fetch at *** from origin *** has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
原因: 子应用跨域或者请求子应用资源没有携带 cookie
解决方案:
-
可能是跨域导致的错误,子应用需要配置跨域
-
可能是求资源没有携带 cookie(一般请求返回码是 302 跳转到登录页),需要通过自定义
fetch
将fetch
的credentials
设置为include
,这样cookie
才会携带上去
warning 警告
当credentials
设置为include
时,服务端的Access-Control-Allow-Origin
不能设置为*
,原因详见,服务端可以这样设置:ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);
2、第三方包已经引入,使用时报错
原因: 脚本本来在全局执行,此时第三方包定义的全局变量(比如var xxx
)会直接挂载到window
上。但是wujie
将所有的脚本都包裹在一个闭包内运行方便劫持修改location
,所以这些全局变量会留在闭包内,无法挂载到window
上,子应用的异步脚本会在另一个闭包内运行,所以拿不到这些全局变量。
解决方案:
1、方式一:需要将第三方包定义的全局变量显式的挂载到window
上(比如window.xxx
),或者修改第三方包webpack
的output.libraryTarget
2、方式二:如果用户不想修改代码可以通过插件
的形式在运行时将全局定义的代码 xxx=
替换成window.xxx=
3、子应用的字体没有生效
原因: @font-face
不会在shadow
内部加载,详见
已解决: 框架会将子应用的@font-face
放到shadow
外部执行,注意子应用的自定义字体名和主应用的自定义字体名不能重复,否则可能存在覆盖问题
4、冒泡系列组件(比如下拉框)弹出位置不正确
原因: 比如element-plus
采用了popper.js
2.0 版本,这个版本计算位置会递归元素一直计算到window.visualViewport
,但是子应用的dom
挂载在shadowRoot
上,并没有window.visualViewport
这部分滚动量,导致偏移计算失败
解决方案: 将子应用将body
设置为position: relative
即可
5、子应用处理异步处理事件时,e.target 变成了 wujie-app
原因: 这个问题是浏览器原生的处理,详见
解决方案: 在异步处理时,获取 e.target 的方式需要修改成:
(e.target.shadowRoot && e.composed) ? (e.composedPath()[0] || e.target) : e.target
6、css 样式内部的相对地址相对的是主应用的域名
已解决: 框架已处理,默认将相对地址转换成绝对地址
7、子应用使用 module federation 引用远程模块报错
原因: 原因同 2,都是由于闭包执行脚本导致脚本内的全局变量在其他脚本中无法读取
解决方案: 在ModuleFederationPlugin
插件中设置library
的type
为window
library: { type: 'window', name: '保持和name一致' }
8、子应用 iframe 初始化时加载、执行了主应用的资源
原因: 原因详见issue
解决方案:
- 主应用提供一个路径比如说
https://host/empty
,这个路径返回不包含任何内容,子应用设置attr
为{src:'https://host/empty'}
,这样 iframe 的 src 就是https://host/empty
- 在主应用 template 的 head 第一个元素插入一个
<script>if(window.parent !== window) {window.stop()}</script>
这样的标签应该可以避免主应用代码污染
9、子应用 window 是一个代理对象,如何获取子应用的真实对象
原因: 为何采用代理,原因详见issue
解决方案:
- 采用
window.__WUJIE_RAW_WINDOW__
获取真实的 window 对象
10、DOMException: Blocked a frame with origin from accessing a cross-origin frame 报错
可能原因: 子应用的沙箱被替换掉了,有三个原因:
- 子应用运行在一个空白的、
src
为主应用host
的iframe
中,这个host
地址会发生 302 之类的跳转导致沙箱被弄掉了 - 子应用为
vite
应用,修改了window.location.href
导致沙箱被替换掉了 - 子应用添加了
jsIgnores
的plugin
,对应的js
文件修改了window.location.href
解决方案:
- 主应用提供一个路径比如说
https://host/empty
,这个路径返回不包含任何内容也不会跳转,子应用设置attr
为{src:'https://host/empty'}
,这样iframe
的src
就是https://host/empty
vite
子应用所有的location
操作都必须采用window.$wujie.location
jsIgnores
对应的js
文件所有的location
操作都必须采用window.$wujie.location
11、子应用的相对地址图片没有替换成绝对地址
原因: 子应用通过 v-html
、innerHtml
或者在template
中动态添加style
时,框架默认的plugin
无法处理这种场景
解决办法: 在子应用入口main
文件最上面 import "./config"
,config
具体代码:
if (window.__POWERED_BY_WUJIE__) {
// eslint-disable-next-line
window.__webpack_public_path__ = window.__WUJIE_PUBLIC_PATH__;
}
12、vite4 子应用样式切换丢失
具体原因和解决办法详见issue
小结
无界的优势
-
组件式的使用方式
无需注册,更无需路由适配,在组件内使用,跟随组件装载、卸载。 -
应用级别的 keep-alive
子应用开启保活模式
后,应用发生切换时整个子应用的状态可以保存下来不丢失,结合预执行模式
可以获得类似ssr
的打开体验 -
多应用同时激活在线
框架具备同时激活多应用,并保持这些应用路由同步的能力 -
纯净无污染
- 无界利用
iframe
和webcomponent
来搭建天然的js
隔离沙箱和css
隔离沙箱 - 利用
iframe
的history
和主应用的history
在同一个top-level browsing context来搭建天然的路由同步机制 - 副作用局限在沙箱内部,子应用切换无需任何清理工作,没有额外的切换成本
- 无界利用
-
性能和体积兼具
- 子应用执行性能和原生一致,子应用实例
instance
运行在iframe
的window
上下文中,避免with(proxyWindow){code}
这样指定代码执行上下文导致的性能下降,但是多了实例化iframe
的一次性的开销,可以通过preload
提前实例化 - 体积比较轻量,借助
iframe
和webcomponent
来实现沙箱,有效的减小了代码量
- 子应用执行性能和原生一致,子应用实例