前端微服务在现在的前端项目中也不陌生,经常在一些大型的项目中有看到过它的身影,而如今最火也最常用的,当初qiankun了,今天我们来聊一聊qiankun是如何实现微服务的

一、前端微服务架构的价值与应用场景

首先,为什么需要有微服务这么一种技术架构呢,它的出现解决了什么问题呢?我们先来看几个场景。

  • 假设你正在维护迭代一个大型项目,但是你每次迭代只是在修改项目的某一个模块,但你每次的代码开发,提交,部署都需要拉上整个项目。耗费的资源更多,速度更慢。这时候你是不是就会想,要是可以只部署这一个模块就好了
  • 假设你公司有一个很陈旧的老项目,用的很老旧的技术架构,你们需要在这个的基础之上去迭代新的需求,想用新的技术架构去进行迭代,你是不是就会想,要是新增部分,能独立开发,独立部署就好了

是的,此时,微服务它就诞生了
解决的问题与优势:

  1. 巨石应用拆分:解决单体前端工程臃肿、维护困难的问题,支持多团队并行开发
  2. 技术栈无关:主应用与子应用可使用不同框架(React/Vue/Angular),实现渐进式升级
  3. 独立部署,可跨团队协作:子应用可独立构建部署,无需整体发布,降低发布风险
  4. 资源按需加载:仅加载当前路由匹配的子应用,优化首屏性能

典型应用场景:

  • 大型后台管理系统(如阿里云控制台)
  • 多产品线聚合平台(如电商导购门户)
  • 遗留系统现代化改造
  • 跨团队协作项目

**二、qiankun 核心实现原理详解

微服务的工作本质,是能够把多个独立项目能够拼接组装成一个大型项目,那既然是拼接的,我们肯定就会疑惑,它是如何把几个毫无联系的独立应用串起来的,它中间做了些什么呢?又是如何进行数据通信的呢?又是如何保证几个应用相互不冲突呢?

诶,下面我们就来看看,qiankun是怎么做的

2.1 子应用注册机制

首先,会有一个主应用的概念,用来管理协调各个子应用,而其他子应用,都需要在主应用中进行注册
原理:通过配置对象声明子应用信息,qiankun 创建应用管理器进行统一调度

// 主应用注册子应用
import { registerMicroApps, start } from 'qiankun';

// registerMicroApps内部最终还是通过调用single-spa的registerApplication方法去注册应用
registerMicroApps([
  {
    name: 'react-app',  // 应用唯一标识
    entry: '//localhost:3001',  // 子应用入口地址
    container: '#subappReact',  // 挂载容器
    activeRule: '/react',  // 路由匹配规则
    props: { user: 'admin' }  // 透传数据
  }{
    name: 'vue-app',  // 应用唯一标识
    entry: '//localhost:3002',  // 子应用入口地址
    container: '#subappVue',  // 挂载容器
    activeRule: '/vue',  // 路由匹配规则
    props: { user: 'admin' }  // 透传数据
  }
]);

start();  // 启动 qiankun

关键注解

  • activeRule 支持函数/字符串/正则,实现动态路由匹配
  • props 实现主应用向子应用的数据注入
  • 应用实例存储在全局 apps 数组中统一管理

子应用注册,并配置了匹配规则,当项目运行时,浏览器访问路由页面或页面路由发生变化时,qiankun会用当前的路由去和注册的规则表进行一个匹配,当匹配到某个规则时,会开始去加载当前规则的子应用(根据配置的entry地址加载子应用),加载完成后,将加载到的内容挂载到配置的container元素上,这个container元素,一定是主应用中的某个元素。

2.2 路由规则匹配

实现流程

  1. 监听 hashchangepopstate 事件
  2. 遍历所有注册应用的 activeRule
  3. 执行匹配算法确定需加载的子应用
// 路由匹配核心逻辑(简化版)
function matchApp(currentPath) {
  return apps.filter(app => {
    if (typeof app.activeRule === 'function') {
      return app.activeRule(currentPath);
    }
    // pathPrefix是个工具函数,里面是一系列的匹配规则
    return pathPrefix(app.activeRule, currentPath);
  });
}

通过匹配确定了需要加载某个应用后,会去取到该app的entry属性,通过这个地址去加载子应用,加载完成后,去执行子应用。那么我们之前有说到,每个子应用,它都是相互独立的,是不会相互影响的,那当多个子应用代码集中在一起执行时,必然要通过隔离机制,将各个子应用以及主应用相互隔离,这必然要用到沙箱机制

2.3 沙箱机制(核心安全隔离)

qiankun主要用到3种沙箱类型
沙箱类型

类型 原理 适用场景
LegacySandbox 单例代理 兼容性要求高
ProxySandbox 多例代理 多实例并行
SnapshotSandbox 快照恢复 IE 等老旧浏览器

现在最普遍的就是ProxySandbox,我们以ProxySandbox来做说明

代理沙箱
代理沙箱的本质是为每个微应用创建一个 window 的代理对象,微应用对全局变量的操作都发生在代理上,不会影响真实的 window。可以理解为,每个子应用代码中的顶级对象不再是window,而是一个集成了window部分属性的代理对象,因此,所有子应用的全局变量定义,函数定义等,本质都会挂在这个代理对象下,然后子应用代码都执行在一个自调用的函数中。从而实现js互不干扰

// 代理沙箱实现(核心代码)
// 简化的 ProxySandbox 实现
class ProxySandbox {
  constructor(name) {
    this.name = name;
    // 创建一个空的 fakeWindow 作为初始全局对象副本
    const rawWindow = window; // 保存真实的 window
    const fakeWindow = Object.create(null); 
    
    // 用于记录在沙箱内新增或修改的全局变量
    this.updatedPropsMap = new Map(); 

    // 创建 Proxy 代理 fakeWindow
    this.proxy = new Proxy(fakeWindow, {
      set: (target, prop, value) => {
        // 将修改记录在 updatedPropsMap 中,而不是直接设置到 rawWindow
        this.updatedPropsMap.set(prop, value);
        // 这里也可以选择将修改同步到 fakeWindow 上,供内部读取
        target[prop] = value;
        return true;
      },
      get: (target, prop) => {
        // 优先从修改记录中取
        if (this.updatedPropsMap.has(prop)) {
          return this.updatedPropsMap.get(prop);
        }
        // 否则,从真实的 window 上读取(如一些原生 API)
        const value = rawWindow[prop];
        if (typeof value === 'function') {
          return value.bind(rawWindow); // 保持函数执行上下文正确
        }
        return value;
      },
      has: (target, prop) => {
        return prop in rawWindow || this.updatedPropsMap.has(prop);
      }
    });
  }
}

css隔离
而css隔离主要是通过给每个子应用的所有样式添加一个特殊的前缀(data-qiankun=xxx)来实现

// 样式隔离的简化思路(通常在处理 HTML 模板时进行)
function processHTML(html, appName) {
// 使用一个唯一的属性选择器为所有样式规则添加作用域
const scopedHTML = html.replace(/<style>([\s\S]*?)<\/style>/g, (match, styleContent) => {
 const scopedStyle = styleContent.replace(/([^{]+\{)([^}]+)(\})/g, (fullMatch, selectorPart, contentPart, endPart) => {
   // 简化处理:为所有简单选择器添加 [data-qiankun="appName"] 前缀
   // 实际实现会更复杂,需要处理各种复杂选择器
   const scopedSelector = selectorPart.split(',').map(selector => {
     return `[data-qiankun="${appName}"] ${selector.trim()}`;
   }).join(', ');
   return `${scopedSelector} { ${contentPart} ${endPart}`;
 });
 return `<style>${scopedStyle}</style>`;
});
return scopedHTML;
}

2.4 子应用加载执行流程

生命周期时序
在这里插入图片描述

加载逻辑
当通过路由匹配到需要加载的app后,会通过importEntry方法去加载子应用,方法内部会利用import-html-entry库去解析子应用,解析出模板,样式以及需要执行的script等,执行script时,会放置在沙箱中执行

// 简化的 loadApp 函数核心逻辑
export async function loadApp(appConfig) {
  // 1. 获取并解析 HTML 入口
  const { template, getExternalScripts, getExternalStyleSheets } = await importHTML(appConfig.entry);
  
  // 2. 创建沙箱环境
  const sandbox = createSandbox(appConfig.name);
  sandbox.active(); // 激活沙箱

	const fakeWindow = sandbox.proxy;

  // 3. 获取并执行样式
  const styles = await getExternalStyleSheets();
  styles.forEach(styleContent => {
    const style = document.createElement('style');
    style.textContent = styleContent;
    document.head.appendChild(style);
  });

  // 4. 关键逻辑:获取脚本并放在沙箱中执行
  const scripts = await getExternalScripts();
  scripts.forEach(scriptCode => {
    // 使用 with 语句和 Proxy 将脚本中的全局变量访问指向伪造的 window (fakeWindow)
    const wrappedCode = `
      with(fakeWindow) {
        ${scriptCode}
      }
    `;
    // 在沙箱上下文(如 iframe 或 Proxy 创建的隔离环境)中执行代码
    (function(fakeWindow) { eval(wrappedCode); }).bind(sandbox.proxy)(sandbox.proxy); 
  });

  // 5. 返回一个包含生命周期钩子的对象
  return {
    // 通常,子应用的 JS 入口文件会将其生命周期函数挂载到全局,这里需要获取它们
    bootstrap: window[`${appConfig.name}_bootstrap`],
    mount: window[`${appConfig.name}_mount`],
    unmount: window[`${appConfig.name}_unmount`],
  };
}

​​with语句​​:改变 JavaScript 的作用域链查找顺序。代码中所有未明确作用域的变量(如 window)会优先从 sandbox.proxy(也就是fakeWindow)中查找。

2.5 生命周期调度机制

而子应用中,需要暴露对应的生命周期接口,供主应用调用,主应用会在子应用挂载时,调用对应的生命周期函数
标准化生命周期

// 子应用暴露的接口(React 示例)
export const bootstrap = async () => {
  console.log('初始化资源');
};

export const mount = async (props) => {
  ReactDOM.render(<App/>, props.container);
};

export const unmount = async (props) => {
  ReactDOM.unmountComponentAtNode(props.container);
};

qiankun 调度引擎

// 生命周期执行器(核心)
async function execHooks(app, hookName) {
  const hooks = app[hookName] || [];
  for (const hook of hooks) {
    await hook(app.props); // 透传容器等参数
  }
}

// 挂载流程
async function mountApp(app) {
  await execHooks(app, 'beforeMount');
  await execHooks(app, 'mount');  // 执行子应用的 mount
  await execHooks(app, 'afterMount');
}

2.6 通信原理

通信机制特点

  • 发布订阅模式:状态变更通知所有关联应用
  • 单向数据流:主应用作为数据源
  • 防循环触发:通过事务ID避免无限循环

qiankun 提供了一个简单的全局状态管理工具,基于发布-订阅模式

// 简化的 initGlobalState 实现
class GlobalState {
  constructor(initialState = {}) {
    this.state = initialState;
    this.listeners = [];
  }

  setGlobalState(newState) {
    this.state = { ...this.state, ...newState };
    // 通知所有订阅者
    this.listeners.forEach(listener => listener(this.state));
  }

  onGlobalStateChange(callback) {
    this.listeners.push(callback);
    // 返回一个取消订阅的函数
    return () => {
      const index = this.listeners.indexOf(callback);
      if (index > -1) {
        this.listeners.splice(index, 1);
      }
    };
  }
}

let globalStateInstance = null;

export function initGlobalState(state) {
  if (globalStateInstance === null) {
    globalStateInstance = new GlobalState(state);
  }
  return globalStateInstance;
}

子应用可以通过onGlobalStateChange监听state的变化,并通过setGlobalState去改变全局状态,全局状态一旦发生改变,会立刻通知所有的其他监听者

export async function mount(props) {
  // 监听数据变化
  props.onGlobalStateChange((state) => {
    // state: 变更后的状态;
    if (!state.routes.length) {
      const _state = {
        routes: routes
      }
      // 设置数据
      props.setGlobalState(_state);
    }
  }, true); // 第二个参数设置为 true 表示一上来就执行一次
  render(props)
}

start初始化
主应用注册完子应用后,会调用start方法进行初始化

// 基于 src/apis.ts 和 src/start.ts 简化
export function start(opts: FrameworkConfiguration = {}) {
  // 1. 设置全局变量,标记 qiankun 启动状态
  window.__POWERED_BY_QIANKUN__ = true; // :cite[2]
  
  // 2. 初始化全局配置,使用默认值合并用户配置
  frameworkConfiguration = {
    prefetch: true,
    singular: true, // 默认单例模式
    sandbox: true,  // 默认开启沙箱
    ...opts,
  };
  
  const {
    prefetch,
    singular = true,
    sandbox = true,
    ...importEntryOpts
  } = frameworkConfiguration;
  
  // 3. 配置预加载策略
  if (prefetch) {
    // 在第一个应用挂载后预加载其他应用资源 :cite[2]
    listenAfterFirstMount(() => prefetchAfterFirstMounted(apps, prefetch));
  }
  
  // 4. 设置沙箱模式
  if (sandbox) {
    if (window.Proxy) {
      // 支持 Proxy 的浏览器使用先进的沙箱
      if (singular) {
        legacySandbox = true;
      } else {
        proxySandbox = true;
      }
    } else {
      // 不支持 Proxy 的浏览器使用降级方案 SnapshotSandbox :cite[1]
      snapshotSandbox = true;
    }
  }
  
  // 5. 启动 single-spa
  startSingleSpa({ urlRerouteOnly: true }); // :cite[7]
  frameworkStartedDefer.resolve(); // 标记框架已启动
}
三、模块联邦

有时当我们有些功能模块需要在各个组件之间复用时,比如A应用的一个模块,希望其他应用也能够调用它。这是一个很常见的需求,之前,webpack5提供了一个模块联邦的能力,很好的解决了这个问题。

模块联邦在构建阶段就明确了模块的“提供方”和“消费方”。在运行时,消费方应用会通过一个全局的模块映射表,动态拉取并提供方应用暴露的代码并执行。这更像是“代码复用”而非传统“通信”,但能达到共享逻辑和状态的效果。

实现原理:

远程应用:暴露一些模块给其他应用使用。这些模块可以是组件、页面、工具函数等。

主机应用:消费(使用)远程应用暴露的模块。

它不是在“加载一个应用”,而是在“加载一个模块”,而这个模块恰好可以是一个完整的 React/Vue 应用。


配置详情:
远程应用配置 (app1/webpack.config.js)

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: ‘app1’,
      filename: ‘remoteEntry.js’, // 入口文件
      exposes: {./Button’:./src/Button’, // 暴露一个按钮组件./App’:./src/App’, // 暴露整个App组件
      },
      shared: { react: { singleton: true }, ‘react-dom’: { singleton: true } }, // 共享依赖
    }),
  ],
};

主机应用配置 (host/webpack.config.js):

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: ‘host’,
      remotes: {
        app1: ‘app1@http://localhost:3001/remoteEntry.js’, // 引用远程应用
      },
      shared: { react: { singleton: true }, ‘react-dom’: { singleton: true } },
    }),
  ],
};

使用:

// 像导入本地模块一样导入远程模块!
import RemoteButton from ‘app1/Button’;
import RemoteApp from ‘app1/App’;

function HostApp() {
  return (
    <div>
      <h1>我是主应用</h1>
      <RemoteButton />
      <RemoteApp />
    </div>
  );
}
四、总结

加载流程复盘:
用户访问 https://portal.com。

1、主应用加载,初始化路由系统。

2、用户点击导航跳转到 /app1。

3、主应用的路由器捕获到变化,查询映射表,发现 /app1 对应 //app1.com/bundle.js。

4、主应用通过 动态脚本加载(如 System.import() 或 自己创建

5、主应用创建沙箱环境,在沙箱中运行微应用代码

6、微应用的脚本执行后,会向全局(如 window)暴露其生命周期函数(window.app1.bootstrap, window.app1.mount 等)。

7、主应用先调用 app1.bootstrap() 进行初始化。

8、主应用准备好一个 DOM 容器(如

),然后调用 app1.mount({ container: ‘#micro-app-container’, props: {…} })。

9、微应用 app1 在自己的 mount 方法中,将整个应用渲染到主应用提供的容器中。

10、当用户从 /app1 导航到 /app2 时,主应用会先调用 app1.unmount() 进行清理,然后重复步骤 4-9 来加载和挂载 app2。

本文剖析了 qiankun 的大致的架构设计与实现,其目标是让读者能够以一个更简单的角度来更简单的理解qiankun以及微服务的大致原理。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐