美好愉快的假期结束了哭哭,来上车学习一波微前端叭~
single-spa
+qiankun
(这两个是不是名字超熟悉),你想要的样子我都有~
如果想看Module Federation解析移步我滴上一篇文章juejin.cn/post/694979…
当然,我们正确操作是:
错了,再来: 👇正文开始
single-spa 都做了啥
我们先看看 single-spa 都做了啥
先想想如果自己实现一个路由改变可以切换子应用的系统需要做啥(我的习惯,先想想自己做怎么实现,然后去源码找印证自己的思路),大致是监听 url 的变化,切换我们子应用,如何获取子应用?() => import()
或者 fetch
资源都可,塞到我们相应的 dom 下面,在应用被卸载的时候移除。
大差不离,要解决的难题就是管理资源加载,应用装载,卸载这些事情的顺序了,也就是生命周期。
那么看了源码,介绍几个核心方法以及思路
从 registerApplication
注册子应用的方法看,执行后存储下来子应用的配置以及初始化 status
。第三个参数是激活态的判断,激活可以简单理解成在界面上可以被看见。
生命周期:子系统必须需导出bootstrap
(初始化)、mount
(加载时)和 unmount
(卸载时)生命周期函数,unload
(清除时)可选。
有以下这些状态
start
:顾名思义启动,除了标志应用开始启动了外,执行我们得 reroute。reroute
执行时机:要么手动执行,registerApp
和start
,要么就是在路由切换的时候,判断当前状态,执生命周期函数。- 如果当前还未曾
start
过,说明是第一次,将激活态的apps
执行loadApps
,加载js entry
结束后,状态修改NOT_BOOTSTRAPPED
。 - 如果
start
过,将appstoUnmont
的卸载,将apptoload
的 load and mount。LOADING_SOURCE_CODE -> NOT_BOOTSTRAPPED
, 加载到资源里,可以拿到用户暴露的生命周期方法,将他们存起来,执行 bootstrap 生命周期,BOOTSTRAPPING -> NOT_MOUNTED
,初始化完成。接下来就是挂载,修改状态执行 mount 生命周期,MOUNTING -> MOUNTED
,挂载结束。对于未激活的
,即路由不匹配的,说明这时候该被卸载,执行 unmout 生命周期,UNMOUNTING -> NOT_MOUNTED
。
- 如果当前还未曾
对于单页面应用比如 react-router
的路由不过也是要用原生的 history.pushState history.replaceState
实现,所以我们增强这两个方法,让他们除了完成原生的能力还调用 reroute
,就实现了闭环。
各种状态:
NOT_LOADED
: app 还未加载 or 应用已经被移除完成;LOADING_SOURCE_CODE
: 表示正在加载子应用源代码;NOT_BOOTSTRAPPED
: 执行完app.loadApp
,即子应用加载完以后的状态BOOTSTRAPPING
: 正在初始化 app,执行 bootstrap 生命周期;;NOT_MOUNTED
: 初始化完成,bootstrap 或 unmout 执行完成;MOUNTING
: 正在挂载,执行 mount;MOUNTED
: 挂载 app 完毕,可以执行 render;UNMOUNTING
: 执行 unmout 生命周期,执行后变 NOT_MOUNTED;UNLOADING
: 移除应用 ,应用不再拥有生命周期,但是并不是真正移除,后面再激活时不需要重新去下载资源,只是做一些状态上的变更;SKIP_BECAUSE_BROKEN
: 加载失败
增强了window.history.pushState
,replaceState
,除了执行原生还会执行 reroute
。
single-spa 的事件用微任务即return Promise.resolve().then(() => actions())
,这样的写法,可以不影响主任务,并且报错不会造成主任务中断。
single-spa 需要改造还很多
js entry 带来的缺陷
还记得 registerApplication
的第二个参数吗,作为 load 资源 entry,在 loadApps
里会被执行,在我们 webpack 打包的项目会用 split chunk
做拆包,但单一 entry 不得不放弃 split chunk
;子应用没有优先级,entry 会被一起加载,子应用特别多会有并发数量限制。
如何通信
虽然拆分的系统应该避免通信,但系统之间的通信依旧难免,用户信息这种共享的信息,在 a
中被修改在 b
中需要同步,需要用户自定义事件通信,a
创建自定义事件,window
上注册自定义事件监听器,a
触发事件,在 b
中接收,公共数据存到 localstorage
。不是很友好的管理。
修改运行时子系统的前缀
我们打包的子系统的 html
资源的引入路径是酱婶儿的,如果插到父域下面就找不到资源了,所以要根据不同子系统给换成绝对路径拉取资源,借助 systemjs-webpack-interop
,根据项目名称匹配配置文件,修改\_\_webpack_public_path\_\_
(webpack 写在了全局,可以自己敲一下,\_\_webpack_require\_\_也有
),配置如图
css 隔离、js 隔离、dom 隔离
只是针对 style
的样式隔离,可以加在 body 上加作用域将全局样式隔离,卸载的时候清除作用域,但对 jq 的项目,选择器的选择还是会错乱。
window
方法、定时器、全局变量等污染,靠代码约束是不够的。
懒得改那就直接上 qiankun 吧
以上的问题大部分可以通过插件、编码约束等解决。但难免使用需要二次封装,没精力重复造轮子就来看看 qiankun 源码吧。
qiankun 帮我们做了啥
看看官网咋说的
如果说 single-spa 是乐高,需要组合才能使用,那 qiankun 就是一辆踩油门就能跑的 🚘 了。 效果就是可以按需加载了,应用拉取也有顺序了,变量污染样式污染也解决了,cv 几行我的应用就跑起来了,舒畅~
我不允许你还不知道他的原理
从几个问题切入,什么是快乐星球~ 现在我就带你探究~
运行时 public-path 解决子应用资源问题
看了文档我就开始怀疑两种可能,一个修改\_\_webpack_require\_\_.e
,load 文件时候根据子应用名字匹配修改 fetch 资源的路径,但这样入侵了 webpack 不是很好;另一种就是修改运行时\_\_webpack_public_path\_\_
全局变量,那么顺着代码来一探究竟叭。
先看看使用
-
在
src
目录新增public-path.js
:if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
-
入口文件
main.js
修改。
import './public-path';
看到使用就明白是使用修改__webpack_public_path__
的方式,这段代码引入入口文件,__webpack_public_path__
被修改成__INJECTED_PUBLIC_PATH_BY_QIANKUN__
,
可以看出在生命周期的钩子里对这个变量进行了赋值,beforeload
加载子应用之前注入变量,beformount
挂载子应用之前注入变量(这个周期可能被多次执行,如果曾经挂载过了才会执行,没有挂载过说明已经 beforeload
里注入过了),beforeUnmount
子应用卸载之前,删除变量。publicPath
是这个方法传进来的,默认是'/'
,看看是谁调用的 getAddOn
,传进去的是啥。
追寻到了实际传入 publicpath
的地方,贴段删减版。
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
...//这里返回了publicpath,importEntry解析entry返回解析好的对象
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
...omit
// getAddon实际是空方法,getAddOns调用了他
getAddOns(global, assetPublicPath);
}
看到这不禁好奇 entry
是啥,扒一下 entry
的 ts 类型
看着命名这猜也猜到了是registerMicroApps
穿进去的 appconfig 的 entry,那么问题来了,我传进去个地址你是咋解析的呢。
我把我的 entry 给改了,后面拼个大聪明,拉取 html 里的 script 是正常的,但动态引入的图片以及 chunk 都找不到了,这是 why 呢。
script 标签里的是绝对路径,比如我们路径是http://localhost:7777/subapp/sub-vue/dacongming/
,即使拼的是错的,域名和端口正确单页面应用的文档还是在的,绝对路径替换后是域名+端口+路径,所以也没啥问题。出问题的就是通过 js 动态插入的,我们的图片、chunk 等,webpack 在打包的时候有全局变量__webpack_public_path__
,将相对路径拼接成绝对路径,当我们在主应用下面拉子应用的 entry 时,如果不修改__webpack_public_path__
,将拿不到正确的资源,所以需要将这个变量替换,代码里打 log 也印证了,吐出来的 assetpath 就是传进去的 entry,如果配错了那自然拿不到资源了。
但registerMicroApps
穿进去的 appconfig 的 entry 如果和我资源的 publicpath
是不匹配的咋整呢,如果我 html 资源入口不是根路径或者是 hash 模式的,那肯定不能作为资源地址了,这就需要修改了,别慌,start 里面支持传入参数修改,配置 getPublicPath
,这样就可以顺利拿到资源了。(提一嘴这方法为啥叫 get 是因为给 qiankun 用的,合理)
start({
// 这个优先级最高,会干掉你的entry,传入方法,可以判断子应用返回不同的public_path
getPublicPath: () => 'http://localhost:7777'
})
// webpack output 参数配置
output: {
..omit
assetModuleFilename: 'static/media/[name].[hash:8][ext]',
filename: 'static/js/[name].[contenthash:8].js',
chunkFilename: 'static/js/[name].[contenthash:8].chunk.js',
},
资源的顺序加载
start
函数传入第一个参数可以配置加载策略
true
就是先加载命中的,其他资源走预加载,可以理解成延迟加载
上面分析 single-spa 的表现知道,资源是会被 Promise.all
一起加载,如何实现的有序呢
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// Each app only needs to be registered once
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
// 看这里是是所有的app都会被执行registerApplication
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
registerApplication({
name,
app: async () => {
loader(true);
// 这里划重点1
await frameworkStartedDefer.promise;
// 划重点2
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
- 划重点 1 :
await
先不管后面await
了啥,如果我await
了new Promise(res => window.res = res)
这样,single-spa 的资源加载是不是就被我停住了呢,只有后续res()
了才能继续,在 qiankun 的start
方法里调用了reslove
。 - 划重点 2 : 看起来是乾坤在调自己的 loadapp 拉资源。
继续看一下
start
方法
export function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts }
const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts)
}
// ...sandbox先不看 omit
startSingleSpa({ urlRerouteOnly })
frameworkStartedDefer.resolve() // 这里就是上面的defer promise res的地方,之后可以开始加载资源。
}
doPrefetchStrategy
预加载策略,如果 prefetchStrategy
是 true 即预加载,调用了 prefetchAfterFirstMounted
方法,那么看一下 qiankun 如何做的预加载
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
window.addEventListener('single-spa:first-mount', function listener() {
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED)
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts))
window.removeEventListener('single-spa:first-mount', listener)
})
}
在第一个应用 mount
的时候,single-spa 会 dispatch 自定义方法 single-spa:first-mount
,
prefetchAfterFirstMounted
方法监听了 single-spa:first-mount
,在第一个应用 mount 的时候触发,判断状态是否是未 load 资源即 NOT_LOADED
,将 notLoadedApps
(appstoload 一样)执行 refetch。
prefetch
使用 requestidlecallback
方法,延迟加载资源,如果没有这个方法用 setTimeout
模拟(requestidlecallback
会让回调在渲染完成之后执行,用 setTimeout
也能模拟,浏览器没执行完同步代码也不会执行 setTimeout
),requestidlecallback
传入的 getExternalStyleSheets
和 getExternalScripts
,正则匹配 script
标签,执行 fetch
拉取代码字符串,把结果存起来,在 loadApp 里可以直接执行了。
ps:另外还有个实现思路,如果 single-spa 没有把 active 即匹配的路由的资源推入 appstoload,不使用 single-spa 的资源 load,自己来控制也是可以的,判断一下是否 active 把 active 的单独 resolve。下面贴的是自己的思路>-<
class Defer {
constructor() {
this.promise = new Promise((res, rej) => {
this.resolve = res
this.reject = rej
})
}
}
function register(entry, opts) {
const defer = new Defer()
const app = {
name: '',
loadApp() {
await defer.promise()
fetch(entry, opts)
},
defer: defer
}
apps.push(app)
}
function judgeRoute() {
return apps.filter((i) => i.route === location.path)
}
function start() {
const apps = judgeRoute()
apps.forEach((i) => i.defer.resolve())
singleSpa.start()
}
沙箱是个啥
在真正执行资源 scripts
的地方,loadApp
里,为了实现 js 隔离,会先创建个沙箱。
不考虑兼容 proxy
,目前 qiankun 在单实例(同时只能 mount
一个微应用)使用 SingularProxySandbox
,在多实例使用 proxySandbox
,其实都是使用 proxy
。
SingularProxySandbox
修改新增的属性还是在真实 window
,如果当前 window
不存在该属性,用 addedPropsMap
记录,如果当前 window
对象存在该属性,且 modifiedMap
中未记录过,则用 modifiedMap
记录该属性初始值;不管是否记录过都会被记录到 currentUpdatedPropsValueMap
,酱紫每次沙箱被卸载的时候,把 window
上原本有的用 modifiedMap
给还原,window
原本就没有的属性,addedPropsMap
的 key
对应的值设置 undefined
其实就是删除掉;下次沙箱激活的时候再用 currentUpdatedPropsValueMap
给恢复回来,如果是多个微应用同时存在就乱了。
多实例使用 proxySandbox
,直接对 window
的操作放到 fakeWindow
,一个应用对应一个 fakewindow
,对 fakewindow
对象进行代理,不污染全局 window
。在取值时,如果不在
fakewindow
有这个属性就从 fakewindow
取,没有就从真 window
取,设置的时候直接设置给 fakewindow
。fakewindow
是创建个新对象,把 window
上可以重新定义的属性拷贝过来。
let activeSandboxCount = 0
class ProxySandbox {
active() {
this.sandboxRunning = true
}
inactive() {
this.sandboxRunning = false
}
constructor() {
const rawWindow = window
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
if (this.sandboxRunning) {
target[prop] = value
return true
}
},
get: (target, prop) => {
let value = prop in target ? target[prop] : rawWindow[prop]
return value
}
})
this.proxy = proxy
}
}
劫持
createSandboxContainer
创建沙箱方法返回当前沙箱实例,以及将要随着子应用的生命周期执行的方法 mount
和 unmount
。沙箱启动后开始劫持各类全局监听,重点分析一下 patchAtMounting
。
return {
instance: sandbox,
/**
* 沙箱被 mount
* 可能是从 bootstrap 状态进入的 mount
* 也可能是从 unmount 之后再次唤醒进入 mount
*/
async mount() {
/* ------------------------------------------ 因为有上下文依赖(window),以下代码执行顺序不能变 ------------------------------------------ */
/* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
sandbox.active()
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length)
const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length)
// must rebuild the side effects which added at bootstrapping firstly to recovery to nature state
if (sideEffectsRebuildersAtBootstrapping.length) {
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild())
}
/* ------------------------------------------ 2. 开启全局变量补丁 ------------------------------------------*/
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
// 沙箱启动后开始劫持各类全局监听 这个劫持方法敲重点
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter)
/* ------------------------------------------ 3. 重置一些初始化时的副作用 ------------------------------------------*/
// 存在 rebuilder 则表明有些副作用需要重建
if (sideEffectsRebuildersAtMounting.length) {
sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild())
}
// clean up rebuilders
sideEffectsRebuilders = []
},
/**
* 恢复 global 状态,使其能回到应用加载之前的状态
*/
async unmount() {
// record the rebuilders of window side effects (event listeners or timers)
// note that the frees of mounting phase are one-off as it will be re-init at next mounting
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) => free())
sandbox.inactive()
}
}
对于可能对全局造成污染的方法都要被劫持,比如计时器,window 上的方法,动态样式表以及 js。
const basePatchers = [
() => patchInterval(sandbox.proxy), // 计时器劫持
() => patchWindowListener(sandbox.proxy), // window 事件监听劫持
() => patchHistoryListener() // history劫持
]
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [...basePatchers, () => patchLooseSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter)],
[SandBoxType.Proxy]: [...basePatchers, () => patchStrictSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter)],
[SandBoxType.Snapshot]: [...basePatchers, () => patchLooseSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter)]
}
计时器劫持思路是重写 setInterval
方法,收集当前应用 window
的计时器的 id
,在当前应用被卸载的时候清除计时器。对于 window
监听的方法也是这个思路,执行的时候指定 proxy
的 window
。
对于样式表就比较难搞了,当 dom
被卸载样式就会被删除,如果不是动态插入的 css
在将要被释放的时候需要收集起来,在下一次 mount
的时候重建。动态插入的样式表会被拦截,插入在自己的作用域 head 里面。
对于样式表的重建比较有意思,如果 HTMLStyleElement
没有被插入到文档,是读取不了 sheet
的,返回 null
。如果是插入到文档的 link
,没法直接读取 link
的 cssRule
,会报错,需要把 link
转成 style
。
fetch(temp1.sheet.href)
.then((res) => res.text())
.then((res) => {
const styleElement = document.createElement('style')
//console.log(res)
styleElement.appendChild(document.createTextNode(res))
document.body.appendChild(styleElement)
console.log(styleElement.sheet.cssRules) // 拿到css rules可以重建
})
对于插入到 body
的 js
会被拦截,用 fetch
获取后指定 proxy window
。上面这些需要拦截 appendChild
方法,同样需要处理 removeChild
,比如 React.createPortal
创建的 style
,插入到子应用的 div
容器里了,但卸载的时候 React
会从 body
里面找,所以还需要处理 removeChild
,对子容器做判断,卸载掉 style
。
总结
除了做应用拆分我要找多个 html
托管资源让人很不爽外,其他成本不得不说还是很低的,一套不错的通用化的解决的方案。
有问题欢迎一起探讨鸭~热烈欢迎o(^▽^)┛举爪爪ღ( ´・ᴗ・` )