一个微前端库的诞生-2 | 实现App的管理和调度


前言

截屏2021-12-24 上午11.51.05.png

上一篇文章中,我们已经实现了Socket模块,提供了通信功能。下面我们来编写App模块,并实现App的调度和管理功能。

首先参考模块划分图,我们要明确,实现App的调度和管理,其实是分成实现App模块本身以及实现对App的调度和管理操作两个步骤,App模块本身起声明作用,让使用者能指定生命周期以及与其他App的依赖关系。然后我们实现Bus模块,它管理所有的App实例,对App的调度和管理在Bus模块中实现。

文章中的代码只给出核心实现,会省略一些处理边界情况的逻辑以及大部分类型声明,完整实现可以参考项目中的源码。

App

App模块本身只起声明作用,提供指定生命周期回调的方法onBootstrap, onActivate, onDestroy

class App {
  public doBootstrap?; // App的初始化生命周期回调
  public doActivate?; // App的激活声明周期回调
  public doDestroy?; // App的销毁生命周期回调
  public isRallieCoreApp;

  constructor (public name) {
    this.name = name // App的名字
    this.isRallieCoreApp = true
  }

  // 指定bootstrap生命周期回调
  public onBootstrap (callback) {
    this.doBootstrap = callback
    return this
  }

  // 指定activate生命周期回调
  public onActivate (callback) {
    this.doActivate = callback
    return this
  }

  // 指定destroy生命周期回调
  public onDestroy (callback) {
    this.doDestroy = callback
    return this
  }
}
复制代码

同时提供relateTorelyOn方法,用于指定App的关联和依赖关系。这部分逻辑也不复杂:

export class App {
  // ...省略部分代码

  public dependencies: Array<{name: string; ctx?: Record<string, any>; data?: any}> = []; // 依赖的App数组,存的不是App实例,是App的名字等相关信息
  public relatedApps: Array<{name: string; ctx?: Record<string, any>}> = []; // 关联的App数组,存的不是App实例,是App的名字等相关信息

  // 指定关联的App
  public relateTo (relatedApps) {
    const getName = (relateApp) => typeof relateApp === 'string' ? relateApp : relateApp.name
    const deduplicatedRelatedApps = deduplicate(relatedApps)
    const currentRelatedAppNames = this.relatedApps.map(item => item.name)
    deduplicatedRelatedApps.forEach((relatedApp) => {
      // 去重地把App信息推入this.relatedApps中
      if (!currentRelatedAppNames.includes(getName(relatedApp))) {
        this.relatedApps.push({
          name: getName(relatedApp),
          ctx: typeof relatedApp !== 'string' ? relatedApp.ctx : undefined
        })
      }
    })
    return this
  }

  // 指定依赖的App
  public relyOn (dependencies) {
    const getName = (dependencyApp) => typeof dependencyApp === 'string' ? dependencyApp : dependencyApp.name
    const deduplicatedDependencies = deduplicate(dependencies)
    const currentDependenciesNames = this.dependencies.map(item => item.name)
    const currentRelatedAppsNames = this.relatedApps.map(item => item.name)
    deduplicatedDependencies.forEach((dependency) => {
      const name = getName(dependency)
      // 去重地把App信息推入this.dependencies
      if (!currentDependenciesNames.includes(name)) {
        this.dependencies.push({
          name,
          ctx: typeof dependency !== 'string' ? dependency.ctx : undefined,
          data: typeof dependency !== 'string' ? dependency.data : undefined
        })
      }
      // 去重地把App信息推入this.relatedApps
      if (!currentRelatedAppsNames.includes(name)) {
        this.relatedApps.push({
          name,
          ctx: typeof dependency !== 'string' ? dependency.ctx : undefined
        })
      }
    })
    return this
  }
}

// 对依赖数组去重
function deduplicate (items) {
  const flags = {}
  const result = []
  items.forEach((item) => {
    const name = typeof item === 'string' ? item : item.name
    if (!flags[name]) {
      result.push(item)
      flags[name] = true
    }
  })
  return result
}
复制代码

有一个需要注意的点是,根据我们对关联和依赖功能的设计,如果一个App A关联了App B,那么在激活App A之前,要先加载App B的资源。如果App A依赖了App B,那么在激活App A之前,要先激活App B。因此,实际上如果A依赖了B,那么A也必然关联B。所以在实现relyOn方法时,我们不仅要把依赖的App信息推入this.dependencies中,还需要推入this.relatedApps

总之,App模块都是一些声明性的逻辑,接下来我们看看如果利用这些声明的信息调度和管理App

Bus

参考模块划分图,Bus其实是@rallie/core这个包中最顶层的模块,我们要让不同的App之间能互相通信和调度,就需要用同一个Bus来管理。由于不同App的代码是作为不同的script插入到文档中的,要让每个script都能访问这同一个Bus,我们只能把这个Bus实例挂载到全局

class Bus {
  constructor(public name) {
    this.name = name
  }
}

const busProxy = {}
const DEFAULT_BUS_NAME = 'DEFAULT_BUS'

// 创建Bus并挂载为全局变量
const createBus = (name = DEFAULT_BUS_NAME) => {
  if (window.RALLIE_BUS_STORE === undefined) {
    Reflect.defineProperty(window, 'RALLIE_BUS_STORE', {
      value: busProxy,
      writable: false
    })
  }

  if (window.RALLIE_BUS_STORE[name]) {
    throw new Error(Errors.duplicatedBus(name))
  } else {
    const bus = new Bus(name)
    Reflect.defineProperty(window.RALLIE_BUS_STORE, name, {
      value: bus,
      writable: false
    })
    return bus
  }
}

// 获取Bus
const getBus = (name = DEFAULT_BUS_NAME) => {
  return window.RALLIE_BUS_STORE && window.RALLIE_BUS_STORE[name]
}

// 如果Bus已经存在,就直接获取getBus,否则,先createBus,再getBus
const touchBus = (name = DEFAULT_BUS_NAME) => {
  let bus: Bus = null
  let isHost: boolean = false
  const existedBus = getBus(name)
  if (existedBus) {
    bus = existedBus
    isHost = false
  } else {
    bus = createBus(name)
    isHost = true
  }
  return [bus, isHost] // isHost是用来标记Bus是否是在本次touch时被创建的
}
复制代码

这样不同script的代码只需要约定好Bus的名字,并通过touchBus这个Api就能访问同一个Bus实例。

接着,在讲解App的管理调度之前,我们先把上一篇实现状态和事件通信模块讲解的Socket也纳入Bus管理

import { EventEmitter } from './event-emitter'
import { Socket } from './socket'

class Bus {
  // ...省略部分代码

  private eventEmitter = new EventEmitter()
  private stores = {}

  public createSocket () {
    return new Socket(this.eventEmitter, this.stores)
  }
}
复制代码

这样不同的script就能用同一个Bus创建出的Socket实例进行通信。

接着进入本文的重点。管理App,可以分解为管理

  • App的创建
  • App的资源加载
  • App的生命生命周期

这三个部分,实质也就是实现这几个方法

class Bus {
  // ...省略部分代码
  private apps: Record<string, App> = {} // App池

  public createApp () {
    // 创建App
  }
  public loadApp (name: string, ctx: Record<string, any> = {}) {
    // 加载App资源
  }
  public activateApp<T> (name: string, data?: T, ctx?: Record<string, any> = {}) {
    // 激活App
  }
  public destroyApp (name: string, data?: T) {
    // 销毁App
  }
}
复制代码

管理App的创建

创建App的逻辑非常简单,就是new一个App实例,然后放入App池中即可

class Bus {
  public createApp (name: string) {
    if (!this.apps[name]) {
      const app = new App(name)
      this.apps[name] = app
      return app
    } else {
      throw new Error(`Can not create an app named ${name} twice`)
    }
  }
}
复制代码

需要注意的是,同一个Bus下管理的每个App的名字都必须是唯一的,不允许创建同名App。

管理App的资源加载

要加载App的资源,首先得有一个地方声明App的资源路径,最常规的思路就是用一个对象来存app的名字与资源路径的映射关系,然后调用loadApp方法时,就从这个对象中取出资源路径并加载资源。

interface Conf {
  assets: {
    js: Array<string | Partial<HTMLScriptElement> | HTMLScriptElement>;
    css: Array<string | Partial<HTMLLinkElement> | HTMLLinkElement>
  }
}

class Bus {
  // ...省略部分代码

  private conf: Conf = { // Bus的配置
    assets: {} // App资源映射关系
  };

  // 配置conf
  public config (conf) {
    this.conf = {
      ...this.conf,
      ...conf,
      assets: { // 声明App的资源路径
        ...this.conf.assets,
        ...(conf?.assets || {})
      }
    }
  }
  // 加载资源
  public async loadResourcesFromAssetsConfig (name) {
    const assets = this.conf.assets
    if (assets[name]) {
      // 插入css资源
      assets[name].css &&
        assets[name].css.forEach((asset) => {
          loadLink(asset)
        })
      // 插入js资源
      if (assets[name].js) {
        for (const asset of assets[name].js) {
          await loadScript(asset)
        }
      }
    }
  }
}

function loadLink (asset) {
  // 插入link标签的方法,实现略
}

function loadScript (asset) {
  // 插入script标签的方法,实现略
}
复制代码

现在我们已经可以用bus.config配置资源路径,用bus.loadResourcesFromAssetsConfig加载资源了。但是这样的设计显然不够灵活,于是我们借鉴koa著名的洋葱圈中间件模型,在loadResourcesFromAssetsConfig之前包一层中间件处理,让使用者可以通过编写中间件的形式自定义控制资源加载的过程。

中间件

首先有必要了解一下koa中间件模型的原理。这里我用一个最简单的实现帮助大家理解

const middlewares = [] // 中间件数组

const use = (fn) => {
  middlewares.push(fn) // 注册中间件就是把中间件函数推入middlewares中
}

use(async function f1 (ctx, next) { // 应用中间件f1
  console.log('f1 start')
  await next()
  console.log('f1 end')
})

use(async function f2 (ctx, next) { // 应用中间件f2
  console.log('f2 start')
  await next()
  console.log('f2 end')
})

// 合成中间件执行函数
const compose = (middlewares) => (ctx, next) => {
  const dispatch = (i) => {
    const fn = middleware[i] // 取出当前中间件
      if (!fn) { // 递归终止条件:没有中间件可以执行了
        return Promise.resolve()
      }
      return Promise.resolve(fn( // 执行当前中间件
        ctx,
        dispatch.bind(null, i + 1) // 递归:把执行下一个中间件的函数作为next参数传入 
      ))
  }
  return dispatch(0)
}

const composedFn = compose(middlewares)
const ctx = {}
composedFn(ctx, function f3 (ctx) {
  console.log('f3 is the core')
})

// 最终会打印出
// f1 start
// f2 start
// f3 is the core
// f2 end
// f1 end
复制代码

简单来说,koa是用递归实现的洋葱圈模型,middlewares中的中间件经过compose后,得到的composedFn实际相当于是在执行f1(ctx, f2(ctx, f3(ctx)))(理解的时候可以暂时忽略Promise)。同时为了方便处理异步,我们的dispatch函数最终返回的是一个Promise,所以中间件的next参数一定会是一个异步函数,我们就可以很方便地把中间件写成async函数。

这里我们不再对中间件原理做过多赘述,因为虽然koa中间件的实现非常短小精悍,但是内部还包含了判断是否执行了多次next函数等其他错误处理逻辑,要讲解清楚还是需要一定的篇幅,我们先从上面给的这个最简实现搞懂如何通过递归实现洋葱圈模型即可。更完整详细的中间件原理,可以参考这篇文章

回到我们要实现的加载App资源的逻辑,现在我们完全可以套用koa的中间件模型

class Bus {
  // ...省略部分代码
  private apps = {}
  private middlewares = [] // 中间件数组
  private conf = {
    assets: {}
  }
  private composedMiddlewareFn; // 合成后的中间件函数

  constructor (name) {
    this.name = name
    this.composedMiddlewareFn = compose(this.middlewares)
  }

  public config (conf) {
    // 与上文的实现一致
  }

  public use (middleware) {
    // 注册中间件:将中间件函数推入middlewares后马上合成执行函数composedMiddlewareFn
    this.middlewares.push(middleware)
    this.composedMiddlewareFn = compose(this.middlewares)
    return this
  }

  // 生成要传递给中间件的上下文对象ctx
  private createContext (name: string, ctx: Record<string, any> = {}) {
    const context: ContextType = {
      name,
      // 把我们实现的加载函数挂到ctx上,方便中间件开发者直接使用
      loadScript: loadScript,
      loadLink: loadLink,
      conf: this.conf,
      ...ctx // 自定义的上下文参数
    }
    return context
  }

  // 把之前实现的loadResourcesFromAssetsConfig改造为洋葱圈模型中的最里层中间件
  public async loadResourcesFromAssetsConfig (ctx) {
    const {
      name,
      loadScript,
      loadLink,
      conf: { assets },
    } = ctx
    if (assets[name]) {
      // 插入css资源
      assets[name].css &&
        assets[name].css.forEach((asset) => {
          loadLink(asset)
        })
      // 插入js资源
      if (assets[name].js) {
        for (const asset of assets[name].js) {
          await loadScript(asset)
        }
      }
    }
  }

  public async loadApp (name, ctx) {
    if (!this.apps[name]) {
      const context = this.createContext(name, ctx)
      // 执行所有中间件,其中loadResourcesFromAssetsConfig是最里层中间件
      await this.composedMiddlewareFn(context, this.loadResourcesFromAssetsConfig.bind(this))
    }
  }
}
复制代码

如果App实例还没有被记录到app池,说明App还未被创建,那就去执行一遍中间件加载资源,资源加载完毕后,插入的script中创建App的逻辑会被执行,App实例被记录到app池中,下一次再执行loadApp就不会去加载资源了,看起来似乎已经大功告成!但是让我们看看下面这个场景:

bus.loadApp('test-app')
bus.loadApp('test-app')
复制代码

我们目前实现的loadApp函数中,是根据this.apps[name]是否已经存了App实例来判断要不要去加载App资源,当我们同步地执行两次bus.loadApp('test-app')时,第一次同步执行完之后,资源加载请求还没有完成,this.apps[name]并不会被赋值,因此第二次同步执行bus.loadApp('test-app')还是会发起资源加载请求,导致bus.createApp('test-app')的逻辑会被执行两次,从而抛出错误,可见用this.apps[name]是否被赋值来判断是否要发起资源加载请求并不可靠。

所以我们来改造一下:

class Bus {
  // ...省略部分代码

  private loadingApps: Record<string, Promise<void>> = {}

  public async loadApp (name, ctx) {
    if (!this.apps[name]) {
      if (!this.loadingApps[name]) {
        this.loadingApps[name] = new Promise((resolve, reject) => {
          const context = this.createContext(name, ctx)
          this.composedMiddlewareFn(context, this.loadResourcesFromAssetsConfig.bind(this)).then(() => {
            if (!this.apps[name]) {
              reject(new Error(`App named ${name} is not created`))
            }
            resolve()
          }).catch((error) => {
            reject(error)
          })
        })
      }
      await this.loadingApps[name]
    }
  }
}
复制代码

我们引入一个新的标记池loadingApps,用来记录加载资源这个过程的Promise,当一个App的资源第一次被加载时,就把执行加载过程的Promise赋值给this.loadingApps[name],当后续又同步执行loadApp时,就直接等待这个Promise的状态转为fullfilled即可,这样就不会重复发起多次资源加载请求了。

以上就是Rallie中资源加载的大致实现了,我们省略了用fetch加载资源的实现以及以lib:开头的App的资源加载的特殊处理,都比较简单,感兴趣的朋友可以参考项目源码,这里就不讲解了。

管理App的生命周期

现在我们已经搞定了App的创建和加载,接下来就是管理App的生命周期。根据我们的设计,App有bootstrapactivatedestroy三个生命周期,我们可以通过bus.activateAppbus.destroyApp来触发App的生命周期回调,且遵循这样的执行规则:

  • 如果只指定了onBootstrap生命周期,应用将只在第一次被activate时执行onBootstrap回调,而不会理会后续的激活
  • 如果只指定了onActivate生命周期,应用将在每次被activate时都执行onActivate回调
  • 如果同时指定了onBootstraponActivate生命周期,应用将在第一次被activate时执行onBootstrap回调,在后续被activate时执行onActivate回调

为了实现上面的效果,我们先给App增加两个标志位:

class App {
  public dependenciesReady: boolean = false // 标记依赖是否都已经被激活
  public bootstrapping: Promise<void> = null // 标记执行bootstrap过程的Promise

  // ...省略部分代码
}
复制代码

接着我们来实现Bus的activateApp方法:

class Bus {
  // ...省略部分代码
  
  // 激活App
  public async activateApp (name, data, ctx) {
    await this.loadApp(name, ctx) // 先加载App的资源
    const app = this.apps[name] // 获取App实例
    if (app) {
      await this.loadRelatedApps(app) // 加载关联的App的资源
      if (!app.bootstrapping) { // 如果bootstrapping没有被赋值过,说明是第一次激活App
        const bootstrapping = async () => { // 执行激活过程
          await this.activateDependencies(app) // 激活依赖
          // 执行生命周期
          if (app.doBootstrap) {
            await Promise.resolve(app.doBootstrap(data))
          } else if (app.doActivate) {
            await Promise.resolve(app.doActivate(data))
          }
        }
        app.bootstrapping = bootstrapping()
        await app.bootstrapping
      } else { // 如果app.bootstrapping已经被赋值过,说明不是第一次激活App,直接进入onActivate生命周期
        await app.bootstrapping
        app.doActivate && (await Promise.resolve(app.doActivate(data)))
      }
    }
  }
  
  // 激活依赖的App
  private async activateDependencies (app: App) {
    if (!app.dependenciesReady && app.dependencies.length !== 0) {
      for (const dependence of app.dependencies) {
        const { name, data, ctx } = dependence
        await this.activateApp(name, data, ctx)
      }
      app.dependenciesReady = true
    }
  }
  // 加载关联的App的资源
  private async loadRelatedApps (app: App) {
    for (const { name, ctx } of app.relatedApps) {
      await this.loadApp(name, ctx)
    }
  }
}

复制代码

在激活App时,我们先调用bus.loadApp,确保App的资源已经被加载,这样我们就能通过this.apps[name]拿到App实例。然后我们采用跟loadApp相似的方法,在App上用一个标记位bootstraping记录执行激活过程的Promise,确保即使多次同步调用bus.activateApp,也只在第一次激活时进入bootstrap流程。

在bootstrap流程中,我们先调用bus.loadApp加载关联App的资源,然后递归调用bus.activateApp激活依赖的App,递归结束,App也就成功被bootstrap了。

看起来很完美,但是我们稍微深入思考一下:递归激活依赖的过程其实就是以要激活的app为入口,对依赖树进行深度优先搜索的过程。因此我们不得不考虑这种情况——当App的依赖关系出现循环依赖,也就是依赖树中出现了环时,深搜过程就会出现死循环,这要怎么解决呢?我们很自然地会想到,这就是一个利用DFS在有向图中寻找环的模型。

我们只需要用一个栈visitPath来记录深搜过程中访问的节点(即当前在激活的App),在访问节点前将节点id(即当前在激活的App的名字)push进visitPath中,在节点访问完成(已经激活了App以及App下的所有依赖)后将节点id从栈中pop出来。这样的话,栈中的节点就是正在访问的当前节点的祖先节点,我们只需要判断当前节点是否已经在栈中出现过,就可以判断是否存在循环依赖,甚至还能找出这条循环依赖的路径。代码如下:

class Bus {
  // ...省略部分代码
  
  // 激活App
  public async activateApp (name, data, ctx, visitPath: string[] = []) {
    await this.loadApp(name, ctx) // 先加载App的资源
    const app = this.apps[name] // 获取App实例
    if (app) {
      await this.loadRelatedApps(app) // 加载关联的App的资源
      if (visitPath.includes(name)) { // 如果当前要激活的app已经在路径栈出现过了,说明存在循环依赖
        const startIndex = visitPath.indexOf(name)
        const circularPath = [...visitPath.slice(startIndex), name]
      throw new Error(`存在循环依赖,依赖路径是: ${circularPath.join('->')}`)
      }
      visitPath.push(name) // 将当前app推入路径栈
      if (!app.bootstrapping) { // 如果bootstrapping没有被赋值过,说明是第一次激活App
        const bootstrapping = async () => { // 执行激活过程
          await this.activateDependencies(app, visitPath) // 激活依赖
          // 执行生命周期
          if (app.doBootstrap) {
            await Promise.resolve(app.doBootstrap(data))
          } else if (app.doActivate) {
            await Promise.resolve(app.doActivate(data))
          }
        }
        app.bootstrapping = bootstrapping()
        await app.bootstrapping
      } else { // 如果app.bootstrapping已经被赋值过,说明不是第一次激活App,直接进入onActivate生命周期
        await app.bootstrapping
        app.doActivate && (await Promise.resolve(app.doActivate(data)))
      }
      visitPath.pop() // 将当前app从路径栈中出栈
    }
  }
  
  // 激活依赖的App
  private async activateDependencies (app: App, visitPath: string[]) {
    if (!app.dependenciesReady && app.dependencies.length !== 0) {
      for (const dependence of app.dependencies) {
        const { name, data, ctx } = dependence
        await this.activateApp(name, data, ctx, visitPath)
      }
      app.dependenciesReady = true
    }
  }
}
复制代码

最后,我们来实现销毁阶段的生命周期,比起激活,这部分逻辑简单很多,只需调用对应的onDestroy回调,并重置一下标志位即可

class Bus {
  public async destroyApp (name: string, data?) {
    if (this.apps[name]) {
      const app = this.apps[name]
      app.doDestroy && (await Promise.resolve(app.doDestroy(data)))
      app.bootstrapping = null
      app.dependenciesReady = false
    }
  }
}
复制代码

总结

以上就是实现App的调度和管理的基本逻辑,实现过程中,我们能掌握:

  • 如何借鉴koa-compose的递归思想实现洋葱圈中间件模型
  • 如何巧用Promise控制逻辑执行顺序
  • 如何在对树进行深度优先搜索的过程中找出环

到目前为止,我们已经自底向上地把@rallie/core的所有模块都实现完了,通信和App的管理功能都有了,可以说我们已经初步实现了一个高度灵活的前端微服务框架。但是设想一下,假如我们直接把这个@rallie/core的包推荐给用户,相信你一定会在说明文档中加入这两条说明:

  • 所有App的开发者需要约定好同一个Bus名,用这个Bus来创建App
  • 不同的App间要通信,也需要单独约定好Bus,用各自约定好的Bus创建的Socket进行通信,才能保证状态,事件和方法不重名

比起让用户自己去约定这种规范,我们在框架层面帮用户制定好开发范式显然是更好的实践。因此下一篇文章中,我们将基于@rallie/core再进行一层封装,帮助前端微服务开发者不必把那么多关注点聚焦在Bus上,而是关注自己正在开发的App即可,从而形成更规范的开发范式。

おすすめ

転載: juejin.im/post/7052879438245167141