fengshi123 / blog

汇总发布的前端博文,大家一起交流学习,如果有帮助到您,欢迎 star ~
1.32k stars 177 forks source link

微前端探索 #21

Open fengshi123 opened 4 years ago

fengshi123 commented 4 years ago

一、微前端由来

随着前端历史化进程的推进,出现了两种前端开发模式,MPA 多页面应用模式和 SPA 单页面应用模式,其分别有自己的独到之处以及不足点。 (1)MPA 模式 例如中后台系统涵盖多个业务模块,分别由不同的团队负责,并且每个业务模块都有独立的域名,访问不同的业务模块会重新刷新浏览器或者新开标签页的方式来实现系统间的跳转。MPA 模式的优点在于部署简单、各个业务模块之间隔离,天然具备技术栈无关、独立开发、独立部署的特性;其缺点也明显,不同模块之间切换会造成浏览器重刷,不同产品域名之间相互跳转,流程体验上会存在明显的断点。 (2)SPA 模式 相信现在前端应用几乎由 SPA 三大马车 Vue、React、Angular 构建开发,应用之间页面跳转通过监听浏览器 URL 进行页面的卸载/挂载,所以其优点是天生具备体验上的优势,页面之间切换无需刷新浏览器,能极大的保证多产品之间流程操作串联时的流程性;缺点则在于各应用模块是强耦合的,并且随着应用的需求迭代,会产生巨石应用。 微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。其集合了 MPA 模式和 SPA 模式各自的优势,通常的微前端架构具有以下优势:

二、iframe 存在的问题

在早期,微前端概念出现之前,我们整合多个团队多个应用,我们不约而同的选择即为 iframe。iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但它的最大问题也在于它的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

三、single-spa 实例

single-spa 是很多微前端框架的基石(其本身就是微前端框架,但很多大厂、微前端框架基于其进行二次封装),所以深刻知道其原理是对微前端的探究和实践的基础。single-spa 是一个将多个单页面应用聚合为一个整体应用的 javascript 微前端框架。 single-spa 具体的教程以及 api 可以查看 single-spa 官网,也建议在阅读以下章节内容时,先自行稍微简单阅读下其官网,知道其是什么、能做什么等。本章节,我们直接使用 single-spa 官网提供的实践实例来进行描述 single-spa 的使用,并且带着对现象背后的思考引出下一章节的原理解析。

1、克隆实例项目

git clone https://github.com/joeldenning/coexisting-vue-microfrontends.git

2、启动项目

在基座应用和子应用目录,分别安装依赖包,然后分别启动应用:

// root-html-file
cd root-html-file
npm install
npm run serve

// navbar
cd navbar
npm install
npm run serve

// app1
cd app1
npm install
npm run serve

// app2
cd app2
npm install
npm run serve

3、观察实例 & 思考

(1)我们查看基座应用的实例代码,基座应用中进行子应用的注册(registerApplication)调用了 single-spa 的 registerApplication 函数,其内部实现了些啥,registerApplication 完再进行 start 启动,start 是做什么的,内部又是怎么实现的? 1

(2)我们查看子应用的入口执行文件 main.js 发现子应用都会导出 3 个周期函数 bootstrap/mount/unmount,那这三个周期函数又是在什么时候执行呢? 2

(3)我们通过浏览器访问 http://localhost:5000/ ,可得以下页面。通过看实例代码,我们可以发现以下页面是基座应用 navbar 的页面,那 single-spa 是怎么做到这个路由匹配的,以及点击 App1、App2 会分别跳转到 app1、app2 子应用的页面,且不会刷新页面,也就是 single-spa 的关键功能点 —— url 路由匹配; 3

(4)我们不断点击 App1 App2,观察浏览器调试框 network tab 选项,我们会发现:切换到不同的子应用,只会在第一次渲染子应用时才会去加载子应用渲染所需的资源,后续的切换不会再加载相关资源;观察浏览器调试框 Elements 选项,我们会发现:不同子应用的 Dom 结构会随着子应用的切换,而对应地挂载、卸载,那 single-spa 是如何进行子应用的资源下载、挂载和卸载呢? 4 5 如果你对以上的一些现象 or 实现不知所以然,并且你很想去了解下原理,那么下一章节的内容 — 原理解析将很适合你。

四、single-spa 原理

用一句话概括 single-spa 的原理:single-spa 是一个状态机 ,框架只负责维护各个子应用的状态,其中怎么加载子应用、挂载子应用、卸载子应用等,都由子应用自身控制,从而 single-spa 框架有很好的扩展性。 我们可以从 single-spa 的 github 官网 clone 源码查看,sinlge-spa 的功能源码主要集中在 src 目录下,src 目录下各个文件的主要功能汇总如下图 6 我们了解大概的源码目录及功能后,我们再回过头去逐一探究下第二部分的一些 api 以及功能的原理。在一些代码注释讲解部分,我们省略掉一些不影响原理理解的参数校验等。

1、registerApplication 注册做了哪些事情

registerApplication 注册方法在文件 /src/applications/app.js 中定义,代码及注释如下所示,其主要做了三件事情:

(1)registerApplication 注册方法代码如下:

export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  // hb: 格式化用户传入的应用配置参数,保证传入的参数是合法的
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );

  // hb: 已经存在相同名称的应用报错
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(
      formatErrorMessage(
        21,
        __DEV__ &&
          `There is already an app registered with name ${registration.name}`,
        registration.name
      )
    );

  // 将每个应用的配置信息都存放到 apps 数组中
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );

  if (isInBrowser) {
    ensureJQuerySupport();
    reroute();
  }
}

(2)loadApps 方法代码如下:

  // hb: 整体返回一个立即resolved的promise,通过微任务来加载apps
  function loadApps() {
    return Promise.resolve().then(() => {
      // hb: 返回封装后的 app,包括给其附上生命周期等
      const loadPromises = appsToLoad.map(toLoadPromise);
      console.log('查看 loadPromises :', loadPromises);

      return (
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => [])
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }

(3)toLoadPromise 方法核心代码如下:


export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) {
      // hb: 说明 app 已经被加载
      return app.loadPromise;
    }

    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }

    app.status = LOADING_SOURCE_CODE;

    let appOpts, isUserErr;

    return (app.loadPromise = Promise.resolve()
      .then(() => {
        // hb: loadApp 即是用户传入的参数  () => System.import('navbar'), 
        // 所以加载子应用其实就是通过用户自己传入的加载方式,即使如果不用 System.import 也可以;
        // 没有明白属性获取来干嘛用 getProps(app) ???
        const loadPromise = app.loadApp(getProps(app));

        // hb: 子应用导出的必须是个对象,且包含 3 个生命周期:bootstrap、mount、unmount
        return loadPromise.then((val) => {
          app.loadErrorTime = null;
          appOpts = val;

          app.status = NOT_BOOTSTRAPPED;
          // hb: 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
          app.bootstrap = flattenFnArray(appOpts, "bootstrap");
          app.mount = flattenFnArray(appOpts, "mount");
          app.unmount = flattenFnArray(appOpts, "unmount");
          app.unload = flattenFnArray(appOpts, "unload");
          app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);

          // hb: 执行到这里说明子应用已成功加载,删除app.loadPromise属性
          delete app.loadPromise;

          return app;
        });
      })
      .catch((err) => {
        // hb: 加载失败,稍后重新加载
        delete app.loadPromise;

        let newStatus;
        if (isUserErr) {
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          newStatus = LOAD_ERROR;
          app.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, app, newStatus);

        return app;
      }));
  });
}

2、start 方法做了些什么

我们从上一小节已经知道 registerApplication 时会对注册并且 url 路径匹配到的子应用进行下载,也是说如果只注册,会下载匹配子应用的资源,但是并不会进行初始化或者渲染;那么 start 方法的作用即进行子应用的初始化和渲染,代码如下所示,我们可以看到在 start 方法中主要调用 reroute 方法,在 reroute 方法中会区分是 start 前还是后,如果是 start 前,则就像上一小节所讲的,是注册时进行子应用资源的下载;如果是 start 后,则调用 performAppChanges 方法,对不同状态的子应用进行对应操作

(1)start 方法代码

// hb: 调用 start 之前,应用会被加载,但不会初始化、挂载和卸载,有了 start 可以更好的控制应用的流程
export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

(2)reroute 方法

export function reroute(pendingPromises = [], eventArguments) {
  const {
    appsToUnload,  // hb: 需要被移除的
    appsToUnmount, // hb: 需要被卸载的
    appsToLoad,    // hb: 需要被加载的
    appsToMount,   // hb: 需要被挂载的
  } = getAppChanges();

  let appsThatChanged;
  // hb: 是否 start 调用 isStarted 为 false 时表示是 start 调用
  if (isStarted()) {  
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }

  // hb: 整体返回一个立即resolved的promise,通过微任务来加载apps
  function loadApps() {
    return Promise.resolve().then(() => {
      // hb: 返回封装后的 app,包括给其附上生命周期等
      const loadPromises = appsToLoad.map(toLoadPromise);
      return (
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => [])
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }

  function performAppChanges() {
    return Promise.resolve().then(() => {
      // hb: 移除应用 => 更改应用状态,执行unload生命周期函数,执行一些清理动作
      // 其实一般情况下这里没有真的移除应用
      const unloadPromises = appsToUnload.map(toUnloadPromise);
      // hb: 先卸载再移除
      const unmountUnloadPromises = appsToUnmount
        .map(toUnmountPromise)
        .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
      const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
      const unmountAllPromise = Promise.all(allUnmountPromises);

      unmountAllPromise.then(() => {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true)
          )
        );
      });

      // hb: 待加载的进行加载 并且进行挂载
      const loadThenMountPromises = appsToLoad.map((app) => {
        return toLoadPromise(app).then((app) =>
          tryToBootstrapAndMount(app, unmountAllPromise)
        );
      });

      // hb: 待挂载的进行挂载
      const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => {
          return tryToBootstrapAndMount(appToMount, unmountAllPromise);
        });
      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {});
    });
  }
}

3、子应用导出的生命周期函数执行时间点

我们查看子应用的入口执行文件 main.js 发现子应用都会导出 3 个周期函数 bootstrap/mount/unmount,那这三个周期函数又是在什么时候执行呢? 其实在下载完子应用资源后,会将子应用的生命周期函数添加在 app(single-spa 中每个子应用是一个 app 对象,然后汇总成数组 apps)的属性中,然后 single-spa 会在子应用 app 状态更新时对应执行其生命周期函数。 (1)加载方法中处理子应用生命周期方法的代码

export function toLoadPromise(app) {
   app.status = NOT_BOOTSTRAPPED;
   // hb: 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
   app.bootstrap = flattenFnArray(appOpts, "bootstrap");
   app.mount = flattenFnArray(appOpts, "mount");
   app.unmount = flattenFnArray(appOpts, "unmount");
   app.unload = flattenFnArray(appOpts, "unload");
   app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
  });
})

(2)flattenFnArray 方法

// hb: 返回一个接受 props 作为参数的函数,这个函数负责执行子应用中的生命周期函数,
// 并确保生命周期函数返回的结果为promise
export function flattenFnArray(appOrParcel, lifecycle) {
  let fns = appOrParcel[lifecycle] || [];
  fns = Array.isArray(fns) ? fns : [fns];
  if (fns.length === 0) {
    fns = [() => Promise.resolve()];
  }

  return function (props) {
    return fns.reduce((resultPromise, fn, index) => {
      return resultPromise.then(() => {
        const thisPromise = fn(props);
      });
    }, Promise.resolve());
  };
}

4、子应用切换

通过实例发现我们的基座应用能根据不同的 URL 对应挂载/卸载我们的子应用,且不会刷新页面,那么 single-spa 是如何做到 url 路由匹配的呢?其实如果大家有了解过 vue-router、react-router 这些单页面应用的路由切换原理的话,其无非应用了 hashchange 事件来监听 hash 路由的变化、popstate事件来监听 history 路由的变化,其 single-spa 的 url 路由匹配的原理是一模一样的(基础 api 就那些,难道还能变出花来吗),在 /src/navigation/navigation-events.js 文件中定义相关操作。

  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

而且我们在注册子应用时,把子应用路由匹配规则作为参数进行传入了,single-spa 在 /src/applications/app.helpers.js 中使用这个传入的路由匹配判断条件进行判断该子应用是否应该处于活跃(挂载)状态,相关代码如下,其中 app.activeWhen 即为传入路由匹配函数。到此,我们就差不多知道了 single-spa 是如何做到当 url 切换时,匹配到相应子应用的。

// hb: 是否应该活跃状态(url 匹配到路由)
export function shouldBeActive(app) {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    handleAppError(err, app, SKIP_BECAUSE_BROKEN);
    return false;
  }
}

5、子应用挂载/卸载如何实现

我们通过以上已经知道 single-spa 控制着子应用的状态变化,例如在注册时进行子应用资源的下载、进行子应用的挂载/卸载,我们第三章节中是通过我们注册应用时传入的下载方式 System.import 进行下载的,并不是 single-spa 内部有资源下载方式;那么挂载/卸载呢?其实挂载/卸载也是通过各个子应用自己传入对应的生命周期函数进行对应的操作,我们查看子应用使用的插件 single-spa-vue(不是 single-spa) 的挂载/卸载生命周期函数,可以看到对应生命周期函数进行 dom 元素的挂载和卸载。而在 single-spa 内部仅仅是在对应的子应用状态执行子应用对应的生命周期函数,single-spa 本身只起控制状态的作用,它自己本身不亲自操刀的,无论下载、挂载、卸载等,这样也能做到更好的扩展性,用户想怎么下载、挂载、卸载,他们自己来决定,只要你传入规范的参数即可。 (1)single-spa-vue 的挂载/卸载生命周期函数

// 挂载生命周期函数
function mount(opts, mountedInstances, props) {
  return Promise
    .resolve()
    .then(() => {
      const appOptions = {...opts.appOptions}
      if (props.domElement && !appOptions.el) {
        appOptions.el = props.domElement;
      }

      if (!appOptions.el) {
        const htmlId = `single-spa-application:${props.name}`
        appOptions.el = `#${htmlId.replace(':', '\\:')} .single-spa-container`
        let domEl = document.getElementById(htmlId)
        if (!domEl) {
          domEl = document.createElement('div')
          domEl.id = htmlId
          document.body.appendChild(domEl)
        }

        // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
        // We want domEl to stick around and not be replaced. So we tell Vue to mount
        // into a container div inside of the main domEl
        if (!domEl.querySelector('.single-spa-container')) {
          const singleSpaContainer = document.createElement('div')
          singleSpaContainer.className = 'single-spa-container'
          domEl.appendChild(singleSpaContainer)
        }

        mountedInstances.domEl = domEl
      }

      if (!appOptions.render && !appOptions.template && opts.rootComponent) {
        appOptions.render = (h) => h(opts.rootComponent)
      }

      if (!appOptions.data) {
        appOptions.data = {}
      }

      appOptions.data = {...appOptions.data, ...props}

      mountedInstances.instance = new opts.Vue(appOptions);
      if (mountedInstances.instance.bind) {
        mountedInstances.instance = mountedInstances.instance.bind(mountedInstances.instance);
      }
    })
}

// 卸载生命周期函数
function unmount(opts, mountedInstances) {
  return Promise
    .resolve()
    .then(() => {
      mountedInstances.instance.$destroy();
      mountedInstances.instance.$el.innerHTML = '';
      delete mountedInstances.instance;

      if (mountedInstances.domEl) {
        mountedInstances.domEl.innerHTML = ''
        delete mountedInstances.domEl
      }
    })
}

相信阅读到这里的读者,此时脑海中对笔者关于 single-spa 的原理总结也深有感触吧,single-spa 是一个状态机 ,框架只负责维护各个子应用的状态,其中怎么加载子应用、挂载子应用、卸载子应用等,都由子应用自身控制,从而 single-spa 框架有很好的扩展性。 通过本章节的阅读,我门深刻理解 single-spa 框架的运行机制,但是 single-spa 作为最底层架构,在实际场景中还是存在一些问题的,如下所示,下一章节我们将围绕这些问题去探讨如何解决。

五、qiankun(乾坤) 原理

qiankun(乾坤) 就是一款由蚂蚁金服推出的比较成熟的微前端框架,基于 single-spa 进行二次开发,用于将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。如果还不了解该框架的同学,可以先查阅qiankun 官网;本章节我们主要围绕第三节抛出的几个疑问,来探讨下 qiankun(乾坤)是如何处理的。

1、子应用独立运行

qiankun 使用 import-html-entry 插件将子应用的 html 作为入口,框架会将 HTML document 作为子节点塞到主框架的容器中。就算子应用更新了,其入口 html 文件的 url 始终不会变,并且完整的包含了所有的初始化资源 url,所以不用再自行维护子应用的资源列表了。并且对旧有的项目作为子应用接入成本几乎为零,开发体验与独立开发时保持不变,相较于 single-spa 的 js entry 而言更加灵活、方便、体验更好。

2、css 样式隔离

由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,子应用之间难免会出现样式互相干扰的问题。样式隔离有两个思路,第一个是使用类似于 CSS Module 或者 BEM 的方案,本质上是通过约定来避免冲突,对于新项目来说,这种方案成本很低,但是如果涉及到与老项目一同运行,那改造成本将会非常高昂;第二个思路是在子应用卸载的时候同时卸载掉样式表,技术原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载样式的目的,这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。 qiankun 框架采用的是第二种思路,使用 import-html-entry,通过解析 html entry 中的  和