深度:從零編寫一個微前端框架

Peter譚金傑發表於2020-05-11

寫在開頭:

手寫框架體系文章,缺手寫vue和微前端框架文章,今日補上微前端框架,覺得寫得不錯,記得點個關注+在看,轉發更好


對原始碼有興趣的,可以看我之前的系列手寫原始碼文章

微前端框架是怎麼匯入載入子應用的  【3000字精讀】

原創:帶你從零看清Node原始碼createServer和負載均衡整個過程

原創:從零實現一個簡單版React (附原始碼)

精讀:10個案例讓你徹底理解React hooks的渲染邏輯

原創:如何自己實現一個簡單的webpack構建工具 【附原始碼】

從零解析webRTC.io Server端原始碼


正式開始:

對於微前端,最近好像很火,之前我公眾號也發過比較多微前端框架文章

深度:微前端在企業級應用中的實踐  (1萬字,華為)

萬字解析微前端、微前端框架qiankun以及原始碼

那麼現在我們需要手寫一個微前端框架,首先得讓大家知道什麼是微前端,現在微前端模式分很多種,但是大都是一個基座+多個子應用模式,根據子應用註冊的規則,去展示子應用。

這是目前的微前端框架基座載入模式的原理,基於single-spa封裝了一層,我看有不少公司是用Vue做載入器(有天然的keep-alive),還有用angular和web components技術融合的


首先專案基座搭建,這裡使用parcel

mkdir pangu 
yarn init 
//輸入一系列資訊
yarn add parcel@next

然後新建一個index.html檔案,作為基座


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    
</body>
</html>

新建一個index.js檔案,作為基座載入配置檔案

新建src資料夾,作為pangu框架的原始碼資料夾,

新建example案例資料夾

現在專案結構長這樣


既然是手寫,就不依賴其他任何第三方庫

我們首先需要重寫hashchange popstate這兩個事件,因為微前端的基座,需要監聽這兩個事件根據註冊規則去載入不同的子應用,而且它的實現必須在React、vue子應用路由元件切換之前,單頁面的路由原始碼原理實現,其實也是靠這兩個事件實現,之前我寫過一篇單頁面實現原理的文章,不熟悉的可以去看看

https://segmentfault.com/a/1190000019936510

const HIJACK_EVENTS_NAME = /^(hashchange|popstate)$/i;
const EVENTS_POOL = {
  hashchange: [],
  popstate: [],
};

window.addEventListener('hashchange', loadApps);
window.addEventListener('popstate', loadApps);

const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, handler) {
  if (
    eventName &&
    HIJACK_EVENTS_NAME.test(eventName) &&
    typeof handler === 'function'
  ) {
    EVENTS_POOL[eventName].indexOf(handler) === -1 &&
      EVENTS_POOL[eventName].push(handler);
  }
  return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, handler) {
  if (eventName && HIJACK_EVENTS_NAME.test(eventName)) {
    let eventsList = EVENTS_POOL[eventName];
    eventsList.indexOf(handler) > -1 &&
      (EVENTS_POOL[eventName] = eventsList.filter((fn) => fn !== handler));
  }
  return originalRemoveEventListener.apply(this, arguments);
};

function mockPopStateEvent(state) {
  return new PopStateEvent('popstate', { state });
}

// 攔截history的方法,因為pushState和replaceState方法並不會觸發onpopstate事件,所以我們即便在onpopstate時執行了reroute方法,也要在這裡執行下reroute方法。
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
window.history.pushState = function (state, title, url) {
  let result = originalPushState.apply(this, arguments);
  reroute(mockPopStateEvent(state));
  return result;
};
window.history.replaceState = function (state, title, url) {
  let result = originalReplaceState.apply(this, arguments);
  reroute(mockPopStateEvent(state));
  return result;
};

// 再執行完load、mount、unmout操作後,執行此函式,就可以保證微前端的邏輯總是第一個執行。然後App中的Vue或React相關Router就可以收到Location的事件了。
export function callCapturedEvents(eventArgs) {
  if (!eventArgs) {
    return;
  }
  if (!Array.isArray(eventArgs)) {
    eventArgs = [eventArgs];
  }
  let name = eventArgs[0].type;
  if (!HIJACK_EVENTS_NAME.test(name)) {
    return;
  }
  EVENTS_POOL[name].forEach((handler) => handler.apply(window, eventArgs));
}

上面程式碼很簡單,建立兩個佇列,使用陣列實現


const EVENTS_POOL = {
  hashchange: [],
  popstate: [],
};

如果檢測到是hashchange popstate兩種事件,而且它們對應的回撥函式不存在佇列中時候,那麼就放入佇列中。(相當於redux中介軟體原理)

然後每次監聽到路由變化,呼叫reroute函式:

function reroute() {
  invoke([], arguments);
}

這樣每次路由切換,最先知道變化的是基座,等基座同步執行完(阻塞)後,就可以由子應用的vue-Rourer或者react-router-dom等庫去接管實現單頁面邏輯了。


那,路由變化,怎麼載入子應用呢?

像一些微前端框架會用import-html之類的這些庫,我們還是手寫吧

邏輯大概是這樣,一共四個埠,nginx反向代理命中基座伺服器監聽的埠(使用者必須首先訪問到根據域名),然後去不同子應用下的伺服器拉取靜態資源然後載入。


提示:所有子應用載入後,只是在基座的一個div標籤中載入,實現原理跟ReactDom.render()這個原始碼一樣,可參考我之前的文章

原創:從零實現一個簡單版React (附原始碼)


那麼我們先編寫一個registrApp方法,接受一個entry引數,然後去根據url變化載入子應用(傳入的第二個引數activeRule


/**
 *
 * @param {string} entry
 * @param {string} function
 */
const Apps = [] //子應用佇列
function registryApp(entry,activeRule) {
    Apps.push({
        entry,
        activeRule
    })
}

註冊完了之後,就要找到需要載入的app

export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp.entry)
    .then(function (response) {
      return response.json();
    })
    .then(function (myJson) {
      console.log(myJson, 'myJson');
    });
}

shouldBeActive根據傳入的規則去判斷是否需要此時掛載:

export function shouldBeActive(app){
    return app.activeRule(window.location)
}

此時的res資料,就是我們通過get請求獲取到的子應用相關資料,現在我們新增subapp1和subapp2資料夾,模擬部署的子應用,我們把它用靜態資源伺服器跑起來

subapp1.js作為subapp1的靜態資源伺服器

const express = require('express');

subapp2.js作為subapp2的靜態資源伺服器


const express = require('express');
const app = express();
const { resolve } = require('path');
app.use(express.static(resolve(__dirname, '../subapp1')));

app.listen(8889, (err) => {
  !err && console.log('8889埠成功');
});

現在檔案目錄長這樣:

基座index.html執行在1234埠,subapp1部署在8889埠,subapp2部署在8890埠,這樣我們從基座去拉取資源時候,就會跨域,所以靜態資源伺服器、webpack熱更新伺服器等伺服器,都要加上cors頭,允許跨域。


const express = require('express');
const app = express();
const { resolve } = require('path');
//設定跨域訪問
app.all('*', function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'X-Requested-With');
  res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
  res.header('X-Powered-By', ' 3.2.1');
  res.header('Content-Type', 'application/json;charset=utf-8');
  next();
});
app.use(express.static(resolve(__dirname, '../subapp1')));

app.listen(8889, (err) => {
  !err && console.log('8889埠成功');
});

⚠️:如果是dev模式,記得在webpack的熱更新伺服器中配置允許跨域,如果你對webpack不是很熟悉,可以看我之前的文章:

萬字硬核     從零實現webpack熱更新HMR

原創:如何自己實現一個簡單的webpack構建工具 【附原始碼】


這裡我使用nodemon啟用靜態資源伺服器,簡單為主,如果你沒有下載,可以:

npm i nodemon -g 
或
yarn add nodemon global 

這樣我們先訪問下8889,8890埠,看是否能訪問到。

訪問8889和8890都可以訪問到對應的資源,成功


正式開啟啟用我們的微前端框架pangu.封裝start方法,啟用需要掛載的APP。


export function start(){
    loadApp()
}

註冊子應用subapp1,subapp2,並且手動啟用微前端


import { registryApp, start } from './src/index';
registryApp('localhost:8889', (location) => location.pathname === '/subapp1');
registryApp('localhost:8890', (location) => location.pathname === '/subapp2');
start()

修改index.html檔案:


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div>
        <h1>基座</h1>
        <div class="subapp">
            <div>
                <a href="/subapp1">子應用1</a>
            </div>
            <div>
                <a href="/subapp2">子應用2</a>
            </div>
        </div>
        <div id="subApp"></div>
    </div>
</body>
<script src="./index.js"></script>

</html>

ok,執行程式碼,發現掛了,為什麼會掛呢?因為那邊返回的是html檔案,我這裡用的fetch請求,JSON解析不了

那麼我們去看看別人的微前端和第三方庫的原始碼吧,例如import-html-entry這個庫

由於之前我解析過qiankun這個微前端框架原始碼,我這裡就不做過度講解,它們是對fetch做了一個text()。


export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp.entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (myJson) {
      console.log(myJson, 'myJson');
    });
}

然後我們已經可以得到拉取回來的html檔案了(此時是一個字串)

由於現實的專案,一般這個html檔案會包含js和css的引入標籤,也就是我們目前的單頁面專案,類似下面這樣:

於是我們需要把指令碼、樣式、html檔案分離出來。用一個物件儲存

本想照搬某個微前端框架原始碼的,但是覺得它寫得也就那樣,今天又主要講原理,還是自己寫一個能跑的把,畢竟html的檔案都回來了,資料處理也不難

export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp[0].entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (text) {
      const dom = document.createElement('div');
      dom.innerHTML = text;
      console.log(dom, 'dom');
    });
}

先改造下,列印下DOM

發現已經能拿到dom節點了,那麼我先處理下,讓它展示在基座中


export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp[0].entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (text) {
      const dom = document.createElement('div');
      dom.innerHTML = text;
      const content = dom.querySelector('h1');
      const subapp = document.querySelector('#subApp-content');
      subapp && subapp.appendChild(content);
    });
}

此時,我們已經可以載入不同的子應用了。

乞丐版的微前端框架就完成了,後面會逐步完善所有功能,向主流的微前端框架靠攏,並且完美支援IE11.記住它叫:pangu

推薦閱讀之前的手寫ws協議:

深度:手寫一個WebSocket協議    [7000字]

最後

  • 歡迎加我微信(CALASFxiaotan),拉你進技術群,長期交流學習...
  • 歡迎關注「前端巔峰」,認真學前端,做個有專業的技術人...

點個贊支援我吧,轉發就更好了

相關文章