老生常谈H5秒开

4,949 阅读12分钟

实现 H5 应用的“秒开”体验,并确保在发布新版本时用户能够及时加载到最新的内容。这涉及到前端性能优化、缓存策略以及版本控制等多个方面。

下面将通过以下几个核心部分来详细讲解,并提供尽可能详细的代码示例和解释:

  1. 构建阶段的优化:为秒开打下基础
  2. HTTP 缓存策略:利用浏览器缓存
  3. 首屏渲染优化:快速展现内容
  4. Service Worker:PWA 的核心,实现极致秒开与版本控制
  5. 懒加载技术:按需加载资源
  6. HTML 入口文件处理:确保更新及时性
  7. 综合实践与注意事项

零、引言:为什么追求秒开与版本更新?

  • 秒开体验:在移动互联网时代,用户对应用加载速度的容忍度极低。研究表明,加载时间超过3秒,用户流失率会显著增加。“秒开”能极大提升用户体验,增加用户留存和转化率。
  • 最新版本加载:及时向用户推送最新的功能和修复的 Bug 至关重要。如果用户一直使用的是旧版本,不仅无法体验新功能,还可能因为未修复的 Bug 导致体验下降或安全问题。

一、构建阶段的优化:为秒开打下基础

构建工具(如 Webpack, Vite, Rollup)是前端优化的第一道关卡。

1.1 代码压缩与混淆

减小 HTML, CSS, JavaScript 文件体积。

以 Webpack 为例 (webpack.config.js):

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin'); // 用于压缩 JavaScript
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); // 用于压缩 CSS
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 用于生成和压缩 HTML

module.exports = (env, argv) => {
    const isProduction = argv.mode === 'production';

    return {
        mode: isProduction ? 'production' : 'development',
        entry: './src/index.js', // 你的入口文件
        output: {
            filename: isProduction ? 'js/[name].[contenthash:8].js' : 'js/[name].js', // 生产环境带 hash
            path: path.resolve(__dirname, 'dist'),
            publicPath: '/', // 根据你的部署环境调整
            clean: true, // 构建前清理 dist 文件夹
        },
        optimization: {
            minimize: isProduction, // 生产环境开启压缩
            minimizer: [
                new TerserPlugin({
                    terserOptions: {
                        compress: {
                            drop_console: true, // 移除 console
                            warnings: false,
                        },
                        format: {
                            comments: false, // 移除注释
                        },
                    },
                    extractComments: false, // 不提取注释到单独文件
                }),
                new CssMinimizerPlugin(),
            ],
            splitChunks: { // 代码分割
                chunks: 'all', // 对所有类型的 chunks 进行分割
                minSize: 20000, // 形成一个新代码块最小的体积,单位 byte
                minRemainingSize: 0, // 分割后剩余的 chunk 最小书童,主要用于开发模式
                minChunks: 1, // 在分割之前,这个代码块至少被引用的次数
                maxAsyncRequests: 30, // 按需加载时的最大并行请求数
                maxInitialRequests: 30, // 入口点的最大并行请求数
                automaticNameDelimiter: '~', // 名称分隔符
                cacheGroups: { // 缓存组
                    defaultVendors: { // 将 node_modules 中的模块打包到 vendors chunk
                        test: /[\/]node_modules[\/]/,
                        priority: -10, // 优先级
                        reuseExistingChunk: true, // 如果当前 chunk 包含的模块已经被抽取出去了,那么将直接复用,而不是生成新的
                        name: 'vendors',
                    },
                    default: {
                        minChunks: 2, // 覆盖外层的 minChunks
                        priority: -20,
                        reuseExistingChunk: true,
                    },
                    // 可以定义更多自定义的 cacheGroups
                    // 例如:将所有 CSS 文件打包到一个文件中
                    styles: {
                        name: 'styles',
                        type: 'css/mini-extract', // 如果使用 mini-css-extract-plugin
                        chunks: 'all',
                        enforce: true,
                    },
                },
            },
            runtimeChunk: 'single', // 将 Webpack 运行时代码提取到单独文件,利于长期缓存
        },
        module: {
            rules: [
                {
                    test: /.js$/,
                    exclude: /node_modules/,
                    use: {
                        loader: 'babel-loader', // ES6+ 转 ES5
                        options: {
                            presets: ['@babel/preset-env']
                        }
                    }
                },
                {
                    test: /.css$/,
                    use: [
                        isProduction ? MiniCssExtractPlugin.loader : 'style-loader', // 生产环境提取 CSS,开发环境用 style-loader
                        'css-loader',
                        'postcss-loader', // 可选,用于 autoprefixer 等
                    ],
                },
                {
                    test: /.(png|svg|jpg|jpeg|gif|webp)$/i,
                    type: 'asset/resource', // Webpack 5 内置资源模块
                    generator: {
                        filename: 'images/[name].[hash:8][ext][query]'
                    }
                },
                // ... 其他 loader
            ],
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: './src/index.html', // HTML 模板文件
                filename: 'index.html',
                inject: 'body', // JS 脚本注入到 body 底部
                minify: isProduction ? {
                    removeComments: true, // 移除 HTML 中的注释
                    collapseWhitespace: true, // 删除空白符与换行符
                    minifyCSS: true, // 压缩内联 CSS
                    minifyJS: true, // 压缩内联 JS
                    removeRedundantAttributes: true,
                    useShortDoctype: true,
                    removeEmptyAttributes: true,
                    removeStyleLinkTypeAttributes: true,
                    keepClosingSlash: true,
                } : false,
            }),
            ...(isProduction ? [new MiniCssExtractPlugin({ // 提取 CSS 到单独文件
                filename: 'css/[name].[contenthash:8].css',
                chunkFilename: 'css/[id].[contenthash:8].css',
            })] : []),
        ],
        devtool: isProduction ? false : 'eval-source-map', // 开发环境开启 source map
    };
};

代码讲解:

  • TerserPlugin: 压缩 JavaScript,移除 console 和注释。
  • CssMinimizerPlugin: 压缩 CSS。
  • HtmlWebpackPlugin: 生成 HTML 文件,并可在生产环境配置 minify 选项来压缩 HTML。
  • output.filename: 使用 [contenthash:8] 为输出的 JS 文件名添加哈希,当文件内容改变时哈希值才会改变,利于浏览器缓存。CSS 文件同理。
  • optimization.splitChunks: 实现代码分割,将公共模块(如 node_modules 中的库)提取到单独的文件(如 vendors.js),避免重复加载,也利于缓存。
  • optimization.runtimeChunk: 将 Webpack 的运行时代码提取出来,因为这部分代码变化较少,单独缓存可以提高命中率。
  • MiniCssExtractPlugin: 将 CSS 从 JS 中提取到独立的 .css 文件,便于单独缓存和并行加载。
  • 图片处理:使用 asset/resource (Webpack 5+) 或 file-loader/url-loader (Webpack 4) 处理图片资源,也可以配置 image-webpack-loader 进行图片压缩。

1.2 图片优化

  • 格式选择:优先使用 WebP (兼容性允许的情况下),它通常比 JPEG 和 PNG 体积更小,质量相当。
  • 图片压缩:使用工具如 image-webpack-loader (集成到构建流) 或 TinyPNG/ImageOptim (手动或脚本)。
  • 响应式图片:使用 <picture> 元素或 srcset 属性,根据设备屏幕密度和尺寸加载不同大小的图片。
<!-- 响应式图片示例 -->
<picture>
   <source srcset="image-large.webp 1200w, image-medium.webp 800w, image-small.webp 400w" type="image/webp">
   <source srcset="image-large.jpg 1200w, image-medium.jpg 800w, image-small.jpg 400w" type="image/jpeg">
   <img src="image-medium.jpg" alt="My awesome image">
</picture>

<img srcset="image-1x.png 1x, image-2x.png 2x" src="image-1x.png" alt="description">

1.3 静态资源 CDN

将 JS, CSS, 图片等静态资源部署到 CDN (Content Delivery Network)。CDN 通过在全球部署的边缘节点,让用户从最近的服务器加载资源,减少网络延迟。

1.4 Tree Shaking

移除 JavaScript 中未被引用的代码 (dead code)。Webpack 在生产模式下默认开启 Tree Shaking (需要配合 ES6模块语法 import/export)。确保 package.json 中设置 "sideEffects": false 或具体指明有副作用的文件,以帮助 Webpack 更有效地进行 Tree Shaking。

// package.json
{
  "name": "my-app",
  "version": "1.0.0",
  "sideEffects": false, // 或者 ["./src/some-module-with-side-effects.js"]
  // ...
}

二、HTTP 缓存策略:利用浏览器缓存

浏览器缓存是提升二次加载速度的关键。主要通过 HTTP 头部字段控制。

2.1 强缓存 (Strong Cache)

浏览器直接从本地缓存读取资源,不向服务器发送请求。通过 Cache-ControlExpires 控制。

  • Cache-Control: max-age=31536000 (单位秒,这里是一年)
  • Cache-Control: public (可被代理服务器缓存) / private (仅浏览器缓存)
  • Cache-Control: no-cache (需要进行协商缓存)
  • Cache-Control: no-store (完全不缓存)
  • Expires: HTTP/1.0 字段,指定过期日期 (绝对时间)。优先级低于 Cache-Control

对于带哈希的资源 (JS, CSS, 图片),可以设置长期强缓存。

Nginx 配置示例 (nginx.conf 或站点配置):

server {
    listen 80;
    server_name your.domain.com;
    root /path/to/your/dist; # 项目构建后的目录

    # HTML 文件 - 通常不建议强缓存或设置较短的强缓存时间,以便及时更新
    location = /index.html {
        add_header Cache-Control "no-cache, must-revalidate"; # 或者 max-age=60 (1分钟)
        # expires -1; # 另一种设置不缓存的方式
    }

    # 带哈希的静态资源 (JS, CSS) - 设置长期缓存
    location ~* .(?:js|css)$ {
        if ($request_filename ~* .[a-f0-9]{8,}.(js|css)$) { # 匹配文件名中带8位以上哈希的
            add_header Cache-Control "public, max-age=31536000, immutable"; # immutable 提示浏览器此资源不会改变
            expires 1y; # 等同于 max-age=31536000
        }
    }

    # 图片等其他静态资源 - 也可以设置长期缓存
    location ~* .(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|webp|woff|woff2|ttf|eot)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        expires 1y;
        access_log off; # 关闭这些静态资源的访问日志,减少IO
        add_header ETag ""; # 如果使用 immutable,可以考虑移除 ETag
    }

    location / {
        try_files $uri $uri/ /index.html; # SPA 路由回退
    }
}

代码讲解:

  • index.html: 通常设置为 no-cache (进行协商缓存) 或一个非常短的 max-age,因为它引用了其他带哈希的资源,HTML 本身需要能及时更新。
  • 带哈希的 JS/CSS/图片:通过 max-age 设置一年(或其他长时间)的强缓存。immutable 告诉浏览器这个资源一旦下载就不会改变,可以更放心地使用缓存。

2.2 协商缓存 (Negotiation Cache)

浏览器向服务器发送请求,服务器根据资源是否有更新来决定是返回 304 Not Modified (使用本地缓存) 还是 200 OK (返回新资源)。

  • ETag / If-None-Match:

    • 服务器为每个资源生成一个唯一标识 ETag (如文件内容的哈希)。
    • 浏览器再次请求时,在 If-None-Match 头部带上上次的 ETag
    • 服务器比较 ETag,如果一致则返回 304
  • Last-Modified / If-Modified-Since:

    • 服务器记录资源的最后修改时间 Last-Modified
    • 浏览器再次请求时,在 If-Modified-Since 头部带上上次的时间。
    • 服务器比较时间,如果未修改则返回 304
    • ETag 的优先级通常高于 Last-Modified,因为它更精确(例如文件内容未变但修改时间变了)。

Nginx 默认会开启 ETagLast-Modified


三、首屏渲染优化:快速展现内容

3.1 Critical CSS (关键 CSS 内联)

将渲染首屏内容所必需的 CSS 提取出来,直接内联到 HTML 的 <head> 中。这样浏览器在解析 HTML 时就能立即应用这些样式,避免了等待外部 CSS 文件下载和解析造成的渲染阻塞。

手动提取或使用工具:
可以使用 critical (NPM 包) 或类似工具自动提取。

// 使用 critical 工具的示例 (构建脚本中)
const critical = require('critical');

critical.generate({
    base: 'dist/', // 构建输出目录
    src: 'index.html', // HTML 入口文件
    target: {
        html: 'index-critical.html' // 输出内联了关键 CSS 的 HTML
    },
    inline: true, // 内联 CSS
    minify: true,
    width: 1300, // 视口宽度
    height: 900, // 视口高度
    // 更多配置项...
    // extract: true, // 如果想把非关键CSS也提取出来异步加载
    // penthouse: { // 底层使用的库的配置
    //   blockJSRequests: false,
    // }
}).then(({html, css}) => {
    console.log('Critical CSS generated!');
}).catch(error => {
    console.error(error);
});

HTML 中内联:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My App</title>
    <style>
        /* critical.css - 内联的关键 CSS */
        body { margin: 0; background: #f0f0f0; }
        .header { background: #333; color: white; padding: 10px; }
        /* ... 更多首屏需要的样式 ... */
    </style>
    <!-- 非关键 CSS 异步加载 -->
    <link rel="preload" href="css/main.ab12cd34.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
    <noscript><link rel="stylesheet" href="css/main.ab12cd34.css"></noscript>
</head>
<body>
    <header class="header">...</header>
    <main>...</main>
    <script src="js/app.xyz7890.js" defer></script> <!-- JS 延迟执行 -->
</body>
</html>

代码讲解:

  • <style> 标签内联关键 CSS。
  • 使用 <link rel="preload" as="style" onload="..."> 异步加载剩余的 CSS,避免阻塞渲染。onload 事件触发后将 rel 改为 stylesheet 来应用样式。
  • <noscript> 标签为不支持 JavaScript 的环境提供回退。
  • <script defer>: 脚本会异步下载,并在 HTML 解析完成后、DOMContentLoaded 事件之前执行,不会阻塞 HTML 解析。

3.2 骨架屏 (Skeleton Screen) 或 Loading 动画

在等待真实内容加载完成前,先显示一个页面的大致轮廓(骨架屏)或一个简单的 Loading 动画,给用户即时反馈,减少焦虑感。

简单 HTML/CSS 骨架屏示例:

<!-- 放在 HTML body 的早期位置 -->
<div id="skeleton-loader" class="skeleton-wrapper">
    <div class="skeleton-header"></div>
    <div class="skeleton-content">
        <div class="skeleton-line"></div>
        <div class="skeleton-line short"></div>
        <div class="skeleton-block"></div>
    </div>
</div>
<style>
    .skeleton-wrapper { display: block; /* 初始显示 */ }
    .skeleton-header { width: 100%; height: 60px; background-color: #e0e0e0; margin-bottom: 20px; }
    .skeleton-content { padding: 15px; }
    .skeleton-line { width: 90%; height: 20px; background-color: #e0e0e0; margin-bottom: 10px; border-radius: 4px; }
    .skeleton-line.short { width: 60%; }
    .skeleton-block { width: 100%; height: 150px; background-color: #e0e0e0; border-radius: 4px; }
    /* 可以添加动画效果 */
    .skeleton-wrapper > div {
        background-image: linear-gradient(90deg, #e0e0e0 0px, #f0f0f0 40px, #e0e0e0 80px);
        background-size: 600px; /* 调整动画宽度 */
        animation: skeleton-shine 1.5s infinite linear;
    }
    @keyframes skeleton-shine {
        0% { background-position: -200px; }
        100% { background-position: calc(600px - 200px); }
    }
</style>
<script>
    // 真实内容加载完成后隐藏骨架屏
    // 这通常在你的主应用逻辑中处理,例如 Vue/React 的 mounted/componentDidMount
    window.addEventListener('load', function() { // 或者更早的时机
        const skeleton = document.getElementById('skeleton-loader');
        if (skeleton) {
            skeleton.style.display = 'none';
        }
    });
</script>

四、Service Worker:PWA 的核心,实现极致秒开与版本控制

Service Worker (SW) 是一个运行在浏览器后台的 JavaScript 脚本,独立于网页,可以拦截和处理网络请求、管理缓存、推送通知等。它是实现 PWA (Progressive Web App) 的关键技术。

4.1 注册 Service Worker

在你的主应用 JavaScript 文件中注册 SW。

// src/index.js 或 app.js
if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js', { scope: '/' }) // scope 定义 SW 控制的范围
            .then(registration => {
                console.log('ServiceWorker registration successful with scope: ', registration.scope);

                // 监听 SW 更新
                registration.addEventListener('updatefound', () => {
                    const newWorker = registration.installing;
                    if (newWorker) {
                        newWorker.addEventListener('statechange', () => {
                            if (newWorker.state === 'installed') {
                                if (navigator.serviceWorker.controller) {
                                    // 新的 SW 已安装,但旧的仍在控制页面
                                    // 可以在这里提示用户刷新页面以获取新版本
                                    console.log('New content is available and will be used when all tabs for this scope are closed, or upon next navigation.');
                                    // 或者更主动地提示用户
                                    if (confirm('新版本已准备好,是否立即刷新?')) {
                                        newWorker.postMessage({ type: 'SKIP_WAITING' }); // 通知新 SW 跳过等待
                                    }
                                } else {
                                    // 首次安装 SW,内容已缓存可供离线使用
                                    console.log('Content is cached for offline use.');
                                }
                            }
                        });
                    }
                });
            })
            .catch(error => {
                console.error('ServiceWorker registration failed: ', error);
            });

        // 页面刷新时,如果新的 SW 已经 waiting,则尝试激活它
        let refreshing;
        navigator.serviceWorker.addEventListener('controllerchange', () => {
            if (refreshing) return;
            window.location.reload();
            refreshing = true;
        });
    });
}

4.2 Service Worker 文件 (service-worker.js)

核心生命周期与事件:

  • install: SW 安装时触发,通常用于预缓存核心静态资源 (App Shell)。
  • activate: SW 激活时触发,通常用于清理旧缓存。
  • fetch: 拦截页面发出的网络请求,可以自定义响应逻辑(从缓存读取、网络请求、生成响应等)。
// public/service-worker.js 或 dist/service-worker.js (确保路径正确)

const CACHE_NAME_PREFIX = 'my-app-cache-';
const CURRENT_CACHE_VERSION = 'v1.2.3'; // 每次发布新版本时,修改此版本号
const CACHE_NAME = `${CACHE_NAME_PREFIX}${CURRENT_CACHE_VERSION}`;

// 需要预缓存的核心资源列表 (App Shell)
// 这些资源通常是构建时生成的带哈希的文件名
const PRECACHE_ASSETS = [
    '/', // 通常是 index.html
    '/index.html',
    '/css/main.ab12cd34.css', // 替换为实际构建后的文件名
    '/js/app.xyz7890.js',
    '/js/vendors.123abcde.js',
    '/images/logo.png',
    // ... 其他核心资源
    // 可以通过构建工具动态生成这个列表
];

// 1. 安装 Service Worker (install event)
self.addEventListener('install', event => {
    console.log(`[Service Worker] Installing version ${CURRENT_CACHE_VERSION}...`);
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => {
                console.log('[Service Worker] Precaching App Shell...');
                // addAll 会原子性地缓存所有资源,有一个失败则全部失败
                return cache.addAll(PRECACHE_ASSETS.map(url => new Request(url, { cache: 'reload' }))); // 确保请求最新的
            })
            .then(() => {
                console.log('[Service Worker] App Shell precached successfully.');
                // 如果希望新的 SW 安装后立即激活并控制页面,而不是等待旧 SW 控制的页面关闭
                // self.skipWaiting(); // 通常在 fetch 事件中处理更新提示后,由用户触发或自动触发
            })
            .catch(error => {
                console.error('[Service Worker] Precaching failed:', error);
                // 如果预缓存失败,可能需要阻止 SW 安装成功,或者有回退策略
            })
    );
});

// 2. 激活 Service Worker (activate event)
self.addEventListener('activate', event => {
    console.log(`[Service Worker] Activating version ${CURRENT_CACHE_VERSION}...`);
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames.map(cacheName => {
                    // 删除所有不匹配当前 CACHE_NAME_PREFIX 或者版本号不是最新的旧缓存
                    if (cacheName.startsWith(CACHE_NAME_PREFIX) && cacheName !== CACHE_NAME) {
                        console.log('[Service Worker] Deleting old cache:', cacheName);
                        return caches.delete(cacheName);
                    }
                })
            );
        }).then(() => {
            console.log('[Service Worker] Old caches deleted.');
            // 让 SW 立即控制当前打开的客户端(页面),而不需要等待页面刷新
            return self.clients.claim();
        })
    );
});

// 3. 拦截网络请求 (fetch event)
self.addEventListener('fetch', event => {
    const { request } = event;

    // 对于非 GET 请求,或者一些特殊路径(如 API),直接走网络
    if (request.method !== 'GET' || request.url.includes('/api/')) {
        event.respondWith(fetch(request));
        return;
    }

    // 导航请求 (HTML 文件),通常采用 Network Falling Back to Cache,或者 Stale-While-Revalidate
    // 以确保用户总是能获取到最新的 HTML (如果 HTML 不带 hash)
    if (request.mode === 'navigate') {
        event.respondWith(
            fetch(request) // 尝试从网络获取
                .then(response => {
                    // 如果网络请求成功,克隆响应并存入缓存
                    if (response.ok) {
                        const responseToCache = response.clone();
                        caches.open(CACHE_NAME).then(cache => {
                            cache.put(request, responseToCache);
                        });
                    }
                    return response;
                })
                .catch(() => {
                    // 网络失败,尝试从缓存中获取
                    return caches.match(request, { cacheName: CACHE_NAME });
                })
        );
        return;
    }


    // 对于静态资源 (JS, CSS, 图片等),通常采用 Cache First, Falling Back to Network 策略
    event.respondWith(
        caches.match(request, { cacheName: CACHE_NAME }) // 检查所有版本的缓存
            .then(cachedResponse => {
                if (cachedResponse) {
                    // console.log('[Service Worker] Serving from cache:', request.url);
                    return cachedResponse;
                }

                // 缓存未命中,发起网络请求
                // console.log('[Service Worker] Cache miss, fetching from network:', request.url);
                return fetch(request).then(networkResponse => {
                    if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
                        // 'basic' type indicates same-origin requests.
                        // Opaque responses (type 'opaque') are for cross-origin requests without CORS,
                        // their status is always 0, and we can't read their body or headers.
                        // It's generally not safe to cache opaque responses directly without careful consideration.
                        return networkResponse;
                    }

                    // 克隆响应,因为响应体只能被读取一次
                    const responseToCache = networkResponse.clone();
                    caches.open(CACHE_NAME)
                        .then(cache => {
                            // console.log('[Service Worker] Caching new resource:', request.url);
                            cache.put(request, responseToCache);
                        });
                    return networkResponse;
                }).catch(error => {
                    console.error('[Service Worker] Fetch failed; returning offline page instead.', error);
                    // 可选:返回一个通用的离线页面或资源
                    // return caches.match('/offline.html');
                });
            })
    );
});

// 4. 处理来自客户端的消息 (message event)
self.addEventListener('message', event => {
    if (event.data && event.data.type === 'SKIP_WAITING') {
        console.log('[Service Worker] Received SKIP_WAITING message. Activating new SW...');
        self.skipWaiting(); // 让新的 SW 跳过等待,立即激活
    }
});

// 确保 service-worker.js 本身不被强缓存,或者缓存时间极短
// 服务器配置 service-worker.js 的 Cache-Control: no-cache 或 max-age=0
// 浏览器会定期(通常24小时)检查 service-worker.js 文件是否有更新。
// 如果文件内容(即使是注释或空格的改变)发生变化,浏览器会下载新的 SW 文件,
// 并触发 install 事件。

代码讲解与版本更新流程:

  • CACHE_NAME: 包含版本号,每次发布新版本时,务必更新 CURRENT_CACHE_VERSION。这会使得新的 SW 创建一个全新的缓存空间。

  • PRECACHE_ASSETS: 列出应用核心 Shell 资源。这些资源在 install 事件中被缓存。文件名应带哈希,这样只有内容改变时文件名才变,SW 才会缓存新版本。

  • install 事件:

    • 打开新版本的缓存 (CACHE_NAME)。
    • 使用 cache.addAll() 预缓存 PRECACHE_ASSETSnew Request(url, { cache: 'reload' }) 确保从网络获取最新资源进行预缓存,而不是使用浏览器 HTTP 缓存中的旧版本。
  • activate 事件:

    • 遍历所有缓存空间 (caches.keys())。
    • 删除不属于当前版本前缀 (CACHE_NAME_PREFIX) 或版本号不是最新的旧缓存。这是实现版本平滑过渡和清理无用资源的关键。
    • self.clients.claim(): 使新的 SW 立即控制当前所有已打开的、在其作用域内的客户端页面,而不需要等待页面刷新。
  • fetch 事件 (缓存策略) :

    • 导航请求 (HTML) : 推荐 "Network Falling Back to Cache" 或 "Stale-While-Revalidate"。这里示例了前者,优先从网络获取,保证用户能拿到最新的 HTML 结构(如果 HTML 没有文件名哈希)。如果网络失败,则从缓存读取。
    • 静态资源 (JS/CSS/Images) : 推荐 "Cache First, Falling Back to Network"。优先从缓存读取,如果缓存命中则直接返回,实现秒开。如果未命中,则发起网络请求,并将成功获取的资源存入当前版本的缓存中。
  • message 事件与 self.skipWaiting() :

    • 当浏览器检测到 service-worker.js 文件有更新时,会下载新的 SW 文件,并触发其 install 事件。
    • 安装成功后,新的 SW 进入 waiting 状态,等待当前控制页面的旧 SW 释放控制权(通常是所有相关标签页关闭后)。
    • 在主应用 JS 中,可以监听 registration.updatefound 和新 SW 的 statechange。当新 SW installed 时,可以提示用户刷新。
    • 如果用户同意,主应用 JS 可以通过 newWorker.postMessage({ type: 'SKIP_WAITING' }); 向新 SW 发送消息。
    • 新 SW 在 message 事件中接收到消息后,调用 self.skipWaiting(),使其跳过等待阶段,立即进入 activating 状态,然后触发 activate 事件,清理旧缓存并控制页面。
  • Service Worker 文件本身的更新:

    • 浏览器会定期检查服务器上的 service-worker.js 文件。如果其内容(字节级别比较)发生变化,就会触发更新流程。
    • 因此,服务器应配置 service-worker.js 文件不被强缓存或缓存时间极短 (e.g., Cache-Control: no-cache, max-age=0, must-revalidate)。

如何确保加载最新版本?

  1. 资源文件名哈希: JS, CSS, 图片等静态资源使用 [contenthash]。内容不变,哈希不变,文件名不变,SW 和浏览器缓存继续有效。内容改变,哈希改变,文件名改变,SW 在 install 时会缓存新的文件名资源,旧的带不同哈希的资源在 activate 时可能被清理(如果缓存策略设计如此)或自然失效。
  2. 更新 CURRENT_CACHE_VERSION: 每次发布新版本(尤其是 PRECACHE_ASSETS 列表或 SW 逻辑有重要更新时),修改 service-worker.js 中的 CURRENT_CACHE_VERSION。这会触发 SW 的 installactivate 流程,创建新缓存,清理旧版本缓存。
  3. service-worker.js 文件本身不被强缓存: 确保浏览器能拉取到最新的 SW 脚本。
  4. 用户提示与 skipWaiting() : 优雅地提示用户有新版本,并提供立即更新的选项,通过 skipWaiting()clients.claim() 快速切换到新版本。

五、懒加载技术:按需加载资源

5.1 图片懒加载

对于不在首屏视口内的图片,延迟加载,直到用户滚动到它们附近。

// 使用 Intersection Observer API 实现图片懒加载
document.addEventListener("DOMContentLoaded", () => {
    const lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));

    if ("IntersectionObserver" in window) {
        let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
            entries.forEach(function(entry) {
                if (entry.isIntersecting) {
                    let lazyImage = entry.target;
                    lazyImage.src = lazyImage.dataset.src; // 将 data-src 赋值给 src
                    if (lazyImage.dataset.srcset) { // 处理 srcset
                        lazyImage.srcset = lazyImage.dataset.srcset;
                    }
                    lazyImage.classList.remove("lazy");
                    lazyImage.classList.add("lazy-loaded");
                    lazyImageObserver.unobserve(lazyImage); // 停止观察已加载的图片
                }
            });
        });

        lazyImages.forEach(function(lazyImage) {
            lazyImageObserver.observe(lazyImage);
        });
    } else {
        // Fallback for browsers that don't support IntersectionObserver
        // 可以简单地全部加载,或者使用 scroll 事件监听 (性能较差)
        lazyImages.forEach(img => {
            img.src = img.dataset.src;
            if (img.dataset.srcset) {
                img.srcset = img.dataset.srcset;
            }
        });
    }
});
``````html
<!-- HTML 结构 -->
<img class="lazy" data-src="path/to/image.jpg" data-srcset="image-small.jpg 400w, image-large.jpg 800w" alt="Lazy loaded image" width="300" height="200" src="placeholder-image.gif">
<!-- placeholder-image.gif 可以是一个很小的占位图或透明图片 -->
<style>
    img.lazy {
        opacity: 0;
        transition: opacity 0.3s ease-in-out;
    }
    img.lazy-loaded {
        opacity: 1;
    }
</style>

5.2 组件/路由懒加载 (Code Splitting)

对于单页应用 (SPA),可以将不同路由或大型组件分割成独立的 JS chunk,在用户访问特定路由或需要特定组件时才加载。Webpack 的 import() 语法支持动态导入。

React 示例 (React.lazy 和 Suspense):

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

const HomePage = lazy(() => import('./pages/HomePage')); // 动态导入
const AboutPage = lazy(() => import('./pages/AboutPage'));

function App() {
    return (
        <Router>
            <Suspense fallback={<div>Loading page...</div>}> {/* 加载时的 fallback UI */}
                <Switch>
                    <Route exact path="/" component={HomePage} />
                    <Route path="/about" component={AboutPage} />
                </Switch>
            </Suspense>
        </Router>
    );
}
export default App;

Vue 示例 (异步组件):

// router.js
import Vue from 'vue';
import Router from 'vue-router';

Vue.use(Router);

const HomePage = () => import(/* webpackChunkName: "home" */ './views/HomePage.vue');
const AboutPage = () => import(/* webpackChunkName: "about" */ './views/AboutPage.vue');

export default new Router({
    mode: 'history',
    routes: [
        { path: '/', component: HomePage },
        { path: '/about', component: AboutPage }
    ]
});

六、HTML 入口文件处理 (index.html)

index.html 是所有资源的入口,它的缓存策略非常重要。

  • 不使用文件名哈希: index.html 通常不带哈希。

  • 缓存策略:

    • 选项1: 不缓存或短缓存 (推荐) :

      • 服务器设置 Cache-Control: no-cache, must-revalidateCache-Control: max-age=60 (例如1分钟)。
      • 这样浏览器每次都会向服务器验证 index.html 是否有更新。如果更新了,就能拿到引用了新哈希资源的 HTML。
    • 选项2: Service Worker 控制:

      • 如果使用 Service Worker,可以将 index.html (通常是 //index.html) 也加入到 PRECACHE_ASSETS 中。
      • SW 的 fetch 事件中对导航请求采用 "Network Falling Back to Cache" 或 "Stale-While-Revalidate" 策略。
      • 当 SW 更新时,新的 index.html (如果它在 PRECACHE_ASSETS 中且内容有变) 会被预缓存。
      • 这种方式下,即使 index.html 被浏览器 HTTP 强缓存了较长时间,只要 SW 更新,用户下次访问时 SW 也能提供更新后的 index.html

权衡:
选项1 更简单直接,依赖标准的 HTTP 缓存。
选项2 更强大,可以实现离线访问 index.html,但 SW 的更新机制需要正确配置。
实践中,两者可以结合。服务器对 index.html 设置短缓存,同时 SW 也对其进行缓存和更新管理。


七、综合实践与注意事项

  1. 测试! 测试! 测试!

    • 使用 Chrome DevTools (Lighthouse, Network, Application 面板) 仔细检查缓存行为、加载时间和 SW 状态。
    • 模拟不同网络条件 (Slow 3G, Offline)。
    • 测试版本更新流程是否顺畅。
  2. 监控与告警:

    • 使用前端性能监控工具 (Sentry, New Relic, Dynatrace 等) 跟踪真实用户体验 (RUM - Real User Monitoring)。
    • 监控 SW 的注册成功率、错误等。
  3. 优雅降级与渐进增强:

    • 对于不支持 SW 的浏览器,应用仍然应该可用,只是没有离线和极致秒开的特性。
    • 懒加载、Critical CSS 等技术不依赖 SW。
  4. 避免 SW 缓存陷阱:

    • 确保正确管理缓存版本,及时清理旧缓存。
    • PRECACHE_ASSETS 列表中的资源文件名必须带哈希,否则 SW 可能会一直提供旧的未哈希文件。
    • service-worker.js 文件本身不能被长期强缓存。
  5. 用户体验:

    • 对于 SW 更新,给用户清晰的提示和操作选项(如“新版本可用,立即刷新?”)。
    • 骨架屏和 Loading 状态要设计得友好。
  6. 预渲染 (Prerendering) 或服务端渲染 (SSR) :

    • 对于内容型网站或对 SEO 要求高的 SPA,可以考虑预渲染或 SSR。
    • 预渲染:在构建时为特定路由生成静态 HTML 文件。
    • SSR:服务器动态渲染页面内容并返回给浏览器。
    • 这些技术可以极大改善首次内容到达时间 (TTFB) 和首次可交互时间 (TTI),但会增加构建或服务器的复杂度。
  7. HTTP/2 或 HTTP/3:

    • 使用支持 HTTP/2 或 HTTP/3 的服务器。它们通过多路复用、头部压缩等特性,可以更有效地加载多个小资源,减少了传统 HTTP/1.1 中合并文件的必要性。
  8. 字体优化:

    • 使用 font-display: swap;optional; 避免字体加载阻塞文本渲染。
    • 只加载需要的字重和字符集 (字体子集化)。
    • 使用 WOFF2 格式。

这个详细的指南涵盖了从构建到部署再到运行时优化 H5 应用以实现秒开和版本更新的多个方面。核心在于资源优化、精细的缓存控制 (HTTP 缓存 + Service Worker) 以及良好的用户体验设计。