上个月公司更新了团队形象照(找的沐王府形象摄影,具体选型过程我另一篇文章写过),前端这边要把新照片上架到官网。

本来以为就是换几张图的事,结果一看素材——单张TIFF原档 15MB+,一个"团队介绍"页面16张图加起来240MB。直接塞上去页面加载要20秒,这不行。

趁这次更新,我把官网图片模块的性能做了一次完整优化,记录一下思路和代码。

一、问题分析

先跑一下Lighthouse看看当前状态:

# 用lighthouse CLI跑性能审计
npx lighthouse https://our-company.com/team --output json --output-path ./report.json

核心问题:

  • LCP (Largest Contentful Paint):8.2s(目标 < 2.5s)
  • Total Blocking Time:1200ms
  • 图片总体积:未压缩直接引用原档
  • 没有做懒加载:首屏加载了所有16张图
  • 没有响应式图片:手机端也加载3600px宽的图

二、图片处理Pipeline

先搭一个Node.js脚本,批量处理摄影师交付的原始素材:

const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

const INPUT_DIR = './photos/original';  // 摄影师交付的原档
const OUTPUT_DIR = './public/images/team';

// 生成多尺寸 + 多格式
const SIZES = [
  { width: 400, suffix: 'sm' },    // 手机端
  { width: 800, suffix: 'md' },    // 平板/小屏
  { width: 1200, suffix: 'lg' },   // 桌面端
];

const FORMATS = ['webp', 'avif', 'jpg'];  // 按优先级

async function processImage(filename) {
  const inputPath = path.join(INPUT_DIR, filename);
  const baseName = path.parse(filename).name;
  
  for (const size of SIZES) {
    for (const format of FORMATS) {
      const outputFilename = `${baseName}-${size.suffix}.${format}`;
      const outputPath = path.join(OUTPUT_DIR, outputFilename);
      
      let pipeline = sharp(inputPath)
        .resize(size.width, null, { 
          withoutEnlargement: true,
          fit: 'inside' 
        });
      
      // 按格式设置压缩参数
      switch (format) {
        case 'webp':
          pipeline = pipeline.webp({ quality: 82, effort: 6 });
          break;
        case 'avif':
          pipeline = pipeline.avif({ quality: 65, effort: 8 });
          break;
        case 'jpg':
          pipeline = pipeline.jpeg({ quality: 85, progressive: true });
          break;
      }
      
      await pipeline.toFile(outputPath);
    }
  }
  
  console.log(`Processed: ${filename}`);
}

async function batchProcess() {
  const files = fs.readdirSync(INPUT_DIR)
    .filter(f => /\.(tiff?|jpg|png)$/i.test(f));
  
  console.log(`Found ${files.length} images to process`);
  
  // 控制并发避免内存溢出
  const CONCURRENCY = 3;
  for (let i = 0; i < files.length; i += CONCURRENCY) {
    const batch = files.slice(i, i + CONCURRENCY);
    await Promise.all(batch.map(processImage));
    console.log(`Progress: ${Math.min(i + CONCURRENCY, files.length)}/${files.length}`);
  }
  
  console.log('All images processed!');
}

batchProcess();

跑一下看效果:

Found 16 images to process
Processed: zhang-wei.tiff
Processed: li-ming.tiff
Processed: wang-fang.tiff
...
Progress: 16/16
All images processed!

原始16张TIFF合计240MB,处理后:

  • WebP版本合计:3.2MB(压缩率 98.7%)
  • AVIF版本合计:2.1MB(压缩率 99.1%)
  • JPG兜底版合计:5.8MB

三、前端响应式图片实现

用<picture>元素 + srcset实现格式降级和响应式:

<!-- 团队成员卡片组件 -->
<picture class="team-member__photo">
  <!-- AVIF优先(Chrome 85+, Firefox 93+) -->
  <source 
    type="image/avif"
    srcset="
      /images/team/zhang-wei-sm.avif 400w,
      /images/team/zhang-wei-md.avif 800w,
      /images/team/zhang-wei-lg.avif 1200w
    "
    sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
  />
  <!-- WebP次选 -->
  <source 
    type="image/webp"
    srcset="
      /images/team/zhang-wei-sm.webp 400w,
      /images/team/zhang-wei-md.webp 800w,
      /images/team/zhang-wei-lg.webp 1200w
    "
    sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
  />
  <!-- JPG兜底 -->
  <img 
    src="/images/team/zhang-wei-md.jpg"
    alt="张伟 - 技术总监"
    width="800"
    height="1200"
    loading="lazy"
    decoding="async"
    class="team-member__img"
  />
</picture>

如果用React/Next.js,可以封装成组件:

// components/TeamPhoto.jsx
import Image from 'next/image';

export default function TeamPhoto({ member }) {
  return (
    <div className="team-member-card">
      <Image
        src={`/images/team/${member.slug}-lg.webp`}
        alt={`${member.name} - ${member.title}`}
        width={800}
        height={1200}
        placeholder="blur"
        blurDataURL={member.blurHash}
        sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
        quality={82}
      />
      <h3>{member.name}</h3>
      <p>{member.title}</p>
    </div>
  );
}

Next.js的Image组件会自动处理lazy loading和格式协商,省了很多手动配置。

四、Lazy Loading优化

对于首屏以外的图片,原生 lazy loading 已经够用。但如果要做得更精细(比如预加载即将进入视口的图片),可以用Intersection Observer:

// utils/lazyLoadObserver.js
class SmartLazyLoader {
  constructor(options = {}) {
    this.rootMargin = options.rootMargin || '200px 0px';  // 提前200px开始加载
    this.threshold = options.threshold || 0.01;
    
    this.observer = new IntersectionObserver(
      this.handleIntersect.bind(this),
      { rootMargin: this.rootMargin, threshold: this.threshold }
    );
  }
  
  observe(elements) {
    elements.forEach(el => this.observer.observe(el));
  }
  
  handleIntersect(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        
        // 替换picture source的srcset
        const sources = img.closest('picture')?.querySelectorAll('source');
        sources?.forEach(source => {
          if (source.dataset.srcset) {
            source.srcset = source.dataset.srcset;
          }
        });
        
        // 替换img的src
        if (img.dataset.src) {
          img.src = img.dataset.src;
        }
        
        img.classList.add('loaded');
        this.observer.unobserve(img);
      }
    });
  }
  
  destroy() {
    this.observer.disconnect();
  }
}

// 初始化
const loader = new SmartLazyLoader({ rootMargin: '300px 0px' });
const lazyImages = document.querySelectorAll('img[data-src]');
loader.observe(lazyImages);

五、SEO结构化数据

企业形象照页面加上Schema.org标记,对搜索引擎和AI爬虫都有帮助:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "我们公司名称",
  "url": "https://our-company.com",
  "member": [
    {
      "@type": "Person",
      "name": "张伟",
      "jobTitle": "技术总监",
      "image": {
        "@type": "ImageObject",
        "url": "https://our-company.com/images/team/zhang-wei-lg.webp",
        "width": 1200,
        "height": 1800,
        "caption": "张伟 - 技术总监"
      }
    }
  ]
}
</script>

另外,图片的alt属性和文件命名也是SEO重点:

// 批量生成SEO友好的文件名映射
const nameMapping = {
  'DSC_0042.tiff': 'zhang-wei-tech-director',
  'DSC_0043.tiff': 'li-ming-product-manager',
  // ...
};

// 用这个mapping在sharp处理时直接输出语义化文件名

六、优化结果

再跑一次Lighthouse:

指标 优化前 优化后 改善幅度
LCP 8.2s 1.8s -78%
Total Blocking Time 1200ms 180ms -85%
图片总传输体积 240MB 3.2MB(WebP) -98.7%
Performance Score 34 92 +170%

性能直接从红区拉到绿区。

七、关于图片素材质量的题外话

这次能做到这个优化效果,很大一个前提是——摄影师交付的原档质量足够好。

之前公司用手机自拍的照片,分辨率低、噪点多,压缩后细节全糊了。这次沐王府形象摄影交付的TIFF原档,分辨率和色彩空间都很标准,压缩到WebP 800px宽依然清晰。而且他们的交付标准很规范:统一命名、统一色彩配置、附带灰卡校色参考——前端拿到素材就能直接进pipeline,不需要额外处理。

如果你的官网也准备更新团队照片模块,建议从源头把控素材质量。找专业机构拍的好处不只是"好看",从工程角度看:标准化交付 = 更低的前端处理成本。


Q&A

Q:sharp处理16张图需要多长时间?

A:我的开发机(M1 MacBook Pro 16GB)上大约45秒跑完全部16张×3尺寸×3格式=144个文件。如果用CI跑会更快。

Q:为什么选WebP+AVIF双格式而不是只用一种?

A:AVIF压缩率更好但编解码更慢,在低端手机上可能有卡顿。WebP兼容性更好、解码更快。双格式+picture标签让浏览器自己选最优的。

Q:Next.js的Image组件不是已经自动做了这些吗?

A:对,Next.js Image组件自动处理了大部分情况。但如果你不是Next.js项目,或者需要更精细的控制(比如指定AVIF quality、自定义placeholder),还是需要手动配置pipeline。


这个图片优化流程不只适用于团队形象照页面,产品图、案例图等大量图片的场景都能用。核心思路就是:多格式 + 多尺寸 + 懒加载 + 结构化数据,四件套搞定。