6

前言

在软件开发中,逐渐出现了类,模块化,组件化,设计模式等来解耦和拆分我们的代码,使得代码更易读,易维护。而微前端架构其实也是一种新的思想来帮助我们更好的拆分一些现在方式无法解决的问题而已。

什么是微前端

  • 微服务的架构思想在前端的映射和落地
  • 针对复杂且大型的web前端的整体架构和组织结构问题,将单体的前端拆分成更小,更简单的模块,使其可以独立开发,测试和部署,最后将其整合到一起。

设计理念

类似与操作系统,将系统的实现与系统的基本操作规则区分开来。将核心功能模块化,划分成几个独立的进程,各自运行,所有的服务进程都运行在不同的地址空间,让服务各自独立。
设计理念.png

落地到浏览器

落地到浏览器.png

如上图所示:微前端落地到浏览器,浏览器将承载一个html页面,在页面中安装,启动相应的服务。

微前端的核心价值

  • 不受技术栈的约束,可以使用任何技术栈来研发独立的模块
  • 自动同步更新,独立开发,部署,测试
  • 将大型web应用拆分,使其更易维护,测试,同时使模块与模块之前更加独立,解决部分紧耦合的问题

微前端带来的问题

  • 部署
  • 服务拆分标准
  • 拆的太多会过于分散,拆的太少会过于密集

拆分方式

个人感觉除去微前端的一些技术实现,它主要难点就在于如何拆分应用,在拆分的同时还需结合团队规模等问题:

  • 按功能维度拆分
  • 按业务逻辑拆分
  • 按前端路由拆分

应用场景

并不是所有应用都适合使用微前端架构,在一个简单的单体应用中使用,反而适得其反。
微前端主要应用在大型的互联网应用,该应用可能具备几个特点:系统庞大到很多人去开发,页面数量达到某个量级,在这种情况下可能会导致系统难以维护,代码量逐渐增大,同时也使得协作方面难以管控,包括测试,回归,并且在工程化方面编译显的耗时。总结有以下几点情况

  • 业务越来越多
  • 组件越来越多
  • 文件越来越多
  • 打包编译速度越来越慢
  • 开发启动速度越来越慢
  • 定位文件越来越慢

微前端落地的几种实现方式

  • npm:子系统以NPM包的形式发布,打包构建的时候集成到主系统一起打包发布。
  • iframe:子工程之间完全独立,以iframe的方式集成到主系统,这样也能使用不能的技术栈去实现。
  • 使用现有框架:single-spa,Mooa,qiankun

微前端基本原理

基本原理.png

和微服务一样,微前端的独立部署是关键。减少服务间的耦合性,无论前端代码部署在哪里,每个微前端都有自己持续交付pipeline,进行构建,测试,部署到生产环境中。最后将多个子系统集成到主系统中。

微前端架构

微前端架构.png

基座工程:

  • 路由控制层: 根据url变化来调其不同的子应用
  • 应用注册: 注册每个子应用的信息
  • 生命周期管理: 得到每个应用的生命周期,如安装,卸载等管理
  • 应用加载器:加载对应的子应用
  • 服务发现:得到每个子应用的服务,入口文件等信息

子应用:

  • 子应用之间相互隔离并独立运行
  • Manifest:记录的该应用入口文件,地址等信息
  • 生命周期:暴露生命周期函数工基座工程管理

single-spa的生命周期管理

生命周期.png

  • not_loaded: 还未加载,默认状态
  • load_source_code: 加载模块中
  • not_bootstrapped: 加载完成,但是还未启动
  • bootstrapping: 正在执行的bootstrap生命周期
  • not_mounted: 未装载
  • mounting:正在装载
  • mounted: 已装载
  • updating: 更新
  • unloading: 清楚加载
  • unmounting: 卸载

路由控制层

路由控制层主要监控路由的变更,通过路由变更来控制子系统是否需要加载。子系统路由发生变化首先会有主系统拦截路由变更时间,决定是否加载子系统,如果路由不需要切换子系统,则将该事件交还给子系统处理。
屏幕快照 2020-05-10 下午9.14.38.png

应用注册

应用注册.png

应用注册其实就是类似于平时的账号注册,需要填写一些基本信息,而在微前端中所指的应用注册主要是指app名称,以及对应子系统配置文件的url。而在single-spa中registerApplication主要包含三个参数,appName,app,activeWhen

  • appName:app名称
  • activeWhen:返回true则加载应用
  • app:加载子应用

    在主应用中会注册多个子应用,而这些子应用的信息及状态会进行保存和管理。

模块加载器

模块加载器.png

注册对应的子系统,当路由规则匹配到某个子系统的时候会先去加载该子系统的manifest文件来获取该子系统的信息,通过该文件去加载对应的子系统。

single-spa实现思路

实现思路.png

single-spa执行队列有两个入口,一是通过监听路由的变化,二是register函数。
每次触发会先判断是否已启动,如果未启动则执行loadApps去加载需要加载的app。如果已启动则调用performAppChanges函数去mount app。
在执行期间,如果有新的app进来也就是队列发生了变更,会将新的app缓存待执行完当前的再循环执行下一次,这个操作由finishUpAndReturn这个函数内部做判断来完成。

single-spa主要有一下几个模块:

  • applcation : app的注册,生命周期过滤,任务超时等处理函数
  • lifecycles:应用的生命周期管理
  • navigation:监听全局路由变化,执行队列的核心函数。
  • parcels:挂在parcel的核心函数,返回parcel的各个生命周期钩子

下面来大致看一下几个核心函数大致的实现

registerApplication

该函数接收4个参数:

  • appNameOrConfig:app名称或一个包含这4个参数的对象,如果是对象的话则下面三个参数就不用传了
  • appOrLoadAppFn:子系统的bundle代码以及生命周期函数
  • activeWhen:何时激活子应用去mount,返回一个boolean
  • customProps:自定义传递给子系统的属性
export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );

  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
      },
      registration
    )
  );
  
  reroute();
}
  • sanitizeArguments主要为了格式化参数,以支持另外一种对象的方式传递
  • 然后将该注册的应用添加到一个数组中,并给该应用一个初始化的状态为NOT_LOADED
  • 最后执行reroute函数

路由监听

function urlReroute() {
  reroute([], arguments);
}

window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
  • 通过监听hashChange和popstate事件来执行reroute函数
const originalAddEventListener = window.addEventListener;

window.addEventListener = function(eventName, fn) {
    if (typeof fn === "function") {
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], listener => listener === fn)
      ) {
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    return originalAddEventListener.apply(this, arguments);
  };
  • 改写addEventListener监听函数,使每次路由变化事件先由single-spa接管,然后再执行原监听函数交还给系统路由。
  • 这里会先将监听函数保存在capturedEventListeners[eventName]数组中,在调用完reroute之后再去执行capturedEventListeners这个队列里面的事件函数,这样能够保证single-spa每次先执行

reroute

reroute.png

let appChangeUnderway = false,
peopleWaitingOnAppChange = [];

function reroute(pendingPromises = [], eventArguments) {
  .......
}

函数接收两个参数:

  • pendingPromises:执行reroute期间,再次调用reroute函数所产生的app
  • eventArguments:路由监听事件的event参数

下面都是reroute函数里面的代码:

  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments
      });
    });
  }
  • 如果reroute在执行期间再次被调用,则会先将数据缓存到peopleWaitingOnAppChange当中,当reroute当前次调用结束后递归执行,以保证执行顺序
  let wasNoOp = true;

  if (isStarted()) {
    appChangeUnderway = true;
    return performAppChanges();
  } else {
    return loadApps();
  }
  • wasNoOp: 等于true的时候表示app没有发生变更,也就是没有发生状态的变化。
  • isStarted: 判断是否已经启动
  • 如果启动了则执行performAppChanges,并将appChangeUnderway=true
  • 否则执行loadApps
  function loadApps() {
    return Promise.resolve().then(() => {
      const loadPromises = getAppsToLoad().map(toLoadPromise);

      if (loadPromises.length > 0) {
        wasNoOp = false;
      }

      return (
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          .then(() => [])
          .catch(err => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }
  • getAppsToLoad:根据一些条件来筛选出需要加载的app
  • toLoadPromise:将每个app都封装到一个promise中,返回一个数组
  • 通过promise.All执行每个promise,主要判断每个app是否有bootstrap,mount,unmount生命周期,有则将app状态修改为NOT_BOOTSTRAPPED,没有则更改为SKIP_BECAUSE_BROKEN
  • 执行callAllEventListeners函数,来调用拦截下来的原生事件。
  function performAppChanges() {
    return Promise.resolve().then(() => {
      const unloadPromises = getAppsToUnload().map(toUnloadPromise);

      const unmountUnloadPromises = getAppsToUnmount()
        .map(toUnmountPromise)
        .map(unmountPromise => unmountPromise.then(toUnloadPromise));

      const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
      if (allUnmountPromises.length > 0) {
        wasNoOp = false;
      }

      const unmountAllPromise = Promise.all(allUnmountPromises);

      const appsToLoad = getAppsToLoad();

      /* We load and bootstrap apps while other apps are unmounting, but we
       * wait to mount the app until all apps are finishing unmounting
       */
      const loadThenMountPromises = appsToLoad.map(app => {
        return toLoadPromise(app)
          .then(toBootstrapPromise)
          .then(app => {
            return unmountAllPromise.then(() => toMountPromise(app));
          });
      });
      if (loadThenMountPromises.length > 0) {
        wasNoOp = false;
      }

      /* These are the apps that are already bootstrapped and just need
       * to be mounted. They each wait for all unmounting apps to finish up
       * before they mount.
       */
      const mountPromises = getAppsToMount()
        .filter(appToMount => appsToLoad.indexOf(appToMount) < 0)
        .map(appToMount => {
          return toBootstrapPromise(appToMount)
            .then(() => unmountAllPromise)
            .then(() => toMountPromise(appToMount));
        });
      if (mountPromises.length > 0) {
        wasNoOp = false;
      }
      return unmountAllPromise
        .catch(err => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
           * events (like hashchange or popstate) should have been cleaned up. So it's safe
           * to let the remaining captured event listeners to handle about the DOM event.
           */
          callAllEventListeners();

          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch(err => {
              pendingPromises.forEach(promise => promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn);
        });
    });
  }
  • unloadPromises: 先拿到需要unload的app,然后封装到一个执行unload的promise中
  • unmountUnloadPromise: 拿到需要unmount的app,封装到一个promise中,再将unmountPromise的结果封装到uploadPromise中
  • allUnmountPromises: 将unmount和unload合并
  • unmountAllPromise: 执行allUnmountPromises,这里不等待执行完成直接执行下面代码
  • getAppsToLoad:加载需要load的app
  • loadThenMountPromises: 将需要load的app封装到loadPromise中,执行该promise,完成后封装到BootstrapPromise中,再接着执行BootStrapPromise,最后等上面unmountAllPromise的执行完毕后将app封装到toMountPromise中去
  • mountPromises:拿到所有需要mount的app,返回的是一个promise
  • 最后在unmountAllPromise执行完后调用callAllEventListeners,然后去挂在app,mount完后执行finishUpAndReturn来看队列中是否还有等待的任务,递归执行reroute

总结

能不用则不用!!!!!


accord
1.3k 声望187 粉丝

希望遇到一个公司,遇到一个团队,大家都愿意把code当作一种艺术去书写