从零开始手写一个微前端框架-渲染篇

前言

自从微前端框架micro-app开源后,很多小伙伴都非常感兴趣,问我是如何实现的,但这并不是几句话可以说明白的。为了讲清楚其中的原理,我会从零开始实现一个简易的微前端框架,它的核心功能包括:渲染、JS沙箱、样式隔离、数据通信。由于内容太多,会根据功能分成四篇文章进行讲解,这是系列文章的第一篇:渲染篇。

通过这些文章,你可以了解微前端框架的具体原理和实现方式,这在你以后使用微前端或者自己写一套微前端框架时会有很大的帮助。如果这篇文章对你有帮助,欢迎点赞留言。

相关推荐

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

整体架构

和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/bailicangdu/simple-micro-app

posted @ 2021-08-03 16:21  cangdu  阅读(912)  评论(1编辑  收藏  举报