乐趣区

关于前端:手撕微前端系列手写一个微前端框架渲染篇

前言

自从微前端框架 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.js

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

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

// /src/index.js

import {defineElement} from './element'

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

export default SimpleMicroApp

引入 simple-micro-app

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

// vue2/src/main.js

import 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.js
import 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.js
import 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.js

export 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.js
import {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.js

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

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

// /src/app.js

export 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…

退出移动版