代码层面优化:懒加载与按需加载、异步初始化、减少主线程阻塞、代码瘦身与混淆
代码层面的优化,说白了就是让浏览器少干活、晚干活、干快活。我见过太多团队把性能问题归咎于网络,结果一顿分析下来,发现是自家代码把主线程堵得死死的。这一章,咱们就聊聊怎么从代码层面把「无感启动」做到极致。
懒加载与按需加载:别让用户为看不见的东西买单
你想想看,用户打开一个页面,可能只是瞄一眼首屏。结果你把整个应用的 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 精确控制,或者改用专门的日志库。
知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以看到,代码层面的优化,最终都指向同一个目标:让主线程更空闲,让用户更快看到内容。
这四条路径,每一条都能单独带来可观的性能提升。但真正的高手,是懂得组合使用的。比如:用懒加载减少首屏体积,用异步初始化让首屏更快渲染,用任务拆分避免主线程卡顿,最后用代码瘦身让整个包更轻量。四管齐下,才能做到真正的「无感启动」。