前言

自从微前端框架micro-app开源后,很多小伙伴都十分感兴趣,问我是如何实现的,但这并不是几句话能够说明确的。为了讲清楚其中的原理,我会从零开始实现一个繁难的微前端框架,它的外围性能包含:渲染、JS沙箱、款式隔离、数据通信。因为内容太多,会依据性能分成四篇文章进行解说,这是系列文章的第一篇:渲染篇。

通过这些文章,你能够理解微前端框架的具体原理和实现形式,这在你当前应用微前端或者本人写一套微前端框架时会有很大的帮忙。如果这篇文章对你有帮忙,欢送点赞留言。

相干举荐

micro-app源码地址:https://github.com/micro-zoe/...

整体架构

和micro-app一样,咱们的繁难微前端框架设计思路是像应用iframe一样简略,而又能够防止iframe存在的问题,其应用形式如下:

最终成果也有点相似,整个微前端利用都被封装在自定义标签micro-app中,渲染后成果如下图:

所以咱们整体架构思路为:CustomElement + HTMLEntry

HTMLEntry就是以html文件作为入口地址进行渲染,入上图中的http://localhost:3000/就是一个html地址。

概念图:

前置工作

在正式开始之前,咱们须要搭建一个开发环境,创立一个代码仓库simple-micro-app

目录构造

代码仓库次要分为src主目录和examples案例目录,vue2为基座利用,react17为子利用,两个我的项目都是应用官网脚手架创立的,构建工具应用rollup。

两个利用页面别离如下图:

基座利用 -- vue2

子利用 -- react17

在vue2我的项目中,配置resolve.alias,将simple-micro-app指向src目录的index.js。

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

在react17的webpack-dev-server中配置动态资源反对跨域拜访。

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

正式开始

为了讲的更加明确,咱们不会间接贴出曾经实现的代码,而是从无到有,一步步实现整个过程,这样能力更加清晰,容易了解。

创立容器

微前端的渲染是将子利用的js、css等动态资源加载到基座利用中执行,所以基座利用和子利用实质是同一个页面。这不同于iframe,iframe则是创立一个新的窗口,因为每次加载都要初始化整个窗口信息,所以iframe的性能不高。

如同每个前端框架在渲染时都要指定一个根元素,微前端渲染时也须要指定一个根元素作为容器,这个根元素能够是一个div或其它元素。

这里咱们应用的是通过customElements创立的自定义元素,因为它不仅提供一个元素容器,还自带了生命周期函数,咱们能够在这些钩子函数中进行加载渲染等操作,从而简化步骤。

// /src/element.js// 自定义元素class MyElement extends HTMLElement {  // 申明须要监听的属性名,只有这些属性变动时才会触发attributeChangedCallback  static get observedAttributes () {    return ['name', 'url']  }  constructor() {    super();  }  connectedCallback() {    // 元素被插入到DOM时执行,此时去加载子利用的动态资源并渲染    console.log('micro-app is connected')  }  disconnectedCallback () {    // 元素从DOM中删除时执行,此时进行一些卸载操作    console.log('micro-app has disconnected')  }  attributeChangedCallback (attr, oldVal, newVal) {    // 元素属性发生变化时执行,能够获取name、url等属性的值    console.log(`attribute ${attrName}: ${newVal}`)  }}/** * 注册元素 * 注册后,就能够像一般元素一样应用micro-app,当micro-app元素被插入或删除DOM时即可触发相应的生命周期函数。 */window.customElements.define('micro-app', MyElement)

micro-app元素可能存在反复定义的状况,所以咱们加一层判断,并放入函数中。

// /src/element.jsexport function defineElement () {  // 如果曾经定义过,则疏忽  if (!window.customElements.get('micro-app')) {    window.customElements.define('micro-app', MyElement)  }}

/src/index.js中定义默认对象SimpleMicroApp,引入并执行defineElement函数。

// /src/index.jsimport { defineElement } from './element'const SimpleMicroApp = {  start () {    defineElement()  }}export default SimpleMicroApp

引入simple-micro-app

在vue2我的项目的main.js中引入simple-micro-app,执行start函数进行初始化。

// vue2/src/main.jsimport SimpleMicroApp from 'simple-micro-app'SimpleMicroApp.start()

而后就能够在vue2我的项目中的任何地位应用micro-app标签。

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

插入micro-app标签后,就能够看到控制台打印的钩子信息。

以上咱们就实现了容器元素的初始化,子利用的所有元素都会放入到这个容器中。接下来咱们就须要实现子利用的动态资源加载及渲染。

创立微利用实例

很显然,初始化的操作要放在connectedCallback 中执行。咱们申明一个类,它的每一个实例都对应一个微利用,用于管制微利用的资源加载、渲染、卸载等。

// /src/app.js// 创立微利用export default class CreateApp {  constructor () {}  status = 'created' // 组件状态,包含 created/loading/mount/unmount  // 寄存利用的动态资源  source = {     links: new Map(), // link元素对应的动态资源    scripts: new Map(), // script元素对应的动态资源  }  // 资源加载完时执行  onLoad () {}  /**   * 资源加载实现后进行渲染   */  mount () {}  /**   * 卸载利用   * 执行敞开沙箱,清空缓存等操作   */  unmount () {}}

咱们在connectedCallback函数中初始化实例,将name、url及元素本身作为参数传入,在CreateApp的constructor中记录这些值,并依据url地址申请html。

// /src/element.jsimport CreateApp, { appInstanceMap } from './app'...connectedCallback () {  // 创立微利用实例  const app = new CreateApp({    name: this.name,    url: this.url,    container: this,  })  // 记入缓存,用于后续性能  appInstanceMap.set(this.name, app)}attributeChangedCallback (attrName, oldVal, newVal) {  // 别离记录name及url的值  if (attrName === 'name' && !this.name && newVal) {    this.name = newVal  } else if (attrName === 'url' && !this.url && newVal) {    this.url = newVal  }}...

在初始化实例时,依据传入的参数申请动态资源。

// /src/app.jsimport loadHtml from './source'// 创立微利用export default class CreateApp {  constructor ({ name, url, container }) {    this.name = name // 利用名称    this.url = url  // url地址    this.container = container // micro-app元素    this.status = 'loading'    loadHtml(this)  }  ...}

申请html

咱们应用fetch申请动态资源,益处是浏览器自带且反对promise,但这也要求子利用的动态资源反对跨域拜访。

// src/source.jsexport default function loadHtml (app) {  fetch(app.url).then((res) => {    return res.text()  }).then((html) => {    console.log('html:', html)  }).catch((e) => {    console.error('加载html出错', e)  })}

因为申请js、css等都须要应用到fetch,所以咱们将它提取进去作为公共办法。

// /src/utils.js/** * 获取动态资源 * @param {string} url 动态资源地址 */export function fetchSource (url) {  return fetch(url).then((res) => {    return res.text()  })}

从新应用封装后的办法,并对获取到到html进行解决。

// src/source.jsimport { fetchSource } from './utils'export default function loadHtml (app) {  fetchSource(app.url).then((html) => {    html = html      .replace(/<head[^>]*>[\s\S]*?<\/head>/i, (match) => {        // 将head标签替换为micro-app-head,因为web页面只容许有一个head标签        return match          .replace(/<head/i, '<micro-app-head')          .replace(/<\/head>/i, '</micro-app-head>')      })      .replace(/<body[^>]*>[\s\S]*?<\/body>/i, (match) => {        // 将body标签替换为micro-app-body,避免与基座利用的body标签反复导致的问题。        return match          .replace(/<body/i, '<micro-app-body')          .replace(/<\/body>/i, '</micro-app-body>')      })    // 将html字符串转化为DOM构造    const htmlDom = document.createElement('div')    htmlDom.innerHTML = html    console.log('html:', htmlDom)    // 进一步提取和解决js、css等动态资源    extractSourceDom(htmlDom, app)  }).catch((e) => {    console.error('加载html出错', e)  })}

html格式化后,咱们就能够失去一个DOM构造。从下图能够看到,这个DOM构造蕴含link、style、script等标签,接下来就须要对这个DOM做进一步解决。

提取js、css等动态资源地址

咱们在extractSourceDom办法中循环递归解决每一个DOM节点,查问到所有link、style、script标签,提取动态资源地址并格式化标签。

// src/source.js/** * 递归解决每一个子元素 * @param parent 父元素 * @param app 利用实例 */function extractSourceDom(parent, app) {  const children = Array.from(parent.children)    // 递归每一个子元素  children.length && children.forEach((child) => {    extractSourceDom(child, app)  })  for (const dom of children) {    if (dom instanceof HTMLLinkElement) {      // 提取css地址      const href = dom.getAttribute('href')      if (dom.getAttribute('rel') === 'stylesheet' && href) {        // 计入source缓存中        app.source.links.set(href, {          code: '', // 代码内容        })      }      // 删除原有元素      parent.removeChild(dom)    } else if (dom instanceof HTMLScriptElement) {      // 并提取js地址      const src = dom.getAttribute('src')      if (src) { // 近程script        app.source.scripts.set(src, {          code: '', // 代码内容          isExternal: true, // 是否近程script        })      } else if (dom.textContent) { // 内联script        const nonceStr = Math.random().toString(36).substr(2, 15)        app.source.scripts.set(nonceStr, {          code: dom.textContent, // 代码内容          isExternal: false, // 是否近程script        })      }      parent.removeChild(dom)    } else if (dom instanceof HTMLStyleElement) {      // 进行款式隔离    }  }}

申请动态资源

下面曾经拿到了html中的css、js等动态资源的地址,接下来就是申请这些地址,拿到资源的内容。

接着欠缺loadHtml,在extractSourceDom上面增加申请资源的办法。

// src/source.js...export default function loadHtml (app) {  ...  // 进一步提取和解决js、css等动态资源  extractSourceDom(htmlDom, app)  // 获取micro-app-head元素  const microAppHead = htmlDom.querySelector('micro-app-head')  // 如果有近程css资源,则通过fetch申请  if (app.source.links.size) {    fetchLinksFromHtml(app, microAppHead, htmlDom)  } else {    app.onLoad(htmlDom)  }  // 如果有近程js资源,则通过fetch申请  if (app.source.scripts.size) {    fetchScriptsFromHtml(app, htmlDom)  } else {    app.onLoad(htmlDom)  }}

fetchLinksFromHtmlfetchScriptsFromHtml别离申请css和js资源,申请资源后的解决形式不同,css资源会转化为style标签插入DOM中,而js不会立刻执行,咱们会在利用的mount办法中执行js。

两个办法的具体实现形式如下:

// src/source.js/** * 获取link近程资源 * @param app 利用实例 * @param microAppHead micro-app-head * @param htmlDom html DOM构造 */ export function fetchLinksFromHtml (app, microAppHead, htmlDom) {  const linkEntries = Array.from(app.source.links.entries())  // 通过fetch申请所有css资源  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]      // 拿到css资源后放入style元素并插入到micro-app-head中      const link2Style = document.createElement('style')      link2Style.textContent = code      microAppHead.appendChild(link2Style)      // 将代码放入缓存,再次渲染时能够从缓存中获取      linkEntries[i][1].code = code    }    // 解决实现后执行onLoad办法    app.onLoad(htmlDom)  }).catch((e) => {    console.error('加载css出错', e)  })}/** * 获取js近程资源 * @param app 利用实例 * @param htmlDom html DOM构造 */ export function fetchScriptsFromHtml (app, htmlDom) {  const scriptEntries = Array.from(app.source.scripts.entries())  // 通过fetch申请所有js资源  const fetchScriptPromise = []  for (const [url, info] of scriptEntries) {    // 如果是内联script,则不须要申请资源    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]      // 将代码放入缓存,再次渲染时能够从缓存中获取      scriptEntries[i][1].code = code    }    // 解决实现后执行onLoad办法    app.onLoad(htmlDom)  }).catch((e) => {    console.error('加载js出错', e)  })}

下面能够看到,css和js加载实现后都执行了onLoad办法,所以onLoad办法被执行了两次,接下来咱们就要欠缺onLoad办法并渲染微利用。

渲染

因为onLoad被执行了两次,所以咱们进行标记,当第二次执行时阐明所有资源都加载实现,而后进行渲染操作。

// /src/app.js// 创立微利用export default class CreateApp {  ...  // 资源加载完时执行  onLoad (htmlDom) {    this.loadCount = this.loadCount ? this.loadCount + 1 : 1    // 第二次执行且组件未卸载时执行渲染    if (this.loadCount === 2 && this.status !== 'unmount') {      // 记录DOM构造用于后续操作      this.source.html = htmlDom      // 执行mount办法      this.mount()    }  }  ...}

mount办法中将DOM构造插入文档中,而后执行js文件进行渲染操作,此时微利用即可实现根本的渲染。

// /src/app.js// 创立微利用export default class CreateApp {  ...  /**   * 资源加载实现后进行渲染   */  mount () {    // 克隆DOM节点    const cloneHtml = this.source.html.cloneNode(true)    // 创立一个fragment节点作为模版,这样不会产生冗余的元素    const fragment = document.createDocumentFragment()    Array.from(cloneHtml.childNodes).forEach((node) => {      fragment.appendChild(node)    })    // 将格式化后的DOM构造插入到容器中    this.container.appendChild(fragment)    // 执行js    this.source.scripts.forEach((info) => {      (0, eval)(info.code)    })    // 标记利用为已渲染    this.status = 'mounted'  }  ...}

以上步骤实现了微前端的根本渲染操作,咱们看一下成果。

开始应用

咱们在基座利用上面嵌入微前端:

<!-- vue2/src/pages/page1.vue --><template>  <div>    <img alt="Vue logo" src="../assets/logo.png">    <HelloWorld :msg="'基座利用vue@' + version" />    <!-- 嵌入微前端 -->    <micro-app name='app' url='http://localhost:3001/'></micro-app>  </div></template>

最终失去的成果如下:

可见react17曾经失常嵌入运行了。

咱们给子利用react17增加一个懒加载页面page2,验证一下多页面利用是否能够失常运行。

page2的内容也非常简单,只是一段题目:

在页面上增加一个按钮,点击即可跳转page2。

点击按钮,失去的成果如下:

失常渲染!

一个繁难的微前端框架就实现了,当然此时它是十分根底的,没有JS沙箱和款式隔离。

对于JS沙箱和款式隔离咱们会独自做一篇文章分享,然而此时咱们还有一件事件须要做 - 卸载利用。

卸载

当micro-app元素被删除时会主动执行生命周期函数disconnectedCallback,咱们在此处执行卸载相干操作。

// /src/element.jsclass MyElement extends HTMLElement {  ...  disconnectedCallback () {    // 获取利用实例    const app = appInstanceMap.get(this.name)    // 如果有属性destory,则齐全卸载利用包含缓存的文件    app.unmount(this.hasAttribute('destory'))  }}

接下来欠缺利用的unmount办法:

// /src/app.jsexport default class CreateApp {  ...  /**   * 卸载利用   * @param destory 是否齐全销毁,删除缓存资源   */  unmount (destory) {    // 更新状态    this.status = 'unmount'    // 清空容器    this.container = null    // destory为true,则删除利用    if (destory) {      appInstanceMap.delete(this.name)    }  }}

当destory为true时,删除利用的实例,此时所有动态资源失去了援用,主动被浏览器回收。

在基座利用vue2中增加一个按钮,切换子利用的显示/暗藏状态,验证屡次渲染和卸载是否失常运行。

成果如下:

一且运行失常!

结语

到此微前端渲染篇的文章就完结了,咱们实现了微前端的渲染和卸载性能,当然它的性能是非常简单的,只是叙述了微前端的根本实现思路。接下来咱们会实现JS沙箱、款式隔离、数据通讯等性能,如果你能耐下心来读一遍,会对你理解微前端有很大帮忙。

代码地址:

https://github.com/bailicangd...