icestark微前端实战中的那些坑与优化技巧

a'ゞ凡敬 框架 阅读 1,583
赞 7 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

项目用 icestark 做微前端架构,一开始没太在意性能,只觉得能跑就行。结果上线后用户反馈首页加载慢,子应用切换卡顿,甚至有时候点一下菜单,要等两三秒才有反应。我自己在低端安卓机上试了下,简直是灾难——页面白屏久、点击无响应、滚动都掉帧。

icestark微前端实战中的那些坑与优化技巧

最离谱的一次是主应用刚启动时,首屏渲染完,切到一个子应用,loading 转了整整 5 秒才出来内容。我看了眼控制台,发现那会儿 JS 线程完全被占满,主线程一堆任务堆着,根本没法交互。

当时就意识到:不能再拖了,必须动手优化。

找到瘼颈了!

先上 Chrome DevTools 的 Performance 面板录了一段完整的子应用切换过程。分析下来,有几个明显的问题:

  • 子应用资源(JS/CSS)全部是动态加载,但没有预加载策略,点到才开始 fetch
  • 每次 mount 时都要执行整个应用的初始化逻辑,包括一些非必要的副作用
  • 某些子应用打包体积太大,超过 1MB,gzip 后还有 300KB+
  • 主应用和子应用共享依赖没处理好,react/react-dom 居然被重复加载了两次

我还顺手加了个简单的性能埋点,在路由跳转前后打 timestamp,统计实际从“开始加载”到“mount 完成”的时间。数据一拉,平均是 4.8s,P90 直接破 6s。这哪是用户体验,这是挑战用户耐心极限。

预加载救了一命

第一个想到的就是预加载。不能等到用户点击才去拉代码,太晚了。于是我在主应用路由变化的时候,提前对即将进入的子应用做资源探测和加载。

icestark 本身提供了 loadApp 方法,可以手动触发加载但不挂载。这个 API 就很适合干这事。

我改成了这样:当用户进入某个一级菜单页时,就开始预加载其下的子应用资源。

// 主应用中监听路由变化
import { loadApp } from '@ice/stark';

const SUB_APP_MAP = {
  '/finance': { url: ['https://jztheme.com/finance/js/index.js'], entry: 'https://jztheme.com/finance/' },
  '/user': { url: ['https://jztheme.com/user/js/index.js'], entry: 'https://jztheme.com/user/' },
};

let pendingApp = null;

function prefetchSubApp(path) {
  const config = SUB_APP_MAP[path];
  if (!config || pendingApp === path) return;

  pendingApp = path;
  // 使用 loadApp 预加载但不挂载
  loadApp({
    name: path.slice(1),
    entry: config.entry,
    activePath: path,
  }).finally(() => {
    pendingApp = null;
  });
}

// 在 react-router 的 Route 组件里调用
// 比如进入 /home 时预加载 /finance
useEffect(() => {
  if (location.pathname === '/home') {
    prefetchSubApp('/finance');
    prefetchSubApp('/user');
  }
}, [location.pathname]);

这个改动之后,首次切换到 finance 子应用的时间直接从 4.8s 降到了 1.6s 左右。因为大部分资源已经在背后悄悄下载好了,真正切换时只需要执行 mount 逻辑。

这里注意我踩过好几次坑:loadApp 的 entry 必须和 registerMicroApps 里的配置一致,否则会重复加载。而且不要滥用预加载,太多并发请求反而会阻塞主流程。

懒初始化 + 条件 mount

第二个问题是子应用 mount 太重。有些子应用自己一启动就发一堆接口、初始化全局状态、注册事件监听,这些其实没必要在 mount 阶段做。

于是我跟子团队沟通,把他们的 bootstrap 和 mount 生命周期拆得更细。把非核心逻辑挪到 mount 之后手动触发,比如通过一个全局 emit 通知:“你现在被激活了,可以加载数据了”。

// 子应用改造后的入口
let mounted = false;

export async function mount(props) {
  render(props.container);
  mounted = true;

  // 不在这里发请求!等主应用通知
}

export async function unmount() {
  // 清理 DOM 和事件
  mounted = false;
}

// 提供给主应用调用的数据激活接口
window.__APP_ACTIVATE__ = async () => {
  if (mounted && !window.__DATA_LOADED__) {
    await fetchUserData();  // 实际的数据加载逻辑
    window.__DATA_LOADED__ = true;
  }
};
// 主应用在子应用 mount 后触发激活
registerMicroApps([
  {
    name: 'finance',
    entry: 'https://jztheme.com/finance/',
    container: '#subapp-viewport',
    activeRule: '/finance',
    onMount: (props) => {
      // 触发子应用的数据加载
      setTimeout(() => {
        if (window.__APP_ACTIVATE__) {
          window.__APP_ACTIVATE__();
        }
      }, 16);
    },
  },
]);

这一招让主线程的 blocking time 从平均 800ms 降到 200ms 以内。虽然整体加载时间没变太多,但**感知流畅度提升非常明显**——页面能快速响应点击,不会“点了没反应”那种焦躁感。

共享依赖别再各自为战

查 network 发现,主应用和子应用都引入了 react@17,结果浏览器下了两遍。虽然都是 CDN,但校验缓存也耗时间,加上重复的初始化,白白浪费性能。

解决方案是统一用 external + shared 的方式,让子应用使用主应用提供的依赖。

// webpack.config.js(子应用)
module.exports = {
  externals: {
    react: 'window.React',
    'react-dom': 'window.ReactDOM',
  },
};
// 主应用 index.html 先注入全局变量
<script src="https://cdn.jsdelivr.net/npm/react@17/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.production.min.js"></script>

这样子应用加载时就不会再去下载自己的 react 包了。光这一项就省了接近 100KB 的传输量和解析时间。尤其是移动端弱网环境下,效果特别明显。

不过要注意版本对齐。有一次测试环境主应用用了 react@18,子应用按 react@17 打包,external 后直接报错 invariant XXX failed。折腾了半天才发现是版本 mismatch。所以现在我们加了 CI 检查,确保共享库版本一致。

代码分割不能省

还有一个隐藏问题:某个子应用把所有页面打包成一个大 bundle,导致即使访问一个小功能页,也要下完整 JS。

后来强制他们上了 code splitting,按路由拆分 chunk。

// 子应用路由配置
const FinanceHome = React.lazy(() => import('./pages/Home'));
const FinanceReport = React.lazy(() => import('./pages/Report'));

function App() {
  return (
    <Suspense fallback="加载中...">
      <Routes>
        <Route path="/" element={<FinanceHome />} />
        <Route path="/report" element={<FinanceReport />} />
      </Routes>
    </Suspense>
  );
}

结合 webpack 的 splitChunks,公共部分单独抽离。最终主 bundle 从 1.2MB 降到 400KB,首屏可交互时间缩短了近 2 秒。

优化后:流畅多了

改完这一套组合拳之后,重新测了一遍数据:

  • 子应用首次加载时间:从 4.8s → 800ms(P90)
  • 主线程阻塞时间:从 800ms → 180ms
  • 资源重复下载:减少 120KB(主要靠 external)
  • 用户反馈“卡顿”比例下降 85%

现在点菜单基本是秒开,最多转个 loading spin 几百毫秒就出来了。虽然还没到极致,但至少不会再被吐槽“这系统不能用”了。

性能数据对比

这是优化前后几个关键指标的对比:

指标 优化前 优化后 提升幅度
子应用加载耗时 4.8s 0.8s 83%
主线程 block time 800ms 180ms 78%
JS 总下载体积 1.8MB 1.1MB 39%
白屏时间 3.2s 1.1s 66%

数据不会骗人。虽然每个改动都不复杂,但叠加起来效果显著。

结语

以上是我的 icestark 性能优化实战经验。没有用什么黑科技,就是老老实实看 performance、拆瓶颈、一步步压时间。过程中踩了不少坑,比如预加载时机不对反拖慢主流程、shared 依赖版本不一致导致 runtime 错误等等。

目前这套方案在生产环境跑了两个月,稳定不少。当然也不是完美,比如子应用样式隔离偶尔还会冲突,但至少性能这块算是过关了。

如果你也在用 icestark 做微前端,希望这些经验能帮你少走点弯路。有更好的方案欢迎评论区交流,一起进步。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论