Bootstrap

微前端学习(下)

一、课程目标

  1. qiankun 整体运行流程
  2. 微前端实现方案

二、课程大纲

  1. qiankun 整体流程
  2. 微前端方案实现
  3. DIY微前端核心能力

1、微前端方案实现

  • 基于 iframe 完全隔离的方案、使用纯的Web Components构建应用
  • EMP基于webpack module federation
  • qiankun、icestark 自己实现JS以及样式隔离

2、qiankun 整体运行流程

总流程
注册子应用
启动主应用
是否激活子应用
初始化子应用-只触发1次
挂载子应用-可能触发多次
卸载子应用-可能触发多次

3、DIY 微前端核心能力

3.1 应用注册 registerMicroApps(apps, lifeCycles?)

  • 参数
    • apps - Array - 必选,微应用的一些注册信息
    • lifeCycles - LifeCycles - 可选,全局的微应用生命周期钩子
  • 类型
    • RegistrableApp
      • name - string - 必选,微应用的名称,微应用之间必须确保唯一。
      • entry - string - 必选,微应用的入口。
      • container - string | HTMLElement - 必选,微应用的容器节点的选择器或者 Element 实例
      • activeRule - string | (location: Location) => boolean | Array<string | (location: Location) => boolean> - 必选,微应用的激活规则
    • LifeCyclest
      • type Lifecycle = (app: RegistrableApp) => Promise;
      • beforeMount - Lifecycle | Array - 可选
      • beforeUnmount - Lifecycle | Array - 可选
      • afterUnmount - Lifecycle | Array - 可选

3.2 监听路由变化

hash模式 | history模式

如何实现前端路由?

要实现前端路由 需要解决两个核心问题:

  • 如何改变 URL 却不引起页面刷新?
  • 如何检测 URL 变化了?
    下面分别使用 hash 和 history 两种实现方式回答上面的两个核心问题。
1、hash 实现

hash 是 URL 中 hash (#) 及后面的那部分,常用作锚点在页面内进行导航,改变 URL 中的 hash 部分不会引起页面刷新。
通过 hashchange 事件监听 URL 的变化,改变 URL 的方式只有这几种:

  • 通过浏览器前进后退改变 URL
  • 通过标签改变 URL
  • 通过window.location改变URL
    这几种情况改变 URL 都会触发 hashchange 事件
// 监听路由变化
window.addEventListener('hashchange', onHashChange)
2、history 实现

history 提供了 pushState 和 replaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新。
history 提供类似 hashchange 事件的 popstate 事件,但 popstate 事件有些不同:

  • 通过浏览器前进后退改变 URL 时会触发 popstate 事件,
  • 通过pushState/replaceState或标签改变 URL 不会触发 popstate 事件。好在我们可以拦截 pushState/replaceState的调用和标签的点击事件来检测 URL 变化,所以监听 URL 变化可以实现,只是没有 hashchange 那么方便。
// 监听浏览器前进后退改变URL
window.addEventListener("popstate", onPopState);

3.3 路由劫持

  • 路由变化时匹配子应用
  • 执行子应用生命周期
  • 加载子应用

3.4 生命周期

  • 主应用
    • beforeLoad: 挂载子应用前。
    • mounted: 挂载子应用后。
    • ummounted: 卸载子应用后。
  • 子应用
    • bootstrap: bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
    • mount: 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法。
    • unmount: 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例。

3.5 资源加载

  • 加载样式表
  • 加载js资源
  • 执行js代码

3.6 预加载

具体实现

依赖包

"import-html-entry": "^1.12.0",
"path-to-regexp": "^6.2.1",
"qiankun": "^2.7.4"

start入口

import { IAppInfo, ILifeCycle } from './types';

import { setAppList, getAppList } from './appList/index';
import { setLifeCycle } from './lifeCycle/index';
import { hackRoute, reRoute } from './route/index';

export const registerMicroApps = (
  appList: IAppInfo[],
  lifeCycle?: ILifeCycle
) => {
  appList && setAppList(appList);
  lifeCycle && setLifeCycle(lifeCycle);
};

export const start = () => {
  const list = getAppList();
  if (!list.length) {
    throw new Error('请先注册应用');
  }

  hackRoute();
  reRoute(window.location.href);
};

存储appList

// appList/index
import { IAppInfo } from '../types';
let appList: IAppInfo[] = [];

export const setAppList = (list: IAppInfo[]): void => {
  appList = list;
};

export const getAppList = () => {
  return appList;
};

存储lifeCycle 以及生命周期方法的实现

import { ILifeCycle, IInternalAppInfo, IAppInfo } from '../types';
import { EAppStatus } from '../enum';
import { loadHTML } from '../loader'
let lifeCycle: ILifeCycle = {};

export const setLifeCycle = (lifeCycles: ILifeCycle): void => {
  lifeCycle = lifeCycles;
};

export const getLifeCycle = () => {
  return lifeCycle;
};

// 存储全局生命周期
// 卸载
export const runUnMounted = async (app: IInternalAppInfo) => {
  app.status = EAppStatus.UNMOUNTING;
  await app.unmounted?.(app);
  app.status = EAppStatus.NOT_MOUNTED;
  await runLifeCycle('unmounted', app);
};

// 初始化 只执行一次
export const runBootstrap = async (app: IInternalAppInfo) => {
  if (app.status !== EAppStatus.LOADED) {
    return app;
  }
  app.status = EAppStatus.BOOTSTRAPING;
  await app.bootstrap?.(app);
  app.status = EAppStatus.NOT_MOUNTED;
};
// 挂载 可多次执行
export const runMounted = async (app: IInternalAppInfo) => {
  app.status = EAppStatus.MOUNTING;
  await app.mounted?.(app);
  app.status = EAppStatus.MOUNTED;
  // 处理对应子应用生命周期
  await runLifeCycle('mounted', app);
};

// 加载前
export const runBeforeLoad = async (app: IInternalAppInfo) => {
  app.status = EAppStatus.LOADING;
  await runLifeCycle('beforeLoad', app);
  // 加载子应用资源
  // app = await loadHTML(app)
  app.status = EAppStatus.LOADED;
};

const runLifeCycle = async (name: keyof ILifeCycle, app: IAppInfo) => {
  // lifeCycles - LifeCycles - 可选,全局的微应用生命周期钩子
  const fn = lifeCycle[name];
  if (fn instanceof Array) {
    await Promise.all(fn.map((item) => item(app)));
  } else {
    await fn?.(app);
  }
};

TS相关类型-枚举

export enum EAppStatus {
   NOT_FOUND = 'NOT_FOUND',
  NOT_LOADED = 'NOT_LOADED',
  LOADING = 'LOADING',
  LOADED = 'LOADED',
  BOOTSTRAPPING = 'BOOTSTRAPPING',
  NOT_MOUNTED = 'NOT_MOUNTED',
  MOUNTING = 'MOUNTING',
  UNMOUNTED = 'UNMOUNTED',
  MOUNTED = 'MOUNTED',
  UNMOUNTING = 'UNMOUNTING',
}

TS相关类型

export interface IAppInfo {
  name: string
  entry: string
  container: string
  activeRule: string
}

export type Lifecycle = (app: IAppInfo) => Promise<any>

export interface ILifecycle {
  beforeLoad?: Lifecycle | Lifecycle[]
  mounted?: Lifecycle | Lifecycle[]
  unmounted?: Lifecycle | Lifecycle
}

export interface IInternalAppInfo extends IAppInfo {
  status: EAppStatus
  bootstrap?: Lifecycle
  mount?: Lifecycle
  unmount?: Lifecycle
  proxy: any
}

export type EventType = 'hashchange' | 'popstate'

路由拦截实现

import { EventType } from '../types'
import {
  runBoostrap,
  runBeforeLoad,
  runMounted,
  runUnmounted,
} from '../lifeCycle'
import { getAppListStatus } from '../utils'

const capturedListeners: Record<EventType, Function[]> = {
  hashchange: [],
  popstate: [],
}

// 劫持和 history 和 hash 相关的事件和函数
// 然后我们在劫持的方法里做一些自己的事情
// 比如说在 URL 发生改变的时候判断当前是否切换了子应用

const originalPush = window.history.pushState
const originalReplace = window.history.replaceState

let historyEvent: PopStateEvent | null = null

let lastUrl: string | null = null

export const reroute = (url: string) => {
  if (url !== lastUrl) {
    const { actives, unmounts } = getAppListStatus()
    Promise.all(
      unmounts
        .map(async (app) => {
          await runUnmounted(app)
        })
        .concat(
          actives.map(async (app) => {
            await runBeforeLoad(app)
            await runBoostrap(app)
            await runMounted(app)
          })
        )
    ).then(() => {
      callCapturedListeners()
    })
  }
  lastUrl = url || location.href
}

const handleUrlChange = () => {
  reroute(location.href)
}

export const hackRoute = () => {
  window.history.pushState = (...args) => {
    originalPush.apply(window.history, args)
    historyEvent = new PopStateEvent('popstate')
    args[2] && reroute(args[2] as string)
  }
  window.history.replaceState = (...args) => {
    originalReplace.apply(window.history, args)
    historyEvent = new PopStateEvent('popstate')
    args[2] && reroute(args[2] as string)
  }

  window.addEventListener('hashchange', handleUrlChange)
  window.addEventListener('popstate', handleUrlChange)

  window.addEventListener = hackEventListener(window.addEventListener)
  window.removeEventListener = hackEventListener(window.removeEventListener)
}

const hasListeners = (name: EventType, fn: Function) => {
  return capturedListeners[name].filter((listener) => listener === fn).length
}

const hackEventListener = (func: Function): any => {
  return function (name: string, fn: Function) {
    if (name === 'hashchange' || name === 'popstate') {
      if (!hasListeners(name, fn)) {
        capturedListeners[name].push(fn)
        return
      } else {
        capturedListeners[name] = capturedListeners[name].filter(
          (listener) => listener !== fn
        )
      }
    }
    return func.apply(window, arguments)
  }
}

export function callCapturedListeners() {
  if (historyEvent) {
    Object.keys(capturedListeners).forEach((eventName) => {
      const listeners = capturedListeners[eventName as EventType]
      if (listeners.length) {
        listeners.forEach((listener) => {
          // @ts-ignore
          listener.call(this, historyEvent)
        })
      }
    })
    historyEvent = null
  }
}

export function cleanCapturedListeners() {
  capturedListeners['hashchange'] = []
  capturedListeners['popstate'] = []
}

loader 加载器

import { IInternalAppInfo } from '../types'
import { importEntry } from 'import-html-entry'
import { ProxySandbox } from './sandbox'

export const loadHTML = async (app: IInternalAppInfo) => {
  const { container, entry } = app

  const { template, getExternalScripts, getExternalStyleSheets } =
    await importEntry(entry)
  const dom = document.querySelector(container)

  if (!dom) {
    throw new Error('容器不存在')
  }

  dom.innerHTML = template

  await getExternalStyleSheets()
  const jsCode = await getExternalScripts()

  jsCode.forEach((script) => {
    const lifeCycle = runJS(script, app)
    if (lifeCycle) {
      app.bootstrap = lifeCycle.bootstrap
      app.mount = lifeCycle.mount
      app.unmount = lifeCycle.unmount
    }
  })

  return app
}

const runJS = (value: string, app: IInternalAppInfo) => {
  if (!app.proxy) {
    app.proxy = new ProxySandbox()
    // @ts-ignore
    window.__CURRENT_PROXY__ = app.proxy.proxy
  }
  app.proxy.active()
  const code = `
    return (window => {
      ${value}
      return window['${app.name}']
    })(window.__CURRENT_PROXY__)
  `
  return new Function(code)()
}

ProxySandbox

export class ProxySandbox {
  proxy: any
  running = false
  constructor() {
    const fakeWindow = Object.create(null)
    const proxy = new Proxy(fakeWindow, {
      set: (target: any, p: string, value: any) => {
        if (this.running) {
          target[p] = value
        }
        return true
      },
      get(target: any, p: string): any {
        switch (p) {
          case 'window':
          case 'self':
          case 'globalThis':
            return proxy
        }
        if (
          !window.hasOwnProperty.call(target, p) &&
          window.hasOwnProperty(p)
        ) {
          // @ts-ignore
          const value = window[p]
          if (typeof value === 'function') return value.bind(window)
          return value
        }
        return target[p]
      },
      has() {
        return true
      },
    })
    this.proxy = proxy
  }
  active() {
    this.running = true
  }
  inactive() {
    this.running = false
  }
}

预加载

export const prefetch = async (app: IInternalAppInfo) => {
  requestIdleCallback(async () => {
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(
      app.entry
    )
    requestIdleCallback(getExternalStyleSheets)
    requestIdleCallback(getExternalScripts)
  })
}

4、qiankun 和 wujie 对比

https://qiankun.umijs.org/zh
https://wujie-micro.github.io/doc/guide/

当谈到微前端中的 wujie 和 qiankun 时,它们都是目前国内比较流行的微前端框架。
下面是它们的优缺点对比:

wujie 的优点:

  1. 简单易用:wujie 提供了简单易用的 API,使得构建微前端应用变得更加容易。
  2. 灵活性:wujie 允许开发团队使用不同的技术栈来构建独立的微前端应用,从而提供了更大的灵活性。
  3. 性能优化:wujie 通过按需加载和缓存机制来优化性能,减少了加载时间和带宽消耗。

wujie 的缺点:

  1. 社区支持相对较少:相对于 qiankun,wujie 的社区支持相对较少,这可能会导致在解决问题时遇到一些困难。
  2. 文档相对不完善:wujie 的文档相对不完善,可能需要开发者花费更多的时间来理解和使用该框架。

qiankun 的优点:

  1. 成熟稳定:qiankun 是一个成熟稳定的微前端框架,已经在许多生产环境中得到验证。
  2. 强大的生态系统:qiankun 有一个庞大的社区支持和活跃的开发者社区,提供了丰富的插件和工具,使得开发更加便捷。
  3. 性能优化:qiankun 通过资源预加载、按需加载和缓存机制来优化性能,提供了更好的用户体验。

qiankun 的缺点:

  1. 学习曲线较陡峭:相对于 wujie,qiankun 的学习曲线可能较陡峭,需要开发者花费一些时间来学习和理解该框架。
  2. 对主应用的侵入性较大:qiankun 对主应用的侵入性较大,需要在主应用中进行一些特定的配置和修改。

综上所述,wujie 和 qiankun 都有各自的优点和缺点。选择哪个框架取决于具体的项目需求、团队技术栈和开发者的偏好。

拓展

除了 wujie 和 qiankun,还有其他一些好用的微前端框架可供选择。以下是其中几个:

1. Single-SPA:

Single-SPA 是一个非常流行的微前端框架,它允许开发团队使用不同的技术栈来构建独立的前端应用,并将它们组合成一个整体的应用。它具有灵活性和可扩展性,并且有一个活跃的社区支持。

2. Piral:

Piral 是一个基于 Web Components 的微前端框架,它提供了一种模块化的方式来构建和组合前端应用。它具有良好的可扩展性和性能,并且支持多种技术栈。

3. Luigi:

Luigi 是一个用于构建微前端应用的开源框架,它提供了一种可插拔的方式来组合和集成不同的前端应用。它具有良好的可扩展性和灵活性,并且支持多种技术栈。

4. Mosaic:

Mosaic 是一个基于 Web Components 的微前端框架,它提供了一种模块化的方式来构建和组合前端应用。它具有良好的可扩展性和性能,并且支持多种技术栈。

这些框架都有各自的特点和优势,选择适合自己项目需求和团队技术栈的框架是很重要的。建议在评估这些框架时,考虑其文档质量、社区支持、可扩展性和性能等因素。

;