若是说,2021年了你还不了解「微前端」,请自觉搬好板凳前排听讲,小编特意邀请了咱们 LigaAI 团队的前端负责人先军老师,带你轻松玩转微前端。css
什么是微前端?
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. – Micro Frontendshtml
微前端是一种多个团队经过独立发布功能的方式,来共同构建现代化 web 应用的技术手段及方法策略。前端
不一样于单纯的前端框架/工具,微前端是一套架构体系,这个概念最先在2016年末由 ThoughtWorks 提出。 微前端是一种相似于微服务的架构,它将微服务的理念应用于浏览器端,将 Web 应用从整个的「单体应用」转变为多个小型前端应用的「聚合体」。vue
各个前端应用「原子化」,能够独立运行、开发、部署,从而知足业务的快速变化,以及分布式、多团队并行开发的需求。react
核心价值(为何要使用微前端?)
- 不限技术栈webpack
主应用不限制接入的子应用的技术栈,子应用拥有彻底自主权。所接入的子应用之间也相互独立,没有任何直接或间接的技术栈、依赖、以及实现上的耦合。git
- 可独立开发、部署web
微应用仓库独立,先后端都可独立开发,部署完成后主框架自动完成同步更新。独立部署的能力在微前端体系中相当重要,可以缩小单次开发的变动范围,进而下降相关风险。 各个微前端都应该有本身的持续交付管道,这些管道能够将微前端构建、测试并部署到生产环境中。vue-router
- 增量升级vuex
在面对各类复杂场景时,咱们一般很难对一个已经存在的系统作全量的技术栈升级或重构。 所以,微前端是一种很是好的实施渐进式重构的手段和策略,它能够逐渐升级咱们的架构、依赖关系和用户体验。当主框架发生重大变化时,微前端的每一个模块能够独立按需升级,不须要总体下线或一次性升级全部内容。若是咱们想要尝试新的技术或互动模式,也能在隔离度更好的环境下作试验。
- 简单、解耦、易维护
微前端架构下的代码库倾向于更小/简单、更容易开发,避免无关组件之间没必要要的耦合,让代码更简洁。经过界定清晰的应用边界来下降意外耦合的可能性,更好地避免这类无心间形成的耦合问题。
在什么场景下使用?
微前端架构旨在解决单体应用在一个相对长的时间跨度下,因为参与人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用 (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: {}, }] } // chainWebpack config.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@OSChina](https://my.oschina.net/u/5057806) ,一块儿交流,共同进步~更多详情,请点击咱们的官方网站 LigaAI-新一代智能研发管理平台
本文部份内容参考: Micro Frontends、Micro Frontends from martinfowler.com、微前端的核心价值、qiankun介绍
本文做者: Alone zhou