微前端落地实践:从调研到上线

公司有个运行了四年的后台管理系统,技术栈是 Vue 2 + Element UI。业务越来越复杂,代码量已经到了 15 万行,打包一次要 8 分钟,开发体验极差。

新需求还要加,但没人敢动老代码。老板一拍脑袋:要不试试微前端?

为什么选微前端

其实一开始考虑的是重构。但评估下来,全部重写至少要 3 个月,而且风险很大——这系统是公司的核心业务,出问题谁都担不起。

微前端的好处是渐进式迁移,新模块用新技术栈,老模块不动,慢慢消化。

微前端架构图

(图:理想的微前端架构,每个模块独立部署)

技术选型

调研了几个方案:

方案优点缺点
iframe最简单,天然隔离性能差,体验差,弹窗无法全屏
qiankun成熟,社区活跃基于 single-spa,有一定学习成本
Module FederationWebpack 5 原生支持需要升级构建工具,老项目改动大
EMP字节开源,功能丰富文档一般,社区较小

最后选了 qiankun,主要原因是:社区成熟、文档完善、对老项目侵入性小。

实施过程

第一步:改造主应用

主应用(基座)负责加载子应用、路由分发、全局状态。

// main-app/src/micro-app.js
import { registerMicroApps, start } from 'qiankun';

const apps = [
  {
    name: 'old-system',
    entry: '//localhost:8081', // 老系统
    container: '#subapp-container',
    activeRule: '/legacy',
  },
  {
    name: 'new-module',
    entry: '//localhost:8082', // 新模块
    container: '#subapp-container',
    activeRule: '/new',
  },
];

registerMicroApps(apps);
start();

主应用的路由:

const routes = [
  { path: '/', component: Home },
  { path: '/legacy/:pathMatch(.*)*', component: MicroAppContainer },
  { path: '/new/:pathMatch(.*)*', component: MicroAppContainer },
];

第二步:改造老系统

这是最麻烦的部分。老系统是 Vue CLI 2 时代建的,webpack 版本很老。

首先升级构建配置,暴露出 qiankun 需要的生命周期钩子:

// vue.config.js
const { name } = require('./package.json');

module.exports = {
  devServer: {
    port: 8081,
    headers: {
      'Access-Control-Allow-Origin': '*', // 允许跨域
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

然后在入口文件导出生命周期:

// main.js
import Vue from 'vue';
import App from './App';
import router from './router';

let instance = null;

function render(props = {}) {
  const { container } = props;
  instance = new Vue({
    router,
    render: h => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

// qiankun 生命周期
export async function bootstrap() {
  console.log('old-system bootstrap');
}

export async function mount(props) {
  console.log('old-system mount', props);
  render(props);
}

export async function unmount() {
  console.log('old-system unmount');
  instance.$destroy();
  instance = null;
}

改造过程中的报错截图

(图:改造过程中遇到的各种报错,全靠 stackoverflow 续命)

第三步:路由处理

老系统内部有自己的路由,和主应用的路由需要协调。

// 老系统的 router.js
const router = new VueRouter({
  mode: 'history',
  base: window.__POWERED_BY_QIANKUN__ ? '/legacy' : '/',
  routes: [
    // ...原有路由
  ],
});

这里有个坑:如果老系统用了 router.push('/some-path'),会跳到根路径,而不是 /legacy/some-path。需要加一层包装:

const originalPush = router.push;
router.push = function(location) {
  if (window.__POWERED_BY_QIANKUN__) {
    if (typeof location === 'string') {
      location = '/legacy' + location;
    } else {
      location.path = '/legacy' + (location.path || '');
    }
  }
  return originalPush.call(this, location);
};

第四步:样式隔离

qiankun 提供了 sandbox 选项,但不是万能的。

start({
  sandbox: {
    strictStyleIsolation: true, // 使用 Shadow DOM
  },
});

Shadow DOM 会带来新问题:某些 UI 库的弹窗样式会失效。最后改用 scoped CSS 方案,手动处理样式前缀。

// vue.config.js
module.exports = {
  css: {
    loaderOptions: {
      postcss: {
        postcssOptions: {
          plugins: [
            require('postcss-prefix-selector')({
              prefix: '[data-app=legacy]',
              transform(prefix, selector) {
                if (selector === 'body' || selector === 'html') {
                  return selector;
                }
                return prefix + ' ' + selector;
              },
            }),
          ],
        },
      },
    },
  },
};

遇到的坑

坑 1:全局变量污染

老系统往 window 上挂了一堆东西,子应用切换后没清理,导致状态残留。

解决:在 unmount 时手动清理。

export async function unmount() {
  instance.$destroy();
  instance = null;
  // 清理全局变量
  delete window.someGlobalVariable;
}

坑 2:第三方库不兼容

某些库(比如 Element UI 的弹窗)默认挂载到 document.body,子应用卸载后 DOM 还在。

解决:配置 appendTo 选项,或者用 qiankun 提供的 createAppContainer

坑 3:开发环境跨域

本地开发时,主应用和子应用端口不同,有跨域问题。

解决:在子应用的 devServer 里配置 CORS。

devServer: {
  headers: {
    'Access-Control-Allow-Origin': '*',
  },
}

上线效果

指标改造前改造后
打包时间8 分钟主应用 1 分钟 + 子应用 2 分钟
首屏加载3.2s1.8s(按需加载子应用)
部署整体发布模块独立发布
新功能开发不敢动新模块随便玩

上线后的监控数据

(图:上线后的性能监控,可以看到加载时间明显下降)

后续规划

  1. 继续拆分老系统的模块
  2. 新模块尝试用 React + TypeScript
  3. 建立子应用的公共组件库
  4. 完善监控和错误上报

总结

微前端不是银弹,能不动最好不动。但如果真的遇到巨石应用的问题,它确实是一个可行的解决方案。

关键是:不要一次性迁移,先跑通一个模块,再逐步扩展。我们花了一个月时间才把第一个子应用跑起来,后面的就快了。

最后感谢 qiankun 团队,文档写得很好,issue 里也能找到大部分问题的解决方案。