每日一诗
丑奴儿·书博山道中壁
作者:[辛弃疾]
少年不识愁滋味,爱上层楼。爱上层楼,为赋新词强说愁。
而今识尽愁滋味,欲说还休。欲说还休,却道天凉好个秋。
微前端是什么?
微前端是什么?解答这个问题之前就必须了解一下微服务。
微服务 一个服务随着迭代越来越大,而且维护人员也越来越大,协同和功能回归需要花费的成本也越来越高。
为了解决这个问题,开发者就想到能不能把这个巨石服务拆成一个一个子服务,独立开发、独立测试、独立部署,降低协同和回归的成本,而且每个服务也可以专注于负责的业务,再也不怕改a业务的代码,一步小心改到了b业务的代码。而且升级的时间也变少了,错误定位范围也缩小了。
那么把微服务的概念用到前端就变成了微前端
微前端每个子应用独立开发、独立测试、独立部署。
微前端解决的问题
-
独立开发
每个子应用独立开发,降低了协同成本,再也不用改a模块的组件还要看是否其他模块是否用到了这个组件。 -
独立构建部署
构建
一个巨石应用,构建的时候花费的构建时间是非常长了,之前作者负责的项目构建时间就长达15分钟,十分不敏捷,就修个fix都得等很长时间。
微前端化后只要打包对应的子应用就好了,时间缩短明显。部署 没有拆之前,虽然优化git flow,但是时常上线还得看看前面的人上线了吗,以防把别人没有验证完的代码带到线上。团队成员少的时候还不严重。团队成员多的时候简直是噩梦。
有时候其他需求验证时间长,你就得一直等。微前端化后虽然也有这个问题,但是把团队成员划分成了模块成员,相对来说就没有那么严重了。 -
独立测试
巨石应用的时候,改动一个小块,为了确保无问题都要全部回归(线上问题无小事,都是大事情,搞不好就得复盘)。
微前端化后就只用回归改模块,再也不用担心影响其他模块了。当然谨慎点还得冒泡其他模块。 -
增量迁移 我们通常模块迁移,从a平台迁移到b平台一般是把代码迁移过去,改造成符合b规范的代码。
代码、目录规范
不同的项目,规范可能不一致,特别是老项目,如果不改造的话就会有可能把b项目的规范给搞乱,而且开发体验也很差。(同一规范的重要性)技术栈 现在团队内项目的技术栈是统一的,但是还有一些老项目的技术栈是不一致的,那么迁移的话就需要改造代码,例如react项目的状态管理工具不一致,还可以改造一下,如果一个使用react、一个使用vue,那么迁移相当于重写。
成本 代码迁移,代码量少还好说,如果代码量一大,上述几点就增加了很高的复杂度,而且老项目一般迭代都是非常久了,历史包袱非常大。
如果微前端化后就完全可把迁移模块改造成子应用,这样迁移的时候就不必担心上述问题。
-
无技术栈限制
微前端后,每个子应用可以有自己的构建工具、自己的技术栈、自己的规范。当然能统一还是统一。
微前端的拆分方式
-
iframe
通过iframe去嵌套其他页面
优点 成本低,侵入性低
缺点 体验差,主子应用通信十方麻烦,而且iframe还有很多不确定的点。 -
nginx
优点 通过nginx重定向,改造成本低,只需要维护一个nginx配置就好了
缺点 体验差,主子应用通信十方麻烦。 -
服务端匹配
优点 成本高,依赖于后端,但是有时候出问题很难定位,作者之前端内h5,分页打包服务端渲染模板的时候,就遇到过服务端渲染了两个模板的内容。导致定位成本高。 缺点 体验差,主子应用通信十方麻烦。 -
js import
例如system.js,可以再浏览器环境下去导入对应的js bundle
优点 成本高,子应用需要改造,但微前端框架乾坤、garfish都封装好了,如果用这些框架那么成本就非常低了
缺点 体验好,主子应用通信简单。
微前端的历史
-
早期 使用system.js、import-html-entry、singleSpa,支持路由劫持、子应用加载,不支持样式隔离和js隔离
-
后来 乾坤对上面的操作进行封装并且支持样式隔离、js隔离,改造成本低
-
现在 最近研究了一下garfish,实践了一下发现很好用,改造成本低,侵入低,上手简单,但是文档没有乾坤那么好。
微前端的原理
这里主要围绕乾坤来进行讲解,原理都是相似的,万变不离其宗。\
- 路由劫持
- 加载子应用
- 调用子应用生命周期进行渲染
乾坤的简单应用
这里主要讲述两步重要改造:
- 注册路由
registerMicroApps([
{
name: "a", // app name registered
entry: "http://localhost:3000",
container: "#container",
activeRule: "/media/a",
},
{
name: "b",
entry: "http://localhost:8082",
container: "#container",
activeRule: "/media/b",
},
]);
start();
- 子应用改造 主要是提供对应的生命周期,以及把包打成umd格式
//main.js
import { createApp } from "vue";
import App from "./App.vue";
let instance = null;
function render(props = {}) {
const { container } = props;
instance = createApp(App);
instance.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;
}
// vue.config.js
const { defineConfig } = require("@vue/cli-service");
const { name } = require("./package");
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
headers: {
"Access-Control-Allow-Origin": "*", // 允许跨域
},
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: "umd", // 把微应用打包成 umd 库格式
chunkLoadingGlobal: `webpackJsonp_${name}`,
},
},
});
接下来我们实现一下其原理,实践具体操作可以看一下官网文档。 qiankun
基座路由劫持
qiankun是通过劫持url来查找应用,从而来加载子应用,如果看过vueRouter或者react-router-dom的同学很容易理解。 分为两种模式:
- hash hash的改变可以监听window的onhashchange事件
- history
history模式则比较麻烦一点,需要监听浏览器的前进后退,以及history的操作
1 forward、go、back、浏览器前进后退操作触发后会触发onpopstate事件(注意这个事件是被动事件)
2 pushState和replaceState则需要进行重写,在原生方法外边包一层
实现代码
let apps = null;
// 查询对应path的App
const findApp = (path) => {
const appOfPath = apps.find((item) => {
const { activeRule } = item;
return Object.is(path, activeRule);
});
return appOfPath;
};
// 处理history路由
const handlePathChange = () => {
const path = window.location.pathname;
const app = findApp(path);
console.log(app);
};
// 处理hash路由
const handleHashChange = () => {
const hash = window.location.hash;
const app = findApp(hash);
console.log(app);
};
// 处理onpopstate
const handlePopState = () => {
handlePathChange();
};
// 路由监听
const processRouterEvent = () => {
const historyPush = window.history.pushState;
const historyReplace = window.history.replaceState;
window.history.pushState = (...reset) => {
handlePathChange();
historyPush.call(window.history, ...reset); //注意要绑定函数调用的this执行 不然会报错非法调用
};
window.history.replaceState = (...reset) => {
handlePathChange();
historyReplace(window.history, ...reset); //注意要绑定函数调用的this执行 不然会报错非法调用
};
window.addEventListener("hashchange", handleHashChange, false);
window.addEventListener("popstate", handlePopState, false);
};
export const start = () => {
// 首次渲染拿path匹配子应用
handlePathChange();
processRouterEvent();
};
export const registerMicroApps = (router) => {
apps = router; // 存储子路由表
};
加载子应用
当我们匹配到子应用后就应该去加载子应用,有两种加载:
-
js入口
通过script标签可以去加载js文件 -
html 入口 html入口主要通过fetch和eval去加载。值得注意的是,渲染的时候我们会通过innerHTML渲染到浏览器上, 但是innerHTML会忽略script标签的加载,主要是因为安全原因,所以我们需要去拿到全部script标签,然后逐一去加载
实现代码
// 追加script
const loadScript = (path) => {
const script = document.createElement("script");
script.src = path;
script.defer = true;
document.head.appendChild(script);
};
//html入口 获取资源
const fetchResource = (path) => {
return fetch(path).then((res) => {
return res.text();
});
};
//html入口加载子应用
const loadHtml = async (app) => {
let htmlPath = app.entry;
if (htmlPath.endsWith("/")) {
htmlPath += "index.html";
} else {
htmlPath += "/index.html";
}
const htmlTemplate = await fetchResource(htmlPath);
renderHtml(htmlTemplate, app);
};
// 渲染子应用
const renderHtml = (html, app) => {
const entry = app.entry;
const div = document.createElement("div");
div.innerHTML = html;
const scripts = div.querySelectorAll("script");
const scriptArr = Array.from(scripts);
for (let script of scriptArr) {
const src = script.src;
const origin = window.location.origin; // 注意资源路径问题
const appSrc = src.replace(origin, entry);
fetchResource(appSrc).then((text) => {
eval(text);
});
}
const container = app.container;
const containerDom = document.querySelector(container);
containerDom.appendChild(div);
};
// 加载子应用
const loadApp = (app) => {
const entry = app.entry;
if (entry.endsWith(".js")) {
loadScript(entry);
} else {
loadHtml(app);
}
};
注意此时我们还没有传递container到子应用,所以子应用渲染的时候还是按照默认容器
子应用微前端生命周期原理
生命周期我们这里大概实现一下bootstrap、mount、unmount,这里你可能会疑惑我们怎么拿到子应用导出的生命周期函数?我们按之前提到到的子应用会打包成umd包,那么我们看一下umd包的内容。
umd包会判断环境,然后选择对应的注入方式 因此我们可以手动构造一个commonJS,这里不用window,主要时解决应用重名问题
// 获取js字符串
const sources = await Promise.all(scriptArr);
// 手动构造commonJS
const module = { exports: {} };
const exports = module.exports;
sources.forEach((text) => {
eval(text);
});
const container = app.container;
const containerDom = document.querySelector(container);
containerDom.appendChild(div);
return module;
然后把生命周期赋值给匹配到的app路由对象,并调用
// 渲染
if (entry.endsWith(".js")) {
// module = await loadScript(entry);
} else {
module = await loadHtml(app);
}
const exports = module.exports;
app.bootstrap = exports.bootstrap;
app.mount = exports.mount;
app.unmount = exports.unmount;
const container = document.querySelector(app.container);
app.bootstrap();
app.mount({ container });
preApp = app;
//卸载
const handlePathChange = () => {
const path = window.location.pathname;
const app = findApp(path);
preApp?.unmount();
loadApp(app);
};
注意还得需要设置__POWERED_BY_QIANKUN__变量,用来给子应用区分是否是微前端环境。当然还有关于静态资源路径问题,可以设置一个运行时的publicPath
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
沙箱隔离
这里就不再具体阐述了
样式隔离
-
css module
-
属性选择器
-
动态加载和卸载css
-
shadow dom
js隔离
-
快照沙箱
-
proxy沙箱
单例沙箱 多例沙箱
应用通信问题
这里主要是在mount的时候传入props,这里有一个问题就是如果是想做到数据响应式的话,需要react和reactDom实例是同一个,通过externals就可以,内部机制还要再看一下源码,下次写写总结,之前也有人把应用通信再封装一层,这样虽然灵活性下降了,但是方便定位问题。
总结
写在夜晚,很多东西越写越多,可以拆成几块去总结了,写的比较粗糙,写的比较急,有些小点没有写下来了,有空再进行填补。一篇小小的总结文章。