什么是微前端?
微前端首次提出是在 2016 年的一篇文章 ThoughtWorks Technology Radar,它把后端的微服务概念延伸到前端界。当前的网页开发的一个趋势是构建富交互、功能强大的 web 应用,也就是我们说的 SPA。背后支撑的是多个后端微服务,往往是不同的团队开发,这种模式被称为单体巨石应用(Frontend Monolith)。
Monolithic Frontends:
微前端背后的思想是认为:现代复杂的web app或者网站,通常由很多 相对独立的功能模块组合而成,而对这些模块负责的应该是 相互独立的多个团队。这些独立的团队由于专业分工不同,会负责着 特定的业务领域,以及完成 特定的开发任务。这样的团队,通常在人员组成方面囊括了从前端开发到服务端开发,从UI实现到数据库设计这样 端到端 的 跨职能人员 构成。
在过去,微前端之类的思路,会被称为 面向垂直划分系统的前端集成(Organisation in Verticals)。
Organisation in Verticals:
微前端核心思想
-
技术不可知主义 每个团队应该选择自己的技术栈以及技术进化路线,而不是与其他团队步调一致。
-
隔离团队之间的代码 即便所有团队都使用同样的框架,也不要共享同一个运行时环境。构建自包含的Apps。不要依赖共享的状态或者全局变量。
-
建立团队自己的前缀 在还不能做到完全隔离的环境下,通过命名规约进行隔离。对于CSS, 事件,Local Storage 以及 Cookies之类的环境之下,通过命名空间进行的隔离可以避免冲突,以及所有权。
-
原生浏览器标准优先于框架封装的API 使用 用于通信的原生浏览器事件机制 ,而不是自己构建一个PubSub系统。如果确实需要设计一个跨团队的通信API,那么也尽量让设计简单为好。
-
构建高可用的网络应用 即便在Javascript执行失败的情况下,站点的功能也应保证可用。使用同构渲染以及渐进增强来提升体验和性能。
为什么会出现微前端?
传统Web架构:
面临问题:随着互联网行业发展,软件规模迅速扩大,传统的单体架构面临一系列问题:难以横向扩展,系统内部错综复杂,可靠性差法技术升级成本巨大。
于是出现了微服务架构。
微服务架构:
优点:各业务独立开发、独立部署,可靠性高,技术可单独升级,易于横向扩展。
新问题:SPA 技术的的兴起,导致前端开发复杂度急速上升,面临传统单体架构后端同样的问题。在这个过程中,也出现了对单页面和多页面方案的探讨。
SPA or MPA?
| 方案 | 优点 | 缺点 |
|---|---|---|
| MPA | HTML直出,各应用之间硬隔离,天生具备技术栈无关、独立开发、独立部署。 | 页面刷新、资源重复加载、体验不好 |
| SPA | 体验好,按需加载,框架支持,快速开发 | 技术栈强耦合,SEO不友好 |
而微前端架构的出现,可以整合两者的优点,能够做到独立开发部署、保留局部刷新良好体验。
微前端面临的问题
应用隔离
最主要的是 JS 沙箱的实现,通常有以下几种方式:
- 利用函数作用域,如 IIFE.
- iframe: 天然隔离,有执行上下文隔离、路由隔离、多实例等优点。
- Proxy代理window对象:记录每次window对象的修改,子应用加载/卸载时还原全局对象,但无法处理多实例场景。
- Proxy代理fakeWindow对象:每个子应用都有一个模拟的window对象,同时将默认的window对象传入,优先取默认的window。
DOM和样式隔离
- Shadow DOM
- 命名方法论,BEM/OOCSS/SMACSS
- CSS Modules
- scoped (vue)
构建时整合 VS 运行时整合
构建时整合: 子应用通过 Package Registry(npm、git等等) 方式整合,与主应用一起打包发布。 运行时整合: 子应用自己构建部署,主应用运行时动态加载子应用资源。
二者也有各自的优缺点:
| 方案 | 优点 | 缺点| | ---- | ---- | ---- | ---- | | 构建时整合 | 主应用、子应用更方便构建优化,资源共享。 | 技术栈耦合,每次要全量部署。 | | 运行时整合 | 独立部署,技术栈无关 | 多出运行时的性能损耗。 |
JS Entry VS HTML Entry
JS Entry 的代表就是 single-spa,这种方式是将一个微应用完整打包成一个 js 文件,包括css、图片等资源,这样容易导致 js bundle 过大,而且有些构建优化手段无法使用,如CSS拆分。而且一旦旧的项目需要接入微前端架构,改造成本比较高。
qiankun 框架为了解决 JS Entry 的问题,于是采用了 HTML Entry. 通过 import-html-entry 实现,原理是用 http 请求加载指定地址的 html 页面,然后解析这个 html 获取相应的 js、css等资源。这样既减少了接入成本,也保持了灵活性。
微前端实现方式
基于前面的分析,微前端架构有几个实现思路可供选择:
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| nginx | 通过nginx配置反向代理,不同的路径转发到不同的应用。 | 简单、快速配置 | 切换应用时页面刷新,影响体验 |
| iframe | 通过父应用嵌套iframe。 | 实现简单、子应用天然隔离 | 需要面对诸多问题,如全局环境完全隔离、DOM无法共享、url不同步等 |
| Web Components | 浏览器原生技术,符合组件化的思想。 | 可以隔离DOM和CSS | 兼容性一般,旧项目改造成本高。 |
| 组合式应用路由分发 | 每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制 | 子应用相互隔离,纯前端改造,体验良好 | 需解决CSS隔离、全局对象污染、通信机制等问题。 |
| 特定中心路由基座式 | 每个子应用独立构建和部署,但必须使用相同技术栈,统一路由。 | 体验良好,子应用通信方便,方便做工程化。 | 需解决CSS隔离、全局对象污染问题,技术升级成本高。 |
接下来就以最火的 qiankun 框架来做演示。
qiankun 框架
qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
特性
- 基于 single-spa 封装,提供了更加开箱即用的 API。
- 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
- HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
- 样式隔离,确保微应用之间样式互相不干扰。
- JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
- 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
- umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。
为什么不是 iframe
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
使用
主应用
- 安装 qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
- 在主应用中注册微应用
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
},
{
name: 'vue app',
entry: { scripts: ['//localhost:7100/main.js'] },
container: '#yourContainer2',
activeRule: '/yourActiveRule2',
},
]);
start();
当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。
如果微应用不是直接跟路由关联的时候(如一个页面内同时加载多个子应用),你也可以选择手动加载微应用的方式:
import { loadMicroApp } from 'qiankun';
loadMicroApp({
name: 'app',
entry: '//localhost:7100',
container: '#yourContainer',
});
微应用
微应用不需要额外安装任何其他依赖即可接入 qiankun 主应用。但是需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出 bootstrap、mount、unmount 三个生命周期钩子,以供主应用在适当的时机调用。
/**
* 渲染函数
* 主应用生命周期钩子中运行/子应用单独启动时运行
*/
function render(props = {}) {
if (props) {
// 注入 actions 实例
actions.setActions(props);
}
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
mode: "history",
routes,
});
// 挂载应用
instance = new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
}
export async function bootstrap() {
console.log("vue app bootstraped");
}
export async function mount(props) {
console.log("vue mount", props);
render(props);
}
export async function unmount() {
console.log("vue unmount");
instance.$destroy();
instance = null;
router = null;
}
除了代码中暴露出相应的生命周期钩子之外,为了让主应用能正确识别微应用暴露出来的一些信息,微应用的打包工具需要增加如下配置:
const packageName = require('./package.json').name;
module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
};
子应用(微应用)的接入就是这么简单!接下来看看 vue 框架的接入示例。
Vue 微应用接入 基于 vue-ci 3 和 vue 2.x 示例。
- 在
src目录新增public-path.js, 设置运行时的 publicPath:
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 入口文件
main.js修改,为了避免根 id #app 与其他的 DOM 冲突,需要限制查找范围。
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';
Vue.config.productionTip = false;
let router = null;
let instance = null;
function render(props = {}) {
const { container } = props;
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
mode: 'history',
routes,
});
instance = new Vue({
router,
store,
render: (h) => h(App),
}).$mount(container ? container.querySelector('#app') : '#app');
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
console.log('[vue] props from main framework', props);
render(props);
}
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
router = null;
}
- 打包配置修改
vue.config.js:
const { name } = require('./package');
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd', // 把微应用打包成 umd 库格式
jsonpFunction: `webpackJsonp_${name}`,
},
},
};
应用通信
qiankun 内部提供了 initGlobalState 方法,用来注册 MicroAppStateActions 实例用于通信,该实例有三个方法:
setGlobalState: 设置globalState,如果和原有的值不同,则通知所有观察者。onGlobalStateChange: 监听globalState的变化,变化时触发此方法的回调函数。offGlobalStateChange: 取消监听,不再响应globalState的改变。
使用示例:
// micro-app-main/src/shared/actions.ts
import { initGlobalState, MicroAppStateActions } from "qiankun";
const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);
export default actions;
// micro-app-main/src/pages/login/index.vue
import actions from "@/shared/actions";
import { ApiLoginQuickly } from "@/apis";
@Component
export default class Login extends Vue {
$router!: VueRouter;
// `mounted` 是 Vue 的生命周期钩子函数,在组件挂载时执行
mounted() {
// 注册一个观察者函数
actions.onGlobalStateChange((state, prevState) => {
// state: 变更后的状态; prevState: 变更前的状态
console.log("主应用观察者:token 改变前的值为 ", prevState.token);
console.log("主应用观察者:登录状态发生改变,改变后的 token 的值为 ", state.token);
});
}
async login() {
// ApiLoginQuickly 是一个远程登录函数,用于获取 token,详见 Demo
const result = await ApiLoginQuickly();
const { token } = result.data.loginQuickly;
// 登录成功后,设置 token
actions.setGlobalState({ token });
}
}
部署
这里以“主应用和微应用部署在不同的服务器,使用 Nginx 代理访问”为例。
主应用的 Nginx 配置:
/app1/ {
proxy_pass http://www.b.com/app1/;
proxy_set_header Host $host:$server_port;
}
主应用注册微应用时,entry 可以为相对路径,activeRule 不可以和 entry 一样(否则主应用页面刷新就变成微应用):
registerMicroApps([
{
name: 'app1',
entry: '/app1/', // http://localhost:8080/app1/
container: '#container',
activeRule: '/child-app1',
},
],
对于 webpack 构建的微应用,微应用的 webpack 打包的 publicPath 需要配置成 /app1/,否则微应用的 index.html 能正确请求,但是微应用 index.html 里面的 js/css 路径不会带上 /app1/。
module.exports = {
output: {
publicPath: `/app1/`,
},
};
微应用打包的 publicPath 加上 /app1/ 之后,必须部署在 /app1 目录,否则无法独立访问。
完整的例子可以参考 qiankun官方DEMO。
原理
qiankun 基于 single-spa 做了二次封装,single-spa 就做了两件事情:
- 加载微应用(加载方法需自己实现)
- 管理微应用的状态(初始化、挂载、卸载)
其余的 JS 沙箱、样式隔离、HTML Entry等都是 qiankun 新增的特性。下图展示了 single-spa 和qiankun 的区别:
HTML Entry
HTML Entry的加载和解析是通过 import-html-entry 包实现的,也是 qiankun 团队出品。import-html-entry 的常规用法如下:
import importHTML from 'import-html-entry';
importHTML('./subApp/index.html')
.then(res => {
console.log(res.template);
res.execScripts().then(exports => {
const mobx = exports;
const { observable } = mobx;
observable({
name: 'kuitos'
})
})
});
importHTML() 源码如下:
export default function importHTML(url, opts = {}) {
let fetch = defaultFetch;
let autoDecodeResponse = false;
let getPublicPath = defaultGetPublicPath;
let getTemplate = defaultGetTemplate;
// compatible with the legacy importHTML api
if (typeof opts === 'function') {
fetch = opts;
} else {
// fetch option is availble
if (opts.fetch) {
// fetch is a funciton
if (typeof opts.fetch === 'function') {
fetch = opts.fetch;
} else { // configuration
fetch = opts.fetch.fn || defaultFetch;
autoDecodeResponse = !!opts.fetch.autoDecodeResponse;
}
}
getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
getTemplate = opts.getTemplate || defaultGetTemplate;
}
return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
.then(response => readResAsString(response, autoDecodeResponse))
.then(html => {
const assetPublicPath = getPublicPath(url);
const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);
return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
template: embedHTML,
assetPublicPath,
getExternalScripts: () => getExternalScripts(scripts, fetch),
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
fetch,
strictGlobal,
beforeExec: execScriptsHooks.beforeExec,
afterExec: execScriptsHooks.afterExec,
});
},
}));
}));
}
最关键的是 processTpl 方法处理 template:
export default function processTpl(tpl, baseURI) {
let scripts = [];
const styles = [];
let entry = null;
const moduleSupport = isModuleScriptSupported();
const template = tpl
.replace(HTML_COMMENT_REGEX, '')
.replace(LINK_TAG_REGEX, match => {
//...
})
.replace(STYLE_TAG_REGEX, match => {
//...
})
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
//...
});
scripts = scripts.filter(function (script) {
// filter empty script
return !!script;
});
return {
template,
scripts,
styles,
// set the last script as entry if have not set
entry: entry || scripts[scripts.length - 1],
};
}
JS 沙箱
使用 Proxy 实现沙箱,不支持的使用模拟的 SnapshotSandbox:
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox ? new LegacySandbox(appName) : new ProxySandbox(appName);
} else {
sandbox = new SnapshotSandbox(appName);
}
ProxySandbox 核心代码:
当set的属性在FakeWindow不存在,但在原生的window中存在时,将该属性以及它的descriptor复制到FakeWindow,实现复制的副本。
get某属性时,优先在FakeWindow查找,没有再到原生window查找。
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// We must kept its description while the property existed in rawWindow before
if (!target.hasOwnProperty(p) && rawWindow.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
// @ts-ignore
target[p] = value;
}
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
rawWindow[p] = value;
}
updatedValueSet.add(p);
this.latestSetProp = p;
return true;
}
// ...
},
get(target: FakeWindow, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'window' || p === 'self') {
return proxy;
}
// ...
// eslint-disable-next-line no-nested-ternary
const value = propertiesWithGetter.has(p)
? (rawWindow as any)[p]
: p in target
? (target as any)[p]
: (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
});
样式隔离
如果是使用 strictStyleIsolation 模式,则用 Shadow DOM:
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appName: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appName);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appName);
});
}
return appElement;
}
获取到了所有的 style 节点后遍历处理 css:
private ruleStyle(rule: CSSStyleRule, prefix: string) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
const rootCombinationRE = /(html[^\w{[]+)/gm;
const selector = rule.selectorText.trim();
let { cssText } = rule;
// handle html { ... }
// handle body { ... }
// handle :root { ... }
if (selector === 'html' || selector === 'body' || selector === ':root') {
return cssText.replace(rootSelectorRE, prefix);
}
// handle html body { ... }
// handle html > body { ... }
if (rootCombinationRE.test(rule.selectorText)) {
const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;
// since html + body is a non-standard rule for html
// transformer will ignore it
if (!siblingSelectorRE.test(rule.selectorText)) {
cssText = cssText.replace(rootCombinationRE, '');
}
}
// handle grouping selector, a,span,p,div { ... }
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// handle div,body,span { ... }
// ...
}),
);
return cssText;
}
简单来说,就是利用正则表达式匹配 html、body、:root 等特殊选择器替换为子应用的挂载点的 css selector,然后给其他的选择器加上子应用的独特前缀,这样就起到了隔离样式的效果。
其他微前端框架
- Single-Spa:最早的微前端框架,兼容多种前端技术栈。
- Mooa:基于Angular的微前端服务框架
- Icestark:阿里飞冰微前端框架,兼容多种前端技术栈。
- Ara Framework:由服务端渲染延伸出的微前端框架。