假期结束!我不允许你还不知道微前端的原理!

7,004 阅读13分钟

美好愉快的假期结束了哭哭,来上车学习一波微前端叭~

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 执行时机:要么手动执行,registerAppstart,要么就是在路由切换的时候,判断当前状态,执生命周期函数。
    • 如果当前还未曾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.pushStatereplaceState,除了执行原生还会执行 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。不是很友好的管理。

修改运行时子系统的前缀

picture20210426160801

我们打包的子系统的 html 资源的引入路径是酱婶儿的,如果插到父域下面就找不到资源了,所以要根据不同子系统给换成绝对路径拉取资源,借助 systemjs-webpack-interop,根据项目名称匹配配置文件,修改\_\_webpack_public_path\_\_(webpack 写在了全局,可以自己敲一下,\_\_webpack_require\_\_也有),配置如图

picture20210426162028

css 隔离、js 隔离、dom 隔离

只是针对 style 的样式隔离,可以加在 body 上加作用域将全局样式隔离,卸载的时候清除作用域,但对 jq 的项目,选择器的选择还是会错乱。 window 方法、定时器、全局变量等污染,靠代码约束是不够的。

懒得改那就直接上 qiankun 吧

以上的问题大部分可以通过插件、编码约束等解决。但难免使用需要二次封装,没精力重复造轮子就来看看 qiankun 源码吧。

qiankun 帮我们做了啥

看看官网咋说的 biaoqingbao02.png

如果说 single-spa 是乐高,需要组合才能使用,那 qiankun 就是一辆踩油门就能跑的 🚘 了。 效果就是可以按需加载了,应用拉取也有顺序了,变量污染样式污染也解决了,cv 几行我的应用就跑起来了,舒畅~

我不允许你还不知道他的原理

kuailexingqiu.gif

从几个问题切入,什么是快乐星球~ 现在我就带你探究~

运行时 public-path 解决子应用资源问题

看了文档我就开始怀疑两种可能,一个修改\_\_webpack_require\_\_.e,load 文件时候根据子应用名字匹配修改 fetch 资源的路径,但这样入侵了 webpack 不是很好;另一种就是修改运行时\_\_webpack_public_path\_\_全局变量,那么顺着代码来一探究竟叭。

先看看使用

  1. src 目录新增 public-path.js

    if (window.__POWERED_BY_QIANKUN__) {
      __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
    }
    
  2. 入口文件 main.js 修改。

  import './public-path';

看到使用就明白是使用修改__webpack_public_path__的方式,这段代码引入入口文件,__webpack_public_path__被修改成__INJECTED_PUBLIC_PATH_BY_QIANKUN__, tupian002.png 可以看出在生命周期的钩子里对这个变量进行了赋值,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 类型 entry ts 类型 看着命名这猜也猜到了是registerMicroApps穿进去的 appconfig 的 entry,那么问题来了,我传进去个地址你是咋解析的呢。 我把我的 entry 给改了,后面拼个大聪明,拉取 html 里的 script 是正常的,但动态引入的图片以及 chunk 都找不到了,这是 why 呢。 script 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',
},

image.png

资源的顺序加载

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 了啥,如果我 awaitnew 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 传入的 getExternalStyleSheetsgetExternalScripts,正则匹配 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 原本就没有的属性,addedPropsMapkey 对应的值设置 undefined 其实就是删除掉;下次沙箱激活的时候再用 currentUpdatedPropsValueMap 给恢复回来,如果是多个微应用同时存在就乱了。

多实例使用 proxySandbox,直接对 window 的操作放到 fakeWindow,一个应用对应一个 fakewindow,对 fakewindow 对象进行代理,不污染全局 window。在取值时,如果不在

fakewindow 有这个属性就从 fakewindow 取,没有就从真 window 取,设置的时候直接设置给 fakewindowfakewindow 是创建个新对象,把 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 创建沙箱方法返回当前沙箱实例,以及将要随着子应用的生命周期执行的方法 mountunmount。沙箱启动后开始劫持各类全局监听,重点分析一下 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 监听的方法也是这个思路,执行的时候指定 proxywindow

对于样式表就比较难搞了,当 dom 被卸载样式就会被删除,如果不是动态插入的 css 在将要被释放的时候需要收集起来,在下一次 mount 的时候重建。动态插入的样式表会被拦截,插入在自己的作用域 head 里面。

对于样式表的重建比较有意思,如果 HTMLStyleElement 没有被插入到文档,是读取不了 sheet 的,返回 null。如果是插入到文档的 link,没法直接读取 linkcssRule,会报错,需要把 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可以重建
 })

对于插入到 bodyjs 会被拦截,用 fetch 获取后指定 proxy window。上面这些需要拦截 appendChild 方法,同样需要处理 removeChild,比如 React.createPortal 创建的 style,插入到子应用的 div 容器里了,但卸载的时候 React 会从 body 里面找,所以还需要处理 removeChild,对子容器做判断,卸载掉 style

总结

除了做应用拆分我要找多个 html 托管资源让人很不爽外,其他成本不得不说还是很低的,一套不错的通用化的解决的方案。

image.png

有问题欢迎一起探讨鸭~热烈欢迎o(^▽^)┛举爪爪ღ( ´・ᴗ・` )