为了验证某些事,我实现了一个toy微前端框架万字长文,请君一览
Posted 恪愚
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了为了验证某些事,我实现了一个toy微前端框架万字长文,请君一览相关的知识,希望对你有一定的参考价值。
众所周知微前端是企业中前端发展的必经之路。
我司搭建了自己的微前端脚手架工具,它将项目变成了“多页应用”,跳转方式由 location.href
这类api进行。所以笔者之前在想:这种方式跳转能不能有动画效果呢?
在“上层建筑”中进行“反直觉”的操作,结果当然是失败的。但是笔者又有了一个新想法:自己实现一个微前端框架,由通过劫持路由和 history
实现一些小操作!
本文将我实现第一版微前端架子的步骤呈现给各位,希望对大家能有一些帮助。
结构
这是toy微前端的项目基本结构,简单介绍下主要目录文件:
- 控制启动的build目录
- 项目所需后端service目录(由node实现)
- 主应用main目录
- 自应用vue2目录(存放vue2项目代码)
- 自应用vue3目录(存放vue3项目代码)
- 框架启动和版本控制文件
package.json
在vue2、vue3同级,你当然还可以创建react相关目录结构或者使用其他技术栈实现相关项目。这是不限制的,也是微前端的优势。
拿vue来说,你必然知道的是:项目启动是和 package.json 息息相关的,我们来看本框架的此文件:
"name": "toy-micro-web",
"version": "1.0.0",
"description": "create micro project for myself",
"main": "index.js",
"scripts":
"start": "node ./build/run.js"
,
"author": "yancy",
"license": "ISC",
"devDependencies": ,
"dependencies":
没有多余代码,也不必有。其中关键在于:scripts
字段。这是我们命令行的执行命令。
它的意思是:当我们按下 npm start
(或 npm run start
)时,会通过node去执行 ./build/run.js
文件。
启动你的微前端
这就是上面我们说的启动目录下的唯一一个文件。它要做的也很简单 —— 进入项目对应目录,执行他们各自的启动命令。
const childProcess = require('child_process')
const path = require('path')
const filePath =
vue2: path.join(__dirname, '../vue2'),
vue3: path.join(__dirname, '../vue3'),
service: path.join(__dirname, '../service'), //启动后端应用node
// react15: path.join(__dirname, '../react15'),
// react16: path.join(__dirname, '../react16'),
main: path.join(__dirname, '../main')
// cd 子应用的目录 npm start 启动项目
function runChild ()
Object.values(filePath).forEach(item =>
childProcess.spawn(`cd $item && npm start`, stdio: "inherit", shell: true )
)
runChild()
我们借助了 child_process
模块的帮助。
这里是qiankun的思想,一次将所有子应用全部启动。本来想按照qiankun的思路去实现,但是写的时候突然想到这就是一个为了我研究其他东西的小玩具,那么精细干啥。后面第二版第三版也有修改但是代码量就多了不放出来了先。
各位在用新技术尝试的时候也可以把这里按照自己想法改进下。
子应用:vue2
执行完这个文件后,命令行中你会发现依次进入到主应用以及各个子应用中了。我们先来看子应用:以vue2为例
这就是一个普通的vue2项目目录,将其置身于“微前端”场景下时,我们需要着重关注 vue.config.js
以及 src 下的 main.js
文件。
在 vue.config.js 中笔者进行了端口号的重置、指定打包路径、热更新的开启、本地服务的跨域内容等等。最重要的还得是“自定义webpack配置项”了:
// 自定义webpack配置
configureWebpack:
resolve:
alias:
'@': resolve('src'),
,
,
output:
// 把子应用打包成 umd 库格式 commonjs 浏览器,node环境
libraryTarget: 'umd',
library: `$packageName`, //在全局环境下获取到打包的内容 ——umd,把这一行去掉与显示在浏览器控制台打印 window.此vue项目文件夹名
// filename: 'vue2.js', //打包的名字
// jsonpFunction: `webpackJsonp_$packageName`
,
,
我们通过 libraryTarget 字段指定打包格式为 umd!并且通过 library 字段指定了打包出来的名字。
这里是关键,也是微前端中很多操作的根本!其原因随后陈述。
先来看 main.js 文件。都知道这里面主要干了一件事:new Vue
new Vue(
router,
render: h => h(App)
).$mount('#app-vue');
这是一般的写法。但是现在是微前端!在子应用加载前会不会要处理必要的参数?在加载时是不是可能要进行一些额外的处理?切换其他应用后要不要取消监听?…
子应用微前端场景改造
所以我们应当区分情况,并暴露出三个函数:
let instance = null;
const render = () =>
// 这个函数就可以在微前端框架中应用,我们也可以通过window.vue2获取到内容
instance = new Vue(
router,
render: h => h(App)
).$mount('#app-vue');
// 通过render触发,但是有一个前提:如果当前是微前端环境则不自己触发,而是需要根据我们的微前端生命周期去对应的触发当前的render函数
if (!window.__MICRO_WEB__)
render()
// 微前端下
export const bootstrap = () =>
// 开始加载结构 - 比如一些在加载之前必要的参数处理
console.log('开始加载')
export const mount = () =>
render();
// 然后得到vue实例
console.log('渲染成功')
export const unmount = () =>
// 比如撤销监听事件,或者处理当前容器的显示内容,,,
console.log('卸载', instance)
现在,让我们执行 npm start
,看一看 umd
的“魅力”:
而若是将 libraryTarget: 'umd',
这一行去掉,则会打印undefined
。说白了就是把子应用打包好作为节点挂载道window上供全局调用。这也就是其原因所在了。
很明显就是依赖webpack的打包特性和 umd 模块的特殊性。
其实微前端并不是一个新的技术,只是新的概念罢了。早期的“服务端组合SSI技术”、以及后来重新被大家认识的“iframe”、还有新兴的“web components技术”,不同于微前端发展初期概念里的“页面级别组合”,他们甚至可以进行组件级别的组合。
这里和另一个技术也“异曲同工” —— webpack5 联邦模块!
const ModuleFederationPlugin = require("webpack").container;
//...
plugins: [
new ModuleFederationPlugin(
// MF 应用名称
name: "app1",
library: type: "var", name: "app_1" ,
// MF 模块入口,可以理解为该应用的资源清单
filename: `remoteEntry.js`,
// 定义应用导出哪些模块
exposes:
"./utils": "./src/utils",
"./foo": "./src/foo",
,
),
],
这段代码就是联邦模块导出的配置。其中library
的 name
字段即是 umd 导出的name!
主应用:main
OK,让我们说回toy微前端。
子应用部分已经结束,接下来该“重中之重”,主应用的介绍。主应用担负起了调配子应用、路由拦截、全局通信、应用启动等诸多功能。
我们来看下主应用的结构:
主应用是以vue3实现的,其中micro目录是重点 —— 微前端控制器。
让我来一一介绍。
首先依然是 main.js文件,这里稍有不同:主应用是微前端主体,它当然也要有生命周期,用来和子应用生命周期“遥相呼应”,但正因为是主体所以自己调用自己不太合适,故而主应用的生命周期不能以export
的方式写在这里!
上面提到主应用应该肩负起调配子应用的职责,故而我们应当在主应用初始化前注册子应用:
import createApp from 'vue'
import App from './App.vue'
import router from './router'
import subNavList from "./store/sub"
import registerApp from "./util"
// 在整个实例初始化之前先将子应用注册好
registerApp(subNavList)
createApp(App).use(router()).mount('#micro_web_main_app')
subNavList很简单,就是子应用的一些信息:路径、名字、需要挂载的节点等,后面我们要根据这些信息处理子应用
export const subNavList = [
name: 'vue2',
activeRule: '/vue2',
container: '#micro-container',
entry: '//localhost:9004/',
,
name: 'vue3',
activeRule: '/vue3',
container: '#micro-container',
entry: '//localhost:9005/',
,
]
然后registerApp
用来将这些子应用注册到微前端中,并且在这个时候注册主应用的生命周期!
import registerMicroApps, start from "../../micro"
import loading from "../store"
export const registerApp = (list) =>
// 将子应用注册到主应用里是没有任何效果的,所以我们应该注册到微前端框架里
// 主应用的生命周期也需要在此时注册好!
registerMicroApps(list,
beforeLoad: [
() =>
// 在生命周期中控制loading
loading.changeLoading(true)
console.log('开始加载')
],
mounted: [
() =>
loading.changeLoading(false)
console.log('渲染完成')
],
destoryed: [
() =>
console.log('卸载完成')
]
)
start()
我们通过参数的方式将生命周期传到处理函数中,这里需要注意的是,主应用的生命周期函数可能不止一个(要干的事情和时机不一样),所以我们用数组形式处理。
先来看registerMicroApps
函数:
export const registerMicroApps = (appList, lifeCycle) =>
setList(appList)
setMainLifeCycle(lifeCycle)
很简答,首先接收子应用列表,然后处理子应用和对应生命周期。setList
函数其实就是一个赋值函数,对应的还有一个getList
取值函数,这是为了避免随意往window上挂载东西。
setMainLifeCycle
也是如此,先将生命周期“存起来”。
什么时候用呢?上一段代码最后还有一个start
函数,我们来看下:
export const start = () =>
// 首先验证当前子应用列表是否为空
let apps = getList();
if(!apps.length)
console.error('当前没有子应用注册')
return;
// 查找到符合当前url的子应用
let app = currentApp()
if(app)
const pathname, hash = window.location
const url = pathname + hash
window.history.pushState('', '', url)
// 这时候我们会发现路由被触发了不止一次,我们可以加一个限制
window.__CURRENT_SUB_APP__ = app.activeRule
首先判断当前有没有子应用注册,若有,取到子应用并将路由赋值,而后将当前行为“告知”全局,我们用“公共变量”__CURRENT_SUB_APP__
去接收,这一点是为了方便后面区分“上一个子应用”和“在一个子应用”。
currentApp
中即是先通过window.location.pathname
获取当前路由,然后在getList
中查找:
const filterApp = (key, value) =>
const currentApp = getList().filter(item => item[key] === value)
return currentApp && currentApp.length ? currentApp[0] :
路由监听
我们会发现上面的操作大多和“router”相关,这里就引出了微前端中另一个重点概念:路由监听。以及后期的“路由劫持”!
其实就是“重写路由跳转函数”,重写的原因也很简单:我们需要做一些符合自己需求的额外处理。
export const rewriteRouter = () =>
window.history.pushState = patchRouter(window.history.pushState, 'micro_push')
window.history.replaceState = patchRouter(window.history.replaceState, 'micro_repalce')
// 给新事件加一个事件绑定
window.addEventListener('micro_push', turnApp);
window.addEventListener('micro_replace', turnApp);
// 给返回事件也添加绑定
window.onpopstate = function ()
turnApp()
我们通过重写pushState
和replaceState
绑定了两个新事件,在路由切换后去执行生命周期~
export const turnApp = async () =>
if(!isTurnChild())
return
console.log('路由切换')
// 执行微前端生命周期
await lifeCycle()
其中判断子应用是否切换的函数isTurnChild
就是利用了我们刚刚说到的挂载到window上的“公共变量”:
export const isTurnChild = () =>
// 需要在判断之前先拿到全局变量,用两个全局变量获取上一个子应用和下一个子应用
window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__
if(window.__CURRENT_SUB_APP__ === window.location.pathname)
return false
let currentApp = window.location.pathname.match(/(\\/\\w+)/)
if(!currentApp)
return
window.__CURRENT_SUB_APP__ = currentApp[0]
return true
其中currentApp
的判断是为了处理路由字符串,方便后面加载资源。
然后我们来看如何执行微前端生命周期lifeCycle
:
export const lifeCycle = async () =>
// 获取到上一个子应用:执行他的unload。我们需要先将上一个子应用卸载掉
const prevApp = findAppByRoute(window.__ORIGIN_APP__)
// 获取到要跳转的子应用:执行他的生命周期
const nextApp = findAppByRoute(window.__CURRENT_SUB_APP__)
console.log(prevApp, nextApp)
if(!nextApp)
return
if(prevApp && prevApp.destoryed)
await destoryed(prevApp)
const app = await beforeLoad(nextApp)
await mounted(app)
// 作为微前端控制器,也需要有自己的生命周期函数
export const beforeLoad = async (app) =>
await runMainLifeCycle('beforeLoad')
app && app.beforeLoad && app.beforeLoad()
// 获取到子应用所有的显示内容 —— 获取真正的子应用
const appContext = await loadhtml(app)
appContext && appContext.beforeLoad && appContext.beforeLoad()
return appContext
export const mounted = async (app) =>
app && app.mount && app.mount()
await runMainLifeCycle('mounted')
export const destoryed = async (app) =>
app && app.unmount && app.unmount()
// 对应的执行一下主应用的生命周期
await runMainLifeCycle('destoryed')
export const runMainLifeCycle = async (type)=>
const mainlife = getMainLifeCycle(); //get函数,获取到主应用生命周期数组
// 等到所有的生命周期执行完毕才能下一步
await Promise.all(mainlife[type].map(async item => await item()))
简单来讲就是一句话:先获取到上一个(也就是要跳出的)子应用,去执行它的卸载生命周期,然后获取到下一个(也就是即将跳转的)子应用,去执行它的“预挂载”生命周期,获取到子应用的内容!然后执行子应用的“挂载”生命周期。因为子应用的节点实际由主应用控制,所以在每次操作时都要去执行主应用对应的生命周期!
findAppByRoute
和上面的currentApp
作用一样,就是根据 router 获取到子应用信息。
这段代码里为什么充斥着async-await
?除了在runMainLifeCycle
中要等待主应用生命周期执行外还有一点:在mounted
中加载子应用资源!
这也就到了第三个重点:资源的获取和处理。我们来看下。
dom获取、js代码获取&执行
export const loadHtml = async (app) =>
// 子应用需要显示在哪里
let container = app.container
// 子应用的入口是啥
let entry = app.entry
const [dom, scripts] = await parseHtml(entry)
console.log(scripts)
const ct = document.querySelector(container)
if(!ct)
console.error('容器不存在')
return
console.log(ct)
ct.innerHTML = dom
return app
在此函数中,我们的主要内容就是“下载”html、和链接的css、js资源。app参数就是获取到的子应用信息,前面提到的结构这里就派上用场了。
下载资源并不只是单纯的下载html就行了,head
、body
中的link
、scripts
标签也是我们需要关注的地方:我们需要将 js 内容也下载下来方便后面运行:
export const parseHtml = async (entry) =>
// 资源加载其实是一个get请求,我们去模拟这个过程
const html = await fetchResource(entry)
let allScripts = []
const div = document.createElement('div')
div.innerHTML = html
// 标签、link、script(src、代码)
const [dom, scriptUrl, script] = await getResources(div, entry)
// 获取所有的js资源
const fetchedScripts = await Promise.all(scriptUrl.map(async item => await fetchResource(item)))
allScripts = script.concat(fetchedScripts)
return [dom, allScripts]
fetchResource
就是fetch请求函数。不必细说。
笔者在getResources
函数中进行了上述的处理。可以看到我将结构分为三类:dom
、script
代码和script
链接(然后根据连接请求资源得到代码合并进script
中),为了归类我利用了递归扫描html代码。这里只放核心代码 —— 判断标签中有没有src
或href
属性,如果有,则说明是链接文件(被scriptUrl
接收),这时还需要判断链接有没有前缀,因为有的链接是本地连接,如果直接请求资源则会“找不到资源路径”,需要处理下:
// 处理script
if(element.nodeName.toLowerCase() === 'script')
为了验证某些事,我实现了一个toy微前端框架万字长文,请君一览