PWA(Progressive Web App,渐进式Web应用),是近年来Web开发领域最热门的技术之一。

它由Google在2015年提出,核心思想是:让Web应用拥有接近原生应用的体验。PWA应用,可以离线访问,可以添加到手机桌面,可以推送通知,可以全屏运行,可以在弱网环境下快速加载。它兼具了Web应用的易传播、免安装、跨平台,和原生应用的高性能、好体验、可离线的优点。

2018年,Chrome、Firefox、Safari等主流浏览器,都已经支持PWA的核心特性。越来越多的公司,开始把自己的Web应用改造成PWA,比如Twitter、阿里巴巴、京东、星巴克等。PWA,已经从一个概念,变成了一个可以在生产环境中使用的成熟技术。

今天,我想从基础到高级,详细讲解一下PWA的配置和实现,包括PWA的核心概念、Web App Manifest、Service Worker、缓存策略、推送通知、添加到桌面、以及一些高级技巧和最佳实践。

一、PWA的核心概念

在开始配置之前,先搞清楚PWA的几个核心概念。

什么是PWA?

PWA,全称Progressive Web App,中文叫渐进式Web应用。它不是一个单一的技术,而是一系列Web技术的集合,通过这些技术,让Web应用拥有接近原生应用的体验。

PWA的核心特性包括:

  1. 可靠(Reliable):在弱网或者离线环境下,也能快速加载,不会显示白屏。
  2. 快速(Fast):页面加载速度快,交互流畅,用户操作响应迅速。
  3. 沉浸式(Engaging):可以添加到桌面,全屏运行,推送通知,让用户感觉像是在用原生应用。

PWA的三大支柱:

PWA的实现,主要依赖三个核心技术:

  1. Web App Manifest:一个JSON配置文件,定义了应用的名称、图标、主题色、启动方式等信息,让应用可以添加到桌面。
  2. Service Worker:一个运行在浏览器后台的脚本,独立于网页,可以拦截请求、缓存资源、处理推送通知,是实现离线访问和推送通知的核心。
  3. HTTPS:PWA必须运行在HTTPS环境下(localhost除外),这是为了安全,防止中间人攻击。

只要你的网站满足这三个条件,就可以说是一个PWA了。当然,还有一些其他的特性,比如响应式设计、应用外壳架构、推送通知等,可以让你的PWA体验更好。

PWA的优势:

相比传统的Web应用和原生应用,PWA有以下优势:

  1. 免安装:不需要去应用商店下载安装,用户访问网站就可以使用,也可以一键添加到桌面。
  2. 跨平台:一套代码,可以在所有支持PWA的浏览器和平台上运行,不需要为iOS、Android分别开发。
  3. 易传播:通过URL就可以分享,用户点击链接就可以访问,传播成本低。
  4. 省空间:不需要下载安装包,不占用手机存储空间,缓存的数据也很小。
  5. 自动更新:每次访问都是最新版本,不需要用户手动更新,也不需要应用商店审核。
  6. 离线可用:通过Service Worker缓存,可以在离线或者弱网环境下使用。

当然,PWA也不是完美的,它也有一些局限性,比如对硬件的访问能力不如原生应用,iOS上的支持还不够完善,推送通知的支持在不同平台上有差异等。但是,随着技术的发展,这些问题正在逐步解决。

二、第一步:配置Web App Manifest

Web App Manifest,是PWA的第一步。它是一个JSON文件,定义了应用的基本信息,让浏览器知道你的应用可以添加到桌面,以及添加到桌面之后的显示方式。

创建manifest.json:

在你的网站根目录下,创建一个manifest.json文件,内容如下:

{
  "name": "我的PWA应用",
  "short_name": "PWA",
  "description": "这是一个PWA示例应用",
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

字段说明:

  • name:应用的完整名称,会显示在安装弹窗和应用列表中。
  • short_name:应用的短名称,添加到桌面后,图标下面显示的名称,建议不超过12个字符。
  • description:应用的描述,说明应用是做什么的。
  • start_url:应用启动时打开的URL,可以是相对路径,也可以是绝对路径。
  • display:应用的显示模式,有四个可选值:

- fullscreen:全屏模式,占用整个屏幕,隐藏浏览器UI。 - standalone:独立应用模式,看起来像原生应用,有自己的窗口,隐藏浏览器的地址栏和工具栏。 - minimal-ui:最小UI模式,比standalone多一些最小的浏览器UI元素。 - browser:浏览器模式,和普通网页一样。 一般推荐用standalone,体验最接近原生应用。

  • background_color:应用启动时的背景色,在应用加载完成之前显示,建议和页面的背景色一致,避免闪屏。
  • theme_color:应用的主题色,会影响浏览器的地址栏颜色、任务切换时的颜色等。
  • orientation:应用的屏幕方向,可选值有anynaturallandscapeportrait等,一般移动端应用用portrait-primary(竖屏)。
  • icons:应用的图标数组,至少需要192x192和512x512两个尺寸的图标。图标格式推荐PNG,也支持SVG和WebP。

在HTML中引入manifest:

创建好manifest.json之后,需要在你的HTML页面中,通过<link>标签引入:

<link rel="manifest" href="/manifest.json">

把这行代码放在<head>标签里,就可以了。

验证manifest:

配置好之后,可以在Chrome浏览器中,打开开发者工具,切换到Application面板,点击Manifest,就可以看到你的manifest配置是否正确,有没有问题。

三、第二步:注册和使用Service Worker

Service Worker,是PWA的核心,也是最复杂的部分。它是一个运行在浏览器后台的脚本,独立于网页,可以拦截网络请求、缓存资源、处理推送通知,是实现离线访问和推送通知的关键。

Service Worker的特点:

  1. 运行在独立的线程:Service Worker运行在独立的线程中,不阻塞主线程,不影响页面的性能。
  2. 没有DOM访问权限:Service Worker不能直接访问页面的DOM,它通过postMessage和页面通信。
  3. 事件驱动:Service Worker是事件驱动的,它会在需要的时候被唤醒,处理完事件后自动休眠,不会一直占用资源。
  4. 可以拦截请求:Service Worker可以拦截页面发出的所有网络请求,决定是从缓存取,还是从网络取,还是自定义响应。
  5. 必须在HTTPS下运行:为了安全,Service Worker必须在HTTPS环境下运行(localhost除外,方便开发调试)。

注册Service Worker:

首先,在你的网站根目录下,创建一个sw.js文件(Service Worker脚本)。然后,在你的HTML页面中,注册这个Service Worker:

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js')
      .then(function(registration) {
        console.log('Service Worker 注册成功,作用域是:', registration.scope);
      })
      .catch(function(error) {
        console.log('Service Worker 注册失败:', error);
      });
  });
}
</script>

这段代码的意思是:如果浏览器支持Service Worker,就在页面加载完成之后,注册根目录下的sw.js。注册成功后,Service Worker的作用域默认是它所在的路径,也就是根目录下的所有页面,都会被这个Service Worker控制。

注意:Service Worker的注册路径,决定了它的作用域。sw.js放在根目录,作用域就是整个网站;如果放在/js/目录下,作用域就只有/js/路径下的页面。所以,一般把sw.js放在根目录。

Service Worker的生命周期:

Service Worker有自己的生命周期,了解生命周期,是正确使用Service Worker的关键。

  1. 解析(Parsed):浏览器解析sw.js文件,如果解析成功,就进入安装阶段。
  2. 安装(Installing):Service Worker第一次注册,或者有新版本的时候,会进入安装阶段。在这个阶段,可以做一些初始化的工作,比如缓存静态资源。安装完成后,进入等待激活阶段。
  3. 等待激活(Waiting):如果页面上已经有一个旧的Service Worker在运行,新的Service Worker安装完成后,会进入等待状态,直到所有的页面都关闭,旧的Service Worker被卸载,新的才会激活。
  4. 激活(Activating):Service Worker激活的时候,可以做一些清理工作,比如删除旧版本的缓存。激活完成后,Service Worker就开始工作了,可以拦截请求、处理事件了。
  5. 激活(Activated):Service Worker完全激活,开始控制页面,处理各种事件。
  6. 冗余(Redundant):如果安装失败,或者被新的版本替换,Service Worker就会变成冗余状态,被浏览器回收。

一个最简单的Service Worker:

下面是一个最简单的Service Worker示例,实现了静态资源的缓存:

// 缓存名称,包含版本号,方便更新
const CACHE_NAME = 'my-pwa-v1';

// 需要缓存的静态资源列表
const urlsToCache = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/script/main.js',
  '/images/logo.png'
];

// 安装事件:缓存静态资源
self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('缓存静态资源');
        return cache.addAll(urlsToCache);
      })
  );
});

// 激活事件:清理旧缓存
self.addEventListener('activate', function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName !== CACHE_NAME;
        }).map(function(cacheName) {
          console.log('删除旧缓存:', cacheName);
          return caches.delete(cacheName);
        })
      );
    })
  );
});

// 请求拦截事件:缓存优先策略
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // 如果缓存中有,就返回缓存
        if (response) {
          return response;
        }
        // 缓存中没有,就从网络获取
        return fetch(event.request);
      })
  );
});

这个Service Worker,做了三件事:

  1. 安装的时候,把指定的静态资源缓存起来。
  2. 激活的时候,删除旧版本的缓存。
  3. 拦截所有请求,如果缓存中有,就返回缓存;没有,就从网络获取。

这样,即使离线了,缓存的静态资源也能正常访问,实现了离线可用。

四、缓存策略

Service Worker最核心的功能,就是拦截请求和缓存资源。不同的资源,适合不同的缓存策略。下面介绍几种常用的缓存策略:

1. 缓存优先(Cache First)

缓存优先,就是先从缓存取,如果缓存中有,就直接返回缓存;如果缓存中没有,再从网络获取,并且把获取到的结果存入缓存。

这种策略,适合不经常变化的静态资源,比如CSS、JS、图片、字体等。这些资源,加载一次之后,就可以一直用缓存,不需要每次都从网络获取,加载速度快,也能离线访问。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        if (response) {
          return response;
        }
        return fetch(event.request).then(function(response) {
          // 把网络获取的结果存入缓存
          return caches.open(CACHE_NAME).then(function(cache) {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      })
  );
});

2. 网络优先(Network First)

网络优先,就是先从网络获取,如果网络成功,就返回网络结果,并且更新缓存;如果网络失败(比如离线),就从缓存取。

这种策略,适合经常变化的资源,比如API接口数据、新闻列表等。这些资源,需要优先获取最新的内容,但是在离线的时候,也能显示缓存的旧内容。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request)
      .then(function(response) {
        // 网络成功,更新缓存
        return caches.open(CACHE_NAME).then(function(cache) {
          cache.put(event.request, response.clone());
          return response;
        });
      })
      .catch(function() {
        // 网络失败,从缓存取
        return caches.match(event.request);
      })
  );
});

3. stale-while-revalidate(缓存同时更新)

这种策略,是先返回缓存的内容,让用户快速看到页面;同时,在后台从网络获取最新内容,更新缓存。下一次访问的时候,就能看到最新的内容了。

这种策略,兼顾了加载速度和内容新鲜度,适合那些对实时性要求不是特别高,但是又希望内容能更新的资源,比如文章列表、商品列表等。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.match(event.request).then(function(cachedResponse) {
        // 从网络获取最新内容,更新缓存
        var fetchPromise = fetch(event.request).then(function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        // 先返回缓存,同时后台更新
        return cachedResponse || fetchPromise;
      });
    })
  );
});

4. 仅网络(Network Only)

仅网络,就是只从网络获取,不使用缓存。这种策略,适合那些必须实时获取、不能缓存的资源,比如支付接口、实时数据等。

self.addEventListener('fetch', function(event) {
  event.respondWith(fetch(event.request));
});

5. 仅缓存(Cache Only)

仅缓存,就是只从缓存取,不从网络获取。这种策略,适合那些在安装的时候就缓存好了、永远不会变化的资源。

self.addEventListener('fetch', function(event) {
  event.respondWith(caches.match(event.request));
});

实际应用中的策略选择:

在实际应用中,一般不会只用一种策略,而是根据不同的资源类型,使用不同的策略。比如:

  • 静态资源(CSS、JS、图片、字体):缓存优先。
  • API接口数据:网络优先,或者stale-while-revalidate。
  • HTML页面:stale-while-revalidate,或者网络优先。
  • 支付、实时数据:仅网络。

可以在Service Worker的fetch事件中,根据请求的URL、类型等,判断使用哪种策略。

五、推送通知

推送通知,是PWA的另一个重要特性,它让Web应用可以像原生应用一样,向用户推送通知,即使用户没有打开页面。

推送通知,需要两个API配合:

  1. Notification API:用于显示通知。
  2. Push API:用于从服务器推送消息到客户端。

请求通知权限:

在使用推送通知之前,需要先请求用户的权限。在页面中,调用以下代码:

if ('Notification' in window) {
  Notification.requestPermission().then(function(permission) {
    if (permission === 'granted') {
      console.log('用户同意了通知权限');
    } else {
      console.log('用户拒绝了通知权限');
    }
  });
}

显示一个简单的通知:

有了权限之后,就可以显示通知了。最简单的方式,是在页面中直接调用:

if (Notification.permission === 'granted') {
  new Notification('通知标题', {
    body: '这是通知的内容',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png'
  });
}

但是,这种方式,只有在页面打开的时候才能显示通知。如果页面关闭了,就收不到了。要实现页面关闭也能收到通知,需要用Push API,配合Service Worker。

服务端推送(Push API):

服务端推送的实现,比较复杂,大致流程是:

  1. 客户端(浏览器)订阅推送服务,获取一个endpoint(推送地址)。
  2. 客户端把这个endpoint发送给你的服务器,保存起来。
  3. 你的服务器,在需要推送的时候,向这个endpoint发送消息。
  4. 浏览器的推送服务,收到消息后,唤醒对应的Service Worker。
  5. Service Worker收到push事件,显示通知。

客户端订阅推送的代码:

// 在页面中,注册Service Worker之后
navigator.serviceWorker.ready.then(function(registration) {
  return registration.pushManager.subscribe({
    userVisibleOnly: true,  // 推送的消息必须对用户可见
    applicationServerKey: urlBase64ToUint8Array('你的公钥')
  });
}).then(function(subscription) {
  // 把subscription发送给你的服务器保存
  fetch('/api/subscribe', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(subscription)
  });
});

Service Worker中处理push事件:

self.addEventListener('push', function(event) {
  var data = event.data.json();
  var title = data.title;
  var options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    data: data
  };
  event.waitUntil(
    self.registration.showNotification(title, options)
  );
});

// 处理通知点击事件
self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  // 点击通知后,打开对应的页面
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

服务端推送的实现,需要用到Web Push协议,不同的语言有不同的库。Node.js可以用web-push库,PHP可以用minishlink/web-push库,Python可以用pywebpush库。

推送通知,是PWA中比较复杂的部分,但是也是最能提升用户体验的部分。如果你的应用需要推送通知,建议花时间研究一下。

六、添加到桌面和安装提示

当你的网站配置了manifest.json和Service Worker,并且满足一定条件的时候,浏览器会自动提示用户,是否要把你的应用添加到桌面。

Chrome的自动安装提示,需要满足以下条件:

  1. 有有效的manifest.json,包含name、shortname、starturl、display(standalone或fullscreen)、icons(至少192和512)。
  2. 有注册并激活的Service Worker。
  3. 网站运行在HTTPS下。
  4. 用户和网站有过交互(至少有一次点击或按键)。
  5. 同一个域名,每90天最多提示一次。

满足条件后,Chrome会在页面加载后,弹出一个安装提示栏,问用户是否要添加到桌面。

自定义安装提示:

默认的安装提示,样式是固定的,不能自定义。但是,你可以监听beforeinstallprompt事件,阻止默认的提示,然后用你自己的UI来提示用户安装。

let deferredPrompt;

window.addEventListener('beforeinstallprompt', function(e) {
  // 阻止默认的安装提示
  e.preventDefault();
  // 保存事件对象,后面用
  deferredPrompt = e;
  // 显示你自己的安装按钮
  showInstallButton();
});

// 用户点击你的安装按钮时
function installApp() {
  if (deferredPrompt) {
    // 显示浏览器的安装弹窗
    deferredPrompt.prompt();
    // 等待用户的选择
    deferredPrompt.userChoice.then(function(choiceResult) {
      if (choiceResult.outcome === 'accepted') {
        console.log('用户同意安装');
      } else {
        console.log('用户拒绝安装');
      }
      deferredPrompt = null;
    });
  }
}

// 监听安装成功事件
window.addEventListener('appinstalled', function(e) {
  console.log('应用安装成功');
});

通过自定义安装提示,你可以在你认为合适的时机(比如用户完成了一个操作、体验了核心功能之后),提示用户安装,转化率会更高。

七、PWA的调试和验证

开发PWA的时候,Chrome的开发者工具,提供了很好的调试支持。

Application面板:

打开Chrome开发者工具,切换到Application面板,这里可以看到和PWA相关的所有信息:

  • Manifest:查看manifest.json的配置是否正确,图标、名称、主题色等是否正常。
  • Service Workers:查看当前页面注册的Service Worker,包括状态、作用域、脚本地址等。可以手动更新、注销Service Worker,也可以模拟离线。
  • Cache Storage:查看Service Worker缓存的资源,包括缓存名称、缓存的内容。可以查看、删除缓存。
  • IndexedDB:查看IndexedDB中的数据。
  • Local Storage / Session Storage:查看本地存储的数据。

Lighthouse审计:

Lighthouse,是Chrome内置的一个自动化审计工具,可以审计你的网站,包括性能、可访问性、最佳实践、SEO,以及PWA。

在开发者工具中,切换到Lighthouse面板,选择要审计的类别(记得勾选Progressive Web App),然后点击Generate report,就可以生成一份详细的审计报告。

报告中,会告诉你你的PWA是否符合标准,有哪些问题需要改进,以及怎么改进。这是检查你的PWA是否合格的最好方式。

八、最佳实践和注意事项

最后,总结一些PWA开发的最佳实践和注意事项:

1. 应用外壳架构(App Shell):

App Shell,是PWA的一种架构模式,就是把应用的外壳(HTML、CSS、JS框架、导航、布局等不经常变化的部分),在Service Worker安装的时候就缓存起来。这样,用户再次访问的时候,外壳可以瞬间从缓存加载,不需要从网络获取,只需要动态加载内容部分。

App Shell架构,能大大提升应用的加载速度,特别是在弱网环境下,用户能很快看到页面的框架,而不是白屏。

2. 缓存版本管理:

Service Worker的缓存,一定要做好版本管理。每次更新静态资源的时候,都要更新缓存名称(比如从my-pwa-v1改成my-pwa-v2),这样,新的Service Worker激活的时候,会删除旧的缓存,用户就能看到最新的内容了。

如果不更新缓存名称,用户可能一直看到旧的缓存内容,更新不生效。

3. 谨慎处理缓存的更新:

使用缓存优先策略的时候,要注意缓存的更新。如果静态资源更新了,但是缓存名称没变,用户可能一直看到旧的内容。

一种常见的做法是,给静态资源的文件名加上哈希值(比如main.abc123.js),这样,文件内容变了,文件名也会变,缓存就会自动更新。

4. 不要缓存不应该缓存的内容:

不是所有的内容都适合缓存。比如,支付接口、用户的个人信息、实时数据等,不应该缓存,否则可能会导致数据不一致或者安全问题。

在Service Worker的fetch事件中,要根据请求的URL和类型,判断哪些可以缓存,哪些不能缓存。

5. 注意Service Worker的更新机制:

Service Worker的更新,有自己的机制。当你更新了sw.js文件,浏览器检测到文件变化(哪怕只有一个字节的变化),就会重新安装新的Service Worker。但是,新的Service Worker安装完成后,会进入等待状态,直到所有的页面都关闭,旧的Service Worker被卸载,新的才会激活。

这意味着,用户可能需要关闭所有的标签页,重新打开,才能看到更新。为了让用户更快地更新,可以在新的Service Worker安装完成后,调用self.skipWaiting(),跳过等待,立即激活。同时,在页面中,监听controllerchange事件,自动刷新页面。

6. 做好降级处理:

不是所有的浏览器都支持PWA的所有特性。在开发的时候,要做好降级处理,确保在不支持Service Worker、不支持推送通知的浏览器上,你的网站也能正常使用,只是没有PWA的特性而已。

这就是"渐进式"的含义:核心功能在所有浏览器上都能用,支持PWA特性的浏览器,能获得更好的体验。

7. 注意iOS的兼容性:

iOS 11.3及以上的Safari,开始支持PWA的核心特性(manifest、Service Worker、添加到主屏幕)。但是,iOS上的PWA,还有一些局限性,比如不支持推送通知、存储空间有限、启动画面的支持不够完善等。

在开发PWA的时候,要注意iOS的兼容性,做好测试和适配。

九、写在最后

PWA,是Web开发的未来趋势之一。它让Web应用拥有了接近原生应用的体验,同时保留了Web应用的优势:免安装、跨平台、易传播、自动更新。

2018年,PWA已经足够成熟,可以在生产环境中使用了。Chrome、Firefox、Safari等主流浏览器,都已经支持PWA的核心特性。越来越多的公司,开始把自己的Web应用改造成PWA,并且获得了很好的效果。

当然,PWA也不是银弹,不是所有的应用都适合做PWA。如果你的应用需要大量的硬件访问(比如摄像头、蓝牙、NFC),或者需要高性能的图形渲染(比如3D游戏),那原生应用可能更合适。但是,对于大部分的内容型、工具型、电商型应用,PWA都是一个很好的选择。

PWA的开发,也不是很难。只要配置好manifest.json,写好Service Worker,做好缓存策略,你的网站就可以变成一个PWA了。当然,要做好一个体验优秀的PWA,还需要在性能、缓存、推送、安装等方面,做更多的优化。

最后,用一句话来结束这篇文章:"PWA,不是一个单一的技术,而是一系列Web技术的集合,它让Web应用拥有了接近原生应用的体验。渐进式、可靠、快速、沉浸式,是PWA的核心特征。如果你还没有尝试过PWA,现在是时候开始了。"

希望这篇文章,能帮助你入门和掌握PWA。如果你有不同的观点或者补充,欢迎在评论区留言,我们一起交流。