從零開始手寫一個微前端框架-渲染篇

cangdu發表於2021-08-03

前言

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

相關文章