如何使用 Webpack 和 LocalStorage 實(shí)現(xiàn)靜態(tài)資源的離線緩存
在現(xiàn)代 Web 應(yīng)用中,實(shí)現(xiàn)靜態(tài)資源的離線緩存可以顯著提升用戶體驗(yàn),特別是在網(wǎng)絡(luò)不穩(wěn)定或離線狀態(tài)下。本文將詳細(xì)介紹如何結(jié)合 Webpack 和 LocalStorage 實(shí)現(xiàn)這一功能。
一、實(shí)現(xiàn)原理概述
1.1 核心思路
- 構(gòu)建階段:使用 Webpack 生成帶有哈希值的靜態(tài)資源文件名
- 緩存階段:在應(yīng)用首次加載時(shí)將資源存入 LocalStorage
- 讀取階段:后續(xù)請(qǐng)求優(yōu)先從 LocalStorage 讀取,失敗則回退到網(wǎng)絡(luò)請(qǐng)求
1.2 技術(shù)組合優(yōu)勢(shì)
- Webpack:自動(dòng)化構(gòu)建、資源版本控制
- LocalStorage:簡(jiǎn)單易用的客戶端存儲(chǔ)方案
- Service Worker(可選):更強(qiáng)大的離線緩存能力
二、Webpack 配置準(zhǔn)備
2.1 基礎(chǔ)配置
// 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 緩存實(shí)現(xiàn)
3.1 緩存工具類
// src/utils/storageCache.js
class StorageCache {
constructor(namespace = 'app_cache') {
this.namespace = namespace;
this.maxSize = 5 * 1024 * 1024; // 5MB LocalStorage限制
}
// 存儲(chǔ)資源
set(key, value) {
try {
const data = {
value,
timestamp: Date.now()
};
localStorage.setItem(`${this.namespace}:${key}`, JSON.stringify(data));
return true;
} catch (e) {
// 超出容量時(shí)清理舊緩存
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);
}
}
// 按時(shí)間排序
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();
}
// 從網(wǎng)絡(luò)加載
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) {
// 網(wǎng)絡(luò)失敗但緩存存在,使用緩存
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();
}
// 從網(wǎng)絡(luò)加載
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) {
// 網(wǎng)絡(luò)失敗但緩存存在,使用緩存
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();四、應(yīng)用集成方案
4.1 主應(yīng)用入口
// 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);
// 回退方案:直接創(chuàng)建script標(biāo)簽加載
const fallbackScript = document.createElement('script');
fallbackScript.src = '/main.js';
document.body.appendChild(fallbackScript);
}
};
// 啟動(dòng)應(yīng)用
loadAssets().then(() => {
console.log('Application assets loaded');
});4.2 HTML模板調(diào)整
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>離線緩存應(yīng)用</title>
<!-- 預(yù)加載asset-manifest.json -->
<link rel="preload" href="/asset-manifest.json" rel="external nofollow" as="fetch" crossorigin="anonymous">
</head>
<body>
<div id="root"></div>
<!-- 初始加載動(dòng)畫 -->
<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>五、高級(jí)優(yōu)化方案
5.1 與Service Worker結(jié)合
// 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配置中注冊(cè)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;
}
}
// 清除過(guò)期緩存
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中,或者不是當(dāng)前版本的文件
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);
});
}在應(yīng)用入口中使用:
// src/index.js
import { showUpdatePrompt } from './ui/updatePrompt';
const checkUpdate = async () => {
const needsUpdate = await AssetLoader.checkUpdate();
if (needsUpdate) {
showUpdatePrompt();
}
};
loadAssets()
.then(() => {
// 保存當(dāng)前manifest到緩存
fetch('/asset-manifest.json')
.then(res => res.json())
.then(manifest => {
StorageCache.set('asset-manifest', manifest);
});
// 檢查更新
checkUpdate();
// 清理過(guò)期緩存
AssetLoader.clearOutdatedCache();
});六、性能與安全考慮
6.1 性能優(yōu)化
分塊緩存:對(duì)大文件進(jìn)行分塊存儲(chǔ)
// 在StorageCache類中添加
async setLargeFile(key, content, chunkSize = 102400) { // 默認(rèn)100KB每塊
const chunks = [];
for (let i = 0; i < content.length; i += chunkSize) {
chunks.push(content.slice(i, i + chunkSize));
}
// 存儲(chǔ)元數(shù)據(jù)
const meta = {
chunkCount: chunks.length,
totalSize: content.length,
timestamp: Date.now()
};
this.set(`${key}_meta`, meta);
// 存儲(chǔ)分塊
for (let i = 0; i < chunks.length; i++) {
this.set(`${key}_chunk_${i}`, chunks[i]);
}
}懶加載非關(guān)鍵資源:按需加載非必要資源
6.2 安全考慮
內(nèi)容驗(yàn)證:
// 在AssetLoader中添加
async loadScript(url) {
// ...原有代碼
.then(content => {
// 簡(jiǎn)單的內(nèi)容驗(yàn)證
if (!content || content.length < 10) {
throw new Error('Invalid content');
}
// 如果是JS,檢查是否包含基本語(yǔ)法
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')
});
})
}緩存隔離:不同用戶/版本使用不同命名空間
// 根據(jù)應(yīng)用版本設(shè)置不同的命名空間
const version = process.env.APP_VERSION || 'v1';
export default new StorageCache(`app_cache_${version}`);七、完整實(shí)現(xiàn)示例
7.1 項(xiàng)目結(jié)構(gòu)
/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
}
}
};八、測(cè)試與驗(yàn)證
8.1 測(cè)試方案
- 離線測(cè)試:
- 首次加載應(yīng)用
- 關(guān)閉網(wǎng)絡(luò)連接
- 刷新頁(yè)面驗(yàn)證是否正常工作
- 緩存更新測(cè)試:
- 部署新版本
- 驗(yàn)證是否檢測(cè)到更新
- 檢查更新后緩存是否正確
- 性能測(cè)試:
- 使用Chrome DevTools的Network面板比較加載時(shí)間
- 模擬慢速網(wǎng)絡(luò)環(huán)境
8.2 驗(yàn)證方法
// 在控制臺(tái)驗(yàn)證緩存狀態(tài)
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
| 特性 | LocalStorage | IndexedDB |
|---|---|---|
| 容量限制 | 5MB左右 | 50MB以上 |
| 數(shù)據(jù)類型 | 僅字符串 | 結(jié)構(gòu)化數(shù)據(jù) |
| 查詢能力 | 簡(jiǎn)單鍵值查詢 | 復(fù)雜查詢 |
| 性能 | 同步操作,可能阻塞主線程 | 異步操作 |
| 適用場(chǎng)景 | 小量簡(jiǎn)單數(shù)據(jù) | 大量結(jié)構(gòu)化數(shù)據(jù) |
9.2 純前端緩存 vs Service Worker
| 特性 | 純前端緩存 | Service Worker |
|---|---|---|
| 實(shí)現(xiàn)復(fù)雜度 | 簡(jiǎn)單 | 中等 |
| 控制粒度 | 應(yīng)用層控制 | 網(wǎng)絡(luò)請(qǐng)求層控制 |
| 離線能力 | 有限 | 強(qiáng)大 |
| 緩存策略 | 手動(dòng)實(shí)現(xiàn) | 內(nèi)置多種策略 |
| 兼容性 | 所有支持LocalStorage的瀏覽器 | 現(xiàn)代瀏覽器 |
十、總結(jié)與最佳實(shí)踐
10.1 實(shí)施建議
- 分層緩存策略:
- 關(guān)鍵資源:使用LocalStorage + Service Worker雙重緩存
- 非關(guān)鍵資源:按需加載
- 大型資源:考慮使用IndexedDB
- 版本控制:
- 每次發(fā)布新版本時(shí)更新緩存命名空間
- 提供明確的更新提示給用戶
- 容量管理:
- 實(shí)現(xiàn)自動(dòng)清理舊緩存機(jī)制
- 監(jiān)控LocalStorage使用量
- 漸進(jìn)增強(qiáng):
- 優(yōu)先保證基礎(chǔ)功能的離線可用性
- 高級(jí)功能可以要求網(wǎng)絡(luò)連接
10.2 注意事項(xiàng)
- 安全性:
- 驗(yàn)證緩存內(nèi)容的完整性
- 防范XSS攻擊對(duì)LocalStorage的影響
- 性能平衡:
- 避免緩存過(guò)多資源導(dǎo)致首次加載變慢
- 合理設(shè)置緩存過(guò)期時(shí)間
- 測(cè)試覆蓋:
- 覆蓋各種網(wǎng)絡(luò)條件測(cè)試
- 驗(yàn)證緩存清理邏輯的正確性
通過(guò)本文介紹的Webpack與LocalStorage結(jié)合的離線緩存方案,你可以為應(yīng)用提供基本的離線功能,顯著提升用戶在弱網(wǎng)環(huán)境下的體驗(yàn)。對(duì)于更復(fù)雜的離線需求,建議結(jié)合Service Worker實(shí)現(xiàn)更全面的離線解決方案
到此這篇關(guān)于如何使用 Webpack 和 LocalStorage 實(shí)現(xiàn)靜態(tài)資源的離線緩存的文章就介紹到這了,更多相關(guān)Webpack 和 LocalStorage離線緩存內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- webpack5處理圖片、圖標(biāo)字體、多媒體等靜態(tài)資源文件
- vue項(xiàng)目之webpack打包靜態(tài)資源路徑不準(zhǔn)確的問(wèn)題
- webpack實(shí)現(xiàn)靜態(tài)資源緩存的方法
- webpack 靜態(tài)資源集中輸出的方法示例
- vue填坑之webpack run build 靜態(tài)資源找不到的解決方法
- 詳解vue-cli與webpack結(jié)合如何處理靜態(tài)資源
- Vue利用localStorage本地緩存使頁(yè)面刷新驗(yàn)證碼不清零功能的實(shí)現(xiàn)
- localstorage實(shí)現(xiàn)帶過(guò)期時(shí)間的緩存功能
- localStorage的黑科技-js和css緩存機(jī)制
相關(guān)文章
手機(jī)開(kāi)發(fā)必備技巧:javascript及CSS功能代碼分享
這篇文章主要介紹了手機(jī)開(kāi)發(fā)必備技巧:javascript及CSS功能代碼分享,本文講解了viewport(可視區(qū)域)操作、鏈接操作、javascript事件等內(nèi)容,需要的朋友可以參考下2015-05-05
淺談監(jiān)聽(tīng)單選框radio改變事件(和layui中單選按鈕改變事件)
今天小編就為大家分享一篇淺談監(jiān)聽(tīng)單選框radio改變事件(和layui中單選按鈕改變事件),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2019-09-09
echarts中幾種漸變方式的具體實(shí)現(xiàn)方式
在使用echarts繪制圖表時(shí),有的時(shí)候需要使用漸變色,下面這篇文章主要給大家介紹了關(guān)于echarts中幾種漸變方式的具體實(shí)現(xiàn)方式,文中通過(guò)實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-11-11
微信小程序自定義select下拉選項(xiàng)框組件的實(shí)現(xiàn)代碼
微信小程序中沒(méi)有select下拉選項(xiàng)框,所以只有自定義。這篇文章主要介紹了微信小程序自定義select下拉選項(xiàng)框組件,需要的朋友可以參考下2018-08-08
JS對(duì)象屬性的檢測(cè)與獲取操作實(shí)例分析
這篇文章主要介紹了JS對(duì)象屬性的檢測(cè)與獲取操作,結(jié)合實(shí)例形式分析了JS針對(duì)ES5、ES6實(shí)現(xiàn)對(duì)象屬性的檢測(cè)與獲取常見(jiàn)操作技巧,需要的朋友可以參考下2020-03-03
JavaScript實(shí)現(xiàn)大文件分片上傳處理
這篇文章主要為大家詳細(xì)介紹了JavaScript實(shí)現(xiàn)大文件分片上傳處理,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-08-08
使用javascript實(shí)現(xiàn)頁(yè)面加載loading動(dòng)畫(附完整源碼)
由于項(xiàng)目中多處要給ajax提交的時(shí)候增加等待動(dòng)畫效果,所以就寫了一個(gè)簡(jiǎn)單的通用js方法,這篇文章主要給大家介紹了關(guān)于如何使用javascript實(shí)現(xiàn)頁(yè)面加載loading動(dòng)畫的相關(guān)資料,需要的朋友可以參考下2024-06-06

