Skip to content
/ micro-app Public
  • Notifications
  • Fork 544
  • Star 5.3k
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Sign up for GitHub

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jump to bottom

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

Closed
bailicangdu opened this issue Aug 3, 2021 · 5 comments
Closed

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

bailicangdu opened this issue Aug 3, 2021 · 5 comments
Labels
docs Improvements or additions to documentation

Comments

@bailicangdu
Copy link
Member

bailicangdu commented Aug 3, 2021

前言

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

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

相关推荐

  • micro-app仓库地址
  • simple-micro-app仓库地址
  • 从零开始写一个微前端框架-渲染篇
  • 从零开始写一个微前端框架-沙箱篇
  • 从零开始写一个微前端框架-样式隔离篇
  • 从零开始写一个微前端框架-数据通信篇
  • 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沙箱、样式隔离、数据通讯等功能,如果你能耐下心来读一遍,会对你了解微前端有很大帮助。

@bailicangdu bailicangdu added the docs Improvements or additions to documentation label Aug 3, 2021
@bailicangdu bailicangdu changed the title 手写一个微前端框架-渲染篇 从零开始手写一个微前端框架-渲染篇 Aug 3, 2021
@bailicangdu bailicangdu changed the title 从零开始手写一个微前端框架-渲染篇 从零开始写一个微前端框架-渲染篇 Aug 3, 2021
This was referenced Aug 4, 2021
从零开始写一个微前端框架-沙箱篇 #19
Closed
从零开始写一个微前端框架-样式隔离篇 #20
Closed
从零开始写一个微前端框架-数据通信篇 #21
Closed
@ksky521 ksky521 mentioned this issue Aug 12, 2021
技术周刊投稿 - CONTENT-AWARE IMAGE RESIZING IN JAVASCRIPT SearchFeed/weekly#4
Closed
@BUPTlhuanyu BUPTlhuanyu mentioned this issue Aug 17, 2021
微前端 BUPTlhuanyu/blog#4
Open
@bailicangdu bailicangdu closed this as completed Aug 25, 2021
@Perseman
Copy link

Perseman commented Nov 16, 2021

// vue.config.js
  devServer: {
    host: '0.0.0.0',
    port: 8080, // 端口号
    https: false, // https:{type:Boolean}
    proxy: 'http://localhost:3000' // 配置跨域处理,只有一个代理
  }

否则会出现

@hopperhuang hopperhuang mentioned this issue Dec 28, 2021
microservice hopperhuang/tech-story#41
Open
@AursorDev
Copy link

AursorDev commented Jul 8, 2022

@Perseman 这个是在获取js的时候,因为他那个资源地址是相对的比如 /static/js/bundle.js,用fetch 去获取的话,相当于获取了基座应用的,比如:localhost:8080/static/js/bundle.js,然后获取不到就会返回一个 404的html页面,用eval去执行html就会报这个错误,

@AursorDev
Copy link

AursorDev commented Jul 8, 2022

@Perseman 解决方法:

  1. 可以在基座应用上把publicPath改成 你的子应用的地址
  2. 在获取资源的时候,手动拼接url,将app.url和你的资源url拼接到一起再进行获取
    url = ${app.url.endsWith('/') ? app.url.substring(0,app.url.length-1):app.url}${url};

@zlqxk
Copy link

zlqxk commented Jul 11, 2022

写的太好了

@zhangmingdi
Copy link

zhangmingdi commented Mar 28, 2023

所有的静态资源都是通过fetch去拿的么,有一些静态资源无法控制跨域怎么办

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Improvements or additions to documentation
Projects
None yet
Development

No branches or pull requests

5 participants
@bailicangdu @Perseman @zlqxk @zhangmingdi @AursorDev

Footer

© 2024 GitHub, Inc.

代做工资流水公司鞍山代开车贷银行流水杭州流水单查询公司流水代做江门薪资流水价格蚌埠消费贷流水菏泽代办企业对公流水遵义工资流水单多少钱曲靖背调流水打印遵义离职证明查询天津企业银行流水图片襄阳查询房贷流水佛山入职银行流水打印海口做车贷银行流水邢台个人流水代做南京签证工资流水嘉兴打印转账流水开封代开工资流水绍兴银行流水电子版代做珠海开工作收入证明沈阳工资流水单模板西宁打印薪资银行流水大庆离职证明费用信阳办理薪资银行流水成都办工资流水单太原打印银行流水账贵阳入职流水办理铜陵查企业对公流水宜昌对公银行流水价格郑州入职工资流水代办菏泽银行流水账单制作香港通过《维护国家安全条例》两大学生合买彩票中奖一人不认账让美丽中国“从细节出发”19岁小伙救下5人后溺亡 多方发声卫健委通报少年有偿捐血浆16次猝死汪小菲曝离婚始末何赛飞追着代拍打雅江山火三名扑火人员牺牲系谣言男子被猫抓伤后确诊“猫抓病”周杰伦一审败诉网易中国拥有亿元资产的家庭达13.3万户315晚会后胖东来又人满为患了高校汽车撞人致3死16伤 司机系学生张家界的山上“长”满了韩国人?张立群任西安交通大学校长手机成瘾是影响睡眠质量重要因素网友洛杉矶偶遇贾玲“重生之我在北大当嫡校长”单亲妈妈陷入热恋 14岁儿子报警倪萍分享减重40斤方法杨倩无缘巴黎奥运考生莫言也上北大硕士复试名单了许家印被限制高消费奥巴马现身唐宁街 黑色着装引猜测专访95后高颜值猪保姆男孩8年未见母亲被告知被遗忘七年后宇文玥被薅头发捞上岸郑州一火锅店爆改成麻辣烫店西双版纳热带植物园回应蜉蝣大爆发沉迷短剧的人就像掉进了杀猪盘当地回应沈阳致3死车祸车主疑毒驾开除党籍5年后 原水城县长再被查凯特王妃现身!外出购物视频曝光初中生遭15人围殴自卫刺伤3人判无罪事业单位女子向同事水杯投不明物质男子被流浪猫绊倒 投喂者赔24万外国人感慨凌晨的中国很安全路边卖淀粉肠阿姨主动出示声明书胖东来员工每周单休无小长假王树国卸任西安交大校长 师生送别小米汽车超级工厂正式揭幕黑马情侣提车了妈妈回应孩子在校撞护栏坠楼校方回应护栏损坏小学生课间坠楼房客欠租失踪 房东直发愁专家建议不必谈骨泥色变老人退休金被冒领16年 金额超20万西藏招商引资投资者子女可当地高考特朗普无法缴纳4.54亿美元罚金浙江一高校内汽车冲撞行人 多人受伤

代做工资流水公司 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化