手写一个微前端框架-源码实现-上(简单易懂)

230 阅读12分钟

今年主要的工作内容就是负责搭建开发一个微前端工程。项目已经进行大半年了,一直想找个时间好好研究下微前端框架的实现。终于在2022年末找了个时间完成了。

本文脉络:微前端优点 -> 框架雏形思考 -> 代码实现[300+行]

微前端架构解决的问题

  • 解构巨石应用
  • 不受技术栈限制
  • 通用型业务抽成子工程,方便复用

巨石应用的问题:

  • 编译慢 [影响编译打包速度,影响开发效率]
  • 代码冲突频繁
  • 更新一点内容,就要重新部署整个巨石应用

微前端雏形思考

微前端体系中涉及到的:主工程、子工程、微前端框架。那么他们的职责大概有哪些呢?我们先简单梳理下:

主应用:

微前端这个依赖应该是安装到主工程里的。那么主工程里大概完成:

  • 注册子应用
  • 路由匹配【原生路由方法的改写】
  • 加载、渲染子应用
  • 鉴权
  • 通信【父子、子父】

主应用 应该能拿到子应用的生命周期、方法。这样才能控制子应用的加载、卸载。

子应用:

  • 渲染
  • 监听通信【主传递来的消息】

通过以上思考,子应用要经历的改造:

1. 子应用要允许跨域。这样父应用才能拿到子应用代码。

vue.config.js:

// 这是项目在本地运行时候,node服的跨域配置。部署到生产,让运维人员配置下的工程允许跨域即可。
devServer:{header:{'Access-Control-Allow-Origin':'*'}};

2. 打包格式配成:umd的,common.js浏览器环境、node环境均可。

vue.config.js:

configWebpack:{output:{libraryTarget:'umd '}}

3. 改写main.js,写三个生命周期供外部调用。把render函数放到mount下执行调用。实例卸载放到unmount下执行调用。

微前端框架

框架是中央处理器,框架辅佐主应用完成整个应用集合的架构工作。

  • 子应用的注册
  • 路由匹配切换对应子工程
  • 生命周期的处理[主子的]
  • 请求获取html
  • 请求获取js插入到html中
  • 异常的捕获与报错【一个框架很重要的部分】
  • 公共事件的管理
  • 全局的状态管理内容
  • 沙箱的隔离
  • 通信机制
  • ...

经过以上盘点后,我们可以着手一步步去落地实现了!

具体实现

一、注册子应用

注册,说直白,就是找个地方存起来,方便全局调度使用。所以它不应该在主应用层,而是在框架里完成。

做一个start方法,开启微前端框架工作机制。

核心逻辑

//start.js
import {setList} from './const/subApps.js'
export const registerMicroApps =(subAppList)=>{
	setList(subAppList)//这里的存储策略你也可以使用window.subAppList = subAppList. 但是不推荐。
}

//const/subApps.js
let arr = []

export const getList = ()=> arr //读取数组内存区域
export const setList = appList => arr = appList //写入数组区域


//index.js

registerMicroApps(yourSubAppList)

注册表

yourSubAppList需要的核心字段:

  • name: 必须唯一,作为应用id
  • entry:应用的服务器地址,告知主去哪里fetch拿自己的资源
  • container:容器、主拿完自己后挂到基座的哪个dom 节点容器里
  • activeRule:激活路由。地址栏出现什么样的关键字才匹配到这个子应用中来
yourSubAppList = [
  {
    name: 'crm',
    entry: '//localhost:9002/',
    container: '#crm',
    activeRule: '/crm'
  }]

二、路由匹配

劫持改写window.history 对象下的两个API来完成。

HTML5新增的API:

  • history.pushState()和history.replaceState()两个方法可以无刷新、增 改地址栏,注意他不会去加载这个url。
  • window.onpopstate 事件 可以监听前进后退的点击。
  • history.state获取当前所在页面的state对象,也即是通过push 和 replace操作后保存的历史记录。

给当前的路由跳转打上补丁代码:

router/rewriteRouter.js。此方法会在start.js 里进行调用的。

//重写这两个跳转方法,由于是方法,即可以调用,那么就是说返回肯定是一个fn才行
export const rewriteRouter = () => {
	window.history.pushState = function (){
		return function(){}
	}
	window.history.replaceState = function (){
		return function(){}
	}
}

由于两个方法功能类似,所以可以进一步抽取封装 utils/index.js

export const patchRouter = (globalEvent,evetName) => {
 return function(){
 	const e = new Event(eventName) 
 	globalEvent.apply(this,argument)//这里不能是箭头函数,this会不对。继承父函数的this运行时环境
 	window.dispacthEvent(e)
 }
}

那么router/rewriteRouter.js

import {turnApp} from './routerChange.js'

export const rewriteRouter = () => {
	window.history.pushState = patchRouter(window.history.pushState,'selfPushState')
	window.history.replaceState = patchRouter(window.histoty.repaceState,'selfReplaceState')

	//加一下事件监听,进而看到我们打过补丁的方法是什么效果
	window.addEventListener('selfPushState',turnApp)
	window.addEventListener('selfReplaceState',turnApp)

	//点击前进后退也要加执行turnApp操作
	window.onpopstate = function (){turnApp()}
}

routerChange.js里的turnApp方法:

export const turnApp = () => {
	console.log("检测到路由发生变动,你是调用了pushState/replaceState方法或触发onpopstate 事件之一")
}

至此,你已经可以在微前端的框架里实现了对路由的监控与拦截。拦下来后可以把一些逻辑加到turnApp这个方法里去执行了。后边会说到应用的各种钩子执行是在这里完成的。

三、获取首个子应用

获取首个子应用信息。

调用时机:在完成注册后调用start。

核心逻辑:根据当前地址栏信息到注册表里匹配 当前是哪个子应用,然后跳转过去。

start方法:

export const start = () => {
	// 判空,注册表为空直接抛出错误,阻断执行。
	const appList = getList()
	if(!appList.length){
		throw Error('子应用的注册表为空,请正确注册')//throw 会阻断后边的代码执行
	}


	// 不为空时,进一步查找跟当前地址栏匹配上的子应用是哪一个
	const curApp = findCurrentApp()

	// 匹配上后,调用改写好的pushState跳过去,同时打个全局标记__CURRENT_SUB_APP__方便别的地方使用。ex:子应用是否切换了。
	if(curApp){
		const {pathname, hash} = window.location//es6 的解构赋值
		const url = pathname + hash

		window.history.pushState(url)//自己打过补丁的pushState

		window.__CURRENT_SUB_APP__ = curApp.activeRule //当前命中的子应用是哪个
	}
}

utils.js。 findCurrentApp:工具函数


export const findCurrentApp = ()=>{
	const curUrl = window.location.pathname
	return filterApp('activeRule'.curUrl)
}

const findCurrentApp = (key,value)=>{
	const curApp = getList().find(item => item[key] === value)// filter 找到所有符合条件的项,所以返回时数组,find找到第一个符合条件的项,返回的是那个项

	return curApp ? curApp : {}
}

扩展说明: start方法里做的全局标记在turnApp这里是有使用到的

export const turnApp = () => {
	if(isTurnChildApp()){
		console.log("检测到子应用切换走了,你想此时做点啥事,埋入进来")
	}
}

const isTurnChildApp = ()=> {
	if(window.__CURRENT_SUB_APP__ === window.location.pathname){
		return false 
	}
	return true
}

四、设置主应用的生命周期

在注册子应用的时候就需要配置好主应用的钩子函数。也就是在registerMicroApps这个方法再扩展一个参数对象lifeCycle。

每一个生命周期的内容不限于只传递一个callback 函数,所以用一个数组存放。

改写registerMicroApps:

export const registerMicroApps = (appList,lifeCycle) => {
	setList(appList)
	setMainLifeCycle(lifeCycle)//效仿注册表信息,这里把生命周期以及回调都在内存里缓存起来
	
}

//const/mainLifeCycle.js

let lifeCycle = {} //要一块区域,存放生命周期的内容
export const getMainLifeCycle = () => lifeCycle
const setMainLifeCycle = cofig => lifeCycle = config

应用层主应用的main.js里,可以传入各个生命周期

registerMicroApps(subAppList,{
        beforeLoad:[
                ()=>{console.log('开始加载,你可以加个loading')}
        ],
        mounted:[
                ()=>{console.log('渲染完成,你可以把loading关闭')}
        ],
        destoryed:[
                ()=>{console.log('卸载完成')}
        ],
})

五、框架层统筹主子生命周期的工作先后

首先选择定位好钩子执行的合适时机,显然在切换应用时候也就是turnApp内部完成。我们构建一个方法lifeCycle,由于钩子里是可能存在异步操作的,所以我们得支持到位。

//原来的 turnApp
export const turnApp = () => {
	if(isTurnChildApp()){
		console.log("检测到子应用切换走了,你想此时做点啥事,埋入进来")
	}
}
//改造后 turnApp
export const turnApp =async () => {
	if(isTurnChildApp()){
		//框架层整体的声明周期统筹方法执行
		await lifeCycle()
	}
}

5.1 lifeCycle方法细节:

核心:首先要拿到切换前后的子应用,进而让旧的应用执行卸载钩子、新的应用执行挂载渲染钩子。主子工程都会有。

注意:

  1. 三个钩子都在框架级又封装了一下,里面包含着主、子对应的钩子。至于先执行主,还是子,经考虑:在初始化加载阶段是先主后子,在渲染和卸载时是先子后主。跟vue的父子组件的钩子执行异曲同工,也是很容易理解的。

  2. 由于每个钩子里用户都有可能执行些异步的操作,所以你会频繁看到async/await。

  3. 主应用搭载上微前端框架后,作为整个应用集合的cpu,承担东西比较多,我们把钩子设计成了数组。而功能职责相对单一的业务型子应用,钩子我们只支持一个就好。

  4. 至于怎么获取上一个,下一个子应用。我们利用之前讲过的全局标识__CURRENT_SUB_APP__来进一步处理。改造下isTurnChildApp方法。

import {findAppByRoute} from '../utils'
import {getMainLifeCycle} from '../const/mainLifeCycle'

export const lifeCycle =async () => {
	//获取到上一个子应用
	const prevApp = findAppByRoute(window.__ORIGIN__APP__)
	//获取到下一个子应用
	const nextApp = findAppByRoute(window.__CURRENT_SUB_APP__)

	//下一个不存在,那就不跳转,什么也不做,即直接return
	if(!nextApp)return;

	//上一个存在且有传销毁钩子,那就执行下销毁这个生命周期。
	if(prevApp && prevApp.destoryed){
		await destoryed()
	}
	//下一个存在,就执行下beforeLoad这个框架级的加载钩子。
	const app =await beforeLoad(nextApp)

	//加载完成后,我们把加载上来的app执行下mounted挂载渲染
	await mounted(app)
}

// 框架级里封装下三个钩子

export const beforeLoad = async (app) => {
	await runMainLifeCycle('beforeLoad')
	app && app.beforeLoad && app.beforeLoad()
	const appContext = null//给后续的加载解析预留的
	return appContext
}

export const mounted = async (app) => {
	app && app.mounted && app.mounted()
	await runMainLifeCycle('mounted')
}

export const destoryed = async (app) =>{
	app && app.destoryed && app.destoryed()
	await runMainLifeCycle('destoryed')
}

// 主应用的钩子执行,我们单独封起来,由于是数组,外加可能有异步,要想统一都执行完可以用promise.all(arr)包裹

export const runMainLifeCycle = async (lifeName) => {
	cosnt mainLifes = getMainLifeCycle()

	<!-- step1 简单执行-->
	mainLifes[lifeName].map( fn => fn()); //=>等同 .map((fn)=>{return fn()}),这里返回的也是一个数组,后续正好传递给promis.all方法

	<!-- step2 但要考虑里面有异步,给fn加异步-->
	mainLifes[lifeName].map(async fn => await fn())

	<!-- step3 但要考虑数组里的回调都执行完成,加all-->
	Promise.all(mainLifes[lifeName].map(async fn => await fn()))

	<!-- step4 要等promise都执行完,给promise加异步 -->
	await Promise.all(mainLifes[lifeName].map(async fn => await fn()))//经过推演,这一句是终极版本


}

5.2 其他相关改造:

5.2.1 改造isTurnChildApp:

export const isTurnChildApp = () =>{
	window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__//在判断之前先备份下当前app
	if(window.__CURRENT_SUB_APP__ === window.location.pathname){//如果相同,这两个标识里存的就是同一个app的activeRule。就放回false,告诉没切换App
		return false
	}

	window.__CURRENT_SUB_APP__ = window.location.pathname//如果不同,就把下一个app赋值给__CURRENT_SUB_APP__,返回true,告诉切换了app
	return true;
}

注意:实际调试发现,基座应用的路径会多一个斜杠/,也就是会多一个跟路由标记。所以上面的方法得加一个过滤。改造后的方法:

export const isTurnChildApp = () =>{
	window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__
	if(window.__CURRENT_SUB_APP__ === window.location.pathname){
		return false
	}

	const curApp = window.location.pathname.match(/(\/w+)/)[0]// '/layout/' -> 只提取 '/layout' 

	if(!curApp)return;

	window.__CURRENT_SUB_APP__ = curApp
	return true;
}

tip:

  • ( )是一个分组
  • \ 是转义匹配
  • w 字母数字下划线
  • + 连续匹配
  • /(\/w+)/整个含义:匹配斜线以及后边连续出现的字母数字下划线,所以遇到/就丢弃了,停止匹配

5.2.2 写查找方法findAppByRoute

在对应的utils.js文件里

export const findAppByRoute = (router) => {
	return findCurrentApp('activeRule',router)
}

// findCurrentApp 之前在 “三、获取首个子应用” 讲过

六.获取子的html并挂载到容器

加载肯定是在框架级的beforeLoad里完成的。 之前在beforeLoad 这个 钩子里我们预留了 appContext ,也就是子应用显示的内容。他在mounted里传入执行渲染。

所以我们接下来开始写我们的loader部分。 loader/index.js

6.1 加载指定子应用的html

export const loadHtml = async(app) =>{

	// 首先,要知道子应用需要挂载到哪展示 
	let container = app.container
	// 子应用的入口
	let entry = app.entry

	// 去entry域名下的服务器上拿到index.html资源
	const html = parseHtml(entry)

	// 获取容器dom
	const subCtDom = document.querySelector(container)

	if(!subCtDom){throw Error('子容器不存在!如果是vue工程请检查dom身上是否绑定了v-if,可替换成v-show ')};
		
	// 装入容器中
	subCtDom.innerHtml = html

	// 经过系列处理后的app丢出去
	return app
}

然后在lifeCycle/index.js 里调用

export const beforeLoad = async(app) =>{
	//...
	const appContext = await loadHtml(app)
	appContext && appContext.beforeLoad && appContext.beforeLoad()
	return appContext;
}

6.2 解析指定路径子应用的html

import {fetchResource} from '../utils/fetchResource'
export const parseHtml = async (entry) => {
	//首先先拿到某个地址下的站点首页代码,我们访问别的站点发现,首先是一个get请求把index.html拿到
	// 这里我们也是使用get请求来做,但是具体使用ajax、fetch、axios都是可以的。这里我们选择fetch。
	const html =await fetchResource(entry)
	return html
}

工具方法封装 fetchResource.js

export const fetchResource = (entry) => {
	return fetch(entry).then(async(res)=>{
		return await res.text()//text是fetch的自带方法,也是promise异步的,得加await否则,打印出来是一个pendding状态的Promise对象

	})

}

通过上边的代码实现,我们发现子应用确实是出现在对应的容器里了,但是展示不完整,是因为什么呢? 那是由于我们并未解析执行index.html里的js部分。所以继续完善。

七.获取所有的js并插入到html中

export const parseHtml = async (entry) => {
	
	const html =await fetchResource(entry)

	<!-- step1 先创建一个div,里边装上要处理的html -->
	const div document.createElemet('div')
	div.innerHtml = html

	<!-- step2 解析js -->
	const [dom,scriptUrl,script] = await parseJs(div,entry) // 加载解析js link/script;script 不仅有外链的js,还有可能包裹js代码块
	



	return html
}

7.1 获取提取js

7.1.1 大致的脉络:

export const parseJs = async (rootDom) => {
	const scriptUrl = []
	const scriptCode = []
	const dom = root.outHTML //包含根节点本身

	return [dom,scriptUrl,scriptCode]
}

7.1.2 深度解析 递归dom 结构提取出所有js部分

export const parseJs = async (rootDom,entry) => {
	const scriptUrl = []
	const scriptCode = []
	const dom = root.outHTML //包含根节点本身

	function deepParse(element){
		let children = element.children
		let children = element.parent

		//script 外链+内嵌 两种场景代码提取
		if(element.nodeName.toLowerCase() === 'script'){
			const src = element.getAttribute('src')

			if(!src){
				scriptCode.push(element.outerHTML)
			}else{
				if(src.startsWith('http')){
					scriptUrl.push(src)
				}else{
					scriptUrl.push(`http:${entry}/${src}`)//防止webpack里publicPath没配置,那么资源地址 src="static/js/chunk-vendors.js"
				}
			}

			if(parent){
				// dom.replaceChild(newnode,oldnode)用新节点替换某个子节点
				// createComment可创建注释节点, 并插入HTML文档:
				parent.replaceChild(document.createComment('此js文件已经被微前端替换'),element)
			}
		}

		//link 标签也会有js的场景
		if(element.nodeName.toLowerCase() === 'link'){
			cosnt 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< children.length ; i++){
			deepParse(children[i])
		}
	}

	deepParse(root)

	return [dom,scriptUrl,scriptCode]
}


7.2 返回头,我们继续完善我们的parseHtml方法

export const parseHtml = async (entry) => {
	
	const html =await fetchResource(entry)

	let allJs = []

	<!-- step1 装上要处理的html -->
	const div document.createElemet('div')
	div.innerHtml = html

	<!-- step2 解析js -->
	const [dom,scriptUrl,scriptCode] = await parseJs(div,entry) 

	<!-- step3 把外链的js请求回来 -->
	const getAllScriptByUrl = await Promise.all(scriptUrl.map(async url => fetchResource(url)))

	<!-- step4 把包裹js的script标签和外链js代码凑一起 -->
	allJs = scriptCode.concat(getAllScriptByUrl)

	<!-- 丢出去供外部使用,包括自己创建的div节点 -->
	return [dom,allJs]
}

7.3 紧接着修改loadHtml方法:

export const loadHtml = async(app) =>{

	//...
	const [dom,allJs] = await parseHtml(entry)

	//...

	// 装入容器中
	subCtDom.innerHtml = dom//包含了所有的js 代码的html片段

	// 经过系列处理后的app丢出去
	return app
}

这样我们把源代码都拿到了,丢给浏览器,自然就帮我们解析渲染出来了。 至此,一个子应用的加载就完全实现了。


通过以上环节,一个mini版本的微前端框架大致实现了,但是还有一些其他功能需要完善,比如:通信、js沙箱、预加载等等,下篇文章抽空会更新。