preface

Since the micro front-end framework micro-App became open source, many friends are very interested and ask me how to achieve it, but it is not a few words can be understood. To illustrate how this works, I’m going to implement a simple micro front-end framework from scratch. Its core features include rendering, JS sandbox, style isolation, and data communication. This is the first in a series of four articles: Rendering.

Through these articles, you will learn how micro front end frameworks work and how they are implemented, which will be of great help if you use them later or write your own. If this post helped you, feel free to like it and leave a comment.

Related to recommend

Micro-app source address: github.com/micro-zoe/m…

The overall architecture

Like micro-app, our simple micro front end framework is designed to be as simple as using iframe, while avoiding the problems existing in iframe. Its usage is as follows:

The end result is a bit similar, with the entire micro front application encapsulated in the custom TAB Micro-app. The rendered effect is as follows:

So our overall architecture idea is: CustomElement + HTMLEntry.

HTMLEntry is rendered with an HTML file as the entry address. http://localhost:3000/ in the image above is an HTML address.

Concept map:

Pre – work

Before we can get started, we need to set up a development environment and create a code repository, simple-micro-app.

The directory structure

The code repository is divided into the SRC main directory and examples directory, with vuE2 for the base application and React17 for the sub-application. Both projects are built using official scaffolding and the build tool is rollup.

The two application pages are shown below:

Base application — VUE2

Subapplication — REact17

In the VUE2 project, configure resolve.alias to point simple-micro-app to index.js in the SRC directory.

// vue.config.js.chainWebpack: config= > {
    config.resolve.alias
      .set("simple-micro-app", path.join(__dirname, '.. /.. /src/index.js'))},Copy the code

Configure static resources in React17’s Webpack-dev-server to support cross-domain access.

// config/webpackDevServer.config.js.headers: {
  'Access-Control-Allow-Origin': The '*',},Copy the code

The official start of the

To make this clear, instead of Posting the finished code, we’ll start from scratch and implement the process step by step so it’s clearer and easier to understand.

Create a container

The rendering of the micro front end is to load the static resources such as JS and CSS of the child application into the base application for execution, so the base application and the child application are essentially the same page. This is different from the iframe, which creates a new window. Since the entire window information is initialized every time the iframe is loaded, the performance of the iframe is low.

Just as each front-end frame must specify a root element at render time, micro front-end renders need to specify a root element as a container, which can be a div or other element.

Here we use customElements created by customElements, because it not only provides an element container, but also comes with its own lifecycle functions. We can do things like load rendering in these hook functions to simplify the steps.

// /src/element.js

// Customize the element
class MyElement extends HTMLElement {
  // Declare the names of the attributes that you want to listen to, and only when those attributes change will the attributeChangedCallback be triggered
  static get observedAttributes () {
    return ['name'.'url']}constructor() {
    super(a); }connectedCallback() {
    // When the element is inserted into the DOM, the static resources of the child application are loaded and rendered
    console.log('micro-app is connected')
  }

  disconnectedCallback () {
    // When an element is removed from the DOM, some unload operations are performed
    console.log('micro-app has disconnected')
  }

  attributeChangedCallback (attr, oldVal, newVal) {
    // When an element attribute changes, you can get the value of the attribute name, URL, and so on
    console.log(`attribute ${attrName}: ${newVal}`)}}When a micro-app element is inserted or removed from the DOM, the corresponding lifecycle function is triggered. * /
window.customElements.define('micro-app', MyElement)
Copy the code

Micro-app elements can be duplicated, so we add a layer of judgment and put it in the function.

// /src/element.js

export function defineElement () {
  // If it is already defined, ignore it
  if (!window.customElements.get('micro-app')) {
    window.customElements.define('micro-app', MyElement)
  }
}
Copy the code

Define the default object SimpleMicroApp in/SRC /index.js and introduce and execute the defineElement function.

// /src/index.js

import { defineElement } from './element'

const SimpleMicroApp = {
  start () {
    defineElement()
  }
}

export default SimpleMicroApp
Copy the code

Introducing simple – micro – app

Introduce simple-micro-app in main.js of the VUE2 project and execute the start function for initialization.

// vue2/src/main.js

import SimpleMicroApp from 'simple-micro-app'

SimpleMicroApp.start()
Copy the code

The Micro-App tag can then be used anywhere in the VUE2 project.

<! -- page1.vue -->
<template>
  <div>
    <micro-app name='app' url='http://localhost:3001/'></micro-app>
  </div>
</template>
Copy the code

After inserting the Micro-app label, you can see the hook information printed by the console.

This completes the initialization of the container element, and all the elements of the child application will be placed into the container. Next we need to complete the static resource loading and rendering of the child application.

Create a microapplication instance

Obviously, the initialized operation is executed in a connectedCallback. We declare a class, each instance of which corresponds to a microapplication, to control the microapplication resource loading, rendering, unloading, and so on.

// /src/app.js

// Create a micro application
export default class CreateApp {
  constructor () {}

  status = 'created' / / component state, including created/loading/mount/unmount to

  // Store the static resources of the application
  source = { 
    links: new Map(), // The static resource corresponding to the link element
    scripts: new Map(), // Static resources corresponding to the script element
  }

  // Execute when the resource has been loaded
  onLoad () {}

  /** render resources after loading */
  mount () {}

  /** * Uninstall the application * perform operations such as closing the sandbox and clearing the cache */
  unmount () {}
}
Copy the code

We initialize the instance in the connectedCallback function, passing in the name, URL, and element itself as parameters, recording these values in CreateApp’s constructor, and requesting the HTML based on the URL address.

// /src/element.js
import CreateApp, { appInstanceMap } from './app'. connectedCallback () {// Create a microapplication instance
  const app = new CreateApp({
    name: this.name,
    url: this.url,
    container: this,})// Write to the cache for subsequent functionality
  appInstanceMap.set(this.name, app)
}

attributeChangedCallback (attrName, oldVal, newVal) {
  // Record the value of name and URL respectively
  if (attrName === 'name'&&!this.name && newVal) {
    this.name = newVal
  } else if (attrName === 'url'&&!this.url && newVal) {
    this.url = newVal
  }
}
...
Copy the code

Static resources are requested based on the parameters passed in when the instance is initialized.

// /src/app.js
import loadHtml from './source'

// Create a micro application
export default class CreateApp {
  constructor ({ name, url, container }) {
    this.name = name // Application name
    this.url = url  / / url
    this.container = container / / micro - app elements
    this.status = 'loading'
    loadHtml(this)}... }Copy the code

Request the HTML

We use FETCH to request static resources. The advantage is that the browser supports promises, but this also requires cross-domain access to the child application’s static resources.

// src/source.js

export default function loadHtml (app) {
  fetch(app.url).then((res) = > {
    return res.text()
  }).then((html) = > {
    console.log('html:', html)
  }).catch((e) = > {
    console.error('Error loading HTML', e)
  })
}
Copy the code

Since the request JS, CSS, and so on are required to use fetch, we extract it as a public method.

// /src/utils.js

/** * Get static resources *@param {string} Url Static resource address */
export function fetchSource (url) {
  return fetch(url).then((res) = > {
    return res.text()
  })
}

Copy the code

Reuse the wrapped method and process the retrieved HTML.

// src/source.js
import { fetchSource } from './utils'

export default function loadHtml (app) {
  fetchSource(app.url).then((html) = > {
    html = html
      .replace(/
      
       ]*>[\s\S]*? <\/head>/i
      [^>.(match) = > {
        // Replace the head tag with micro-app-head, because web pages are only allowed to have one head tag
        return match
          .replace(/<head/i.'<micro-app-head')
          .replace(/<\/head>/i.'</micro-app-head>')
      })
      .replace(/
      
       ]*>[\s\S]*? <\/body>/i
      [^>.(match) = > {
        // Replace the body tag with micro-app-body to prevent problems caused by duplication of the body tag applied to the dock.
        return match
          .replace(/<body/i.'<micro-app-body')
          .replace(/<\/body>/i.'</micro-app-body>')})// Convert the HTML string to a DOM structure
    const htmlDom = document.createElement('div')
    htmlDom.innerHTML = html
    console.log('html:', htmlDom)

    // Further extract and process js, CSS and other static resources
    extractSourceDom(htmlDom, app)
  }).catch((e) = > {
    console.error('Error loading HTML', e)
  })
}
Copy the code

After the HTML is formatted, we have a DOM structure. As you can see from the image below, the DOM structure contains the link, style, script, and so on. The DOM needs to be processed further.

Extract static resource addresses such as JS and CSS

We recursively process each DOM node in the extractSourceDom method, query all link, style, script tags, extract the static resource address and format the tags.

// src/source.js

/** * recursively process each child element *@param Parent Parent element *@param App Application instance */
function extractSourceDom(parent, app) {
  const children = Array.from(parent.children)
  
  // Recurse each child element
  children.length && children.forEach((child) = > {
    extractSourceDom(child, app)
  })

  for (const dom of children) {
    if (dom instanceof HTMLLinkElement) {
      // Extract the CSS address
      const href = dom.getAttribute('href')
      if (dom.getAttribute('rel') = = ='stylesheet' && href) {
        // Count to the source cache
        app.source.links.set(href, {
          code: ' '.// Code content})}// Delete the original element
      parent.removeChild(dom)
    } else if (dom instanceof HTMLScriptElement) {
      // And extract the JS address
      const src = dom.getAttribute('src')
      if (src) { / / remote script
        app.source.scripts.set(src, {
          code: ' '.// Code content
          isExternal: true.// Whether to remote script})}else if (dom.textContent) { / / an inline script
        const nonceStr = Math.random().toString(36).substr(2.15)
        app.source.scripts.set(nonceStr, {
          code: dom.textContent, // Code content
          isExternal: false.// Whether to remote script
        })
      }

      parent.removeChild(dom)
    } else if (dom instanceof HTMLStyleElement) {
      // Make style isolation}}}Copy the code

Requesting static resources

The above has got HTML CSS, JS and other static resources in the address, the next is to request these addresses, get the content of the resource.

Next, we refine loadHtml by adding a method to request resources under extractSourceDom.

// src/source.js.export default function loadHtml (app) {...// Further extract and process js, CSS and other static resources
  extractSourceDom(htmlDom, app)

  // Get the micro-app-head element
  const microAppHead = htmlDom.querySelector('micro-app-head')
  // If there is a remote CSS resource, request it via fetch
  if (app.source.links.size) {
    fetchLinksFromHtml(app, microAppHead, htmlDom)
  } else {
    app.onLoad(htmlDom)
  }

  // If there is a remote JS resource, request it via fetch
  if (app.source.scripts.size) {
    fetchScriptsFromHtml(app, htmlDom)
  } else {
    app.onLoad(htmlDom)
  }
}
Copy the code

FetchLinksFromHtml and fetchScriptsFromHtml request CSS and JS resources, respectively. After requesting resources, the processing mode is different. CSS resources will be converted into style tags and inserted into the DOM, while JS will not execute immediately.

The concrete implementation of the two methods is as follows:

// src/source.js
/** * Get link remote resource *@param App Application instance *@param microAppHead micro-app-head
 * @param HtmlDom HTML DOM structure */
 export function fetchLinksFromHtml (app, microAppHead, htmlDom) {
  const linkEntries = Array.from(app.source.links.entries())
  // Request all CSS resources via fetch
  const fetchLinkPromise = []
  for (const [url] of linkEntries) {
    fetchLinkPromise.push(fetchSource(url))
  }

  Promise.all(fetchLinkPromise).then((res) = > {
    for (let i = 0; i < res.length; i++) {
      const code = res[i]
      // Get the CSS resource and insert the style element into the micro-app-head
      const link2Style = document.createElement('style')
      link2Style.textContent = code
      microAppHead.appendChild(link2Style)

      // Put the code in the cache and get it from the cache when you render it again
      linkEntries[i][1].code = code
    }

    // Execute the onLoad method after the processing is complete
    app.onLoad(htmlDom)
  }).catch((e) = > {
    console.error('Error loading CSS', e)
  })
}

/** * Get js remote resource *@param App Application instance *@param HtmlDom HTML DOM structure */
 export function fetchScriptsFromHtml (app, htmlDom) {
  const scriptEntries = Array.from(app.source.scripts.entries())
  // Request all JS resources via fetch
  const fetchScriptPromise = []
  for (const [url, info] of scriptEntries) {
    // If it is an inline script, there is no need to request resources
    fetchScriptPromise.push(info.code ? Promise.resolve(info.code) :  fetchSource(url))
  }

  Promise.all(fetchScriptPromise).then((res) = > {
    for (let i = 0; i < res.length; i++) {
      const code = res[i]
      // Put the code in the cache and get it from the cache when you render it again
      scriptEntries[i][1].code = code
    }

    // Execute the onLoad method after the processing is complete
    app.onLoad(htmlDom)
  }).catch((e) = > {
    console.error('Error loading JS', e)
  })
}
Copy the code

As you can see above, both the CSS and JS execute onLoad methods after loading, so the onLoad method is executed twice. Next we need to refine the onLoad method and render the micro application.

Apply colours to a drawing

Since onLoad is executed twice, we mark it, and when the second execution is done, all resources are loaded, and then render.

// /src/app.js

// Create a micro application
export default class CreateApp {...// Execute when the resource has been loaded
  onLoad (htmlDom) {
    this.loadCount = this.loadCount ? this.loadCount + 1 : 1
    // Perform the render on the second execution and the component is not unloaded
    if (this.loadCount === 2 && this.status ! = ='unmount') {
      // Record the DOM structure for subsequent operations
      this.source.html = htmlDom
      // Execute the mount method
      this.mount()
    }
  }
  ...
}
Copy the code

The micro application completes basic rendering by inserting the DOM structure into the document in the mount method and then executing the JS file for rendering.

// /src/app.js

// Create a micro application
export default class CreateApp {.../** render resources after loading */
  mount () {
    // Clone the DOM node
    const cloneHtml = this.source.html.cloneNode(true)
    // Create a fragment node as a template so that no redundant elements are generated
    const fragment = document.createDocumentFragment()
    Array.from(cloneHtml.childNodes).forEach((node) = > {
      fragment.appendChild(node)
    })

    // Insert the formatted DOM structure into the container
    this.container.appendChild(fragment)

    / / js
    this.source.scripts.forEach((info) = >{(0.eval)(info.code)
    })

    // The tag is applied as rendered
    this.status = 'mounted'}... }Copy the code

The above steps complete the basic rendering of the micro front end, let’s take a look at the effect.

Begin to use

We embedded the micro front under the base application:

<! -- vue2/src/pages/page1.vue -->
<template>
  <div>
    <img alt="Vue logo" src=".. /assets/logo.png">
    <HelloWorld :msg="' Base application vue@' + version" />
    <! -- 👇 embedded micro front end -->
    <micro-app name='app' url='http://localhost:3001/'></micro-app>
  </div>
</template>
Copy the code

The final results are as follows:

React17 is already embedded and running.

Let’s add a lazy page page2 to the child application react17 to verify that the multi-page application works properly.

page2Is very simple, just a paragraph of the title:

Add a button to the page and click to jump to Page2.

Click the button to get the following effect:

Render normally! 🎉 🎉

A simple micro front-end framework is complete, but at this point it is very basic, without JS sandbox and style isolation.

We’ll do a separate article on JS sandbox and style isolation, but there’s one more thing we need to do in the meantime — uninstall the application.

uninstall

The lifecycle function disconnectedCallback is automatically executed when the micro-app element is deleted, where we perform the unload related operations.

// /src/element.js

class MyElement extends HTMLElement {... disconnectedCallback () {// Obtain the application instance
    const app = appInstanceMap.get(this.name)
    // If there is a deStory property, the application is completely uninstalled, including cached files
    app.unmount(this.hasAttribute('destory'))}}Copy the code

Next, refine the unmount method of the application:

// /src/app.js

export default class CreateApp {.../** * Uninstall application *@param Destory Whether to completely destroy and delete cache resources */
  unmount (destory) {
    // Update the status
    this.status = 'unmount'
    // Empty the container
    this.container = null
    // If deStory is true, the application is deleted
    if (destory) {
      appInstanceMap.delete(this.name)
    }
  }
}
Copy the code

When deStory is true, the instance of the application is deleted. At this point, all static resources lose reference and are automatically reclaimed by the browser.

Add a button in the base app VUE2 to toggle the show/hide state of the child app and verify that multiple render and uninstall are working properly.

The effect is as follows:

A and normal operation! 🎉

conclusion

To this micro front-end rendering article on the end of the article, we have completed the micro front-end rendering and unloading function, of course, its function is very simple, just describe the micro front-end basic implementation ideas. Next we will complete THE JS sandbox, style isolation, data communication and other features, if you can bear to read it, it will help you understand the micro front end.

Code address:

Github.com/bailicangdu…