Skip to content

手写微前端demo

慕课网教程的主/子应用 都是基于webpack的(vue项目基于vue-cli)

只是为了手写框架, 示例尽可能简单

这里只做一个简单的 Tab功能

框架完善后, 编写一个内管系统

micro-frontend-learndemo - mygithub

vite + 原生html/js 为宿主应用搭建微前端框架

webpack5 + vue2.7 为子应用

仿照 qiankun 的简易微前端框架

qiankun 又是基于 single-spa 所以本demo和qiankunsingle-spa会很像

目前不支持子应用使用 vite 打包工具,因为是读取子应用 html 直接注入主应用容器的

而vite开发阶段dev用的是esm,并且dev拦截资源按需编译,主应用中本地不会启动子应用?

TODO: 微前端原理 另开一篇讲

TODO: microCore 实现现在市面上多种微前端方案, 通过不同的入口文件暴露出来使用

  • qiankun/single-spa 阿里
  • micor-app 京东零售
  • 无界
  • iframe - 携程

注册子应用配置信息

qiankun-在主应用中注册微应用

js
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);

start();

我们实现自己的 registerMicroApps

暂时可以看出是一个存储到 核心类库 中的一些配置项

vite.config.ts 中的 defineConfig({})

创建主应用

使用 vite 创建原生 html 应用

👇 mainApp/src/main.ts

ts
import { registerMicroApps } from '../../microCore/index';
import './style.css'

document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
  <div>
    <img src="/vite.svg" class="logo" alt="Vite logo" />
    <h1>Vite + TypeScript</h1>
  </div>
`

registerMicroApps([
  {
    name: 'vue3 app',
    entry: '//localhost:7100',
    container: '#yourContainer2',
    activeRule: '/vue2demo',
  }
]);

👆 删除一些多余内容, 并按 qiankun 调用注册子应用方法 registerMicroApps

创建微应用核心库

因为是主应用调用核心库, 因此主应用的 ts 配置需要包含 核心库 目录

配置 mainApp/tsconfig.json 添加 "../microCore"

json
"include": ["src", "../microCore"]

👇 microCore/index.ts

ts
// 微前端核心类库 提供给 mainAPP 使用
interface SubappInfo {
  name: string,
  entry: string,
  container: string,
  activeRule: string,
}

export function registerMicroApps(option :SubappInfo[]) {
  console.log('存储子应用注册信息', option)
}

microCore 新增 const/index.ts 用于存储运行时的注册子应用信息

ts
import type { SubappInfo } from '../type'

let subappList:SubappInfo[] = []
export const getAppList = () => subappList
export const setList = (appList:SubappInfo[]) => subappList = appList

👇 microCore/index.ts

ts
// 微前端核心类库 提供给 mainAPP 使用
import { setAppList } from './const'
import type { SubappInfo } from './type'

export function registerMicroApps(option :SubappInfo[]) {
  setAppList(option) // <-- this
}

拦截路由

完成注册子应用信息后, 调用 微前端核心库start 来重写路由机制, 让子应用的路由都经过 核心库

👇 microCore/router/rewriteRouter.ts

ts
/**
 * 重写 history API
 */
export const rewriteRouter = () => {

  const originalPushState = window.history.pushState
  const originalReplaceState = window.history.replaceState

  window.history.pushState = function () {
    originalPushState.apply(this, arguments)
    console.log('history.pushState')
  }

  window.history.replaceState = function() {
    originalReplaceState.apply(this, arguments)
    console.log('history.replaceState')
  }

  window.onpopstate = function() {
    console.log('onpopstate')
  }
}

👇 microCore/index.ts

ts
export function registerMicroApps(option :SubappInfo[]) {
  setAppList(option)
  rewriteRouter() // <-- this
}

控制台输入 history.pushState(null, '', '/foo') 触发了我们的拦截 console

浏览器前进返回触发 打印 'onpopstate'

🤔 视频里利用 自定义事件监听器来触发 回调

ts
export const rewriteRouter = () => {
  rewriteOriginFn(window.history.pushState, 'customerEventListenerPush') // 传入 未执行的原生方法 和 自定义事件名字符串
  rewriteOriginFn(window.history.replaceState, 'customerEventListenerReplace')

  window.addEventListener('customerEventListenerPush', openApp) // 创建 自定义事件
  window.addEventListener('customerEventListenerReplace', openApp)
}
ts
function rewriteOriginFn(originFn, eventListenerName) {
  return function() {
    originFn.apply(this, argument) // 执行原生方法
    const e = new Event(eventListenerName) // 利用 new Event 把事件监听器名字符串 实例化为 事件
    window.dispatchEvent(e) // 通过 dispatchEvent 执行事件实例
  }
}

👆 rewriteOriginFn 封装重写原生方法的重复部分可以理解

🤔 但是第2个参数完全可以是一个回调函数, 为什么要用自定义事件监听器的方式调用回调?TODO:

当前URL匹配子应用注册信息

通过 location 中的 pathName 来匹配注册信息中的子应用数据对象

VueRouter 原理文章中 我们利用 Vue.util.reactive 把当前Url数据转为响应式数据

通过 watch 来触发 回调 render

现在没有响应式数据, 则需要自己手动在所有 URL 改变的地方手动触发 render

  • 页面 init 时, 如浏览器输入地址 xxx/vue2demo/xx刷新
  • 主/子应用, 通过 pushState 等方法跳转页面(各现代前端 Router 的原理底层 )

qiankun 这些步骤发生在 start()

👇 microCore/utils/index.ts

ts
import { getAppList } from "../const"
import { SubappInfo } from "../type"

/**
 * 获取 URL 上的 pathname 作为 子应用name
 * @returns 
 */
export const getSubappNameByUrl = () => {
  return window.location.pathname
}

/**
 * 根据当前 URL 和 子应用注册列表 匹配出当前子应用信息
 */
export const getCurrentSubappInfo:()=>SubappInfo|null = () => {

  const appList = getAppList()
  const urlAppName = getSubappNameByUrl()

  const res = appList.find(item => item.activeRule === urlAppName)
  return res ?? null
}

👇 microCore/index.ts

ts
export function start() {
  // 判断子应用注册是否为空
  const appList = getAppList()
  if(!appList.length) {
    throw '子应用列表为空, 请使用 registerMicroApps() 注册至少1个子应用'
  }

  // 获取当前 URL 匹配到的子应用信息
  const currentAppInfo = getCurrentSubappInfo()
  if(!currentAppInfo) return

  console.log('currentAppInfo',currentAppInfo)
}

编写主应用 Tab 功能

ts
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
  <div>
    <img src="/vite.svg" class="logo" alt="Vite logo" />
    <h1>Vite + TypeScript</h1>
    <ul>
      <li>/vue2demo#page1</li>
      <li>/vue2demo#page2</li>
    </ul>
  </div>
`
document.querySelectorAll('li')?.forEach(ele=>{
  ele.addEventListener('click',()=>{
    window.history.pushState(null,'', ele.innerText)
  })
})

加载子应用

前面只是拦截了路由没有写加载逻辑

👇 microCore/load/loadSubApp.ts

ts
import { getCurrentSubappInfo } from "../utils"

export const loadApp = ()=>{
  // 获取当前 URL 匹配到的子应用信息
  const currentAppInfo = getCurrentSubappInfo()
  if(!currentAppInfo) return

  console.log('加载', currentAppInfo.activeRule)
}

👆 可以看出和 start 里逻辑重复, start 处理初始化和刷新逻辑

在路由监听逻辑里都调用这个函数

❕ 期望的效果是 子应用切换时才加载 同一个子应用不应该再次加载

所以应该判断 当前已加载的子应用 与 切换的子应用是否同一个,同一个时不触发 load

暂时用全局变量存 当前已加载的子应用

👇 microCore/load/loadSubApp.ts

ts
/**
 * 加载 子应用
 * @returns 
 */
export const loadApp = ()=>{
  // 获取当前 URL 匹配到的子应用信息
  const currentAppInfo = getCurrentSubappInfo()
  if(!currentAppInfo) return

  if(window.__CURRENT_SUB_APP__ === currentAppInfo.activeRule) return

  console.log('加载', currentAppInfo.activeRule)

  window.__CURRENT_SUB_APP__ = currentAppInfo.activeRule // 定义 当前已加载的子应用 判断同一个子应用不触发load
}

ts定义 Window 全局变量 只要在 tsconfig.json 识别范围内,定义在哪都可以

👇 microCore/type.ts

ts
declare global {
  interface Window {
    __CURRENT_SUB_APP__: string;
  }
}

此时还没有编写初次加载页面/刷新时加载的逻辑

一般来说和监听路由里的逻辑重复写

但是尝试一下 加载页面/刷新 时触发 pushState 从而触发路由监听试试 会不会因此多出一层路由

/vue2demo push /vue2demo

浏览器返回时 是否会导致2层

直接在控制台测试下来 pushstate 与当前 location.href 不会多出一层相同的历史

但是在实际编写的时候,在有路由历史的情况下 刷新并触发pushstate当前url 会丢失历史,相当于刷新后的路由变成了首个路由

TODO: 不 pushstate 的刷新可以正常返回, 不刷新的 pushstate 也可以返回, 但是刷新 + pushstate 就这样了???

这里暂时先 重复写监听路由里相同的逻辑

👇 microCore/index.ts

ts
export function start() {
  // 判断子应用注册是否为空
  const appList = getAppList()
  if(!appList.length) {
    throw '子应用列表为空, 请使用 registerMicroApps() 注册至少1个子应用'
  }

  // 获取当前 URL 匹配到的子应用信息
  // const currentAppInfo = getCurrentSubappInfo()
  // if(!currentAppInfo) return

  // console.log('init currentAppInfo',location.pathname + location.hash)
  // history.pushState(null, '', location.href)
  // window.__CURRENT_SUB_APP__ = currentAppInfo.activeRule // 定义 当前已加载的子应用 判断同一个子应用不触发load
  loadApp() // <-- this
}

主应用中定义通用生命周期

在主应用编写 生命周期

microCore 的执行过程中调用传入进来的生命周期

也就是会作用于加载所有子应用的过程x

👇 主应用入口文件注册子应用信息

ts
registerMicroApps(subAppList,
  {
    beforeLoad:[
      ()=>{
        console.log('开始加载')
      }
    ],
    mounted:[
      ()=>{
        console.log('渲染完成')
      }
    ],
    destoryed:[
      ()=>{
        console.log('销毁完成')
      }
    ]
  }
)

生命周期的钩子逻辑是简单的发布订阅机制,把未执行的函数列表先存储起来,在执行到的某个时机去相应的调用这些函数

我们和存储子应用信息方式相同 创建 microCore/const/mainLifeCycle.ts

ts
import type { LifeCycles } from '../type'

let mainLifeCycles:LifeCycles = {}
export const getMainlLifeCycles = () => mainLifeCycles
export const setMainlLifeCycles = (mainlLifeCycles:LifeCycles) => mainLifeCycles = mainlLifeCycles

👇 microCore/type.ts

ts
export interface LifeCycles {
  beforeLoad?: Function[],
  mounted?: Function[],
  destoryed?: Function[],
}

编写调用逻辑

因为上面 首次加载/刷新 和 监听路由变化触发 的 loadSubApp 抽离到了一起

因此 microCore/load/loadSubApp.ts

ts
export const loadApp = async ()=>{
  // 获取当前 URL 匹配到的子应用信息
  const currentAppInfo = getCurrentSubappInfo()
  if(!currentAppInfo) return

  if(window.__CURRENT_SUB_APP__ === currentAppInfo.activeRule) return

  console.log('加载', currentAppInfo.activeRule)

  // 1. 调 开始前 生命周期 // <-- this
  const {beforeLoad, mounted, destoryed} = getMainlLifeCycles()
  beforeLoad?.forEach(fn=>fn())

  // 2. 加载子应用(耗时) // <-- this
  await sleep()
  mounted?.forEach(fn=>fn())

  // 3. 调 完成 生命周期 // <-- this
  destoryed?.forEach(fn=>fn())

  window.__CURRENT_SUB_APP__ = currentAppInfo.activeRule // 定义 当前已加载的子应用 判断同一个子应用不触发load
}

生命周期效果如 👇

生命周期loading实践

表示左边的表达式不能判断是否有值再赋值, 即使有 ?. 也不能确定 innerHTML 是否存在

换成 !. 就能排除掉前面是 nullundefined的情况 document.querySelector('#yourContainer')!.innerHTML = '子应用加载中'

TODO:

子应用生命周期

子应用生命周期为注册信息对象中的属性

ts
registerMicroApps([
  {
    name: 'vue2.7 app',
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/vue2demo',
    beforeLoad: () => console.log('vue2demo 生命周期 beforeLoad'), // <-- 子应用生命周期
    mounted: () => console.log('vue2demo 生命周期 mounted'),
    destoryed: () => console.log('vue2demo 生命周期 destoryed')
  }
], {
  beforeLoad: [], // <-- 主应用生命周期
  mounted: [],
  destoryed: [],
})

👆 主/子应用 生命周期触发时机相同 相当于主应用设置通用逻辑 子应用设置自定义逻辑

要获取前子应用和后子应用, 才能分别触发他们的生命周期

在匹配到目标路径是注册信息中的另一个子应用时 👇 修改当前子应用全局变量标识 的同时记录原子应用路径

ts
window.__ORIGIN_SUB_APP__ = window.__CURRENT_SUB_APP__ // 修改 __CURRENT_SUB_APP__ 的值前 记录原值作为 preSubApp
window.__CURRENT_SUB_APP__ = currentAppInfo.activeRule // 定义 当前已加载的子应用 判断同一个子应用不触发load

👇 调用目录子应用的生命周期不需要判断

ts
export const loadApp = async ()=>{
  // 获取当前 URL 匹配到的子应用信息
  const currentAppInfo = getCurrentSubappInfo()
  if(!currentAppInfo) return

  if(window.__CURRENT_SUB_APP__ === currentAppInfo.activeRule) return

  // 1. 调 开始前 生命周期
  const {beforeLoad, mounted, destoryed} = getMainlLifeCycles()
  beforeLoad?.forEach(fn=>fn())
  currentAppInfo?.beforeLoad?.() // <-- this

  // 2. 加载子应用(耗时) 调 完成 生命周期
  await sleep()
  mounted?.forEach(fn=>fn())
  currentAppInfo?.mounted?.() // <-- this

  window.__ORIGIN_SUB_APP__ = window.__CURRENT_SUB_APP__ // 修改 __CURRENT_SUB_APP__ 的值前 记录原值作为 preSubApp
  window.__CURRENT_SUB_APP__ = currentAppInfo.activeRule // 定义 当前已加载的子应用 判断同一个子应用不触发load
}

👇 调用销毁 则需要获取上一个子应用存在时, 并且时机是修改全局变量变量之后

ts
export const loadApp = async ()=>{

  // ... 略
  window.__ORIGIN_SUB_APP__ = window.__CURRENT_SUB_APP__ // 修改 __CURRENT_SUB_APP__ 的值前 记录原值作为 preSubApp
  window.__CURRENT_SUB_APP__ = currentAppInfo.activeRule // 定义 当前已加载的子应用 判断同一个子应用不触发load

  // 3. 销毁上一个子应用调 销毁 生命周期 // <-- this
  const preSubApp = findSubAppInfo(window.__ORIGIN_SUB_APP__)
  if(preSubApp) {
    destoryed?.forEach(fn=>fn())
    preSubApp?.destoryed?.()
  }
}

nodejs脚本批量启动前端项目

初始化vue2.7 + webpack 子应用

👇 run.mjs

js
import { spawn } from 'node:child_process'
import { fileURLToPath, URL } from 'node:url'

const demoPath = {
  vue2: fileURLToPath(new URL('./subApp', import.meta.url)),
  main: fileURLToPath(new URL('./mainApp', import.meta.url))
}

Object.values(demoPath).forEach(path => {
  spawn(`cd ${path} && pnpm dev`, { stdio: 'overlapped', shell: true })
})

加载并渲染子应用资源

👇 microCore/loadResource.ts

ts
/**
 * fetch 请求静态资源返回文件内容
 * @param url 
 * @returns 
 */
export const fetchResource = (url:string) => {
  return fetch(url).then(res => res.text())
}

👇 跨域问题

subApp/build/webpack.dev.config.js

js
devServer: {
  allowedHosts: 'all', // 无效...
  headers: { 'Access-Control-Allow-Origin': '*' }, // allowedHosts 配置了也不能跨域访问本静态资源服务器 需要配置 headers
}

👆 子应用 devServer 配置允许跨域

解析HTML内容

👇 把读取到的 html 文本内容通过 innerHTML 挂载到子应用配置信息的 container 节点上

ts
const htmlContent = await fetchResource(currentAppInfo.entry) // 请求到 html 内容
mountSubApp(htmlRes, currentAppInfo)
ts
const mountSubApp = (htmlContent:string, appInfo:SubappInfo) => {
  const subAppRootDom = document.querySelector(appInfo.container)
  subAppRootDom!.innerHTML = htmlContent
}

至此, html 虽然挂载上去了, 但是子应用内容是空的, 因为子应用是 SPA 应用, 需要加载到子应用 JS 才能渲染出内容

解析JS内容

ts
/**
 * 解析 HTMl 文本内容 成 JS/CSS/HTML 数据
 */
export const pasrseHtml = (htmlContent:string) => {

  // 1. 创建一个div存放 fetch 到的html文本内容
  // 这么做是为了去掉html body 标签,  方便数据处理和递归
  const divDom = document.createElement('div')
  divDom.innerHTML = htmlContent
  parseScript(divDom) // 此时的 divDom 的内容是平级的 meta div script
  
  return htmlContent
}

递归结构

ts
const parseScript = (root: HTMLDivElement) => {

  function deepParse (element) {
    const children = element.children // 类数组 不能用forEach
    for (const item of children) {
      deepParse(item)
    }
  }

  deepParse(root)
}

👇 处理js

ts
function deepParse (element:HTMLElement) {

  if(element.nodeName.toLowerCase() === 'script') {
    const src = element.getAttribute('src')
    if(!src) {
      // 非外部链接的js资源 内联逻辑
      script.push(element.outerHTML)
    } else {
      // 外部链接 绝对路径/相对路径
      if(src.startsWith('http')) {
        scriptUrl.push(src)
      }else{
        scriptUrl.push(`http:${entry}/${src}`)
      }
    }
  }

  const children = element.children // 类数组 不能用forEach
  for (const item of children) {
    deepParse(item)
  }
}

得到 script 请求的 绝对路径, 通过 fetch 请求得到 js文本内容

👇 通过 eval 执行 js文本内容

ts
const [htmlRes, jsList] = await pasrseHtml(htmlContent, currentAppInfo.entry)
mountSubApp(htmlRes, currentAppInfo)
jsList.forEach(item => eval(item))

至此 子应用的 内容也渲染出来了

调整子应用生命周期及模块化

👆 配置子应用生命周期是在 注册子应用信息 时编写的

为了允许 子应用 前端应用可以独立运行(不依靠主应用)

需要 子应用 的入口逻辑分为2种情况触发

  1. 常规 SPA 加载 入口逻辑 立即触发渲染
  2. 微前端环境 由主应用调用子应用中的生命周期(上面定义在子应用注册信息中)触发渲染

设置 微前端环境变量 告诉 前端项目 现在是作为子应用运行在微前端的主应用里

修改子应用入口逻辑

js
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

const render = () => {
  new Vue({
    render: h => h(App)
  }).$mount('#app')
}

// 入口逻辑立即执行 new Vue 改为非微前端环境下 执行
if (!window.__MICRO_WEB__) {
  render()
}

// 抛出 子应用生命周期函数
export const beforeLoad = () => console.log('vue2demo 生命周期 beforeLoad')
export const mounted = () => {
  console.log('vue2demo 生命周期 mounted')
  render() // 微前端环境下 由生命周期执行 new Vue
}
export const destoryed = () => console.log('vue2demo 生命周期 destoryed')

👇 执行子应用入口逻辑前 设置环境变量

ts
window.__MICRO_WEB__ = true // 执行子应用入口逻辑前 设置环境变量
jsList.forEach(item=>eval(item))

修改子应用打包模块化方式

👇 子应用 出口配置 webpack

js
output: {
  path: resolveApp('dist'),
  filename: 'js/[name].[hash:6].js',
  chunkFilename: 'js/[name].chunk.[hash:4].js',

  // 把子应用打包成 umd 库格式 // <-- this
  library: 'vue2demo',
  libraryTarget: 'umd'
}

👇 devServer 打包后的 chunk

执行js文本获取抛出的变量

要获取到 umd 模块化抛出的内容, 只要满足一种判断条件就行

  • CJS
  • AMD
  • 全局变量

视频的做法是通过全局变量获取到里面抛出的内容

ts
window.__MICRO_WEB__ = true // 执行子应用入口逻辑前 设置环境变量
jsList.forEach(item => eval(item))

console.log(window.vue2demo) // <-- this { beforeLoad, ... }

👆 TODO: 奇怪的是只能执行一次, 如 vue2 -> vue3 -> vue2 第二次 vue2 加载并执行 umd 后, window.vue2demo 为 undefined, 无法执行 mounted

可以用 tsup 打包一个简单模块化 umd 包尝试访问多次

除此之外, 我们还可以伪造一个符合 CJS 判断条件的对象用于获取

手写过 CJS 就知道该模块化的本质就是往一个对象中存储数据, 再获取出来

ts
window.__MICRO_WEB__ = true // 执行子应用入口逻辑前 设置环境变量

window.exports = {} // <-- this
jsList.forEach(item => eval(item))

console.log(window.exports) // <-- this { vue2demo: { beforeLoad, ...} }

沙箱机制

快照沙箱

在子应用js逻辑中挂载挂载变量到 window 上, 希望这个操作被隔离

因为子应用所有的 js 逻辑都在 UMD 模块化中

只要想办法把这个 UMD 模块内部的 window 被改写就可以了

可以通过局部变量实现 模块内访问 window 就优先取局部变量

首先 我们需要一个被改写的 window (也就是一份拷贝) 这种方式称为 快照沙箱

ts
export const snapShotSandbox = () => {
  const sandboxWindow = {}

  const getSandboxWindow = ()=>{
    return sandboxWindow
  }

  const active = () => {
    for (const key in window) {
      sandboxWindow[key] = window[key]
    }
    return getSandboxWindow()
  }

  const inactive = () => {
    for (const key in window) {
      const sandboxVal = sandboxWindow[key]
      if(window[key] !== sandboxVal) {
        try {
          window[key] = sandboxVal
        } catch (error) {
          console.log('还原快照window err', error)
        }
      }
    }
  }
  return {
    active, inactive
  }
}

👇 注入到 umd 中

ts
const jsText = `
  (()=>{
    const self = ${sandboxWindow}
    ${item}
  })()
`
eval(jsText)

👆 模板字符串语法中拼接的变量 对象时被转字符串会是 [object Object]

因此不能这么把 拷贝的 window 塞进去

只能挂载在 window 下, 通过字符串访问到

ts
const { active } = snapShotSandbox()
window.sandboxWindow = active()

const jsText = `
  (()=>{
    const self = window.sandboxWindow
    ${item}
  })()
`
eval(jsText)

👆 报错 self is not a function

猜测不能用 self 变量, 会自动被浏览器转为 window

ts
const jsText = `
  (()=>{
    const window = window.sandboxWindow
    ${item}
  })()
`

👆 报错 window 内置变量不能作变量名

ts
const jsText = `
  ((window)=>{
    ${item}
  })(window.sandboxWindow)
`
eval(jsText)

👆 可以发现这样也是不对的 要通过 window.sandboxWindow 才能访问

我们再来🤔一下

在子应用js逻辑中挂载挂载变量到 window 上, 希望这个操作被隔离

只要想办法把 UMD 模块内部的 window 被改写就可以了

可以通过局部变量实现 模块内访问 window 就优先取局部变量

👆 这种思路的效果就子应用操作的是自己的作用域, 这样就不不像是一个应用了

要的效果是 切换到 子应用A , 快照拷贝一份被子应用操作前的 window

当切换到其他子应用前把 window 还原为快照 window (也就是子应用操作的是真正的 window 只不过这次操作会在之后还原)

👇 umd 执行依然直接操作 window

ts
jsList.forEach(item => eval(item)) // 触发第二次 eval 全局模块变量会被置空?

👇 通过销毁子应用时机 还原回原来的 window

ts
const { active, inactive } = snapShotSandbox()
currentAppInfo.sandbox = { active, inactive } // 存储到子应用信息中 用于通过上一个子应用来还原对应的(每个子应用的沙箱快照不同)沙箱快照
active()

// 3. 销毁上一个子应用调 销毁 生命周期
const preSubApp = findSubAppInfo(window.__ORIGIN_SUB_APP__)
if(preSubApp) {
  // 还原 window 为快照
  preSubApp?.sandbox?.inactive()
}

代理沙箱

快照沙箱不支持同时启动多个子应用

通过 new Proxy 让所有子应用操作的 window 是另一个对象(模块化中传入的 window 不能是原 window 而是代理后的 window)

set 的都是另一个对象

get 时则判断另一个对象属性是否存在,不存在则取原 window 上的属性

ts
export const proxySandbox = () => {
  let proxyWindow = {} // 每个子应用都调用一次 active 即 proxyWindow 不会复用

  const active = () => {
    return new Proxy(window, {
      get(window, key){
        return proxyWindow[key] || window[key]
      },
      set(window, key, newVal){
        proxyWindow[key] = newVal
        return true
      }
    })
  }

  const inactive = () => {
    proxyWindow = {}
  }

  return {
    active, inactive
  }
}
ts
// const { active, inactive } = snapShotSandbox() // <-- this
const {active, inactive} = proxySandbox()
currentAppInfo.sandbox = { active, inactive } // 存储到子应用信息中 用于通过上一个子应用来还原对应的(每个子应用的沙箱快照不同)沙箱快照
const proxyWindow = active()


// jsList.forEach(item => eval(item)) // 触发第二次 eval 全局模块变量会被置空?
window.proxyWindow = proxyWindow
jsList.forEach(item => {
  eval(`
    ((window)=>{
      ${item}
    })(window.proxyWindow)
  `)
})

👆 快照沙箱 中提到不能用 window 作变量名来改写, 因此用 window 的临时变量传入(当多个子应用时 临时变量将根据执行顺序需要被多次改写, 因此多个子应用时属于不可信的变量, 不建议操作或读取它)

验证: 主/子应用 分别加一个按钮 consolewindow.a (用控制台不能操作到代理后 window, 只会输出原 window)

ts
// 主应用
document.querySelector('button')?.addEventListener('click', ()=>{
  console.log(window.a) // --> undefined
})

// vue2.7 子应用
setup(){
  function getWindowA () {
    console.log(window.a) // --> '1'
  }
  return {
    getWindowA
  }
}

样式隔离

CSS Modules

shadow DOM

子应用单独css文件

webpack 一般打包生产环境的 css 会拆分出来

而只要拆分出来 根据子应用切换请求以及移除相关 CSS 文件 就自然形成了样式隔离

通信 46 47 48 49

  • 通过生命周期 往 子应用info 里放一个共享数据/方法 自定义数据结构来操作对方
  • customevent - 原生自带的发布订阅功能 等同于 自己写个发布订阅类 eventBus.on/emit

store存储

主应用 入口文件往 window 上挂载微前端核心提供的 store

子应用通过 window.store 操作 (没有vue的响应式功能, 只是普通的数据存储共享)

👇 存储数据 并 提供 发布订阅 update 后自动触发

ts
import { Store } from "../type"
/**
 * 操作存储数据的功能 store
 * 并 提供 发布订阅 update 后自动触发
 */
export const createStore:()=>Store = () => {
  let store = {}
  const observers: Function[] = []

  const getStore = () => store

  const setStore = (newVal:{}) => {
    // newVal 不等于 原数据 才触发发布订阅
    if(newVal !== store) {
      const oldVal = store // 暂存后 赋新值
      store = newVal
      // 自动触发发布订阅
      observers.forEach(fn => fn(newVal, oldVal))
    }
  }

  const addSubscribe = (fn: Function) => {
    observers.push(fn)
  }

  return {
    getStore, setStore, addSubscribe
  }
}

👇 主应用

ts
window.store = createStore()
// 添加订阅者 每次修改 store 都会触发这个回调
window.store.addSubscribe((newVal:{}, oldVal:{}) => {
  console.log('Subscribe', newVal,oldVal)
})

const originStore = window.store.getStore()
window.store.setStore({
  ...originStore,
  a: 'a'
})

registerMicroApps()
start()

子应用就可以通过 window.store 操作

性能优化

缓存子应用静态资源

首次 fetch 子应用 html/JS 时缓存 后续切换时不发出 fetch 请求

ts
const cache = {} // 以子应用name 来缓存html/JS 内容

// 2. 加载子应用(耗时) 调 完成 生命周期
// 添加缓存判断
if(!cache[currentAppInfo.name]) {
  const htmlContent = await fetchResource(currentAppInfo.entry)
  const [htmlRes, jsList] = await pasrseHtml(htmlContent, currentAppInfo.entry)
  cache[currentAppInfo.name] = [htmlRes, jsList] // 添加缓存
}

const [htmlRes, jsList] = cache[currentAppInfo.name]
mountSubApp(htmlRes, currentAppInfo)

预加载子应用

加载当前子应用结束后 就加载其他子应用

👇 主应用

ts
registerMicroApps()

start()

preFetchApp(['/vue2demo'])
ts
import { findSubAppInfo, getUrlPathName } from "../utils"
import { fetchApp } from "./cacheFetch"

export const preFetchApp = (appNameList:string[]) => {
  appNameList.forEach(appname => {
    const urlAppName = getUrlPathName()
    if(urlAppName === appname) return  // 当前pathname加载由 start 触发 return 避免重复加载

    const appinfo = findSubAppInfo(appname)
    if(appinfo) {
      fetchApp(appinfo)
    }
  })
}
ts
import { SubappInfo } from "../type"
import { fetchResource, pasrseHtml } from "./loadResource"

const cache = {} // 以子应用name 来缓存html/JS 内容

export const fetchApp = async (appinfo:SubappInfo) => {
  // 添加缓存判断
  if(!cache[appinfo.name]) {
    const htmlContent = await fetchResource(appinfo.entry)
    const [htmlRes, jsList] = await pasrseHtml(htmlContent, appinfo.entry)
    cache[appinfo.name] = [htmlRes, jsList] // 添加缓存
  }

  return cache[appinfo.name]
}

ts 怎么定义 运行时的 对象数据 如👆 cahce[name] 其中name 是用户输入的值 用 typeof ?

npm发布并加自动化流程 55 - 61