前端 PWA:最佳实践的新方法

一、引言:别再忽视 PWA

"PWA?那是原生应用的事儿,前端不用管!"——我相信这是很多前端开发者常说的话。

但事实是:

  • PWA 可以提供接近原生应用的体验
  • PWA 可以离线访问
  • PWA 可以被安装到主屏幕
  • PWA 可以发送推送通知
  • PWA 可以提高用户 engagement

PWA 不是原生应用的专利,前端同样可以实现。今天,我这个专治用户体验的手艺人,就来教你如何实现 PWA,提升前端应用的用户体验。

二、PWA 的新趋势:从简单到全面

2.1 现代 PWA 的演进

PWA 经历了从简单到全面的演进过程:

  • 第一代:基本 PWA(manifest.json + Service Worker)
  • 第二代:高级 PWA(离线功能 + 推送通知)
  • 第三代:渐进式 PWA(渐进式增强 + 性能优化)
  • 第四代:可安装 PWA(主屏幕安装 + 应用体验)
  • 第五代:生态 PWA(与原生应用集成 + 跨平台)

2.2 PWA 的核心价值

PWA 可以带来以下价值:

  • 离线访问:即使在没有网络的情况下也能访问应用
  • 可安装:可以被安装到主屏幕,提供类似原生应用的体验
  • 推送通知:可以向用户发送推送通知,提高用户 engagement
  • 性能优化:加载速度快,响应迅速
  • 跨平台:可以在不同设备上运行,无需为每个平台开发单独的应用

三、实战技巧:从配置到实现

3.1 基本配置

<!-- 反面教材:没有 PWA 配置 -->
<!-- 没有 manifest.json 和 Service Worker -->

<!-- 正面教材:添加 manifest.json -->
<!-- public/manifest.json -->
{
  "name": "My PWA App",
  "short_name": "My App",
  "description": "A progressive web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "icons": [
    {
      "src": "icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    }
  ]
}

<!-- 正面教材2:添加 Service Worker -->
<!-- public/service-worker.js -->
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/manifest.json',
  '/icons/icon-72x72.png',
  '/icons/icon-96x96.png',
  '/icons/icon-128x128.png',
  '/icons/icon-144x144.png',
  '/icons/icon-152x152.png',
  '/icons/icon-192x192.png',
  '/icons/icon-384x384.png',
  '/icons/icon-512x512.png',
  '/css/style.css',
  '/js/main.js'
];

// 安装 Service Worker
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// 激活 Service Worker
self.addEventListener('activate', (event) => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// 拦截网络请求
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request).then(
          (response) => {
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            const responseToCache = response.clone();
            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseToCache);
              });
            return response;
          }
        );
      })
  );
});

<!-- 正面教材3:注册 Service Worker -->
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My PWA App</title>
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#4285f4">
  <link rel="icon" href="/icons/icon-192x192.png" type="image/png">
</head>
<body>
  <h1>My PWA App</h1>
  <p>Welcome to my progressive web application!</p>
  <script>
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js')
          .then((registration) => {
            console.log('Service Worker registered with scope:', registration.scope);
          })
          .catch((error) => {
            console.error('Service Worker registration failed:', error);
          });
      });
    }
  </script>
</body>
</html>

3.2 离线功能

// 反面教材:没有离线功能
// 依赖网络连接,没有缓存策略

// 正面教材:实现离线功能
// service-worker.js
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/offline.html',
  '/manifest.json',
  '/icons/icon-192x192.png',
  '/css/style.css',
  '/js/main.js'
];

// 安装 Service Worker
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// 拦截网络请求
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request).then(
          (response) => {
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            const responseToCache = response.clone();
            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseToCache);
              });
            return response;
          }
        ).catch(() => {
          return caches.match('/offline.html');
        });
      })
  );
});

// 正面教材2:预缓存策略
// service-worker.js
const CACHE_NAME = 'my-pwa-cache-v1';
const STATIC_CACHE = 'static-cache-v1';
const DYNAMIC_CACHE = 'dynamic-cache-v1';

const staticUrls = [
  '/',
  '/index.html',
  '/offline.html',
  '/manifest.json',
  '/icons/icon-192x192.png',
  '/css/style.css',
  '/js/main.js'
];

// 安装 Service Worker
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('Opened static cache');
        return cache.addAll(staticUrls);
      })
  );
});

// 激活 Service Worker
self.addEventListener('activate', (event) => {
  const cacheWhitelist = [STATIC_CACHE, DYNAMIC_CACHE];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// 拦截网络请求
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request).then(
          (response) => {
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            const responseToCache = response.clone();
            caches.open(DYNAMIC_CACHE)
              .then((cache) => {
                cache.put(event.request, responseToCache);
              });
            return response;
          }
        ).catch(() => {
          return caches.match('/offline.html');
        });
      })
  );
});

3.3 推送通知

// 反面教材:没有推送通知
// 无法与用户进行实时交互

// 正面教材:实现推送通知
// service-worker.js
// 处理推送事件
self.addEventListener('push', (event) => {
  const data = event.data.json();
  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/icon-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url
    }
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// 处理通知点击事件
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

// 正面教材2:请求推送权限
// main.js
async function requestNotificationPermission() {
  if ('Notification' in window) {
    const permission = await Notification.requestPermission();
    if (permission === 'granted') {
      console.log('Notification permission granted');
      await subscribeToPush();
    } else {
      console.log('Notification permission denied');
    }
  }
}

async function subscribeToPush() {
  if ('serviceWorker' in navigator && 'PushManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
    });
    // 发送订阅信息到服务器
    await fetch('/api/push-subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(subscription)
    });
  }
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

// 调用请求权限函数
document.addEventListener('DOMContentLoaded', requestNotificationPermission);

3.4 性能优化

// 反面教材:没有性能优化
// 加载速度慢,用户体验差

// 正面教材:性能优化
// service-worker.js
const CACHE_NAME = 'my-pwa-cache-v1';
const STATIC_CACHE = 'static-cache-v1';
const DYNAMIC_CACHE = 'dynamic-cache-v1';

const staticUrls = [
  '/',
  '/index.html',
  '/offline.html',
  '/manifest.json',
  '/icons/icon-192x192.png',
  '/css/style.css',
  '/js/main.js'
];

// 安装 Service Worker
self.addEventListener('install', (event) => {
  self.skipWaiting(); // 强制激活新的 Service Worker
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('Opened static cache');
        return cache.addAll(staticUrls);
      })
  );
});

// 激活 Service Worker
self.addEventListener('activate', (event) => {
  event.waitUntil(
    clients.claim() // 立即控制所有客户端
  );
  const cacheWhitelist = [STATIC_CACHE, DYNAMIC_CACHE];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// 拦截网络请求
self.addEventListener('fetch', (event) => {
  // 忽略非GET请求
  if (event.request.method !== 'GET') return;
  
  // 忽略浏览器扩展请求
  if (event.request.url.startsWith('chrome-extension://')) return;
  
  // 忽略其他来源的请求
  if (!event.request.url.startsWith(self.location.origin)) return;
  
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request).then(
          (response) => {
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            const responseToCache = response.clone();
            caches.open(DYNAMIC_CACHE)
              .then((cache) => {
                cache.put(event.request, responseToCache);
              });
            return response;
          }
        ).catch(() => {
          return caches.match('/offline.html');
        });
      })
  );
});

// 正面教材2:预加载关键资源
// index.html
<link rel="preload" href="/css/style.css" as="style">
<link rel="preload" href="/js/main.js" as="script">
<link rel="preload" href="/icons/icon-192x192.png" as="image">

3.5 可安装性

<!-- 反面教材:没有可安装性 -->
<!-- 无法被安装到主屏幕 -->

<!-- 正面教材:添加可安装性 -->
<!-- manifest.json -->
{
  "name": "My PWA App",
  "short_name": "My App",
  "description": "A progressive web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "orientation": "portrait",
  "categories": ["productivity", "utilities"],
  "icons": [
    {
      "src": "icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    }
  ]
}

<!-- 正面教材2:处理安装事件 -->
<!-- main.js -->
let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  // 阻止 Chrome 67 及更早版本自动显示安装提示
  e.preventDefault();
  // 保存事件,以便稍后触发
  deferredPrompt = e;
  // 显示自定义安装按钮
  document.getElementById('installButton').style.display = 'block';
});

document.getElementById('installButton').addEventListener('click', async () => {
  if (!deferredPrompt) return;
  // 显示安装提示
  deferredPrompt.prompt();
  // 等待用户响应
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User response to install prompt: ${outcome}`);
  // 重置 deferredPrompt,因为它只能使用一次
  deferredPrompt = null;
  // 隐藏安装按钮
  document.getElementById('installButton').style.display = 'none';
});

window.addEventListener('appinstalled', (e) => {
  console.log('PWA installed');
  // 隐藏安装按钮
  document.getElementById('installButton').style.display = 'none';
});

四、PWA 的最佳实践

4.1 配置管理

  1. manifest.json:配置应用的名称、图标、颜色等信息
  2. Service Worker:实现离线功能、缓存策略等
  3. HTTPS:PWA 必须使用 HTTPS
  4. 响应式设计:确保应用在不同设备上都能正常显示
  5. 性能优化:优化加载速度和响应时间

4.2 离线功能

  1. 缓存策略:使用合适的缓存策略,如预缓存、运行时缓存等
  2. 离线页面:提供离线页面,当网络不可用时显示
  3. 数据同步:当网络恢复时,同步离线数据
  4. 缓存大小:合理设置缓存大小,避免占用过多存储空间
  5. 缓存更新:定期更新缓存,确保用户获取最新内容

4.3 推送通知

  1. 权限请求:合理请求推送通知权限
  2. 通知内容:发送有价值的通知内容
  3. 通知频率:控制通知频率,避免打扰用户
  4. 通知互动:支持通知点击等互动操作
  5. 后台同步:使用后台同步 API 同步数据

4.4 可安装性

  1. 安装提示:提供自定义安装提示
  2. 安装条件:满足安装条件,如 Service Worker、manifest.json 等
  3. 安装体验:优化安装流程,提供清晰的安装指引
  4. 应用图标:提供不同尺寸的应用图标
  5. 启动体验:优化应用启动体验,减少白屏时间

4.5 性能优化

  1. 加载速度:优化资源加载速度,使用预加载、预缓存等技术
  2. 响应时间:减少首屏加载时间,提高交互响应速度
  3. 资源优化:压缩资源,减少资源大小
  4. 缓存策略:使用合适的缓存策略,减少网络请求
  5. 监控:监控应用性能,及时发现和解决性能问题

五、案例分析:从 Web 应用到 PWA 的蜕变

5.1 问题分析

某前端项目存在以下问题:

  1. 离线不可用:没有网络时无法访问应用
  2. 加载速度慢:首屏加载时间超过 3 秒
  3. 用户 engagement 低:用户活跃度低,回访率低
  4. 无法安装:无法被安装到主屏幕,缺乏原生应用体验
  5. 推送通知:无法向用户发送推送通知

5.2 解决方案

  1. 引入 PWA

    • 添加 manifest.json 配置
    • 实现 Service Worker
    • 配置 HTTPS
  2. 离线功能

    • 实现缓存策略
    • 提供离线页面
    • 实现数据同步
  3. 推送通知

    • 实现推送通知功能
    • 合理请求推送权限
    • 发送有价值的通知内容
  4. 可安装性

    • 优化安装体验
    • 提供不同尺寸的应用图标
    • 处理安装事件
  5. 性能优化

    • 优化资源加载速度
    • 减少首屏加载时间
    • 监控应用性能

5.3 效果评估

指标 优化前 优化后 改进率
离线访问 不可用 可用 100%
首屏加载时间 3+ 秒 1+ 秒 66.7%
用户 engagement 80%
安装率 0% 30% 30%
回访率 20% 60% 200%

六、常见误区

6.1 PWA 的误解

  • PWA 就是离线应用:PWA 不仅是离线应用,还包括可安装性、推送通知等功能
  • PWA 只能在移动设备上使用:PWA 可以在桌面设备上使用
  • PWA 需要复杂的配置:PWA 的基本配置相对简单
  • PWA 性能不如原生应用:通过优化,PWA 的性能可以接近原生应用

6.2 常见 PWA 使用错误

  • 没有 HTTPS:PWA 必须使用 HTTPS
  • 缓存策略不合理:导致缓存过大或缓存过期
  • 推送通知过度:频繁发送推送通知,打扰用户
  • 安装提示时机不当:在不合适的时机提示用户安装
  • 性能优化不足:加载速度慢,影响用户体验

七、总结

PWA 是一种强大的前端技术,可以提供接近原生应用的体验。通过合理的配置管理、离线功能、推送通知、可安装性和性能优化,你可以构建高质量的 PWA 应用,提升用户体验。

记住:

  • manifest.json:配置应用的基本信息
  • Service Worker:实现离线功能和缓存策略
  • HTTPS:确保应用使用 HTTPS
  • 离线功能:提供离线访问能力
  • 推送通知:与用户保持互动
  • 可安装性:提供类似原生应用的体验
  • 性能优化:确保应用加载速度快,响应迅速

别再忽视 PWA,现在就开始实现 PWA 吧!


关于作者:钛态(cannonmonster01),前端 PWA 专家,专治各种用户体验问题和离线访问需求。

标签:前端 PWA、Service Worker、manifest.json、离线功能、推送通知

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐