柚子快報邀請碼778899分享:如何實現(xiàn)一個微前端框架
柚子快報邀請碼778899分享:如何實現(xiàn)一個微前端框架
名詞:
架構(gòu)層級:系統(tǒng)架構(gòu) 應(yīng)用級 模塊 代碼
微前端:子應(yīng)用調(diào)度(耦合) 共享 (巨石應(yīng)用)單實例/多實例
架構(gòu)質(zhì)量:穩(wěn)定性(回歸) 健壯性(容錯) 拓展性 維護(hù)性
實際業(yè)務(wù)場景:
目前我們已經(jīng)有多個成熟項目,各項目分別用不同的技術(shù)棧實現(xiàn),react15、react16、react17 、vue2、vue3、JQuery/js原生 等。我們希望通過構(gòu)建一個項目作為門戶/平臺將現(xiàn)有的多個項目融合起來?;诖?,我們采用微前端框架來實現(xiàn),將門戶平臺作為主應(yīng)用,其他被融合的項目作為子應(yīng)用嵌入其中,同時不影響各子應(yīng)用的獨立運行和維護(hù)迭代。
微前端實現(xiàn)方式:
Iframe -- 多域名鑒權(quán)問題 web component -- 不成熟 自研框架 -- 定制化 獨立通信機制 沙箱環(huán)境 首次加載資源大 實現(xiàn):路由分發(fā)? 主應(yīng)用控制路由匹配和子應(yīng)用加載? 子應(yīng)用實現(xiàn)功能 主應(yīng)用-- 注冊子應(yīng)用、加載渲染子應(yīng)用、路由匹配、獲取數(shù)據(jù)、通信 子應(yīng)用-- 渲染、監(jiān)聽通信傳遞過來的數(shù)據(jù)
Micro-web微前端框架實現(xiàn)方式
實現(xiàn)一個主應(yīng)用作為中央控制器: VUE3
在主應(yīng)用中注冊子應(yīng)用:通過路由匹配設(shè)置需要展示的當(dāng)前子應(yīng)用
main-nav.vue
import { ref, nextTick, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NAV_LIST } from '../../const'
import { headerState } from '../../store'
export default {
name: 'nav',
setup() {
const router = useRouter();
const route = useRoute();
watch(route, (val) => {
for (let i = 0, len = NAV_LIST.length; i < len; i++) {
if (
NAV_LIST[i].url &&
val.fullPath.indexOf(NAV_LIST[i].url) !== -1
) {
headerState.setCurrentIndex(i)
return
}
}
}, { deep: true })
const setCurrentIndex = (item) => {
router.push(`${item.url}`)
}
return {
NAV_LIST,
currentIndex: headerState.currentIndex,
setCurrentIndex,
}
}
};
/const/nav.js
export const NAV_LIST = [
{
name: '首頁',
status: true,
value: 0,
url: '/vue3/#/index',
hash: '',
},
{
name: '資訊',
status: false,
value: 1,
url: '/react15#/information',
},
{
name: '視頻',
status: false,
value: 2,
url: '/react17#/video',
hash: '',
},
{
name: '選車',
status: false,
value: 3,
url: '/vue3/#/select',
hash: '',
},
{
name: '新能源',
status: false,
value: 4,
url: '/vue2#/energy',
hash: '',
},
{
name: '新車',
status: false,
value: 5,
url: '/react16#/new-car',
hash: '',
},
{
name: '排行',
status: false,
value: 6,
url: '/react16#/rank',
hash: '',
},
]
/store/headerState
import { ref } from 'vue'
// 當(dāng)前導(dǎo)航所在位置
export const currentIndex = ref(0)
// 修改導(dǎo)航選擇
export const setCurrentIndex = (key, cb) => {
currentIndex.value = key
cb && cb()
}
/store/leftNav
import * as loading from './loading'
import * as appInfo from '../store'
export const navList = [
{
name: 'react15',// 唯一
entry: '//localhost:9002/',
loading,
container: '#micro-container',
activeRule: '/react15',
appInfo,
},
{
name: 'react16',
entry: '//localhost:9003/',
loading,
container: '#micro-container',
activeRule: '/react16',
appInfo,
},
{
name: 'vue2',
entry: '//localhost:9004/',
loading,
container: '#micro-container',
activeRule: '/vue2',
appInfo,
},
{
name: 'vue3',
entry: '//localhost:9005/',
loading,
container: '#micro-container',
activeRule: '/vue3',
appInfo,
},
];
主應(yīng)用生命周期:
在主應(yīng)用中傳入子應(yīng)用的導(dǎo)航(第一個參數(shù))和生命周期(第二個參數(shù)),beforeLoad 是開始加載,mounted 是渲染完成,destoryed 是卸載完成
import { leftNav, headerState, footerState } from '../store';
import { registerMicroApps, start } from 'test-micro-web';
export const starMicroApp = () => {
// 注冊子應(yīng)用
registerMicroApps(
leftNav.navList,
// 生命周期
{
beforeLoad: [
app => {
app.loading.openLoading()
// 每次改動,都將頭部和底部顯示出來,不需要頭部和底部的頁面需要子應(yīng)用自己處理
headerState.changeHeader(true)
footerState.changeFooter(true)
console.log('開始加載 -- ', app.name);
},
],
mounted: [
app => {
console.log('加載完成 -- ', app.name);
setTimeout(() => {
app.loading.closeLoading()
}, 200)
},
],
destoryed: [
app => {
console.log('卸載完成 -- ', app.name);
},
],
},
{
}
);
// 如果當(dāng)前是根路由,且沒有子應(yīng)用,默認(rèn)進(jìn)入到 主應(yīng)用vue3的根路由
if (window.location.pathname === '/') {
window.history.pushState(null, null, '/vue3#/index');
}
// 啟動
start();
};
框架實現(xiàn):
registerMicroApps 接收兩個參數(shù),apps 子應(yīng)用參數(shù)列表和 mainLifecycle 生命周期,然后通過 setMainLifecycle 緩存主應(yīng)用的生命周期
//start.js
import { setList} from "./const/subApps"
// 注冊子應(yīng)用列表
export const registerMicroApps = (apps, mainLifecycle) => {
// apps.forEach(app => window.appList.push(app));
// 注冊子應(yīng)用
setList(apps)
// 保留主應(yīng)用的生命周期
setMainLifecycle(mainLifecycle)
}
setList 方法
// 子應(yīng)用列表
let list = []
export const setList = (data) => {
if (Array.isArray(data)) {
list = data;
return
}
list.push(data)
}
setMainLifecycle 是緩存主應(yīng)用的生命周期
export const setMainLifecycle = (data) => {
mainLifecycle = data
}
findAppByRoute 查找上一個和下一個 app 里面的內(nèi)容,isTurnChild 判斷子應(yīng)用是否做了切換,通過 window.__CURRENT_SUB_APP__ 判斷當(dāng)前路由已改變,修改當(dāng)前路由,通過 window.__CURRENT_HASH__ 判斷當(dāng)前 hash 值是否改變
// 根據(jù) 路由 查找子應(yīng)用
export const findAppByRoute = (router) => {
return filterApp('activeRule', router);
};
// 根據(jù) name 查找子應(yīng)用
export const findAppByName = (name) => {
return filterApp('name', name);
};
export const filterApp = (key, rule) => {
const currentApp = getList().filter(app => app[key] === rule);
return currentApp.length ? currentApp[0] : false;
};
跳轉(zhuǎn)時先將上一個子應(yīng)用生命周期卸載,然后啟動當(dāng)前子應(yīng)用的生命周期
// 改變了路由,重新裝載新的子應(yīng)用
export const lifecycle = async () => {
const prevApp = findAppByRoute(window.__ORIGIN_APP__); // 獲取上一個子應(yīng)用
const nextApp = findAppByRoute(window.__CURRENT_SUB_APP__); // 獲取跳轉(zhuǎn)后的子應(yīng)用
if (!nextApp) {
return
}
if (prevApp) {
// 卸載上一個應(yīng)用
await unmount(prevApp);
}
// 還原 prevApp 快照。
// prevApp.sandBox.active()
await boostrap(nextApp);
await mount(nextApp);
}
prevApp 是獲取到上一個子應(yīng)用,卸載掉上一個子應(yīng)用,nextApp 是獲取到要跳轉(zhuǎn)到的子應(yīng)用,執(zhí)行對應(yīng)的子應(yīng)用生命周期。若沒有獲取到下一個子應(yīng)用,直接退出,后面的就不用執(zhí)行。獲取到上一個子應(yīng)用,并且有它自己的生命周期,由其它子應(yīng)用切換過來的,卸載掉上一個子應(yīng)用。beforeLoad、mounted 和 destoryed 是主應(yīng)用的生命周期,runMainLifeCycle 是運行主應(yīng)用生命周期,等待所有的方法執(zhí)行完成,subApp 是獲取的是子應(yīng)用的內(nèi)容
獲取子應(yīng)用的html內(nèi)容 子應(yīng)用入口解析html并掛載到根元素進(jìn)行渲染 解析其它dom/script
htmlLoader 是加載 html 的方法,container 是第一個子應(yīng)用需要顯示在哪里,#id 內(nèi)容,entry 是子應(yīng)用的入口。
// 加載和渲染html
export const htmlLoader = async (app) => {
const {
container: cantainerName, entry, name
} = app
let [dom, scriptsArray] = await parseHtml(entry, name);
let container = document.querySelector(cantainerName);
if (!container) {
throw Error(` ${name} 的容器不存在,請查看是否正確指定`);
}
container.innerHTML = dom;
scriptsArray.map((item) => {
sandbox(item, name);
});
}
// 解析html
export const parseHtml = async (url, appName) => {
const div = document.createElement('div');
let scriptsArray = [];
div.innerHTML = await fetchUrl(url);
const [scriptUrls, scripts, elements] = getResources(div, findAppByName(appName));
const fetchedScript = await Promise.all(scriptUrls.map(url => fetchUrl(url)));
scriptsArray = scripts.concat(fetchedScript);
cache[appName] = [elements, scriptsArray];
return [elements, scriptsArray];
}
parseHtml 是解析 html,在 fetchUrl 后解析、加載、執(zhí)行流程。通過 script.concat 處理 fetchedScripts,將得到且攜帶標(biāo)簽里面的內(nèi)容、外部鏈接引入的內(nèi)容結(jié)合到一起,構(gòu)成子應(yīng)用所有 script 的集合,在其它方法中將這些集合運行起來,得到完整的子應(yīng)用渲染,最后將 dom、allScript 直接緩存,后面可以直接使用 cache,獲取到對應(yīng)的子應(yīng)用。
// 解析 js 內(nèi)容
export const getResources = (root, app) => {
const scriptUrls = [];
const scripts = [];
function deepParse(element) {
const children = element.children;
const parent = element.parentNode;
// 處理位于 link 標(biāo)簽中的 js 文件
if (element.nodeName.toLowerCase() === 'script') {
const src = element.getAttribute('src');
if (!src) {
// 直接在 script 標(biāo)簽中書寫的內(nèi)容
let script = element.outerHTML;
scripts.push(script);
} else {
if (src.startsWith('http')) {
scriptUrls.push(src);
} else {
// fetch 時 添加 publicPath
scriptUrls.push(`http:${app.entry}/${src}`);
}
}
if (parent) {
let comment = document.createComment('此 js 文件已被微前端替換');
// 在 dom 結(jié)構(gòu)中刪除此文件引用
parent.replaceChild(comment, element);
}
}
// 處理位于 link 標(biāo)簽中的 js 文件
if (element.nodeName.toLowerCase() === 'link') {
const href = element.getAttribute('href');
if (href.endsWith('.js')) {
if (href.startsWith('http')) {
scriptUrls.push(href);
} else {
// fetch 時 添加 publicPath
scriptUrls.push(`http:${app.entry}/${href}`);
}
}
}
for (let i = 0; i < children.length; i++) {
deepParse(children[i]);
}
}
deepParse(root);
return [scriptUrls, scripts, root.outerHTML];
}
在 getResources 中,scriptUrl是 鏈接/src /href,script是寫在 script 中的 js 腳本內(nèi)容,dom 是需要展示到子應(yīng)用上的 DOM 結(jié)構(gòu)。deepParse 是深度解析,獲取到所有 script 和 link 的鏈接、內(nèi)容,先處理位于 script 中的內(nèi)容,link 也會有 js 的內(nèi)容,遞歸遍歷進(jìn)行處理
子應(yīng)用生命周期改造
bootstrap mount unmount
import React from 'react'
import "./index.scss"
import ReactDOM from 'react-dom'
import BasicMap from './src/router';
import { setMain } from './src/utils/global'
export const render = () => {
ReactDOM.render(
}
if (!window.__MICRO_WEB__) {
render()
}
export async function bootstrap() {
console.log('react bootstrap')
}
export async function mount(app) {
setMain(app)
console.log('react mount')
render()
}
export async function unmount(ctx) {
console.log('react unmout')
const { container } = ctx
if (container) {
document.querySelector(container).innerHTML = ''
}
}
微前端環(huán)境變量window.__MICRO_WEB__ == true,獲取生命周期掛載到app上, 以sandBox暴露出去
// 創(chuàng)建沙箱環(huán)境
export const sandbox = (script, app) => {
// 創(chuàng)建沙箱環(huán)境
const global = new ProxySandBox();
// 設(shè)置微前端環(huán)境
window.__MICRO_WEB__ = true;
// 獲取子應(yīng)用生命周期
const lifeCycles = performScriptForEval(script, app, global.proxy);
const performScriptForEval = (script, appName, global) => {
const globalWindow = (0, eval)(window)
globalWindow.proxy = global;
const scriptText = `
((window) => {
try{
${script}
} catch (e) {
console.error('run script error: ' + e)
}
return window['${appName}']
}).bind(window.proxy)(window.proxy)
`
return eval(scriptText)// app module mount
}
app.sandBox = global;
app.bootstrap = lifeCycles.bootstrap;
app.mount = lifeCycles.mount;
app.unmount = lifeCycles.unmount;
}
快照沙箱(單實例)--應(yīng)用于老版本瀏覽器中,代理window(共兩層)->激活沙箱->銷毀沙箱
代理沙箱(多實例) --激活沙箱->new Proxy(window,handler) 設(shè)置兩個攔截方法 get() set() 獲取到屬性指向window -- (function) window.defaultValue[key] / window.target[key] -> 銷毀沙箱
css樣式隔離-- 三種方法 css modules / shadow dom / minicss
css modules:webpack打包命名空間(略)
shadow dom:開啟shadow dom模式(API):box1.attachShadow(init:{mode:’open’}) , appendChild添加到div容器, 新版本瀏覽器中支持
minicss: webpack插件loader打包輸出不同的子應(yīng)用單獨css文件,子應(yīng)用切換時會清空css的引入link,后再加載新的
父子組件通信-- 兩種 props / customevent
props:依賴注入 - 主應(yīng)用信息在組件中暴露出來(store) 生命周期mounted中傳遞app實例的參數(shù)對象,registerApp方法注入到子應(yīng)用中,然后方法調(diào)用
customevent:事件監(jiān)聽on() window.addEventListener ,事件觸發(fā)emit() new CustomEvent() window.dispatchEvent() detail可傳遞參數(shù)
子應(yīng)用之間通信-- props通過父組件傳遞 / customevent
customevent : window.custom 先在一個子應(yīng)用監(jiān)聽test2 再在另一個子應(yīng)用監(jiān)聽到之后觸發(fā)
全局狀態(tài)管理-- 全局方法createSrore = (()=>{})() 接收初始數(shù)據(jù)initData為store ,其中定義一個方法getStore用于獲取store , 定義第二個方法update 用于將store更新再通知到所有訂閱者observers.forEach(async item=> await item(store,oldstore)),定義第三個方法 subscribe用于添加訂閱者,暴露createStore攜帶三個方法。 主應(yīng)用中獲取 createStore() 掛到window.store= store, 子應(yīng)用中獲取 const storeData=window.store.getStore(), 然后可在子應(yīng)用中進(jìn)行store更新 window.store.update(value:{...storeData, a:1})
提高加載性能:兩步緩存預(yù)加載
應(yīng)用緩存-- 根據(jù)子應(yīng)用name做緩存 psrseHtml=async (entry,name)=>{ 獲取所有script資源 cache[name]=[dom,script]}
預(yù)加載-- 定義prefetch()方法獲取其它子應(yīng)用列表然后使用await Promise.all() ,在start.js中start()方法尾部執(zhí)行prefetch(),這樣會將其它未展示的子應(yīng)用中的js資源保存到了當(dāng)前的緩存對象中cache{}。
柚子快報邀請碼778899分享:如何實現(xiàn)一個微前端框架
好文閱讀
本文內(nèi)容根據(jù)網(wǎng)絡(luò)資料整理,出于傳遞更多信息之目的,不代表金鑰匙跨境贊同其觀點和立場。
轉(zhuǎn)載請注明,如有侵權(quán),聯(lián)系刪除。