代码层面优化:懒加载与按需加载、异步初始化、减少主线程阻塞、代码瘦身与混淆

代码层面的优化,说白了就是让浏览器少干活、晚干活、干快活。我见过太多团队把性能问题归咎于网络,结果一顿分析下来,发现是自家代码把主线程堵得死死的。这一章,咱们就聊聊怎么从代码层面把「无感启动」做到极致。

懒加载与按需加载:别让用户为看不见的东西买单

你想想看,用户打开一个页面,可能只是瞄一眼首屏。结果你把整个应用的 JS、图片、组件一股脑全加载了。这不是浪费带宽,是浪费用户的耐心。

懒加载的核心逻辑:只有当某个资源即将进入视口,或者用户即将与之交互时,才去加载它。

我的经验法则:首屏之外的图片、非首屏的组件、用户点击后才弹的弹窗、路由级别的页面,统统做懒加载。

举个常见的例子——图片懒加载。以前我用 scroll 事件监听,性能开销大。现在直接用 IntersectionObserver,干净利落。

// 图片懒加载 —— 我项目里常用的写法
const images = document.querySelectorAll('img[data-src]');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.removeAttribute('data-src');
      observer.unobserve(img);
    }
  });
}, {
  rootMargin: '100px 0px' // 提前 100px 加载,体验更好
});

images.forEach(img => observer.observe(img));

小技巧:rootMargin 设置一个合理的提前量,比如 100px-200px。这样用户还没看到图片,浏览器已经在后台加载了,真正做到「无感」。

按需加载则更激进。它不关心「即将出现」,只关心「用户要了没」。最常见的场景就是路由懒加载。我用 React 的时候,习惯把每个页面拆成一个动态 import()。

// React 路由按需加载 —— 我习惯这么写
const Home = React.lazy(() => import('./pages/Home'));
const About = React.lazy(() => import('./pages/About'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));

// 配合 Suspense 包裹,加载时显示 fallback
<Suspense fallback={<LoadingSpinner />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
</Suspense>

这样做的好处很明显:用户访问 /about 时,浏览器只下载 About.js,Dashboard.js 碰都不碰。首屏体积直接砍半。

异步初始化:别让「启动」变成「卡死」

很多应用启动慢,不是因为代码多,而是因为所有初始化任务都挤在 DOMContentLoaded 之前完成。我见过一个项目,启动时要做 SDK 初始化、埋点上报、权限校验、本地数据读取……全串行执行,主线程被占得死死的。

异步初始化的思路:把非关键任务往后挪,或者用 requestIdleCallback 塞到浏览器空闲时执行。

任务类型 执行时机 示例
关键任务 同步执行,立即完成 渲染首屏 DOM、绑定核心交互事件
重要但不紧急 微任务 / 下一个宏任务 埋点 SDK 初始化、非首屏数据预取
非关键任务 requestIdleCallback 日志上报、本地缓存清理、预加载下一屏

我自己的做法是:把初始化拆成三个阶段。

// 异步初始化 —— 我常用的三段式
function initializeApp() {
  // 阶段一:关键任务,同步执行
  renderCriticalUI();
  bindEssentialEvents();

  // 阶段二:重要任务,用 requestAnimationFrame 或 setTimeout 延迟
  setTimeout(() => {
    initAnalytics();
    prefetchUserData();
  }, 0);

  // 阶段三:非关键任务,浏览器空闲时做
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      cleanLocalCache();
      preloadNextPageAssets();
    }, { timeout: 2000 });
  }
}

注意:requestIdleCallback 的兼容性在低版本浏览器上不太好。我一般会加一个降级方案,比如用 setTimeout 兜底,保证功能不丢。

减少主线程阻塞:别让 JS 成为「拦路虎」

主线程阻塞,说白了就是浏览器忙着执行你的 JS,没空去渲染页面、响应用户点击。用户感觉「页面死了」,其实浏览器在「埋头苦干」。

常见的阻塞元凶:

  • 长任务(Long Task)—— 单次执行超过 50ms 的 JS 任务
  • 强制同步布局(Forced Reflow)—— 在 JS 中频繁读写 DOM 布局属性
  • 大循环 / 大计算 —— 比如遍历一个 10 万条数据的数组

我曾经接手过一个项目,页面加载后要处理一份 5 万行的配置数据。原来的写法是直接一个 for 循环,主线程卡了 800ms。用户点任何按钮都没反应。

我的优化方案是:把大任务拆成小任务,用 setTimeout 或 requestAnimationFrame 分批执行。

// 大任务拆解 —— 我处理 5 万行数据的方案
function processLargeData(data, chunkSize = 500) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, data.length);
    for (let i = index; i < end; i++) {
      // 处理单条数据
      transformItem(data[i]);
    }
    index = end;

    if (index < data.length) {
      // 还没处理完,让出主线程,下一帧继续
      requestAnimationFrame(processChunk);
    } else {
      // 全部处理完成
      onComplete();
    }
  }

  processChunk();
}

避坑指南:我曾经把 chunkSize 设得太小(比如 50),结果任务被切得太碎,总执行时间反而变长了。后来我根据数据量和目标帧率(60fps)算了一个合理的 chunkSize,一般 200-500 比较合适。

另外,强制同步布局也是个隐形杀手。比如你在一个循环里交替读写 offsetHeight 和 style.height,浏览器每次都要重新计算布局。我习惯的做法是:先批量读,再批量写。

// 避免强制同步布局 —— 先读后写
// ❌ 坏写法:读写交替,触发多次 reflow
for (let i = 0; i < items.length; i++) {
  const height = items[i].offsetHeight; // 读
  items[i].style.height = height * 2 + 'px'; // 写
}

// ✅ 好写法:先批量读,再批量写
const heights = [];
for (let i = 0; i < items.length; i++) {
  heights.push(items[i].offsetHeight); // 批量读
}
for (let i = 0; i < items.length; i++) {
  items[i].style.height = heights[i] * 2 + 'px'; // 批量写
}

代码瘦身与混淆:给 JS 减减肥

代码体积直接影响下载时间和解析时间。我见过一个项目,打包后的 JS 有 2MB,光下载就要 3 秒(3G 网络),解析还要 1 秒。用户等 4 秒才能看到东西,这哪叫「无感启动」?

代码瘦身的几个方向:

  • Tree Shaking:去掉没用到的代码。Webpack 和 Rollup 都支持,但前提是你得用 ES Module 的 import/export。
  • 代码分割:把代码拆成多个 chunk,按需加载。上面讲的路由懒加载就是典型。
  • 压缩混淆:去掉注释、缩短变量名、合并字符串。Terser 是目前最主流的工具。
  • 移除冗余 polyfill:很多项目用了 @babel/polyfill,但目标浏览器已经原生支持了。我建议用 core-js 按需引入,或者用 browserslist 精确控制。
优化手段 典型效果 工具/配置
Tree Shaking 减少 10%-30% 体积 Webpack sideEffects: false
代码分割 首屏体积减少 40%-60% 动态 import()
压缩混淆 减少 50%-70% 体积 TerserPlugin
移除冗余 polyfill 减少 20%-50KB @babel/preset-env + useBuiltIns: 'usage'

我个人的习惯是,在 Webpack 配置里把 optimization.minimize 打开,然后用 Terser 做深度压缩。同时开启 moduleIds: 'deterministic',保证 chunk hash 稳定,利于缓存。

// webpack.config.js —— 我常用的压缩配置
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,    // 去掉 console.log
            drop_debugger: true,   // 去掉 debugger
            pure_funcs: ['console.info'] // 去掉特定函数
          },
          mangle: true,            // 混淆变量名
          output: {
            comments: false        // 去掉注释
          }
        }
      })
    ]
  }
};

注意:drop_console 会去掉所有 console 调用。如果你在线上还需要保留某些日志(比如错误上报),可以用 pure_funcs 精确控制,或者改用专门的日志库。

知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,代码层面的优化,最终都指向同一个目标:让主线程更空闲,让用户更快看到内容。

代码层面优化知识体系 代码层面优化:核心路径 无感启动 懒加载与按需加载 异步初始化 减少主线程阻塞 代码瘦身与混淆 IntersectionObserver / 动态 import() requestIdleCallback / 任务分阶段 长任务拆分 / 避免强制布局 Tree Shaking / Terser 压缩

这四条路径,每一条都能单独带来可观的性能提升。但真正的高手,是懂得组合使用的。比如:用懒加载减少首屏体积,用异步初始化让首屏更快渲染,用任务拆分避免主线程卡顿,最后用代码瘦身让整个包更轻量。四管齐下,才能做到真正的「无感启动」。