前期框架调研
参考文档
官网
github仓库
无界Polyfill
(为无界微前端提供更好的场景兼容方案)
wujie-polyfill.github.io/doc/plugins…
基础设置
父应用配置
参数介绍
name: string;
唯一性用户必须保证
url: string;
需要渲染的url
html?: string;
需要渲染的html, 如果用户已有则无需从url请求
props?: { [key: string]: any };
注入给子应用的数据
attrs?: { [key: string]: any };
自定义运行iframe的属性
degradeAttrs?: { [key: string]: any };
自定义降级渲染iframe的属性
replace?: (
code
:
string
) => string;
代码替换钩子
fetch?: (
input
:
RequestInfo
,
init
?:
RequestInit
) =>
Promise
;
自定义fetch,资源和接口
alive?: boolean;
子应用保活模式,state不会丢失
exec?: boolean;
预执行模式
fiber?: boolean;
js采用fiber模式执行
degrade?: boolean;
子应用采用降级iframe方案
plugins: Array;
子应用插件
beforeLoad?: lifecycle;
子应用生命周期
beforeMount?: lifecycle;
afterMount?: lifecycle;
beforeUnmount?: lifecycle;
afterUnmount?: lifecycle;
没有做生命周期改造的子应用不会调用
activated?: lifecycle;
deactivated?: lifecycle;
非保活应用不会调用
loadError?: loadErrorHandler
子应用资源加载失败后调用
main.ts配置
微前端-无界实践 此为简易版整理
import WujieVue from 'wujie-vue3'; // 无界基于vue框架组件封装,父应用是vue3就用wujie-vue3,父应用是vue2就用wujie-vue2
import lifecycles from './lifecycle'; // 子应用生命周期
import plugins from './plugin'; // 子应用插件
const { setupApp, preloadApp, bus } = WujieVue;
// 在 xxx-sub 路由下子应用将激活路由同步给主应用,主应用跳转对应路由高亮菜单栏
bus.$on('sub-route-change', (subName, { fullPath, hash, matched, meta, name, params, path, query }) => {
const currentPath = router.currentRoute.value.href;
if (subName + path !== currentPath.split('?')[0]) {
router.push({ path, params, query });
}
});
bus.$on('logOutTip', () => {
// 401的情况只有【登录页面】按钮,且提示为【当前登录已过期】,子应用过来的情况 目前只有登录状态失效的情况
ElMessageBox.confirm('当前登录已过期', '提示', {
confirmButtonText: '登录页面',
showClose: false,
showCancelButton: false,
closeOnClickModal: false,
type: 'warning',
}).then(() => {
window.location.href = import.meta.env.VITE_ADMIN_URL;
});
});
bus.$on('logOut', () => {
window.location.href = import.meta.env.VITE_ADMIN_URL;
});
const attrs = {};
setupApp({
name: 'bskpt-admin', // 唯一性用户必须保证
url: import.meta.env.VITE_CHILD_ALL + '/', // 需要渲染的url
attrs,
exec: true, // 预执行模式
props,
// fetch: credentialsFetch,
plugins,
prefix: { 'prefix-dialog': '/dialog', 'prefix-location': '/location' },
degrade,
...lifecycles,
});
if (window.localStorage.getItem('preload') !== 'false') {
preloadApp({
name: 'bskpt-admin',
});
}
app.use(WujieVue).use(router).use(i18n).mount('#app');
src/lifecycle.ts
import { setMicroAppLoading } from '@/utils/microAppLoading';
const lifecycles = {
beforeLoad: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} beforeLoad 生命周期`),
beforeMount: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} beforeMount 生命周期`),
afterMount: () => setMicroAppLoading(false),
beforeUnmount: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} beforeUnmount 生命周期`),
afterUnmount: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} afterUnmount 生命周期`),
activated: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} activated 生命周期`),
deactivated: (appWindow: any) => console.info(`${appWindow.__WUJIE.id} deactivated 生命周期`),
loadError: (url: string, e: any) => console.info(`${url} 加载失败`, e),
};
export default lifecycles;
环境变量
// .env.development
VITE_CHILD_ALL = '//localhost:9091/bskpt-admin/sub'
// .env.dev
VITE_CHILD_ALL = '//common.hlestudy.com:17006/bskpt-admin/sub'
在父应用使用一个vue页面承载子应用
src/views/Sub/index.vue
<template>
<!-- 单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
<WujieVue width="100%" height="100%" name="bskpt-admin" :url="vue2Url" :props="{ token, rules, userInfo }"></WujieVue>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { getToken } from '@/core/auth';
import { useUserStore } from '@/store/modules/user';
const queryParams = useRoute().query;
const queryString =
Object.keys(queryParams).length !== 0
? '?' +
Object.keys(queryParams)
.map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key]))
.join('&')
: '';
const vue2Url = computed(() => import.meta.env.VITE_CHILD_ALL + useRoute().path + queryString);
const token = getToken();
const userStore = useUserStore();
const rules = userStore.rulesPermission;
const userInfo = userStore.userInfo;
</script>
动态路由
src/store/modules/permission.ts
const Sub = () => import('@/views/Sub/index.vue');
const filterAsyncRoutes = (routes: RouteRecordRaw[], menuPermission: string[], depth: number, parentPath: string) => {
const depthWithPermission = [1, 2];
const asyncRoutes: RouteRecordRaw[] = [];
depth++;
routes.forEach((route) => {
const tmpRoute = { ...route, depth, parentPath }; // ES6扩展运算符复制新对象
tmpRoute.meta = {};
// 判断用户(角色)是否有该路由的访问权限
if (
(hasPermission(menuPermission, tmpRoute) && depthWithPermission.includes(depth)) ||
!depthWithPermission.includes(depth)
) {
tmpRoute.path = '/' + route.url.replace(/(^\/*)|(\/*$)/g, '');
tmpRoute.meta.title = route.menuName;
tmpRoute.meta.hidden = !depthWithPermission.includes(depth - 1);
if (tmpRoute.component) {
if (tmpRoute.component?.toString() == 'Layout') {
tmpRoute.component = Layout;
} else {
const component = modules[`../../views/${tmpRoute.component}.vue`];
if (component) {
tmpRoute.component = component;
} else {
tmpRoute.component = Sub;
}
}
} else {
const component = returnComponent(tmpRoute.path, router.options.routes);
if (component) {
tmpRoute.component = component;
} else {
tmpRoute.component = Sub;
}
}
if (tmpRoute.parentMenuId === -1) {
tmpRoute.component = Layout;
}
if (tmpRoute.nodes) {
tmpRoute.nodes = filterAsyncRoutes(tmpRoute.nodes, menuPermission, depth, tmpRoute.path);
}
tmpRoute.children = tmpRoute.nodes;
asyncRoutes.push(tmpRoute);
}
});
return asyncRoutes;
};
子应用配置
main.js配置
if (window.__POWERED_BY_WUJIE__) {
let instance;
window.__WUJIE_MOUNT = () => {
document.documentElement.classList.add("isFormIframe");
instance = new Vue({
beforeCreate() {
Vue.prototype.$bus = this;
},
router,
store,
render: (h) => h(App),
}).$mount("#app");
console.log(window.$wujie?.props);
if (window.$wujie?.props.token) {
setToken(window.$wujie?.props.token);
} else {
setToken(localStorage.getItem("Saas_Token"));
}
if (window.$wujie?.props.rules) {
store.commit("setRules", window.$wujie?.props.rules);
}
if (window.$wujie?.props.userInfo) {
store.commit("setUserInfo", window.$wujie?.props.userInfo);
}
};
window.__WUJIE_UNMOUNT = () => {
// 因为elementUI没有抛出messageBox的实例,暂时只能通过dom判断;如果不判断,当实例不存在的时候,doClose() 会找不到实例,从而报错阻塞进程
const messageBoxDiv = document.getElementsByClassName("el-message-box");
if (messageBoxDiv.length) {
MessageBox.close();
}
instance.$destroy();
};
} else {
new Vue({
beforeCreate() {
Vue.prototype.$bus = this;
},
router,
store,
render: (h) => h(App),
}).$mount("#app");
}
App.vue配置
watch: {
// 在 vue2-sub 路由下主动告知主应用路由跳转,主应用也跳到相应路由高亮菜单栏
$route() {
const { fullPath, hash, matched, meta, name, params, path, query } =
this.$route;
window.$wujie?.bus.$emit("sub-route-change", "bskpt-admin", {
fullPath,
hash,
matched,
meta,
name,
params,
path,
query,
});
},
},
src/router/index.js
base: process.env.BASE_URL, // BASE_URL = "/bskpt-admin/sub/"
vue.config.js
publicPath: process.env.BASE_URL, // BASE_URL = "/bskpt-admin/sub/"
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
"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",
},
}
Access-Control-Allow-Origin
Access-Control-Allow-Origin 响应标头指定了该响应的资源是否被允许与给定的来源(origin)共享。
Access-Control-Allow-Origin: *
服务器允许任何来源的请求访问其资源
Access-Control-Allow-Origin:
可以设置为一个具体的源(origin),以指定允许访问资源的来源。<origin> 在这里应该被替换为实际的源(域名、协议和端口),例如 https://example.com。
也可以配置为一个逗号分隔的多个来源(但实际还是一个来源)
Access-Control-Allow-Origin: null
备注: null 不应该被使用:“返回 Access-Control-Allow-Origin: "null" 似乎是安全的,但任何使用非分级协议(如 data: 或 file:)的资源和沙盒文件的 Origin 的序列化都被定义为‘null’。许多用户代理将授予这类文件对带有 Access-Control-Allow-Origin: "null" 头的响应的访问权,而且任何源都可以用 null 源创建一个恶意文件。因此,应该避免将 ACAO 标头设置为‘null’值。”
Access-Control-Allow-Credentials
用于在请求要求包含 credentials([Request.credentials](https://developer.mozilla.org/zh-CN/docs/Web/API/Request/credentials) 的值为 include)时,告知浏览器是否可以将对请求的响应暴露给前端 JavaScript 代码。
Access-Control-Allow-Credentials: true
这个头的唯一有效值(区分大小写)。如果不需要 credentials,相比将其设为 false,请直接忽视这个头。
Access-Control-Allow-Headers
列出了将会在正式请求的 [Access-Control-Request-Headers](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Access-Control-Request-Headers) 字段中出现的首部信息。
Access-Control-Allow-Methods
应答中明确了客户端所要访问的资源允许使用的方法或方法列表。
Content-Type
Content-Type 实体头部用于指示资源的 MIME 类型 media type 。
在响应中,Content-Type 标头告诉客户端实际返回的内容的内容类型。浏览器会在某些情况下进行 MIME 查找,并不一定遵循此标题的值; 为了防止这种行为,可以将标题 [X-Content-Type-Options](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/X-Content-Type-Options) 设置为 nosniff。
src/core/sso.js
import url from "url";
import { setToken, setLoginFrom } from "./auth.js";
// 单点登录
// 入口地址查询
try {
const pathReg = /\/sso\/auth$/;
// 符合单点登录授权地址
const data = url.parse(location.href, true);
if (
pathReg.test(location.pathname) &&
data.query &&
data.query.Authorization
) {
const query = Object.assign({}, data.query);
delete query.Authorization;
window.location.search = url.format({
query,
}); // 删除query参数
if (data.query && data.query.Authorization)
setToken(data.query.Authorization);
if (data.query && data.query.loginFrom) setLoginFrom(data.query.loginFrom);
}
} catch (error) {
console.error(error);
}
功能适配
-
去掉原先的header、sidebar
-
基础接口由父应用调用,子应用接收父应用传值
-
获取用户信息 getUserInfo
-
获取用户菜单 findAuthByUserId
-
菜单列表 listInit
-
获取按钮权限 getInterfaceById
-
-
公共功能提取,例如:header、sidebar、消息通知
-
登录逻辑改造
踩坑点
父应用
url什么时候要加"/",什么时候不加"/"
图1为setupApp配置,加"/"
子应用加载过慢,添加一个loading
-
src/utils/microAppLoading.ts
import { ref } from 'vue';
export let microAppLoading = ref(false);
export function setMicroAppLoading(loading: boolean) { microAppLoading.value = loading; }
-
src/layout/index.vue
import { microAppLoading } from '@/utils/microAppLoading';
-
src/permission.ts
import { setMicroAppLoading } from '@/utils/microAppLoading'; router.afterEach((to) => { ... if (to.path.replace(/(^/)|(/$)/g, '') === 'home') { // 如果是home,就直接关掉loading,否则走到子应用,再去关闭。当前只有home,所以仅判断home,后期多个静态路由时,应该增加判断 setMicroAppLoading(false); } });
-
src/lifecycle.ts
import { setMicroAppLoading } from '@/utils/microAppLoading'; const lifecycles = { beforeLoad: (appWindow: any) => console.info(
${appWindow.__WUJIE.id} beforeLoad 生命周期), beforeMount: (appWindow: any) => console.info(${appWindow.__WUJIE.id} beforeMount 生命周期), afterMount: () => setMicroAppLoading(false), beforeUnmount: (appWindow: any) => console.info(${appWindow.__WUJIE.id} beforeUnmount 生命周期), afterUnmount: (appWindow: any) => console.info(${appWindow.__WUJIE.id} afterUnmount 生命周期), activated: (appWindow: any) => console.info(${appWindow.__WUJIE.id} activated 生命周期), deactivated: (appWindow: any) => console.info(${appWindow.__WUJIE.id} deactivated 生命周期), loadError: (url: string, e: any) => console.info(${url} 加载失败, e), };export default lifecycles;
插件修复了wujie框架下UI事件由子应用传递时,target会指向到WUJIE-APP标签问题。当前现象:子应用elementui 使用远程搜索时,非中文和退格,参数为undefined
package.json
"wujie-polyfill": "^1.0.6"
src/plugin.ts
import { EventTargetPlugin } from 'wujie-polyfill';
const plugins = [
EventTargetPlugin(),
...
]
重定向路由只能在最后面加(否则接口还没获取到的时候就跳转到home了),所以加在了动态路由那边
if (menuPermission?.data) {
userStore.setMenus(menuPermission?.data);
const accessRoutes = await permissionStore.generateRoutes(menuPermission.data);
accessRoutes.forEach((route) => {
router.addRoute(route);
});
router.addRoute({
path: '/:pathMatch(.*)*',
redirect: '/home',
meta: { hidden: true },
});
next({ ...to, replace: true });
}
向子应用传递query
src/views/Sub/index.vue
<template>
<!-- 单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
<WujieVue width="100%" height="100%" name="bskpt-admin" :url="vue2Url" :props="{ token, rules, userInfo }"></WujieVue>
</template>
<script setup lang="ts">
...
const vue2Url = computed(() => import.meta.env.VITE_CHILD_ALL + useRoute().path + queryString);
...
</script>
单例模式下,子应用svg加载问题
在单例模式下,子应用的 js 只会运行一次,如果采用 svg symbol 方案作为 icon,在第一次渲染会挂载到子应用的 body 上面,但是当子应用发生切换再次切换回来,会将上一次渲染的dom全部都丢弃,导致 svg symbol 全部丢失,从而 icon 渲染不出来
无界内部其实会将部分元素存储在 styleSheetElements 数组中,主要是动态产生的样式,这个不能丢弃,等到子应用再次被渲染时,又将 styleSheetElements 数组中的元素重新插入到元素里面
此时我们可以 styleSheetElements 将 svg symbol 也存储进去,等到下次渲染的时候会从数组中拿出来重新渲染,这样 icon 就可以渲染出来了
plugins: [
{
appendOrInsertElementHook(element, iframeWindow) {
if (
element.nodeName === "svg" &&
(element.getAttribute("aria-hidden") === "true" ||
element.style.display === "none" ||
element.style.visibility === "hidden" ||
(element.style.height === "0px" && element.style.width === "0px"))
) {
iframeWindow.__WUJIE.styleSheetElements.push(element);
}
},
},
],
子应用监听document的一些操作 无法执行
解决方案
src/plugin.ts
documentAddEventListenerHook(iframeWindow: any, type: any, handler: any, options: any) {
options = {
capture: false,
};
// 为了解决clickoutside指令在父应用不生效的bug
// 副作用:会劫持所有的mouseup、mousedown事件,包括业务代码,如果后续不想被劫持,可在options单独传递参数self: true用来防止被劫持
if (['mouseup', 'mousedown'].includes(type) && !(isObject(options) && options?.self)) {
document.addEventListener(type, handler, options);
}
},
documentRemoveEventListenerHook(iframeWindow: any, type: any, handler: any, options: any) {
if (['mouseup', 'mousedown'].includes(type)) {
document.removeEventListener(type, handler, options);
}
},
复现场景
-
子应用使用elementui el-cascader
-
截图1中展开了级联选择器
-
菜单从截图1切换到截图2,截图2中没有级联选择器,级联选择器仍然存在(认为不应该存在)
截图3可以看出el-cascader使用了指令clickoutside 截图4可以看出clickoutside指令监听了document,认为是document监听问题
子应用
message这个东西有副作用,子应用每次切换都会将dom全部销毁,但是message默认渲染的dom不会消失,所以需要在销毁子应用的时候主动调用message的销毁函数告诉message要重新渲染容器
src/main.js
window.__WUJIE_UNMOUNT = () => {
// 因为elementUI没有抛出messageBox的实例,暂时只能通过dom判断;如果不判断,当实例不存在的时候,doClose() 会找不到实例,从而报错阻塞进程
const messageBoxDiv = document.getElementsByClassName("el-message-box");
if (messageBoxDiv.length) {
MessageBox.close();
}
};
src/core/permission.js
if (getToken()) {
await store
.dispatch("loadBaseData")
.then(() => {
next();
})
.catch(async (error) => {
const isExpired = error?.code === 401;
await MessageBox.confirm(
!isExpired ? "初始数据加载失败!" : "当前登录已过期",
"提示",
{
confirmButtonText: "登录页面",
cancelButtonText: "重新加载数据",
showClose: false,
showCancelButton: !isExpired,
closeOnClickModal: false,
type: "warning",
}
)
.then(() => {
if (window.__POWERED_BY_WUJIE__) {
window.$wujie?.bus.$emit("logOut");
} else {
store.dispatch("logout").then(() => {
next();
});
}
})
.catch(() => {
window.location.reload();
next();
});
});
}
待优化
单例模式切换成保活模式改造点
改造原因
需求期望部分子应用路由需要缓存(keep-alive),但在单例模式下,切换路由子应用会经历以下阶段:销毁当前应用实例 => 同步新路由 => 创建新应用实例,意味着每次都是新实例,keep-alive对子应用无效果
在保活模式下,子应用内部的数据和路由的状态不会随着页面切换而丢失,经测试,在父应用中添加keep-alive对子应用的webComponent无效,在子应用内部添加keep-alive可以完成缓存效果
改造步骤
父应用
-
setupApp中将添加保活模式配置项alive: true
-
父应用不使用keep-alive组件,同时去除路由动态组件的key字段,避免每次路由切换后动态组件重载
子应用
- 子应用中添加keep-alive,同时获取缓存的路由Name(路由组件需要添加name,确保组件和路由表中的name保持一致)添加至keep-alive的include属性中
子应用加载时机
原先在父应用中setupApp是在main.js中加载的,会出现父应用用户信息接口还没调用完成,但子应用页面已经预加载的情况。调整方案如下:
-
将setupApp位置调整至父应用的路由守卫中,在等待用户信息接口调用完成后才执行setupApp
-
在WujieVue组件中也传入props,包含用户信息,路由信息等
路由多次push
保活模式下,父子路由同步方面的功能无界并没有帮我们实现,会出现同一个页面触发了两次push(父应用和子应用都会push),点击浏览器返回上一步无效,需要点击两次
由于两次push,我们只需将子应用的路由push全部改为replace即可解决
父子路由流程复杂
原监听事件双向触发,排查问题困难,因此改造路由,路由变更都由父应用触发,使路由流程变为单向
暂时无法在飞书文档外展示此内容
-
劫持子应用的push和replace,使其直接触发父应用的push或replac事件,同时将原子应用的push和replace事件改名为originalPush和originalReplace挂载至子应用路由原型对象上,以便后续使用
-
由于在保活模式下,父子应用的路由无界没有帮我们做到一致,需要自行处理。因此在Sub组件中对路由做监听,并触发admin-route-change事件
-
在子应用App.vue中对admin-route-change事件做监听,并调用originalReplace真正修改子应用路由。注意:这里不能调用originalPush,否则浏览器返回上一步需要点击两次才有效
-
处理页面刷新的情况,在子应用的window.__WUJIE_MOUNT中手动触发originalReplace事件,将子应用路由切换到当前路由