如果说,2021年了你还不理解「微前端」,请盲目搬好板凳前排听讲,小编顺便邀请了咱们 LigaAI 团队的前端负责人先军老师,带你轻松玩转微前端。
什么是微前端?
Techniques, strategies and recipes for building a modern web app with
multiple teams that can ship features independently. – Micro Frontends微前端是一种多个团队通过独立公布性能的形式,来独特构建现代化 web 利用的技术手段及办法策略。
不同于单纯的前端框架/工具,微前端是一套架构体系,这个概念最早在2016年底由 ThoughtWorks 提出。 微前端是一种相似于微服务的架构,它将微服务的理念利用于浏览器端,将 Web 利用从整个的「单体利用」转变为多个小型前端利用的「聚合体」。
各个前端利用「原子化」,能够独立运行、开发、部署,从而满足业务的疾速变动,以及分布式、多团队并行开发的需要。
外围价值(为什么要应用微前端?)
- 不限技术栈
主利用不限度接入的子利用的技术栈,子利用领有齐全自主权。所接入的子利用之间也互相独立,没有任何间接或间接的技术栈、依赖、以及实现上的耦合。
- 可独立开发、部署
微利用仓库独立,前后端均可独立开发,部署实现后主框架主动实现同步更新。独立部署的能力在微前端体系中至关重要,可能放大单次开发的变更范畴,进而升高相干危险。 各个微前端都应该有本人的继续交付管道,这些管道能够将微前端构建、测试并部署到生产环境中。
- 增量降级
在面对各种简单场景时,咱们通常很难对一个曾经存在的零碎做全量的技术栈降级或重构。 因而,微前端是一种十分好的施行渐进式重构的伎俩和策略,它能够逐步降级咱们的架构、依赖关系和用户体验。当主框架产生重大变动时,微前端的每个模块能够独立按需降级,不须要整体下线或一次性降级所有内容。如果咱们想要尝试新的技术或互动模式,也能在隔离度更好的环境下做试验。
- 简略、解耦、易保护
微前端架构下的代码库偏向于更小/简略、更容易开发,防止无关组件之间不必要的耦合,让代码更简洁。通过界定清晰的利用边界来升高意外耦合的可能性,更好地防止这类无意间造成的耦合问题。
在什么场景下应用?
微前端架构旨在解决单体利用在一个绝对长的时间跨度下,因为参加人员、团队的增多、变迁,从一个一般利用演变成一个巨石利用 (Frontend Monolith) 后利用不可保护的问题。这类问题在企业级 Web 利用中尤为常见。
- 兼容遗留零碎
现今技术一直更迭,团队想要保技术栈不落后,就须要在兼容原有零碎的前提下,应用新框架去开发新性能。而遗留零碎的性能早已欠缺,且运行稳固,团队没有必要也没有精力去将遗留零碎重构一遍。此时团队如果须要应用新框架、新技术去开发新的利用,应用微前端是很好的解决方案。
- 利用聚合
大型的互联网公司,或商业Saas平台,都会为用户/客户提供很多利用和服务。如何为用户出现具备对立用户体验和一站式的利用聚合成为必须解决的问题。前端聚合已成为一个技术趋势,目前比拟现实的解决方案就是微前端。
- 不同团队间开发同一个利用,所用技术栈不同
团队须要把第三方的SaaS利用进行集成或者把第三方私服利用进行集成(比方在公司外部部署的 gitlab等),以及在已有多个利用的状况下,须要将它们聚合为一个单利用。
图源:https://micro-frontends.org/
什么是qiankun?
qiankun 是一个基于 single-spa 的微前端实现库,旨在帮忙大家能更简略、无痛地构建一个生产可用微前端架构零碎。
qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品对立接入平台。在通过一批线上利用的充沛测验及打磨后,该团队将其微前端内核抽取进去并开源,心愿能同时帮忙有相似需要的产品更不便地构建本人的微前端零碎,同时也心愿通过社区的帮忙将 qiankun 打磨得更加成熟欠缺。
目前 qiankun 已在蚂蚁外部服务了超过 200+ 线上利用,在易用性及齐备性上,相对是值得信赖的。
基于 single-spa 封装,提供了更加开箱即用的 API。
不限技术栈,任意技术栈的利用均可 应用/接入,不论是 React/Vue/Angular/JQuery 还是其余等框架。
HTML Entry 接入形式,让你接入微利用像应用 iframe 一样简略。
款式隔离,确保微利用之间款式相互不烦扰。
JS 沙箱,确保微利用之间 全局变量/事件 不抵触。
⚡️ 资源预加载,在浏览器闲暇工夫预加载未关上的微利用资源,减速微利用关上速度。
umi 插件,提供了 @umijs/plugin-qiankun 供 umi 利用一键切换成微前端架构零碎。
遇到的问题及解决倡议
子利用动态资源404
1.所有图片等动态资源上传至 cdn,css 中间接援用 cdn 地址(举荐)
2.将字体文件和图片打包成base64(实用于字体文件和图片体积小的我的项目)(但总是有一些不符合要求的资源,请应用第三种)
// webpack config loader, 增加以下rule到rules中{ test: /\.(png|jpe?g|gif|webp|woff2?|eot|ttf|otf)$/i, use: [{ loader: 'url-loader', options: {}, }]}// chainWebpackconfig.module.rule('fonts').use('url-loader').loader('url-loader').options({}).end();config.module.rule('images').use('url-loader').loader('url-loader').options({}).end();
3.在打包时给其注入残缺门路(实用于字体文件和图片体积比拟大的我的项目)
const elementFromPoint = document.elementFromPoint;document.elementFromPoint = function (x, y) { const result = Reflect.apply(elementFromPoint, this, [x, y]); // 如果坐标元素为shadow则用该shadow再次获取 if (result && result.shadowRoot) { return result.shadowRoot.elementFromPoint(x, y); } return result;};
css款式隔离
默认状况下,qiankun会主动开启沙箱模式,但这个模式无奈隔离主利用与子利用,也无奈适应同时加载多子利用的场景。 qiankun还给出了shadow dom的计划,须要配置sandbox: { strictStyleIsolation: true }
基于 ShadowDOM 的严格款式隔离并不是一个能够无脑应用的计划,大部分状况下都须要接入利用做一些适配后能力失常在 ShadowDOM 中运行起来。比方 react 场景下须要解决这些问题 ,使用者须要分明开启了 strictStyleIsolation 意味着什么。上面会列出我解决ShadowDom的一些案例。
fix shadow dom
getComputedStyle
当获取shadow dom的计算款式的时候传入的element是DocumentFragment,会报错。
const getComputedStyle = window.getComputedStyle;window.getComputedStyle = (el, ...args) => { // 如果为shadow dom则间接返回 if (el instanceof DocumentFragment) { return {}; } return Reflect.apply(getComputedStyle, window, [el, ...args]);};
elementFromPoint
依据坐标(x, y)当获取一个子利用的元素的时候,会返回shadow root,并不会返回真正的元素。
const elementFromPoint = document.elementFromPoint;document.elementFromPoint = function (x, y) { const result = Reflect.apply(elementFromPoint, this, [x, y]); // 如果坐标元素为shadow则用该shadow再次获取 if (result && result.shadowRoot) { return result.shadowRoot.elementFromPoint(x, y); } return result;};
document 事件 target 为 shadow
当咱们在document增加click、mousedown、mouseup等事件的时候,回调函数中的event.target不是真正的指标元素,而是shadow root元素。
// fix: 点击事件target为shadow元素的问题const {addEventListener: oldAddEventListener, removeEventListener: oldRemoveEventListener} = document;const fixEvents = ['click', 'mousedown', 'mouseup'];const overrideEventFnMap = {};const setOverrideEvent = (eventName, fn, overrideFn) => { if (fn === overrideFn) { return; } if (!overrideEventFnMap[eventName]) { overrideEventFnMap[eventName] = new Map(); } overrideEventFnMap[eventName].set(fn, overrideFn);};const resetOverrideEvent = (eventName, fn) => { const eventFn = overrideEventFnMap[eventName]?.get(fn); if (eventFn) { overrideEventFnMap[eventName].delete(fn); } return eventFn || fn;};document.addEventListener = (event, fn, options) => { const callback = (e) => { // 以后事件对象为qiankun盒子,并且以后对象有shadowRoot元素,则fix事件对象为实在元素 if (e.target.id?.startsWith('__qiankun_microapp_wrapper') && e.target?.shadowRoot) { fn({...e, target: e.path[0]}); return; } fn(e); }; const eventFn = fixEvents.includes(event) ? callback : fn; setOverrideEvent(event, fn, eventFn); Reflect.apply(oldAddEventListener, document, [event, eventFn, options]);};document.removeEventListener = (event, fn, options) => { const eventFn = resetOverrideEvent(event, fn); Reflect.apply(oldRemoveEventListener, document, [event, eventFn, options]);};
js 沙箱
次要是隔离挂载在window上的变量,而qiankun外部曾经帮你解决好了。在子利用运行时拜访的window其实是一个Proxy代理对象。 所有子利用的全局变量变更都是在闭包中产生的,不会真正回写到 window 上,这样就能防止多实例之间的净化了。
图源:前端优选
复用公共依赖
比方:企业中的util、core、request、ui等公共依赖,在微前端中,咱们不须要每个子利用都加载一次,这样既浪费资源并且还会导致原本单例的对象,变成了多例。 在webpack中配置externals。把须要复用的排除打包,而后在index.html中加载排除的lib外链(子利用须要在script或者style标签加上ignore属性,有了这个属性,qiankun 便不会再去加载这个 js/css,而子项目独立运行,这些 js/css 仍能被加载)
<link ignore rel="stylesheet" href="//element-ui.css"><script ignore src="//element-ui.js"></script>
externals: { 'element-ui': { commonjs: 'element-ui', commonjs2: 'element-ui', amd: 'element-ui', root: 'ElementUI' // 外链cdn加载挂载到window上的变量名 }}
父子共享(以国际化为例)
利用注册时或加载时,将依赖传递给子项目
// 注册registerMicroApps([ { name: 'micro-1', entry: 'http://localhost:9001/micro-1', container: '#micro-1', activeRule: '/micro-1', props: { i18n: this.$i18n } },]);// 手动加载loadMicroApp({ name, entry, container: `#${this.boxId}`, props: { i18n: this.$i18n }});
子利用启动时获取props参数初始化
let { i18n } = props;if (!i18n) { // 当独立运行时或主利用未共享时,动静加载本地国际化 const module = await import('@/common-module/lang'); i18n = module.default;}new Vue({ i18n, router, render});
主利用在注册子利用或者手动加载子利用时把共享的变量通过props传递给子利用,子利用在bootstrap或者mount钩子函数中获取,如果没有从props中获取到该变量,子利用则动静加载本地变量。
keep-alive(Vue)
其实并不倡议做keepAlive,然而我还是做了,我能说什么…
网上有其余计划,我没有驳回,我在这里说下我的计划吧(综合了网上的计划),应用loadMicroApp手动加载和卸载子利用。这里有几个难点:
// microApp.js (能够走CI/CD运维配置,也能够通过接口从服务器获取)const apps = [{ name: 'micro-1', activeRule: '/micro-1'}, { name: 'micro-2', activeRule: '/micro-2', prefetch: true}, { name: 'micro-3', activeRule: '/micro-3', prefetch: false, // 预加载资源 preload: false, // 预渲染 keepalive: true // 缓存子利用}];export default apps.map(app => ({ ...app, entry: getEntryUrl(app.name) }));
<template> <div v-show="isActive" :id="boxId" :class="b()" /></template><script>import { loadMicroApp } from 'qiankun';export default { name: 'MicroApp', props: { app: { type: Object, required: true } }, inject: ['appLayout'], computed: { boxId() { return `micro-app_${this.app.name}`; }, activeRule() { return this.app.activeRule; }, currentPath() { return this.$route.fullPath; }, // 判断以后子利用是否为激活状态 isActive() { const {activeRule, currentPath} = this; const rules = Array.isArray(activeRule) ? [ ...activeRule ] : [activeRule]; return rules.some(rule => { if (typeof rule === 'function') { return rule(currentPath); } return currentPath.startsWith(`${rule}`); }); }, isKeepalive() { return this.app.keepalive; } }, watch: { isActive: { handler() { this.onActiveChange(); } } }, created () { // 须要等spa start后再加载利用,才会有shadow节点 this.$once('started', () => { this.init(); }); // 把以后实例退出到layout中 this.appLayout.apps.set(this.app.name, this); }, methods: { init() { // 预挂载 if (this.app.preload) { this.load(); } // 如果路由间接进入以后利用则会在这里挂载 this.onActiveChange(); }, /** * 加载微利用 * @returns {Promise<void>} */ async load() { if (!this.appInstance) { const { name, entry, preload } = this.app; this.appInstance = loadMicroApp({ name, entry, container: `#${this.boxId}`, props: { ..., appName: name, preload, active: this.isActive } }); await this.appInstance.mountPromise; } }, /** * 状态变更 * @returns {Promise<void>} */ async onActiveChange() { // 触发全局事件 this.eventBus.$emit(`${this.isActive ? 'activated' : 'deactivated'}:${this.app.name}`); // 如果以后为激活则加载 if (this.isActive) { await this.load(); } // 如果以后为生效并且以后利用已加载并且配置为不缓存则卸载以后利用 if (!this.isActive && this.appInstance && !this.isKeepalive) { await this.appInstance.unmount(); this.appInstance = null; } // 告诉布局以后状态变更 this.$emit('active', this.isActive); } }};</script>
// App.vue (layout)<template> <template v-if="!isMicroApp"> <keep-alive> <router-view v-if="keepAlive" /> </keep-alive> <router-view v-if="!keepAlive" /> </template> <micro-app v-for="app of microApps" :key="app.name" :app="app" @active="onMicroActive" /></template><script> computed: { isMicroApp() { return !!this.currentMicroApp; } }, mounted () { // 启动qiankun主利用,开启多例与严格款式隔离沙箱(shadow dom) start({ singular: false, sandbox: { strictStyleIsolation: true } }); // 过滤出须要预加载的子利用进行资源预加载 const prefetchAppList = this.microApps.filter(item => item.prefetch); if (prefetchAppList.length) { // 提早执行,搁置影响以后拜访的利用资源加载 (window.requestIdleCallback || setTimeout)(() => prefetchApps(prefetchAppList)); } // 触发微利用的初始化事件,代表spa曾经started了 this.appValues.forEach(app => app.$emit('started')); }, methods: { onMicroActive() { this.currentMicroApp = this.appValues.find(item => item.isActive); } }</script>
路由的响应,如果咱们不卸载keepAlive的子利用,则子利用仍然会响应路由的变动,从而导致子利用的以后路由曾经不是来到时的路由了。
/** * 让vue-router反对keepalive,当主路由变更时如果以后子利用没有该路由则不做解决 * 因为通过浏览器后退后退会先触发主路由的监听,导致没有及时告诉到子利用deactivated,则子利用路由没有及时进行监听,则会解决本次主路由变更 * @param router */const supportKeepAlive = (router) => { const old = router.history.transitionTo; router.history.transitionTo = (location, cb) => { const matched = router.getMatchedComponents(location); if (!matched || !matched.length) { return; } Reflect.apply(old, router.history, [location, cb]); };};// 重写监听路由变更事件supportKeepAlive(instance.$router);// 如果为预挂载并且以后不为激活状态则进行监听路由,并设置_startLocation为空,为了在激活的时候能够响应if (preload && !active) { // 如果以后子利用不是预加载(我这里做了多个子利用并存且能够预加载),并且拜访的不是以后子利用则把路由进行 instance.$router.history.teardown(); instance.$router.history._startLocation = '';}
页面的activated与deactivated触发。
// 在子利用创立的时候监听激活与生效事件if (eventBus) { eventBus.$on(`activated:${appName}`, activated); eventBus.$on(`deactivated:${appName}`, deactivated);}/** * 获取以后路由的组件 * @returns {*} */const getCurrentRouteInstance = () => { const {matched} = instance?.$route || {}; if (matched?.length) { const { instances } = matched[matched.length - 1]; if (instances) { return instances.default || instances; } }};/** * 触发以后路由组件hook * @param hook */const fireCurrentRouterInstanceHook = (hook) => { const com = getCurrentRouteInstance(); const fns = com?.$options?.[hook]; if (fns) { fns.forEach(fn => Reflect.apply(fn, com, [{ micro: true }])); }};/** * 激活以后子利用回调 */const activated = () => { instance?.$router.history.setupListeners(); console.log('setupListeners'); fireCurrentRouterInstanceHook('activated');};/** * 被 keep-alive 缓存的组件停用时调用。 */const deactivated = () => { instance?.$router.history.teardown(); console.log('teardown'); fireCurrentRouterInstanceHook('deactivated');};
vuex 全局状态共享
(慎用!毁坏了vuex的理念, 不适用于大量的数据)
子利用应用本人的vuex,并不是真正的应用主利用的vuex。须要共享的vuex模块主利用与子利用实践来说是援用的雷同的文件,咱们在这个vuex模块标记它是否须要共享,并watch主利用与子利用的该模块。
当子利用中的state产生了扭转则更新主利用的state,相同主利用的state变更后也同样批改子利用的state。
/** * 获取命名空间状态数据 * @param state 状态数据 * @param namespace 命名空间 * @returns {*} */const getNamespaceState = (state, namespace) => namespace === 'root' ? state : get(state, namespace);/** * 更新状态数据 * @param store 状态存储 * @param namespace 命名空间 * @param value 新的值 * @returns {*} */const updateStoreState = (store, namespace, value) => store._withCommit(() => setVo(getNamespaceState(store.state, namespace), value));/** * 监听状态存储 * @param store 状态存储 * @param fn 变更事件函数 * @param namespace 命名空间 * @returns {*} * @private */const _watch = (store, fn, namespace) => store.watch(state => getNamespaceState(state, namespace), fn, { deep: true });const updateSubStoreState = (stores, ns, value) => stores.filter(s => s.__shareNamespaces.has(ns)).forEach(s => updateStoreState(s, ns, value));export default (store, mainStore) => { // 如果有主利用存储则开启共享 if (mainStore) { // 多个子利用与主利用共享时判断主利用存储是否曾经标记为已共享 if (mainStore.__isShare !== true) { // 所有子利用状态 mainStore.__subStores = new Set(); // 已监听的命名空间 mainStore.__subWatchs = new Map(); mainStore.__isShare = true; } // 把以后子利用存储放入主利用外面 mainStore.__subStores.add(store); const shareNames = new Set(); const { _modulesNamespaceMap: moduleMap } = store; // 监听以后store,更新主利用store,并统计该子利用须要共享的所有命名空间 Object.keys(moduleMap).forEach(key => { const names = key.split('/').filter(k => !!k); // 如果该命名空间的下级命名空间曾经共享则上级不须要再共享 const has = names.some(name => shareNames.has(name)); if (has) { return; } const { _rawModule: { share } } = moduleMap[key]; if (share === true) { const namespace = names.join('.'); // 监听以后子利用存储的命名空间,发生变化后更新主利用与之同名的命名空间数据 _watch(store, value => updateStoreState(mainStore, namespace, value), namespace); shareNames.add(namespace); } }); // 存储以后子利用须要共享的命名空间 store.__shareNamespaces = shareNames; shareNames.forEach(ns => { // 从主利用同步数据 updateStoreState(store, ns, getNamespaceState(mainStore.state, ns)); if (mainStore.__subWatchs.has(ns)) { return; } // 监听主利用的状态,更新子利用存储 const w = mainStore.watch(state => getNamespaceState(state, ns), value => updateSubStoreState([...mainStore.__subStores], ns, value), { deep: true }); console.log(`主利用store监听模块【${ns}】数据`); mainStore.__subWatchs.set(ns, w); }); } return store;};
看到这里,你肯定也惊叹于微前端的精妙吧!纸上得来终觉浅,期待各位的实际口头,如果遇到任何问题,欢送关注咱们 LigaAI@sf ,一起交换,共同进步~更多详情,请点击咱们的官方网站 LigaAI-新一代智能研发治理平台
本文局部内容参考:Micro Frontends、Micro Frontends from martinfowler.com、微前端的外围价值、qiankun介绍
本文作者: Alone zhou
本文链接:https://blog.cn-face.com/2021/06/17/微前端(qiankun)尝鲜/
版权申明: 本博客所有文章除特地申明外,均采纳 BY-NC-SA 许可协定。转载请注明出处!