前端性能优化实战指南

Created on

前言

在现代 Web 开发中,性能优化是提升用户体验的关键因素。本文将从多个维度深入探讨前端性能优化的核心技术,帮助开发者构建更快速、更流畅的 Web 应用。

CDN 加速

什么是 CDN

CDN(Content Delivery Network,内容分发网络)是一种分布式服务器系统,可以根据用户的地理位置,将内容从最近的节点快速传递给用户。对于前端开发来说,CDN 是提升首屏加载速度的利器。

工作原理

  1. 内容分发:将静态资源(JS、CSS、图片等)部署到 CDN 的边缘节点
  2. 智能调度:当用户请求资源时,CDN 通过 DNS 解析将请求指向最近的节点
  3. 缓存机制:边缘节点缓存静态资源,减少回源请求
  4. 负载均衡:多个节点分担流量压力,提高可用性

使用示例

// Webpack配置externals避免打包CDN资源
module.exports = {
  externals: {
    vue: "Vue",
    axios: "axios",
    lodash: "_",
  },
};
<!-- 在HTML中引入CDN资源 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

图片懒加载

实现原理

图片懒加载是指当图片进入或即将进入可视区域时才开始加载,避免一次性加载所有图片造成的性能问题。这种技术可以显著减少初始页面加载时间和带宽消耗。

实现方式一:Intersection Observer API(推荐)

// 现代浏览器推荐方案,性能更好
const images = document.querySelectorAll("img[data-src]");

const imageObserver = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute("data-src");
        observer.unobserve(img);
      }
    });
  },
  {
    rootMargin: "50px", // 提前50px开始加载
  }
);

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

实现方式二:原生 loading 属性

<!-- 最简单的方式,浏览器原生支持 -->
<img src="image.jpg" loading="lazy" alt="懒加载图片" />

实现方式三:传统滚动监听(兼容性方案)

function lazyLoad() {
  const images = document.querySelectorAll("img[data-src]");

  images.forEach((img) => {
    const rect = img.getBoundingClientRect();
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      img.src = img.dataset.src;
      img.removeAttribute("data-src");
    }
  });
}

// 使用节流优化性能
window.addEventListener("scroll", throttle(lazyLoad, 200));
window.addEventListener("resize", throttle(lazyLoad, 200));
lazyLoad(); // 初始化加载

虚拟列表

什么是虚拟列表

虚拟列表是一种优化长列表渲染的技术,只渲染可视区域内的列表项,大幅减少 DOM 节点数量。当列表数据量超过千条时,使用虚拟列表可以显著提升性能。

基础实现

class VirtualList {
  constructor(options) {
    this.container = options.container;
    this.itemHeight = options.itemHeight;
    this.data = options.data;
    this.visibleCount = Math.ceil(
      this.container.clientHeight / this.itemHeight
    );
    this.bufferSize = options.bufferSize || 3; // 缓冲区大小

    this.init();
  }

  init() {
    // 创建滚动容器
    this.scrollContainer = document.createElement("div");
    this.scrollContainer.style.height =
      this.data.length * this.itemHeight + "px";
    this.scrollContainer.style.position = "relative";

    // 创建内容容器
    this.contentContainer = document.createElement("div");
    this.contentContainer.style.position = "absolute";
    this.contentContainer.style.top = "0";
    this.contentContainer.style.left = "0";
    this.contentContainer.style.right = "0";

    this.scrollContainer.appendChild(this.contentContainer);
    this.container.appendChild(this.scrollContainer);

    this.container.addEventListener("scroll", () => this.render());
    this.render();
  }

  render() {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.max(
      0,
      Math.floor(scrollTop / this.itemHeight) - this.bufferSize
    );
    const endIndex = Math.min(
      this.data.length,
      startIndex + this.visibleCount + this.bufferSize * 2
    );

    const offsetY = startIndex * this.itemHeight;
    const visibleData = this.data.slice(startIndex, endIndex);

    this.contentContainer.style.transform = `translateY(${offsetY}px)`;
    this.contentContainer.innerHTML = visibleData
      .map(
        (item, index) => `
        <div style="height: ${this.itemHeight}px; line-height: ${
          this.itemHeight
        }px;">
          ${startIndex + index}: ${item}
        </div>
      `
      )
      .join("");
  }
}

// 使用示例
const data = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
new VirtualList({
  container: document.querySelector("#list-container"),
  itemHeight: 50,
  data: data,
  bufferSize: 3,
});

推荐库

对于生产环境,推荐使用成熟的库:

回流与重绘

1、回流(Reflow)

当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流

导致回流的原因:

2、重绘(Repaint)

当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘

导致重绘的原因:

注意:当触发回流时,一定会触发重绘,但是重绘不一定会引发回流。

3、如何避免回流与重绘?

减少回流与重绘的措施:

操作 DOM 的策略
// ❌ 不好的做法:多次操作DOM
const el = document.getElementById("test");
el.style.padding = "5px";
el.style.borderLeft = "1px";
el.style.borderRight = "2px";

// ✅ 好的做法:一次性修改样式
const el = document.getElementById("test");
el.style.cssText = "padding: 5px; border-left: 1px; border-right: 2px;";

// 或者使用类名
el.className = "active";
批量修改 DOM
// ✅ 使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
document.getElementById("list").appendChild(fragment);
脱离文档流
// ✅ 使用 absolute 或 fixed
.animated-element {
  position: absolute; /* 或 fixed */
  /* 元素变化不会影响其他元素 */
}
读写分离
// ❌ 不好的做法:读写交替
div.style.left = div.offsetLeft + 1 + "px";
div.style.top = div.offsetTop + 1 + "px";

// ✅ 好的做法:读写分离
const left = div.offsetLeft;
const top = div.offsetTop;
div.style.left = left + 1 + "px";
div.style.top = top + 1 + "px";
使用 transform 和 opacity
/* ✅ 使用 transform 和 opacity 触发 GPU 加速 */
.box {
  transform: translateX(100px); /* 不会触发回流 */
  opacity: 0.5; /* 不会触发回流 */
}

/* ❌ 避免使用 left、top 等 */
.box {
  left: 100px; /* 会触发回流 */
}

浏览器渲染队列机制

浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列

浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。

上面提到的读写分离,就是利用了这个机制,将多个读操作(或者写操作)放在一起,避免打断渲染队列的批处理。

防抖和节流

防抖(Debounce)

在单位时间内,用户触发了操作,则重新计时。只有在停止触发一段时间后才执行。

使用场景:

实现

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    const context = this;
    if (timer) clearTimeout(timer);

    timer = setTimeout(() => {
      fn.apply(context, args);
    }, delay);
  };
}

// 使用示例
const searchInput = document.querySelector("#search");
searchInput.addEventListener(
  "input",
  debounce(function (e) {
    console.log("搜索:", e.target.value);
    // 发送搜索请求
  }, 500)
);

立即执行版本

function debounce(fn, delay, immediate = false) {
  let timer = null;
  return function (...args) {
    const context = this;

    if (timer) clearTimeout(timer);

    if (immediate && !timer) {
      fn.apply(context, args);
    }

    timer = setTimeout(() => {
      if (!immediate) {
        fn.apply(context, args);
      }
      timer = null;
    }, delay);
  };
}

节流(Throttle)

在单位时间内,用户触发了操作,只会执行一次。时间到了才会执行下一次。

使用场景:

时间戳版本

function throttle(fn, delay) {
  let lastTime = 0;
  return function (...args) {
    const context = this;
    const now = Date.now();

    if (now - lastTime >= delay) {
      fn.apply(context, args);
      lastTime = now;
    }
  };
}

// 使用示例
window.addEventListener(
  "scroll",
  throttle(function () {
    console.log("滚动位置:", window.scrollY);
  }, 200)
);

定时器版本

function throttle(fn, delay) {
  let timer = null;
  return function (...args) {
    const context = this;

    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(context, args);
        timer = null;
      }, delay);
    }
  };
}

完整版本(结合时间戳和定时器)

function throttle(fn, delay, options = {}) {
  let timer = null;
  let lastTime = 0;

  const { leading = true, trailing = true } = options;

  return function (...args) {
    const context = this;
    const now = Date.now();

    // 首次不执行
    if (!leading && !lastTime) {
      lastTime = now;
    }

    const remaining = delay - (now - lastTime);

    if (remaining <= 0 || remaining > delay) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      lastTime = now;
      fn.apply(context, args);
    } else if (!timer && trailing) {
      timer = setTimeout(() => {
        lastTime = leading ? Date.now() : 0;
        timer = null;
        fn.apply(context, args);
      }, remaining);
    }
  };
}

防抖 vs 节流

特性防抖节流
执行时机停止触发后延迟执行持续触发时按固定间隔执行
使用场景搜索框、表单验证滚动监听、鼠标移动
执行频率低(只执行最后一次)中(按时间间隔执行)

图片优化

1. 使用合适的图片格式

<!-- 使用 picture 标签提供多种格式 -->
<picture>
  <source srcset="image.webp" type="image/webp" />
  <source srcset="image.jpg" type="image/jpeg" />
  <img src="image.jpg" alt="示例图片" />
</picture>

2. 图片压缩

// Webpack配置
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif|svg)$/i,
        use: [
          {
            loader: "image-webpack-loader",
            options: {
              mozjpeg: { progressive: true, quality: 65 },
              optipng: { enabled: false },
              pngquant: { quality: [0.65, 0.9], speed: 4 },
              gifsicle: { interlaced: false },
            },
          },
        ],
      },
    ],
  },
};

3. 响应式图片

<!-- 使用 srcset 提供不同分辨率 -->
<img
  srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w"
  sizes="(max-width: 600px) 480px, (max-width: 1000px) 800px, 1200px"
  src="large.jpg"
  alt="响应式图片"
/>

4. 使用 CSS 代替图片

/* 使用 CSS 实现简单图形 */
.triangle {
  width: 0;
  height: 0;
  border-left: 50px solid transparent;
  border-right: 50px solid transparent;
  border-bottom: 100px solid #333;
}

/* 使用渐变代替背景图 */
.gradient-bg {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

5. 雪碧图(CSS Sprites)

/* 将多个小图标合并成一张图 */
.icon {
  background-image: url("sprites.png");
  background-repeat: no-repeat;
}

.icon-home {
  background-position: 0 0;
  width: 20px;
  height: 20px;
}

.icon-user {
  background-position: -20px 0;
  width: 20px;
  height: 20px;
}

Webpack 优化

1. 代码压缩

// Webpack 5 内置 terser
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true, // 移除 console
            drop_debugger: true, // 移除 debugger
          },
        },
      }),
    ],
  },
};

2. Gzip 压缩

const CompressionPlugin = require("compression-webpack-plugin");

module.exports = {
  plugins: [
    new CompressionPlugin({
      algorithm: "gzip",
      test: /\.(js|css|html|svg)$/,
      threshold: 10240, // 大于10KB才压缩
      minRatio: 0.8,
    }),
  ],
};

3. 代码分割

module.exports = {
  optimization: {
    splitChunks: {
      chunks: "all",
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: "vendors",
          priority: 10,
        },
        common: {
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

4. Tree Shaking

// package.json
{
  "sideEffects": false // 标记所有文件无副作用
}

// 或指定有副作用的文件
{
  "sideEffects": ["*.css", "*.scss"]
}

性能监控

1. 使用 Performance API

// 获取性能指标
const perfData = performance.getEntriesByType("navigation")[0];

console.log(
  "DNS查询耗时:",
  perfData.domainLookupEnd - perfData.domainLookupStart
);
console.log("TCP连接耗时:", perfData.connectEnd - perfData.connectStart);
console.log("页面加载耗时:", perfData.loadEventEnd - perfData.loadEventStart);
console.log("DOM解析耗时:", perfData.domComplete - perfData.domInteractive);

2. 使用 Lighthouse

Chrome DevTools 内置的性能分析工具,可以生成详细的性能报告。

3. 使用 Web Vitals

import { getCLS, getFID, getFCP, getLCP, getTTFB } from "web-vitals";

getCLS(console.log); // 累积布局偏移
getFID(console.log); // 首次输入延迟
getFCP(console.log); // 首次内容绘制
getLCP(console.log); // 最大内容绘制
getTTFB(console.log); // 首字节时间

总结

前端性能优化是一个持续的过程,需要从多个维度进行优化:

  1. 资源优化:使用 CDN、图片压缩、代码压缩
  2. 加载优化:懒加载、预加载、按需加载
  3. 渲染优化:减少回流重绘、使用虚拟列表
  4. 交互优化:防抖节流、响应式设计
  5. 构建优化:Webpack 配置优化、Tree Shaking、代码分割

通过合理应用这些技术,可以显著提升 Web 应用的性能和用户体验。记住,过早优化是万恶之源,应该先根据实际性能瓶颈进行针对性优化。