PWA进阶:打造离线可用的Web应用

引言

在移动互联网时代,用户对Web应用的期望已不仅限于可访问性,更要求其具备类似原生应用的体验。Progressive Web App (PWA) 技术的出现,使Web应用能够提供接近原生应用的用户体验,尤其是在网络连接不稳定或完全离线的情况下仍然可以使用。本文将深入探讨如何构建真正离线可用的PWA,帮助开发者掌握这一强大技术的核心要点。

PWA核心技术回顾

在深入探讨离线功能之前,让我们简要回顾PWA的三个核心技术:

  1. Service Worker:一种运行在浏览器背后的脚本,能够拦截和处理网络请求,是实现离线功能的关键
  2. Web App Manifest:定义应用的外观和行为的JSON文件
  3. HTTPS:PWA必须通过安全连接提供服务

Service Worker生命周期

Service Worker的生命周期是理解离线功能的基础:

  1. 注册:告诉浏览器Service Worker的位置
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => {
      console.log('Service Worker注册成功,作用域为:', registration.scope);
    })
    .catch(error => {
      console.log('Service Worker注册失败:', error);
    });
}
  1. 安装:首次注册或更新时触发,通常用于缓存静态资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('v1').then(cache => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/script.js',
        '/images/logo.png'
      ]);
    })
  );
});
  1. 激活:安装成功后触发,用于清理旧缓存
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.filter(cacheName => {
          return cacheName !== 'v1';
        }).map(cacheName => {
          return caches.delete(cacheName);
        })
      );
    })
  );
});
  1. 拦截请求:激活后开始拦截网络请求
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

高级缓存策略

1. 缓存优先策略 (Cache First)

适用于不经常变化的静态资源,如图片、CSS、JavaScript文件等。

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // 如果在缓存中找到响应,则返回缓存的版本
        if (response) {
          return response;
        }
        
        // 否则发送网络请求并缓存结果
        return fetch(event.request).then(networkResponse => {
          // 检查我们收到的是否为有效响应
          if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
            return networkResponse;
          }
          
          // 克隆响应,因为响应是流,只能使用一次
          const responseToCache = networkResponse.clone();
          
          caches.open('v1')
            .then(cache => {
              cache.put(event.request, responseToCache);
            });
            
          return networkResponse;
        });
      })
  );
});

2. 网络优先策略 (Network First)

适用于需要最新数据的请求,同时提供离线访问能力。

self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // 如果响应成功,克隆它并存储到缓存中
        if (response.status === 200) {
          const responseToCache = response.clone();
          caches.open('v1')
            .then(cache => {
              cache.put(event.request, responseToCache);
            });
        }
        return response;
      })
      .catch(() => {
        // 如果网络请求失败,尝试从缓存中获取
        return caches.match(event.request);
      })
  );
});

3. Stale-While-Revalidate

提供快速响应的同时在后台更新缓存。

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.open('v1').then(cache => {
      return cache.match(event.request).then(response => {
        const fetchPromise = fetch(event.request).then(networkResponse => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        // 立即返回缓存的响应,同时更新缓存
        return response || fetchPromise;
      });
    })
  );
});

IndexedDB实现离线数据存储

对于需要存储和操作结构化数据的应用,IndexedDB是理想的选择:

// 打开数据库
const openDB = () => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyDatabase', 1);
    
    request.onerror = event => {
      reject('数据库打开失败');
    };
    
    request.onsuccess = event => {
      resolve(event.target.result);
    };
    
    request.onupgradeneeded = event => {
      const db = event.target.result;
      // 创建对象仓库
      const store = db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
      store.createIndex('title', 'title', { unique: false });
      store.createIndex('completed', 'completed', { unique: false });
    };
  });
};

// 添加任务
const addTask = async (task) => {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['tasks'], 'readwrite');
    const store = transaction.objectStore('tasks');
    const request = store.add(task);
    
    request.onsuccess = () => {
      resolve(request.result);
    };
    
    request.onerror = () => {
      reject('添加任务失败');
    };
  });
};

// 获取所有任务
const getAllTasks = async () => {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['tasks'], 'readonly');
    const store = transaction.objectStore('tasks');
    const request = store.getAll();
    
    request.onsuccess = () => {
      resolve(request.result);
    };
    
    request.onerror = () => {
      reject('获取任务失败');
    };
  });
};

实现离线数据同步

当网络恢复时,将本地更改同步到服务器:

// 检查网络连接并同步数据
const syncData = async () => {
  if (!navigator.onLine) return;
  
  try {
    const tasks = await getAllTasks();
    const unsynced = tasks.filter(task => !task.synced);
    
    for (const task of unsynced) {
      const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(task)
      });
      
      if (response.ok) {
        // 更新本地状态为已同步
        await updateTaskSyncStatus(task.id, true);
      }
    }
  } catch (error) {
    console.error('同步失败:', error);
  }
};

// 监听网络恢复事件
window.addEventListener('online', syncData);

提供离线用户体验反馈

确保用户了解应用的离线状态:

// 检测并显示网络状态
const updateNetworkStatus = () => {
  const statusEl = document.getElementById('network-status');
  
  if (navigator.onLine) {
    statusEl.textContent = '在线';
    statusEl.classList.remove('offline');
    statusEl.classList.add('online');
    // 尝试同步数据
    syncData();
  } else {
    statusEl.textContent = '离线';
    statusEl.classList.remove('online');
    statusEl.classList.add('offline');
  }
};

// 初始化时检查
updateNetworkStatus();

// 监听网络状态变化
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);

完整示例:离线待办事项应用

下面是一个完整的离线待办事项应用示例:

index.html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>离线待办事项</title>
  <link rel="manifest" href="/manifest.json">
  <link rel="stylesheet" href="/styles.css">
  <meta name="theme-color" content="#2196F3">
</head>
<body>
  <header>
    <h1>我的待办事项</h1>
    <div id="network-status"></div>
  </header>
  
  <main>
    <form id="task-form">
      <input type="text" id="task-input" placeholder="添加新任务..." required>
      <button type="submit">添加</button>
    </form>
    
    <ul id="tasks-list"></ul>
  </main>
  
  <script src="/app.js"></script>
  <script>
    // 注册 Service Worker
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js')
        .then(registration => {
          console.log('Service Worker注册成功');
        })
        .catch(error => {
          console.error('Service Worker注册失败:', error);
        });
    }
  </script>
</body>
</html>

app.js

// IndexedDB操作
const dbPromise = idb.openDB('tasks-store', 1, {
  upgrade(db) {
    db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
  }
});

// DOM元素
const taskForm = document.getElementById('task-form');
const taskInput = document.getElementById('task-input');
const tasksList = document.getElementById('tasks-list');
const networkStatus = document.getElementById('network-status');

// 更新网络状态显示
const updateNetworkStatus = () => {
  if (navigator.onLine) {
    networkStatus.textContent = '在线';
    networkStatus.className = 'online';
    syncTasks();
  } else {
    networkStatus.textContent = '离线';
    networkStatus.className = 'offline';
  }
};

// 添加任务到IndexedDB
const addTask = async (title) => {
  const db = await dbPromise;
  const tx = db.transaction('tasks', 'readwrite');
  const store = tx.objectStore('tasks');
  
  await store.add({
    title,
    completed: false,
    createdAt: new Date().toISOString(),
    synced: false
  });
  
  await tx.done;
  await loadTasks();
  
  if (navigator.onLine) {
    syncTasks();
  }
};

// 从IndexedDB加载任务
const loadTasks = async () => {
  const db = await dbPromise;
  const tasks = await db.getAll('tasks');
  
  tasksList.innerHTML = '';
  
  tasks.forEach(task => {
    const li = document.createElement('li');
    li.dataset.id = task.id;
    li.classList.toggle('completed', task.completed);
    
    const checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.checked = task.completed;
    checkbox.addEventListener('change', () => toggleTaskStatus(task.id, checkbox.checked));
    
    const span = document.createElement('span');
    span.textContent = task.title;
    
    const syncStatus = document.createElement('small');
    syncStatus.className = task.synced ? 'synced' : 'unsynced';
    syncStatus.textContent = task.synced ? '已同步' : '未同步';
    
    li.appendChild(checkbox);
    li.appendChild(span);
    li.appendChild(syncStatus);
    tasksList.appendChild(li);
  });
};

// 切换任务状态
const toggleTaskStatus = async (id, completed) => {
  const db = await dbPromise;
  const tx = db.transaction('tasks', 'readwrite');
  const store = tx.objectStore('tasks');
  
  const task = await store.get(id);
  task.completed = completed;
  task.synced = false;
  await store.put(task);
  
  await tx.done;
  await loadTasks();
  
  if (navigator.onLine) {
    syncTasks();
  }
};

// 同步任务到服务器
const syncTasks = async () => {
  const db = await dbPromise;
  const tasks = await db.getAll('tasks');
  const unsyncedTasks = tasks.filter(task => !task.synced);
  
  for (const task of unsyncedTasks) {
    try {
      // 这里使用模拟API调用
      const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(task)
      }).catch(() => ({ ok: false }));
      
      if (response.ok) {
        const tx = db.transaction('tasks', 'readwrite');
        const store = tx.objectStore('tasks');
        task.synced = true;
        await store.put(task);
        await tx.done;
      }
    } catch (error) {
      console.error('同步失败:', error);
    }
  }
  
  await loadTasks();
};

// 事件监听器
taskForm.addEventListener('submit', event => {
  event.preventDefault();
  const title = taskInput.value.trim();
  if (title) {
    addTask(title);
    taskInput.value = '';
  }
});

window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);

// 初始化
updateNetworkStatus();
loadTasks();

sw.js

const CACHE_NAME = 'tasks-app-v1';

const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/manifest.json',
  '/idb.js',
  '/icons/icon-192x192.png',
  '/icons/icon-512x512.png'
];

// 安装Service Worker并缓存静态资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        return cache.addAll(ASSETS_TO_CACHE);
      })
      .then(() => self.skipWaiting())
  );
});

// 激活并清理旧缓存
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.filter(cacheName => {
          return cacheName !== CACHE_NAME;
        }).map(cacheName => {
          return caches.delete(cacheName);
        })
      );
    }).then(() => self.clients.claim())
  );
});

// 处理网络请求
self.addEventListener('fetch', event => {
  // 静态资源使用缓存优先策略
  if (event.request.url.match(/\.(html|css|js|json|png|jpg|jpeg|svg|gif)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          return response || fetch(event.request).then(fetchRes => {
            return caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, fetchRes.clone());
              return fetchRes;
            });
          });
        })
    );
  } else if (event.request.url.includes('/api/')) {
    // API请求使用网络优先,失败时将请求缓存起来等待后续同步
    event.respondWith(
      fetch(event.request)
        .catch(() => {
          return new Response(JSON.stringify({
            error: 'Currently offline. Your request will sync when online.'
          }), {
            headers: { 'Content-Type': 'application/json' },
            status: 503
          });
        })
    );
  }
});

// 后台同步
self.addEventListener('sync', event => {
  if (event.tag === 'sync-tasks') {
    event.waitUntil(syncTasks());
  }
});

// 模拟同步任务
const syncTasks = async () => {
  // 这部分代码在真实应用中需要与主线程共享IndexedDB逻辑
  // 为了简化示例,这里只是展示概念
  console.log('后台同步任务执行');
};

manifest.json

{
  "name": "离线待办事项",
  "short_name": "待办事项",
  "description": "一个支持离线使用的待办事项应用",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

测试与调试

Lighthouse审计

使用Chrome DevTools中的Lighthouse进行PWA审计,确保你的应用满足PWA的要求。

模拟离线状态

  1. 在Chrome DevTools中勾选"Network"面板的"Offline"选项
  2. 使用navigator.onLine属性检测网络状态
  3. 监听onlineoffline事件

Service Worker调试

在Chrome DevTools中,进入"Application"面板,查看"Service Workers"部分:

  1. 查看Service Worker状态
  2. 模拟更新周期
  3. 查看缓存内容

最佳实践

  1. 渐进增强:确保基本功能在不支持Service Worker的浏览器上仍然可用

  2. 定期更新缓存:实现缓存版本管理,避免过时的资源

  3. 防止缓存污染:小心处理用户特定数据,不要将敏感信息存入缓存

  4. 适当的后备机制:当资源既不在缓存中也无法从网络获取时,提供合理的后备内容

  5. 定期清理IndexedDB:实现数据过期策略,避免无限存储

  6. 注意配额限制:浏览器对存储空间有限制,监控使用情况并处理超出配额的情况

  7. 提供明确的离线指示器:让用户知道应用当前是在线还是离线状态

结论

构建真正离线可用的PWA需要深入理解Service Worker、缓存策略和客户端存储技术。通过合理组合这些技术,我们可以打造出在各种网络条件下都能提供出色用户体验的Web应用。

随着浏览器对PWA技术的支持不断增强,离线Web应用将会变得越来越强大,为用户提供更加流畅、可靠的体验,同时为开发者提供更广阔的创新空间。

参考资源

  1. MDN Web Docs: Service Worker API
  2. Web.dev: Progressive Web Apps
  3. Google Developers: Workbox
  4. MDN Web Docs: IndexedDB API
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

天天进步2015

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值