导读

1.历史是怎样的 🤔
2.是怎样设计的 🤔
3.我们生活里的微前端 !😊

企业微信截图_b04511c8-93ba-429a-852b-21cd3fc61e09.png

背景

🌰
在开始之前,我们先来看两个例子

1 一个完整的商业化流程后台流程

商机 >> 项目中心 >> 方案中心 >> 报价单 >> 合同 >> 履约财税
企业微信截图_6f1ed29f-9c2c-4e2c-8244-e81fc46f0410.png
从商机的建立,到最终产品交付,履约收款,是一个很长很复杂的链路, 每个系统又可以拆成相对独立的几个子模块,比如合同,可以分为生成合同(智能化推荐), 合同评审(负责人,财务,法务等), 在面对如此巨量的系统时,我们通常将其按照模块分成几个项目,通过对应的链接去跳转对应的系统来实现整体流程的串联, 再到后面,我们希望给客户经理一个 【平台系统】 可能以项目流程为中心去聚合其他应用,通常的做法是iframe或者集成其他业务方npm模块的形式,但这种模式通常有一些问题,参考Why Not Iframe

所以在这种背景下,我们希望可以实现一个插拔式 SPA 应用架构, 来将不同业务子系统集中到一个大平台上,统一对外开放使用

2 saas 能力的按需加载

saas 的出现是为了解决企业信息管理的成本诉求, 就如我在大学的时候, 为一家洗衣公司开发一款在线洗衣小程序,并且有后台可以看到对应的订单, 客户相关的信息,这家公司可以基于此来宣传并提供诸如上门取送衣物的服务来提高市场占有率,并基于地域下单量,客户沟通等选择开分店,但是当时的问题是什么呢? 第一点, 客户无法承担招聘开发带来的成本,所以选择让我们来开发,并给予报酬 第二点, 我们开发完,是不是可以寻求其他有相同诉求的客户,再次为其提供服务呢? 我当时还未了解sass这种模式,当我知道有赞提供同样并且功能更加丰富,并且成本更低的服务时, 很庆幸, 这种认知差让我赚到了一些钱并在大四好好玩了一下

那回过来, 如何让客户挑选自己需要的功能模块来构建自己的saas服务(按业务模块进行服务的售卖), 就需要如下架构的应用设计, 可以把相对独立的功能模块作为一个微应用,由微前端+微服务构成,多个微应用组合形成一个自定义sass, 每个微应用单独定价
我们世界里面的微前端 - 图5

方案设计

通过上面的描述,我们想要的核心能力为,可以将一个大的应用拆解成一个个小的相对独立的子系统, 每个子系统可以独立开发,部署, 主框架主要充当调度者的角色, 如下图的例子中,主框架配置子应用的路由为 businessMicroApp: { url: ‘/business/**’, entry: ‘./business.js’ },则当浏览器的地址为 /business 或者/business/home 时,框架需要先加载 entry 资源,待 entry 资源加载完毕, 再将其挂载到 container 主应用节点上面 , 确保子应用的路由系统注册进主框架之后,再去由子应用的路由系统接管 url change 事件。同时在子应用路由切出时,主框架需要调用子应用的卸载方法卸载应用,如 React 场景下挂载,卸载对应的方法如下

  1. const mount = () => ReactDOM.render(<App2 />, container);
  2. const unmount = () => ReactDOM.unmountAtNode(container);

image.png

综上所述,我们的核心是需要设计一套路由系统,劫持路由改变事件,优先处理子应用逻辑 , 主应用负责整体布局, 以及注册子应用 (给到框架), 子应用需要在入口暴露对应挂载,卸载等生命周期方法

子应用注册

在之前的描述中,我们需要获取到子应用对应的名称,激活条件, 以及资源入口 (或者动态加载子应用的方式)等信息,那我们就需要维护一个结构,并给到主应用对应的方法将这些信息注册进来, 如下

  1. // 保存所有的子应用
  2. const apps = [];
  3. // 子应用注册方法
  4. export function registerApplication(name, loadApp, activeWhen, customProps) {
  5. const registration = {
  6. name,
  7. loadApp,
  8. activeWhen,
  9. customProps,
  10. };
  11. // 为每个注册的应用设置初始状态
  12. apps.push(
  13. Object.assign(
  14. {
  15. status: NOT_LOADED,
  16. },
  17. registration
  18. )
  19. );
  20. }

主应用使用方法如下

  1. // 是否激活
  2. function pathPrefix(prefix) {
  3. return function (location) {
  4. return location.pathname.startsWith(`${prefix}`);
  5. };
  6. }
  7. // ./app/app1.js
  8. let domEl;
  9. export function bootstrap(props) {
  10. return Promise.resolve().then(() => {
  11. domEl = document.createElement("div");
  12. domEl.id = "app1";
  13. document.body.appendChild(domEl);
  14. });
  15. }
  16. export function mount(props) {
  17. return Promise.resolve().then(() => {
  18. // 在这里通常使用框架将ui组件挂载到dom
  19. domEl.textContent = "App 1 is mounted!";
  20. });
  21. }
  22. export function unmount(props) {
  23. return Promise.resolve().then(() => {
  24. // 在这里通常是通知框架把ui组件从dom中卸载
  25. domEl.textContent = "";
  26. });
  27. }
  28. // 动态加载, 返回的是个promise, chrome已经支持
  29. const app1 = () => import("./app/app1.js");
  30. registerApplication("app1", app1, pathPrefix("/app1"), {});


👆上面我们将注册进来的子应用设置了未加载这个状态, 下一步我们就可以监测路由的变化,从而匹配激活应用

路由系统劫持

如果你之前了解过react-router-dom之类的库的话,会发现他使用html5新增的history Api 来实现无刷新更改地址栏链接, 如下,而且也提供了 popstate 这个监听函数,当url发生变化的时候会触发

  1. history.pushState(); // 添加新的状态到历史状态栈
  2. history.replaceState(); // 用新的状态代替当前状态
  3. history.state // 返回当前状态对象

它的核心是当路由变化时,通知订阅的Router重新渲染进而执行matchPath去看当前浏览器url是否匹配Router的path,来决定是否渲染Router传进来的component组件 , 从而实现更改路由来加载不同组件的逻辑,那我们也可以沿着这个思路, 再主应用中监听路由的变化,当匹配到对应子应用的标识的时候去挂载对应的子应用,并卸载之前已挂载的子应用, 简化的伪代码如下

  1. // 子应用的协调逻辑
  2. function urlReroute() {
  3. // 1 移除和卸载需要卸载的应用
  4. // 2 加载和挂载需要进行挂载的应用
  5. // 3 确保应用卸载和挂载完成后在注册路由事件监听器
  6. }
  7. // 监听路由更改事件
  8. window.addEventListener("popstate", urlReroute);

此处demo代码

这里引出了两个问题

1 怎么知道哪些子应用是需要加载, 装载, 卸载的呢
2 如何确保子应用的路由系统注册进主框架之后,再去由子应用的路由系统接管 url change 事件

关于这两个问题的解决方案

1 可以给每个子应用app打标,用于区分当前是什么状态,进而做筛选
2 跟之前小程序分享的双线程数据通信中,在逻辑层跟视图层尚未 Ready时,将消息暂存在消息队列中一样,将路由相关的方法劫持重写,存到一个捕获的 【监听池】中,等子应用加载 Ready了, 再手动派发事件

子应用打标

子应用在被初始化挂载到对应的节点,再到被移除的整个流程其实很像组件的生命周期一样,以single-spa实现为例,每个子应用存在12个生命周期状态, 每个状态的转化流程如下,

  1. 每个子应用初始的状态为 【未加载】
    2. 当当前子应用被激活时, 加载子应用资源(js, html)
    3. 当当前子应用的资源加载完成后, 调用子应用生命周期的bootstrap, mount来将子应用挂载在页面上
    4. 当当前子应用失活时,调用子应用生命周期的unmount 卸载子应用

image.png

子应用状态如下

  1. // 加载子应用资源 >> 挂载子应用 >> 卸载子应用
  2. export const NOT_LOADED = "NOT_LOADED"; // 未加载,初始状态
  3. export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载原代码
  4. export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 没有启动
  5. export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 微应用启动中,初始化的时候调用一次
  6. export const NOT_MOUNTED = "NOT_MOUNTED"; // 没有挂载
  7. export const MOUNTING = "MOUNTING"; // 挂载中
  8. export const MOUNTED = "MOUNTED"; // 已经挂载
  9. export const UNMOUNTING = "UNMOUNTING"; // 卸载中
  10. export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 运行出错

通过给每个子应用打生命周期状态的标记, 我们可以得到哪些子应用是 待加载, 待挂载, 待卸载的, 进而执行对应的生命周期方法 loadapp, (bootstrap mount) , unmount ,并且更新子应用状态, 即上文中reroute子应用的协调逻辑, 这个方法如下

  1. export function getAppChanges() {
  2. const appsToLoad = [], // 待加载
  3. appsToMount = [], // 待挂载
  4. appsToUnmount = [], // 待卸载
  5. appsToUnload = [], // 待移除
  6. apps.forEach((app) => {
  7. const appShouldBeActive = apps.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
  8. switch (app.status) {
  9. case NOT_LOADED:
  10. case LOADING_SOURCE_CODE:
  11. if (appShouldBeActive) {
  12. appsToLoad.push(app);
  13. }
  14. break;
  15. case NOT_BOOTSTRAPPED:
  16. case NOT_MOUNTED:
  17. if(appShouldBeActive) {
  18. appsToMount.push(app);
  19. }
  20. break;
  21. case MOUNTED:
  22. if (!appShouldBeActive) {
  23. appsToUnmount.push(app);
  24. }
  25. break;
  26. }
  27. });
  28. return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
  29. }


至此, 第一个点已经处理, 需要注意的是, 子应用生命周期函数每次执行,对应的状态都会被更新,再次看下上面的图,我们来完善之前reroute处理子应用的相关逻辑

完善处理子应用

我们首先根据上面getAppChanges获取需要操作的子应用

  1. const { appsToLoad, appsToMount, appsToUnmount, appsToUnload } = getAppChanges()

然后判断当前微前端是否启动了,如果启动则调用performAppChanged函数根据getAppChanges函数的返回值对微应用进行相应的处理,并改变相应的状态, 否则先预加载子应用

  1. if (isStarted()) {
  2. return preformAppChanges();
  3. } else {
  4. // 加载待加载应用
  5. return loadApps();
  6. }

preformAppChanges逻辑如下

  1. function preformAppChanges() {
  2. return Promise.resolve().then(() => {
  3. // 1 移除和卸载需要卸载的应用, 并更改状态
  4. const unloadPromises = appsToUnload.map(toUnloadPromise);
  5. // 先卸载再移除
  6. const unmountPromises = appsToUnmount
  7. .map(toUnmountPromise)
  8. .map((promise) => promise.then(toUnloadPromise));
  9. const unmountAllPromises = Promise.all(unmountPromises.concat(unloadPromises));
  10. // 2 加载和挂载需要进行挂载的应用, 并更改状态
  11. const loadPromises = appsToLoad.map((app) => {
  12. return toLoadPromise(app).then((app) => {
  13. tryToBootstrapAndMount(app);
  14. });
  15. });
  16. // 之前已经加载过的应用
  17. const mountPromises = appsToMount.map((app) => tryToBootstrapAndMount(app));
  18. // 3 确保应用卸载和挂载完成后在注册路由事件监听器
  19. return unmountAllPromises
  20. .then(() => {
  21. // 派发加载之前的子路由事件
  22. callAllEventListeners();
  23. })
  24. .catch((err) => {
  25. callAllEventListeners();
  26. throw err;
  27. });
  28. });
  29. }
  30. function tryToBootstrapAndMount() {
  31. if (shouldBeActive(app)) {
  32. return toBootstrapPromise(app).then((app) => {
  33. return shouldBeActive(app) ? toMountPromise(app) : app;
  34. });
  35. }
  36. return app;
  37. }

这样我们完善了子应用的处理逻辑,现在就是补充对应的生命周期处理方法, 以及加载前路由事件队列储存,加载后派发等相关逻辑了

生命周期方法

在上一节preformAppChanges中,我们调用了诸如toLoadPromise, toMountPromise, 等方法去加载,挂载子应用, 其主要是调用子应用暴露的生命周期方法,以及管理子应用的生命周期状态

toLoadPromise 加载子应用资源的逻辑如下

  1. export function toLoadPromise(app) {
  2. return Promise.resolve().then(() => {
  3. // 缓存机制,启动是同步的,加载流程是异步的,正在加载中,还没加载完的子应用不用再次加载
  4. if (app.loadPromise) {
  5. return app.loadPromise;
  6. }
  7. if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
  8. return app;
  9. }
  10. app.status = LOADING_SOURCE_CODE;
  11. return (app.loadPromise = Promise.resolve().then(() => {
  12. // 这里使用的动态导入的方式
  13. const loadPromise = app.loadApp(app.customProps);
  14. return loadPromise
  15. .then((val) => {
  16. app.status = NOT_BOOTSTRAPPED;
  17. app.bootstrap = flattenFnArray(val, 'bootstrap');
  18. app.mount = flattenFnArray(val, 'mount');
  19. app.unmount = flattenFnArray(val, 'unmount');
  20. app.unload = flattenFnArray(val, 'unload');
  21. delete app.loadPromise;
  22. return app;
  23. })
  24. .catch((err) => {
  25. app.status = LOAD_ERROR;
  26. delete app.loadPromise;
  27. return app;
  28. });
  29. }));
  30. });
  31. }

这里需要注意的是,在加载子应用时,我们是通过动态导入js的方式来加载模块并返回一个promise, 该 promise resolve 为一个包含其所有导出的模块对象, 我们可以在代码中的任意位置调用这个表达式。 而且现代浏览器也原生支持, 只需设置script标签的type属性为module即可, 但这种方案有一些弊端,人们更倾向于HTML Entry ,这里我们暂不介绍,后面再说

toMountPromise 挂载子应用的方法如下

  1. export function toMountPromise(app) {
  2. return Promise.resolve().then(() => {
  3. if (app.status !== NOT_MOUNTED) {
  4. return app;
  5. }
  6. app.mount(app.customProps)
  7. .then(() => {
  8. app.status = MOUNTED;
  9. return app;
  10. })
  11. .catch((err) => {
  12. console.error(err);
  13. app.status = SKIP_BECAUSE_BROKEN;
  14. return app;
  15. });
  16. });
  17. }


这里的处理比较简单,其他生命周期的处理也大致一致,就不过多介绍了, 大家可以直接去看完整的代码

路由事件劫持

终于来到上面👆第二个问题的解决部分了, 核心是什么大家还记得吧, 没错,劫持路由,优先处理子应用加载逻辑, 处理 Ready 之前的路由事件放到队列中管理,等 Ready了,派发下,就跟小程序双线程通信消息队列的场景很像

image.png

劫持

我们监听路由的变化来优先执行处理子应用逻辑

  1. function urlReroute() {
  2. reroute([], arguments)
  3. }
  4. // 劫持路由变化
  5. window.addEventListener('hashchange', urlReroute);
  6. window.addEventListener('popstate', urlReroute);

后面来的路由事件进行劫持保存到 capturedEventListeners

  1. export const routingEventsListeningTo = ["hashchange", "popstate"];
  2. const capturedEventListeners = { // 存储hashchang和popstate注册的方法
  3. hashchange: [],
  4. popstate: []
  5. }
  6. // 重写事件监听器添加和移除函数,拦截和收集路由事件监听器,
  7. // 就像redux中间件的逻辑差不多
  8. const originAddEventListener = window.addEventListener;
  9. const originRemoveEventListener = window.removeEventListener;
  10. window.addEventListener = function (eventName, fn) {
  11. if (
  12. routingEventsListeningTo.indexOf(eventName) >= 0 &&
  13. !find(capturedEventListeners[eventName], (listener) => listener === fn)
  14. ) {
  15. eventListeners[eventName].push(fn);
  16. return;
  17. }
  18. return originAddEventListener.apply(this, arguments);
  19. };
  20. window.removeEventListener = function (eventName, fn) {
  21. if (routingEventsListeningTo.indexOf(eventName) >= 0) {
  22. capturedEventListeners[eventName] = capturedEventListeners[eventName].filter((listener) => listener !== fn);
  23. return;
  24. }
  25. return originRemoveEventListener.apply(this, arguments);
  26. };

派发

子应用加载后执行路由事件池里面的事件, 即是上面preformAppChanges中的callAllEventListeners逻辑,其实很简单,就是根据事件类型去循环执行队列里保存的监听函数

  1. // 入参为 [{type: 'popstate'}, ...]
  2. function callCapturedEventListeners(eventArgs) {
  3. if (!eventArgs) return;
  4. const eventType = eventArgs[0].type; // popstate haschange
  5. if (capturedEventListeners.indexOf(eventType) >= 0) {
  6. capturedEventListeners[eventType].forEach((listener) => {
  7. try {
  8. listener.apply(this, eventArgs);
  9. } catch (err) {
  10. throw err;
  11. }
  12. });
  13. }
  14. }

到这里,我们基础实现了一个基础的微前端框架,可以看到核心其实并不复杂,而且上面也有一些逻辑被我们屏蔽了,大家了解思想就好, 但也留了一些坑,比如 html entry, 其实我们的微应用框架(基于qiankun)也是独立部署,产物为html entry, 框架去请求对应的html然后解析执行等逻辑

可以在微应用的打包上传OSS日志的log中看到

  1. uploading 腾讯 OSS ...
  2. https://static1.tuyacn.com/static/d-platform-product-manage/0.0.0-19/index.html uploaded. cost: 176.501653ms
  3. https://static1.tuyacn.com/static/d-platform-product-manage/0.0.0-19/static/js/main.9b30d3ba.js uploaded. cost: 200.051004ms
  4. ...
  5. 腾讯 CDN 上传成功, 10 个文件, 共耗时 761.53418ms


对应html

  1. <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title></title><script defer="defer" src="./static/js/runtime.9660bc96.js"></script><script defer="defer" src="./static/js/react-related.aa0c57ad.js"></script><script defer="defer" src="./static/js/vendors-others.ac0a6610.js"></script><script defer="defer" src="./static/js/main.45dfce24.js"></script><link href="./static/css/vendors-others.eb015858.css" rel="stylesheet"><link href="./static/css/main.9243395f.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

那我们就来说下 html entry 这个方案, 这个方案最好的就是简单, 简单到像iframe一样, 这也是qiankun的核心理念之一, 真的这点非常不错

html entry

这里我们主要使用import-html-entry 来实现, 这个库会调用fetch请求html资源,再找出js, css 等静态资源去请求,注意需要将相对路径做转化,先加载 CSS 再加载 JS 文件等, 加载html的逻辑如下

  1. export const loadHTML = async (app) => {
  2. const { container, entry } = app
  3. // template:处理好的 HTML 内容
  4. // getExternalStyleSheets:fetch CSS 文件
  5. // getExternalScripts:fetch JS 文件
  6. const { template, getExternalScripts, getExternalStyleSheets } =
  7. await importEntry(entry)
  8. const dom = document.querySelector(container)
  9. if (!dom) {
  10. throw new Error('容器不存在')
  11. }
  12. dom.innerHTML = template
  13. await getExternalStyleSheets()
  14. const jsCode = await getExternalScripts()
  15. jsCode.forEach((script) => {
  16. const lifeCycle = runJS(script, app)
  17. console.log('lifeCycle', lifeCycle, jsCode, app);
  18. if (lifeCycle) {
  19. app.bootstrap = lifeCycle.bootstrap
  20. app.mount = lifeCycle.mount
  21. app.unmount = lifeCycle.unmount
  22. }
  23. })
  24. return app
  25. }
  26. const runJS = (value, app) => {
  27. const code = `
  28. return (window => {
  29. ${value}
  30. return window['${app.name}']
  31. })(window)
  32. `
  33. return new Function(code)()
  34. }


在使用时子应用打包是需配置让主应用能正确识别微应用暴露出来的一些信息 即可, 至于js隔离,css隔离, 应用间通信等方案可阅读文章最后的参考部分,先这样 代码 (建议看别人的)

一些想法💡

在写完这些后,我脑子中涌现了很多想法,我想写出来,分享给大家,很早之前我看过一本经济学的书,里面描述了社会进步以及生产效率提高的一大因素,就是分工, 分工产生了交换, 进而促使资产流动, 我们不必要去做饭也可以吃到别人劳动做的饭,我们只需要拿自己劳动的收入去做支出就好, (当然,偶尔自己做做饭还是很不错的),并且可以让每个人去做自己比较擅长的事

像我们现在出来的这些概念并不是凭空出现的,就比如微应用就借鉴了后段微服务的思想,而这种思想有很早的出现在生活其他领域中,比如汽车模块化生产,以及现在全球化市场中苹果, 耐克的生产方式一样,我上次再听机器人的规划会的时候,突然意识到它的组成零件真的好多并且复杂,可能每家公司负责提供一部分,组装起来形成一个完整的产品, 比如涂鸦负责软件,联网云这些,乐动等负责各种算法,其他零件厂商提供各种传感器, 芯片, 电机之类的,在我之前看的扫地机产业白皮书里面,是一整个产业链, 这种都在负责自己擅长的领域,其实才能促进整个行业的进步, 还有之前的万科的房屋装配模块化(万科将其称为标准化预制构件), 真的让人感慨颇多。 我甚至预测后面直接会有一套住房模版库, 以及万科在每个模块的产品经理化,跟我之前想的其他领域产品思维参与进去的想法不谋而合, 就像开一家html餐厅,产品化的思维打造一些特色食品, 并且跟客户建立直接的联系,并迭代改进

image.png

image.png

参考

前端插拔式 SPA 应用架构实现方案
可能是你见过最完善的微前端解决方案
single-spa