如何使用 Webpack 和 LocalStorage 实现静态资源的离线缓存

 更新时间:2026年05月20日 11:36:59   作者:北辰alk  
本文介绍了使用Webpack和LocalStorage实现静态资源离线缓存的方法,包括原理、Webpack配置、LocalStorage缓存实现、应用集成方案、高级优化方案、性能与安全考虑、完整实现示例、测试与验证、备选方案比较及总结与最佳实践等内容,感兴趣的朋友一起看看吧

在现代 Web 应用中,实现静态资源的离线缓存可以显著提升用户体验,特别是在网络不稳定或离线状态下。本文将详细介绍如何结合 Webpack 和 LocalStorage 实现这一功能。

一、实现原理概述

1.1 核心思路

  1. 构建阶段:使用 Webpack 生成带有哈希值的静态资源文件名
  2. 缓存阶段:在应用首次加载时将资源存入 LocalStorage
  3. 读取阶段:后续请求优先从 LocalStorage 读取,失败则回退到网络请求

1.2 技术组合优势

  • Webpack:自动化构建、资源版本控制
  • LocalStorage:简单易用的客户端存储方案
  • Service Worker(可选):更强大的离线缓存能力

二、Webpack 配置准备

2.1 基础配置

// webpack.config.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    publicPath: '/'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: './public/index.html',
      filename: 'index.html'
    })
  ]
};

2.2 生成资源清单

// webpack.config.js 添加
const WebpackAssetsManifest = require('webpack-assets-manifest');
module.exports = {
  // ...其他配置
  plugins: [
    // ...其他插件
    new WebpackAssetsManifest({
      output: 'asset-manifest.json',
      publicPath: true,
      writeToDisk: true
    })
  ]
};

三、LocalStorage 缓存实现

3.1 缓存工具类

// src/utils/storageCache.js
class StorageCache {
  constructor(namespace = 'app_cache') {
    this.namespace = namespace;
    this.maxSize = 5 * 1024 * 1024; // 5MB LocalStorage限制
  }
  // 存储资源
  set(key, value) {
    try {
      const data = {
        value,
        timestamp: Date.now()
      };
      localStorage.setItem(`${this.namespace}:${key}`, JSON.stringify(data));
      return true;
    } catch (e) {
      // 超出容量时清理旧缓存
      if (e.name === 'QuotaExceededError') {
        this.clearOldest(20); // 清理20%的旧缓存
        return this.set(key, value); // 重试
      }
      console.error('LocalStorage set error:', e);
      return false;
    }
  }
  // 获取资源
  get(key) {
    const item = localStorage.getItem(`${this.namespace}:${key}`);
    if (!item) return null;
    try {
      const data = JSON.parse(item);
      return data.value;
    } catch (e) {
      console.error('LocalStorage parse error:', e);
      return null;
    }
  }
  // 清理最早的缓存
  clearOldest(percentage = 20) {
    const keys = [];
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith(`${this.namespace}:`)) {
        keys.push(key);
      }
    }
    // 按时间排序
    keys.sort((a, b) => {
      const itemA = JSON.parse(localStorage.getItem(a));
      const itemB = JSON.parse(localStorage.getItem(b));
      return (itemA?.timestamp || 0) - (itemB?.timestamp || 0);
    });
    // 删除指定百分比的旧缓存
    const removeCount = Math.ceil(keys.length * (percentage / 100));
    for (let i = 0; i < removeCount; i++) {
      localStorage.removeItem(keys[i]);
    }
  }
  // 清除所有缓存
  clear() {
    Object.keys(localStorage).forEach(key => {
      if (key.startsWith(`${this.namespace}:`)) {
        localStorage.removeItem(key);
      }
    });
  }
}
export default new StorageCache();

3.2 资源加载器

// src/utils/assetLoader.js
import StorageCache from './storageCache';
const CACHE_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7天缓存有效期
class AssetLoader {
  constructor() {
    this.cache = StorageCache;
  }
  // 加载JS资源
  async loadScript(url) {
    // 尝试从缓存加载
    const cached = this.cache.get(url);
    if (cached && this.isCacheValid(cached)) {
      this.injectScript(cached.content);
      return Promise.resolve();
    }
    // 从网络加载
    return fetch(url)
      .then(response => {
        if (!response.ok) throw new Error('Network response was not ok');
        return response.text();
      })
      .then(content => {
        // 缓存资源
        this.cache.set(url, {
          content,
          timestamp: Date.now()
        });
        // 注入脚本
        this.injectScript(content);
      })
      .catch(error => {
        console.error('Failed to load script:', url, error);
        if (cached) {
          // 网络失败但缓存存在,使用缓存
          this.injectScript(cached.content);
        } else {
          throw error;
        }
      });
  }
  // 加载CSS资源
  async loadStyle(url) {
    // 尝试从缓存加载
    const cached = this.cache.get(url);
    if (cached && this.isCacheValid(cached)) {
      this.injectStyle(cached.content);
      return Promise.resolve();
    }
    // 从网络加载
    return fetch(url)
      .then(response => {
        if (!response.ok) throw new Error('Network response was not ok');
        return response.text();
      })
      .then(content => {
        // 缓存资源
        this.cache.set(url, {
          content,
          timestamp: Date.now()
        });
        // 注入样式
        this.injectStyle(content);
      })
      .catch(error => {
        console.error('Failed to load style:', url, error);
        if (cached) {
          // 网络失败但缓存存在,使用缓存
          this.injectStyle(cached.content);
        } else {
          throw error;
        }
      });
  }
  // 检查缓存是否有效
  isCacheValid(cached) {
    return cached && (Date.now() - cached.timestamp) < CACHE_EXPIRY;
  }
  // 注入脚本到DOM
  injectScript(content) {
    const script = document.createElement('script');
    script.text = content;
    document.body.appendChild(script);
  }
  // 注入样式到DOM
  injectStyle(content) {
    const style = document.createElement('style');
    style.textContent = content;
    document.head.appendChild(style);
  }
}
export default new AssetLoader();

四、应用集成方案

4.1 主应用入口

// src/index.js
import AssetLoader from './utils/assetLoader';
// 从asset-manifest.json获取资源列表
const loadAssets = async () => {
  try {
    const response = await fetch('/asset-manifest.json');
    const manifest = await response.json();
    // 加载CSS
    if (manifest['main.css']) {
      await AssetLoader.loadStyle(manifest['main.css']);
    }
    // 加载JS
    await AssetLoader.loadScript(manifest['main.js']);
  } catch (error) {
    console.error('Failed to load assets:', error);
    // 回退方案:直接创建script标签加载
    const fallbackScript = document.createElement('script');
    fallbackScript.src = '/main.js';
    document.body.appendChild(fallbackScript);
  }
};
// 启动应用
loadAssets().then(() => {
  console.log('Application assets loaded');
});

4.2 HTML模板调整

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>离线缓存应用</title>
  <!-- 预加载asset-manifest.json -->
  <link rel="preload" href="/asset-manifest.json" rel="external nofollow"  as="fetch" crossorigin="anonymous">
</head>
<body>
  <div id="root"></div>
  <!-- 初始加载动画 -->
  <div id="app-loading" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">
    <p>Loading application...</p>
  </div>
</body>
</html>

五、高级优化方案

5.1 与Service Worker结合

// public/sw.js
const CACHE_NAME = 'app-cache-v1';
const ASSET_MANIFEST_URL = '/asset-manifest.json';
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => fetch(ASSET_MANIFEST_URL)
        .then(response => response.json())
        .then(manifest => {
          const assets = Object.values(manifest).filter(
            value => typeof value === 'string'
          );
          return cache.addAll([ASSET_MANIFEST_URL, ...assets]);
        })
      )
  );
});
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
  );
});

在Webpack配置中注册Service Worker:

// webpack.config.js
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
module.exports = {
  plugins: [
    new WorkboxWebpackPlugin.InjectManifest({
      swSrc: './public/sw.js',
      swDest: 'sw.js'
    })
  ]
};

5.2 缓存更新策略

// src/utils/assetLoader.js 添加
class AssetLoader {
  // ...原有代码
  // 检查资源更新
  async checkUpdate() {
    try {
      const response = await fetch('/asset-manifest.json?t=' + Date.now());
      const newManifest = await response.json();
      const oldManifest = this.cache.get('asset-manifest');
      if (!oldManifest) return true;
      // 比较main.js的hash是否变化
      return newManifest['main.js'] !== oldManifest['main.js'];
    } catch (error) {
      console.error('Failed to check update:', error);
      return true;
    }
  }
  // 清除过期缓存
  async clearOutdatedCache() {
    const manifest = this.cache.get('asset-manifest');
    if (!manifest) return;
    Object.keys(localStorage).forEach(key => {
      if (key.startsWith('app_cache:')) {
        const cachedUrl = key.replace('app_cache:', '');
        // 如果缓存的文件不在manifest中,或者不是当前版本的文件
        if (!Object.values(manifest).includes(cachedUrl)) {
          localStorage.removeItem(key);
        }
      }
    });
  }
}

5.3 用户界面提示

// src/ui/updatePrompt.js
export function showUpdatePrompt() {
  const prompt = document.createElement('div');
  prompt.style.position = 'fixed';
  prompt.style.bottom = '20px';
  prompt.style.right = '20px';
  prompt.style.padding = '15px';
  prompt.style.background = '#fff';
  prompt.style.borderRadius = '5px';
  prompt.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)';
  prompt.style.zIndex = '1000';
  prompt.innerHTML = `
    <p>A new version is available!</p>
    <button id="refresh-btn" style="margin-top: 10px; padding: 5px 10px;">
      Refresh
    </button>
  `;
  document.body.appendChild(prompt);
  document.getElementById('refresh-btn').addEventListener('click', () => {
    window.location.reload(true);
  });
}

在应用入口中使用:

// src/index.js
import { showUpdatePrompt } from './ui/updatePrompt';
const checkUpdate = async () => {
  const needsUpdate = await AssetLoader.checkUpdate();
  if (needsUpdate) {
    showUpdatePrompt();
  }
};
loadAssets()
  .then(() => {
    // 保存当前manifest到缓存
    fetch('/asset-manifest.json')
      .then(res => res.json())
      .then(manifest => {
        StorageCache.set('asset-manifest', manifest);
      });
    // 检查更新
    checkUpdate();
    // 清理过期缓存
    AssetLoader.clearOutdatedCache();
  });

六、性能与安全考虑

6.1 性能优化

分块缓存:对大文件进行分块存储

// 在StorageCache类中添加
async setLargeFile(key, content, chunkSize = 102400) { // 默认100KB每块
  const chunks = [];
  for (let i = 0; i < content.length; i += chunkSize) {
    chunks.push(content.slice(i, i + chunkSize));
  }
  // 存储元数据
  const meta = {
    chunkCount: chunks.length,
    totalSize: content.length,
    timestamp: Date.now()
  };
  this.set(`${key}_meta`, meta);
  // 存储分块
  for (let i = 0; i < chunks.length; i++) {
    this.set(`${key}_chunk_${i}`, chunks[i]);
  }
}

懒加载非关键资源:按需加载非必要资源

6.2 安全考虑

内容验证

// 在AssetLoader中添加
async loadScript(url) {
  // ...原有代码
  .then(content => {
    // 简单的内容验证
    if (!content || content.length < 10) {
      throw new Error('Invalid content');
    }
    // 如果是JS,检查是否包含基本语法
    if (!content.includes('function') && !content.includes('=>')) {
      throw new Error('Invalid JavaScript content');
    }
    // 缓存资源
    this.cache.set(url, {
      content,
      timestamp: Date.now(),
      etag: response.headers.get('ETag')
    });
  })
}

缓存隔离:不同用户/版本使用不同命名空间

// 根据应用版本设置不同的命名空间
const version = process.env.APP_VERSION || 'v1';
export default new StorageCache(`app_cache_${version}`);

七、完整实现示例

7.1 项目结构

/my-app
  ├── public/
  │   ├── index.html
  │   └── sw.js
  ├── src/
  │   ├── utils/
  │   │   ├── storageCache.js
  │   │   └── assetLoader.js
  │   ├── ui/
  │   │   └── updatePrompt.js
  │   └── index.js
  ├── package.json
  └── webpack.config.js

7.2 完整Webpack配置

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const WebpackAssetsManifest = require('webpack-assets-manifest');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
module.exports = {
  mode: 'production',
  entry: {
    main: './src/index.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    publicPath: '/'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: './public/index.html',
      filename: 'index.html',
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeRedundantAttributes: true,
        useShortDoctype: true,
        removeEmptyAttributes: true,
        removeStyleLinkTypeAttributes: true,
        keepClosingSlash: true,
        minifyJS: true,
        minifyCSS: true,
        minifyURLs: true
      }
    }),
    new WebpackAssetsManifest({
      output: 'asset-manifest.json',
      publicPath: true,
      writeToDisk: true,
      customize: (entry, original, manifest, asset) => {
        // 只处理JS和CSS文件
        if (entry.value.match(/\.(js|css)$/)) {
          return {
            key: entry.key,
            value: entry.value
          };
        }
        return null;
      }
    }),
    new WorkboxWebpackPlugin.InjectManifest({
      swSrc: './public/sw.js',
      swDest: 'sw.js',
      exclude: [/asset-manifest\.json$/]
    })
  ],
  optimization: {
    splitChunks: {
      chunks: 'all',
      maxSize: 244 * 1024 // 244KB
    }
  }
};

八、测试与验证

8.1 测试方案

  • 离线测试
    • 首次加载应用
    • 关闭网络连接
    • 刷新页面验证是否正常工作
  • 缓存更新测试
    • 部署新版本
    • 验证是否检测到更新
    • 检查更新后缓存是否正确
  • 性能测试
    • 使用Chrome DevTools的Network面板比较加载时间
    • 模拟慢速网络环境

8.2 验证方法

// 在控制台验证缓存状态
function checkCacheStatus() {
  const cache = new StorageCache();
  const manifest = cache.get('asset-manifest');
  console.log('Cached Manifest:', manifest);
  if (manifest) {
    Object.entries(manifest).forEach(([key, url]) => {
      const cached = cache.get(url);
      console.log(`Resource ${key}:`, cached ? 'Cached' : 'Not Cached');
    });
  }
  console.log('LocalStorage Usage:',
    `${JSON.stringify(localStorage).length / 1024} KB`);
}
checkCacheStatus();

九、备选方案比较

9.1 LocalStorage vs IndexedDB

特性LocalStorageIndexedDB
容量限制5MB左右50MB以上
数据类型仅字符串结构化数据
查询能力简单键值查询复杂查询
性能同步操作,可能阻塞主线程异步操作
适用场景小量简单数据大量结构化数据

9.2 纯前端缓存 vs Service Worker

特性纯前端缓存Service Worker
实现复杂度简单中等
控制粒度应用层控制网络请求层控制
离线能力有限强大
缓存策略手动实现内置多种策略
兼容性所有支持LocalStorage的浏览器现代浏览器

十、总结与最佳实践

10.1 实施建议

  • 分层缓存策略
    • 关键资源:使用LocalStorage + Service Worker双重缓存
    • 非关键资源:按需加载
    • 大型资源:考虑使用IndexedDB
  • 版本控制
    • 每次发布新版本时更新缓存命名空间
    • 提供明确的更新提示给用户
  • 容量管理
    • 实现自动清理旧缓存机制
    • 监控LocalStorage使用量
  • 渐进增强
    • 优先保证基础功能的离线可用性
    • 高级功能可以要求网络连接

10.2 注意事项

  • 安全性
    • 验证缓存内容的完整性
    • 防范XSS攻击对LocalStorage的影响
  • 性能平衡
    • 避免缓存过多资源导致首次加载变慢
    • 合理设置缓存过期时间
  • 测试覆盖
    • 覆盖各种网络条件测试
    • 验证缓存清理逻辑的正确性

通过本文介绍的Webpack与LocalStorage结合的离线缓存方案,你可以为应用提供基本的离线功能,显著提升用户在弱网环境下的体验。对于更复杂的离线需求,建议结合Service Worker实现更全面的离线解决方案

到此这篇关于如何使用 Webpack 和 LocalStorage 实现静态资源的离线缓存的文章就介绍到这了,更多相关Webpack 和 LocalStorage离线缓存内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • javascript实现简单滚动窗口

    javascript实现简单滚动窗口

    这篇文章主要为大家详细介绍了javascript实现简单滚动窗口,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-06-06
  • javascript动态修改Li节点值的方法

    javascript动态修改Li节点值的方法

    这篇文章主要介绍了javascript动态修改Li节点值的方法,涉及针对li节点的操作技巧,非常具有实用价值,需要的朋友可以参考下
    2015-01-01
  • 斜45度寻路实现函数

    斜45度寻路实现函数

    没事写个寻路的,很简单,有需要的朋友可以参考下。
    2009-08-08
  • js实现楼层效果的简单实例

    js实现楼层效果的简单实例

    下面小编就为大家带来一篇js实现楼层效果的简单实例。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2016-07-07
  • 用js获取点击图片的值!

    用js获取点击图片的值!

    用js获取点击图片的值!...
    2007-07-07
  • List Installed Software Features

    List Installed Software Features

    List Installed Software Features...
    2007-06-06
  • 跟我学习javascript的执行上下文

    跟我学习javascript的执行上下文

    跟我学习javascript的执行上下文,读完本文后,你应该清楚了解释器做了什么,为什么函数和变量能在声明前使用以及它们的值是如何决定的,需要了解这些内容的朋友可以参考下
    2015-11-11
  • 微信小程序实现上传照片代码实例解析

    微信小程序实现上传照片代码实例解析

    这篇文章主要介绍了微信小程序实现上传照片代码实例解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-08-08
  • 原生JS实现导航下拉菜单效果

    原生JS实现导航下拉菜单效果

    这篇文章主要介绍了JS实现导航下拉菜单效果,用原生JS实现的一个导航下拉菜单,下拉菜单的宽度与浏览器视口的宽度一样,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-03-03
  • js实现点击后将文字或图片复制到剪贴板的方法

    js实现点击后将文字或图片复制到剪贴板的方法

    这篇文章主要介绍了js实现点击后将文字或图片复制到剪贴板的方法,功能非常实用,需要的朋友可以参考下
    2014-08-08

最新评论