微前端
微前端入门🍔
1. 啥是微前端?🥰
微前端这个 idea 其实和router一样,也是从后端那里偷过来的。
偷师的东西你们可能也听说过,就是大名鼎鼎的微服务(micro web service)
那啥是微服务嘞?简单来说,就是把原来庞大的系统,拆成一个个细小的服务,放在分布式系统上边。
举个🌰的话,就相当于你把一整块大列巴切开,然后放在不同的柜子里(大列巴好吃爱吃🥰)
这么说是不是有点概念了,那好,微服务介绍好了,现在就看看咱们的微前端是咋个事咯:
- 前边说了这么多,那一想肯定是得把工程拆开咯,现在一般是拆成两个大块:
- 主应用:负责管理各个子应用的
生命周期,注册 - 子应用:具体实现各个页面
- 主应用:负责管理各个子应用的
在主应用里,可以看到,要干的事情和router是极其类似的,实际上,个人感觉微前端就是router-pro-max,只不过要考虑的方面更加多更加全面,待会再细说
2. 那为啥要用微前端嘞,写一块不好吗?😶
还是那个🌰,想一下,你把一块大列巴切开了放可能带来的好处:
- 每个柜子的压力会变小
- 如果有一块大列巴坏了,其他的也不会受影响
- 如果有天想换个口味,那把其中一块大列巴换成土豆,也很方便呀
那就一条一条来看咯:
- 把不同页面放到不同服务器上,单个服务器的压力肯定会减小(虽然主要压力不在这)
- 如果一个页面出现了问题,其他的页面不受影响,不会像传统单体那样
一挂全挂 - 各个页面独立,出了问题维护很方便
- 由于各个页面独立,因此项目可以选用不同的技术栈,比如
登录用react写,发帖用vue写;不仅如此,还可以根据需求,灵活选择ssr还是csr,这真的很酷🐶
但是有优点,就会伴随着难点和缺点
- 不同页面如何通信?
- 每个页面都有一套依赖库,性能如何优化?如何共享依赖库?
- 如何确定应用边界?
- ……
但是这些,今天都不讲😝
主要原因是我不会,次要原因是太复杂了,讲不完(或许主要原因是这个😙)
3. 目前微前端实现🤩
微前端的实现方案有挺多,比如说:
- qiankun,自己实现 JS 及样式隔离
- icestark,iframe 方案,浏览器原生隔离,但存在一些问题
- emp,Webpack 5 Module Federation(联邦模块)方案
- WebComponent 等方案
但是这么多实现方案解决的场景问题还是分为两类:
- 单实例:当前页面只存在一个子应用,一般使用 qiankun 就行
- 多实例:当前页面存在多个子应用,可以使用浏览器原生隔离方案,比如 iframe 或者 WebComponent 这些
当然了,并不是说单实例只能用 qiankun,浏览器原生隔离方案也是可行的,只要你接受它们带来的不足就行:
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
上述内容摘自Why Not Iframe。
本文的实现方案和 qiankun 一致,但是其中涉及到的功能及原理方面的东西都是通用的,你换个实现方案也需要这些。
4. 话不多说,上手吧🥱
讲讲思路先:
不同页面在不同服务器上,我们通过
主应用进入程序,那接下来访问子页面其实要干的事情就是向子应用的服务器发个请求,拿到它的html\css\js等文件,插入进来执行没别的,简单粗暴吧,开干咯
🤗首先,得有个主应用吧
随随便便创一个得了,跑在5173这个默认口上。
再随随便便创一个子应用,(我这里子应用还是上次的todo-list),跑在10086端口
🤔然后,就要动脑子啦,主页面该如何配置子页面呢?
咱们首先看看子页面需要什么功能咯,记得之前说过和router很相似吗,那就看看router有什么功能咯:
1 | |
可以看到有: path, 和 component两个prop,
那在现在的话,就相当于path和子页面服务器路径咯
其实叫
path有失偏颇,它其实是一个匹配规则,而不是简单的path所以我接下来采用
activateRule代替path
这就完了?怎么可能!
挂载页面挂载到哪里还没考虑呢!所以还得加一个container属性存放挂载位置,
那现在我们就有了一个基本的子页面配置:
1 | |
这就完了?怎么可能!
开动小脑瓜想想,除了router本身的特性,component也有自己的闪光点:生命周期,页面也是需要生命周期的,而且还要分为
主体生命周期:组件挂载前、挂载完毕、卸载
1
2
3
4
5
6
7
8// /types/type.ts
export type MainLifeCycle = {
beforeLoad?: LifeCycleFunc | LifeCycleFunc[],
mounted?: LifeCycleFunc | LifeCycleFunc[],
unmounted?: LifeCycleFunc | LifeCycleFunc[]
}
export type LifeCycleFunc = (app: AppInfo) => Promise<any>单独页面声明周期:这个就多了,想要几个要几个,这里图方便,写了三个,跟子页面其他属性混一起得了
1
2
3
4
5
6
7
8// /types/type.ts
export interface ChildAppInfo extends AppInfo {
status: AppStatus,
bootstrap?: LifeCycleFunc, //构建中
mount?: LifeCycleFunc, // 挂载完毕
unmount?: LifeCycleFunc, // 卸载
}这个
status就是组件的详细状态咯,初始值为AppStatus.NOT_LOADED1
2
3
4
5
6
7
8
9
10
11// /types/enum.ts
export enum AppStatus {
NOT_LOADED = "NOT_LOADED",
LOADING = "LOADING",
LOADED = "LOADED",
BOOTSTRAPPING = "BOOTSTRAPPING",
NOT_MOUNTED = "NOT_MOUNTED", // bootstarp过后还未mount
MOUNTING = "MOUNTING",
MOUNTED = "MOUNTED",
UNMOUNTING = "UNMOUNTING",
}
每次挂载、卸载页面的时候,都要分别调用子页面和主体的的声明周期函数
为此,封装了几个生命周期的执行函数:
1 | |
看似很长,其实就是改变子页面的状态,并执行相应的回调函数,再调用主体声明周期
注意:
beforeLoad这个生命周期,是在页面挂载之前执行的,所以beforeLoad执行比页面本身生命周期还要前边,注意执行顺序
相信大家也注意到那个loadHTML了,那就先来讲讲怎么loadHTML吧😏
其实相当简单,直接向服务器发个fetch就行了,
1 | |
1 | |
这就简单完成了一个fetch拿回HTML文件的功能咯,
那这个函数先放在这里好了,之后再给它丰富功能,比如解析执行js什么的
别忘了解决跨域!!!
写完这么多函数,总得调用一下吧,但是先别急,先把页面啥的挂上去🤨
简简单单写一个appList,把子页面的配置信息都写里边,到时候forEach就好咯,注意要设置status默认值!
1 | |
然后再写一个注册页面的函数:
1 | |
这个函数跟大家想象的不太一样喔,他要在主页面里调用,也就是咱们的react文件中
1 | |
到现在为止,咱们已经配置好咱们的子页面啦,现在跑跑看咯!当然什么都没有啦!页面还没挂上呢,声明周期也没加,文件也没获取,还有好长的路要走😣
考虑一个问题,如何在appList中挑出要渲染的页面嘞?🥲
捋捋思路:
咱们的子页面配置里不是有actiavteRule这一项吗,就用它!
把appList遍历一遍,如果activateRule匹配当前路径的话,要做点什么;如果不匹配的话,可能也要做点什么(废话)
仔细想想咯,当切换页面的时候,比如说从
/切换到/todo,是不是要把原来的页面先unmount,再去mount新页面;那如何判断哪个页面是当前页面呢?或者说,如何匹配当前页面呢?欸,
react-router-dom使用了path-to-regexp这个库,看了下,相当好用,感兴趣的可以看看,但是我们现在只需要知道它的一个调用就行了:
再加上
location.href,我们很容易能写出
1const isActive = match(app.activeRule, { end: false })(location.pathname)判断是否
active,再根据页面当前状态,判断要做什么操作,是挂载还是卸载
1 | |
通过这个工具函数,我们就得到了actives 和unmounts这俩数组,之后对unmounts数组以此unmount,对actives依次执行一套声明周期就完事咯,用伪代码大概是这个样子,到时候把这个封装进函数就行咯
1 | |
别看现在形式一片大好,其实真正的难点还藏着呢🙄路由劫持来咯
其实这个难点就是最基础的–如何根据url加载和切换页面?
嘿嘿,这就是另一个知识点咯
想想
React-Router-Dom中是不是有BrowserRouter和HashRouter,其实他俩就对应着 JS 中 的HashRouter和History,二者有些许差别。老web通常是HashRouter,新web基本都是History。二者api也有差异,我就不自己写了,以下内容摘自:https://juejin.cn/post/7044530785746944014
reactRouter实现路由的两种模式
hash模式(对应HashRouter)
www.test.com/#/name就是Hash URL,当#后面的哈希值发生变化时,不会向服务器请求数据,可以通过hashchange事件来监听到URL的变化,从而进行跳转页面。browser模式(对应BrowserRouter)
History模式使用 HTML5 提供的 history API(pushState、replaceState 和 popstate 事件)来保持 UI 和 URL 的同步
两种实现方式的区别是什么?
兼容性问题
一看到browser模式是html5新推出的功能,就可以知道区别主要是
兼容性问题,browser模式主要是在高版本浏览器使用的,hash模式主要是在老版本浏览器使用的,写法的不同
BrowserRouter 创建的 URL 格式:xxx.com/path
HashRouter 创建的 URL 格式:xxx.com/#/path
history中新增的方法
pushState(state,title,url)
该方法的作用是 在历史记录中新增一条记录,改变浏览器地址栏的url,但是,不刷新页面。
pushState对象接受三个参数,
- state:一个与添加的记录相关联的状态对象,主要用于popstate事件。该事件触发时,该对象会传入回调函数。也就是说,浏览器会将这个对象序列化以后保留在本地,重新载入这个页面的时候,可以拿到这个对象。如果不需要这个对象,此处可以填null。
- title:新页面的标题。但是,现在所有浏览器都忽视这个参数,所以这里可以填空字符串。
- url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。
举个例子,假设当前网址是hello.com/1.html,使用pushState()方法在浏览记录中添加一个新纪录
1 | |
添加新纪录后,浏览器的地址栏立刻显示`hello.com/2.html,但不会跳转到2.html,也不会检查2.html是否存在,它只是成为浏览历史中的最新记录。
总之,pushState()方法不会触发页面刷新,只是导致history对象发生变化,地址栏会有反应,使用该方法后,就可以使用history.state属性读出状态对象
1 | |
注意:如果pushState的URL参数设置了一个新的hash值,并不会触发hashchange事件。
replaceState(state,title,url)
replaceState方法的作用是替换当前的历史记录,其他的都与pushState()方法一模一样。
假定当前网页是example.com/example.html。
1 | |
popstate事件
popstate事件是window对象上的事件,配合pushState()和replaceState()方法使用。当同一个文档(可以理解为同一个网页,不能跳转,跳转了就不是同一个网页了)的浏览历史出现变化时,就会触发popstate事件。
上面我们说过,调用pushState()或者replaceState()方法都会改变当前的历史记录,仅仅调用pushState()方法或replaceState()方法 ,并不会触发该事件,另外一个条件是用户必须点击浏览器的倒退按钮或者前进按钮,或者使用js调用history.back()或者history.forward()等方法。
所以,记住popstate事件触发的条件
1 | |
只要符合这两个条件,popstate事件就会触发
具体例子
1 | |
先点击pushState按钮,在点击后退按钮,就会触发popstate事件
再来一个例子
1 | |
直接点击a标签,也可以触发popstate事件
OK,我来总结一下,老版
hashRouter只拥有hashChange这一个监听,而在history中,把这个监听拆成了两部分:
(push | replace)State:负责切换路由,但不重新渲染popState: 触发事件所以我们要干的,就是让我们
pushState的时候,触发popState或者hashChange,调用我们自己的函数: 把子页面拉过来,也就是重写这几个函数,他有个响亮的名字: 路由劫持
那既然要重写listener,那肯定就都得自己维护一个队列咯,大概就长这样:
1 | |
那每次pushState或者replaceState就得触发popState,就要监听popState往这加东西咯,
1 | |
因为要重新监听hashChange和popState,我们重新定义EventListener,让这俩事件触发时,往listendRouterChange里加点料
1 | |
然后,pushState的时候要挂载页面,可以看到,这里用的是reroute这个函数,记得之前的页面挂载函数吗,实现一下咯
1 | |
最后,来看看文件加载吧,快结束咯😏
首先的首先,就是跨域问题
1 | |
之后又要动脑咯,资源怎么加载嘞?
首先,咱们上边提到的的html文件中肯定有引入的文件需要加载,可能是图片,也可能是css、 js,这里就要做一个判断:
1 | |
对于
link文件,它可能是外部引入,也可能是本地引用对于本地引用:要把相对路径转为绝对路径
对于外部引入:不变
就这样⬇️
1
2
3
4
5
6
7
8
9
10
11
12
13
14const parseLink = (
link: HTMLElement,
parent: HTMLElement,
app: ChildAppInfo
) => {
const rel = link.getAttribute('rel')
const href = link.getAttribute('href')
if (rel === 'stylesheet' && href) {
parent.appendChild(link)
return getCompletionURL(href, app.entry)
} else if (href) {
link.setAttribute('href', getCompletionURL(href, app.entry)!)
}
}1
2
3
4
5
6
7
8
9
10
11
12// /utils/concatUrl.ts
export function getCompletionURL(src: string | null, baseURI: string) {
if (!src) return src
// 如果 URL 已经是协议开头就直接返回
if (/^(https|http)/.test(src)) return src
// 通过原生方法拼接 URL
return new URL(src, getCompletionBaseURL(baseURI)).toString()
}
export function getCompletionBaseURL(url: string) {
return url.startsWith('//') ? `${location.protocol}${url}` : url
}
对于
script文件,也有两种情况,外部引入的话还要额外fetch一次,这里用url表示外联,text如果有那就是内联1
2
3
4
5
6
7
8
9const parseScript = (
script: HTMLElement,
parent: HTMLElement,
app: ChildAppInfo
) => {
const src = script.getAttribute('src')
parent.appendChild(script)
return { url: getCompletionURL(src, app.entry), text: script.innerHTML }
}
合并起来就是这样咯:
1 | |
但是,前端就是轮子多,这些其实都不用自己写的,有个import-html-entry库可以直接用,嘎嘎爽
1 | |
文件加载完,就要运行js咯,这回是真快了!😝
1 | |
本来打算这么跑的,但是没跑成,晚上回去看看咋个事,反正就是把js拿出来执行,就这个意思
