从零开始写一个微前端框架(渲染篇)

标签: dev | 发表时间:2021-08-05 00:00 | 作者:
出处:http://itindex.net/relian

前言

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

相关 [从零开始 前端 框架] 推荐:

从零开始写一个微前端框架(渲染篇)

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

GitLab-CI:从零开始的前端自动化部署

- - DockOne.io
gitlab-ci&&自动化部署工具的运行机制. 1、通过在项目根目录下配置.gitlab-ci.yml文件,可以控制CI流程的不同阶段,例如install/检查/编译/部署服务器. GitLab平台会扫描.gitlab-ci.yml文件,并据此处理CI流程. 2、CI流程在每次团队成员push/merge后之后触发.

从零开始搭建SRE

- - DockOne.io
【编者的话】Google在10年前创造了SRE这个工种. SRE,Site Reliability Engineering的缩写. 其中site是指Website,可以翻译为网站可靠性工程. 几年前资深Google SRE Chris Jones等人联合撰写了《Google SRE: How Google runs production systems》,首次向外界解密了Google的生产环境以及整个SRE的方法论.

王家林“云计算分布式大数据Hadoop实战高手之路---从零开始”的第一讲Hadoop图文训练课程:10分钟理解云计算分布式大数据处理框架Hadoop

- - CSDN博客云计算推荐文章
                                                                                                                                                     .

从零开始做工作室!

- l - 果壳网 guokr.com - 果壳网
DIYer:dwj300 制作时间:1周 制作难度:★★★★☆ GEEK指数:★★★★☆. 在“被没有足够工作空间”、“找不到工具和材料”、“花了太多时间找东西”弄到抓狂以后,我们决定清理地下杂物室,做个工作间. 在施工过程中我们学到了很多,希望这些经验会对想建设或者拓展工作空间的人们有帮助.

从零开始的Spring Session(二)

- - 程序猿DD | 博客
上一篇文章介绍了一些Session和Cookie的基础知识,这篇文章开始正式介绍Spring Session是如何对传统的Session进行改造的. 官网这么介绍Spring Session:. 其具体的特性非常之多,具体的内容可以从文档中了解到,笔者做一点自己的总结,Spring Session的特性包括但不限于以下:.

前端框架你究竟选什么

- - 博客 - 伯乐在线
在做web开发的时候难免遇到一个问题,那就是,选择什么样的框架. Apache基金会今天发布了Flex 4.8版本, 这是Adobe将Flex捐献给Apache基金会后发布的第一个版本. 需要注意的是,Flex目前还在孵化阶段,还不是Apache的正式项目,Flex 4.8也不是一个正式的Apache版本.

[转][转]前端开发框架对比

- - heiyeluren的blog(黑夜路人的开源世界)
来源: http://www.ibm.com/developerworks/cn/web/1404_wangfx_jsframeworks/. 本文选取了 Bootstrap、jQuery UI、jQuery Mobile、Sencha ExtJS、Sencha Touch、Sencha GXT、Dojo、Dojo Mobile、Mootools、Foundation、YUI、Kissy、QWrap 等 16 个国内外前端开发框架进行初步的横向比较,可以作为大家挑选前端开发框架的初步参考.

从零开始学习 jQuery(十)jQueryUI常用功能实战

- zhai - 第三极 | 移动开发者
本系列文章将带您进入jQuery的精彩世界, 其中有很多作者具体的使用经验和解决方案,  即使你会使用jQuery也能在阅读中发现些许秘籍.. 使用jQueryUI完成制作网站的大部分常用功能.. 经过公司内部收集, 整理了一些经常使用javascript实现的功能.  实现这些功能的主角不是让人眼花缭乱的jQuery插件,  而是jQuery UI..

从零开始学Android应用安全测试(Part1)

- - FreeBuf.COM | 关注黑客与极客
在本系列文章中,利用InsecureBankv2这款含有漏洞的安卓应用,我们可以了解到有关安卓应用安全的种种概念. 我们将从一个新手的角度看待每一个问题. 所以,我建议新手朋友可以关注下本系列文章. 由于教程是从零开始,前面的东西不免会比较基础,老鸟请先飞过吧. 在对安卓应用测试之前,我们需要搭建一个合适的移动渗透平台.