为原有的 NextJS 构建 PWA

2020 年 5 月 22 日 星期五(已编辑)
1332
2
AI 生成的摘要
此内容由 AI 生成
本文介绍了如何将 PWA(渐进式 Web 应用)集成到 NextJs 项目。PWA 允许网站像 app 一样安装到移动端或桌面主屏。核心流程包括:准备 manifest.json 配置文件(推荐在线或用 CLI 工具生成图标),放到项目 public 目录;在 src 目录添加 _document.tsx,用于加入 meta 标签与苹果设备兼容性设置。关键是使用 workservice 实现离线访问,推荐通过 next-offline 插件简化配置,在 next.config.js 中设置插件后重启服务即可。生产环境下,使用 nginx 反向代理需注意缓存、headers、并发数、防盗链等设置,以保证 PWA 正常运行。文中附有常见问题和 nginx 配置建议。
这篇文章上次修改于 2020 年 11 月 16 日 星期一,可能部分内容已经不适用,如有疑问可询问作者。

为原有的 NextJS 构建 PWA

花了一上午的时间,总算是把 pwa 整上了。先来说说什么是 pwa。

渐进式 Web 应用会在桌面和移动设备上提供可安装的、仿应用的体验,可直接通过 Web 进行构建和交付。它们是快速、可靠的 Web 应用。最重要的是,它们是适用于任何浏览器的 Web 应用。如果你在构建一个 Web 应用,其实已经开始构建渐进式 Web 应用了。

简单来说,支持 pwa 的网站再移动端或者桌面端都可以模拟成设备中的一个 app,存在于主屏幕上。

开始之前

每个 pwa 应用都需要一个 manifest.json, 可能看成是一个配置文件。可以去 https://app-manifest.firebaseapp.com/ 生成。

结构类似是这样的

{
  "name": "静かな森",
  "short_name": "静かな森",
  "theme_color": "#27ae60",
  "description": "致虚极,守静笃。",
  "background_color": "#2ecc71",
  "display": "standalone",
  "scope": "/",
  "start_url": "/",
  "lang": "zh-cmn-Hans",
  "prefer_related_applications": true,
  "icons": [
    {
      "src": "manifest-icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "manifest-icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ]
}

但是我这边测试的时候,这个网站的图标生成炸掉了,之后我又找了一个 cli 工具,可以用来生成 icon。可以去 GitHub 搜一下 pwa-asset-generator

准备工作完成后,你可以有如下文件。

├── apple-icon-120.png 
├── apple-icon-152.png 
├── apple-icon-167.png 
├── apple-icon-180.png 
├── manifest-icon-192.png 
├── manifest-icon-512.png 
└── manifest.json

集成到 NextJs 项目中

首先你需要把以上文件复制到项目根目录的 public 目录中,如果不存在可以新建一个空的目录。

来到 src 目录,新建一个 _document.tsx 改文件用来控制 NexJs 该如何渲染根节点。

这里我们需要添加亿些 meta 标签。

<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="application-name" content="静かな森" />
<meta name="apple-mobile-web-app-title" content="静かな森" />
<meta name="msapplication-tooltip" content="静かな森" />
<meta name="theme-color" content="#27ae60" />
<meta name="msapplication-navbutton-color" content="#27ae60" />
<meta name="msapplication-starturl" content="/" />
<meta
  name="viewport"
  content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>

你也可以在这里添加一些其他的 meta 比如苹果设备上显示的图标等等。

那么完成了一步,接下来才是最重要的一步。

首先你需要知道 PWA 应用必须使用 workservice, 换句话说只有使用 workservice 才可以离线访问,这才算得上应用。

部署 WorkService

传统的方案较为繁琐,在这里我们采用 next-offline 来实现。

首先安装 next-offline

yarn add next-offline

接着在 next.config.js 中配置如下

const withOffline = require('next-offline')
module.exports = withOffline({
  workboxOpts: {
    swDest: process.env.NEXT_EXPORT
      ? 'service-worker.js'
      : 'static/service-worker.js',
    runtimeCaching: [
      {
        urlPattern: /^https?.*/,
        handler: 'NetworkFirst',
        options: { cacheName: 'offlineCache', expiration: { maxEntries: 200 } },
      },
    ],
  },
})

如果你有多个配置则可以采用嵌套写法,如

const withSourceMaps = require('@zeit/next-source-maps')()

const SentryWebpackPlugin = require('@sentry/webpack-plugin')
const {
  NEXT_PUBLIC_SENTRY_DSN: SENTRY_DSN,
  SENTRY_ORG,
  SENTRY_PROJECT,
  SENTRY_AUTH_TOKEN,
  NODE_ENV,
} = process.env

process.env.SENTRY_DSN = SENTRY_DSN

const isProd = process.env.NODE_ENV === 'production'
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
const env = require('dotenv').config().parsed || {}
const withImages = require('next-images')
const withOffline = require('next-offline')
const configs = withSourceMaps(
  withImages(
    withBundleAnalyzer({
      webpack: (config, options) => {
        if (!options.isServer) {
          config.resolve.alias['@sentry/node'] = '@sentry/browser'
        }

        if (
          SENTRY_DSN &&
          SENTRY_ORG &&
          SENTRY_PROJECT &&
          SENTRY_AUTH_TOKEN &&
          NODE_ENV === 'production'
        ) {
          config.plugins.push(
            new SentryWebpackPlugin({
              include: '.next',
              ignore: ['node_modules'],
              urlPrefix: '~/_next',
              release: options.buildId,
            }),
          )
        }

        return config
      },
      env: {
        PORT: 2323,
        ...env,
      },
      assetPrefix: isProd ? env.ASSETPREFIX || '' : '',
      async rewrites() {
        return [
          { source: '/sitemap.xml', destination: '/api/sitemap' },
          { source: '/feed', destination: '/api/feed' },
          { source: '/rss', destination: '/api/feed' },
          { source: '/atom.xml', destination: '/api/feed' },
          {
            source: '/service-worker.js',
            destination: '/_next/static/service-worker.js',
          },
        ]
      },
      experimental: {
        granularChunks: true,
        modern: true,
      },
    }),
  ),
)
module.exports = isProd
  ? withOffline({
      workboxOpts: {
        swDest: process.env.NEXT_EXPORT
          ? 'service-worker.js'
          : 'static/service-worker.js',
        runtimeCaching: [
          {
            urlPattern: /^https?.*/,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'offlineCache',
              expiration: {
                maxEntries: 200,
              },
            },
          },
        ],
      },
      ...configs,
    })
  : configs

安装之后再次启动应用。workservice 已经搞定。就是这么简单。

打开 Chrome devtools,选择 audits 选项,生成报告,你会看到 processive Web app 图标亮了。

生产环境部署

这一步反而是最难的,因为一般我们会使用 nginx 或者其他高性能服务器反代。考虑到缓存和 Headers 不同,大概率会产生不同的问题。

我出现了如下问题:

  • 504 错误,查看 nginx 的缓存时间,建议关闭缓存,因为 workservice 自带缓存。
  • 500 错误,如果使用 pm2 托管 nodejs 应用,查看 pm2 时候超出了内存大小而重启
  • network error,查看 nginx 并发数,由于采用了 workservice 所以单 ip 的并发数比较多,建议设置成一般的两倍
  • js,css 加载 404,查看 nginx 是否开启了防盗链,workservice 请求的时候不带 referrer

附 nginx 反代配置参考

#PROXY-START/
 location ~ (sw.js)$ {
     proxy_pass http://127.0.0.1:2323;
     proxy_set_header Host $host;
     proxy_set_header X-Real-IP $remote_addr;
     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
     proxy_set_header REMOTE-HOST $remote_addr;
     add_header Last-Modified $date_gmt;
     add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
     if_modified_since off;
     expires off;
     etag off;
 }
 location /
 {
     proxy_pass http://127.0.0.1:2323;
     proxy_set_header Host $host;
     proxy_set_header X-Real-IP $remote_addr;
     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
     proxy_set_header REMOTE-HOST $remote_addr;
 
     add_header X-Cache $upstream_cache_status;
     proxy_ignore_headers Set-Cookie Cache-Control expires;
     add_header Cache-Control no-cache;
     expires off;
 }
 
 
 #PROXY-END/
  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...