- 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 specify
onBootstrap
The life cycle of the application will only be the first timeactivate
When performingonBootstrap
Callback, ignoring subsequent activations - If you only specify
onActivate
The life cycle, the application will be in eachactivate
When performingonActivate
The callback - If both are specified
onBootstrap
andonActivat
E life cycle, the application will be in the firstactivate
When performingonBootstrap
The 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 from
koa-compose
Implementation of onion ring middleware model with recursive thought - How to use opportunely
Promise
Controls 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.