手写single-spa,理解微前端原理

一. single-spa介绍

微前端架构是行业流行的解决方案,主要有以下两个优点:

  • 技术栈无关
  • 协作效率高,如独立构建,独立部署

single-spa是一个微前端框架,其核心原理是使用路由劫持,当路由与子应用匹配时会执行加载子应用逻辑。

二. 技术方案

2.1 整体架构

2.1.1 主应用

主应用作为整个框架基座,负责提供基本页面布局和注册子应用。

2.1.1.1 基本布局

页面结构根据业务诉求做调整,核心逻辑是提供一个子应用挂载节点。

javascript 复制代码
function App() {
  return (
    <div>
      <header>
        <h1>Master</h1>
      </header>
      <div>
        <nav>
          <ul>
            <li onClick={() => history.pushState(null, '', '/home')}>home</li>
            <li onClick={() => history.pushState(null, '', '/account')}>
              account
            </li>
          </ul>
        </nav>
        <main>
          <div id='subapp' />
        </main>
      </div>
    </div>
  )
}
2.1.1.2 注册子应用

首先定义一个single-spa.config.js文件,然后在入口文件引入。

javascript 复制代码
import { registerApplication, start } from 'single-spa'

registerApplication(
  '@qianqian/home',
  () => System.import('@qianqian/home'),
  location => location.pathname.startsWith('/home'),
)

registerApplication(
  '@qianqian/account',
  () => System.import('@qianqian/account'),
  location => location.pathname.startsWith('/account'),
)

start()

其次在主应用的html文档添加一个script标签,核心逻辑是注册子应用模块可执行文件引用路径。

html 复制代码
<!-- 引入systemjs -->
<script src="/common/system.0579ab.js"></script>
<!-- 注册子应用模块可执行文件引用路径 -->
<script type="systemjs-importmap">
  {
    "imports": {
      "@qianqian/account": "http://localhost:8001/main.js",
      "@qianqian/home": "http://localhost:8002/main.js"
    }
  }
</script>

2.1.2 子应用

子应用入口文件暴露生命周期钩子函数,如bootstrapmountunmount

javascript 复制代码
let root = null

const lifecycles = {
  bootstrap(opts) {
    return Promise.resolve()
  },
  mount(opts) {
    return new Promise(resolve => {
      root = createRoot(document.querySelector('#subapp'))
      root.render(<App />)
      resolve()
    })
  },
  unmount(opts) {
    return new Promise(resolve => {
      if (root) {
        root.unmount()
        root = null
      }
      resolve()
    })
  },
}

export const { bootstrap, mount, unmount } = lifecycles

2.2 整体流程

整体执行流程如图所示,首先先注册子应用路由,然后添加路由拦截逻辑。

当有路由与子应用匹配时,执行加载子应用逻辑,判断当前子应用是否首次加载,如果是需要先获取子应用入口可执行文件,然后调用bootstrap生命周期函数,接着调用mount生命周期函数。

当路由变化加载别的子应用时则需要先调用当前子应用的unmount生命周期函数。

2.3 构建产物

子应用构建产物类型有两种: modulesystem,可以通过webpack output.libraryTarget配置。

产物类型 说明
module 优点是可以直接使用浏览器支持ESM模块化机制,缺点是webpack不支持热更新,本地开发体验不友好
system 优点是支持热更新,缺点是需要额外引入systemjs进行模块解析

2.4 缺点

2.4.1 代码分割

子应用不支持使用webpack.optimization配置,因为single-spa架构设计理念是子应用提供入口执行文件,主应用获取该文件并执行,如果子应用使用了webpack.optimization配置,那么js产物会分割成多个chunk,那入口执行文件的逻辑就不完整,主应用执行该文件就不能正常触发子应用业务逻辑。

相对的,single-spa推荐使用webpack.externals配置。

2.4.2 mini-css-extract-plugin

子应用不支持使用mini-css-extract-plugin,原因参考2.2.1

2.4.3 js沙盒和css隔离

single-spa没有js沙盒和css隔离机制,需要额外使用其它方案处理。

css隔离常见方案:

  • css moudle
  • styled component
  • shadow DOM

js沙盒常见方案:

  • 快照沙箱
  • Proxy沙箱

三. 实现single-spa

3.1 定义registerApplication

registerApplication方法用于注册子应用,入参如下:

  • appName: 子应用名称
  • loadApp: 获取子应用入口可执行文件
  • activeWhen: 判断是否加载子应用
  • customProps: 传递子应用自定义属性
javascript 复制代码
const apps = []

function registerApplication(appName, loadApp, activeWhen, customProps) {
  apps.push({
    appName,
    loadApp,
    activeWhen,
    customProps,
    status: NOT_LOADED,
  })
}

3.2 定义start

start方法主要添加路由拦截逻辑和调用加载子应用方法。

javascript 复制代码
function patchedUpdateState(updateState) {
  return function () {
    const urlBefore = window.location.href
    const result = updateState.apply(this, arguments)
    const urlAfter = window.location.href
    if (urlBefore !== urlAfter) {
      window.dispatchEvent(
        new PopStateEvent('popstate', { state: window.history.state }),
      )
    }
    return result
  }
}

// 路由拦截
function patchHistoryApi() {
  window.addEventListener('popstate', () => {
    reroute()
  })
  window.history.pushState = patchedUpdateState(window.history.pushState)
}

function start() {
  patchHistoryApi()
  reroute()
}

3.3 reroute

处理子应用加载逻辑。核心逻辑如下:

  • 根据当前路由判断需要加载/卸载子应用
  • 调用要卸载子应用的unmount生命周期函数
  • 调用要加载子应用的bootstrapmount生命周期函数
javascript 复制代码
function reroute() {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
  performAppChanges()

  function performAppChanges() {
    return Promise.resolve().then(() => {
      const unmountPromises = appsToUnmount.map(toUnmountPromise)
      const unmountAllPromise = Promise.all(unmountPromises)
      const loadThenMountPromises = appsToLoad.map(app =>
        toLoadPromise(app).then(app =>
          tryToBootstrapAndMount(app, unmountAllPromise),
        ),
      )
      const mountPromises = appsToMount.map(app =>
        tryToBootstrapAndMount(app, unmountAllPromise),
      )
      // 先执行子应用卸载逻辑,再执行子应用加载逻辑
      return unmountAllPromise.then(() =>
        Promise.all(loadThenMountPromises.concat(mountPromises)),
      )
    })
  }
}

3.3.1 getAppChanges

根据子应用的statusactiveWhen方法判断是否加载/卸载。

javascript 复制代码
function shouldBeActive(app) {
  return app.activeWhen(window.location)
}

function getAppChanges() {
  const appsToUnmount = []
  const appsToLoad = []
  const appsToMount = []
  apps.forEach(app => {
    const appShouldBeActive = shouldBeActive(app)
    switch (app.status) {
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) appsToLoad.push(app)
        break
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (appShouldBeActive) appsToMount.push(app)
        break
      case MOUNTED:
        if (!appShouldBeActive) appsToUnmount.push(app)
        break
    }
  })

  return {
    appsToLoad,
    appsToMount,
    appsToUnmount,
  }
}

3.3.2 lifecycle

子应用生命周期函数处理逻辑。

3.3.2.1 load

获取子应用入口可执行文件暴露的生命周期钩子函数。

javascript 复制代码
function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) return app.loadPromise
    if (app.status !== NOT_LOADED) return app
    app.status = LOADING_SOURCE_CODE
    return (app.loadPromise = Promise.resolve().then(() => {
      const loadPromise = app.loadApp(app.customProps)
      return loadPromise.then(lifecycle => {
        app.status = NOT_BOOTSTRAPPED
        app.bootstrap = lifecycle.bootstrap
        app.mount = lifecycle.mount
        app.unmount = lifecycle.unmount
        delete app.loadPromise
        return app
      })
    }))
  })
}
3.3.2.2 bootstrap

子应用首次加载时调用。

javascript 复制代码
function toBootstrapPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== NOT_BOOTSTRAPPED) return app
    app.status = BOOTSTRAPPING
    return app.bootstrap(app.customProps).then(() => {
      app.status = NOT_MOUNTED
      return app
    })
  })
}
3.3.2.3 mount

子应用每次加载时调用。

javascript 复制代码
function toMountPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== NOT_MOUNTED) return app
    app.status = MOUNTING
    return app.mount(app.customProps).then(() => {
      app.status = MOUNTED
      return app
    })
  })
}
3.3.2.4 unmount

子应用每次卸载时调用。

javascript 复制代码
function toUnmountPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== MOUNTED) return app
    app.status = UNMOUNTING
    return app.unmount(app.customProps).then(() => {
      app.status = NOT_MOUNTED
      return app
    })
  })
}

四. 踩坑

4.1 webpack resolve.modules配置

在使用pnpm构建monorepo项目时,需要注意子项目webpack resolve.modules配置中的node_modules不要使用绝对路径,否则会导致依赖模块解析错误。原因是pnpm对于项目没有直接声明引用的依赖是存放在node_modules/.pnpm目录下。

javascript 复制代码
// error
module.exports = {
  // ...
  resolve: {
    modules: [path.resolve(__dirname, 'src'), path.resolve(__dirname), 'node_modules'],
  },
}

// right
module.exports = {
  // ...
  resolve: {
    modules: [path.resolve(__dirname, 'src'), 'node_modules'],
  },
}

五. 总结

single-spa是一个优秀的微前端框架,其核心理念值得借鉴学习。但是single-spa也存在一些不足之处,如对子应用的代码产物有比较多的限制,上手成本也相对较高,需要对模块化规范有比较深的了解。代码仓库

创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!

相关推荐
喝拿铁写前端1 小时前
前端与 AI 结合的 10 个可能路径图谱
前端·人工智能
codingandsleeping1 小时前
浏览器的缓存机制
前端·后端
-代号95272 小时前
【JavaScript】十二、定时器
开发语言·javascript·ecmascript
灵感__idea3 小时前
JavaScript高级程序设计(第5版):扎实的基本功是唯一捷径
前端·javascript·程序员
摇滚侠3 小时前
Vue3 其它API toRow和markRow
前端·javascript
難釋懷3 小时前
JavaScript基础-history 对象
开发语言·前端·javascript
beibeibeiooo3 小时前
【CSS3】04-标准流 + 浮动 + flex布局
前端·html·css3
拉不动的猪3 小时前
刷刷题47(react常规面试题2)
前端·javascript·面试
浪遏3 小时前
场景题:大文件上传 ?| 过总字节一面😱
前端·javascript·面试
计算机毕设定制辅导-无忧学长3 小时前
HTML 与 JavaScript 交互:学习进程中的新跨越(一)
javascript·html·交互