• Project address: github.com/ralliejs/ra…
  • File address: rallie.js.cool/
  • Series of articles:
    • Birth of a Microfront-end library -0 | Rallie: Another possibility for a microfront-end
    • A micro front-end library was born -1 | to implement state and event communication modules
    • The birth of a micro front-end library -2 | implementation of App management and scheduling

preface

In the last article, we implemented the Socket module to provide communication. Let’s write the App module and realize the scheduling and management functions of the App.

First of all, referring to the module division diagram, we should make it clear that the implementation of App scheduling and management is actually divided into two steps: implementation of App module itself and implementation of App scheduling and management operations. App module itself plays a declarative role, so that users can specify the life cycle and dependence with other apps. Then we implement the Bus module, which manages all App instances, and the scheduling and management of App is realized in the Bus module.

The code in this article provides only the core implementation, omits some logic for handling boundary cases and most type declarations. The complete implementation can be referred to the source code in the project.

App

The App module itself is declarative only, providing methods to specify lifecycle callbacks onBootstrap, onActivate, and onDestroy

class App {
  publicdoBootstrap? ;// App initialization lifecycle callback
  publicdoActivate? ;// App activation declaration cycle callback
  publicdoDestroy? ;// App destruction lifecycle callback
  public isRallieCoreApp;

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

  // Specify the bootstrap lifecycle callback
  public onBootstrap (callback) {
    this.doBootstrap = callback
    return this
  }

  // Specify the activate lifecycle callback
  public onActivate (callback) {
    this.doActivate = callback
    return this
  }

  // Specify the destroy lifecycle callback
  public onDestroy (callback) {
    this.doDestroy = callback
    return this}}Copy the code

Both relateTo and relyOn methods are provided to specify associations and dependencies for the App. This part of the logic isn’t complicated either:

export class App {
  / /... Omit some code

  public dependencies: ArrayThe < {name: string; ctx? : Record<string.any>; data? :any} > = [];// An array of dependent apps that store not App instances but App names and other related information
  public relatedApps: ArrayThe < {name: string; ctx? : Record<string.any>} > = [];// The associated App array is not the App instance, but the App name and other related information

  // Specify the associated 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) = > {
      // Push the App info into this.relatedApps
      if(! currentRelatedAppNames.includes(getName(relatedApp))) {this.relatedApps.push({
          name: getName(relatedApp),
          ctx: typeofrelatedApp ! = ='string' ? relatedApp.ctx : undefined})}})return this
  }

  // Specify the App to depend on
  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)
      // Push the App information into this.dependencies
      if(! currentDependenciesNames.includes(name)) {this.dependencies.push({
          name,
          ctx: typeofdependency ! = ='string' ? dependency.ctx : undefined.data: typeofdependency ! = ='string' ? dependency.data : undefined})}// Push the App info to this.relatedApps
      if(! currentRelatedAppsNames.includes(name)) {this.relatedApps.push({
          name,
          ctx: typeofdependency ! = ='string' ? dependency.ctx : undefined})}})return this}}// De-duplicates the dependent array
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
}
Copy the code

One thing to note is that, according to our design for associations and dependencies, if App A is associated with App B, then the resources of App B should be loaded before activating App A. If App A depends on App B, then App B needs to be activated before App A can be activated. So, in fact, if A depends on B, then A must also depend on B. So when we implement the relyOn method, we need to push not only the dependent App information into this.dependencies, but also into this.relatedApps

In short, App modules are declarative logic, so let’s see how to schedule and manage App using the information from these declarations

Bus

According to the module division diagram, Bus is actually the top-level module in the package @rallie/core. To enable different apps to communicate and schedule each other, we need to use the same Bus for management. Since the code of different apps is inserted into the document as different scripts, the only way for each script to access the same Bus is to mount the Bus instance globally

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

const busProxy = {}
const DEFAULT_BUS_NAME = 'DEFAULT_BUS'

// Create a Bus and mount it as a global variable
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
  }
}

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

If Bus already exists, get getBus; otherwise, createBus, then 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 is used to indicate whether the Bus was created during the touch
}
Copy the code

In this way, different scripts only need to agree on the name of the Bus and access the same Bus instance through the touchBus Api.

Then, before explaining the management scheduling of App, we first put the Socket that implements the state and event communication module explained in the previous article into Bus management

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

class Bus {
  / /... Omit some code

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

  public createSocket () {
    return new Socket(this.eventEmitter, this.stores)
  }
}
Copy the code

This allows different scripts to communicate using the Socket instance created by the same Bus.

Then come to the main point of this paper. Managing App, which can be broken down into managing

  • The creation of the App
  • App resource loading
  • App life lifecycle

These three parts, in essence, are to achieve these several methods

class Bus {
  / /... Omit some code
  private apps: Record<string, App> = {} / / App pool

  public createApp () {
    / / create the App
  }
  public loadApp (name: string.ctx: Record<string.any> = {{})// Load App resources
  }
  public activateApp<T> (name: string, data? : T, ctx? : Record<string.any> = {{})/ / activate the App
  }
  public destroyApp (name: string, data? : T) {/ App/destroyed}}Copy the code

Manage App creation

The logic for creating an App is simple: create an App instance and add it to the App pool

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`)}}}Copy the code

Note that the name of each App managed by the same Bus must be unique. The same App cannot be created.

Manage App resource loading

To load an App resource, you first have to have a place to declare the App resource path. The most common idea is to use an object to store the mapping between the App name and the resource path, and then call the loadApp method to fetch the resource path from this object and load the resource.

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

class Bus {
  / /... Omit some code

  private conf: Conf = { // Bus configuration
    assets: {} // App resource mapping
  };

  / / configure the conf
  public config (conf) {
    this.conf = { ... this.conf, ... conf,assets: { // Declare the App resource path. this.conf.assets, ... (conf? .assets || {}) } } }// Load resources
  public async loadResourcesFromAssetsConfig (name) {
    const assets = this.conf.assets
    if (assets[name]) {
      // Insert CSS resources
      assets[name].css &&
        assets[name].css.forEach((asset) = > {
          loadLink(asset)
        })
      // Insert the js resource
      if (assets[name].js) {
        for (const asset of assets[name].js) {
          await loadScript(asset)
        }
      }
    }
  }
}

function loadLink (asset) {
  // Insert link tag
}

function loadScript (asset) {
  // Insert a script tag
}
Copy the code

Now we can use the bus. The resource allocation in the config path, with a bus. LoadResourcesFromAssetsConfig load resources. But the design is not flexible, so our using for reference of koa famous onion rings middleware model and package before loadResourcesFromAssetsConfig a middleware layer processing, let users can write middleware in the form of a custom control resource loading process.

The middleware

First, it’s important to understand how the KOA middleware model works. I’m going to use a very simple implementation to help you understand

const middlewares = [] // Middleware array

const use = (fn) = > {
  middlewares.push(fn) // To register middleware is to push middleware functions into middlewares
}

use(async function f1 (ctx, next) { // Application middleware F1
  console.log('f1 start')
  await next()
  console.log('f1 end')
})

use(async function f2 (ctx, next) { // Application middleware F2
  console.log('f2 start')
  await next()
  console.log('f2 end')})// The synthetic middleware executes the function
const compose = (middlewares) = > (ctx, next) = > {
  const dispatch = (i) = > {
    const fn = middleware[i] // Fetch the current middleware
      if(! fn) {// Recursive termination conditions: there is no middleware left to execute
        return Promise.resolve()
      }
      return Promise.resolve(fn( // Execute current middleware
        ctx,
        dispatch.bind(null, i + 1) // Recursion: Pass the function that executes the next middleware as the next argument))}return dispatch(0)}const composedFn = compose(middlewares)
const ctx = {}
composedFn(ctx, function f3 (ctx) {
  console.log('f3 is the core')})// It will eventually print out
// f1 start
// f2 start
// f3 is the core
// f2 end
// f1 end
Copy the code

In short, KOA is a recursive onion circle model, and the middleware in Middlewares passes through Compose to get the composedFn that actually executes F1 (CTX, F2 (CTX, F3 (CTX))(ignore the Promise for now). Meanwhile, in order to facilitate the processing of asynchracy, our dispatch function finally returns a Promise, so the next parameter of the middleware must be an asynchronous function, so we can easily write the middleware as async function.

Here we don’t do too much redundancy theory of middleware, because although the realization of koa middleware is very short, but also includes internal determine whether performed many times next function and other error handling logic, to have a clear or needs certain space, we first from above to the minimalist implementation understand how to implement onion rings by recursive model. For a more complete and detailed overview of middleware principles, see this article.

Going back to the logic we were going to implement to load App resources, we can now fully apply koA’s middleware model

class Bus {
  / /... Omit some code
  private apps = {}
  private middlewares = [] // Middleware array
  private conf = {
    assets: {}}private composedMiddlewareFn; // The synthesized middleware function

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

  public config (conf) {
    // Consistent with the implementation above
  }

  public use (middleware) {
    // Register middleware: push the middleware function into middlewares and immediately synthesize the execution function composedMiddlewareFn
    this.middlewares.push(middleware)
    this.composedMiddlewareFn = compose(this.middlewares)
    return this
  }

  // Generate the context object CTX to pass to the middleware
  private createContext (name: string.ctx: Record<string.any> = {{})const context: ContextType = {
      name,
      // Attach our load function to CTX for middleware developers to use directly
      loadScript: loadScript,
      loadLink: loadLink,
      conf: this.conf, ... ctx// Custom context parameters
    }
    return context
  }

  / / before implementation of the transformed loadResourcesFromAssetsConfig onion rings to model the innermost layer of the middleware
  public async loadResourcesFromAssetsConfig (ctx) {
    const {
      name,
      loadScript,
      loadLink,
      conf: { assets },
    } = ctx
    if (assets[name]) {
      // Insert CSS resources
      assets[name].css &&
        assets[name].css.forEach((asset) = > {
          loadLink(asset)
        })
      // Insert the js resource
      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)
      / / perform all middleware, including loadResourcesFromAssetsConfig is the innermost layer middleware
      await this.composedMiddlewareFn(context, this.loadResourcesFromAssetsConfig.bind(this))}}}Copy the code

If the App instance has not been recorded in the App pool, it indicates that the App has not been created, then the middleware will load the resource once. After the resource is loaded, the logic of creating App in the inserted script will be executed, and the App instance will be recorded in the App pool. The next execution of loadApp will not load the resource. It looks like you’re done! But let’s look at this scenario:

bus.loadApp('test-app')
bus.loadApp('test-app')
Copy the code

LoadApp (‘test-app’); bus.loadApp(‘test-app’); bus.loadApp(‘test-app’); This.apps [name] will not be assigned, so the second synchronization of bus.loadapp (‘test-app’) will still initiate a resource load request, causing the logic of bus.createApp(‘test-app’) to be executed twice, thus throwing an error. If this.apps[name] is assigned a value, it is not reliable to determine whether to initiate a resource load request.

So let’s change it:

class Bus {
  / /... Omit some code

  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]
    }
  }
}
Copy the code

We introduce a new tag pool, loadingApps, to record the Promise of the loading process. When an App’s resource is loaded for the first time, we assign the Promise of the loading process to this.loadingApps[name]. Just wait for the Promise state to change to fullfilled, so you don’t have to make multiple resource load requests.

The above is the general implementation of Rallie resource loading, we omit the implementation of fetch to load resources and the special processing of App resource loading with lib:, are relatively simple, interested friends can refer to the project source, here will not explain.

Manage the App lifecycle

Now that we have the App created and loaded, it’s time to manage the App life cycle. According to our design, the App has three life cycles: bootstrap, Activate, destroy. We can use bus.activateApp and Bus. destroyApp to trigger the App’s life cycle callback, and follow the following execution rules:

  • If you only specifyonBootstrapThe life cycle of the application will only be the first timeactivateWhen performingonBootstrapCallback, ignoring subsequent activations
  • If you only specifyonActivateThe life cycle, the application will be in eachactivateWhen performingonActivateThe callback
  • If both are specifiedonBootstrapandonActivatE life cycle, the application will be in the firstactivateWhen performingonBootstrapThe onActivate callback is executed when it is activated

To achieve the above effect, we first add two flag bits to the App:

class App {
  public dependenciesReady: boolean = false // Flag whether dependencies are already activated
  public bootstrapping: Promise<void> = null // Marks the Promise to perform the bootstrap procedure

  / /... Omit some code
}
Copy the code

Then we implement Bus activateApp method:

class Bus {
  / /... Omit some code
  
  / / activate the App
  public async activateApp (name, data, ctx) {
    await this.loadApp(name, ctx) // Load App resources first
    const app = this.apps[name] // Get the App instance
    if (app) {
      await this.loadRelatedApps(app) // Load the resources associated with the App
      if(! app.bootstrapping) {// If bootstrapping has not been assigned, it is the first time the App has been activated
        const bootstrapping = async() = > {// Perform the activation process
          await this.activateDependencies(app) // Activate dependencies
          // Execute the lifecycle
          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 { // If app.bootstrapping has already been assigned, it is not the first time that the app has been activated
        await app.bootstrapping
        app.doActivate && (await Promise.resolve(app.doActivate(data)))
      }
    }
  }
  
  // Activate the dependent 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}}// Load the resources associated with the App
  private async loadRelatedApps (app: App) {
    for (const { name, ctx } of app.relatedApps) {
      await this.loadApp(name, ctx)
    }
  }
}

Copy the code

To activate the App, we call bus.loadapp to make sure the App’s resources have been loaded, so we can get the App instance through this.apps[name]. We then applied a similar approach to loadApp, recording the Promise of performing the activation process with a flag bit bootstraping on the App to ensure that even if multiple simultaneous calls to Bus. activateApp were made, the Bootstrap process would only enter on the first activation.

In the bootstrap process, we first call Bus. loadApp to load the resources of the associated App, and then recursively call Bus. activateApp to activate the dependent App. At the end of the recursion, the App is successfully bootstrap.

It seems perfect, but let’s think a little deeper: the process of recursively activating a dependency is actually a depth-first search of the dependency tree with the app to activate as the entry point. Therefore, we have to consider this situation — when App dependencies have cyclic dependencies, that is, when there are rings in the dependency tree, the deep search process will have an infinite loop. How to solve this problem? It is natural to think that this is a model that uses DFS to find rings in directed graphs.

We only need to use a stack visitPath to record the nodes visited in the deep search process (i.e. the currently activated App), and push the node ID (i.e. the name of the currently activated App) into the visitPath before visiting the node. Pop the node ID out of the stack after the node access is complete (the App and all dependencies under the App have been activated). In this case, the node in the stack is the ancestor of the current node being visited, and we can determine whether there is a cyclic dependency and even find the path of the cyclic dependency simply by determining whether the current node has already appeared in the stack. The code is as follows:

class Bus {
  / /... Omit some code
  
  / / activate the App
  public async activateApp (name, data, ctx, visitPath: string[] = []) {
    await this.loadApp(name, ctx) // Load App resources first
    const app = this.apps[name] // Get the App instance
    if (app) {
      await this.loadRelatedApps(app) // Load the resources associated with the App
      if (visitPath.includes(name)) { // If the current app is already in the path stack, there is a cyclic dependency
        const startIndex = visitPath.indexOf(name)
        const circularPath = [...visitPath.slice(startIndex), name]
      throw new Error('there is a cyclic dependency, the dependency path is:${circularPath.join('- >')}`)
      }
      visitPath.push(name) // Push current app onto path stack
      if(! app.bootstrapping) {// If bootstrapping has not been assigned, it is the first time the App has been activated
        const bootstrapping = async() = > {// Perform the activation process
          await this.activateDependencies(app, visitPath) // Activate dependencies
          // Execute the lifecycle
          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 { // If app.bootstrapping has already been assigned, it is not the first time that the app has been activated
        await app.bootstrapping
        app.doActivate && (await Promise.resolve(app.doActivate(data)))
      }
      visitPath.pop() // Remove the current app from the path stack}}// Activate the dependent 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}}}Copy the code

Finally, we implement the destroy phase lifecycle, which is much simpler than activation, by calling the corresponding onDestroy callback and resetting the flag bit

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}}}Copy the code

conclusion

The above is the basic logic for implementing App scheduling and management. During the implementation process, we can master:

  • How to draw lessons fromkoa-composeImplementation of onion ring middleware model with recursive thought
  • How to use opportunelyPromiseControls the order in which logic is executed
  • How do I find rings during depth-first search of a tree

So far, we have completed all the modules of @rallie/ Core from the bottom up, and have communication and App management functions. It can be said that we have initially realized a highly flexible front-end microservice framework. But imagine if we were to recommend the @rallie/core package directly to users. I’m sure you would include these two instructions in the documentation:

  • All App developers need to agree on the same Bus name and use this Bus to create the App
  • To communicate with each other, different apps also need to agree on a separate Bus and use the Socket created by the agreed Bus to communicate, so as to ensure that the status, events and methods do not have the same name

It is better practice for us to help the user develop the development paradigm at the framework level rather than having the user agree on the specification themselves. Therefore, in the next article, we will add another layer of encapsulation based on @rallie/core to help front-end microservice developers not focus so much on Bus, but on the App they are developing, thus forming a more standardized development paradigm.