发布于 2026-01-06 2 阅读
0

渐进式 Rails 应用 什么是 PWA(面向 Rails 开发者) 入门 推送通知 离线模式 热模块重载 渐进式 Rails 应用 开发者全球展示挑战赛 由 Mux 呈现:展示你的项目!

Progressive Rails 应用

什么是 PWA(面向 Rails 开发者)

入门

推送通知

离线模式

热模块重载

Progressive Rails 应用

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

使用 Ruby on Rails 和 Webpacker 创建渐进式 Web 应用的分步教程

如果你在谷歌上搜索这个主题,你会找到很多关于如何使用 Rails 实现渐进式 Web 应用的教程。但我对目前找到的所有方案都不太满意:我想要的是一个完全基于 Webpack 的解决方案。 

在本文中,我将向您介绍如何使用最新的 Webpacker 和最新的 Rails 版本来实现渐进式 Web 应用 (PWA)。 

这项技术仅使用一个简单的 npm 包,并且支持任何现代 Rails 应用程序,因此我们将定义一个新概念:“渐进式 Rails 应用程序”(PRA)。 
我提供的解决方案无需通过控制器来提供 Service Worker,也无需跳过 Webpack 管道,从而避免了任何变通方法。

如果您已经了解 PWA 是什么,可以直接跳到“入门指南”;否则,下一章将为您准备。

什么是 PWA(面向 Rails 开发者)

要使我们的应用被认定为 PWA,必须满足一些条件。一旦所有这些条件都得到满足,用户就可以像安装普通应用一样在移动设备上安装该应用。此外,不久之后,您还可以将您的 PWA 发布到 Play 商店或将其安装到桌面设备上。

让我们从 Rails 开发者的角度逐一来看这些要点:

即使离线也能立即加载

我们的应用程序必须注册一个 Service Worker,以便在没有网络连接的情况下也能加载。本教程重点介绍如何为 Rails 应用程序配置 Service Worker,我们将使用 Webpacker 来实现。

该网站通过 HTTPS 提供服务。

对于 Rails 开发者来说,这仅仅意味着需要config.force_ssl = trueproduction.rb配置中添加相应的参数。这样,​​你的网站将通过 https 提供服务,从而满足此要求。

响应式设计

有很多前端库可以简化响应式应用程序的设计。在之前的文章中,我解释了为什么你应该放弃 Sprockets,转而使用 Webpack 来处理 CSS和其他静态资源。如果你已经这样做了,那么你可以直接使用 npm 包来安装 Bootstrap 或 Bulma,它们可以帮助你设计前端。

manifest.json

您需要将此文件放置在公共文件夹中,该文件将包含在用户设备上安装应用程序所需的所有信息。

入门

创建一个全新的 Rails 应用(或者使用你现有的应用):

rails new progressive-rails-app --skip-sprockets
Enter fullscreen mode Exit fullscreen mode

并在配置文件中启用生产环境的 HTTPS config/environments/production.rb
这一点很重要,因为 PWA 的一个要求是它必须运行在 HTTPS 上。
如果您是从零开始,以下是一些创建内容的命令。我们添加一个简单的帖子 CRUD 功能,以便开始测试我们的应用程序:

bundle exec rails g scaffold Post title:string content:text
bundle exec rails db:migrate
Enter fullscreen mode Exit fullscreen mode

并将根页面指向:

root 'posts#index'
Enter fullscreen mode Exit fullscreen mode

你还可以添加一些种子:

5.times do |i|
  Post.create(title: "Post #{i}", content: 'A lot of stuff')
end
Enter fullscreen mode Exit fullscreen mode

现在我们有了一些数据和一个可运行的应用程序,我们可以开始添加使其成为 PWA 所需的功能了。

manifest.json

如果您通过 Google Chrome 控制台调试您的应用,您会发现您的应用不包含清单文件。

全新的 Rails 应用,没有任何 manifest.json 文件。

我们来创建一个清单文件。这是每个 PWA 的起点。

将以下内容添加到您的application.html.erb模板中:

<link rel="manifest" href="/manifest.json">
Enter fullscreen mode Exit fullscreen mode

在项目文件夹中创建一个清单文件。清单文件应包含所有必要的配置项,以避免浏览器中出现警告。以下是一个manifest.json示例public

# manifest.json example
{
  "short_name": "PWA",
  "name": "Progressive Web App",
  "icons": [
    {
      "src": "/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "background_color": "#3367D6",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#3367D6"
}
Enter fullscreen mode Exit fullscreen mode

您可以从https://www.favicon-generator.org/生成您需要的所有图标,并将它们放在一个public/icons文件夹中。

调整 Webpacker 配置

在撰写本文时,Webpacker 提供的默认配置还不允许我们编写渐进式 Web 应用程序。 

主要原因是 Service Workers 应该位于 public 文件夹中,而所有资源都编译在 public/packs 文件夹中。 

我们需要自定义 Webpacker 配置,以便:

  • 持续编译 public/packs 文件夹中的所有资源;
  • 在 public 文件夹中编译 Service Worker,不要在名称末尾添加井号;
  • 让 webpack-dev-server 监听整个公共文件夹,以便在 Service Worker 更改时也能热重载内容。

我修改了@rails/webpacker配置并将其发布为npm 包。该包允许您在开发渐进式 Rails 应用时充分利用 Webpack 的强大功能和热模块重载等高级特性。它完美地解决了上述问题。

安装webpacker-pwanpm 包并进行如下yarn add webpacker-pwa更改:config/webpack/environment.js

const { resolve } = require('path');
const { config, environment, Environment } = require('@rails/webpacker');
const WebpackerPwa = require('webpacker-pwa');
new WebpackerPwa(config, environment);
module.exports = environment;
Enter fullscreen mode Exit fullscreen mode

并添加以下内容webpacker.json

service_workers_entry_path: service_workers
Enter fullscreen mode Exit fullscreen mode

service_workers这是我们将编写 Service Worker 的文件夹。 
如果配置正确,运行后bin/webpack应该会看到:

webpacker-pwa is configured but no service workers are available.
Enter fullscreen mode Exit fullscreen mode

第一位服务工作者

service-worker.js在文件夹中创建一个文件app/javascript/service_workers,并添加以下调试内容:

self.addEventListener('install', function(event) {
    console.log('Service Worker installing.');
});

self.addEventListener('activate', function(event) {
    console.log('Service Worker activated.');
});
self.addEventListener('fetch', function(event) {
    console.log('Service Worker fetching.');
});
Enter fullscreen mode Exit fullscreen mode

并将其注册到application.js

window.addEventListener('load', () => {
  navigator.serviceWorker.register('/service-worker.js').then(registration => {
    console.log('ServiceWorker registered: ', registration);

    var serviceWorker;
    if (registration.installing) {
      serviceWorker = registration.installing;
      console.log('Service worker installing.');
    } else if (registration.waiting) {
      serviceWorker = registration.waiting;
      console.log('Service worker installed & waiting.');
    } else if (registration.active) {
      serviceWorker = registration.active;
      console.log('Service worker active.');
    }
  }).catch(registrationError => {
    console.log('Service worker registration failed: ', registrationError);
  });
});
Enter fullscreen mode Exit fullscreen mode

这将使我们能够查看服务工作线程是否正常工作。

注意:目前请勿使用 webpack-dev-server。它尚未正常工作,但我们会尽快解决!

注2:添加public/service-worker.js*.gitignore。您不应该直接推送此文件。它由webpack编译。

如果一切操作正确,您将在 Chrome 开发者工具控制台中看到以下内容:
已安装服务工作线程

在 Chrome 控制台中,您应该看到:

ServiceWorker registered:  
Service Worker installing.
Service worker installing.
Service Worker activating.
Enter fullscreen mode Exit fullscreen mode

现在,当我们部署应用程序并且 SSL 可用时,您应该会在浏览器上看到安装图标。
在 Chrome 浏览器上安装图标

如果你想在本地进行测试,可以使用像 ngrok.com 这样的服务,只需ngrok http 3000在项目的根文件夹上运行即可。

如果收到“阻止主机”错误,可以通过config.hosts = nil在内部设置来暂时禁用此检查config/environments/development.rb

你的渐进式 Rails 应用

搞定!你的第一个 Service Worker 已经运行成功,你的应用现在正式成为一个渐进式 Rails 应用了!🎉

已安装的应用

这看起来已经很棒了,因为它允许你在设备上安装应用程序。

从现在开始,您可以按照任何渐进式 Web 应用 (PWA) 教程来着手实现您的服务。如果您已经熟悉 Service Worker,可以直接像往常一样开始编码。但在这里,我将更详细地介绍如何最大限度地发挥渐进式 Rails 应用的优势,并实现一些您可能经常需要的功能。

推送通知

这就是你来这里的原因,对吧?你也想向用户发送推送通知,对吧?你也想让你的应用显示“想要向您发送推送通知”这条超级烦人的消息,然后开始骚扰你的客户,对吧?那就开始吧!

征得许可

首先需要请求权限,否则无法发送任何通知。您应该在注册 Service Worker 之后执行此操作,因此请按如下方式更改 application.js 中的代码:

navigator.serviceWorker.register('/service-worker.js').then(registration => {
  console.log('ServiceWorker registered: ', registration);

  // all code from before

  window.Notification.requestPermission().then(permission => {    
    if(permission !== 'granted'){
      throw new Error('Permission not granted for Notification');
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

你应该妥善处理所有情况,但这超出了本教程的范围,请参考例如这篇教程,以了解更多关于如何/何时请求权限以及如何管理用户所有可能响应的详细信息。

征得许可

此外,我们的应用程序推送通知可能会惹恼用户。

点击“允许”以授予通知权限,如果您刷新页面,它将不会再询问您,因为它之前已经获得了权限。

订阅通知服务

您需要生成一对公钥/私钥才能发送通知。有关原因的详细信息,请参阅上面链接的文章。

yarn global add web-push
web-push generate-vapid-keys
Enter fullscreen mode Exit fullscreen mode

现在,请将我们的service-worker.js设置更改为订阅推送管理器并对推送通知做出反应:

function urlB64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  const rawData = atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

self.addEventListener('install', function(event) {
  console.log('Service Worker installing.');
});

self.addEventListener('activate', async function(event) {
  console.log('Service Worker activated.');
  try {
    const applicationServerKey = urlB64ToUint8Array('<YOUR_PUBLIC_KEY_HERE>')
    const options = { applicationServerKey, userVisibleOnly: true }
    const subscription = await self.registration.pushManager.subscribe(options)
    console.log(JSON.stringify(subscription))
  } catch (err) {
    console.log('Error', err)
  }
});
self.addEventListener('fetch', function(event) {
  console.log('Service Worker fetching.');
});
self.addEventListener('push', function(event) {
  console.log('[Service Worker] Push Received.');
  console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);

  const title = 'A nice title';
  const options = {
    body: event.data.text(),
    icon: 'images/icon.png',
    badge: 'images/badge.png'
  };

  event.waitUntil(self.registration.showNotification(title, options));
});
Enter fullscreen mode Exit fullscreen mode

每次更改服务工作线程时,都需要在开发者工具的 Chrome 应用程序标签页中手动取消注册,然后刷新页面。

您应该在浏览器控制台中看到:

{ "endpoint":"https://fcm.googleapis.com/fcm/send/...",
  "expirationTime":null,
  "keys":{
    "p256dh":"...",
    "auth":"..." }
}
Enter fullscreen mode Exit fullscreen mode

留着它们,以后会用到的。

在 DevTools 中,从您取消注册 Service Worker 的同一个窗口中,您现在可以发送测试推送通知。

发送测试推送通知

从 Ruby 发送通知

简而言之,这很简单。添加webpushgem 并使用以下代码:

require 'webpush'

Webpush.payload_send(
    message: 'Hello from ruby',
    endpoint: <ENDPOINT-HERE>,
    p256dh: <P256DH-HERE>,
    auth: <AUTH-HERE>,
    vapid: {
        subject: 'Hello from ruby',
        public_key: <PUBLIC-KEY>,
        private_key: <PRIVATE-KEY>
    }
)
Enter fullscreen mode Exit fullscreen mode

您可以将其另存为notifications.rb并运行ruby notifications.rb以进行测试。

这意味着,当您在前端订阅时,您需要在后端保存端点和所有其他信息,并(可能)将它们与您的用户关联起来。

离线模式

即使没有网络连接,我们也希望向用户显示一些内容。让我们看看如何实现。我将介绍一个简单的离线页面,其中包含一张图片,之后的内容就由你来决定了。我的灵感来源于这篇文章

首先创建离线页面:

get 'offline', to: 'home#offline', as: :offline
Enter fullscreen mode Exit fullscreen mode
class HomeController < ApplicationController
  def offline
    render 'offline', layout: false
  end
end
Enter fullscreen mode Exit fullscreen mode

我们渲染一个非常简单的视图,不使用应用程序其余部分使用的布局。

html
  head    
    css:
      body {
        background-color: #00a4cd;
        color: #ffffff;
        padding: 4rem 2rem;
      }

      .text-center {
        text-align: center;
      }
  body
    .text-center
      = image_pack_tag 'logo_white.svg'
    h1.text-center
      ' You need a working internet connection to use Agreeder
    p.text-center
      ' Offline mode is not supported yet
Enter fullscreen mode Exit fullscreen mode

并按如下方式编辑您的 Service Worker:

const OFFLINE_VERSION = 1;
const CACHE_NAME = 'offline';
const OFFLINE_URL = 'offline';

self.addEventListener('install', function (event) {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE_NAME);
    // Setting {cache: 'reload'} in the new request will ensure that the response
    // isn't fulfilled from the HTTP cache; i.e., it will be from the network.
    await cache.add(new Request(OFFLINE_URL, {cache: 'reload'}));
  })());
});

self.addEventListener('activate', async function (event) {
  event.waitUntil((async () => {
  // Enable navigation preload if it's supported.
  // See https://developers.google.com/web/updates/2017/02/navigation-preload
    if ('navigationPreload' in self.registration) {
      await self.registration.navigationPreload.enable();
    }
  })());

  // Tell the active service worker to take control of the page immediately.
  self.clients.claim();
});

self.addEventListener('fetch', function (event) {
  // We only want to call event.respondWith() if this is a navigation request
  // for an HTML page.
    event.respondWith((async () => {
      try {
        // First, try to use the navigation preload response if it's supported.
        const preloadResponse = await event.preloadResponse;
        if (preloadResponse) {
          return preloadResponse;
        }

        return await caches.match(event.request) || await fetch(event.request);
      } catch (error) {
        // catch is only triggered if an exception is thrown, which is likely
        // due to a network error.
        // If fetch() returns a valid HTTP response with a response code in
        // the 4xx or 5xx range, the catch() will NOT be called.
        console.log('Fetch failed; returning offline page instead.', error);

        const cache = await caches.open(CACHE_NAME);
        const cachedResponse = await cache.match(OFFLINE_URL);
        return cachedResponse;
      }
    })());
});
Enter fullscreen mode Exit fullscreen mode

重新加载 Service Worker 并刷新页面。现在,您可以在 Chrome 开发者工具中启用离线模式,您应该可以看到一个离线页面!

无图离线页面

该图像尚未缓存,因此在离线模式下尚不可用。

当然,要显示图片,我们还需要将其缓存。添加到缓存很简单,只需将其添加到添加离线页面的位置即可:

await cache.add(new Request('/packs/media/images/logo_white-a925d045a774f93a59598e709010d411.svg', {cache: 'reload'}));
Enter fullscreen mode Exit fullscreen mode

重新加载 Service Worker,在“在线模式”下刷新页面,并在 Chrome 开发者工具中检查资源是否已正确缓存:

离线页面(含图片)

这种方法虽然可行,但并非最佳方案,因为你需要指定要缓存的资源的准确名称,而且如果资源或离线页面发生更改,你还需要记得更新 Service Worker。我会在另一篇文章中解释如何解决这个问题,但如果你时间紧迫,想提前了解,可以看看Google Workbox。

热模块重载

webpack-dev-server热模块重载仍然有效,但很遗憾,我们的服务工作线程无法使用。Webpacker 中间件默认只将请求重定向到/packswebpack-dev-server,而由于我们的服务工作线程位于公共文件夹目录中,因此无法被提供服务。

我开发了一个小型 gem,它添加了一个中间件,并允许你同时运行 Service Worker。它的名字是 [此处应填写webpacker-pwagem 名称]。你可以将它添加到你的 Gemfile 文件中,或者从 GitHub 项目复制粘贴中间件到你的项目中。

这个 Rack 中间件将拦截 Service Worker 的请求,并通过 webpack-dev-server 提供这些请求。

请注意,“刷新”后的 Service Worker 会被加载,但不会被激活。这是正常的!您可以通过添加以下代码来自动刷新它:

self.addEventListener('install', function(event) {
  self.skipWaiting();
});
Enter fullscreen mode Exit fullscreen mode

现在你可以开始编写你的服务工作线程代码,并充分利用热模块重载功能了!

Service Worker 的热模块重载


我们会随时更改通知标题。

Progressive Rails 应用

您已准备好开始编写您的渐进式 Rails 应用。 
我想向您展示在 Rails 中使用 Service Worker 是多么简单,我希望 Webpacker 能尽快提供开箱即用的支持,或许可以借鉴其他方案webpacker-pwa
现在您已经了解如何编写 Service Worker、发送推送通知以及实现离线页面,您可以开始阅读相关指南来实现您所需的功能。我希望本指南能帮助您开始将您的 Rails 应用升级为渐进式 Rails 应用,并且从现在开始,您会更有信心,因为这其实是一项非常简单的任务。

有关 Webpacker 的其他教程,请查看我之前在 dev.to 和 medium.com 上的博客文章。

感谢Renuo AG一如既往地支持我撰写本文,并感谢他们对 Rails 生态系统所做的所有改进。

不妨了解一下Agreeder,这是一款非常棒的应用程序,它可以通过整理你的偏好来帮助你做决定。

也请了解一下Renuo 的内部奖励系统Gifcoins。这是一个免费且易于使用的工具,方便公司内部员工互相表扬,也是一种表达“谢谢”的全新方式!

文章来源:https://dev.to/coorasse/the-progressive-rails-app-46ma