手写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 子应用

子应用入口文件暴露生命周期钩子函数,如bootstrap,mount和unmount。

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 构建产物

子应用构建产物类型有两种: module和system,可以通过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生命周期函数
  • 调用要加载子应用的bootstrap和mount生命周期函数
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

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

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也存在一些不足之处,如对子应用的代码产物有比较多的限制,上手成本也相对较高,需要对模块化规范有比较深的了解。代码仓库

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

相关推荐
定栓3 分钟前
Webpack迁到Vite第一节:模块系统迁移:require → import
运维·前端·webpack
小熊在忙fpga12 分钟前
N32G430芯片的flash表示
前端·javascript·html
Data analyse45618 分钟前
App埋点版本兼容的字段迁移校验
大数据·运维·服务器·前端·数据库·数据分析
NC 王炸23 分钟前
AI 做的便签,换个浏览器就没了?加一个导出按钮,把数据带走
javascript·ai编程·数据备份·localstorage·txt导出
todoitbo24 分钟前
本来只想给内网开个口子,Natapp 却多给了一道防护
前端·javascript·vue.js·内网穿透·网络防护·natapp
会灭火的程序员29 分钟前
SuperMap AgentX Server智能体接入WEB应用技术文档
前端·supermap·agentx·agentx server
CRMEB34 分钟前
商品 SKU 设计,从简单规格到多规格踩过什么
java·前端·数据库·开源
Chloe.Zz35 分钟前
一篇入门Agent
开发语言·前端·javascript
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 表格标签
前端·html·html5
silvamux_1 小时前
4款新模型商务邮件翻译横评:0.98到0.46
android·java·javascript