新消息丨从零开发——微前端框架实践
孙晓淳(笑鹅) 阿里开发者 2023-05-31 09:01 发表于浙江
阿里妹导读
我们对微前端框架的内容做了一个详细的介绍,并从零开始用Typescript实现了微前端的基本功能。
(资料图片仅供参考)
本文我们首先实现一个可进行子应用注册和资源加载的微前端框架,实现在一个vue3主应用中加载3个不同技术栈(vue2、react15、react16)的子应用,并且页面上渲染出各个子应用的内容;
然后,我们对该微前端框架实现扩展,实现
运行环境隔离(沙箱) css样式隔离 应用间通讯(含父子通信、子应用间通信) 全局状态管理(全局store的简单使用) 利用应用缓存和预加载子应用提高加载性能一、前置准备
再开发我们自己的微前端框架之前,我们需要做一定的架构设计准备。我们考虑微前端架构设计时的整体思路,并画出项目架构图。
1.1 微前端框架实现思路
采用路由分发式(本文使用的是hash模式) 主应用控制路由匹配和子应用加载,共享依赖加载 子应用做功能,并接入主应用实现主子控制和联动1.2 分析框架& 项目架构图
首先分析需求:
1.主应用功能:
a.注册子应用;
b.加载、渲染子应用
c.路由匹配(activeWhen, rules- 由框架判断)
d.获取数据(公共依赖,通过数据做鉴权处理)
e.通信(父子通信、子父通信)
2.子应用功能:
a.渲染
b.监听通信(主应用传递过来的数据)
3.微前端框架功能
a.子应用的注册
b.有开始内容(应用加载完成)
c.路由更新判断
d.匹配对应的子应用
e.加载子应用的内容
f.完成所有依赖项的执行
g.将子应用渲染在固定的容器内
h.公共事件的管理
i.异常的捕获和报错
j.全局的状态管理的内容
k.沙箱的隔离
l.通信机制
4.服务端的功能:提供数据服务
整体项目架构图如下:
二、开发微前端框架
本节我们开始开发主子应用并且实现微前端框架的基础功能
* 首先,我们实现主子应用的开发:按照我们的实际项目需求进行子应用的搭建和改造工作,每种子应用改造的方式大同小异;并且开发主应用,主应用起着整体的调度工作,按照对应的路由匹配规则渲染对应的子应用
* 然后我们实现微前端框架的基础功能,包括:应用注册、路由拦截、主应用生命周期添加、微前端生命周期添加、加载和解析html及js、渲染、执行脚本文件等内容。
2.1 准备子应用
技术选型
本文采用Vue3技术栈开发主应用,并准备了3个不同技术栈的子应用:
子应用: vue2子应用(实现home主页) React15子应用(博客页) React16子应用(照片页)项目目录结构
注:在开发微前端框架前,我们首先需要准备3个子应用,子应用的具体实现并非重点,完整代码可见:blog-website-mircroFE-demo:https://code.alibaba-inc.com/sunxiaochun.sxc/blog-website-mircroFE-demo,项目目录结构如下:
.├── main 主应用 ├── micro 微前端框架├── vue2 子应用├── react15 子应用├── react16 子应用└── README.md
子应用改造接入微前端
当我们有了几个子应用后,需要对其进行一些改造,从而使其能接入微前端。
对于Vue2/3子应用,为其添加vue.config.js ,配置关键点:设置devServer里的contentBase和headers允许跨域和output
// vue2/vue.config.jsconst packageName = "vue2";const port = 9004;// 设置端口号module.exports = { outputDir: "dist", // 打包的目录 assetsDir: "static", // 打包的静态资源 filenameHashing: true, // 打包出来的文件,会带有hash信息 publicPath: "http://localhost:9004", devServer: { contentBase: path.join(__dirname, "dist"), hot: false, disableHostCheck: true, port, headers: { "Access-Control-Allow-Origin": "*", // 本地服务的跨域内容 }, }, // 自定义webpack配置 configureWebpack: { output: { library: `${packageName}`,//设置包名,从而可以通过window.vue2获得子应用内容 libraryTarget: "umd", }, },};
对于react15/16子应用,改造webpack.config.js,注意修改webpack.config.js里的output和devServer
// react16/webpack.config.jsconst path = require("path")module.exports = { entry: { path: ["./index.js"] }, // +++ output: { path: path.resolve(__dirname, "dist"), filename: "react15.js", library: "react15", libraryTarget: "umd", umdNamedDefine: true, publicPath: "http://localhost:9002/" }, devServer: { // 子应用配置本地允许跨域 headers: { "Access-Control-Allow-Origin": "*" }, contentBase: path.join(__dirname, "dist"), compress: true, port: 9002, historyApiFallback: true, hot: true, }}
设置启动脚本
当新建好3个子应用后,我们的目录结构是这样的
.├── build 全局启动脚本├── main 主应用├── vue2 子应用├── react15 子应用├── react16 子应用├── package.json 定义start启动脚本└── README.md
以启动react16子应用为例, cd react16 &&yarn start 只能启动单个react子应用,我们需要配置一个命令来一次性启动所有子项目:
// package.json\"scripts\": { // 在根目录的package.json配置start命令 \"start\": \"node ./build/run.js\"}
// build/run.jsconst childProcess = require("child_process")const path = require("path");const filePath = { vue2:path.join(__dirname,"../vue2"), react15:path.join(__dirname,"../react15"), react16:path.join(__dirname,"../react16"),}// cd 子应用目录;npm start启动项目function runChild(){ Object.values(filePath).forEach(item =>{ childProcess.spawn(`cd ${item} && yarn start`,{stdio:\"inherit\",shell:true}); })}runChild();
这样,在根目录下执行 yarn start 即可一次性启动3个子应用
2.2 主应用开发
主应用负责所有子应用的卸载、更新和加载整个流程,主应用是链接子应用和微前端框架的工具。
构建主应用基本页面
主应用需要负责整体页面布局,使用vue3开发主应用main,其主体框架如下:
子应用内容
可以看到,主应用在页面中设置了一个子应用的容器 子应用内容,在这个区域中,我们展示不同的子应用内容。
在主应用中进行子应用注册
有了主应用之后,首先需求在主应用中注册子应用的信息。需要存储的子应用信息:
name :子应用唯一id activeRule :激活状态(即在哪些路由下渲染该子应用) container : 渲染容器(子应用要放在哪个容器里显示) entry 子应用的资源入口:(从哪里获取子应用的文件)通过主应用注册子应用:设置一个subNavList存储子应用信息的注册信息,本文的3个子应用的注册信息如下
// main/store/sub.tsexport interface IAppDTO { name:string; activeRule:string; container: string; entry:string;}export const subNavList: IAppDTO[] = [ { name:"react15", activeRule:"/react15", container:"#micro-container", entry:"//localhost:9002/", }, { name:"react16", activeRule:"/react16", container:"#micro-container", entry:"//localhost:9003/", }, { name:"vue2", activeRule:"/vue2", container:"#micro-container", entry:"//localhost:9004/", },]
然后编写微前端框架中注册子应用的方法,将子应用注册进微前端框架(这里为了简单直接挂载到window)里
// main/micro/start.ts/** 微前端框架提供的方法 */export const registerMicroApps = (appList:any[]) =>{ (window as any).appList = appList;}
最后在主应用中注册子应用
// main/src/main.jsimport { subNavList} from"./store/sub"import { registerApp } from "./util"/** 注册4个子应用,即只需要将传入的subNavList进行保存即可 */registerApp(subNavList)
2.3 实现路由拦截
有了子应用列表后,我们需要启动微前端,以便来渲染对应的子应用,本节实现的是: 主应用控制路由匹配和子应用加载
路由拦截方法
下面我们编写微前端中路由拦截方法,实现思路:
监听路由切换 & 重写路由切换事件pushState和replaceState 并且监听浏览器的前进/后退按钮window.onpopstate// main/micro/router/rewriteRouter// 给当前的路由跳转打补丁 globalEvent 原生事件 eventName 自定义事件名称export const patchRouter = (globalEvent,eventName) =>{ return function (){ // 1.创建新事件 const e = new Event(eventName); // 2.原生事件代码函数执行 globalEvent.apply(this,arguments);//this指向globalEvent // 3.触发刚创建好的事件 window.dispatchEvent(e); }}export const turnApp = ()=>{ console.log(\"路由切换了\");}// 重写window的路由跳转export const rewriteRouter = ()=>{ window.history.pushState = patchRouter(window.history.pushState,"micro_push"); window.history.replaceState = patchRouter(window.history.replaceState,"micro_replace"); window.addEventListener("micro_push",turnApp), window.addEventListener("micro_replace",turnApp) // 监听返回事件 window.onpopstate = function(){ turnApp();//路由切换防范 }}
在启动微前端框架时调用我们重写的路由监听方法
// main/micro/startimport {rewriteRouter} from "./router/rewriteRouter";rewriteRouter();// 实现路由拦截
根据路由查找子应用
子应用注册时保存了子应用列表的信息,对于当前路由,需要找到其对应的子应用。如下。我们编写一个根据当前路由获取子应用的通用方法
// main/micro/utils/index.js/** * 根据当前路由获取子应用(利用activeRule判断) */export const currentApp = ()=>{ const currentUrl = window.location.pathname; return filterApp("activeRule",currentUrl)}/** * 查找子应用的函数 */const filterApp = (key,value)=>{ const currentApp = getList().filter(item=>item[key] === value); return currentApp && currentApp.length ? currentApp[0] : {};}
然后,我们编写启动微前端框架的路由查找方法,实现当进入项目,对于首个展示的子应用,先验证当前子应用列表是否为空,不为空根据route匹配找到当前对应的子应用,如果子应用存在,则跳转到子应用对应的url。
// main/micro/start.tsimport { getList, setList } from \"./const/subApps\";import {rewriteRouter} from "./router/rewriteRouter";const { currentApp} = require("./utils/index.js")export const start = ()=>{ // 1.验证当前子应用列表是否为空 const apps = getList(); // 子应用列表为空 if(!apps.length){ throw Error("子应用列表为空,请正确注册") } // 有子应用的内容,查找到符合当前路由的子应用 const app = currentApp(); if(app){ const {pathname,hash} = window.location; const url = pathname+hash window.history.pushState("","",url); } (window as any).__CURRENT_SUB_APP__ = app.activeRule;}
2.4 生命周期
实现路由拦截后,对于如何挂载和卸载该路由下的子应用,就需要去实现一套生命周期。
子应用生命周期
子应用通常有下面三个生命周期:
1. bootstrap :开始加载应用,用于配置子应用的全局信息等
2. mount :应用进行挂载,用来渲染子应用
3. unmount :应用进行卸载,用于销毁子应用
这是一个协议接入,只要子应用实现了boostrap、mount和unmount这三个生命周期狗子,有这三个函数导出,我们的框架就可以知道如何加载这个子应用。
// /vu2/main.jslet instance = null;const render = () =>{ //Vue2子应用本身创建实例new Vue() // 在微前端框架中,这个实例的执行与销毁,都应该交给主应用去执行 instance = new Vue({ router, render: h =>h(App) }).$mount("#app-vue")}// 如果不在微前端环境下,直接执行if(!window.__MICRO_WEB__){ render();}// 以vue2子应用为例,定义其生命周期的内容,这里我们只是简单的打印一些内容export const bootstrap = () =>{ console.log(\"vue2子应用开始加载\");}export const mount = () =>{ render(); console.log(\"vue2子应用渲染成功\");}export const unmount = () =>{ console.log("vue2子应用卸载",instance);}
主应用生命周期
主应用的生命周期主要有三个:
1.beforeLoad:挂载子应用前,开始加载
2.mounted 挂载子应用后,渲染完成,
3.destoryed 卸载子应用卸载完成,
我们改写微前端框架提供的方法,加入生命周期的内容
// main/micro/start.tsexport interface ILifeCycle { beforeLoad?: any; mounted?: any; destroyed?: any;}export const registerMicroApps = (appList:any[],lifeCycle:ILifeCycle) =>{ // 1.设置子应用列表 setList(appList) // 2. 生命周期 lifeCycle.beforeLoad[0](); setTimeout(()=>{ lifeCycle.mounted[0](); },2000) setMainLifeCycle(lifeCycle);}
在主应用中利用微前端提供的注册微应用的方法registerMicroApps中设置主应用的生命周期,并通过主应用生命周期去控制子应用的内容显示
// main/src/utils/index.tsimport { registerMicroApps, start } from \"../../micro\"import { loading } from "../store"/** * 注册主应用 * @param list 子应用列表 */export const registerApp = (list: any[]) =>{ // 注册到微前端框架里 registerMicroApps(list,{ beforeLoad:[ ()=>{ loading.changeLoading(true); console.log(\"开始加载\"); } ], mounted:[ ()=>{ loading.changeLoading(false); console.log("渲染完成"); } ], destroy:[ ()=>{ console.log("卸载完成"); } ] }); // 启动微前端框架 start();}
微前端框架生命周期
微前端的生命周期:如果路由变化时,对子应用进行对应的销毁和加载操作
首先,编写微前端框架监听子应用是否做了切换的方法 isTurnChild 、以及根据路由获取子应用的方法 findAppByRoute
// micro/utils/index.js/** * 子应用是否做了切换 * */export const isTurnChild = () =>{ const {pathname} = window.location; let prefix = pathname.match(/(\/\w+)/) if (prefix) { prefix = prefix[0] } // 上一个子应用 window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__; if(window.__CURRENT_SUB_APP__ === prefix){ return false; } const currentApp = window.location.pathname.match(/(\/\w+)/); if(!currentApp){ return; } // 当前子应用 window.__CURRENT_SUB_APP__ = currentApp[0] ; return true}/** * 根据路由获取子应用 */export const findAppByRoute = (router)=>{ return filterApp("activeRule",router)}
其次,编写微前端框架的生命周期 lifeCycle 方法
实现思路是:微前端挂载到主应用,执行注册函数时,传入的就是主应用的生命周期,遍历执行这个主应用的所有生命周期,微前端框架就可以实现将子应用注册到主应用。
// main/micro/lifeCycle/index.tsimport { findAppByRoute } from \"../utils\";import { getMainLifeCycle } from "../const/mainLifeCycle"import {IAppDTO} from "../../src/interface/IAppDTO"import { loadHtml } from \"../loader\";export const lifecycle = async ()=>{ // 1.获取到上一个子应用 const prevApp = findAppByRoute((window as any).__ORIGIN_APP__); // 2.获取到要跳转的子应用 const nextApp = findAppByRoute((window as any).__CURRENT_SUB_APP__) // 没有下一个子应用,后续方法无需执行 if(!nextApp){ return; } // 如果有下一个子应用,卸载上一个子应用 if(prevApp && prevApp.unmount){ if(prevApp.proxy){ prevApp.proxy.inactive();//将沙箱销毁 } await destroyed(prevApp); } // 加载下一个子应用 const app = await beforeLoad(nextApp); // 渲染下一个子应用 await mounted(app);}// 微前端-加载子应用export const beforeLoad = async (app:IAppDTO) =>{ await runMainLifeCycle("beforeLoad") app && app.bootstrap && app.bootstrap(); const subApp = await loadHtml(app) // 获取的子应用的内容 subApp && subApp.bootstrap && subApp.bootstrap(); return subApp;}// 微前端-渲染子应用export const mounted = async (app:IAppDTO)=>{ app && app.mount && app.mount({ appInfo:app.appInfo, entry:app.entry }); await runMainLifeCycle("mounted");}// 微前端-卸载子应用export const destroyed = async(app:IAppDTO)=>{ app && app.unmount && app.unmount(); await runMainLifeCycle("destroyed")}// 对应的执行主应用的生命周期export const runMainLifeCycle = async(type:string) =>{ const mainlife = getMainLifeCycle(); // 等待所有生命周期执行完成 await Promise.all(mainlife[type].map(async item =>await item()));}
这样,当微前端框架监听到对应的子应用切换时,就执行微前端的生命周期
// micro/router/routerHandleconst {isTurnChild} = require("../utils/index.js")const { lifecycle } =require("../lifeCycle")export const turnApp = ()=>{ if(isTurnChild()){ // 路由切换的时候,微前端的生命周期执行 lifecycle(); }}
最终效果:当我们切换路由时(例如从vue2子应用切换到react15子应用时),控制台打印如下:
开始加载vue2子应用开始加载vue2渲染成功渲染完成vue2卸载卸载完成开始加载react15开始加载react15渲染成功渲染完成
2.5 获取需要展示的页面
为了展示子应用页面,首先需要做的:主应用获取子应用的生命周期,结构,方法,文件等。这样主应用才能控制子应用的渲染和加载。
主应用的生命周期有三个:beforeLoad开始加载、mounted 渲染完成、destoryed 卸载完成。实际上, 我们在主应用生命周期beforeLoad中去获取页面内容(加载资源):
子应用显示页面本质上是通过get请求(network中的doc)去获取页面信息 ,由此我们就可以在微前端框架中通过get请求去获取到子应用对应的页面,根据url通过fetch拿到页面内容,最后再将内容赋值给对应的容器就可以显示子应用对应的页面了, 但是直接赋值给容器,容器是没法 解析html中的标签 ,对于 link和script(src,js代码)元素 ,需要专门提取出来这些元素进行处理加载和解析html
因为所有的网站都是以HTML作为入口文件的,这份HTML中实际已经包含了子应用的所有信息(网页结构、js与css资源等),在微前端框架中,可以通过找到HTML中的静态资源并加载,从而渲染出对应的子应用。
// micro/loader/index.jsimport { IAppDTO } from \"../../src/interface/IAppDTO\";// 发送请求,调用原生fetch获取页面html的内容export const fetchResource = (url:string) =>{ // 会触发跨越限制,所以要对子应用的配置文件进行改造(见2.1.2节) return fetch(url).then(async res =>await res.text() )}// 加载html的方法(核心思路:根据子应用入口,找到其对应的html,并渲染至对应容器)export const loadHtml = async (app:IAppDTO) =>{ // 1.子应用需要显示在哪里 const container = app.container; //#id内容 // 2.子应用的入口 const entry = app.entry; // 3. 获取html const html = await parseHtml(entry) //4. 渲染至对应容器 const ct = document.querySelector(container); if(!ct){ throw new Error("容器不存在,请查看"); } ct.innerHTML = html; return app;}// 解析HTML的内容export const parseHtml = async (entry:string) =>{ const html = await fetchResource(entry); const div = document.createElement("div"); div.innerHTML = html; return html}
有了加载和解析子应用的HTML的方法,就需要在加载子应用的生命周期中进行调用
// micro/lifeCycle/index.js// 微前端-加载子应用export const beforeLoad = async (app:IAppDTO) =>{ await runMainLifeCycle("beforeLoad") app && app.bootstrap && app.bootstrap(); const subApp = await loadHtml(app) // 获取的子应用的内容并渲染至容器中 subApp && subApp.bootstrap && subApp.bootstrap(); return subApp;}
加载和执行js
上面我们只加载了HTML资源,但实际上子 应用除了dom资源外,还有script资源也需加载。
实现思路:在 getResource 方法中我们递归寻找元素,将 link 、 script 元素找出来并做对应的解析处理即可。对于dom资源,直接进行渲染,对于script资源有两种情况:
1.内部script脚本
2.外部scriptUrl链接资源
我们分别进行处理
// micro/loader/index.js/** * 解析子应用所有的资源 * @param entry 子应用入口 * @returns 返回子应用所有的dom和script资源 */export const parseHtml = async (entry:string) =>{ const html = await fetchResource(entry); let allScript = []; const div = document.createElement("div"); div.innerHTML = html; //html中包含标签、link、script const [dom,scriptUrl,script] = await getResource(div,entry); const fetchedScripts = await Promise.all(scriptUrl.map(async item =>fetchResource(item))); allScript = script.concat(fetchedScripts); return [dom,allScript]}/** * 获取对应的子应用的资源 * @param root 容器 * @param entry 入口 * @returns dom:子应用html scriptUrl:外链js script:js脚本 */export const getResource = async(root:any,entry:string)=>{ const scriptUrl:any[] = []; const script:any[] = []; const dom = root.outerHTML; // 深度解析 function deepParse(element:any){ const children = element.children; const parent = element.parent; // 1. 处理script中的内容 if(element.nodeName.toLowerCase() === "script"){ const src = element.getAttribute("src"); if(!src){ script.push(element.outerHTML);// script没有src属性,即没有外链其他的js资源,直接在script中书写的内容 }else{ if(src.startsWith("http")){ scriptUrl.push(src); }else{ scriptUrl.push(`http:${entry}/${src}`) } } if(parent){ parent.replaceChild(document.createComment("此js文件已经被微前端替换"),element) } } // link中也会有js的内容 if(element.nodeName.toLowerCase() === "link"){ const href = element.getAttribute("href"); if(href.endsWith(".js")){ if(href.startsWith("http")){ scriptUrl.push(href); }else{ scriptUrl.push(`http:${entry}/${href}`) } } } for(let i=0;i
结果如下:
congratulations!
到现在我们就实现了在一个vue3主应用中加载3个不同技术栈(vue2、react15、react16)的子应用,并且成功在页面上展示了出来!
三、微前端框架-辅助功能
上文我们已经解决了微前端中应用的加载和切换,本节我们给微前端添加其他辅助功能,例如:预加载、应用通讯、全局store等功能
3.1 运行环境隔离 - 沙箱
为了避免应用间发生冲突,不同子应用之间的运行环境应该进行隔离,防止全局变量的互相污染。沙箱有两种实现:快照沙箱 & 代理沙箱
快照沙箱
快照沙箱就是在应用沙箱挂载和卸载的时候记录快照,在应用切换时依据快照来恢复环境。
具体实现思路是:记录当前执行的子应用的变量,当子应用切换的时候,将变量置为初始值。
// main/micro/sandbox/snapShotSandbox// 快照沙箱,应用场景:比较老版本的浏览器export class SnapShotSandbox { constructor(){ // 1. 代理对象 this.proxy = window; this.active(); } // 沙箱激活 active(){ // 创建一个沙箱快照,用for循环window,new map一个新对象作为快照对象 this.snapshot = new Map(); // 遍历全局环境 for(const key in window){ this.snapshot[key] = window[key]; } } // 沙箱销毁 inactive(){ for(const key in window){ if(window[key]!== this.snapshot[key]){ // 还原操作 window[key] = this.snapshot[key]; } } }}
代理沙箱
代理沙箱是利用ES6的 proxy 去代理 window ,监听 window 的改变,是更现代化的沙箱方法。
具体实现思路:设置一个空对象 defaultValue 去储存子应用的变量
当 window 改变时,将改变值以 key , value 的形式存储到 defaultValue 中;当需要获取 window 属性值的时候,也在代理的 get 中去返回 defaultValue 对应的值 沙箱销毁的时候inactive也只需要将defaultValue置为{}// main/micro/sandbox/proxySandbox.js// 代理沙箱let defaultValue = {} // 子应用的沙箱容器export class ProxySandbox{ constructor(){ this.proxy = null; this.active(); } // 沙箱激活 active(){ //子应用需要设置属性 this.proxy = new Proxy(window,{ get(target, key) { if (typeof target[key] === "function") { return target[key].bind(target) } return defaultValue[key] || target[key] }, set(target,key,value){ defaultValue[key]=value; return true; } }) } // 沙箱销毁 inactive(){ defaultValue = {}; }}
3.2 CSS样式隔离
利用沙箱解决了JS之间的副作用冲突,接下来我们需要解决CSS之间的冲突,为CSS做样式隔离。
常用样式隔离方法有:
1. css modules :利用webpack配置打包机制进行css模块化处理,通过编译生成不冲突的选择器名
2. shadow dom :创建个新的元素进行包裹,但语法较新,兼容性较差,设置 shadow dom 的步骤:
a.设置mode 利用attachShadow得到shadow
b.为shadow dom添加内容
3. minicss (本框架选用) :一个webpack插件,该插件将css打包成单独的文件,然后页面通过link进行引用
module: { rules: [ { test: /\.(cs|scs)s$/, use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"] }, ] },
4.csss in js:将应用的css样式写在javaScript文件里,最终生成不冲突的选择器。
3.3 应用间通信
对于应用通讯,有两种实现方式:props和customevent
基于Props的方式
这里有一个父子通信的实例场景:子应用动态控制主应用nav的显示与隐藏。如下,在react16子应用中编写了login登陆页面,在该页面中不应显示导航条nav
实现如下:首先,子应用需要一个控制主应用中nav显示和隐藏的方法。所以我们在主应用的store中维护一个 navStatus 属性,表示导航条nav是否显示和隐藏至,暴露一个修改该 navStatus 属性的方法 changeNav
// main/src/store/nav.tsimport { ref } from "vue";// 控制导航nav显示的状态navStatusexport const navStatus = ref(true);// 更改navStatus显示的方法export const changeNav = (type:boolean) =>navStatus.value = type;
将该状态绑定至主应用导航条中,
然后,将主应用的所有store内容(包含navStatus)用Props的方式传递给所有子应用。
// 将主应用的store用props的方式传递给子应用import * as appInfo from "../store/index";export const subNavList: IAppDTO[] = [ { name:"vue2", activeRule:"/vue2", container:"#micro-container", entry:"//localhost:9004/", appInfo }, { name:"react15", activeRule:"/react15", container:"#micro-container", entry:"//localhost:9002/", appInfo, }, { name:"react16", activeRule:"/react16", container:"#micro-container", entry:"//localhost:9003/", appInfo },]
修改子应用的mounted生命周期方法,注册子应用时将含有 navStatus 属性的 appInfo 传递给子应用
// main/micro/lifeCycle/index.ts// 微前端-渲染子应用export const mounted = async (app:IAppDTO)=>{ app && app.mount && app.mount({ // ++ 给子应用传递appInfo appInfo:app.appInfo, // ++ entry:app.entry }); await runMainLifeCycle("mounted");}
最后,以react16子应用为例,在react16子应用中拿到主应用传递来的信息,并在子应用的渲染中隐藏nav。
// react16/index.jsexport const mount = (app) =>{ // ++ 利用主应用传递过来的方法changeNav来隐藏nav app.appInfo.header.changeNav(false); // +++ render(); console.log(\"react16渲染成功\");}
上述是一个主子应用通信的实例,对于子应用之间的通讯,也可以利用基于props的方式:子应用1 跟父应用交互,父应用再传递给子应用2。
基于CustomerEvent的方式
另一种应用间通讯的模型是挂一个事件总线,应用之间不直接相互交互,都去一个事件总线上注册和监听事件。实现思路:通过 new CustomEvent 对象去监听事件(on)和触发事件(emit),如下:
首先,利用 customEvent 创建 Custom 类
// main/micro/customevent/index.jsexport class Custom{ // 事件监听 on(name,cb){ window.addEventListener(name,(e)=>{ cb(e.detail) }) } // 事件触发 emit(name,data){ const event = new CustomEvent(name,{ detail:data }); window.dispatchEvent(event); }}
有了Custom类后, 我们在微前端框架中创建一个 custom 对象,为其添加事件监听方法 test ,并将其挂载到全局 window 上
// main/micro/start.tsconst { Custom } = require("./customevent/index.js");const custom = new Custom();custom.on("test",(data:any)=>{ console.log(\"监听到的数据:\",data);});window.custom = custom;
这样,在子应用里,我们就可以通过 window.custom 来获取 custom 对象。
3.4 全局状态管理 - 全局store
建立微前端中的全局状态管理基于发布订阅模式,通过主应用监听某个方法、子应用添加订阅者来监听到一些全局状态的改变。
具体实现思路是:利用 store 保存数据, observer 管理订阅者(subscribeStore方法用于添加订阅者),并提供获取和更新store的 getStore 和 updateStore 的方法。
// main/micro/store/index.tsexport const creatStore = (initData:{} = {}) =>(() =>{ // 利用闭包去保存传参的初始数据 let store = initData; // 管理所有的订阅者,依赖 const observers = []; // 获取store const getStore = () =>{ return store; } // 更新store const updateStore = (newValue) =>new Promise((res) =>{ if (newValue !== store) { // 执行保存store的操作 let oldValue = store; // 将store更新 store = newValue; res(store); // 通知所有的订阅者,监听store的变化 observers.forEach(fn =>fn(newValue, oldValue)); } }) // 添加订阅者,fn为方法 const subscribeStore = (fn) =>{ observers.push(fn); } // 整个store本质是一个闭包函数,把方法return出去 return { getStore, updateStore, subscribeStore }})()
有了全局状态,如何在主应用中引入即可。若要在子应用中也使用store,将store挂载到window上,子应用直接访问window.store即可,使用全局变量也是应用通讯的一种方式。
3.5 提高加载性能
应用缓存
当前,当切换不同的子应用时,都会重新加载页面,若页面资源非常多,加载会比较缓慢,所以需要在微前端框架中对应用进行缓存,提升加载性能。
应用缓存的实现思路是:
定义一个 cache 对象,根据子应用的 appName 来做缓存 如果当前子应用的html已经解析并且加载过,就返回已经加载过的内容。如果没有,则走正常加载和解析的流程//main/micro/loader/index.tsconst cache = {};// 根据子应用的name做缓存/** * 得到子应用所有的资源 * @param entry 子应用入口 name:appName */export const parseHtml = async (entry:string,name:string) =>{ // 如果命中应用缓存,直接返回缓存内容,没有则继续进行资源解析加载流程 if(cache[name]){ return cache[name]; } const html = await fetchResource(entry); let allScript = []; const div = document.createElement("div"); div.innerHTML = html; const [dom,scriptUrl,script] = await getResource(div,entry); const fetchedScripts = await Promise.all(scriptUrl.map(async item =>fetchResource(item))); allScript = script.concat(fetchedScripts); // 将该子应用的资源保存至缓存对象cache中 cache[name] = [dom,script]; return [dom,allScript]}
预加载子应用
提升加载性能的另一个思路是预加载子应用,即在启动微前端框架时,先获取当前要加载的子应用,再预加载剩下的所有的子应用。
// main/micro/loader/prefetch.tsimport { getList } from \"micro/const/subApps\";import { parseHtml } from "./index"export const prefetch = async() =>{ // 1. 获取到所有子应用列表 - 不包含当前正在显示的 const list = getList().filter(item =>window.location.pathname.startsWith(item.activeRule)); // 2. 预加载剩下的子应用 Promise.all(list.map(async item =>await parseHtml(item.entry,item.name)));}
这样,在做子应用切换时,避免一定的延迟感~
四、写在最后
在之前的内容中,我们对微前端框架的内容做了一个详细的介绍,并从零开始用Typescript实现了微前端的基本功能,对这样的一个简易微前端框架,仍有可以扩展和继续学习的地方。例如
如何实现微前端框架的自动发布?如何通过npm来发布我们编写的框架?能否创建一个自动部署平台来实现应用的自动化部署? 现有热门的微前端框架(qiankun、single-spa、icestark)是如何实现微前端框架的应用注册&加载、沙箱隔离、全局状态管理、预加载这些功能?参考资料:
从零到一实现企业级微前端框架,保姆级教学:https://juejin.cn/post/7004661323124441102 微前端连载 6/7:微前端框架 - qiankun 大法好:https://juejin.cn/post/6846687602439897101 从零打造微前端框架:实战“汽车资讯平台”项目:https://coding.imooc.com/class/chapter/520.html#Anchor关键词:
- 新消息丨从零开发——微前端框架实践
- Vietnam's produce exports up 29% with China|每日速读
- 这些小众的良心工具,90%的人都没用过! 全球热点
- 拜登出席空军学院毕业典礼又谈中国,此后摔倒成焦点
- 世界最新:蔻蔻椰创始人好大心:未来十年,我们只做椰子水这一件事
- 南京土木工程师解读《史记》
- 世界今日讯!录播又如何?苹果全球首播不带货 130万人围观:网友点赞 最佛系态度卖最多货
- 代客泊车功能什么意思(代客泊车和自动泊车区别)|每日观察
- 焦点热讯:中国石油再获展品创新金奖
- 年内千余家公司发布减持预案 多只千亿大市值股遭减持
-
新资讯:恋母情结是什么意思_什么是恋母情结
欢迎观看本篇文章,小升来为大家解答以上问题。恋母情结是什么意思,什么是恋母情结很多人还不知道,现在让
-
环球速看:河北大学什么时候放暑假 大学什么时候放暑假
1、大学暑假一般7月10号左右就放假了,具体到地方、学校、专业略有差异。2、大学假期要好好的给自己规划一
-
天天报道:香港有线电视终止收费电视服务
香港有线电视收费广播牌照6月1日零时正式终止,开播近30年的香港有线电视收费服务告别观众。香港有线
-
美化乡村环境从治理生活污水开始_当前热门
近日,据湛江日报报道,吴川农村生活污水处理设施建设有序进行,该市农村生活污水治理率50 43%,已完成2022
-
快播:新学期新梦想作文450字_新学期新梦想作文
1、新学期新梦想作文新的开始承载新的希望,新的开始放飞新的梦想,新的开始充满新的自信,新的开始积蓄新
-
乳化剂-10商品报价动态(2023-06-01)|今日热搜
交易商品牌 产地交货地最新报价乳化剂-10 NP-10 TX-10215KG 桶山东锦礼化工有限公司凌飞 三江山东省 济南
-
解构主义建筑代表人物_解构主义|全球简讯
1、什么叫 "解构 "呢? "解构 "这个词,单从字面上理解,一个 "解 "字意为 "解开、分解、拆卸 "; "构 "字
-
离线下载是什么东西_离线下载是什么意思
迅雷是目前使用最广泛的下载工具之一。它的功能非常强大,待机状态下也可以用迅雷下载。迅雷离线下载是什么
-
2022年教师节主题是什么(教师节是什么时间)-焦点速讯
诸多的对于2022年教师节主题是什么,教师节是什么时间这个问题都颇为感兴趣的,为大家梳理了下,一
-
世界今头条!天津市第五次全国经济普查综合试点在南开区启动
津云新闻讯:6月1日上午,天津市第五次全国经济普查综合试点启动仪式在南开区熙悦汇购物中心举行,为天津市
X 关闭
北京试点全龄友好住宅项目 老年家庭购房首付最低35%
西安新增本土确诊病例150例 详情发布
广东最低气温跌至-6℃现冰挂 部分道路及海上交通受影响
“2022科学跨年系列活动”启动 提高公众对科学类流言“免疫力”
珠科院多举措助力大湾区抗旱防咸保供水
X 关闭
得知西安疫情防控“升级” 男子夜骑共享单车回咸阳淳化
中国医生将任SIU主席背后:从追随者同行者到引领者
海南省通报政法队伍教育整顿成果
云南两地发现核酸阳性人员 西安实行最严格的社会面管控
广东梅州大埔中央红色交通线沿线发现多株百岁古树